diff --git a/.cargo/config.toml b/.cargo/config.toml index e1bb33505b..ee1ea72246 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -36,10 +36,12 @@ test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p t test-fastly-reuse = "test -p trusted-server-adapter-fastly --target wasm32-wasip1 --features reusable-sandbox" # --- Axum adapter (native dev server) --- +# Test and clippy also cover the permission signal crates, whose own tests +# run with the Axum adapter in CI. build-axum = "build -p trusted-server-adapter-axum" check-axum = "check -p trusted-server-adapter-axum" -clippy-axum = "clippy -p trusted-server-adapter-axum --all-targets --all-features -- -D warnings" -test-axum = "test -p trusted-server-adapter-axum" +clippy-axum = "clippy -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy --all-targets --all-features -- -D warnings" +test-axum = "test -p trusted-server-adapter-axum -p trusted-server-permission-signal-gpc -p trusted-server-permission-signal-gpp -p trusted-server-permission-signal-mtm -p trusted-server-permission-signal-tcf -p trusted-server-permission-signal-us-privacy" # --- Cloudflare adapter (native host + wasm32-unknown-unknown) --- # Build/check target the WASM runtime (requires the `cloudflare` feature); diff --git a/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md b/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md index 681952b322..6035dc0b7d 100644 --- a/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md +++ b/.claude/skills/deploying-trusted-server-to-fastly/SKILL.md @@ -28,7 +28,7 @@ For each shared-name store a service needs: `fastly resource-link create --service-id --version --resource-id --name trusted_server_config` 3. Push config into the scoped store with the name override, so the write cannot land in the shared default: - `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=_ts_config ts config push --adapter fastly --app-config ` + `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=_ts_config ts config push --adapter fastly --app-config --permissions ` At runtime the service opens `trusted_server_config` and gets the linked physical store, so no other service is touched. Apply the same physical-name-per-service diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index ee71d7b708..78aecbc0ee 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -2,9 +2,6 @@ name: Setup integration test environment description: Prepare toolchains, Viceroy, and Docker images for integration test jobs. inputs: - origin-port: - description: Fixed origin port baked into the WASM binary for integration tests. - required: true install-viceroy: description: Install the Viceroy binary used by integration tests. required: false @@ -73,23 +70,11 @@ runs: - name: Build WASM binary if: ${{ inputs.build-wasm == 'true' }} shell: bash - env: - TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} - TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 - TRUSTED_SERVER__EC__PARTNERS: >- - [{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"},{"name":"Integration Test Partner 2","source_domain":"inttest2.example.com","bidstream_enabled":true,"api_token":"integration-test-token-bravo-32-bytes-ok"}] - TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 - name: Build Axum native binary if: ${{ inputs.build-axum == 'true' }} shell: bash - env: - TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} - TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 - TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build -p trusted-server-adapter-axum - name: Build WordPress test container @@ -116,9 +101,4 @@ runs: - name: Build Cloudflare Workers bundle if: ${{ inputs.build-cloudflare == 'true' }} shell: bash - env: - TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} - TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 - TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: bash crates/trusted-server-adapter-cloudflare/build.sh diff --git a/.github/workflows/inspector.yml b/.github/workflows/inspector.yml new file mode 100644 index 0000000000..4cb3427722 --- /dev/null +++ b/.github/workflows/inspector.yml @@ -0,0 +1,23 @@ +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: Set up Rust toolchain + uses: ./.github/actions/setup-rust + with: + target: wasm32-unknown-unknown + cache-key: cargo-inspector + - name: Build the inspector engine + run: ./scripts/build-inspector-wasm.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e3f2b33343..59912baec1 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -29,10 +29,12 @@ jobs: - name: Set up shared integration build environment uses: ./.github/actions/setup-integration-test-env with: - origin-port: ${{ env.ORIGIN_PORT }} install-viceroy: "false" build-cloudflare: "true" + - name: Check that no permission policy is compiled into the Cloudflare WASM + run: ./scripts/check-no-compiled-policy.sh crates/trusted-server-adapter-cloudflare/build/index_bg.wasm + - name: Generate integration Viceroy configs run: ./scripts/generate-integration-viceroy-configs.sh env: @@ -67,7 +69,6 @@ jobs: id: shared-setup uses: ./.github/actions/setup-integration-test-env with: - origin-port: ${{ env.ORIGIN_PORT }} install-viceroy: "true" build-wasm: "false" build-axum: "false" @@ -126,7 +127,6 @@ jobs: id: shared-setup uses: ./.github/actions/setup-integration-test-env with: - origin-port: ${{ env.ORIGIN_PORT }} install-viceroy: "true" build-wasm: "false" build-test-images: "false" @@ -170,7 +170,6 @@ jobs: id: shared-setup uses: ./.github/actions/setup-integration-test-env with: - origin-port: ${{ env.ORIGIN_PORT }} install-viceroy: "true" build-wasm: "false" build-test-images: "false" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b27173dd66..c0dcdf1673 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,21 @@ jobs: - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + - name: Check that no permission policy is compiled into the Fastly WASM + run: ./scripts/check-no-compiled-policy.sh target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm + + # The sample carries both markers the guard looks for, so a guard that + # stops finding them passes the sample and fails the job here. Exit 1 is + # the refusal; exit 2 means the guard could not run. + - name: Check that the policy guard refuses a file that carries a policy + run: | + status=0 + ./scripts/check-no-compiled-policy.sh config/permissions/sample.yaml || status=$? + if [ "$status" -ne 1 ]; then + echo "the guard should refuse config/permissions/sample.yaml with exit 1, and exited $status" >&2 + exit 1 + fi + test-cloudflare: name: cargo check (cloudflare native + wasm32-unknown-unknown) runs-on: ubuntu-latest @@ -153,17 +168,11 @@ jobs: run: cargo check-spin - name: Build Spin adapter release WASM - # Mirror the Fastly release-build overrides so the artifact embeds usable - # settings instead of the trusted-server.toml placeholders, which startup - # rejects (falling back to the generic 503 router). This proves the - # documented Spin artifact can boot, not just compile. - env: - TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 - TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 - TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release + - name: Check that no permission policy is compiled into the Spin WASM + run: ./scripts/check-no-compiled-policy.sh target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm + - name: Run Spin adapter tests (native host) run: cargo test-spin diff --git a/AGENTS.md b/AGENTS.md index a88d0df59e..fe44a130ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,11 @@ crates/ trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component) trusted-server-cli/ # Host-target `ts` operator CLI + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device module) + edgecookie/ # vendor Edge Cookie module crates (built-in HMAC module is in core) + geo/ # vendor geo module crates (host geo is injected by the adapter) + permission-signal/ # permission signal module crates, one per scheme (gpc, gpp, tcf, us-privacy); core links none trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -63,7 +68,11 @@ fastly compute serve EDGEZERO_MANIFEST="/edgezero.toml" \ ts deploy --adapter fastly --service-id --application-release "" -# 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 --permissions ` (see +# trusted-server.example.toml for the template). Axum reads the policy file +# from the folder TRUSTED_SERVER_PERMISSIONS_DIR names. cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -162,6 +171,24 @@ cd crates/trusted-server-js/lib && node build-all.mjs cargo install viceroy --version 0.21.0 --locked --force ``` +### Windows (use WSL for the Linux-only tests) + +The Rust adapter tests run natively on Windows through the cargo aliases +(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), +and CI runs them on `ubuntu-latest`. + +The Docker-based integration suite (`scripts/integration-tests.sh`) and the +Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, +which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them +inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the +WSL distro with the same toolchain as `.tool-versions` (rustup + the +`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then +run the scripts from a clone on the WSL native filesystem for fast builds. +The CLI crate (`trusted-server-cli`) does not build on a Windows host either, +because its dependency `edgezero-adapter-fastly` uses a standard library +feature that is unstable on Windows, so run its tests and the template cache +harness (`scripts/template-cache-local-test.sh`), which builds it, in WSL too. + --- ## Coding Conventions @@ -289,6 +316,11 @@ 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. (e.g. use example.com for URLs by default) - Do not write or commit real domains, customer names, credentials, @@ -302,6 +334,37 @@ impl core::error::Error for MyError {} under the [allowlist maintenance policy](docs/superpowers/specs/2026-05-18-check-domains-design.md#allowlist-maintenance-policy). Per-line suppressions also need review. +### Permission model terminology + +Permissions are the primitive. A module declares the permissions it requires +(`required_permissions`) and the system decides whether each is _set_. Consent +is only one of many ways a permission may be established. Country or +jurisdiction rules (a `Granted` group baseline), legitimate interest, or +configuration can set a permission with no consent at all. + +- A module that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated module **runs once its required permissions are set**, by whatever + method. + +**Evidence is not rationed, use is.** Every module and every integration sees +all the evidence available for a request, including host signals such as the TLS +JA4 and HTTP/2 signals. The core never decides which vendor may see what, +because withholding a signal from one vendor and not another discriminates +between them, and the core stays neutral. What a vendor may _do_ with the +evidence is governed by the permissions it declares and the system sets. Access +is universal, use is gated. + +The practical consequence: never "fix" a vendor's access to a signal by hiding +the signal. If a use needs controlling, express it as a permission. A change +that removes evidence from a module's reach is working against the +architecture, not protecting it. + +- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`, + GDPR and TCF strings) where it genuinely means a consent signal. In the + permission layer prefer "permission", "set" / "unset", and "signal" (consent + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -318,6 +381,42 @@ Bad: `"fix: added feature flags"` --- +## Module Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral: + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ----------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------- | +| Edge Cookie identity | `EdgeCookieModule` (`ec/module.rs`) | `[ec] module` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceModule` (`ec/device.rs`) | `[device] module` | User-Agent only (default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] module` | Disabled, no location (default) | `crates/geo/` | +| Permission signals | `PermissionSignalModule` (`permission_signal/mod.rs`) | `[permission-signal] modules` (an ordered list) | None, and with no module every permission stays at its country and region baseline | `crates/permission-signal/` | + +Principles for adding or changing a module: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_module` + and `build_geo_module`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Modules read request evidence, not a fixed parameter set.** A module must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 signals) through an evidence + abstraction rather than a hard-coded struct of fields. Host signals come from + the host (the Fastly SDK) and are opt-in, so a neutral module triggers no + host signal calls. +- **Modules are separated by capability but composed per request, and one may + need another's output.** Geo resolves the country and region the permission + model uses, and the permission model gates whether the Edge Cookie module + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + multiple vendor modules share a backend (for example a vendor's Edge Cookie, + geo, and device module on one cloud pipeline) they share a single call per + request rather than calling independently. Give a module the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -347,14 +446,14 @@ IntegrationRegistration::builder(ID) ## Configuration Files -| File | Purpose | -| ----------------------------- | ------------------------------------------------------------------------------------------------- | -| `edgezero.toml` | EdgeZero app/platform manifest and logical stores | -| `fastly.toml` | Fastly service configuration and build settings | -| `trusted-server.example.toml` | Source-controlled Trusted Server app-config template | -| `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | -| `rust-toolchain.toml` | Pins Rust version to 1.98.1 | -| `.env.dev` | Local development environment variables | +| File | Purpose | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `edgezero.toml` | EdgeZero app/platform manifest and logical stores | +| `fastly.toml` | Fastly service configuration and build settings | +| `trusted-server.example.toml` | Source-controlled app-config template, which carries the `[ec]`, `[geo]` and `[device]` module selectors and the `[permission-signal] modules` list | +| `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | +| `rust-toolchain.toml` | Pins Rust version to 1.98.1 | +| `.env.dev` | Local development environment variables | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a13150356..8c7d3d8173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** Settings must name the publisher's permission policy in `[permissions] policy`, and its file is supplied with `ts config push --permissions`. A domain without a valid policy no longer starts. No adapter build carries a policy anymore, and the policy a domain loads decides the permissions of each request it serves. The push writes the SHA-256 of the file's exact bytes into the blob and, on Fastly, Cloudflare and Spin, the file itself, and Axum reads `.yaml` from the folder `TRUSTED_SERVER_PERMISSIONS_DIR` names. A domain whose settings name no policy, or whose file is missing, is not the file that was pushed or does not parse, answers `500`. `ts config validate` checks the policy when given `--permissions`, and `ts config diff` needs the file whenever the settings name a policy, because the stored settings carry its SHA-256. +- **Breaking:** The Cloudflare adapter reads its settings from its config store, the Workers KV namespace `ts config push` writes, bound as `trusted_server_config` by default, and no longer reads the `TRUSTED_SERVER_CONFIG` Worker variable, which holds at most 5 KB. Bind the namespace in `wrangler.toml` and push into it. +- A Spin service whose settings fail to load answers with the status of the startup error's kind, `500` for a configuration or settings error and `503` for a key-value store error, as the other adapters do, where it answered `503` for every kind. - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. diff --git a/Cargo.lock b/Cargo.lock index 8bd891a869..56c28bb9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6551,11 +6551,18 @@ dependencies = [ "futures", "log", "reqwest 0.12.28", + "serde_json", "simple_logger", "temp-env", + "tempfile", "tokio", "tower 0.4.13", "trusted-server-core", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -6565,7 +6572,6 @@ dependencies = [ "async-trait", "base64", "bytes", - "derive_more", "edgezero-adapter-cloudflare", "edgezero-core", "error-stack", @@ -6577,6 +6583,11 @@ dependencies = [ "toml", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "worker", ] @@ -6604,6 +6615,11 @@ dependencies = [ "trusted-server-core", "trusted-server-device-fastly", "trusted-server-geo-fastly", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", "url", "urlencoding", ] @@ -6628,6 +6644,11 @@ dependencies = [ "tokio", "trusted-server-core", "trusted-server-js", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-mtm", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", ] [[package]] @@ -6719,6 +6740,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml_ng", "sha2 0.11.1", "subtle", "temp-env", @@ -6763,6 +6785,7 @@ dependencies = [ "edgezero-core", "env_logger", "error-stack", + "hex", "http", "http-body-util", "libc", @@ -6770,6 +6793,7 @@ dependencies = [ "reqwest 0.12.28", "scraper", "serde_json", + "sha2 0.11.1", "testcontainers", "tokio", "toml", @@ -6807,6 +6831,42 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-mtm" +version = "0.1.0" +dependencies = [ + "http", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 32965d91ac..a8c3964d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,11 @@ resolver = "2" members = [ "crates/device/fastly", "crates/geo/fastly", + "crates/permission-signal/gpc", + "crates/permission-signal/gpp", + "crates/permission-signal/mtm", + "crates/permission-signal/tcf", + "crates/permission-signal/us-privacy", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -124,6 +129,11 @@ tracing = "0.1" trusted-server-core = { path = "crates/trusted-server-core" } trusted-server-device-fastly = { path = "crates/device/fastly" } trusted-server-geo-fastly = { path = "crates/geo/fastly" } +trusted-server-permission-signal-gpc = { path = "crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "crates/permission-signal/gpp" } +trusted-server-permission-signal-mtm = { path = "crates/permission-signal/mtm" } +trusted-server-permission-signal-tcf = { path = "crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "crates/permission-signal/us-privacy" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/README.md b/README.md index 8d5d406296..0355800bae 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ ts --help # Create local config, then edit placeholders before validation ts config init # Edit trusted-server.toml. Server auctions use map-shaped -# [auction.providers.] and [auction.bidders.] tables. -ts config validate +# [auction.providers.] and [auction.bidders.] tables, and +# [permissions] policy names your permission policy file. +ts config validate --permissions standard.yaml # Audit a public page with Chrome/Chromium to bootstrap a draft config ts audit generate https://publisher.example diff --git a/config/permissions/sample.yaml b/config/permissions/sample.yaml new file mode 100644 index 0000000000..bc1cf688d0 --- /dev/null +++ b/config/permissions/sample.yaml @@ -0,0 +1,551 @@ +# The place rules for Trusted Server's permission model, and the jurisdiction +# each place falls under. The `name` at the top is the display name a tool +# such as the permissions inspector shows for this file. +# +# The vocabulary is not ours. The permissions are the IAB Tech Lab Privacy +# Taxonomy Data Uses, with the IAB TCF Europe purposes mapped onto them where +# no Data Use exists yet. That is deliberate, because a permission a module +# declares should be stated in the industry's own terms rather than in a +# vocabulary this project invented, so a reader can check it against the +# taxonomy rather than against us. +# +# This file is a sample for testing and evaluation only. It is not a +# production policy, it is not legal advice, and no deployment should run it +# as it stands. Read it, change it, or replace it, and have whoever is +# accountable for the deployment decide what it should say. +# +# No policy ships with Trusted Server and no adapter build carries one. A domain's +# settings name the publisher's own policy file in [permissions] policy, the +# file is supplied with `ts config push --permissions ` when the service +# is deployed, and the visitor's signals decide the rest at runtime. This copy +# is for testing and evaluation, so the tests and the permissions inspector +# read it. The samples live in config/permissions, and choosing one is always +# an explicit choice by whoever configures or inspects. +# +# There are three parts: +# groups named permission baselines, defined once and referenced by rules +# rules a tree of places, from the whole world down to a single region, +# each naming the group and the jurisdiction that apply there +# signals how each session signal (TCF, US-style opt-out) maps onto Data Uses, +# so no signal-to-permission policy lives in the code +# +# The rules tree +# -------------- +# `rules` is one tree. Its top stands for the whole world, countries sit under +# it, and a country's regions sit under that country: +# +# rules: +# group: gdpr-eu +# jurisdiction: gdpr # inherited by every country not overriding it, +# # and the answer when no country resolves +# GB: gdpr-uk # inherits gdpr +# US: +# group: us-opt-out +# jurisdiction: non-regulated +# CA: +# group: us-opt-out +# jurisdiction: us-state +# NY: us-opt-out # inherits non-regulated +# +# Every node names a `group`. Writing a plain group name (`GB: gdpr-uk`) is the +# short form of a node with that group, no jurisdiction of its own, and nothing +# below it. To give a node regions, or a jurisdiction, write it as a block with +# a `group:` line and the rest beside it. A block with no `group:` line is an +# error, because then the node says nothing. +# +# Any node may also name a `jurisdiction`, which is the consent handling that +# applies there. A node that names none inherits the nearest one above it. The +# top of the tree must name both `group` and `jurisdiction`, so inheritance +# always ends somewhere, and the top node is also the answer for a visitor whose +# place the geo module could not resolve at all. +# +# A `jurisdiction` is one of: +# gdpr the EU, EEA and UK regime +# us-state a US state with a comprehensive privacy law. It carries no +# state code, because the region naming it is the state, so +# only a region may name it, never a country or the top +# non-regulated a place with no matching regulation +# unknown declining to name one +# +# Place codes are the codes a geo module returns, matched without regard to +# upper or lower case: +# country ISO 3166-1 alpha-2 (for example FR, US, GB) +# region ISO 3166-2 subdivision, with no country prefix (for example CA for +# California, written under US) +# The Fastly geo module emits these codes directly, and any other module +# must do the same. Those codes are at most three letters long, so a place code +# can never be confused with the reserved words `group`, `jurisdiction` and +# `permissions`. Where the tree puts a code also tells the two DE codes apart, +# since ISO 3166-1 DE is Germany at the country level and ISO 3166-2 DE is +# Delaware under US. +# +# The most specific place wins, and whatever a node does not answer falls back +# to the node above it. A visitor is matched on their region if it is listed, +# otherwise on their country, otherwise on the top of the tree. So the top node +# is also the answer for a country nobody has written a rule for. +# +# A geo module that reports an outright lookup failure is the exception. It +# resolves every permission to the requires-signal floor, and its jurisdiction +# to unknown, rather than to the top of the tree, though no geo module shipped +# today reports one. +# A permission flag is one of: +# granted set without any signal (for example strictly necessary) +# requires_signal set only when a signal grants it (opt-in) +# denied never set, even when a signal grants it +# +# Permission names are the IAB Privacy Taxonomy Data Uses, mapped from the IAB +# TCF Europe purposes. Every group below lists every Data Use so its policy is +# fully explicit. Two purposes have no Data Use yet: TCF purpose 1 (device +# storage) uses a proposed `necessary.operations.storage` key, and TCF purpose +# 11 (limited-data content selection) keeps its TCF identifier +# `select-basic-content`. Both are flagged for an upstream taxonomy addition. +# +# Trusted Server is not the policy authority, and this repository file is a +# sample modeling common practice rather than legal advice. Service operations +# and legal obligations are granted, the ad-tech Data Uses follow each +# jurisdiction's regime through the `signals` mapping, umbrella Data Uses are +# denied so every grant is named at the specific level, and Data Uses a web +# request never exercises are denied. A deployer edits this file to set its +# own policy per jurisdiction. +# +# Named baselines. Each group lists every Data Use and its flag, so a group's +# meaning is explicit. (A `default: ` shorthand is also accepted for any +# Data Use a group omits.) +name: Sample (testing and evaluation only) + +groups: + # European Union and EEA, where the model is opt-in, so every modeled ad-tech + # Data Use requires a TCF consent signal. + gdpr-eu: + # Service operations, security, fraud prevention and legal obligations run + # on their own lawful footing and are never carried by a consent string, + # matching TCF special purposes 1 and 2 where they touch ad delivery. + necessary.operations.security: granted + necessary.operations.authentication: granted + necessary.operations.debugging: granted + necessary.operations.website_use: granted + necessary.operations.quality_assurance: granted + necessary.operations.support: granted + necessary.operations.upgrades: granted + necessary.operations.payment_processing: granted + necessary.operations.notifications.email: granted + necessary.operations.notifications.sms: granted + necessary.operations.survey: granted + necessary.fraud_detection: granted + necessary.legal_obligation.age_verification: granted + necessary.legal_obligation.content_moderation: granted + necessary.legal_obligation.dsr: granted + necessary.legal_obligation.hold: granted + disclosure.law_enforcement: granted + disclosure.outside_counsel: granted + functional.security: granted + functional.performance: granted + analytics.reporting.system: granted + analytics.ad_reporting.ad_fraud_detection: granted + advertising_marketing.serving: granted + # The modeled ad-tech Data Uses. Each maps to a TCF purpose in `signals` + # below, so a consent record grants it and an absent consent revokes it. + necessary.operations.storage: requires_signal + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.frequency_capping: requires_signal + advertising_marketing.negative_targeting: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.third_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + advertising_marketing.personalize.system: requires_signal + functional.personalization: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.ad_delivery_and_targeting: requires_signal + analytics.ad_reporting.ad_viewability: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + analytics.ad_reporting.campaign_insights: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Marketing channel consent arrives through its own opt-in rather than + # the TC string, so these require a signal no TCF record supplies. + advertising_marketing.communications.email: requires_signal + advertising_marketing.communications.sms: requires_signal + # Sale or sharing of data. + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + # Umbrella Data Uses stay denied so every grant is named at the specific + # level, and a reviewer sees exactly what the policy permits. + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.first_party: denied + advertising_marketing.personalize: denied + advertising_marketing.third_party: denied + analytics: denied + analytics.ad_reporting: denied + analytics.reporting: denied + disclosure: denied + functional: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.legal_obligation: denied + necessary.operations: denied + necessary.operations.notifications: denied + # Data Uses a server handling web requests never exercises. + necessary.employment.hr.hiring: denied + + # United Kingdom: device storage proceeds without a signal under the reformed + # ePrivacy regime, and the ad-tech Data Uses stay opt-in. + gdpr-uk: + # Service operations, security, fraud prevention and legal obligations run + # on their own lawful footing and are never carried by a consent string, + # matching TCF special purposes 1 and 2 where they touch ad delivery. + necessary.operations.security: granted + necessary.operations.authentication: granted + necessary.operations.debugging: granted + necessary.operations.website_use: granted + necessary.operations.quality_assurance: granted + necessary.operations.support: granted + necessary.operations.upgrades: granted + necessary.operations.payment_processing: granted + necessary.operations.notifications.email: granted + necessary.operations.notifications.sms: granted + necessary.operations.survey: granted + necessary.fraud_detection: granted + necessary.legal_obligation.age_verification: granted + necessary.legal_obligation.content_moderation: granted + necessary.legal_obligation.dsr: granted + necessary.legal_obligation.hold: granted + disclosure.law_enforcement: granted + disclosure.outside_counsel: granted + functional.security: granted + functional.performance: granted + analytics.reporting.system: granted + analytics.ad_reporting.ad_fraud_detection: granted + advertising_marketing.serving: granted + # The modeled ad-tech Data Uses. Each maps to a TCF purpose in `signals` + # below, so a consent record grants it and an absent consent revokes it. + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: requires_signal + advertising_marketing.frequency_capping: requires_signal + advertising_marketing.negative_targeting: requires_signal + advertising_marketing.profiling: requires_signal + advertising_marketing.first_party.targeted: requires_signal + advertising_marketing.third_party.targeted: requires_signal + advertising_marketing.personalize.profiling: requires_signal + advertising_marketing.personalize.content: requires_signal + advertising_marketing.personalize.system: requires_signal + functional.personalization: requires_signal + analytics.ad_reporting.measure_ad_performance: requires_signal + analytics.ad_reporting.ad_delivery_and_targeting: requires_signal + analytics.ad_reporting.ad_viewability: requires_signal + analytics.ad_reporting.content_performance: requires_signal + analytics.ad_reporting.market_research: requires_signal + analytics.ad_reporting.campaign_insights: requires_signal + necessary.operations.improve: requires_signal + select-basic-content: requires_signal + # Marketing channel consent arrives through its own opt-in rather than + # the TC string, so these require a signal no TCF record supplies. + advertising_marketing.communications.email: requires_signal + advertising_marketing.communications.sms: requires_signal + # Sale or sharing of data. + disclosure.sale: denied + disclosure.share: denied + disclosure.third_party_sale: denied + # Umbrella Data Uses stay denied so every grant is named at the specific + # level, and a reviewer sees exactly what the policy permits. + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.first_party: denied + advertising_marketing.personalize: denied + advertising_marketing.third_party: denied + analytics: denied + analytics.ad_reporting: denied + analytics.reporting: denied + disclosure: denied + functional: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.legal_obligation: denied + necessary.operations: denied + necessary.operations.notifications: denied + # Data Uses a server handling web requests never exercises. + necessary.employment.hr.hiring: denied + + # United States: an opt-out model, so the modeled ad-tech Data Uses and the + # sale and sharing disclosures are granted until an opt-out signal revokes + # the ones it covers. + us-opt-out: + # Service operations, security, fraud prevention and legal obligations run + # on their own lawful footing and are never carried by a consent string, + # matching TCF special purposes 1 and 2 where they touch ad delivery. + necessary.operations.security: granted + necessary.operations.authentication: granted + necessary.operations.debugging: granted + necessary.operations.website_use: granted + necessary.operations.quality_assurance: granted + necessary.operations.support: granted + necessary.operations.upgrades: granted + necessary.operations.payment_processing: granted + necessary.operations.notifications.email: granted + necessary.operations.notifications.sms: granted + necessary.operations.survey: granted + necessary.fraud_detection: granted + necessary.legal_obligation.age_verification: granted + necessary.legal_obligation.content_moderation: granted + necessary.legal_obligation.dsr: granted + necessary.legal_obligation.hold: granted + disclosure.law_enforcement: granted + disclosure.outside_counsel: granted + functional.security: granted + functional.performance: granted + analytics.reporting.system: granted + analytics.ad_reporting.ad_fraud_detection: granted + advertising_marketing.serving: granted + # The modeled ad-tech Data Uses. Each maps to a TCF purpose in `signals` + # below, so a consent record grants it and an absent consent revokes it. + necessary.operations.storage: granted + advertising_marketing.first_party.contextual: granted + advertising_marketing.frequency_capping: granted + advertising_marketing.negative_targeting: granted + advertising_marketing.profiling: granted + advertising_marketing.first_party.targeted: granted + advertising_marketing.third_party.targeted: granted + advertising_marketing.personalize.profiling: granted + advertising_marketing.personalize.content: granted + advertising_marketing.personalize.system: granted + functional.personalization: granted + analytics.ad_reporting.measure_ad_performance: granted + analytics.ad_reporting.ad_delivery_and_targeting: granted + analytics.ad_reporting.ad_viewability: granted + analytics.ad_reporting.content_performance: granted + analytics.ad_reporting.market_research: granted + analytics.ad_reporting.campaign_insights: granted + necessary.operations.improve: granted + select-basic-content: granted + # Marketing channel consent arrives through its own opt-in rather than + # the TC string, so these require a signal no TCF record supplies. + advertising_marketing.communications.email: requires_signal + advertising_marketing.communications.sms: requires_signal + # Sale or sharing of data. + disclosure.sale: granted + disclosure.share: granted + disclosure.third_party_sale: granted + # Umbrella Data Uses stay denied so every grant is named at the specific + # level, and a reviewer sees exactly what the policy permits. + advertising_marketing: denied + advertising_marketing.communications: denied + advertising_marketing.first_party: denied + advertising_marketing.personalize: denied + advertising_marketing.third_party: denied + analytics: denied + analytics.ad_reporting: denied + analytics.reporting: denied + disclosure: denied + functional: denied + necessary: denied + necessary.employment: denied + necessary.employment.hr: denied + necessary.legal_obligation: denied + necessary.operations: denied + necessary.operations.notifications: denied + # Data Uses a server handling web requests never exercises. + necessary.employment.hr.hiring: denied + +# The place tree. Each node names the `group` that applies there, and may name +# the `jurisdiction` too, either as a plain group name or as a block with a +# `group:` line and the rest beside it. See the header for the full format. +rules: + # The whole world. `group` is the baseline for a visitor whose place the geo + # module could not resolve, and `jurisdiction` is the consent handling for + # that same visitor. Both are required, and every country below inherits the + # jurisdiction unless it names its own. + group: gdpr-eu + jurisdiction: gdpr + + # European Union (27). Each inherits the top node's gdpr jurisdiction. + AT: gdpr-eu + BE: gdpr-eu + BG: gdpr-eu + HR: gdpr-eu + CY: gdpr-eu + CZ: gdpr-eu + DK: gdpr-eu + EE: gdpr-eu + FI: gdpr-eu + FR: gdpr-eu + DE: gdpr-eu + GR: gdpr-eu + HU: gdpr-eu + IE: gdpr-eu + IT: gdpr-eu + LV: gdpr-eu + LT: gdpr-eu + LU: gdpr-eu + MT: gdpr-eu + NL: gdpr-eu + PL: gdpr-eu + PT: gdpr-eu + RO: gdpr-eu + SK: gdpr-eu + SI: gdpr-eu + ES: gdpr-eu + SE: gdpr-eu + + # European Economic Area (non-EU): GDPR applies through the EEA agreement. + IS: gdpr-eu + LI: gdpr-eu + NO: gdpr-eu + + # United Kingdom, which keeps the GDPR jurisdiction under its own regime. + GB: gdpr-uk + + # Australia, where no GDPR or US state law applies. + AU: + group: us-opt-out + jurisdiction: non-regulated + + # United States. Every state follows this country node until a region under it + # says otherwise, so a state appears below only where its own law applies. The + # regions listed are the states with an active comprehensive privacy law, each + # naming `us-state` so the consent gates treat the visitor as covered by it. A + # region may also carry a `permissions:` map to vary single Data Uses on top of + # its group, for example: + # CA: + # group: us-opt-out + # jurisdiction: us-state + # permissions: + # advertising_marketing.first_party.targeted: denied + # Each `permissions` entry maps a Data Use to granted, requires_signal, or + # denied. Or name a stricter group. + US: + group: us-opt-out + jurisdiction: non-regulated + # California. + CA: + group: us-opt-out + jurisdiction: us-state + # Virginia. + VA: + group: us-opt-out + jurisdiction: us-state + # Colorado. + CO: + group: us-opt-out + jurisdiction: us-state + # Connecticut. + CT: + group: us-opt-out + jurisdiction: us-state + # Utah. + UT: + group: us-opt-out + jurisdiction: us-state + # Montana. + MT: + group: us-opt-out + jurisdiction: us-state + # Oregon. + OR: + group: us-opt-out + jurisdiction: us-state + # Texas. + TX: + group: us-opt-out + jurisdiction: us-state + # Florida. + FL: + group: us-opt-out + jurisdiction: us-state + # Delaware. + DE: + group: us-opt-out + jurisdiction: us-state + # Iowa. + IA: + group: us-opt-out + jurisdiction: us-state + # Nebraska. + NE: + group: us-opt-out + jurisdiction: us-state + # New Hampshire. + NH: + group: us-opt-out + jurisdiction: us-state + # New Jersey. + NJ: + group: us-opt-out + jurisdiction: us-state + # Tennessee. + TN: + group: us-opt-out + jurisdiction: us-state + # Minnesota. + MN: + group: us-opt-out + jurisdiction: us-state + # Maryland. + MD: + group: us-opt-out + jurisdiction: us-state + # Indiana. + IN: + group: us-opt-out + jurisdiction: us-state + # Kentucky. + KY: + group: us-opt-out + jurisdiction: us-state + # Rhode Island. + RI: + group: us-opt-out + jurisdiction: us-state + +# What each session signal is allowed to do to the Data Uses, for the +# decisions that are a deployment's policy rather than a scheme's own meaning. +# For each Data Use a signal produces a grant or a revoke, which the resolver +# then applies against the group baseline above (a grant sets a +# `requires_signal` Data Use, a revoke drops a `granted` one, and `denied` +# always wins so no signal can set it). +# +# What a scheme's own signal means is not here. Which TCF purpose grants which +# Data Use is the TCF scheme's knowledge and lives in the TCF permission signal +# module crate, crates/permission-signal/tcf, so a deployment that runs no +# TCF carries no table of another scheme's numbers. Which schemes run at all, +# and in what order, is [permission_signal] module in trusted-server.toml. +signals: + # A present TCF v2 record (a standalone TC string, or the EU TCF section of a + # GPP string). With authoritative true, the record's consent to a purpose + # grants the Data Uses the TCF module maps to that purpose, and its refusal + # revokes them, whereas with authoritative false the record is ignored. + # The flag governs only whether the record answers. Whether its answer + # stands over an opt-out signal below, or the opt-out over it, is the order + # the modules are asked in, [permission_signal] module in + # trusted-server.toml, where the last module with an opinion decides. The + # default order asks Global Privacy Control first, being a browser setting + # with no interface of its own, and the schemes carrying a choice someone + # made through an interface after. + tcf: + authoritative: true + # US-style opt-out of sale or sharing. It applies when any listed source is + # set, and its module revokes the Data Uses listed here. Whether that + # revoke stands when a TCF record consents to the same Data Use depends on + # the module order, see above. The list below models the US state opt-out + # scope, being sale, sharing, targeted advertising, and device storage as + # the conservative reading, so an opted-out visitor gets no Edge Cookie + # written and no identifier shared, while contextual advertising and + # measurement continue after an opt-out. A deployer can widen the list, or + # write `revokes: all` to drop every granted Data Use. An opt-out suppresses + # use for the request and is never a destructive withdrawal of an + # already-issued identifier. + us_opt_out: + sources: [gpc, gpp_sale_opt_out, us_privacy_opt_out] + revokes: + - necessary.operations.storage + - disclosure.sale + - disclosure.share + - disclosure.third_party_sale + - advertising_marketing.first_party.targeted + - advertising_marketing.third_party.targeted + - advertising_marketing.profiling diff --git a/crates/permission-signal/gpc/Cargo.toml b/crates/permission-signal/gpc/Cargo.toml new file mode 100644 index 0000000000..267aaf3c52 --- /dev/null +++ b/crates/permission-signal/gpc/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "trusted-server-permission-signal-gpc" +description = "Global Privacy Control as a permission signal module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +[dev-dependencies] +# The tests read the repository's sample policy, which only core's +# test-utils feature compiles. +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs new file mode 100644 index 0000000000..5673982bdf --- /dev/null +++ b/crates/permission-signal/gpc/src/lib.rs @@ -0,0 +1,197 @@ +//! Global Privacy Control as a permission signal module. +//! +//! Answers from the `Sec-GPC` request header, which core reads into the +//! consent record's `gpc` flag. Separate from the GPP and US Privacy modules +//! so that a publisher who does not act on Global Privacy Control can leave +//! this one out of the configured list without also losing the other two +//! opt-outs. +//! +//! This module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// The `Sec-GPC` request header, Global Privacy Control. +#[derive(Debug, Default, Clone, Copy)] +pub struct GpcModule; + +impl GpcModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for GpcModule { + fn id(&self) -> &'static str { + name() + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a module the policy does not list stays + // silent even when configuration names it. + if !input.policy.opt_out_sources().contains(&OptOutSource::Gpc) { + return ConsentSignal::Neutral; + } + if input.consent.gpc && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } + + /// The header's one value, when it was sent. There is nothing to + /// decode, so a sent header is always valid. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The repository's sample policy lists this scheme as an opt-out and + /// revokes device storage on it, and leaves ad measurement alone. + fn sample_policy() -> &'static SignalPolicy { + PermissionMaps::sample().signals() + } + + fn with_header(set: bool) -> ConsentContext { + ConsentContext { + gpc: set, + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GpcModule::new().signal(permission, &input) + } + + #[test] + fn vouches_for_the_header_only_when_it_was_sent() { + let evidence = OwnedRequestInfo::default(); + assert_eq!( + GpcModule::new().valid_signal(&with_header(true), &evidence), + Some(ValidSignal::new(short(), "gpc", "1")), + "a sent header is the one value it can carry" + ); + assert_eq!( + GpcModule::new().valid_signal(&with_header(false), &evidence), + None, + "no header is no signal" + ); + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + GpcModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[test] + fn revokes_a_listed_permission_when_the_header_is_set() { + assert_eq!( + answer( + &with_header(true), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the sample policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_header(true), + sample_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "what an opt-out takes away is the policy's decision, and measurement is not listed" + ); + } + + #[test] + fn is_silent_when_the_header_is_absent() { + assert_eq!( + answer( + &with_header(false), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + // A policy declaring no opt-out sources at all. + let unlisted = SignalPolicy::default(); + assert_eq!( + answer(&with_header(true), &unlisted, Permission::StoreOnDevice), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even when the header is set" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_header(true); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + sample_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GpcModule::new().withdraws(Permission::StoreOnDevice, &input), + "a browser setting suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/gpp/Cargo.toml b/crates/permission-signal/gpp/Cargo.toml new file mode 100644 index 0000000000..de305561a5 --- /dev/null +++ b/crates/permission-signal/gpp/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "trusted-server-permission-signal-gpp" +description = "The GPP US sale opt-out as a permission signal module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +[dev-dependencies] +# The tests read the repository's sample policy, which only core's +# test-utils feature compiles. +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs new file mode 100644 index 0000000000..b0b15e1820 --- /dev/null +++ b/crates/permission-signal/gpp/src/lib.rs @@ -0,0 +1,269 @@ +//! The GPP US sale opt-out as a permission signal module. +//! +//! Answers from the US sale opt-out carried in the `__gpp` string, which core +//! decodes into the consent record. It says nothing about the EU TCF section a +//! GPP string may also carry, because that is the TCF module's scheme. +//! +//! This module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// A GPP US sale opt-out, read from the `__gpp` string. +#[derive(Debug, Default, Clone, Copy)] +pub struct GppSaleOptOutModule; + +impl GppSaleOptOutModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for GppSaleOptOutModule { + fn id(&self) -> &'static str { + name() + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a module the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::GppSaleOptOut) + { + return ConsentSignal::Neutral; + } + // A GPP string that arrived and could not be read is treated as the + // opt-out it may have carried, because a preference this module + // cannot see is not the same as no preference. + let unreadable = input.consent.raw_gpp_string.is_some() && input.consent.gpp.is_none(); + let opted_out = input + .consent + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out) + == Some(true); + if (opted_out || unreadable) && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } + + /// The GPP string, when it decoded. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.gpp.as_ref()?; + let raw = consent.raw_gpp_string.as_deref()?; + Some(ValidSignal::new(short(), "gpp", raw)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn sample_policy() -> &'static SignalPolicy { + PermissionMaps::sample().signals() + } + + fn with_sale_opt_out(value: Option) -> ConsentContext { + ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: value, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + GppSaleOptOutModule::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + GppSaleOptOutModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[test] + fn an_unreadable_gpp_string_is_read_as_the_opt_out_it_may_have_carried() { + let unreadable = ConsentContext { + raw_gpp_string: Some("not a GPP string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + answer(&unreadable, sample_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "an unreadable string refuses what the policy lets an opt-out revoke" + ); + assert_eq!( + answer( + &unreadable, + sample_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "and nothing the policy leaves alone" + ); + } + + #[test] + fn vouches_for_the_gpp_string_only_when_it_decoded() { + let evidence = OwnedRequestInfo::default(); + let decoded = ConsentContext { + raw_gpp_string: Some("DBABMA~CPreadable".to_owned()), + ..with_sale_opt_out(Some(false)) + }; + assert_eq!( + GppSaleOptOutModule::new().valid_signal(&decoded, &evidence), + Some(ValidSignal::new(short(), "gpp", "DBABMA~CPreadable")), + "a decoded string is vouched for as received, whatever it says" + ); + let unreadable = ConsentContext { + raw_gpp_string: Some("not a GPP string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + GppSaleOptOutModule::new().valid_signal(&unreadable, &evidence), + None, + "an unreadable string is not" + ); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the sample policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + sample_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the sample's revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_opt_out(Some(false)), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a section present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_opt_out(None), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a section carrying no sale flag is silence" + ); + } + + #[test] + fn is_silent_when_no_gpp_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_opt_out(Some(true)), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_opt_out(Some(true)); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + sample_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !GppSaleOptOutModule::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/permission-signal/mtm/Cargo.toml b/crates/permission-signal/mtm/Cargo.toml new file mode 100644 index 0000000000..e590fd5fe8 --- /dev/null +++ b/crates/permission-signal/mtm/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "trusted-server-permission-signal-mtm" +description = "Model Terms for Marketing, the Preference Management Platform answer, as a permission signal module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +[dev-dependencies] +http = { workspace = true } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme is 51Degrees' own, so 51Degrees +# holds the crate. +[package.metadata.maintainers] +owner = "51Degrees" +status = "vendor owned" diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs new file mode 100644 index 0000000000..3d621c7cf3 --- /dev/null +++ b/crates/permission-signal/mtm/src/lib.rs @@ -0,0 +1,584 @@ +//! Model Terms for Marketing (MTM) as a permission signal module. +//! +//! The Preference Management Platform (PMP) asks a visitor one question and +//! keeps one of three words, `standard`, `personalized` or `non-marketing`. +//! That word is the whole of the answer, and this module reads it from the +//! first party cookie `__mtm_pref` the PMP writes on the publisher's domain, +//! which exists so that the server can see the answer at all. The PMP keeps +//! its own copy elsewhere and mirrors it here. Absent, the module says +//! nothing and the country and region rules stand, which is clause 5.3 of the +//! Model Terms, under which a preference with no value permits neither kind of +//! marketing. +//! +//! What each word means is worked out from the Model Terms' own text, the +//! versioned document this module declares as the terms the data is +//! available under. Appendix 1 defines standard marketing as content unrelated +//! to browsing history or interactions, and personalized marketing as content +//! related to them, both including the use of cookies, and clause 4.4 permits +//! measurement, optimization and product development subject to the +//! preference. So the Data Uses that depend on browsing history or +//! interactions are the ones `standard` and `personalized` differ on. +//! +//! `non-marketing` changes nothing. The visitor named neither kind of +//! marketing, so this module has no choice to act on and every Data Use is +//! left to the country and region rules, which is clause 5.3's position. +//! +//! The word is still recorded and the terms are still declared, because the +//! question was answered and that is a different fact from never having been +//! asked. Only the permissions are untouched. +//! +//! Where a baseline grants marketing without a signal, the baseline therefore +//! applies. Under the sample policy's `gdpr-eu` and `gdpr-uk` groups every +//! marketing Data Use requires a signal, so nothing is granted. Under +//! `us-opt-out` they are granted outright, so marketing is permitted. A +//! deployment wanting a decline to bind in an opt-out jurisdiction says so in +//! its own permissions policy, which is where jurisdiction belongs. +//! +//! This module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use std::sync::OnceLock; + +use trusted_server_core::consent::ConsentContext; +use trusted_server_core::constants::COOKIE_MTM_PREF; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ + ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, +}; +use trusted_server_core::tdl::Tdl; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// The scheme, as a [`ValidSignal`] names it. +pub const SCHEME: &str = "mtm"; + +/// The terms the data is available under when a word is present. Versioned, +/// and never a page that can be edited, because whoever receives the data +/// has to be able to prove what terms were in force when it was sent. +pub const TERMS: &str = "https://m4ow.uk/mtm/2.txt"; + +/// The Data Uses either word grants: storage and the cookies both kinds of +/// marketing include, contextual advertising, and the measurement, +/// optimization and product development clause 4.4 permits. +const UNDER_EITHER_WORD: &[&str] = &[ + "necessary.operations.storage", + "advertising_marketing.first_party.contextual", + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.content_performance", + "analytics.ad_reporting.market_research", + "necessary.operations.improve", + "select-basic-content", +]; + +/// The Data Uses that depend on browsing history or interactions, granted by +/// `personalized` and refused by `standard`. Refused rather than left alone, +/// because the visitor chose against them and that choice has to stand over +/// a country baseline that would otherwise grant them. +const PERSONALIZED_ONLY: &[&str] = &[ + "advertising_marketing.profiling", + "advertising_marketing.first_party.targeted", + "advertising_marketing.personalize.profiling", + "advertising_marketing.personalize.content", +]; + +/// The three words a PMP answer can be. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preference { + /// Marketing unrelated to browsing history or interactions. + Standard, + /// Marketing related to browsing history or interactions. + Personalized, + /// No marketing at all. The visitor actively declined it. + NonMarketing, +} + +impl Preference { + /// The word as the PMP wrote it, or `None` for anything else. Any other + /// value is not a word this scheme knows and is treated as absent. + #[must_use] + pub fn parse(word: &str) -> Option { + match word.trim() { + "standard" => Some(Self::Standard), + "personalized" => Some(Self::Personalized), + "non-marketing" => Some(Self::NonMarketing), + _ => None, + } + } + + /// The word as the PMP writes it. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Personalized => "personalized", + Self::NonMarketing => "non-marketing", + } + } +} + +/// The answer on this request, read from the platform's first party cookie. +#[must_use] +pub fn preference(evidence: &dyn RequestInfo) -> Option { + evidence.cookie(COOKIE_MTM_PREF).and_then(Preference::parse) +} + +/// The Model Terms for Marketing, read from the PMP answer. +#[derive(Debug, Default, Clone, Copy)] +pub struct MtmModule; + +impl MtmModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +/// Every Data Use either word says something about, computed once. +fn covered() -> PermissionSet { + static COVERED: OnceLock = OnceLock::new(); + *COVERED.get_or_init(|| { + Permission::all() + .filter(|permission| { + let name = permission.as_str(); + UNDER_EITHER_WORD.contains(&name) || PERSONALIZED_ONLY.contains(&name) + }) + .collect() + }) +} + +impl PermissionSignalModule for MtmModule { + fn id(&self) -> &'static str { + name() + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + let Some(word) = preference(input.evidence) else { + // No answer on the request. Silence, so the country and region + // rules stand, amended by whatever other modules say. + return ConsentSignal::Neutral; + }; + // `non-marketing` changes nothing. The visitor made no marketing choice + // for this module to act on, so whatever the country and region + // rules say stands, which is clause 5.3's position for a preference + // that names neither kind of marketing. + // + // What this means where a baseline grants marketing without a signal, + // stated because it is not obvious and somebody will need it. Under + // the sample policy's `gdpr-eu` and `gdpr-uk` groups every marketing + // Data Use is `requires_signal`, so nothing is granted and the answer + // is a refusal in effect. Under `us-opt-out` the same Data Uses are + // `granted`, so the baseline applies and marketing is permitted. A + // deployment that wants a decline to bind in an opt-out jurisdiction + // sets that in its own permissions policy, which is where jurisdiction + // belongs, and not in this module, which only says what the two + // marketing words mean. + if word == Preference::NonMarketing { + return ConsentSignal::Neutral; + } + let name = permission.as_str(); + if UNDER_EITHER_WORD.contains(&name) { + return ConsentSignal::Grant; + } + if PERSONALIZED_ONLY.contains(&name) { + return match word { + Preference::Personalized => ConsentSignal::Grant, + // Revoked rather than left alone, because the visitor chose + // against these and a choice must stand over a country + // baseline that would otherwise grant them. + Preference::Standard => ConsentSignal::Revoke, + // Unreachable: handled above. + Preference::NonMarketing => ConsentSignal::Neutral, + }; + } + // A Data Use the Model Terms say nothing about. + ConsentSignal::Neutral + } + + /// Every Data Use either word grants, which is what a page waits for + /// while the PMP question is open. The policy is not consulted, because + /// what the words mean is the Model Terms' own. + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + covered() + } + + /// The word, when one is present. There is nothing else to validate, + /// because a value that is not one of the two words is not an answer. + fn valid_signal( + &self, + _consent: &ConsentContext, + evidence: &dyn RequestInfo, + ) -> Option { + preference(evidence).map(|word| ValidSignal::new(short(), SCHEME, word.as_str())) + } + + /// The Model Terms, whenever a word is present, because either answer is + /// given under them. Nothing is declared without an answer. + fn tdls(&self, _consent: &ConsentContext, evidence: &dyn RequestInfo) -> Vec { + if preference(evidence).is_none() { + return Vec::new(); + } + Tdl::new(TERMS).into_iter().collect() + } +} + +#[cfg(test)] +mod tests { + use http::HeaderMap; + + use super::*; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permissions::Acquisition; + + fn with_cookie(value: &str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + format!("other=1; {COOKIE_MTM_PREF}={value}; another=2") + .parse() + .expect("should build a cookie header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + fn answer(evidence: &OwnedRequestInfo, permission: Permission) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + let input = SignalInput::new(&consent, evidence, &policy, Acquisition::RequiresSignal); + MtmModule::new().signal(permission, &input) + } + + fn named(name: &str) -> Permission { + Permission::all() + .find(|permission| permission.as_str() == name) + .unwrap_or_else(|| panic!("the taxonomy should carry {name}")) + } + + /// The exact bytes the Preference Management Platform writes, generated + /// by running its own code rather than transcribed from a document. Both + /// implementations are checked against the same string so the contract + /// between them is proven rather than agreed twice from two readings. + /// + /// A browser sends only the name and value, so the value is taken the way + /// a browser takes it, which also proves no attribute can leak into it. + const PMP_SET_COOKIE: &[(&str, &str)] = &[ + ( + "standard", + "__mtm_pref=standard; Path=/; Max-Age=34560000; SameSite=Lax; Secure", + ), + ( + "personalized", + "__mtm_pref=personalized; Path=/; Max-Age=34560000; SameSite=Lax; Secure", + ), + ( + "non-marketing", + "__mtm_pref=non-marketing; Path=/; Max-Age=34560000; SameSite=Lax; Secure", + ), + ( + "personalized", + "__mtm_pref=personalized; Path=/; Max-Age=34560000; SameSite=Lax; Domain=.example.com; Secure", + ), + ( + "non-marketing", + "__mtm_pref=non-marketing; Path=/; Max-Age=34560000; SameSite=Lax; Domain=.example.com; Secure", + ), + ]; + + /// The value a browser would send back, taken from a `Set-Cookie` string. + fn value_a_browser_would_send(set_cookie: &str) -> &str { + let pair = set_cookie.split(';').next().unwrap_or_default().trim(); + pair.split_once('=').expect("a cookie is name=value").1 + } + + /// Every string the platform writes must parse to the word it names, with + /// no attribute leaking into the value and no whitespace surviving. + #[test] + fn the_platforms_own_set_cookie_strings_parse_to_the_word_they_carry() { + for (word, set_cookie) in PMP_SET_COOKIE { + let value = value_a_browser_would_send(set_cookie); + assert_eq!( + value, *word, + "the value a browser sends must be the bare word" + ); + assert_eq!( + Preference::parse(value).map(Preference::as_str), + Some(*word), + "the module must accept what the platform writes: {set_cookie}", + ); + } + } + + /// The cookie has to stay readable by the script that owns the question. + /// + /// A browser lets a script neither read nor replace a cookie carrying + /// `HttpOnly` (RFC 6265 section 5.3), so a platform that set one would stop + /// seeing its own answer and the visitor's next choice would be lost with + /// nothing reporting anything wrong. + /// + /// This module only ever reads the cookie, so the rule binds whoever + /// writes it. The assertion is here because these fixtures are the + /// platform's real output, so the day that changes, this is where it shows. + #[test] + fn the_preference_cookie_is_never_httponly() { + for (word, set_cookie) in PMP_SET_COOKIE { + assert!( + !set_cookie.to_ascii_lowercase().contains("httponly"), + "`{word}` must stay readable by the script that owns it: {set_cookie}", + ); + } + } + + /// The permission each word produces, asserted as a whole set rather than + /// row by row, so the contract can be read off one place and any future + /// change to the table has to be made deliberately. + #[test] + fn each_word_produces_the_permissions_the_model_terms_table_states() { + let marketing_only = |word: &str| -> (Vec<&'static str>, Vec<&'static str>) { + let evidence = with_cookie(word); + let mut granted = Vec::new(); + let mut revoked = Vec::new(); + for name in UNDER_EITHER_WORD.iter().chain(PERSONALIZED_ONLY.iter()) { + match answer(&evidence, named(name)) { + ConsentSignal::Grant => granted.push(*name), + ConsentSignal::Revoke => revoked.push(*name), + // `non-marketing` leaves everything to the baseline, so + // Neutral is the whole of its answer and is counted + // neither way. + ConsentSignal::Neutral => {} + } + } + granted.sort_unstable(); + revoked.sort_unstable(); + (granted, revoked) + }; + + let mut personalized_only_sorted = PERSONALIZED_ONLY.to_vec(); + personalized_only_sorted.sort_unstable(); + + let (standard_grant, standard_revoke) = marketing_only("standard"); + assert_eq!(standard_revoke, personalized_only_sorted); + assert_eq!(standard_grant.len(), UNDER_EITHER_WORD.len()); + + let (personalized_grant, personalized_revoke) = marketing_only("personalized"); + assert!( + personalized_revoke.is_empty(), + "personalized refuses nothing" + ); + assert_eq!( + personalized_grant.len(), + UNDER_EITHER_WORD.len() + PERSONALIZED_ONLY.len(), + ); + + // `non-marketing` moves nothing in either direction, so both lists are + // empty and the country and region rules decide every one of them. + let (decline_grant, decline_revoke) = marketing_only("non-marketing"); + assert!( + decline_grant.is_empty(), + "a decline grants nothing: {decline_grant:?}" + ); + assert!( + decline_revoke.is_empty(), + "a decline refuses nothing: {decline_revoke:?}" + ); + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + MtmModule::new().id(), + "permission-signal.mtm", + "the module is named by its crate folder" + ); + } + + #[test] + fn reads_exactly_the_three_words_and_treats_anything_else_as_absent() { + assert_eq!(Preference::parse("standard"), Some(Preference::Standard)); + assert_eq!( + Preference::parse(" personalized "), + Some(Preference::Personalized) + ); + assert_eq!( + Preference::parse("non-marketing"), + Some(Preference::NonMarketing) + ); + assert_eq!( + Preference::parse("non_marketing"), + None, + "the PMP writes the hyphen, and a near miss is not an answer" + ); + assert_eq!( + Preference::parse("Personalised"), + None, + "not a word the scheme knows" + ); + assert_eq!(Preference::parse(""), None); + assert_eq!( + preference(&with_cookie("standard")), + Some(Preference::Standard) + ); + assert_eq!(preference(&with_cookie("yes")), None); + assert_eq!(preference(&OwnedRequestInfo::default()), None); + } + + #[test] + fn either_word_grants_what_both_kinds_of_marketing_include() { + for name in UNDER_EITHER_WORD { + for word in ["standard", "personalized"] { + assert_eq!( + answer(&with_cookie(word), named(name)), + ConsentSignal::Grant, + "{word} should grant {name}" + ); + } + } + } + + #[test] + fn only_personalized_grants_what_depends_on_browsing_history_and_standard_refuses_it() { + for name in PERSONALIZED_ONLY { + assert_eq!( + answer(&with_cookie("personalized"), named(name)), + ConsentSignal::Grant, + "personalized should grant {name}" + ); + assert_eq!( + answer(&with_cookie("standard"), named(name)), + ConsentSignal::Revoke, + "standard should refuse {name}, because the visitor chose against it" + ); + } + } + + #[test] + fn says_nothing_about_a_data_use_the_terms_do_not_cover() { + let email = named("advertising_marketing.communications.email"); + assert_eq!( + answer(&with_cookie("personalized"), email), + ConsentSignal::Neutral + ); + assert_eq!( + answer(&with_cookie("standard"), email), + ConsentSignal::Neutral + ); + } + + #[test] + fn says_nothing_at_all_without_a_word() { + for permission in Permission::all() { + assert_eq!( + answer(&OwnedRequestInfo::default(), permission), + ConsentSignal::Neutral, + "no answer should leave {} to the country rules", + permission.as_str() + ); + assert_eq!( + answer(&with_cookie("maybe"), permission), + ConsentSignal::Neutral, + "a value that is not a word should be treated as absent" + ); + } + } + + #[test] + fn declares_the_eleven_data_uses_the_words_cover() { + let declared = MtmModule::new().grants(&SignalPolicy::default()); + assert_eq!( + declared.iter().count(), + UNDER_EITHER_WORD.len() + PERSONALIZED_ONLY.len(), + "should declare each covered Data Use once" + ); + for name in UNDER_EITHER_WORD.iter().chain(PERSONALIZED_ONLY) { + assert!(declared.contains(named(name)), "{name} should be declared"); + } + } + + #[test] + fn vouches_for_the_word_and_declares_the_terms_only_when_one_is_present() { + let consent = ConsentContext::default(); + let module = MtmModule::new(); + assert_eq!( + module.valid_signal(&consent, &with_cookie("standard")), + Some(ValidSignal::new(short(), SCHEME, "standard")) + ); + let declared = module.tdls(&consent, &with_cookie("personalized")); + assert_eq!( + declared.iter().map(Tdl::as_str).collect::>(), + vec![TERMS], + "an answer is given under the Model Terms" + ); + assert_eq!(module.valid_signal(&consent, &with_cookie("maybe")), None); + assert!(module.tdls(&consent, &with_cookie("maybe")).is_empty()); + assert_eq!( + module.valid_signal(&consent, &OwnedRequestInfo::default()), + None + ); + assert!( + module + .tdls(&consent, &OwnedRequestInfo::default()) + .is_empty() + ); + } + + /// `non-marketing` changes nothing, so every Data Use the Model Terms + /// cover comes back Neutral and the country and region rules decide. + #[test] + fn non_marketing_changes_nothing_at_all() { + let evidence = with_cookie("non-marketing"); + for name in UNDER_EITHER_WORD.iter().chain(PERSONALIZED_ONLY.iter()) { + assert_eq!( + answer(&evidence, named(name)), + ConsentSignal::Neutral, + "non-marketing must leave {name} to the baseline", + ); + } + } + + /// The word is still an answer even though it moves no permission, so it + /// is recorded and the terms are declared. A reader looking at what the + /// appliance decided can then see that the question was answered, which is + /// a different fact from never having been asked. + #[test] + fn non_marketing_is_still_a_recorded_answer_under_the_terms() { + let evidence = with_cookie("non-marketing"); + let consent = ConsentContext::default(); + let module = MtmModule::new(); + + let signal = module + .valid_signal(&consent, &evidence) + .expect("a decline is a valid answer"); + assert_eq!(signal.value, "non-marketing"); + assert_eq!( + module.tdls(&consent, &evidence).len(), + 1, + "the answer was given under the Model Terms whatever it said", + ); + } + + #[test] + fn neither_word_is_a_withdrawal() { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + let evidence = with_cookie("standard"); + let input = SignalInput::new(&consent, &evidence, &policy, Acquisition::RequiresSignal); + assert!( + !MtmModule::new().withdraws(Permission::StoreOnDevice, &input), + "a standard answer refuses personalization and leaves an issued identifier alone" + ); + } +} diff --git a/crates/permission-signal/tcf/Cargo.toml b/crates/permission-signal/tcf/Cargo.toml new file mode 100644 index 0000000000..ff0566b8ba --- /dev/null +++ b/crates/permission-signal/tcf/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "trusted-server-permission-signal-tcf" +description = "IAB TCF v2 as a permission signal module, with the purpose mapping." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +[dev-dependencies] +# The tests read the repository's sample policy, which only core's +# test-utils feature compiles. +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs new file mode 100644 index 0000000000..359f37e28d --- /dev/null +++ b/crates/permission-signal/tcf/src/lib.rs @@ -0,0 +1,478 @@ +//! IAB TCF v2 as a permission signal module. +//! +//! Answers from the decoded TCF record for the purposes this crate maps to +//! each permission, and is the one place that knows what a TCF purpose is. +//! Core decodes the TC string, keeps the record against the Edge Cookie +//! identifier, expires it by age and resolves it against a GPP EU section, and +//! this module reads what that pipeline produced rather than decoding the +//! cookie a second time. Reading the wire directly would silently skip the +//! cached record on a returning visitor and the expiry rule, and answer +//! differently from every other reader of the same request. +//! +//! This module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +mod mapping; + +use std::sync::OnceLock; + +pub use mapping::purpose_for; + +#[cfg(test)] +use trusted_server_core::consent::types::TcfConsent; +use trusted_server_core::consent::{ConsentContext, effective_tcf}; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ + ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, +}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// TCF v2, when the policy says TCF answers for this deployment. +/// +/// The mapping from permission to purpose is this crate's, in +/// [`purpose_for`], so core carries no table of another scheme's numbers. A +/// permission no purpose maps to gets silence, not a refusal. +#[derive(Debug, Default, Clone, Copy)] +pub struct TcfModule; + +impl TcfModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for TcfModule { + fn id(&self) -> &'static str { + name() + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy still says whether a TCF record answers for this + // deployment at all. What it no longer says is which purpose grants + // which permission, because that is this scheme's own knowledge. + if !input.policy.tcf_authoritative() { + return ConsentSignal::Neutral; + } + let Some(purpose) = mapping::purpose_for(permission) else { + // TCF has nothing to say about this Data Use, so it says nothing. + return ConsentSignal::Neutral; + }; + if unreadable(input.consent) { + // A TC string arrived and could not be read. The visitor expressed + // a preference this module cannot see, which is not the same as + // no preference, so it fails closed on everything it maps rather + // than leaving the place baseline standing. An expired record is + // not this case, expiry being its own explicit state. + return ConsentSignal::Revoke; + } + let Some(record) = effective_tcf(input.consent) else { + // No TCF record on the request. Silence, not refusal, because + // reading an absent scheme as a refusal would revoke on every + // request that did not carry it. + return ConsentSignal::Neutral; + }; + if record.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + // A purpose the visitor did not consent to is a refusal. Reading it + // as silence would leave the country baseline standing and grant + // what they declined. + ConsentSignal::Revoke + } + } + + /// Every Data Use a purpose maps to, when the policy lets a record answer, + /// and nothing when it does not, because a silenced record grants nothing + /// and a page must not wait for it. + fn grants(&self, policy: &SignalPolicy) -> PermissionSet { + if !policy.tcf_authoritative() { + return PermissionSet::none(); + } + mapped() + } + + /// The standalone TC string, when it decoded and has not expired. A record + /// carried inside a GPP string is the GPP string's, which the GPP + /// module vouches for. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.tcf.as_ref()?; + let raw = consent.raw_tc_string.as_deref()?; + Some(ValidSignal::new(short(), "tcf", raw)) + } + + /// Only a TCF record refusing storage withdraws, because only TCF records + /// a visitor declining the very signal storage depended on. A US-style + /// opt-out suppresses use for the request and never destroys an identifier, + /// so the other modules leave this at its default. + /// + /// Whether the refusal is destructive at all is core's to decide from the + /// jurisdiction's storage baseline, which is why this answers the narrow + /// question only. It does not consult `tcf_authoritative`, matching the + /// rule as it stood before the seam, where a record refusing storage + /// withdrew whether or not the policy let the record grant anything. + fn withdraws(&self, permission: Permission, input: &SignalInput<'_>) -> bool { + if permission != Permission::StoreOnDevice { + return false; + } + effective_tcf(input.consent).is_some_and(|record| !record.has_storage_consent()) + } +} + +/// Every Data Use a purpose maps to, computed once rather than on every +/// request, because the mapping is a constant of this crate. +fn mapped() -> PermissionSet { + static MAPPED: OnceLock = OnceLock::new(); + *MAPPED.get_or_init(|| { + Permission::all() + .filter(|permission| mapping::purpose_for(*permission).is_some()) + .collect() + }) +} + +/// Whether a TC string arrived that could not be decoded, expiry aside. +fn unreadable(consent: &ConsentContext) -> bool { + consent.raw_tc_string.is_some() && consent.tcf.is_none() && !consent.expired +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::GppConsent; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + /// The repository's sample policy, under which a TCF record answers. + fn sample_policy() -> &'static SignalPolicy { + PermissionMaps::sample().signals() + } + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + fn with_record(consented: &[usize]) -> ConsentContext { + ConsentContext { + tcf: Some(tcf_with_purposes(consented)), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfModule::new().signal(permission, &input) + } + + fn withdraws(consent: &ConsentContext, policy: &SignalPolicy, permission: Permission) -> bool { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::RequiresSignal); + TcfModule::new().withdraws(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + TcfModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[test] + fn grants_a_permission_whose_purpose_the_record_consents_to() { + assert_eq!( + answer( + &with_record(&[1]), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Grant, + "Purpose 1 consent grants device storage" + ); + } + + #[test] + fn revokes_a_permission_whose_purpose_the_record_refuses() { + assert_eq!( + answer( + &with_record(&[1]), + sample_policy(), + Permission::SelectPersonalisedAds + ), + ConsentSignal::Revoke, + "a purpose the visitor did not consent to is a refusal, not silence" + ); + } + + #[test] + fn is_silent_for_a_data_use_no_purpose_grants() { + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + answer(&with_record(&[1]), sample_policy(), sale), + ConsentSignal::Neutral, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } + + #[test] + fn is_silent_when_no_record_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent record is silence, never a refusal" + ); + } + + #[test] + fn an_unreadable_tc_string_is_this_modules_refusal_and_an_expired_one_is_not() { + // A string arrived that could not be decoded. That is a preference + // this module cannot see, so it refuses what it maps, silently, + // and says nothing about a Data Use no purpose covers. + let unreadable = ConsentContext { + raw_tc_string: Some("not a TC string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + answer(&unreadable, sample_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "an unreadable record refuses the Data Uses this scheme covers" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert_eq!( + answer(&unreadable, sample_policy(), email), + ConsentSignal::Neutral, + "and says nothing about a Data Use no purpose maps to" + ); + assert_eq!( + answer( + &unreadable, + &SignalPolicy::default(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a silenced scheme says nothing, readable or not" + ); + let expired = ConsentContext { + raw_tc_string: Some("CPold".to_owned()), + expired: true, + ..ConsentContext::default() + }; + assert_eq!( + answer(&expired, sample_policy(), Permission::StoreOnDevice), + ConsentSignal::Neutral, + "expiry is its own explicit state and not an unreadable record" + ); + } + + #[test] + fn vouches_for_the_standalone_tc_string_only_when_it_decoded() { + let evidence = OwnedRequestInfo::default(); + let decoded = ConsentContext { + raw_tc_string: Some("CPreadable".to_owned()), + ..with_record(&[1]) + }; + assert_eq!( + TcfModule::new().valid_signal(&decoded, &evidence), + Some(ValidSignal::new(short(), "tcf", "CPreadable")), + "a decoded record vouches for the string as received" + ); + let unreadable = ConsentContext { + raw_tc_string: Some("not a TC string".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + TcfModule::new().valid_signal(&unreadable, &evidence), + None, + "an unreadable string is not vouched for" + ); + let expired = ConsentContext { + raw_tc_string: Some("CPold".to_owned()), + expired: true, + ..ConsentContext::default() + }; + assert_eq!( + TcfModule::new().valid_signal(&expired, &evidence), + None, + "an expired record is not one this module uses, so it is not vouched for" + ); + assert_eq!( + TcfModule::new().valid_signal(&ConsentContext::default(), &evidence), + None, + "and no record is no signal" + ); + } + + #[test] + fn declares_exactly_the_data_uses_a_purpose_maps_to() { + // Under the sample policy a record answers, so every mapped Data Use + // is declared and an unmapped one is not. Under a policy that silences + // the record nothing is declared, because a record that cannot answer + // is not one a page should wait for. + let declared = TcfModule::new().grants(sample_policy()); + assert!( + declared.contains(Permission::StoreOnDevice), + "purpose 1 maps to storage, so storage is grantable" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!( + !declared.contains(email), + "no purpose maps to a marketing channel, so it is not grantable" + ); + assert_eq!( + declared.iter().count(), + Permission::all() + .filter(|permission| purpose_for(*permission).is_some()) + .count(), + "should declare each mapped Data Use once and nothing else" + ); + assert!( + TcfModule::new().grants(&SignalPolicy::default()).is_empty(), + "a silenced record grants nothing" + ); + } + + #[test] + fn a_non_authoritative_policy_silences_the_record_but_not_the_withdrawal() { + // The default policy declares no TCF block, so the record does not + // answer for the deployment. Withdrawal is the narrower, destructive + // question and keeps the rule it had before the seam, which did not + // consult the flag. + let silenced = SignalPolicy::default(); + assert!( + !silenced.tcf_authoritative(), + "the fixture must not be authoritative" + ); + assert_eq!( + answer( + &with_record(&[4]), + &silenced, + Permission::SelectPersonalisedAds + ), + ConsentSignal::Neutral, + "a record the policy does not let answer stays silent" + ); + assert!( + withdraws(&with_record(&[4]), &silenced, Permission::StoreOnDevice), + "but a record refusing storage still withdraws, as it did before the seam" + ); + } + + #[test] + fn withdraws_only_for_storage_and_only_when_refused() { + assert!( + withdraws( + &with_record(&[4]), + sample_policy(), + Permission::StoreOnDevice + ), + "refusing Purpose 1 withdraws storage" + ); + assert!( + !withdraws( + &with_record(&[1]), + sample_policy(), + Permission::StoreOnDevice + ), + "consenting to Purpose 1 is not a withdrawal" + ); + assert!( + !withdraws( + &with_record(&[]), + sample_policy(), + Permission::SelectPersonalisedAds + ), + "no other permission is ever withdrawn, refused or not" + ); + assert!( + !withdraws( + &ConsentContext::default(), + sample_policy(), + Permission::StoreOnDevice + ), + "and no record is never a withdrawal" + ); + } + + #[test] + fn reads_the_eu_section_of_a_gpp_string_when_there_is_no_standalone_record() { + // Core resolves a GPP string's EU TCF section as the effective record + // when no TC string arrived, and this module reads what core resolved + // rather than the wire, so it sees that section too. + let consent = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![2], + eu_tcf: Some(tcf_with_purposes(&[4])), + us_sale_opt_out: None, + }), + ..ConsentContext::default() + }; + assert_eq!( + answer(&consent, sample_policy(), Permission::SelectPersonalisedAds), + ConsentSignal::Grant, + "the EU section's consent to Purpose 4 grants targeted advertising" + ); + assert!( + withdraws(&consent, sample_policy(), Permission::StoreOnDevice), + "and its refusal of Purpose 1 withdraws storage" + ); + } +} diff --git a/crates/permission-signal/tcf/src/mapping.rs b/crates/permission-signal/tcf/src/mapping.rs new file mode 100644 index 0000000000..07bda1c894 --- /dev/null +++ b/crates/permission-signal/tcf/src/mapping.rs @@ -0,0 +1,172 @@ +//! Which Data Use each TCF purpose grants. +//! +//! This table lives here, in the crate for the scheme it belongs to, rather +//! than in the policy file core reads. Core does not know what a TCF purpose +//! is, and a deployment that runs no TCF at all should not carry a table of +//! another scheme's numbers in its configuration. +//! +//! It was moved verbatim from the `signals.tcf.purposes` block of the sample +//! policy, so behavior is unchanged for a deployment that never edited that +//! block. A deployment that had edited it now changes this crate instead. +//! +//! # Where this should eventually come from +//! +//! The IAB Privacy Taxonomy is adding a `tcf` column. When that is finalized +//! it becomes the single source for this mapping and the table below is +//! replaced by reading it, rather than being maintained by hand. Until then +//! this is the authority for this crate. + +use trusted_server_core::permissions::Permission; + +/// A TCF purpose number and the Data Use identifiers it grants. +/// +/// Identifiers rather than [`Permission`] values, so the table reads the same +/// as the policy block it came from and can be checked against the taxonomy by +/// eye. +const PURPOSES: &[(u8, &[&str])] = &[ + (1, &["necessary.operations.storage"]), + ( + 2, + &[ + "advertising_marketing.first_party.contextual", + "advertising_marketing.frequency_capping", + "advertising_marketing.negative_targeting", + ], + ), + (3, &["advertising_marketing.profiling"]), + ( + 4, + &[ + "advertising_marketing.first_party.targeted", + "advertising_marketing.third_party.targeted", + ], + ), + (5, &["advertising_marketing.personalize.profiling"]), + ( + 6, + &[ + "advertising_marketing.personalize.content", + "advertising_marketing.personalize.system", + "functional.personalization", + ], + ), + ( + 7, + &[ + "analytics.ad_reporting.measure_ad_performance", + "analytics.ad_reporting.ad_delivery_and_targeting", + "analytics.ad_reporting.ad_viewability", + ], + ), + (8, &["analytics.ad_reporting.content_performance"]), + ( + 9, + &[ + "analytics.ad_reporting.market_research", + "analytics.ad_reporting.campaign_insights", + ], + ), + (10, &["necessary.operations.improve"]), + (11, &["select-basic-content"]), +]; + +/// The TCF purpose that grants `permission`, or `None` when no purpose does. +/// +/// A permission no purpose maps to is one TCF has nothing to say about, and +/// the module answers silence for it rather than a refusal. +/// +/// Compared on the identifier string, so a lookup is at most twenty string +/// comparisons. Resolving each identifier back to a [`Permission`] first would +/// scan the whole taxonomy per row, and this runs for every permission on +/// every request. +#[must_use] +pub fn purpose_for(permission: Permission) -> Option { + let id = permission.as_str(); + PURPOSES + .iter() + .find_map(|(purpose, uses)| uses.contains(&id).then_some(*purpose)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_identifier_in_the_table_is_a_real_data_use() { + // The failure this guards is a typo silently disabling a purpose. A + // name that resolves to nothing would make the purpose grant nothing, + // and no test asserting one specific mapping would notice the ones it + // does not name. + let mut unknown = Vec::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if Permission::from_identifier(id).is_none() { + unknown.push(format!("purpose {purpose}: {id}")); + } + } + } + assert!( + unknown.is_empty(), + "these Data Use identifiers are not in the taxonomy: {unknown:?}" + ); + } + + #[test] + fn no_data_use_is_granted_by_two_purposes() { + // The policy parser used to refuse this as a duplicate. With the table + // in code the check moves here, so a purpose cannot be silently + // shadowed by an earlier row. + let mut seen = std::collections::BTreeMap::new(); + for (purpose, uses) in PURPOSES { + for id in *uses { + if let Some(first) = seen.insert(*id, *purpose) { + panic!("{id} is granted by purpose {first} and again by purpose {purpose}"); + } + } + } + } + + #[test] + fn the_purposes_the_policy_block_used_to_declare_still_map() { + // The two mappings the old policy parser test pinned, now pinned here. + assert_eq!( + purpose_for(Permission::StoreOnDevice), + Some(1), + "Purpose 1 should map to device storage" + ); + assert_eq!( + purpose_for(Permission::SelectPersonalisedAds), + Some(4), + "Purpose 4 should map to targeted advertising" + ); + } + + #[test] + fn a_purpose_granting_several_uses_is_found_from_each_of_them() { + // Purpose 4 grants two Data Uses, and both must resolve back to it. + let first = Permission::from_identifier("advertising_marketing.first_party.targeted") + .expect("should be a known Data Use"); + let third = Permission::from_identifier("advertising_marketing.third_party.targeted") + .expect("should be a known Data Use"); + assert_eq!( + purpose_for(first), + Some(4), + "the first-party Data Use is Purpose 4" + ); + assert_eq!(purpose_for(third), Some(4), "and so is the third-party one"); + } + + #[test] + fn a_data_use_no_purpose_grants_maps_to_nothing() { + // A sale disclosure is a Data Use the taxonomy carries and no TCF + // purpose grants. Silence rather than a refusal is the contract, and + // it starts here. + let sale = Permission::from_identifier("disclosure.sale") + .expect("should be a known Data Use with no TCF purpose"); + assert_eq!( + purpose_for(sale), + None, + "TCF has nothing to say about a Data Use none of its purposes grant" + ); + } +} diff --git a/crates/permission-signal/us-privacy/Cargo.toml b/crates/permission-signal/us-privacy/Cargo.toml new file mode 100644 index 0000000000..1e54c66d03 --- /dev/null +++ b/crates/permission-signal/us-privacy/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "trusted-server-permission-signal-us-privacy" +description = "The US Privacy string sale opt-out as a permission signal module." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } + +[dev-dependencies] +# The tests read the repository's sample policy, which only core's +# test-utils feature compiles. +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this crate, the way Prebid.js requires a named +# maintainer of every adapter. The scheme's own body is the natural owner, and +# until one adopts the crate the Trusted Server maintainers hold it. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs new file mode 100644 index 0000000000..ea3bd14b07 --- /dev/null +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -0,0 +1,271 @@ +//! The US Privacy string sale opt-out as a permission signal module. +//! +//! Answers from the sale opt-out carried in the four character `us_privacy` +//! string, which core decodes into the consent record. Core also constructs +//! that string from a Global Privacy Control header in a US state when the +//! deployment's consent settings say to, and this module sees the result +//! the same way, because it reads the record and not the wire. +//! +//! This module lives outside `trusted-server-core` deliberately, like every +//! scheme. Why is set out once, in `permission_signal/README.md` in core. + +use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; +use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; + +/// The name `[permission-signal] modules` selects this module by, from its +/// crate folder. +#[must_use] +pub fn name() -> &'static str { + trusted_server_core::module_name!() +} + +/// The name the page is told a signal came from, being the name without the +/// type folder. +fn short() -> &'static str { + trusted_server_core::module_name::short_form( + trusted_server_core::permission_signal::MODULE_TYPE, + name(), + ) +} + +/// A US Privacy string sale opt-out, read from `us_privacy`. +#[derive(Debug, Default, Clone, Copy)] +pub struct UsPrivacyModule; + +impl UsPrivacyModule { + /// A new module. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl PermissionSignalModule for UsPrivacyModule { + fn id(&self) -> &'static str { + name() + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // The policy decides whether this scheme counts at all and what an + // opt-out takes away, so a module the policy does not list stays + // silent even when configuration names it. + if !input + .policy + .opt_out_sources() + .contains(&OptOutSource::UsPrivacyOptOut) + { + return ConsentSignal::Neutral; + } + // A US Privacy string that arrived and could not be read is treated + // as the opt-out it may have carried, because a preference this + // module cannot see is not the same as no preference. + let unreadable = + input.consent.raw_us_privacy.is_some() && input.consent.us_privacy.is_none(); + let opted_out = input + .consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes); + if (opted_out || unreadable) && input.policy.opt_out_revokes(permission) { + return ConsentSignal::Revoke; + } + // Silence rather than refusal. Reading an absent signal as a refusal + // would revoke the permission on every request that did not carry this + // scheme, which is most of them. + ConsentSignal::Neutral + } + + /// The US Privacy string, when it decoded. + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + consent.us_privacy.as_ref()?; + let raw = consent.raw_us_privacy.as_deref()?; + Some(ValidSignal::new(short(), "us_privacy", raw)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::consent::ConsentContext; + use trusted_server_core::consent::types::UsPrivacy; + use trusted_server_core::evidence::OwnedRequestInfo; + use trusted_server_core::permission_signal::SignalInput; + use trusted_server_core::permissions::{Acquisition, PermissionMaps, SignalPolicy}; + + fn sample_policy() -> &'static SignalPolicy { + PermissionMaps::sample().signals() + } + + fn with_sale_flag(opt_out_sale: PrivacyFlag) -> ConsentContext { + ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale, + lspa_covered: PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + } + } + + fn answer( + consent: &ConsentContext, + policy: &SignalPolicy, + permission: Permission, + ) -> ConsentSignal { + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new(consent, &evidence, policy, Acquisition::Granted); + UsPrivacyModule::new().signal(permission, &input) + } + + #[test] + fn answers_to_its_identifier() { + assert_eq!( + UsPrivacyModule::new().id(), + name(), + "the module answers to the identifier configuration names" + ); + } + + #[test] + fn an_unreadable_us_privacy_string_is_read_as_the_opt_out_it_may_have_carried() { + let unreadable = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + answer(&unreadable, sample_policy(), Permission::StoreOnDevice), + ConsentSignal::Revoke, + "an unreadable string refuses what the policy lets an opt-out revoke" + ); + assert_eq!( + answer( + &unreadable, + sample_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "and nothing the policy leaves alone" + ); + } + + #[test] + fn vouches_for_the_us_privacy_string_only_when_it_decoded() { + let evidence = OwnedRequestInfo::default(); + let decoded = ConsentContext { + raw_us_privacy: Some("1YNN".to_owned()), + ..with_sale_flag(PrivacyFlag::No) + }; + assert_eq!( + UsPrivacyModule::new().valid_signal(&decoded, &evidence), + Some(ValidSignal::new(short(), "us_privacy", "1YNN")), + "a decoded string is vouched for as received, whatever it says" + ); + let unreadable = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; + assert_eq!( + UsPrivacyModule::new().valid_signal(&unreadable, &evidence), + None, + "an unreadable string is not" + ); + } + + #[test] + fn revokes_a_listed_permission_on_a_sale_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Revoke, + "the sample policy revokes device storage on an opt-out" + ); + } + + #[test] + fn is_silent_for_a_permission_the_policy_does_not_revoke() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + sample_policy(), + Permission::MeasureAdPerformance + ), + ConsentSignal::Neutral, + "measurement is not on the sample's revoke list" + ); + } + + #[test] + fn is_silent_when_the_string_does_not_opt_out() { + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::No), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a string present and not opting out is not an opt-out" + ); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::NotApplicable), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "and a string saying the flag does not apply is silence" + ); + } + + #[test] + fn is_silent_when_no_string_arrived() { + assert_eq!( + answer( + &ConsentContext::default(), + sample_policy(), + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "an absent signal is silence, never a refusal" + ); + } + + #[test] + fn is_silent_when_the_policy_does_not_list_this_scheme() { + let unlisted = SignalPolicy::default(); + assert_eq!( + answer( + &with_sale_flag(PrivacyFlag::Yes), + &unlisted, + Permission::StoreOnDevice + ), + ConsentSignal::Neutral, + "a scheme the policy does not count stays silent even on an opt-out" + ); + } + + #[test] + fn never_withdraws() { + let consent = with_sale_flag(PrivacyFlag::Yes); + let evidence = OwnedRequestInfo::default(); + let input = SignalInput::new( + &consent, + &evidence, + sample_policy(), + Acquisition::RequiresSignal, + ); + assert!( + !UsPrivacyModule::new().withdraws(Permission::StoreOnDevice, &input), + "a sale opt-out suppresses use for the request and never destroys an identifier" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 94ad02170c..4bbb3451cf 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -29,11 +29,18 @@ reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-mtm = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } [dev-dependencies] trusted-server-core = { workspace = true, features = ["test-utils"] } axum = { workspace = true } base64 = { workspace = true } +serde_json = { workspace = true } temp-env = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower = { workspace = true, features = ["util"] } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 802f732726..c0685ad7c4 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -38,10 +38,12 @@ use trusted_server_core::settings_data::{ default_config_key, default_config_store_name, get_settings_from_config_store, }; -use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::platform::{PolicyOrigin, RuntimeServices}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; -use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; +use crate::platform::{ + AxumPlatformConfigStore, AxumPlatformSecretStore, AxumPolicyFolder, build_runtime_services, +}; // --------------------------------------------------------------------------- // AppState @@ -52,9 +54,40 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The permission signal modules `[permission-signal] modules` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_modules: + Arc<[Arc]>, + /// The permission policy the domain's settings loaded, which every + /// request is given. + permission_policy: Arc, + /// Services a caller supplied for every request, rather than services built + /// from the request context. `None` in a deployment. services: Option, } +/// The permission signal modules this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no module of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + /// Build the application state, loading settings and constructing all per-application components. /// /// # Errors @@ -70,6 +103,7 @@ fn build_state() -> Result, Report> { &store_name, &config_key, &trusted_server_core::settings_data::default_secret_store_name(), + PolicyOrigin::Source(&AxumPolicyFolder::from_env()), )?; build_state_with_settings(settings) } @@ -115,20 +149,38 @@ fn build_state_with_services( plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; + let permission_policy = settings.required_permission_policy()?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + permission_signal_modules, + permission_policy, services, })) } impl AppState { + /// Builds the per-request services, carrying the permission policy the + /// domain's settings loaded whichever services the request is given. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx, &self.settings)) + .unwrap_or_else(|| { + build_runtime_services( + ctx, + &self.settings, + &self.permission_signal_modules, + &self.permission_policy, + ) + }) + .with_permission_policy(Arc::clone(&self.permission_policy)) } } @@ -201,9 +253,13 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent), and -/// a geo lookup failure is logged and treated as no location. +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the 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 @@ -221,14 +277,7 @@ fn build_ec_context( services: &RuntimeServices, req: &Request, ) -> Result> { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) + EcContext::read_from_request_resolving_geo(&state.settings, req, services) } // --------------------------------------------------------------------------- @@ -663,8 +712,8 @@ impl TrustedServerApp { /// Build the full application router from explicit settings. /// /// Testing seam: integration tests use this to drive the router with - /// known-good settings instead of the baked `get_settings()` result, - /// whose embedded placeholder secrets fail validation by design. + /// settings they build, where the process loads its settings from the + /// config store when it starts. /// /// # Errors /// @@ -774,6 +823,11 @@ mod tests { [ec.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction + # operation is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true "#; /// Builds application state directly, bypassing the composition root's @@ -791,6 +845,10 @@ mod tests { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + // These tests exercise the Edge Cookie module path, and a + // request with no signal module resolves at the place baseline. + permission_signal_modules: Arc::default(), + permission_policy: trusted_server_core::permissions::PermissionPolicy::sample(), // This test drives the per-request path, which builds its services // from the request context. services: None, @@ -812,7 +870,12 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_runtime_services(&ctx, &state.settings); + let services = build_runtime_services( + &ctx, + &state.settings, + &state.permission_signal_modules, + &state.permission_policy, + ); let req = ctx.into_request(); let error = build_ec_context(&state, &services, &req) @@ -823,4 +886,180 @@ mod tests { "the error should name the selected module, got: {error}" ); } + + mod startup_policy { + use std::path::Path; + + use edgezero_core::blob_envelope::BlobEnvelope; + use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; + use trusted_server_core::permissions::{SAMPLE_POLICY_TEXT, policy_sha256}; + use trusted_server_core::settings::PermissionsConfig; + + use super::*; + use crate::platform::{PERMISSIONS_DIR_ENV_VAR, config_env_var}; + + const HANDLER_PASSWORD_VAR: &str = + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD"; + const PROXY_SECRET_VAR: &str = + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET"; + + /// Settings whose secrets are the key names the tests' variables + /// resolve. + fn startup_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "handler_password" + + [publisher] + domain = "publisher.example" + cookie_domain = ".publisher.example" + origin_url = "https://origin.publisher.example" + proxy_secret = "publisher_proxy_secret" + "#, + ) + .expect("should parse the startup settings") + } + + /// The sample with a comment naming `test`, so its digest is one no + /// earlier load in the process holds and the folder is read. + fn file_only_this_test_pins(test: &str) -> String { + format!("{SAMPLE_POLICY_TEXT}# pinned only by {test}\n") + } + + /// The blob the Axum push writes for settings naming `standard`, + /// pinned to `policy_text` and carrying no text of its own. + fn blob_naming_standard(policy_text: &str) -> String { + let mut settings = startup_settings(); + settings.permissions = PermissionsConfig { + policy: Some("standard".to_owned()), + sha256: Some(policy_sha256(policy_text)), + text: None, + }; + let data = serde_json::to_value(&settings).expect("should serialize settings"); + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())) + .expect("should serialize the envelope") + } + + /// Builds the state as the process starts, with `blob` as its settings + /// and `folder` as its policy folder. + fn start( + blob: &str, + folder: Option<&Path>, + ) -> Result, Report> { + let config_var = config_env_var(DEFAULT_CONFIG_STORE_ID, DEFAULT_CONFIG_STORE_ID); + temp_env::with_vars( + [ + (config_var.as_str(), Some(blob)), + ( + HANDLER_PASSWORD_VAR, + Some("a-handler-password-for-the-startup-tests"), + ), + ( + PROXY_SECRET_VAR, + Some("a-proxy-secret-for-the-startup-tests-32-bytes"), + ), + (PERMISSIONS_DIR_ENV_VAR, folder.and_then(Path::to_str)), + ( + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME", + None, + ), + ("EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY", None), + ( + "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME", + None, + ), + ], + build_state, + ) + } + + #[test] + fn each_request_is_given_the_policy_its_domain_loaded() { + let policy = trusted_server_core::test_support::policies::open(); + let state = build_state_with_settings( + startup_settings().with_permission_policy(Arc::clone(&policy)), + ) + .expect("should build the state"); + let request = request_builder() + .method("GET") + .uri("https://publisher.example/article") + .body(edgezero_core::body::Body::empty()) + .expect("should build the request"); + + let services = + state.services_for_request(&RequestContext::new(request, PathParams::default())); + + assert!( + Arc::ptr_eq(services.permission_policy(), &policy), + "the request should decide under its domain's policy, not the sample" + ); + } + + #[test] + fn startup_reads_the_named_policy_file_from_the_policy_folder() { + let pushed = file_only_this_test_pins("the folder read test"); + let folder = tempfile::tempdir().expect("should create a policy folder"); + std::fs::write(folder.path().join("standard.yaml"), &pushed) + .expect("should write the policy file"); + + let state = start(&blob_naming_standard(&pushed), Some(folder.path())) + .expect("should load the policy file the settings name"); + + let policy = state + .settings + .permission_policy() + .expect("should keep the policy with the settings"); + assert_eq!(policy.name(), "standard"); + assert_eq!(policy.sha256(), policy_sha256(&pushed)); + } + + #[test] + fn startup_without_the_pushed_policy_file_answers_500_on_every_route() { + let pushed = file_only_this_test_pins("the startup refusal test"); + let blob = blob_naming_standard(&pushed); + let empty = tempfile::tempdir().expect("should create an empty folder"); + let edited = tempfile::tempdir().expect("should create a policy folder"); + std::fs::write( + edited.path().join("standard.yaml"), + format!("{pushed}# edited after the push\n"), + ) + .expect("should write an edited policy file"); + + for (folder, reason) in [ + (None, PERMISSIONS_DIR_ENV_VAR), + (Some(empty.path()), "standard.yaml"), + (Some(edited.path()), "is not the file that was pushed"), + ] { + let Err(error) = start(&blob, folder) else { + panic!("the domain should not load from {folder:?}"); + }; + let message = format!("{error:?}"); + assert!( + message.contains("`standard`") && message.contains(reason), + "the logged reason should name the policy and say `{reason}`: {message}" + ); + + let router = startup_error_router(&error); + for method in publisher_fallback_methods() { + for path in ["/", "/article"] { + let request = request_builder() + .method(method.clone()) + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build the request"); + let response = futures::executor::block_on(router.oneshot(request)) + .expect("should route the request"); + assert_eq!( + response.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "{method} {path} should answer 500" + ); + } + } + } + } + } } diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 7597ca4208..15ffc037be 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -201,6 +201,9 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 369037fe53..7a0f2b0cc1 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -1,5 +1,6 @@ use std::future::Future; use std::net::IpAddr; +use std::path::{Component, Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; @@ -8,11 +9,12 @@ use std::time::Duration; use async_trait::async_trait; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; use error_stack::{Report, ResultExt as _}; +use trusted_server_core::permissions::PolicyReference; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, - RuntimeServices, StoreId, StoreName, + PlatformPendingRequest, PlatformPolicySource, PlatformResponse, PlatformSecretStore, + PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -146,6 +148,84 @@ impl PlatformSecretStore for AxumPlatformSecretStore { } } +// --------------------------------------------------------------------------- +// PlatformPolicySource +// --------------------------------------------------------------------------- + +/// The environment variable naming the folder of permission policy files. +pub const PERMISSIONS_DIR_ENV_VAR: &str = "TRUSTED_SERVER_PERMISSIONS_DIR"; + +/// The extension of a permission policy file. +const POLICY_FILE_EXTENSION: &str = "yaml"; + +/// Reads a domain's permission policy file from a folder, as +/// `/.yaml`. +/// +/// The domain's blob pins the file by its SHA-256, which the runtime loader +/// checks, so the folder must hold the bytes that were pushed. +pub struct AxumPolicyFolder { + folder: Option, +} + +impl AxumPolicyFolder { + /// The folder [`PERMISSIONS_DIR_ENV_VAR`] names, where an empty value is + /// no folder. + #[must_use] + pub fn from_env() -> Self { + Self::new( + std::env::var_os(PERMISSIONS_DIR_ENV_VAR) + .filter(|folder| !folder.is_empty()) + .map(PathBuf::from), + ) + } + + /// Reads from `folder`, and refuses every read when it is `None`. + #[must_use] + pub fn new(folder: Option) -> Self { + Self { folder } + } +} + +impl PlatformPolicySource for AxumPolicyFolder { + fn policy_text(&self, reference: &PolicyReference) -> Result> { + let name = reference.policy.as_str(); + let Some(folder) = &self.folder else { + return Err(Report::new(PlatformError::PolicySource).attach(format!( + "{PERMISSIONS_DIR_ENV_VAR} is not set, so no folder holds the permission policy `{name}`" + ))); + }; + if !is_plain_file_stem(name) { + return Err(Report::new(PlatformError::PolicySource).attach(format!( + "the permission policy name `{name}` is not a plain file name" + ))); + } + let path = folder.join(format!("{name}.{POLICY_FILE_EXTENSION}")); + let bytes = std::fs::read(&path).map_err(|error| { + Report::new(PlatformError::PolicySource).attach(format!( + "failed to read the permission policy file {}: {error}", + path.display() + )) + })?; + String::from_utf8(bytes).map_err(|_| { + Report::new(PlatformError::PolicySource).attach(format!( + "the permission policy file {} is not UTF-8 text", + path.display() + )) + }) + } +} + +/// Whether `name` is a single file name inside the folder, holding no path +/// separator and nothing that leaves the folder. +fn is_plain_file_stem(name: &str) -> bool { + let mut components = Path::new(name).components(); + !name.contains(['/', '\\']) + && matches!( + (components.next(), components.next()), + (Some(Component::Normal(_)), None) + ) +} + // --------------------------------------------------------------------------- // PlatformBackend // --------------------------------------------------------------------------- @@ -530,6 +610,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_modules: &Arc< + [Arc], + >, + permission_policy: &Arc, ) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { @@ -578,6 +662,13 @@ pub fn build_runtime_services( settings, Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), )) + // The signal modules were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_modules(Arc::clone(permission_signal_modules)) + // The policy the domain's settings loaded decides the request's + // permissions. + .permission_policy(Arc::clone(permission_policy)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -598,6 +689,91 @@ mod tests { use std::time::Duration; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + fn reference(policy: &str) -> PolicyReference { + PolicyReference { + policy: policy.to_owned(), + sha256: "the loader checks this".to_owned(), + } + } + + #[test] + fn the_policy_folder_reads_the_named_file_byte_for_byte() { + let folder = tempfile::tempdir().expect("should create a policy folder"); + let text = "groups:\r\n open:\r\n default: granted\r\n"; + std::fs::write(folder.path().join("standard.yaml"), text).expect("should write a policy"); + + let read = AxumPolicyFolder::new(Some(folder.path().to_path_buf())) + .policy_text(&reference("standard")) + .expect("should read the named file"); + + assert_eq!(read, text, "should return the file's exact text"); + } + + #[test] + fn the_policy_folder_refuses_when_no_folder_is_named() { + let error = AxumPolicyFolder::new(None) + .policy_text(&reference("standard")) + .expect_err("should refuse with no folder"); + + let message = format!("{error:?}"); + assert!( + message.contains(PERMISSIONS_DIR_ENV_VAR) && message.contains("`standard`"), + "should name the variable and the policy: {message}" + ); + } + + #[test] + fn the_policy_folder_refuses_a_file_it_does_not_hold() { + let folder = tempfile::tempdir().expect("should create a policy folder"); + + let error = AxumPolicyFolder::new(Some(folder.path().to_path_buf())) + .policy_text(&reference("standard")) + .expect_err("should refuse a missing file"); + + let message = format!("{error:?}"); + assert!( + message.contains("standard.yaml"), + "should name the file it looked for: {message}" + ); + } + + #[test] + fn the_policy_folder_refuses_a_name_that_leaves_the_folder() { + let root = tempfile::tempdir().expect("should create a root folder"); + let folder = root.path().join("policies"); + std::fs::create_dir(&folder).expect("should create the policy folder"); + // A file the refused names would reach if they were followed. + std::fs::write(root.path().join("outside.yaml"), "groups: {}\n") + .expect("should write a file outside the folder"); + let source = AxumPolicyFolder::new(Some(folder)); + + for name in ["../outside", "..\\outside", "sub/standard", "..", ".", ""] { + let error = source + .policy_text(&reference(name)) + .expect_err("should refuse a name that is not a plain file name"); + assert!( + format!("{error:?}").contains("not a plain file name"), + "should refuse `{name}` as a name: {error:?}" + ); + } + } + + #[test] + fn the_policy_folder_refuses_a_file_that_is_not_utf8_text() { + let folder = tempfile::tempdir().expect("should create a policy folder"); + std::fs::write(folder.path().join("standard.yaml"), [0xff, 0xfe, 0x00]) + .expect("should write a file that is not text"); + + let error = AxumPolicyFolder::new(Some(folder.path().to_path_buf())) + .policy_text(&reference("standard")) + .expect_err("should refuse bytes that are not UTF-8"); + + assert!( + format!("{error:?}").contains("not UTF-8 text"), + "should say why: {error:?}" + ); + } + #[test] fn auction_http_capabilities_are_explicit() { let client = AxumPlatformHttpClient::new(); diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs new file mode 100644 index 0000000000..e162f05498 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -0,0 +1,679 @@ +//! The four shipped signal modules assembled together, as a deployment +//! runs them. +//! +//! Each module crate tests its own scheme in isolation, in its own unit +//! tests. What is tested here is what only shows when multiple modules +//! run in order through core's assembly: an answer to a prompt applying +//! over an opt-out, one opt-out standing when another is removed, a scheme +//! left off the list not running at all, and withdrawal being TCF's alone +//! and scoped to the place. This sits in the Axum adapter's tests because +//! it is the first crate that links all four, and core deliberately links +//! none. The names a deployment writes in configuration are checked here for +//! the same reason, against the identifiers the real crates answer to. +//! +//! The consent records here are built by hand, so nothing in core's consent +//! pipeline runs. In a deployment that pipeline also synthesizes a US Privacy +//! opt-out from a Global Privacy Control header in a US state when the consent +//! settings say to, and the `us_privacy` module then acts on it, which is +//! why removing `gpc` from the list alone does not make that header inert. + +use std::sync::Arc; + +use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; +use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::{ + PermissionSignalModule, build_permission_signal_modules, +}; +use trusted_server_core::permissions::{Permission, PermissionMaps, PermissionState}; +use trusted_server_core::platform::GeoInfo; +use trusted_server_core::settings::Settings; +use trusted_server_permission_signal_gpc::GpcModule; +use trusted_server_permission_signal_gpp::GppSaleOptOutModule; +use trusted_server_permission_signal_mtm::MtmModule; +use trusted_server_permission_signal_tcf::TcfModule; +use trusted_server_permission_signal_us_privacy::UsPrivacyModule; + +/// The four IAB modules an adapter offers, in the default order. MTM is +/// offered after them and is tested with them below. +fn all_four() -> Vec> { + vec![ + Arc::new(GpcModule::new()), + Arc::new(GppSaleOptOutModule::new()), + Arc::new(UsPrivacyModule::new()), + Arc::new(TcfModule::new()), + ] +} + +/// All five, as an adapter offers them. +fn all_five() -> Vec> { + let mut modules = all_four(); + modules.push(Arc::new(MtmModule::new())); + modules +} + +/// Settings naming these identifiers in `[permission-signal] modules`. +fn settings_naming(names: &[&str]) -> Settings { + let mut settings = Settings::default(); + settings.permission_signal.modules = + Some(names.iter().map(|name| (*name).to_owned()).collect()); + settings +} + +/// The modules a deployment gets from naming these identifiers in +/// `[permission-signal] modules`, through the same entry point an adapter's +/// composition root uses. +fn configured(names: &[&str]) -> Arc<[Arc]> { + build_permission_signal_modules(&settings_naming(names), &all_four()) + .expect("should select modules this build offers") +} + +/// Every module except the one named, in the default order, as a +/// publisher removes one from configuration. +fn all_but(excluded: &str) -> Arc<[Arc]> { + let names: Vec<&str> = all_four() + .iter() + .map(|module| trusted_server_core::permission_signal::short_name(module.as_ref())) + .filter(|name| *name != excluded) + .collect(); + configured(&names) +} + +fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::default() +} + +fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + modules: &[Arc], +) -> PermissionState { + assemble_permissions( + PermissionMaps::sample(), + consent, + &no_evidence(), + geo, + modules, + ) +} + +/// Builds a minimal decoded TCF record consenting to the given 1-indexed +/// purposes, with everything else refused. +fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } +} + +fn us_privacy_opted_out() -> UsPrivacy { + UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::NotApplicable, + } +} + +fn gpp_sale_opted_out() -> GppConsent { + GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + } +} + +/// A US opt-out state, where the baseline grants storage without a signal, so +/// a revoke is observable as a drop and a refusal is never a withdrawal. +fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } +} + +// ---------------------------------------------------------------------- +// Which modules run. +// ---------------------------------------------------------------------- + +#[test] +fn the_documented_names_select_every_shipped_module_in_order() { + // The names the guide and the example configuration list, which must be + // the names the shipped crates answer to within the section. + let documented = ["gpc", "gpp", "us-privacy", "tcf"]; + let selected: Vec<&str> = configured(&documented) + .iter() + .map(|module| trusted_server_core::permission_signal::short_name(module.as_ref())) + .collect(); + assert_eq!( + selected, documented, + "each documented name selects the shipped module it names, in the order written" + ); +} + +#[test] +fn an_old_name_is_refused_naming_the_names_available() { + for old in ["gpp_sale_opt_out", "us_privacy"] { + let Err(error) = build_permission_signal_modules(&settings_naming(&[old]), &all_four()) + else { + panic!("should refuse the old name `{old}`"); + }; + let message = format!("{error:?}"); + assert!( + message.contains(&format!("`{old}` is not available in this build")) + && message.contains("Available modules are gpc, gpp, us-privacy, tcf"), + "the refusal names the old name and the names to write instead: {message}" + ); + } +} + +#[test] +fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline, because the map granted + // these purposes and Global Privacy Control revokes them. + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); +} + +#[test] +fn a_module_left_off_the_list_does_not_run() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + + let everything = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !everything.is_set(Permission::StoreOnDevice), + "with every module running, the header takes storage away" + ); + + let pruned = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + pruned.is_set(Permission::StoreOnDevice), + "a publisher who does not want to act on Global Privacy Control removes it from \ + the list, and the module that read the header then does not run" + ); +} + +#[test] +fn removing_one_opt_out_leaves_the_others_working() { + // The reason the three opt-outs are separate modules rather than one. + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("gpc")); + assert!( + !state.is_set(Permission::StoreOnDevice), + "dropping Global Privacy Control must not drop the US Privacy opt-out with it" + ); +} + +#[test] +fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let consent = ConsentContext { + gpc: true, + us_privacy: Some(UsPrivacy { + opt_out_sale: PrivacyFlag::No, + ..us_privacy_opted_out() + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out module should suppress, whatever the others say" + ); +} + +// ---------------------------------------------------------------------- +// Opt-out and prompt precedence. +// +// The modules are asked in order and each amends what the ones before it +// settled, so a later module can amend an opt-out. The default order asks +// Global Privacy Control first, being a browser setting with no interface of +// its own, and the schemes carrying a choice someone made through an +// interface after, which is why an answer given at a prompt amends the +// header the visitor arrived with. A deployment wanting the opposite puts +// the module it wants to win last. +// ---------------------------------------------------------------------- + +#[test] +fn a_prompt_answer_applies_over_a_gpc_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with GPC set, and under the \ + default order the answer they gave is applied over the header they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_us_privacy_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(us_privacy_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a US Privacy opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn a_prompt_answer_applies_over_a_gpp_sale_opt_out_signal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "the visitor answered a prompt after arriving with a GPP sale opt-out, and under \ + the default order the answer they gave amends the signal they sent" + ); +} + +#[test] +fn the_opt_out_wins_when_a_deployment_puts_it_last() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let modules = configured(&["tcf", "gpc"]); + let state = assembled(&consent, GeoStatus::Located(&geo), &modules); + assert!( + !state.is_set(Permission::StoreOnDevice), + "the same request, with the order reversed in configuration, lets the header win" + ); +} + +// ---------------------------------------------------------------------- +// The TCF mapping, now the TCF crate's, still reaches every purpose. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // Consent to all purposes except Purpose 7 (measure ad performance), in a + // US opt-out state where the baseline granted them all, so a revoke is + // observable as a drop. + let consented: Vec = (1..=11).filter(|&purpose| purpose != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + assert!( + state.is_set(Permission::StoreOnDevice) && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" + ); +} + +// ---------------------------------------------------------------------- +// Withdrawal scoping: only a TCF storage refusal withdraws, and only where +// the baseline did not grant storage outright. Opt-outs suppress use but +// never destroy an already-issued identifier. +// +// No location resolves at the policy's top node, the gdpr-eu group, where +// storage requires a signal. A US opt-out state grants it outright. +// ---------------------------------------------------------------------- + +#[test] +fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + state.storage_withdrawn(), + "refusing the signal storage depends on should withdraw" + ); +} + +#[test] +fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.storage_withdrawn(), + "storage never depended on the record here, so refusal suppresses without destroying" + ); +} + +#[test] +fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "a consenting record is not a withdrawal" + ); +} + +#[test] +fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + assert!( + !assembled(&consent, GeoStatus::Located(&geo), &all_four()).storage_withdrawn() + && !assembled(&consent, GeoStatus::NoLocation, &all_four()).storage_withdrawn(), + "GPC suppresses use for the request but never destroys the identifier" + ); +} + +#[test] +fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(us_privacy_opted_out()), + gpp: Some(gpp_sale_opted_out()), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_four()); + assert!( + !state.storage_withdrawn(), + "sale opt-outs suppress use but never destroy the identifier" + ); +} + +#[test] +fn no_signal_is_not_a_withdrawal() { + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &all_four(), + ); + assert!( + !state.storage_withdrawn(), + "absence of a signal must never destroy an identifier" + ); +} + +#[test] +fn a_withdrawal_needs_the_tcf_module_to_be_running() { + // The withdrawal is TCF's answer, so a deployment that removed the TCF + // module from the list has no scheme left that can withdraw. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + let state = assembled(&consent, GeoStatus::NoLocation, &all_but("tcf")); + assert!( + !state.storage_withdrawn(), + "a scheme that does not run cannot withdraw, whatever the request carries" + ); +} + +// ---------------------------------------------------------------------- +// Unreadable and expired records, assembled with the real modules. +// ---------------------------------------------------------------------- + +#[test] +fn an_unreadable_tcf_record_is_the_tcf_modules_refusal() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + assert!( + !state.storage_withdrawn(), + "and it fails closed by suppression, never destructively" + ); + assert!( + state.signals().is_empty(), + "and the string is not a valid signal, so nothing downstream sees it" + ); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_but("tcf")); + assert!( + state.is_set(Permission::StoreOnDevice), + "a scheme that does not run answers nothing, readable or not" + ); +} + +#[test] +fn an_unreadable_gpp_string_is_the_gpp_modules_opt_out_and_the_order_decides() { + // The GPP module reads its own unreadable string as the opt-out it + // may have carried. Asked after it, a readable TCF record consenting to + // storage amends that, because the order is the policy. Nothing in + // core answers ahead of the modules. + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + raw_tc_string: Some("CPreadable".to_owned()), + raw_gpp_string: Some("this is not a GPP string".to_owned()), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + assert!( + !assembled(&consent, GeoStatus::Located(&geo), &all_but("tcf")) + .is_set(Permission::StoreOnDevice), + "without TCF the GPP module's reading of its unreadable string stands" + ); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "asked last, the readable TCF record amends the GPP module's answer" + ); + let schemes: Vec<&str> = state.signals().iter().map(|s| s.scheme).collect(); + assert_eq!( + schemes, + vec!["tcf"], + "only the readable string is vouched for, so only it goes any further" + ); +} + +#[test] +fn an_expired_tcf_record_is_not_treated_as_malformed() { + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &all_four()); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); +} + +#[test] +fn a_visitor_in_the_eu_with_no_record_is_awaiting_what_tcf_could_grant() { + let geo = GeoInfo { + city: String::new(), + country: "FR".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + let state = assembled( + &ConsentContext::default(), + GeoStatus::Located(&geo), + &all_four(), + ); + assert!( + state.is_awaited(Permission::StoreOnDevice) + && state.is_awaited(Permission::SelectPersonalisedAds), + "a prompt that has not run leaves the TCF-mapped Data Uses awaited" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!( + !state.is_awaited(email), + "a channel no module can grant is not awaited" + ); + let state = assembled( + &ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }, + GeoStatus::Located(&geo), + &all_four(), + ); + assert!( + state.is_set(Permission::StoreOnDevice) && !state.is_awaited(Permission::StoreOnDevice), + "an answered prompt settles what it granted" + ); + assert!( + !state.is_awaited(Permission::SelectPersonalisedAds) + && !state.is_set(Permission::SelectPersonalisedAds), + "a record refusing a purpose is a refusal, not an awaited answer" + ); +} + +#[test] +fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() { + use trusted_server_core::constants::COOKIE_MTM_PREF; + + let geo = GeoInfo { + city: String::new(), + country: "FR".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + let evidence = |word: &str| { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "cookie", + format!("{COOKIE_MTM_PREF}={word}") + .parse() + .expect("should build a cookie header"), + ); + OwnedRequestInfo::new(String::new(), headers) + }; + let standard = assemble_permissions( + PermissionMaps::sample(), + &ConsentContext::default(), + &evidence("standard"), + GeoStatus::Located(&geo), + &all_five(), + ); + assert!( + standard.is_set(Permission::StoreOnDevice) + && standard.is_set(Permission::SelectBasicAds) + && !standard.is_set(Permission::SelectPersonalisedAds) + && !standard.is_awaited(Permission::SelectPersonalisedAds), + "standard grants storage and contextual advertising and refuses targeting" + ); + assert_eq!( + standard.signals(), + &[trusted_server_core::permissions::ValidSignal::new( + "mtm", "mtm", "standard" + )], + "the word is the valid signal, as received" + ); + assert_eq!( + standard + .tdls() + .iter() + .map(trusted_server_core::tdl::Tdl::as_str) + .collect::>(), + vec!["https://m4ow.uk/mtm/2.txt"], + "an answer is given under the versioned Model Terms" + ); + let personalized = assemble_permissions( + PermissionMaps::sample(), + &ConsentContext::default(), + &evidence("personalized"), + GeoStatus::Located(&geo), + &all_five(), + ); + assert!( + personalized.is_set(Permission::SelectPersonalisedAds), + "personalized grants targeting too" + ); + let unanswered = assemble_permissions( + PermissionMaps::sample(), + &ConsentContext::default(), + &no_evidence(), + GeoStatus::Located(&geo), + &all_five(), + ); + assert!( + unanswered.is_awaited(Permission::SelectPersonalisedAds) && unanswered.tdls().is_empty(), + "with no answer the question is still open and no terms are declared" + ); +} diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index b3eaca5558..9481f5099f 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -15,9 +15,10 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// Build the full application router from explicit test settings. /// -/// The settings baked into the binary contain placeholder secrets that -/// `get_settings()` rejects by design, which would turn every route into a -/// startup error page (and its route table into the fallback-only set). +/// The process reads its settings from its config store when it starts, +/// which a test does not set up, so `TrustedServerApp::routes()` would turn +/// every route into a startup error page (and its route table into the +/// fallback-only set). fn test_settings() -> trusted_server_core::settings::Settings { trusted_server_core::settings::Settings::from_toml( r#" @@ -37,9 +38,15 @@ fn test_settings() -> trusted_server_core::settings::Settings { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse route test settings") + // A loaded domain carries its policy, and these tests decide under the + // repository's sample. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) } fn test_router() -> edgezero_core::router::RouterService { @@ -895,6 +902,11 @@ const UNINJECTED_MODULE_TOML: &str = r#" [ec.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction operation + # is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true "#; /// A module selection this adapter can never supply must fail while the diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 2ce969ef79..722eb4893a 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -23,7 +23,6 @@ cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] [dependencies] async-trait = { workspace = true } bytes = { workspace = true } -derive_more = { workspace = true } edgezero-adapter-cloudflare = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -31,6 +30,11 @@ futures = { workspace = true } log = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-mtm = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } worker = { workspace = true, optional = true } diff --git a/crates/trusted-server-adapter-cloudflare/README.md b/crates/trusted-server-adapter-cloudflare/README.md index 208827e689..231bba3d73 100644 --- a/crates/trusted-server-adapter-cloudflare/README.md +++ b/crates/trusted-server-adapter-cloudflare/README.md @@ -5,11 +5,12 @@ Cloudflare Workers adapter for Trusted Server. Production code targets adapter tests and deliberately exclude the Workers entry point. The crate translates Worker requests and responses, registers the shared -router, and implements Cloudflare-specific HTTP and secret access. The current -runtime does not open the KV config value written by `ts config push`; startup -instead consumes the nested `TRUSTED_SERVER_CONFIG` variable bridge. It also -has no `/health` route or request-time KV registry and permits one enabled -auction provider. +router, and implements Cloudflare-specific HTTP and secret access. For each +request the Worker reads its settings from the config store, the Workers KV +namespace bound as `trusted_server_config` that `ts config push --adapter +cloudflare` writes, and serves the router built from them, or the startup +error router when they cannot be read or do not load. It has no `/health` +route or request-time KV registry and permits one enabled auction provider. Build and test from the repository root: @@ -20,6 +21,5 @@ cargo test-cloudflare Run `./scripts/smoke-cloudflare.sh` for the isolated Wrangler handoff check. The [Cloudflare deployment guide](../../docs/guide/cloudflare.md) documents the -required double encoding, secret bindings, failure cases, and cleanup. Keep -portable request behavior in -[`trusted-server-core`](../trusted-server-core/README.md). +config store, secret bindings, failure cases, and cleanup. Keep portable +request behavior in [`trusted-server-core`](../trusted-server-core/README.md). diff --git a/crates/trusted-server-adapter-cloudflare/cloudflare.toml b/crates/trusted-server-adapter-cloudflare/cloudflare.toml index f505e6b2fa..6d20f75e72 100644 --- a/crates/trusted-server-adapter-cloudflare/cloudflare.toml +++ b/crates/trusted-server-adapter-cloudflare/cloudflare.toml @@ -15,7 +15,7 @@ name = "TRUSTED_SERVER_KV" name = "trusted_server_config" [stores.config.adapters.cloudflare] -name = "TRUSTED_SERVER_CONFIG" +name = "trusted_server_config" [stores.secrets] name = "trusted_server_secrets" diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 52d393d4e5..83258330a9 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -13,8 +13,6 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; -#[cfg(any(test, target_arch = "wasm32"))] -use trusted_server_core::config_payload::CONFIG_BLOB_KEY; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; use trusted_server_core::ec::EcContext; @@ -50,26 +48,6 @@ use crate::platform::build_runtime_services; // AppState // --------------------------------------------------------------------------- -#[cfg(target_arch = "wasm32")] -thread_local! { - static CLOUDFLARE_CONFIG_JSON: std::cell::OnceCell = const { std::cell::OnceCell::new() }; - static CLOUDFLARE_ENV: std::cell::OnceCell = const { std::cell::OnceCell::new() }; -} - -#[cfg(target_arch = "wasm32")] -pub fn set_cloudflare_config_json(value: String) { - CLOUDFLARE_CONFIG_JSON.with(|slot| { - let _ = slot.set(value); - }); -} - -#[cfg(target_arch = "wasm32")] -pub fn set_cloudflare_env(env: worker::Env) { - CLOUDFLARE_ENV.with(|slot| { - let _ = slot.set(env); - }); -} - /// Application state built once at startup and shared across all requests. pub struct AppState { settings: Arc, @@ -84,119 +62,100 @@ pub struct AppState { /// rather than resolved again on the request path. /// `None` for a deployment that selects no module. ec_module: Option>, + /// The permission signal modules `[permission-signal] modules` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_modules: + Arc<[Arc]>, + /// The permission policy the domain's settings loaded, which every + /// request is given. + permission_policy: Arc, /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, } -/// Build the application state, loading settings and constructing all per-application components. -/// -/// # Errors +/// Builds the application a Worker serves a request with. /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. -fn build_state() -> Result, Report> { - let settings = load_startup_settings()?; - build_state_with_settings(settings) -} - +/// The settings are read from the config store with an awaited Workers KV +/// read, which `Hooks::build_app` cannot make, so the Worker's fetch entry +/// point calls this and serves the result with `EdgeZero`'s `dispatch_app`. +/// Settings that cannot be read or do not load give the startup error +/// router. #[cfg(target_arch = "wasm32")] -fn load_startup_settings() -> Result> { - settings_from_cloudflare_config_json() -} - -#[cfg(not(target_arch = "wasm32"))] -fn load_startup_settings() -> Result> { - Err(Report::new(TrustedServerError::Configuration { - message: "Cloudflare startup settings require a Worker config binding".to_string(), - }) - .attach("use TrustedServerApp::routes_with_settings for host tests")) +pub async fn build_app(env: &worker::Env) -> edgezero_core::app::App { + let router = match load_startup_settings(env) + .await + .and_then(build_state_with_settings) + { + Ok(state) => build_router(&state), + Err(error) => { + log::error!("failed to build application state: {:?}", error); + startup_error_router(&error) + } + }; + edgezero_core::app::App::with_name(router, TrustedServerApp::name()) } -/// Older Cloudflare bindings used this JSON property before config stores adopted -/// the manifest-derived default. +/// Reads the Worker's settings from its config store, the Workers KV +/// namespace that `ts config push --adapter cloudflare` writes. /// -/// Remove this fallback only when support for those bindings is deliberately retired. -#[cfg(any(test, target_arch = "wasm32"))] -const LEGACY_CONFIG_BLOB_KEY: &str = "app_config"; - -#[cfg(any(test, target_arch = "wasm32"))] -#[derive(Debug, Eq, PartialEq, derive_more::Display)] -enum CloudflareConfigEnvelopeError { - #[display( - "Cloudflare TRUSTED_SERVER_CONFIG has no `{primary_key}` or legacy `{legacy_key}` property" - )] - Missing { - primary_key: &'static str, - legacy_key: &'static str, - }, - #[display("Cloudflare TRUSTED_SERVER_CONFIG value at `{key}` must be a string")] - NonString { key: &'static str }, -} - -#[cfg(any(test, target_arch = "wasm32"))] -impl core::error::Error for CloudflareConfigEnvelopeError {} - +/// The binding and the key are resolved as the push resolves them. The +/// binding is the config store's platform name, which is +/// `trusted_server_config` unless +/// `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` names another, and +/// the key is the store's logical ID unless the matching `__KEY` variable +/// names another. +/// +/// # Errors +/// +/// [`TrustedServerError::Configuration`] when the namespace cannot be opened +/// or read, holds nothing at the key, or the settings do not load. #[cfg(target_arch = "wasm32")] -fn settings_from_cloudflare_config_json() -> Result> { - let raw_config = CLOUDFLARE_CONFIG_JSON.with(|slot| slot.get().cloned()); - let raw_config = raw_config.ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: "Cloudflare TRUSTED_SERVER_CONFIG is required".to_string(), - }) - .attach(format!( - "set TRUSTED_SERVER_CONFIG to JSON containing the `{CONFIG_BLOB_KEY}` blob envelope" - )) - })?; - let value: serde_json::Value = serde_json::from_str(&raw_config).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "invalid Cloudflare TRUSTED_SERVER_CONFIG JSON".to_string(), - }) - .attach(format!("failed to parse TRUSTED_SERVER_CONFIG: {error}")) - })?; - let envelope = cloudflare_config_envelope(&value).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: error.to_string(), - }) - })?; - let env = CLOUDFLARE_ENV - .with(|slot| slot.get().cloned()) +async fn load_startup_settings(env: &worker::Env) -> Result> { + use edgezero_core::config_store::ConfigStore as _; + + let selectors = config_store_selectors(env); + let store_name = trusted_server_core::settings_data::config_store_name(&selectors); + let key = trusted_server_core::settings_data::config_key(&selectors); + let unreadable = || TrustedServerError::Configuration { + message: format!( + "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" + ), + }; + let store = edgezero_adapter_cloudflare::config_store::CloudflareConfigStore::from_env( + env, + store_name.as_ref(), + ) + .map_err(|error| Report::new(unreadable()).attach(error.to_string()))?; + let envelope = store + .get(&key) + .await + .map_err(|error| Report::new(unreadable()).attach(error.to_string()))? .ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: "Cloudflare Worker environment is unavailable during startup".to_string(), - }) + Report::new(unreadable()).attach("the namespace holds nothing at that key") })?; - let secret_store = crate::platform::CloudflareSecretStoreAdapter { env }; - let default_secret_store = StoreName::from(DEFAULT_SECRET_STORE_ID); - settings_from_config_blob(envelope, &secret_store, &default_secret_store) + let secret_store = crate::platform::CloudflareSecretStoreAdapter { env: env.clone() }; + settings_from_config_blob( + &envelope, + &secret_store, + &StoreName::from(DEFAULT_SECRET_STORE_ID), + trusted_server_core::platform::PolicyOrigin::Blob, + ) } -#[cfg(any(test, target_arch = "wasm32"))] -fn cloudflare_config_envelope( - value: &serde_json::Value, -) -> Result<&str, CloudflareConfigEnvelopeError> { - match value.get(CONFIG_BLOB_KEY).filter(|envelope| { - // Treat a blank placeholder as absent so a populated legacy property - // remains usable during migration. - envelope.as_str() != Some("") - }) { - Some(envelope) => envelope - .as_str() - .ok_or(CloudflareConfigEnvelopeError::NonString { - key: CONFIG_BLOB_KEY, - }), - None => match value.get(LEGACY_CONFIG_BLOB_KEY) { - Some(envelope) => envelope - .as_str() - .ok_or(CloudflareConfigEnvelopeError::NonString { - key: LEGACY_CONFIG_BLOB_KEY, - }), - None => Err(CloudflareConfigEnvelopeError::Missing { - primary_key: CONFIG_BLOB_KEY, - legacy_key: LEGACY_CONFIG_BLOB_KEY, - }), - }, - } +/// The config store selectors the Worker's variables set. They are read one +/// by one, because a Worker's environment cannot be listed. +#[cfg(target_arch = "wasm32")] +fn config_store_selectors(env: &worker::Env) -> edgezero_core::env_config::EnvConfig { + let id = trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID.to_ascii_uppercase(); + let names = ["NAME", "KEY"].map(|setting| format!("EDGEZERO__STORES__CONFIG__{id}__{setting}")); + edgezero_core::env_config::EnvConfig::from_vars( + names + .into_iter() + .filter_map(|name| env.var(&name).ok().map(|value| (name, value.to_string()))), + ) } /// Build the application state from explicit settings. @@ -234,27 +193,64 @@ fn build_state_with_services( plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; + let permission_policy = settings.required_permission_policy()?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), ec_module, + permission_signal_modules, + permission_policy, services, })) } +/// The permission signal modules this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no module of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + impl AppState { /// Builds the per-request services, carrying the Edge Cookie module the /// composition root already resolved so the request path does not resolve - /// `[ec] module` a second time. - /// Nothing is carried when the composition root found nothing safe to keep, - /// and the request path resolves for itself. + /// `[ec] module` a second time. Nothing is carried when the composition + /// root found nothing safe to keep, and the request path resolves for + /// itself. The services also carry the permission policy the domain's + /// settings loaded, whichever services the request is given. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx, &self.settings)) + .unwrap_or_else(|| { + build_runtime_services( + ctx, + &self.settings, + &self.permission_signal_modules, + &self.permission_policy, + ) + }) .with_resolved_ec_module(self.ec_module.clone()) + .with_permission_policy(Arc::clone(&self.permission_policy)) } } @@ -265,8 +261,12 @@ impl AppState { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Geo comes from the Workers `cf` object when deployed, and a geo lookup -/// failure is logged and treated as no location. +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the 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 @@ -284,14 +284,7 @@ fn build_ec_context( services: &RuntimeServices, req: &Request, ) -> Result> { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + EcContext::read_from_request_resolving_geo(settings, req, services) } // --------------------------------------------------------------------------- @@ -489,16 +482,17 @@ impl Hooks for TrustedServerApp { "TrustedServer" } + /// The startup error router, because this hook cannot read the settings. + /// + /// A Worker reads its settings from the config store with an awaited + /// read, so its fetch entry point builds the application with + /// `build_app` and never calls this hook. fn routes() -> RouterService { - let state = match build_state() { - Ok(s) => s, - Err(ref e) => { - log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); - } - }; - - build_router(&state) + startup_error_router(&Report::new(TrustedServerError::Configuration { + message: "the Cloudflare adapter reads its settings from the config store \ + when the Worker builds its application" + .to_string(), + })) } } @@ -506,8 +500,8 @@ impl TrustedServerApp { /// Build the full application router from explicit settings. /// /// Testing seam: cross-adapter parity tests use this to drive the router - /// with known-good settings instead of the baked `get_settings()` result, - /// whose embedded placeholder secrets fail validation by design. + /// with settings they build, where a Worker reads its settings from its + /// config store. /// /// # Errors /// @@ -849,6 +843,11 @@ mod tests { [ec.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction + # operation is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true "#; /// The per-request Edge Cookie read must return its error rather than a @@ -873,7 +872,12 @@ mod tests { // No resolved module is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx, &settings); + let services = build_runtime_services( + &ctx, + &settings, + &Arc::default(), + &trusted_server_core::permissions::PermissionPolicy::sample(), + ); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) @@ -901,9 +905,13 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse startup test settings"); + .expect("should parse startup test settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { @@ -919,6 +927,27 @@ mod tests { settings } + #[test] + fn each_request_is_given_the_policy_its_domain_loaded() { + let policy = trusted_server_core::test_support::policies::open(); + let state = build_state_with_settings( + aps_profile_settings().with_permission_policy(Arc::clone(&policy)), + ) + .expect("should build the state"); + let req = request_builder() + .method("GET") + .uri("https://publisher.example/article") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + + let services = state.services_for_request(&RequestContext::new(req, PathParams::default())); + + assert!( + Arc::ptr_eq(services.permission_policy(), &policy), + "the request should decide under its domain's policy, not the sample" + ); + } + #[test] fn startup_registers_aps_renderer_route() { let state = build_state_with_settings(aps_profile_settings()) @@ -932,20 +961,6 @@ mod tests { ); } - #[test] - fn cloudflare_config_prefers_manifest_default_key() { - let value = serde_json::json!({ - LEGACY_CONFIG_BLOB_KEY: "legacy-envelope", - CONFIG_BLOB_KEY: "manifest-envelope", - }); - - assert_eq!( - cloudflare_config_envelope(&value), - Ok("manifest-envelope"), - "manifest-derived key should take precedence" - ); - } - #[test] fn disabled_startup_accepts_dormant_multi_provider_auction_plan() { let mut settings = Settings::from_toml( @@ -963,9 +978,13 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse startup test settings"); + .expect("should parse startup test settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); settings.auction.enabled = false; settings.auction.providers = std::iter::IntoIterator::into_iter(["provider-a", "provider-b"]) @@ -1007,9 +1026,13 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse startup test settings"); + .expect("should parse startup test settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); settings.auction.enabled = true; settings.auction.providers = std::iter::IntoIterator::into_iter(["provider-a", "provider-b"]) @@ -1040,88 +1063,54 @@ mod tests { ); } - #[test] - fn cloudflare_config_accepts_legacy_app_config_key() { - let value = serde_json::json!({ LEGACY_CONFIG_BLOB_KEY: "legacy-envelope" }); - - assert_eq!( - cloudflare_config_envelope(&value), - Ok("legacy-envelope"), - "legacy app_config key should remain compatible" - ); - } - - #[test] - fn cloudflare_config_treats_blank_primary_as_absent() { - let value = serde_json::json!({ - CONFIG_BLOB_KEY: "", - LEGACY_CONFIG_BLOB_KEY: "legacy-envelope", - }); + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_hook_router_answers_every_route_with_the_startup_error() { + // A Worker builds its application with `build_app`, which reads the + // settings it awaits, so the synchronous hook has none to serve. + let router = TrustedServerApp::routes(); - assert_eq!( - cloudflare_config_envelope(&value), - Ok("legacy-envelope"), - "blank primary should not shadow a populated legacy property" - ); - } - - #[test] - fn cloudflare_config_reports_missing_keys() { - let value = serde_json::json!({}); - - let error = cloudflare_config_envelope(&value) - .expect_err("should reject config without either accepted property"); - - assert_eq!( - error, - CloudflareConfigEnvelopeError::Missing { - primary_key: CONFIG_BLOB_KEY, - legacy_key: LEGACY_CONFIG_BLOB_KEY, - }, - "missing config should name both accepted keys" - ); - assert_eq!( - error.to_string(), - format!( - "Cloudflare TRUSTED_SERVER_CONFIG has no `{CONFIG_BLOB_KEY}` or legacy `{LEGACY_CONFIG_BLOB_KEY}` property" - ), - "missing config should report absent properties, not invalid types" - ); + for method in publisher_fallback_methods() { + for path in ["/", "/article"] { + let req = edgezero_core::http::request_builder() + .method(method.clone()) + .uri(format!("https://publisher.example{path}")) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await.expect("should route the request"); + assert_eq!( + resp.status().as_u16(), + 500, + "{method} {path} should answer the configuration error's 500" + ); + } + } } - #[test] - fn cloudflare_config_does_not_mask_malformed_manifest_value() { - let value = serde_json::json!({ - LEGACY_CONFIG_BLOB_KEY: "legacy-envelope", - CONFIG_BLOB_KEY: true, - }); + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_startup_router_answers_500_to_a_policy_load_error() { + use trusted_server_core::test_support::policy_failure; - assert_eq!( - cloudflare_config_envelope(&value), - Err(CloudflareConfigEnvelopeError::NonString { - key: CONFIG_BLOB_KEY, - }), - "malformed manifest-derived value should not fall back" + let error = policy_failure::load_error(); + assert!( + format!("{error:?}").contains(&format!("`{}`", policy_failure::POLICY_NAME)), + "the startup error should name the policy: {error:?}" ); - } - #[test] - fn cloudflare_config_reports_malformed_legacy_value() { - let value = serde_json::json!({ LEGACY_CONFIG_BLOB_KEY: false }); - let error = cloudflare_config_envelope(&value) - .expect_err("should reject a malformed legacy config value"); - - assert_eq!( - error, - CloudflareConfigEnvelopeError::NonString { - key: LEGACY_CONFIG_BLOB_KEY, - }, - "malformed legacy value should name the legacy key" - ); - assert_eq!( - error.to_string(), - "Cloudflare TRUSTED_SERVER_CONFIG value at `app_config` must be a string", - "configuration error should name the malformed legacy key" - ); + let router = startup_error_router(&error); + for method in publisher_fallback_methods() { + for path in ["/", "/article"] { + let req = edgezero_core::http::request_builder() + .method(method.clone()) + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router.oneshot(req).await.expect("should route the request"); + assert_eq!( + resp.status().as_u16(), + 500, + "{method} {path} should answer 500" + ); + } + } } } diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 980a9b2099..b09efcbd8e 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -19,17 +19,18 @@ use worker::{Context, Env, Request, Response, Result, event}; #[event(fetch)] /// Dispatches an incoming Cloudflare Worker fetch event. /// +/// The application is built for the request from the settings in the +/// Worker's config store, then served by `EdgeZero`. +/// /// # Errors /// /// Returns a Workers runtime error when the fallback error response cannot be /// constructed. pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - if let Ok(config) = env.var("TRUSTED_SERVER_CONFIG") { - app::set_cloudflare_config_json(config.to_string()); - } - app::set_cloudflare_env(env.clone()); - - match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await { + let app = app::build_app(&env).await; + match edgezero_adapter_cloudflare::dispatch_app::(&app, req, env, ctx) + .await + { Ok(resp) => Ok(resp), Err(e) => { log::error!("worker dispatch error: {e:?}"); diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index df99052afa..58077685a6 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -194,9 +194,8 @@ mod tests { } fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { - // Build from explicit test settings: the settings baked into the - // binary contain placeholder secrets that `get_settings()` rejects - // by design. + // A Worker reads its settings from its config store, so the test + // builds its own. let mut s = Settings::from_toml( r#" [[handlers]] @@ -215,6 +214,9 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index b946b96301..931e42b775 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -77,18 +77,19 @@ impl PlatformBackend for NoopBackend { // --------------------------------------------------------------------------- // edgezero handle adapters — no #[cfg] needed; platform-specific store -// construction is handled by edgezero's run_app before we receive the ctx. +// construction is handled by EdgeZero's dispatch before we receive the ctx. // --------------------------------------------------------------------------- -/// Bridges edgezero's [`ConfigStoreHandle`] (injected by `run_app` from the -/// `TRUSTED_SERVER_CONFIG` env-var binding) to [`PlatformConfigStore`]. +/// Bridges edgezero's [`ConfigStoreHandle`], which `EdgeZero`'s dispatch +/// registers for a request from the stores the application declares, to +/// [`PlatformConfigStore`]. /// /// Reads delegate through the handle. Writes are unsupported on all current /// adapter targets and return errors. /// -/// Note: Cloudflare config is a single flat JSON env-var binding — all keys -/// live in one namespace. The `store_name` argument is intentionally ignored; -/// callers cannot route to a different store by passing a different name. +/// Note: the handle opens one Workers KV namespace. The `store_name` +/// argument is intentionally ignored; callers cannot route to a different +/// store by passing a different name. struct ConfigStoreHandleAdapter(ConfigStoreHandle); impl PlatformConfigStore for ConfigStoreHandleAdapter { @@ -112,8 +113,8 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } -/// Bridges edgezero's [`KvHandle`] (injected by `run_app` from the -/// `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. +/// Bridges edgezero's [`KvHandle`] (registered by `EdgeZero`'s dispatch from +/// the `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. /// /// Delegates all operations through `KvHandle`'s raw-bytes API, which includes /// key/value validation before forwarding to the underlying store. @@ -122,7 +123,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { /// inside the `KvHandle` that `RuntimeServices::kv_handle()` constructs from /// this adapter. The overhead is negligible (string length checks only) and /// avoided by the fact that we reuse the already-opened `env.kv()` handle from -/// `run_app` rather than opening a new one. +/// the dispatch rather than opening a new one. struct KvHandleAdapter(KvHandle); #[async_trait::async_trait(?Send)] @@ -582,10 +583,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. /// -/// Config and KV are sourced from the edgezero handles that `run_app` injects -/// before routing — via the `TRUSTED_SERVER_CONFIG` env-var binding and the -/// `TRUSTED_SERVER_KV` KV namespace declared in `cloudflare.toml`. No -/// platform-specific `#[cfg]` is required for these two stores. +/// Config and KV are sourced from the edgezero handles that `EdgeZero`'s +/// dispatch registers before routing, from the stores the application +/// declares, and a request with no handle gets a store that answers +/// nothing. No platform-specific `#[cfg]` is required for these two stores. /// /// Secrets still require direct `worker::Env` access because /// `SecretHandle::get_bytes` is async while `PlatformSecretStore::get_bytes` @@ -597,6 +598,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_modules: &Arc< + [Arc], + >, + permission_policy: &Arc, ) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -605,13 +610,14 @@ pub fn build_runtime_services( #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); - // Config: use the ConfigStoreHandle injected by run_app — no #[cfg] needed. + // Config: use the ConfigStoreHandle the dispatch registered, so no + // #[cfg] is needed. let config_store: Arc = ctx .config_store_default() .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) .unwrap_or_else(|| Arc::new(NoopConfigStore)); - // KV: use the KvHandle injected by run_app — no #[cfg] needed. + // KV: use the KvHandle the dispatch registered, so no #[cfg] is needed. let kv_store: Arc = ctx .kv_store_default() .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) @@ -643,6 +649,13 @@ pub fn build_runtime_services( .backend(Arc::new(NoopBackend)) .http_client(http_client) .geo(geo) + // The signal modules were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_modules(Arc::clone(permission_signal_modules)) + // The policy the domain's settings loaded decides the request's + // permissions. + .permission_policy(Arc::clone(permission_policy)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -665,9 +678,10 @@ pub fn build_runtime_services( /// "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 US -/// state privacy rules in `[consent.us_states] privacy_states` are written as -/// two-letter codes and a name would never match one. +/// 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, @@ -830,12 +844,11 @@ mod tests { #[test] fn a_us_state_visitor_reaches_the_us_state_jurisdiction_and_its_opt_out() { // The region header has to reach the privacy outcome. - // `detect_jurisdiction` reaches its US branch only when the country is - // `US` and a region is present. With no region a US visitor falls - // through to `NonRegulated`, where `allows_ec_creation` returns true - // without reading `ctx.gpc`, so a Sec-GPC opt-out would be ignored. - let config = trusted_server_core::consent_config::ConsentConfig::default(); - + // `detect_jurisdiction` reaches a US state node of the policy tree + // only when the country is `US` and a region is present. With no + // region a US visitor falls through to the country node's + // `NonRegulated`, where `allows_ec_creation` returns true without + // reading `ctx.gpc`, so a Sec-GPC opt-out would be ignored. let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US"), ("cf-region-code", "CA")]); let geo = build_geo(&ctx) .lookup(None) @@ -847,8 +860,10 @@ mod tests { "the ISO 3166-2 subdivision code should reach the geo info" ); - let jurisdiction = - trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config); + let jurisdiction = trusted_server_core::consent::jurisdiction::detect_jurisdiction( + trusted_server_core::permissions::PermissionMaps::sample(), + Some(&geo), + ); assert_eq!( jurisdiction, trusted_server_core::consent::jurisdiction::Jurisdiction::UsState("CA".to_owned()), @@ -878,7 +893,10 @@ mod tests { "no region header should mean no region, not an invented one" ); assert_eq!( - trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config), + trusted_server_core::consent::jurisdiction::detect_jurisdiction( + trusted_server_core::permissions::PermissionMaps::sample(), + Some(&geo), + ), trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, "with no region a US visitor cannot be placed in a privacy state" ); diff --git a/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs index e573c97470..4ff88815d6 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs @@ -1,6 +1,37 @@ //! Keep the checked-in Cloudflare configuration aligned with the runtime defaults. -use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; +use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; + +/// The binding of the key-value store's own namespace. +const KV_STORE_BINDING: &str = "TRUSTED_SERVER_KV"; + +const WRANGLER_MANIFESTS: [(&str, &str); 2] = [ + ("wrangler.toml", include_str!("../wrangler.toml")), + ("wrangler.ci.toml", include_str!("../wrangler.ci.toml")), +]; + +/// The KV namespaces a Wrangler manifest declares, as binding and id. +fn kv_namespaces(manifest: &str) -> Vec<(String, String)> { + let manifest: toml::Value = + toml::from_str(manifest).expect("should parse the Wrangler manifest"); + manifest["kv_namespaces"] + .as_array() + .expect("should declare KV namespaces") + .iter() + .map(|namespace| { + ( + namespace["binding"] + .as_str() + .expect("should name a binding") + .to_owned(), + namespace["id"] + .as_str() + .expect("should name an id") + .to_owned(), + ) + }) + .collect() +} #[test] fn cloudflare_manifest_uses_the_runtime_config_store_id() { @@ -12,27 +43,45 @@ fn cloudflare_manifest_uses_the_runtime_config_store_id() { Some(DEFAULT_CONFIG_STORE_ID), "Cloudflare config store should match the manifest-derived runtime default" ); + assert_eq!( + manifest["stores"]["config"]["adapters"]["cloudflare"]["name"].as_str(), + Some(DEFAULT_CONFIG_STORE_ID), + "the config store's binding should be its default platform name" + ); } #[test] -fn wrangler_placeholder_uses_the_runtime_blob_key() { - let manifest: toml::Value = toml::from_str(include_str!("../wrangler.toml")) - .expect("should parse the Wrangler manifest"); - let raw_config = manifest["vars"]["TRUSTED_SERVER_CONFIG"] - .as_str() - .expect("should declare the config JSON binding"); - let config: serde_json::Value = - serde_json::from_str(raw_config).expect("should parse the config JSON placeholder"); - let entries = config - .as_object() - .expect("should contain config properties"); - - assert_eq!(entries.len(), 1, "should declare only the current blob key"); - assert_eq!( - entries - .get(CONFIG_BLOB_KEY) - .and_then(serde_json::Value::as_str), - Some(""), - "placeholder should use the runtime key and remain invalid until seeded" - ); +fn each_wrangler_manifest_binds_the_config_store_as_a_namespace_of_its_own() { + for (name, manifest) in WRANGLER_MANIFESTS { + let namespaces = kv_namespaces(manifest); + let id_of = |binding: &str| { + namespaces + .iter() + .find(|(bound, _)| bound == binding) + .map(|(_, id)| id.clone()) + .unwrap_or_else(|| panic!("{name} should bind `{binding}`: {namespaces:?}")) + }; + + assert_ne!( + id_of(DEFAULT_CONFIG_STORE_ID), + id_of(KV_STORE_BINDING), + "{name} should keep the config store apart from the key-value store" + ); + } +} + +#[test] +fn no_wrangler_manifest_carries_settings_in_a_variable() { + for (name, manifest) in WRANGLER_MANIFESTS { + let manifest: toml::Value = + toml::from_str(manifest).expect("should parse the Wrangler manifest"); + + assert!( + manifest + .get("vars") + .and_then(|vars| vars.get("TRUSTED_SERVER_CONFIG")) + .is_none(), + "{name} should leave the settings to the config store" + ); + } } diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 422ed654e5..4c630134db 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -15,9 +15,10 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// Build the full application router from explicit test settings. /// -/// The settings baked into the binary contain placeholder secrets that -/// `get_settings()` rejects by design, which would turn every route into a -/// startup error page (and its route table into the fallback-only set). +/// A Worker reads its settings from its config store when it builds its +/// application, and `TrustedServerApp::routes()`, which cannot read the store, +/// serves the startup error router on every route (and its route table is the +/// fallback-only set). /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. @@ -40,9 +41,14 @@ fn test_router() -> RouterService { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); TrustedServerApp::routes_with_settings(settings) .expect("should build router from test settings") @@ -70,9 +76,9 @@ fn assert_route_registered(method: &str, path: &str) { } /// Build a router from explicit test settings so routes resolve to their real -/// handlers instead of the `startup_error_router` fallback. The settings baked -/// into the binary carry placeholder secrets that `get_settings()` rejects, -/// which would otherwise turn every route into a startup error page. +/// handlers instead of the `startup_error_router` fallback, which +/// `TrustedServerApp::routes()` serves because it cannot read the config +/// store. fn make_router() -> RouterService { let settings = trusted_server_core::settings::Settings::from_toml( r#" @@ -87,6 +93,9 @@ fn make_router() -> RouterService { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "integration-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] module = "hmac" @@ -94,7 +103,9 @@ fn make_router() -> RouterService { passphrase = "test-secret-key-32-bytes-minimum" "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); TrustedServerApp::routes_with_settings(settings) .expect("should build router from test settings") @@ -102,8 +113,8 @@ fn make_router() -> RouterService { #[test] fn routes_build_without_panic() { - // build_state() may fail (no real settings in CI) — startup_error_router - // is the fallback. Either way, routes() must not panic. + // routes() cannot read the config store, so it serves the startup error + // router, and building it must not panic. let _router = TrustedServerApp::routes(); } @@ -706,6 +717,11 @@ const UNINJECTED_MODULE_TOML: &str = r#" [ec.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction operation + # is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true "#; /// A module selection this adapter can never supply must fail while the diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index 9992db7123..6c05702a1d 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -10,11 +10,13 @@ compatibility_flags = ["nodejs_compat", "cache_option_enabled"] binding = "TRUSTED_SERVER_KV" id = "ci-local-kv" -[vars] -# Placeholder replaced by the integration test harness with a JSON object that -# contains the runtime Trusted Server app-config blob envelope. -TRUSTED_SERVER_CONFIG = "{}" +# The config store, which the integration harness seeds with the settings +# blob before `wrangler dev` starts. +[[kv_namespaces]] +binding = "trusted_server_config" +id = "ci-local-config" +[vars] # Fictitious integration-only secret values. `worker::Env::secret` reads these # string bindings in local Wrangler runs; production values are provisioned with # `wrangler secret put` instead of being committed to a manifest. diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index f3b5ef6aa3..2af0255b7e 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -21,11 +21,15 @@ binding = "TRUSTED_SERVER_KV" # `wrangler dev --local` (Miniflare default) tolerates this placeholder. id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" -[vars] -# TRUSTED_SERVER_CONFIG is required at startup. Replace this intentionally -# invalid placeholder with JSON containing the manifest-default app-config blob -# envelope before deploying or running `wrangler dev` against real traffic. -TRUSTED_SERVER_CONFIG = '{"trusted_server_config":""}' +# The config store. `ts config push --adapter cloudflare` writes the settings +# blob into this namespace and the Worker reads it when it builds its +# application. The binding is the store's platform name, which +# EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME changes, and +# `ts provision --adapter cloudflare` creates the namespace and writes its id +# here. +[[kv_namespaces]] +binding = "trusted_server_config" +id = "REPLACE_WITH_YOUR_CONFIG_KV_NAMESPACE_ID" # App-config secret values are provisioned as Worker secrets with # `wrangler secret put `. The pushed blob contains only those key diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index aedfe6f381..c561e4941e 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -35,6 +35,11 @@ log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-mtm = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-device-fastly = { workspace = true } trusted-server-geo-fastly = { workspace = true } url = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index c387059250..9771f4cd2d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -110,7 +110,6 @@ use trusted_server_core::ec::admin::{ deny_admin_diagnostic_fallback, handle_admin_ec_lookup, handle_admin_eids_lookup, }; use trusted_server_core::ec::batch_sync::handle_batch_sync; -use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -124,8 +123,10 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; +use trusted_server_core::permissions::PermissionState; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, build_geo_module, + ClientInfo, GeoInfo, PlatformKvStore, PolicyOrigin, RuntimeServices, StoreName, + build_geo_module, }; use trusted_server_device_fastly::FastlyHostSignals; @@ -202,6 +203,15 @@ pub(crate) struct AppState { /// rather than resolved again on the request path. /// `None` for a deployment that selects no module. pub(crate) ec_module: Option>, + /// The permission signal modules `[permission-signal] modules` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + pub(crate) permission_signal_modules: + Arc<[Arc]>, + /// The permission policy the domain's settings loaded, which every + /// request is given. + pub(crate) permission_policy: Arc, } /// Build the application state, loading settings and constructing all per-application components. @@ -225,6 +235,7 @@ pub(crate) fn load_settings_from_config_store( &stores.config_store_name, &stores.config_key, &stores.secret_store_name, + PolicyOrigin::Blob, ) } @@ -263,6 +274,12 @@ pub(crate) fn build_state_from_settings( plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; + let permission_policy = settings.required_permission_policy()?; let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); let default_kv_store = Arc::new(UnavailableKvStore) as Arc; @@ -274,9 +291,31 @@ pub(crate) fn build_state_from_settings( default_kv_store, auction_telemetry_sink, ec_module, + permission_signal_modules, + permission_policy, })) } +/// The permission signal modules this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no module of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + fn warn_if_certificate_check_disabled(settings: &Settings) { if !settings.proxy.certificate_check { log::warn!( @@ -345,6 +384,13 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) + // The signal modules were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_modules(Arc::clone(&state.permission_signal_modules)) + // The policy the domain's settings loaded decides the request's + // permissions. + .permission_policy(Arc::clone(&state.permission_policy)) .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); // Hand every request the module resolved at the composition root, so the @@ -490,16 +536,8 @@ fn build_ec_request_state( let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed during EC setup: {e}"); - None - }); - let (ec_context, setup_error) = - match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { + match EcContext::read_from_request_resolving_geo(settings, req, services) { Ok(mut context) => { context.set_device_signals(device_signals); // Orphan-recovery eligibility is intentionally left false here. @@ -513,18 +551,21 @@ fn build_ec_request_state( } Err(report) => (EcContext::default(), Some(report)), }; + let geo_info = ec_context.geo_info().cloned(); // Bot gate: suppress KV-backed EC writes for unrecognized clients, except - // consent withdrawals. Revocations keep the write path so tombstones stay - // authoritative even for privacy-extension-heavy clients. + // when the request carries an explicit withdrawal signal. The write path + // stays open for withdrawal so tombstones remain authoritative even for + // privacy-extension-heavy clients that do not look like known browsers. A + // merely not-permitted (pre-consent or fail-closed) request writes nothing, + // so it does not need the graph. let kv_graph = crate::maybe_identity_graph(settings); - let finalize_kv_graph = if setup_error.is_none() - && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) - { - kv_graph.clone() - } else { - None - }; + let finalize_kv_graph = + if setup_error.is_none() && (is_real_browser || ec_context.storage_withdrawn()) { + kv_graph.clone() + } else { + None + }; let kv_graph = if is_real_browser { kv_graph } else { None }; EcRequestState { @@ -563,11 +604,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 @@ -576,6 +621,7 @@ async fn run_pre_route_filters( services, req, geo_info, + permissions, }) .await { @@ -686,13 +732,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 @@ -875,7 +928,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); @@ -1376,7 +1436,7 @@ impl TrustedServerApp { let mut router = RouterService::builder() .middleware(FinalizeResponseMiddleware::new( Arc::clone(&state.settings), - Arc::new(FastlyPlatformGeo), + build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), )) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); @@ -1585,6 +1645,9 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -1604,6 +1667,9 @@ mod tests { "#, ) .expect("should parse test settings") + // A loaded domain carries its policy, and these tests decide under the + // repository's sample. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) } fn test_router() -> RouterService { @@ -1611,6 +1677,25 @@ mod tests { TrustedServerApp::routes_for_state(&state) } + #[test] + fn each_request_is_given_the_policy_its_domain_loaded() { + let policy = trusted_server_core::test_support::policies::open(); + let state = + build_state_from_settings(test_settings().with_permission_policy(Arc::clone(&policy))) + .expect("should build test state"); + let context = RequestContext::new( + empty_request(Method::GET, "/article"), + PathParams::default(), + ); + + let services = build_per_request_services(&state, &context); + + assert!( + Arc::ptr_eq(services.permission_policy(), &policy), + "the request should decide under its domain's policy, not the sample" + ); + } + #[test] fn per_request_services_register_the_fastly_template_assembler() { let state = build_state_from_settings(test_settings()).expect("should build test state"); @@ -1672,6 +1757,10 @@ mod tests { registry: Arc::new(registry), default_kv_store, ec_module, + // These tests exercise routing, and a request with no signal + // module resolves at the place baseline. + permission_signal_modules: Arc::default(), + permission_policy: trusted_server_core::permissions::PermissionPolicy::sample(), }); TrustedServerApp::routes_for_state(&state) } @@ -1812,6 +1901,28 @@ mod tests { ); } + #[test] + fn the_startup_router_answers_500_to_a_policy_load_error() { + use trusted_server_core::test_support::policy_failure; + + let error = policy_failure::load_error(); + assert!( + format!("{error:?}").contains(&format!("`{}`", policy_failure::POLICY_NAME)), + "the startup error should name the policy: {error:?}" + ); + + let router = startup_error_router(&error); + for method in super::publisher_fallback_methods() { + for path in ["/", "/article"] { + assert_eq!( + route(&router, empty_request(method.clone(), path)).status(), + StatusCode::INTERNAL_SERVER_ERROR, + "{method} {path} should answer 500" + ); + } + } + } + #[test] fn dynamic_tsjs_fallback_is_get_only() { assert!( @@ -2094,9 +2205,13 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse production-shaped settings"); + .expect("should parse production-shaped settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); let state = build_state_from_settings(settings).expect("should build state"); let router = TrustedServerApp::routes_for_state(&state); @@ -2772,6 +2887,9 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -2784,7 +2902,8 @@ mod tests { origin_url = "https://assets.example.com" "#, ) - .expect("should parse asset-route settings"); + .expect("should parse asset-route settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); let state = build_state_from_settings(settings).expect("should build state"); let router = TrustedServerApp::routes_for_state(&state); @@ -2875,6 +2994,7 @@ mod tests { .http_client(Arc::new(StreamingHttpClient)) .geo(Arc::new(crate::platform::FastlyPlatformGeo)) .client_info(ClientInfo::default()) + .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build() } @@ -3041,6 +3161,12 @@ mod tests { [ec] passphrase = "test-secret-key-32-bytes-minimum" + # The deprecated passphrase migrates to the hmac module, so + # single-jurisdiction operation is acknowledged because no + # geo module is selected. + [geo] + assume_single_jurisdiction = true + [auction] enabled = true providers = {} @@ -3069,6 +3195,7 @@ mod tests { .http_client(Arc::clone(&origin) as Arc) .geo(Arc::new(crate::platform::FastlyPlatformGeo)) .client_info(ClientInfo::default()) + .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build(); let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); let registry = Arc::new( @@ -3202,6 +3329,9 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] enabled = false config_store_id = "test-config-store-id" @@ -3214,7 +3344,8 @@ mod tests { origin_url = "https://assets.example.com" "#, ) - .expect("should parse asset-route settings"); + .expect("should parse asset-route settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); let state = build_state_from_settings(settings).expect("should build state"); let services = streaming_runtime_services(); diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 5da109fb19..8f7f920726 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -925,6 +925,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] module = "hmac" @@ -1156,6 +1159,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 0924f083a8..03e2be94a6 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -342,6 +342,9 @@ mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] module = "hmac" diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index db825427a1..fb11c8d055 100644 --- a/crates/trusted-server-adapter-fastly/src/sandbox.rs +++ b/crates/trusted-server-adapter-fastly/src/sandbox.rs @@ -494,11 +494,16 @@ mod tests { origin_url = "https://origin.test-publisher.example" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] passphrase = "test-secret-key-32-bytes-minimum" "#, ) .expect("should parse sandbox test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) } /// A real `AppState`, so retention is exercised against the type the diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 504dbd9fba..769a4acbf7 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -425,6 +425,7 @@ mod tests { .http_client(http_client) .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) + .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) .build() } diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 81eeb94307..93f891ecff 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -32,6 +32,11 @@ futures = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-permission-signal-gpc = { workspace = true } +trusted-server-permission-signal-gpp = { workspace = true } +trusted-server-permission-signal-mtm = { workspace = true } +trusted-server-permission-signal-tcf = { workspace = true } +trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-js = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/trusted-server-adapter-spin/README.md b/crates/trusted-server-adapter-spin/README.md index fe33c90557..1c9adbb844 100644 --- a/crates/trusted-server-adapter-spin/README.md +++ b/crates/trusted-server-adapter-spin/README.md @@ -5,10 +5,11 @@ Experimental Fermyon Spin adapter for Trusted Server. The component targets platform tests. The crate maps Spin HTTP, variables, and the `default` key-value store into the -shared runtime. Startup failures install a restricted 503 router that keeps -`/health` live. The adapter permits one enabled auction provider. Request-time -KV services beyond startup config loading remain unavailable. Its entry point -uses `anyhow::Result` only because the EdgeZero Spin FFI requires that type. +shared runtime. Startup failures install a restricted router that answers with +the status of the error's kind and keeps `/health` live. The adapter permits +one enabled auction provider. Request-time KV services beyond startup config +loading remain unavailable. Its entry point uses `anyhow::Result` only because +the EdgeZero Spin FFI requires that type. Build and test from the repository root: diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index ce40eb5931..1446b7ee05 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -77,6 +77,15 @@ pub struct AppState { /// rather than resolved again on the request path. /// `None` for a deployment that selects no module. ec_module: Option>, + /// The permission signal modules `[permission-signal] modules` selects + /// from the scheme crates this adapter links, in the order they run. + /// Selected once here so a name no crate answers to fails startup rather + /// than the first request, and handed to every request's services. + permission_signal_modules: + Arc<[Arc]>, + /// The permission policy the domain's settings loaded, which every + /// request is given. + permission_policy: Arc, /// Services a caller supplied for every request, rather than services built /// from the request context. `None` in a deployment. services: Option, @@ -121,7 +130,12 @@ fn load_startup_settings() -> Result> { .attach(error.to_string()) })?; let secret_store = SpinSecretStoreAdapter; - settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) + settings_from_config_blob( + &raw_envelope, + &secret_store, + &default_secret_store_name(), + trusted_server_core::platform::PolicyOrigin::Blob, + ) } #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] @@ -167,27 +181,64 @@ fn build_state_with_services( plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let permission_signal_modules = + trusted_server_core::permission_signal::build_permission_signal_modules( + &settings, + &shipped_signal_modules(), + )?; + let permission_policy = settings.required_permission_policy()?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), ec_module, + permission_signal_modules, + permission_policy, services, })) } +/// The permission signal modules this adapter links, in the order they run +/// when configuration names none. Global Privacy Control is first because it +/// is a browser setting with no interface of its own, and the three that +/// carry a choice someone made through an interface follow, so an answer +/// given at a prompt amends the header the visitor arrived with. +/// +/// Core supplies no module of its own, so this is where a deployment's +/// schemes are decided. A scheme is added by linking its crate here, and a +/// scheme core has never heard of plugs in the same way. +fn shipped_signal_modules() +-> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + Arc::new(trusted_server_permission_signal_mtm::MtmModule::new()), + ] +} + impl AppState { /// Builds the per-request services, carrying the Edge Cookie module the /// composition root already resolved so the request path does not resolve - /// `[ec] module` a second time. - /// Nothing is carried when the composition root found nothing safe to keep, - /// and the request path resolves for itself. + /// `[ec] module` a second time. Nothing is carried when the composition + /// root found nothing safe to keep, and the request path resolves for + /// itself. The services also carry the permission policy the domain's + /// settings loaded, whichever services the request is given. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx, &self.settings)) + .unwrap_or_else(|| { + build_runtime_services( + ctx, + &self.settings, + &self.permission_signal_modules, + &self.permission_policy, + ) + }) .with_resolved_ec_module(self.ec_module.clone()) + .with_permission_policy(Arc::clone(&self.permission_policy)) } } @@ -448,9 +499,13 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Spin's platform geo is a no-op, so jurisdiction stays Unknown unless the -/// request carries TCF consent, and a geo lookup failure is logged and treated -/// as no location. +/// The geo lookup runs inside +/// [`EcContext::read_from_request_resolving_geo`], so every adapter reports the +/// same distinction: no location falls back to the 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 @@ -468,14 +523,7 @@ fn build_ec_context( services: &RuntimeServices, req: &Request, ) -> Result> { - let geo_info = services - .geo() - .lookup(services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + EcContext::read_from_request_resolving_geo(settings, req, services) } fn cache_purge_not_supported() -> Response { @@ -550,16 +598,20 @@ fn legacy_admin_alias_denied() -> Response { // Startup error fallback // --------------------------------------------------------------------------- -/// Returns a [`RouterService`] that responds to every route with a generic -/// 503 Service Unavailable. The startup error is logged but not echoed in the -/// response body so that deployment state is not leaked to anonymous callers. +/// Returns a [`RouterService`] that responds to every route with the status +/// of the startup error's kind, as the other adapters do: 500 for a +/// configuration or settings error and 503 for a key-value store error. The +/// startup error is logged but not echoed in the response body so that +/// deployment state is not leaked to anonymous callers. fn startup_error_router(e: &Report) -> RouterService { log::error!("startup failed, serving error fallback: {:?}", e); - let handler = |_ctx: RequestContext| { - let body = edgezero_core::body::Body::from("Service Unavailable\n"); + let status = e.current_context().status_code(); + let reason = status.canonical_reason().unwrap_or("Error"); + let handler = move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from(format!("{reason}\n")); let mut resp = Response::new(body); - *resp.status_mut() = StatusCode::SERVICE_UNAVAILABLE; + *resp.status_mut() = status; resp.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"), @@ -569,8 +621,8 @@ fn startup_error_router(e: &Report) -> RouterService { // Cover the full publisher fallback method set (GET, POST, HEAD, OPTIONS, // PUT, PATCH, DELETE) so degraded behaviour stays consistent with the - // healthy router: every method on `/` and `/{*rest}` returns the generic - // 503 instead of a router-level 405 for HEAD/OPTIONS/PATCH. + // healthy router: every method on `/` and `/{*rest}` returns the startup + // error's status instead of a router-level 405 for HEAD/OPTIONS/PATCH. let mut builder = RouterService::builder().middleware(FinalizeResponseMiddleware::new( Arc::new(Settings::default()), )); @@ -615,8 +667,8 @@ impl TrustedServerApp { /// Build the full application router from explicit settings. /// /// Testing seam: cross-adapter parity tests use this to drive the router - /// with known-good settings instead of the baked `get_settings()` result, - /// whose embedded placeholder secrets fail validation by design. + /// with settings they build, where the component reads its settings from + /// its config store when it starts. /// /// # Errors /// @@ -1061,9 +1113,13 @@ mod tests { [ec] passphrase = "fictional-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse startup test settings"); + .expect("should parse startup test settings") + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); settings.auction.enabled = true; settings.auction.providers = std::iter::IntoIterator::into_iter(["provider-a", "provider-b"]) @@ -1088,6 +1144,27 @@ mod tests { settings } + #[test] + fn each_request_is_given_the_policy_its_domain_loaded() { + let policy = trusted_server_core::test_support::policies::open(); + let mut settings = multi_provider_settings(); + settings.auction.enabled = false; + let state = build_state_with_settings(settings.with_permission_policy(Arc::clone(&policy))) + .expect("should build the state"); + let req = request_builder() + .method("GET") + .uri("https://publisher.example/article") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + + let services = state.services_for_request(&RequestContext::new(req, PathParams::default())); + + assert!( + Arc::ptr_eq(services.permission_policy(), &policy), + "the request should decide under its domain's policy, not the sample" + ); + } + #[test] fn startup_registers_aps_renderer_route() { let mut settings = multi_provider_settings(); @@ -1182,6 +1259,11 @@ mod tests { [ec.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction + # operation is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true "#; /// The per-request Edge Cookie read must return its error rather than a @@ -1206,7 +1288,12 @@ mod tests { // No resolved module is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx, &settings); + let services = build_runtime_services( + &ctx, + &settings, + &Arc::default(), + &trusted_server_core::permissions::PermissionPolicy::sample(), + ); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) @@ -1485,33 +1572,78 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn startup_error_router_serves_503_for_all_fallback_methods() { + async fn startup_error_router_answers_the_error_kinds_status_for_all_fallback_methods() { // The degraded router must answer every publisher-fallback method // (including HEAD/OPTIONS/PATCH) on both "/" and nested paths with the - // generic 503, never a router-level 405, so startup-failure behaviour - // stays consistent with the healthy router. - let report = Report::new(TrustedServerError::BadRequest { - message: "startup failure".to_string(), + // status of the startup error's kind, never a router-level 405, so + // startup-failure behaviour stays consistent with the healthy router. + let configuration = Report::new(TrustedServerError::Configuration { + message: "the configuration names no permission policy".to_string(), }); - let router = startup_error_router(&report); + let kv_store = Report::new(TrustedServerError::KvStore { + store_name: "trusted_server_kv".to_string(), + message: "unavailable".to_string(), + }); + + for (report, expected, reason) in [ + (configuration, 500, "Internal Server Error\n"), + (kv_store, 503, "Service Unavailable\n"), + ] { + let router = startup_error_router(&report); + for method in ["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { + for path in ["/", "/some/nested/page"] { + let req = edgezero_core::http::request_builder() + .method(method) + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = router + .oneshot(req) + .await + .expect("should route startup-error request"); + let status = resp.status().as_u16(); + assert_eq!( + status, expected, + "{method} {path} must return {expected} from the startup fallback, got {status}" + ); + if method != "HEAD" { + let body = resp.into_body().into_bytes().unwrap_or_default(); + assert_eq!( + &body[..], + reason.as_bytes(), + "{method} {path} should answer the status's reason and not the error" + ); + } + } + } + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_startup_router_answers_500_to_a_policy_load_error() { + use trusted_server_core::test_support::policy_failure; - for method in ["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { - for path in ["/", "/some/nested/page"] { + let error = policy_failure::load_error(); + assert!( + format!("{error:?}").contains(&format!("`{}`", policy_failure::POLICY_NAME)), + "the startup error should name the policy: {error:?}" + ); + + let router = startup_error_router(&error); + for method in publisher_fallback_methods() { + for path in ["/", "/article"] { let req = edgezero_core::http::request_builder() - .method(method) + .method(method.clone()) .uri(path) .body(edgezero_core::body::Body::empty()) .expect("should build request"); let status = router .oneshot(req) .await - .expect("should route startup-error request") + .expect("should route the request") .status() .as_u16(); - assert_eq!( - status, 503, - "{method} {path} must return 503 from the startup fallback, got {status}" - ); + assert_eq!(status, 500, "{method} {path} should answer 500"); } } } diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index fd57a42a4f..6deb3cdb99 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -221,9 +221,8 @@ mod tests { } fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { - // Build from explicit test settings: the settings baked into the - // binary contain placeholder secrets that `get_settings()` rejects - // by design. + // The component reads its settings from its config store, so the + // test builds its own. let mut s = Settings::from_toml( r#" [[handlers]] @@ -242,6 +241,9 @@ mod tests { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should load test settings"); diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index cec9732c25..4c31f4ffeb 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -707,6 +707,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, + permission_signal_modules: &Arc< + [Arc], + >, + permission_policy: &Arc, ) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -743,6 +747,13 @@ pub fn build_runtime_services( settings, Arc::new(NullGeo), )) + // The signal modules were selected once at startup from the scheme + // crates this adapter links, so every request asks exactly the ones + // configuration named, in that order. + .permission_signal_modules(Arc::clone(permission_signal_modules)) + // The policy the domain's settings loaded decides the request's + // permissions. + .permission_policy(Arc::clone(permission_policy)) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1049,8 +1060,12 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = - build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); + let services = build_runtime_services( + &ctx, + &trusted_server_core::settings::Settings::default(), + &Arc::default(), + &trusted_server_core::permissions::PermissionPolicy::sample(), + ); 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 ab33bc16b0..dccd6f63f4 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -14,9 +14,10 @@ use trusted_server_core::settings::Settings; /// Build the full application router from explicit test settings. /// -/// The settings baked into the binary contain placeholder secrets that -/// `get_settings()` rejects by design, which would turn every route into a -/// startup error page (and its route table into the fallback-only set). +/// The component reads its settings from its config store when it starts, +/// which a test does not have, so `TrustedServerApp::routes()` would turn +/// every route into a startup error page (and its route table into the +/// fallback-only set). /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. @@ -39,9 +40,14 @@ fn test_router() -> RouterService { [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) - .expect("should parse route test settings"); + .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()); TrustedServerApp::routes_with_settings(settings) .expect("should build router from test settings") @@ -1001,6 +1007,11 @@ const UNINJECTED_MODULE_TOML: &str = r#" [ec.acme] endpoint = "https://ec.acme.example.com" + + # An Edge Cookie module is configured, so single-jurisdiction operation + # is acknowledged because no geo module is selected. + [geo] + assume_single_jurisdiction = true "#; /// A module selection this adapter can never supply must fail while the diff --git a/crates/trusted-server-cli/src/app_config.rs b/crates/trusted-server-cli/src/app_config.rs index bee536146b..630a1b7862 100644 --- a/crates/trusted-server-cli/src/app_config.rs +++ b/crates/trusted-server-cli/src/app_config.rs @@ -91,32 +91,58 @@ pub fn resolve_app_config_file(args: &AppConfigArgs) -> Result Ok(resolve_app_config_path(None, &args.manifest, &app_name)) } -fn load_settings_with_env_overlay( - args: &AppConfigArgs, - env_overlay: bool, -) -> Result { - let manifest_loader = ManifestLoader::from_path(&args.manifest) - .map_err(|err| format!("failed to load {}: {err}", args.manifest.display()))?; +/// The app-config file a command reads and the app name its environment +/// overlay is read under. +#[derive(Debug)] +pub(crate) struct ResolvedAppConfig { + /// The `trusted-server.toml` path. + pub(crate) path: PathBuf, + /// The manifest's `[app].name`. + pub(crate) app_name: String, +} + +/// Resolves the app-config file `app_config` names, or `.toml` +/// beside `manifest`, and the manifest's app name. +/// +/// # Errors +/// +/// Returns a user-facing string when the manifest cannot be loaded or has no +/// `[app].name`. +pub(crate) fn resolve_app_config( + manifest: &Path, + app_config: Option<&Path>, +) -> Result { + let manifest_loader = ManifestLoader::from_path(manifest) + .map_err(|err| format!("failed to load {}: {err}", manifest.display()))?; let app_name = manifest_loader.manifest().app.name.clone().ok_or_else(|| { format!( "{} has no [app].name; cannot resolve trusted-server.toml", - args.manifest.display() + manifest.display() ) })?; - let app_config_path = - resolve_app_config_path(args.app_config.as_deref(), &args.manifest, &app_name); + Ok(ResolvedAppConfig { + path: resolve_app_config_path(app_config, manifest, &app_name), + app_name, + }) +} + +fn load_settings_with_env_overlay( + args: &AppConfigArgs, + env_overlay: bool, +) -> Result { + let resolved = resolve_app_config(&args.manifest, args.app_config.as_deref())?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = env_overlay; let app_config = app_config::deserialize_app_config_with_options::( - &app_config_path, - &app_name, + &resolved.path, + &resolved.app_name, &opts, ) - .map_err(|err| format!("failed to load {}: {err}", app_config_path.display()))?; + .map_err(|err| format!("failed to load {}: {err}", resolved.path.display()))?; Ok(LoadedSettings { - app_config_path, + app_config_path: resolved.path, settings: app_config.into_settings(), }) } diff --git a/crates/trusted-server-cli/src/commands/config/mod.rs b/crates/trusted-server-cli/src/commands/config/mod.rs index 77af7de851..b47238bc6d 100644 --- a/crates/trusted-server-cli/src/commands/config/mod.rs +++ b/crates/trusted-server-cli/src/commands/config/mod.rs @@ -1,2 +1,3 @@ pub mod ad_templates; pub mod init; +pub mod policy; diff --git a/crates/trusted-server-cli/src/commands/config/policy.rs b/crates/trusted-server-cli/src/commands/config/policy.rs new file mode 100644 index 0000000000..11e6cde13b --- /dev/null +++ b/crates/trusted-server-cli/src/commands/config/policy.rs @@ -0,0 +1,852 @@ +//! The permission policy files `ts config push`, `ts config validate` and +//! `ts config diff` take with `--permissions`. +//! +//! A domain's settings name the policy file they run in +//! `[permissions] policy`, by its stem. These commands check the named file +//! and write its SHA-256 into `[permissions] sha256`, and for the adapters +//! that read the policy from the blob also the file itself into +//! `[permissions] text`, before `EdgeZero` builds the envelope. `EdgeZero` +//! reads the settings from a path, so they reach it through a temporary file +//! under the system temp folder, loaded with the `TRUSTED_SERVER__` overlay +//! already applied and handed over with `no_env`, so the overlay is applied +//! once and the digest is taken after it. + +use std::ffi::OsStr; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use edgezero_cli::args::{ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs}; +use edgezero_core::app::AXUM_ADAPTER; +use edgezero_core::app_config::{self, AppConfigLoadOptions}; +use tempfile::TempPath; +use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::permissions::{PermissionMaps, policy_sha256}; +use trusted_server_core::settings::{PermissionsConfig, Settings}; + +use crate::app_config::{ResolvedAppConfig, resolve_app_config}; + +/// The extension a permission policy file carries. +const POLICY_FILE_EXTENSION: &str = "yaml"; + +/// The settings table that names the policy. +const PERMISSIONS_TABLE: &str = "permissions"; + +/// The policy file's SHA-256, which only the push writes. +const SHA256_KEY: &str = "sha256"; + +/// The policy file itself, which only the push writes. +const TEXT_KEY: &str = "text"; + +/// What `ts config validate` says when it is given no policy file. +const POLICY_NOT_CHECKED: &str = + "the policy was not checked, because no --permissions file was given"; + +/// The `--permissions` flag. +#[derive(Debug, Default, clap::Args)] +pub struct PolicyFileArgs { + /// A permission policy file, which the settings name by its stem in + /// `[permissions] policy`. Repeat for each file. + #[arg(long = "permissions", value_name = "FILE")] + pub files: Vec, +} + +/// The arguments of `ts config push`. +#[derive(Debug, clap::Args)] +pub struct PushArgs { + #[command(flatten)] + pub edgezero: ConfigPushArgs, + #[command(flatten)] + pub policy: PolicyFileArgs, +} + +/// The arguments of `ts config validate`. +#[derive(Debug, clap::Args)] +pub struct ValidateArgs { + #[command(flatten)] + pub edgezero: ConfigValidateArgs, + #[command(flatten)] + pub policy: PolicyFileArgs, +} + +/// The arguments of `ts config diff`. +#[derive(Debug, clap::Args)] +pub struct DiffArgs { + #[command(flatten)] + pub edgezero: ConfigDiffArgs, + #[command(flatten)] + pub policy: PolicyFileArgs, +} + +/// Pushes the settings with the policy file they name, which the push +/// always requires. +/// +/// # Errors +/// +/// Returns a user-facing string when the policy check or `EdgeZero`'s push +/// fails. +pub fn run_push(args: PushArgs) -> Result<(), String> { + run_push_with( + args, + edgezero_cli::run_config_push_typed::, + ) +} + +/// [`run_push`] with `EdgeZero`'s push passed in, so a test can read the +/// settings it is handed. +fn run_push_with( + args: PushArgs, + push: impl FnOnce(&ConfigPushArgs) -> Result<(), String>, +) -> Result<(), String> { + let mut edgezero = args.edgezero; + let prepared = prepare( + &edgezero.manifest, + edgezero.app_config.as_deref(), + edgezero.no_env, + &args.policy.files, + PolicyText::for_adapter(&edgezero.adapter), + )?; + edgezero.app_config = Some(prepared.file.to_path_buf()); + edgezero.no_env = true; + push(&edgezero)?; + log::info!( + "permissions: policy `{}`, sha256 {}", + prepared.policy, + prepared.sha256 + ); + Ok(()) +} + +/// Validates the settings, and with `--permissions` the policy file they +/// name. +/// +/// # Errors +/// +/// Returns a user-facing string when the policy check or `EdgeZero`'s +/// validation fails. +pub fn run_validate(args: ValidateArgs) -> Result<(), String> { + run_validate_with( + args, + edgezero_cli::run_config_validate_typed::, + ) +} + +/// [`run_validate`] with `EdgeZero`'s validation passed in, so a test can +/// read the settings it is handed. +fn run_validate_with( + args: ValidateArgs, + validate: impl FnOnce(&ConfigValidateArgs) -> Result<(), String>, +) -> Result<(), String> { + let mut edgezero = args.edgezero; + if args.policy.files.is_empty() { + load_settings( + &edgezero.manifest, + edgezero.app_config.as_deref(), + edgezero.no_env, + )?; + validate(&edgezero)?; + log::warn!("permissions: {POLICY_NOT_CHECKED}"); + return Ok(()); + } + // Validation writes nothing, so the file itself is left out. + let prepared = prepare( + &edgezero.manifest, + edgezero.app_config.as_deref(), + edgezero.no_env, + &args.policy.files, + PolicyText::Omitted, + )?; + edgezero.app_config = Some(prepared.file.to_path_buf()); + edgezero.no_env = true; + validate(&edgezero)?; + log::info!( + "permissions: policy `{}` checked, sha256 {}", + prepared.policy, + prepared.sha256 + ); + Ok(()) +} + +/// Diffs the settings, as the push would write them, against the store. +/// +/// The stored settings carry the policy file's SHA-256, and on every adapter +/// but Axum the file itself, so settings that name a policy are compared +/// only with the file `--permissions` supplies. +/// +/// # Errors +/// +/// Returns a user-facing string when the settings name a policy and no +/// `--permissions` file is given, or when the policy check or `EdgeZero`'s +/// diff fails. +pub fn run_diff(args: DiffArgs) -> Result { + let mut edgezero = args.edgezero; + if args.policy.files.is_empty() { + let loaded = load_settings( + &edgezero.manifest, + edgezero.app_config.as_deref(), + edgezero.no_env, + )?; + if let Some(policy) = loaded.settings.permissions.policy.as_deref() { + return Err(format!( + "the settings name the permission policy `{policy}`, and a push writes \ + that file's SHA-256 into the stored settings, so `ts config diff` needs \ + the file to compare them. Supply it with --permissions " + )); + } + let exit = edgezero_cli::run_config_diff_typed::(&edgezero)?; + log::warn!( + "permissions: the settings name no permission policy, which `ts config push` refuses" + ); + return Ok(exit); + } + let prepared = prepare( + &edgezero.manifest, + edgezero.app_config.as_deref(), + edgezero.no_env, + &args.policy.files, + PolicyText::for_adapter(&edgezero.adapter), + )?; + edgezero.app_config = Some(prepared.file.to_path_buf()); + edgezero.no_env = true; + edgezero_cli::run_config_diff_typed::(&edgezero) +} + +/// Whether the settings handed to `EdgeZero` carry the policy file itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PolicyText { + /// For the adapters that read the policy from the blob. + Included, + /// For Axum, which reads the file from its folder, and for validation. + Omitted, +} + +impl PolicyText { + fn for_adapter(adapter: &str) -> Self { + if adapter.eq_ignore_ascii_case(AXUM_ADAPTER) { + Self::Omitted + } else { + Self::Included + } + } +} + +/// The settings `EdgeZero` is handed, with the policy they name. +#[derive(Debug)] +struct PreparedSettings { + /// The settings file, closed so `EdgeZero` can read it, and removed + /// when this is dropped. + file: TempPath, + /// The policy's name. + policy: String, + /// The SHA-256 of the policy file. + sha256: String, +} + +/// A policy file `--permissions` supplied, read and checked. +#[derive(Debug)] +struct PolicyFile { + name: String, + path: PathBuf, + text: String, +} + +/// The settings a command reads, loaded with the overlay unless `no_env` is +/// set. +struct LoadedSettings { + resolved: ResolvedAppConfig, + options: AppConfigLoadOptions, + settings: Settings, +} + +/// Loads the settings with the `TRUSTED_SERVER__` overlay unless `no_env` is +/// set, and refuses settings that set what only the push writes. +fn load_settings( + manifest: &Path, + app_config: Option<&Path>, + no_env: bool, +) -> Result { + let resolved = resolve_app_config(manifest, app_config)?; + let mut options = AppConfigLoadOptions::default(); + options.env_overlay = !no_env; + let settings = app_config::deserialize_app_config_with_options::( + &resolved.path, + &resolved.app_name, + &options, + ) + .map_err(|error| format!("failed to load {}: {error}", resolved.path.display()))? + .into_settings(); + refuse_fields_the_push_writes(&settings.permissions)?; + Ok(LoadedSettings { + resolved, + options, + settings, + }) +} + +/// Loads the settings, checks the policy files, and writes the settings with +/// the named file's SHA-256, and its text when `text` asks for it, to a +/// temporary file. +fn prepare( + manifest: &Path, + app_config: Option<&Path>, + no_env: bool, + files: &[PathBuf], + text: PolicyText, +) -> Result { + let LoadedSettings { + resolved, + options, + settings, + } = load_settings(manifest, app_config, no_env)?; + let mut document = + app_config::load_app_config_raw_with_options(&resolved.path, &resolved.app_name, &options) + .map_err(|error| format!("failed to load {}: {error}", resolved.path.display()))?; + + let files = read_policy_files(files)?; + let policy = named_policy(&settings.permissions, &files)?; + let sha256 = policy_sha256(&policy.text); + + let table = document + .get_mut(PERMISSIONS_TABLE) + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| { + format!( + "{} names a policy outside a `[{PERMISSIONS_TABLE}]` table", + resolved.path.display() + ) + })?; + table.insert(SHA256_KEY.to_owned(), toml::Value::String(sha256.clone())); + if text == PolicyText::Included { + table.insert( + TEXT_KEY.to_owned(), + toml::Value::String(policy.text.clone()), + ); + } + + Ok(PreparedSettings { + file: write_settings(&document)?, + policy: policy.name.clone(), + sha256, + }) +} + +/// Refuses settings that set what only the push writes. +fn refuse_fields_the_push_writes(permissions: &PermissionsConfig) -> Result<(), String> { + for (key, set) in [ + (SHA256_KEY, permissions.sha256.is_some()), + (TEXT_KEY, permissions.text.is_some()), + ] { + if set { + return Err(format!( + "`[{PERMISSIONS_TABLE}] {key}` is written by `ts config push` from the file \ + --permissions supplies, so neither the settings nor the TRUSTED_SERVER__ \ + environment overlay may set it" + )); + } + } + Ok(()) +} + +/// Reads every file `--permissions` supplied and checks that it is a valid +/// policy, whether or not the settings name it. +fn read_policy_files(paths: &[PathBuf]) -> Result, String> { + let mut files: Vec = Vec::with_capacity(paths.len()); + for path in paths { + let name = policy_name(path)?; + let bytes = fs::read(path).map_err(|error| { + format!( + "failed to read the permission policy file {}: {error}", + path.display() + ) + })?; + let text = String::from_utf8(bytes).map_err(|_| { + format!( + "the permission policy file {} is not UTF-8 text", + path.display() + ) + })?; + PermissionMaps::from_yaml(&text).map_err(|error| { + format!( + "the permission policy file {} is not valid: {error}", + path.display() + ) + })?; + if let Some(other) = files.iter().find(|file| file.name == name) { + return Err(format!( + "the permission policy files {} and {} both supply the policy `{name}`", + other.path.display(), + path.display() + )); + } + files.push(PolicyFile { + name, + path: path.clone(), + text, + }); + } + Ok(files) +} + +/// The policy a file supplies, which is its stem. +fn policy_name(path: &Path) -> Result { + if path.extension().and_then(OsStr::to_str) != Some(POLICY_FILE_EXTENSION) { + return Err(format!( + "the permission policy file {} must end `.{POLICY_FILE_EXTENSION}`", + path.display() + )); + } + path.file_stem() + .and_then(OsStr::to_str) + .map(str::to_owned) + .ok_or_else(|| { + format!( + "the permission policy file {} has no name before `.{POLICY_FILE_EXTENSION}`", + path.display() + ) + }) +} + +/// The supplied file the settings name. +fn named_policy<'a>( + permissions: &PermissionsConfig, + files: &'a [PolicyFile], +) -> Result<&'a PolicyFile, String> { + let Some(policy) = permissions.policy.as_deref() else { + return Err(format!( + "the settings name no permission policy, so name the publisher's policy file \ + by its stem in `[{PERMISSIONS_TABLE}] policy` and supply the file with \ + --permissions " + )); + }; + files + .iter() + .find(|file| file.name == policy) + .ok_or_else(|| { + let supplied: Vec<&str> = files.iter().map(|file| file.name.as_str()).collect(); + format!( + "the settings name the permission policy `{policy}`, which no --permissions \ + file supplies (supplied: {})", + if supplied.is_empty() { + "none".to_owned() + } else { + supplied.join(", ") + } + ) + }) +} + +/// Writes `document` to a temporary file under the system temp folder. +fn write_settings(document: &toml::Value) -> Result { + let rendered = toml::to_string(document) + .map_err(|error| format!("failed to write the settings for EdgeZero: {error}"))?; + let mut file = tempfile::Builder::new() + .prefix("trusted-server-") + .suffix(".toml") + .tempfile() + .map_err(|error| format!("failed to create a temporary settings file: {error}"))?; + file.write_all(rendered.as_bytes()) + .and_then(|()| file.flush()) + .map_err(|error| format!("failed to write a temporary settings file: {error}"))?; + Ok(file.into_temp_path()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::commands::config::init::EXAMPLE_CONFIG; + + /// A valid policy whose top node grants everything. + const OPEN_POLICY: &str = "groups:\n open:\n default: granted\nrules:\n group: open\n jurisdiction: non-regulated\n"; + + /// A valid policy whose top node grants nothing without a signal. + const STRICT_POLICY: &str = "groups:\n strict:\n default: requires_signal\nrules:\n group: strict\n jurisdiction: gdpr\n"; + + /// Settings that name the policy `standard`. + const NAMES_STANDARD: &str = "[permissions]\npolicy = \"standard\"\n"; + + /// A folder holding a manifest whose app is `app_name` and the example + /// settings with `permissions` appended. + struct Project { + folder: TempDir, + } + + impl Project { + fn new(app_name: &str, permissions: &str) -> Self { + let folder = tempfile::tempdir().expect("should create a project folder"); + fs::write( + folder.path().join("edgezero.toml"), + format!("[app]\nname = \"{app_name}\"\n"), + ) + .expect("should write the manifest"); + fs::write( + folder.path().join("trusted-server.toml"), + format!("{EXAMPLE_CONFIG}\n{permissions}"), + ) + .expect("should write the settings"); + Self { folder } + } + + fn policy_file(&self, file_name: &str, bytes: &[u8]) -> PathBuf { + let path = self.folder.path().join(file_name); + fs::write(&path, bytes).expect("should write a policy file"); + path + } + + fn manifest(&self) -> PathBuf { + self.folder.path().join("edgezero.toml") + } + + fn settings(&self) -> PathBuf { + self.folder.path().join("trusted-server.toml") + } + + fn prepare( + &self, + files: &[PathBuf], + no_env: bool, + text: PolicyText, + ) -> Result { + prepare( + &self.manifest(), + Some(&self.settings()), + no_env, + files, + text, + ) + } + } + + /// The `[permissions]` table of the settings file at `path`. + fn read_permissions(path: Option<&Path>) -> toml::Table { + let path = path.expect("should hand EdgeZero a settings file"); + let document: toml::Table = + toml::from_str(&fs::read_to_string(path).expect("should read the handed settings")) + .expect("should parse the handed settings"); + document[PERMISSIONS_TABLE] + .as_table() + .expect("should carry the permissions table") + .clone() + } + + /// The `[permissions]` table of the settings `EdgeZero` is handed. + fn handed_permissions(prepared: &PreparedSettings) -> toml::Table { + read_permissions(Some(&prepared.file)) + } + + #[test] + fn the_push_writes_the_digest_and_the_exact_text_of_the_named_file() { + let project = Project::new("trusted-server", NAMES_STANDARD); + // Line endings a checkout could convert, so the bytes must survive. + let text = OPEN_POLICY.replace('\n', "\r\n"); + let standard = project.policy_file("standard.yaml", text.as_bytes()); + + let prepared = project + .prepare(&[standard], true, PolicyText::Included) + .expect("should prepare the settings"); + + let permissions = handed_permissions(&prepared); + assert_eq!(prepared.policy, "standard"); + assert_eq!(prepared.sha256, policy_sha256(&text)); + assert_eq!( + permissions[SHA256_KEY].as_str(), + Some(policy_sha256(&text).as_str()), + "should write the SHA-256 of the file's exact bytes" + ); + assert_eq!( + permissions[TEXT_KEY].as_str(), + Some(text.as_str()), + "should write the file byte for byte" + ); + assert_eq!(permissions["policy"].as_str(), Some("standard")); + } + + #[test] + fn axum_and_validation_leave_the_text_out() { + let project = Project::new("trusted-server", NAMES_STANDARD); + let standard = project.policy_file("standard.yaml", OPEN_POLICY.as_bytes()); + + let mut push = PushArgs { + edgezero: ConfigPushArgs::default(), + policy: PolicyFileArgs { + files: vec![standard.clone()], + }, + }; + push.edgezero.adapter = "axum".to_owned(); + push.edgezero.manifest = project.manifest(); + push.edgezero.app_config = Some(project.settings()); + push.edgezero.no_env = true; + let mut pushed = None; + run_push_with(push, |handed| { + pushed = Some(read_permissions(handed.app_config.as_deref())); + Ok(()) + }) + .expect("should push"); + + let mut validate = ValidateArgs { + edgezero: ConfigValidateArgs::default(), + policy: PolicyFileArgs { + files: vec![standard], + }, + }; + validate.edgezero.manifest = project.manifest(); + validate.edgezero.app_config = Some(project.settings()); + validate.edgezero.no_env = true; + let mut validated = None; + run_validate_with(validate, |handed| { + validated = Some(read_permissions(handed.app_config.as_deref())); + Ok(()) + }) + .expect("should validate"); + + for (command, permissions) in [("push to Axum", pushed), ("validate", validated)] { + let permissions = permissions.expect("should hand settings to EdgeZero"); + assert!(permissions.contains_key(SHA256_KEY), "{command}"); + assert!( + !permissions.contains_key(TEXT_KEY), + "{command} should leave the file out: {permissions:?}" + ); + } + assert_eq!(PolicyText::for_adapter("fastly"), PolicyText::Included); + assert_eq!(PolicyText::for_adapter("Axum"), PolicyText::Omitted); + } + + #[test] + fn validation_without_a_file_refuses_a_digest_the_settings_set() { + let project = Project::new( + "trusted-server", + &format!("{NAMES_STANDARD}{SHA256_KEY} = \"0000\"\n"), + ); + let mut validate = ValidateArgs { + edgezero: ConfigValidateArgs::default(), + policy: PolicyFileArgs::default(), + }; + validate.edgezero.manifest = project.manifest(); + validate.edgezero.app_config = Some(project.settings()); + validate.edgezero.no_env = true; + + let error = run_validate_with(validate, |_| { + Err("EdgeZero should not be asked to validate".to_owned()) + }) + .expect_err("a digest the settings set should be refused"); + + assert!( + error.contains("`[permissions] sha256`"), + "should name the field: {error}" + ); + } + + #[test] + fn a_diff_without_the_file_is_refused_when_the_settings_name_a_policy() { + let project = Project::new("trusted-server", NAMES_STANDARD); + let mut diff = DiffArgs { + edgezero: ConfigDiffArgs::default(), + policy: PolicyFileArgs::default(), + }; + diff.edgezero.adapter = "fastly".to_owned(); + diff.edgezero.manifest = project.manifest(); + diff.edgezero.app_config = Some(project.settings()); + diff.edgezero.no_env = true; + + let error = run_diff(diff).expect_err("the diff should be refused"); + + assert!( + error.contains("`standard`") && error.contains("--permissions "), + "should name the policy and how to supply its file: {error}" + ); + } + + #[test] + fn the_digest_is_taken_after_the_environment_overlay() { + // An app name of its own, so no other test reads the variable. + let project = Project::new("policy-overlay-probe", NAMES_STANDARD); + let files = [ + project.policy_file("standard.yaml", OPEN_POLICY.as_bytes()), + project.policy_file("strict.yaml", STRICT_POLICY.as_bytes()), + ]; + + temp_env::with_var( + "POLICY_OVERLAY_PROBE__PERMISSIONS__POLICY", + Some("strict"), + || { + let overlaid = project + .prepare(&files, false, PolicyText::Included) + .expect("should prepare the overlaid settings"); + assert_eq!( + overlaid.policy, "strict", + "the overlay should choose the policy" + ); + assert_eq!(overlaid.sha256, policy_sha256(STRICT_POLICY)); + let handed = handed_permissions(&overlaid); + assert_eq!( + handed["policy"].as_str(), + Some("strict"), + "EdgeZero should be handed the policy the overlay chose" + ); + assert_eq!( + handed[SHA256_KEY].as_str(), + Some(policy_sha256(STRICT_POLICY).as_str()), + "EdgeZero should be handed the digest of that policy's file" + ); + + let plain = project + .prepare(&files, true, PolicyText::Included) + .expect("should prepare the settings without the overlay"); + assert_eq!(plain.policy, "standard", "--no-env should skip the overlay"); + assert_eq!(plain.sha256, policy_sha256(OPEN_POLICY)); + assert_eq!( + handed_permissions(&plain)["policy"].as_str(), + Some("standard"), + "EdgeZero should be handed the policy the file names" + ); + }, + ); + } + + #[test] + fn the_example_settings_as_written_name_no_policy_and_are_refused() { + let project = Project::new("trusted-server", ""); + let standard = project.policy_file("standard.yaml", OPEN_POLICY.as_bytes()); + + let error = project + .prepare(&[standard], true, PolicyText::Included) + .expect_err("settings naming no policy should be refused"); + + assert!( + error.contains("name no permission policy") + && error.contains("`[permissions] policy`") + && error.contains("--permissions "), + "should say what is missing and how to supply it: {error}" + ); + } + + #[test] + fn a_policy_no_supplied_file_has_is_refused() { + let project = Project::new("trusted-server", NAMES_STANDARD); + let strict = project.policy_file("strict.yaml", STRICT_POLICY.as_bytes()); + + let error = project + .prepare(&[strict], true, PolicyText::Included) + .expect_err("a policy no file supplies should be refused"); + assert!( + error.contains("`standard`") && error.contains("supplied: strict"), + "should name the policy and the files given: {error}" + ); + + let error = project + .prepare(&[], true, PolicyText::Included) + .expect_err("a push without a file should be refused"); + assert!(error.contains("supplied: none"), "{error}"); + } + + #[test] + fn a_digest_or_text_the_settings_set_is_refused() { + for (key, value) in [(SHA256_KEY, "\"0000\""), (TEXT_KEY, "\"groups: {}\"")] { + let project = Project::new( + "trusted-server", + &format!("{NAMES_STANDARD}{key} = {value}\n"), + ); + let standard = project.policy_file("standard.yaml", OPEN_POLICY.as_bytes()); + + let error = project + .prepare(&[standard], true, PolicyText::Included) + .expect_err("a field only the push writes should be refused"); + + assert!( + error.contains(&format!("`[permissions] {key}`")) + && error.contains("TRUSTED_SERVER__"), + "should name the field and both ways it could be set: {error}" + ); + } + } + + #[test] + fn a_file_that_is_not_a_yaml_policy_in_utf8_text_is_refused() { + let project = Project::new("trusted-server", NAMES_STANDARD); + let cases = [ + ( + project.policy_file("standard.yml", OPEN_POLICY.as_bytes()), + "must end `.yaml`", + ), + ( + project.policy_file("standard.yaml", &[0xff, 0xfe, 0x00]), + "is not UTF-8 text", + ), + ( + project.policy_file("broken.yaml", b"groups: [\n"), + "is not valid", + ), + ]; + + for (path, reason) in cases { + let error = project + .prepare(std::slice::from_ref(&path), true, PolicyText::Included) + .expect_err("the file should be refused"); + assert!( + error.contains(reason) && error.contains(&path.display().to_string()), + "should name {} and say `{reason}`: {error}", + path.display() + ); + } + } + + #[test] + fn every_supplied_file_is_checked_whether_or_not_it_is_named() { + let project = Project::new("trusted-server", NAMES_STANDARD); + let files = [ + project.policy_file("standard.yaml", OPEN_POLICY.as_bytes()), + project.policy_file("unused.yaml", b"rules: {}\n"), + ]; + + let error = project + .prepare(&files, true, PolicyText::Included) + .expect_err("an invalid file should be refused even when no setting names it"); + + assert!(error.contains("unused.yaml"), "{error}"); + } + + #[test] + fn two_files_that_supply_one_policy_are_refused() { + let project = Project::new("trusted-server", NAMES_STANDARD); + for folder in ["a", "b"] { + fs::create_dir(project.folder.path().join(folder)).expect("should create a folder"); + } + let files = [ + project.policy_file("a/standard.yaml", OPEN_POLICY.as_bytes()), + project.policy_file("b/standard.yaml", STRICT_POLICY.as_bytes()), + ]; + + let error = project + .prepare(&files, true, PolicyText::Included) + .expect_err("two files with one name should be refused"); + + assert!( + error.contains("both supply the policy `standard`"), + "{error}" + ); + } + + #[test] + fn the_settings_handed_over_are_removed_when_done() { + let project = Project::new("trusted-server", NAMES_STANDARD); + let standard = project.policy_file("standard.yaml", OPEN_POLICY.as_bytes()); + + let prepared = project + .prepare(&[standard], true, PolicyText::Included) + .expect("should prepare the settings"); + let path = prepared.file.to_path_buf(); + + assert!( + path.exists(), + "the settings should exist while EdgeZero reads them" + ); + assert!( + path.starts_with(std::env::temp_dir()), + "the settings should be in the system temp folder: {}", + path.display() + ); + drop(prepared); + assert!(!path.exists(), "the settings should be removed"); + } +} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 05b4779f8f..1a8821b877 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -2,14 +2,14 @@ use std::process; use clap::{Parser, Subcommand}; use edgezero_cli::args::{ - ActiveVersionArgs, AuthArgs, BuildArgs, ConfigDiffArgs, ConfigGcArgs, ConfigPushArgs, - ConfigValidateArgs, DeployArgs, HealthcheckArgs, ProvisionArgs, RollbackArgs, ServeArgs, + ActiveVersionArgs, AuthArgs, BuildArgs, ConfigGcArgs, DeployArgs, HealthcheckArgs, + ProvisionArgs, RollbackArgs, ServeArgs, }; -use trusted_server_core::config::TrustedServerAppConfig; use crate::commands::audit::{AuditArgs, run_audit}; use crate::commands::config::ad_templates::{AdTemplatesCommand, run_ad_templates}; use crate::commands::config::init::{ConfigInitArgs, run_config_init}; +use crate::commands::config::policy; use crate::commands::pbs::{self, PbsArgs}; use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; @@ -64,13 +64,15 @@ enum ConfigCommand { /// Initialize a Trusted Server config file from the example template. Init(ConfigInitArgs), /// Diff `trusted-server.toml` against the live `EdgeZero` config. - Diff(ConfigDiffArgs), + Diff(policy::DiffArgs), /// Reclaim orphaned chunk entries leaked from prior oversized pushes. Gc(ConfigGcArgs), - /// Push `trusted-server.toml` as a blob envelope through `EdgeZero`. - Push(ConfigPushArgs), - /// Validate `edgezero.toml` and the typed Trusted Server config. - Validate(ConfigValidateArgs), + /// Push `trusted-server.toml`, with the permission policy file it names, + /// as a blob envelope through `EdgeZero`. + Push(policy::PushArgs), + /// Validate `edgezero.toml`, the typed Trusted Server config and, with + /// `--permissions`, the permission policy file it names. + Validate(policy::ValidateArgs), } #[derive(Debug, clap::Args)] @@ -129,24 +131,20 @@ fn dispatch(args: Args) -> Result { Command::Config(ConfigCommand::Init(args)) => { run_config_init(&args).map(|()| RunOutcome::Success) } - Command::Config(ConfigCommand::Diff(args)) => { - match edgezero_cli::run_config_diff_typed::(&args) { - Ok(edgezero_cli::DiffExit { code: 0 }) => Ok(RunOutcome::Success), - Ok(edgezero_cli::DiffExit { code: 1 }) => Ok(RunOutcome::AssertionFailed), - Ok(edgezero_cli::DiffExit { code }) => process::exit(code), - Err(err) => Err(err), - } - } + Command::Config(ConfigCommand::Diff(args)) => match policy::run_diff(args) { + Ok(edgezero_cli::DiffExit { code: 0 }) => Ok(RunOutcome::Success), + Ok(edgezero_cli::DiffExit { code: 1 }) => Ok(RunOutcome::AssertionFailed), + Ok(edgezero_cli::DiffExit { code }) => process::exit(code), + Err(err) => Err(err), + }, Command::Config(ConfigCommand::Gc(args)) => { edgezero_cli::run_config_gc(&args).map(|()| RunOutcome::Success) } Command::Config(ConfigCommand::Push(args)) => { - edgezero_cli::run_config_push_typed::(&args) - .map(|()| RunOutcome::Success) + policy::run_push(args).map(|()| RunOutcome::Success) } Command::Config(ConfigCommand::Validate(args)) => { - edgezero_cli::run_config_validate_typed::(&args) - .map(|()| RunOutcome::Success) + policy::run_validate(args).map(|()| RunOutcome::Success) } Command::Deploy(args) => edgezero_cli::run_deploy(&args).map(|()| RunOutcome::Success), Command::Healthcheck(args) => { @@ -561,13 +559,58 @@ mod tests { panic!("expected config push command"); }; let default_push = ConfigPushArgs::default(); - assert_eq!(push.adapter, "fastly"); - assert_eq!(push.app_config, default_push.app_config); - assert_eq!(push.manifest, default_push.manifest); - assert_eq!(push.store, default_push.store); - assert!(!push.local); - assert!(!push.dry_run); - assert!(!push.no_env); + assert_eq!(push.edgezero.adapter, "fastly"); + assert_eq!(push.edgezero.app_config, default_push.app_config); + assert_eq!(push.edgezero.manifest, default_push.manifest); + assert_eq!(push.edgezero.store, default_push.store); + assert!(!push.edgezero.local); + assert!(!push.edgezero.dry_run); + assert!(!push.edgezero.no_env); + assert!( + push.policy.files.is_empty(), + "should supply no policy file unless asked" + ); + } + + #[test] + fn config_push_validate_and_diff_take_each_policy_file() { + let files = [ + "--permissions", + "standard.yaml", + "--permissions", + "strict.yaml", + ]; + let expected = vec![PathBuf::from("standard.yaml"), PathBuf::from("strict.yaml")]; + + let push = parse( + &[ + ["ts", "config", "push", "--adapter", "fastly"].as_slice(), + &files, + ] + .concat(), + ); + let Command::Config(ConfigCommand::Push(push)) = push.command else { + panic!("expected config push command"); + }; + assert_eq!(push.policy.files, expected); + + let validate = parse(&[["ts", "config", "validate"].as_slice(), &files].concat()); + let Command::Config(ConfigCommand::Validate(validate)) = validate.command else { + panic!("expected config validate command"); + }; + assert_eq!(validate.policy.files, expected); + + let diff = parse( + &[ + ["ts", "config", "diff", "--adapter", "fastly"].as_slice(), + &files, + ] + .concat(), + ); + let Command::Config(ConfigCommand::Diff(diff)) = diff.command else { + panic!("expected config diff command"); + }; + assert_eq!(diff.policy.files, expected); } #[test] @@ -577,13 +620,13 @@ mod tests { panic!("expected config diff command"); }; let default_diff = ConfigDiffArgs::default(); - assert_eq!(diff.adapter, "fastly"); - assert_eq!(diff.app_config, default_diff.app_config); - assert_eq!(diff.manifest, default_diff.manifest); - assert_eq!(diff.store, default_diff.store); - assert!(!diff.local); - assert!(!diff.exit_code); - assert!(!diff.no_env); + assert_eq!(diff.edgezero.adapter, "fastly"); + assert_eq!(diff.edgezero.app_config, default_diff.app_config); + assert_eq!(diff.edgezero.manifest, default_diff.manifest); + assert_eq!(diff.edgezero.store, default_diff.store); + assert!(!diff.edgezero.local); + assert!(!diff.edgezero.exit_code); + assert!(!diff.edgezero.no_env); } #[test] @@ -592,7 +635,10 @@ mod tests { let Command::Config(ConfigCommand::Push(push)) = args.command else { panic!("expected config push command"); }; - assert!(push.staging, "should target the staging publication flow"); + assert!( + push.edgezero.staging, + "should target the staging publication flow" + ); Args::try_parse_from([ "ts", @@ -614,7 +660,7 @@ mod tests { panic!("expected config diff command"); }; assert!( - diff.staging, + diff.edgezero.staging, "should compare against the staging publication flow" ); @@ -724,12 +770,15 @@ mod tests { let Command::Config(ConfigCommand::Validate(validate)) = args.command else { panic!("expected config validate command"); }; - assert_eq!(validate.app_config, Some(PathBuf::from("publisher-a.toml"))); - assert!(validate.no_env); - assert!(validate.strict); + assert_eq!( + validate.edgezero.app_config, + Some(PathBuf::from("publisher-a.toml")) + ); + assert!(validate.edgezero.no_env); + assert!(validate.edgezero.strict); let default_validate = ConfigValidateArgs::default(); - assert_eq!(validate.manifest, default_validate.manifest); + assert_eq!(validate.edgezero.manifest, default_validate.manifest); } #[test] diff --git a/crates/trusted-server-cli/tests/config_env_overlay.rs b/crates/trusted-server-cli/tests/config_env_overlay.rs index d81b0366a9..a14b6ee292 100644 --- a/crates/trusted-server-cli/tests/config_env_overlay.rs +++ b/crates/trusted-server-cli/tests/config_env_overlay.rs @@ -27,6 +27,11 @@ ids = ["trusted_server_config"] [stores.secrets] ids = ["trusted_server_secrets"] "#; +/// The policy file the fixture's settings name. +const SAMPLE_POLICY: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../config/permissions/sample.yaml" +); const REWRITE_ENV: &str = "TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES"; const SANITIZE_ENV: &str = "TRUSTED_SERVER__AUCTION__SANITIZE_CREATIVES"; const GAM_ATTRIBUTION_ENV: &str = "TRUSTED_SERVER__INTEGRATIONS__GPT__GAM_ATTRIBUTION_ENABLED"; @@ -127,7 +132,7 @@ fn map_config_applies_rewrite_creatives_environment_override() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) - .args(["--yes", "--no-diff"]) + .args(["--yes", "--no-diff", "--permissions", SAMPLE_POLICY]) .current_dir(project.directory.path()) .env(REWRITE_ENV, "false") .output() @@ -170,7 +175,7 @@ fn migrated_config_applies_boolean_environment_overrides() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) - .args(["--yes", "--no-diff"]) + .args(["--yes", "--no-diff", "--permissions", SAMPLE_POLICY]) .current_dir(project.directory.path()) .env(GAM_ATTRIBUTION_ENV, "true") .env(AD_TEMPLATES_ENABLED_ENV, "false") @@ -219,7 +224,7 @@ fn migrated_config_applies_sanitize_creatives_environment_override() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) - .args(["--yes", "--no-diff"]) + .args(["--yes", "--no-diff", "--permissions", SAMPLE_POLICY]) .current_dir(project.directory.path()) .env(SANITIZE_ENV, "true") .output() @@ -262,7 +267,7 @@ fn map_config_default_rewrite_creatives_has_no_local_diff() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) - .args(["--yes", "--no-diff"]) + .args(["--yes", "--no-diff", "--permissions", SAMPLE_POLICY]) .current_dir(project.directory.path()) .output() .expect("should run ts config push"); @@ -308,6 +313,7 @@ fn map_config_default_rewrite_creatives_has_no_local_diff() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) + .args(["--permissions", SAMPLE_POLICY]) .current_dir(project.directory.path()) .output() .expect("should run ts config diff"); @@ -348,7 +354,7 @@ fn map_shaped_provider_and_bidder_environment_overlays_apply() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) - .args(["--yes", "--no-diff"]) + .args(["--yes", "--no-diff", "--permissions", SAMPLE_POLICY]) .current_dir(project.directory.path()) .env( PROVIDER_ENDPOINT_ENV, @@ -409,6 +415,7 @@ fn pushed_envelope_sha_is_stable_for_one_context_key_allowlist() { .arg(&project.manifest_path) .arg("--app-config") .arg(&project.config_path) + .args(["--permissions", SAMPLE_POLICY]) .args(flags) .current_dir(project.directory.path()) .output() diff --git a/crates/trusted-server-cli/tests/config_store_defaults.rs b/crates/trusted-server-cli/tests/config_store_defaults.rs index 2d120855d0..b613a0d7c2 100644 --- a/crates/trusted-server-cli/tests/config_store_defaults.rs +++ b/crates/trusted-server-cli/tests/config_store_defaults.rs @@ -6,8 +6,16 @@ use std::process::{Command, Output}; use tempfile::TempDir; use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; +use trusted_server_core::permissions::policy_sha256; -fn project() -> TempDir { +/// The policy file the project's settings name. +const SAMPLE_POLICY: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../config/permissions/sample.yaml" +); + +/// A project holding the example settings with `permissions` appended. +fn project_with(permissions: &str) -> TempDir { let directory = tempfile::tempdir().expect("should create a temporary project"); let mut manifest: toml::Value = toml::from_str(include_str!("../../../edgezero.toml")) .expect("should parse the repository manifest"); @@ -23,26 +31,45 @@ fn project() -> TempDir { .expect("should write the test manifest"); fs::write( directory.path().join("trusted-server.toml"), - include_str!("../../../trusted-server.example.toml"), + format!( + "{}\n{permissions}", + include_str!("../../../trusted-server.example.toml") + ), ) .expect("should copy the example app config"); directory } -fn push(project: &TempDir, args: &[&str], overrides: &[(&str, &str)]) -> Output { - let output = Command::new(env!("CARGO_BIN_EXE_ts")) - .args([ - "config", - "push", - "--adapter", - "axum", - "--local", - "--no-diff", - ]) +/// A project whose settings name the repository's sample policy. +fn project() -> TempDir { + project_with("[permissions]\npolicy = \"sample\"\n") +} + +/// Runs a local Axum push of `project` with the sample policy file. +fn run_push(project: &TempDir, args: &[&str], overrides: &[(&str, &str)]) -> Output { + let push = [ + "config", + "push", + "--adapter", + "axum", + "--local", + "--no-diff", + "--permissions", + SAMPLE_POLICY, + ]; + run_ts(project, &[&push[..], args].concat(), overrides) +} + +/// Runs `ts` in `project` with the publisher settings the example leaves as +/// placeholders. +fn run_ts(project: &TempDir, args: &[&str], overrides: &[(&str, &str)]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ts")) .args(args) .current_dir(project.path()) // Do not inherit operator configuration or credentials from the test runner. .env_clear() + // The push hands its settings over in a file in the temp folder. + .envs(["TMPDIR", "TMP", "TEMP"].map(|name| (name, std::env::temp_dir().into_os_string()))) .env("RUST_LOG", "info") .env("TRUSTED_SERVER__PUBLISHER__DOMAIN", "publisher.example.com") .env( @@ -55,7 +82,11 @@ fn push(project: &TempDir, args: &[&str], overrides: &[(&str, &str)]) -> Output ) .envs(overrides.iter().copied()) .output() - .expect("should run a local config push"); + .expect("should run ts") +} + +fn push(project: &TempDir, args: &[&str], overrides: &[(&str, &str)]) -> Output { + let output = run_push(project, args, overrides); assert!( output.status.success(), "local push should succeed: {}", @@ -90,6 +121,45 @@ fn config_push_default_store_and_key_match_the_compiled_runtime() { envelope["data"]["publisher"]["domain"], "publisher.example.com" ); + let sample = fs::read_to_string(SAMPLE_POLICY).expect("should read the sample policy"); + assert_eq!( + envelope["data"]["permissions"], + serde_json::json!({ "policy": "sample", "sha256": policy_sha256(&sample) }), + "should pin the named file by its digest, with no text for Axum" + ); +} + +#[test] +fn config_push_refuses_the_example_settings_as_written() { + // The example names no policy, because no tool chooses one. + let project = project_with(""); + + let output = run_push(&project, &["--yes"], &[]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "the push should be refused"); + assert!( + stderr.contains("`[permissions] policy`"), + "should name what is missing: {stderr}" + ); + assert!( + !project.path().join(".edgezero").exists(), + "a refused push should write nothing" + ); +} + +#[test] +fn config_validate_without_the_policy_file_says_the_policy_was_not_checked() { + let project = project(); + + let output = run_ts(&project, &["config", "validate"], &[]); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "validation should pass: {stderr}"); + assert!( + stderr.contains("the policy was not checked"), + "should say that the policy was not checked: {stderr}" + ); } #[test] diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 0a8d052805..10431f25fa 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -41,6 +41,7 @@ rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } toml = { workspace = true } diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 19aa0b82f3..e0d5caa6f8 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 134411a912..21c4afd750 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,7 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{consent_allows_server_side_auction, gate_eids_by_permissions}; use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -171,10 +171,11 @@ pub async fn handle_auction( // Story 5 middleware contract: auction is a read-only EC route. // It must not generate EC IDs; it only consumes pre-routed context. - // Only forward the EC ID to auction partners when consent allows it. - // Owned so the identity-graph snapshot can be stored back on `ec_context` - // below without holding a borrow of it across the mutation. - let ec_id = if ec_context.ec_allowed() { + // Forward the EC ID to auction partners only when sharing is permitted, + // being storage plus personalized-ad selection, the same pair that gates + // EIDs. Owned so the identity-graph snapshot can be stored back on + // `ec_context` below without holding a borrow of it across the mutation. + let ec_id = if ec_context.ec_sharing_allowed() { ec_context.ec_value().map(str::to_owned) } else { None @@ -282,8 +283,9 @@ pub async fn handle_auction( // `ts-eids` cookie so later requests can still forward the browser's // full OpenRTB-style EID structure. // - // Gate this on the same identity-consent condition as the EC ID - // (`ec_id.is_some()`, which is already filtered by `ec_context.ec_allowed()`). + // Gate this on the same identity condition as the EC ID + // (`ec_id.is_some()`, which is already filtered by the sharing pair via + // `ec_context.ec_sharing_allowed()`). // Otherwise a US/GPC or US-Privacy opt-out context — where EC identity use is // denied but a non-personalized auction may still run — could forward // persistent client EIDs from the body/cookie, since `gate_eids_by_consent` @@ -351,10 +353,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 @@ -677,7 +678,7 @@ mod tests { fn services_with_telemetry(sink: Arc) -> RuntimeServices { let telemetry_sink: Arc = sink; - RuntimeServices::builder() + crate::platform::test_support::test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -689,13 +690,25 @@ mod tests { .build() } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { - EcContext::new_for_test( + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( + ec_value.map(str::to_owned), + ConsentContext::default(), + ec_allowed, + ) + } + + /// A context in a non-regulated jurisdiction with the permission gate open, + /// for tests whose subject is dispatch or identity reuse rather than the + /// jurisdiction gate. + fn make_non_regulated_ec_context(ec_value: Option<&str>) -> EcContext { + EcContext::new_for_test_gated( ec_value.map(str::to_owned), ConsentContext { - jurisdiction, + jurisdiction: Jurisdiction::NonRegulated, ..ConsentContext::default() }, + true, ) } @@ -747,7 +760,7 @@ mod tests { graph.create(&ec_id, &live).expect("should seed live row"); lookups.store(0, std::sync::atomic::Ordering::Relaxed); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut ec_context = make_non_regulated_ec_context(Some(&ec_id)); let req = Request::builder() .method("POST") .uri("https://test-publisher.com/auction") @@ -847,9 +860,8 @@ mod tests { ), ) .expect("should seed the row under the canonical key"); - let mut ec_context = - make_ec_context(Jurisdiction::NonRegulated, Some(CANONICAL_COOKIE_VALUE)) - .with_module_for_test(Arc::new(CanonicalizingModule)); + let mut ec_context = make_non_regulated_ec_context(Some(CANONICAL_COOKIE_VALUE)) + .with_module_for_test(Arc::new(CanonicalizingModule)); let req = Request::builder() .method("POST") .uri("https://test-publisher.com/auction") @@ -922,7 +934,15 @@ mod tests { live.consent.ok = true; graph.create(&ec_id, &live).expect("should seed live row"); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let mut ec_context = EcContext::new_for_test_gated( + Some(ec_id.clone()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }, + true, + ) + .with_module_for_test(crate::ec::tests::hmac_module()); ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); let req = Request::builder() .method("POST") @@ -1096,7 +1116,7 @@ mod tests { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"probe response".to_vec()); - let services = RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -1105,7 +1125,11 @@ mod tests { .geo(Arc::new(NoopGeo)) .client_info(ClientInfo::default()) .build(); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let mut ec_context = EcContext::new_for_test_gated(None, consent, true); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1154,7 +1178,7 @@ mod tests { orchestrator.register_provider(Arc::new(PanicOnBidProvider)); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut ec_context = make_non_regulated_ec_context(None); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1217,7 +1241,7 @@ mod tests { let orchestrator = AuctionOrchestrator::from_plan(plan, None); let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); - let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let mut ec_context = make_non_regulated_ec_context(None); let body = json!({ "adUnits": [{ "code": "div-gpt-ad-1", @@ -1279,7 +1303,9 @@ mod tests { let telemetry_sink = Arc::new(RecordingTelemetrySink::default()); let services = services_with_telemetry(Arc::clone(&telemetry_sink)); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let mut ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + // The default consent context keeps the jurisdiction unknown, so the + // server-side auction gate fails closed; the EC gate is off to match. + let mut ec_context = make_ec_context(false, Some(&ec_id)); let body = json!({ "adUnits": [ @@ -1476,7 +1502,7 @@ mod tests { fn resolve_auction_eids_returns_empty_without_snapshot() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1488,7 +1514,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_without_registry() { let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, None, &ec_context); assert!( @@ -1501,7 +1527,7 @@ mod tests { fn resolve_auction_eids_returns_none_when_consent_denied() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); + let ec_context = make_ec_context(false, Some(&ec_id)); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1513,7 +1539,11 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_when_no_ec() { let registry = PartnerRegistry::empty(); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + let ec_context = EcContext::new_for_test_gated(None, consent, true); let result = resolve_auction_eids(&EcKvSnapshot::NotRead, Some(®istry), &ec_context); assert!( @@ -1526,7 +1556,7 @@ mod tests { fn resolve_auction_eids_returns_empty_on_kv_miss() { let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let snapshot = EcKvSnapshot::Failed { ec_id: ec_id.clone(), diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 204201e605..d13d4aa452 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -7540,7 +7540,7 @@ mod tests { let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); let http = Arc::new(StubHttpClient::new()); http.push_response(204, Vec::new()); - let services = RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) .secret_store(Arc::clone(&secret_store) as Arc<_>) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -7606,7 +7606,7 @@ mod tests { let http = Arc::new(StubHttpClient::new()); http.push_response(204, Vec::new()); http.push_response(204, Vec::new()); - let services = RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) .secret_store(Arc::clone(&secret_store) as Arc<_>) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -7666,7 +7666,7 @@ mod tests { }); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); let http = Arc::new(StubHttpClient::new()); - let services = RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) .secret_store(Arc::new(UnusedSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -7754,7 +7754,7 @@ mod tests { }); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); let http = Arc::new(StubHttpClient::new()); - let services = RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) .secret_store(Arc::new(UnusedSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -7809,7 +7809,7 @@ mod tests { }); let backend = Arc::new(NamingBackend::new(BackendNamingPolicy::Axum)); let http = Arc::new(StubHttpClient::new()); - let services = RuntimeServices::builder() + let services = crate::platform::test_support::test_services_builder() .config_store(Arc::clone(&config_store) as Arc<_>) .secret_store(Arc::clone(&secret_store) as Arc<_>) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 3548d6006c..570f714a11 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -1449,6 +1449,9 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +assume_single_jurisdiction = true + [ec] module = "hmac" diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index b59520a96b..48cce20546 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -5,14 +5,17 @@ //! stored [`edgezero_core::blob_envelope::BlobEnvelope`] and reconstruct //! [`Settings`] from its data value. +use std::sync::Arc; + use edgezero_core::blob_envelope::BlobEnvelope; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; use crate::config::TrustedServerAppConfig; use crate::error::TrustedServerError; -use crate::platform::{PlatformSecretStore, StoreName}; +use crate::permissions::{PermissionPolicy, PolicyReference, held_policy, load_policy}; +use crate::platform::{PlatformSecretStore, PolicyOrigin, StoreName}; use crate::secret_resolution::resolve_secret_references; -use crate::settings::Settings; +use crate::settings::{PermissionsConfig, Settings}; /// Canonical logical secret store used by Trusted Server app-config secrets. pub const DEFAULT_SECRET_STORE_ID: &str = "trusted_server_secrets"; @@ -35,16 +38,22 @@ pub const CONFIG_BLOB_KEY: &str = DEFAULT_CONFIG_STORE_ID; /// /// Secret references are resolved after envelope verification and before /// deserialization. The envelope data itself is never mutated or rewritten. +/// The permission policy the settings name is then read from +/// `policy_origin`, checked and parsed, and kept with the settings, which +/// keep only its name and digest in `[permissions]`. A policy source is not +/// read for a digest whose rules this instance already holds. /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the envelope cannot be -/// parsed, fails integrity verification, secret resolution fails, or resolved -/// settings are invalid. +/// parsed, fails integrity verification, secret resolution fails, resolved +/// settings are invalid, or the settings name no permission policy or one +/// that cannot be read, is not the file that was pushed, or is not valid. pub fn settings_from_config_blob( envelope_json: &str, secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, + policy_origin: PolicyOrigin<'_>, ) -> Result> { let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { Report::new(TrustedServerError::Configuration { @@ -66,11 +75,86 @@ pub fn settings_from_config_blob( secret_store, default_secret_store_name, )?; - let settings = Settings::from_json_value(data)?; + let mut settings = Settings::from_json_value(data)?; crate::config::validate_settings_for_runtime(&settings)?; + let policy = load_domain_policy(&mut settings.permissions, policy_origin)?; + log_permission_baseline(&policy); + settings.attach_permission_policy(policy); Ok(settings) } +/// Loads the permission policy `permissions` names from `origin`. +/// +/// The policy's text is parsed into the policy, so `permissions` keeps only +/// its name and digest. A digest names one file, so a source is not read +/// for a digest whose rules this instance already holds. Text the blob +/// carries is always checked, because that text is already in hand. +fn load_domain_policy( + permissions: &mut PermissionsConfig, + origin: PolicyOrigin<'_>, +) -> Result, Report> { + let refuse = |message: String| Report::new(TrustedServerError::Configuration { message }); + let Some(policy) = permissions.policy.clone() else { + return Err(refuse( + "the configuration names no permission policy, so name the publisher's policy \ + file in `[permissions] policy` and push it with `ts config push --permissions \ + `" + .to_owned(), + )); + }; + let Some(sha256) = permissions.sha256.clone() else { + return Err(refuse(format!( + "the permission policy `{policy}` has no SHA-256, so the configuration was not \ + pushed with its file, which `ts config push --permissions ` supplies" + ))); + }; + let blob_text = permissions.text.take(); + let reference = PolicyReference { policy, sha256 }; + let text = match origin { + PolicyOrigin::Blob => blob_text.ok_or_else(|| { + refuse(format!( + "the configuration carries no text for the permission policy `{}`, which \ + `ts config push --permissions ` writes for this adapter", + reference.policy + )) + })?, + PolicyOrigin::Source(source) => { + if let Some(policy) = held_policy(&reference) { + return Ok(policy); + } + source + .policy_text(&reference) + .change_context(TrustedServerError::Configuration { + message: format!( + "the permission policy `{}` cannot be read", + reference.policy + ), + })? + } + }; + load_policy(reference, &text) +} + +/// Logs the policy's declared default once per settings load, so an operator +/// can see which permissions an unmatched request is granted without a +/// signal, and which jurisdiction its consent gates apply. +fn log_permission_baseline(policy: &PermissionPolicy) { + let maps = policy.maps(); + let granted: Vec = maps + .baseline(None, None) + .permissions() + .iter() + .map(|permission| permission.to_string()) + .collect(); + log::debug!( + "Permission baseline: policy `{}` ({}) top node, jurisdiction {}; granted without a signal: [{}]", + policy.name(), + policy.sha256(), + maps.default_jurisdiction(), + granted.join(", ") + ); +} + fn remove_inactive_secret_references(data: &mut serde_json::Value) { if data .pointer("/tinybird/enabled") @@ -147,13 +231,17 @@ mod tests { AssetOriginAuth, EcPartner, ProxyAssetRoute, S3SigV4AuthConfig, TrustedClientIpConfig, }; use crate::test_support::tests::{ - crate_test_settings_str, hmac_passphrase, select_hmac_module, select_host_signals_module, + crate_test_settings_str, hmac_passphrase, sample_permissions, select_hmac_module, + select_host_signals_module, }; + /// Test settings as `ts config push` writes them, naming the repository's + /// sample policy. fn test_settings() -> Settings { let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + settings.permissions = sample_permissions(); settings } @@ -246,6 +334,8 @@ mod tests { serde_json::from_value(value).expect("should build pull-sync partner") } + /// The blob envelope `ts config push` writes for `settings`, naming the + /// repository's sample policy. fn envelope_json(settings: &Settings) -> String { let data = serde_json::to_value(settings).expect("should serialize settings to JSON"); let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); @@ -257,6 +347,7 @@ mod tests { envelope_json, &EchoSecretStore, &StoreName::from("trusted_server_secrets"), + PolicyOrigin::Blob, ) } @@ -380,6 +471,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should resolve every static credential from the mapped store"); @@ -465,6 +557,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should resolve the trusted client IP shared secret"); @@ -493,6 +586,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect_err("should reject a missing trusted client IP shared secret"); let message = error.to_string(); @@ -528,6 +622,7 @@ mod tests { &envelope_json, &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should resolve the labeled block's passphrase"); @@ -595,6 +690,7 @@ mod tests { &envelope_json, &RefusesAValueAsAKey, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .unwrap_or_else(|error| panic!("[ec.{name}] should load: {error:?}")); @@ -627,6 +723,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should resolve default S3 secret keys"); @@ -656,6 +753,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should load partner without resolving an API token"); @@ -682,6 +780,7 @@ mod tests { &envelope_json, &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should resolve a pull token enabled by a string boolean"); @@ -713,6 +812,7 @@ mod tests { &envelope_json, &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should skip a pull token disabled by a string boolean"); @@ -734,6 +834,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect_err("should reject a missing active pull-sync token"); @@ -769,6 +870,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should skip inactive optional feature references"); @@ -809,6 +911,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should skip stale DataDome protection references"); @@ -946,6 +1049,7 @@ mod tests { &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, ) .expect("should resolve the host_signals passphrase from the mapped store"); @@ -1019,4 +1123,325 @@ mod tests { "error should mention integrity verification" ); } + + mod permission_policy { + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::permissions::{SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT, policy_sha256}; + use crate::platform::PlatformPolicySource; + use crate::test_support::tests::policy_only_this_test_loads; + + /// Policy files by name, as a store of them would hold them, counting + /// every read. + #[derive(Default)] + struct MemoryPolicies { + files: BTreeMap, + reads: AtomicUsize, + } + + impl MemoryPolicies { + fn holding<'a>(files: impl IntoIterator) -> Self { + Self { + files: files + .into_iter() + .map(|(name, text)| (name.to_owned(), text.to_owned())) + .collect(), + reads: AtomicUsize::new(0), + } + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } + } + + impl PlatformPolicySource for MemoryPolicies { + fn policy_text( + &self, + reference: &PolicyReference, + ) -> Result> { + self.reads.fetch_add(1, Ordering::SeqCst); + self.files.get(&reference.policy).cloned().ok_or_else(|| { + Report::new(PlatformError::PolicySource) + .attach(format!("no file named `{}`", reference.policy)) + }) + } + } + + /// The `[permissions]` a push writes for an adapter with a policy + /// source: the name and the digest of `text`, and no file. + fn pinned(policy: &str, text: &str) -> PermissionsConfig { + PermissionsConfig { + policy: Some(policy.to_owned()), + sha256: Some(policy_sha256(text)), + text: None, + } + } + + fn maps(settings: &Settings) -> *const crate::permissions::PermissionMaps { + std::ptr::from_ref( + settings + .permission_policy() + .expect("should keep the policy") + .maps(), + ) + } + + fn load_with( + permissions: PermissionsConfig, + origin: PolicyOrigin<'_>, + ) -> Result> { + let mut settings = test_settings(); + settings.permissions = permissions; + let data = serde_json::to_value(&settings).expect("should serialize settings"); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + settings_from_config_blob( + &serde_json::to_string(&envelope).expect("should serialize envelope"), + &EchoSecretStore, + &StoreName::from("trusted_server_secrets"), + origin, + ) + } + + fn refusal(result: Result>) -> String { + let error = result.expect_err("the domain should not load"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, which answers 500: {error:?}" + ); + format!("{error:?}") + } + + #[test] + fn a_domain_naming_no_policy_does_not_load() { + let message = refusal(load_with(PermissionsConfig::default(), PolicyOrigin::Blob)); + + assert!( + message.contains("names no permission policy") + && message.contains("`[permissions] policy`"), + "should say what is missing: {message}" + ); + } + + #[test] + fn a_policy_with_no_digest_does_not_load() { + let permissions = PermissionsConfig { + sha256: None, + ..sample_permissions() + }; + + let message = refusal(load_with(permissions, PolicyOrigin::Blob)); + + assert!(message.contains("`sample` has no SHA-256"), "{message}"); + } + + #[test] + fn a_blob_without_its_file_does_not_load_where_the_file_travels_in_the_blob() { + let permissions = PermissionsConfig { + text: None, + ..sample_permissions() + }; + + let message = refusal(load_with(permissions, PolicyOrigin::Blob)); + + assert!( + message.contains("carries no text for the permission policy `sample`"), + "{message}" + ); + } + + #[test] + fn a_file_that_is_not_the_pushed_file_does_not_load() { + let permissions = PermissionsConfig { + text: Some(format!("{SAMPLE_POLICY_TEXT}\n# edited after the push\n")), + ..sample_permissions() + }; + + let message = refusal(load_with(permissions, PolicyOrigin::Blob)); + + assert!( + message.contains("`sample` is not the file that was pushed"), + "{message}" + ); + } + + #[test] + fn a_pushed_file_that_is_not_a_valid_policy_does_not_load() { + let text = "groups: [\n"; + let permissions = PermissionsConfig { + policy: Some("broken".to_owned()), + sha256: Some(policy_sha256(text)), + text: Some(text.to_owned()), + }; + + let message = refusal(load_with(permissions, PolicyOrigin::Blob)); + + assert!(message.contains("`broken` is not valid"), "{message}"); + } + + #[test] + fn the_named_policy_loads_and_only_its_name_and_digest_stay_in_the_settings() { + let settings = load_with(sample_permissions(), PolicyOrigin::Blob) + .expect("a blob carrying its policy should load"); + + let policy = settings + .permission_policy() + .expect("should keep the policy with the settings"); + assert_eq!(policy.name(), SAMPLE_POLICY_NAME); + assert_eq!(policy.sha256(), policy_sha256(SAMPLE_POLICY_TEXT)); + assert_eq!( + settings.permissions, + PermissionsConfig { + text: None, + ..sample_permissions() + }, + "the parsed file should leave only its name and digest" + ); + } + + #[test] + fn two_loads_that_pin_one_digest_parse_the_file_once() { + let load = || { + load_with(sample_permissions(), PolicyOrigin::Blob) + .expect("a blob carrying its policy should load") + }; + let (first, second) = (load(), load()); + + assert!( + std::ptr::eq(maps(&first), maps(&second)), + "the second load should keep the rules the first one parsed" + ); + } + + #[test] + fn a_policy_source_supplies_the_file_and_any_text_in_the_blob_is_unused() { + for (group, blob_text) in [ + ("source-without-blob-text", None), + ("source-over-blob-text", Some("not the pushed file")), + ] { + let text = policy_only_this_test_loads(group); + let source = MemoryPolicies::holding([("standard", text.as_str())]); + let permissions = PermissionsConfig { + text: blob_text.map(str::to_owned), + ..pinned("standard", &text) + }; + + let settings = load_with(permissions, PolicyOrigin::Source(&source)) + .expect("the source's file should load"); + + assert_eq!(source.reads(), 1, "the file should come from the source"); + assert_eq!( + settings + .permission_policy() + .expect("should keep the policy") + .name(), + "standard" + ); + } + } + + #[test] + fn a_policy_the_source_cannot_read_does_not_load() { + let text = policy_only_this_test_loads("source-cannot-read"); + + let message = refusal(load_with( + pinned("standard", &text), + PolicyOrigin::Source(&MemoryPolicies::default()), + )); + + assert!( + message.contains("`standard` cannot be read") && message.contains("no file named"), + "should name the policy and the source's reason: {message}" + ); + } + + #[test] + fn a_second_host_pinning_a_held_digest_reads_nothing() { + let text = policy_only_this_test_loads("second-host"); + let source = MemoryPolicies::holding([("standard", text.as_str())]); + + let first = load_with(pinned("standard", &text), PolicyOrigin::Source(&source)) + .expect("the first host should load"); + assert_eq!(source.reads(), 1, "the first host should read the file"); + let second = load_with(pinned("standard", &text), PolicyOrigin::Source(&source)) + .expect("the second host should load"); + + assert_eq!( + source.reads(), + 1, + "the second host should read nothing while the rules are held" + ); + let policy = second.permission_policy().expect("should keep the policy"); + assert_eq!(policy.name(), "standard"); + assert_eq!(policy.sha256(), policy_sha256(&text)); + assert!( + std::ptr::eq(maps(&first), maps(&second)), + "the second host should be given the rules the first one loaded" + ); + } + + #[test] + fn the_first_load_of_a_digest_reads_the_file_and_checks_it() { + let text = policy_only_this_test_loads("first-load"); + let edited = format!("{text}# edited after the push\n"); + let source = MemoryPolicies::holding([("standard", edited.as_str())]); + + for reads in 1..=2 { + let message = refusal(load_with( + pinned("standard", &text), + PolicyOrigin::Source(&source), + )); + + assert!( + message.contains("`standard` is not the file that was pushed"), + "{message}" + ); + assert_eq!( + source.reads(), + reads, + "a refused file should leave nothing held, so each load reads it" + ); + } + } + + #[test] + fn a_digest_the_instance_does_not_hold_is_read() { + let pushed_first = policy_only_this_test_loads("pushed-first"); + let pushed_next = policy_only_this_test_loads("pushed-next"); + let first_source = MemoryPolicies::holding([("standard", pushed_first.as_str())]); + let next_source = MemoryPolicies::holding([("standard", pushed_next.as_str())]); + + let first = load_with( + pinned("standard", &pushed_first), + PolicyOrigin::Source(&first_source), + ) + .expect("the first digest should load"); + let next = load_with( + pinned("standard", &pushed_next), + PolicyOrigin::Source(&next_source), + ) + .expect("the next digest should load"); + + assert_eq!( + next_source.reads(), + 1, + "a digest the instance does not hold should be read, whatever its name" + ); + assert_eq!( + next.permission_policy() + .expect("should keep the policy") + .sha256(), + policy_sha256(&pushed_next) + ); + assert!( + !std::ptr::eq(maps(&first), maps(&next)), + "each digest should have its own rules" + ); + } + } } diff --git a/crates/trusted-server-core/src/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs index 9073730731..8cce131156 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, in +/// the policy whose rules are `maps`. +/// +/// Walks the policy's 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(maps: &PermissionMaps, geo: Option<&GeoInfo>) -> Jurisdiction { + 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,14 @@ 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; + use crate::permissions::PermissionMaps; 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 +142,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(PermissionMaps::sample(), Some(&geo)), Jurisdiction::Gdpr, "Germany should trigger GDPR" ); @@ -114,10 +152,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(PermissionMaps::sample(), Some(&geo)), Jurisdiction::Gdpr, "Norway (EEA) should trigger GDPR" ); @@ -125,77 +162,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(PermissionMaps::sample(), 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(PermissionMaps::sample(), 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(PermissionMaps::sample(), Some(&delaware)), + Jurisdiction::UsState("DE".to_owned()), + "US/DE should be Delaware" + ); + let germany = make_geo("DE", None); + assert_eq!( + detect_jurisdiction(PermissionMaps::sample(), 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(PermissionMaps::sample(), 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(PermissionMaps::sample(), 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 + // sample 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(PermissionMaps::sample(), 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(PermissionMaps::sample(), 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(PermissionMaps::sample(), 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(PermissionMaps::sample(), 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 76aa6ad859..07f83cfcc9 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -26,6 +26,8 @@ //! req: &req, //! config: &settings.consent, //! geo: geo.as_ref(), +//! default_jurisdiction, +//! permission_maps: policy.maps(), //! }); //! ``` @@ -51,6 +53,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; @@ -73,6 +76,19 @@ 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, + /// The rules of the policy the request's domain loaded, which place a + /// located request in its jurisdiction. + pub permission_maps: &'a crate::permissions::PermissionMaps, } /// Extracts, decodes, and normalizes consent signals from a request. @@ -102,7 +118,7 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext // 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() @@ -128,7 +144,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); @@ -137,6 +153,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.permission_maps, 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 @@ -279,8 +308,14 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b } /// Returns the effective decoded TCF consent for enforcement decisions. +/// +/// A standalone TC string wins, and the EU TCF section of a GPP string stands +/// in when there is none. Public because the TCF permission signal module +/// lives outside core and reads the record this pipeline produced, rather than +/// decoding the cookie a second time and disagreeing with every other reader +/// of the same request about expiry and the cached record. #[must_use] -fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() @@ -433,69 +468,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) - } - } - } -} - -/// Returns whether consent allows EIDs to be written to the identity graph. -/// -/// Applies the same decision as [`gate_eids_by_consent`] for a present -/// [`ConsentContext`], without logging: the effective TCF consent (standalone -/// TC string or GPP EU TCF section) must grant Purpose 1 (storage/access) -/// **and** Purpose 4 (personalized ads). With no TCF data, EIDs are allowed -/// only when GDPR does not apply. -/// -/// A TCF signal means TCF rules apply. In decode mode a TC signal always sets -/// `gdpr_applies`, so checking the decoded TCF first is equivalent to checking -/// `gdpr_applies` first. In [`ConsentMode::Proxy`] the TC string is not -/// decoded, so a request carrying a TC cookie has no effective TCF and GDPR -/// applies: EID writes are withheld even when Purpose 4 is granted. This -/// fails closed, matching how [`gate_eids_by_consent`] strips egress EIDs in -/// proxy mode. -#[must_use] -pub(crate) fn allows_eid_persistence(ctx: &ConsentContext) -> bool { - match effective_tcf(ctx) { - Some(tcf) => allows_eid_transmission(tcf), - None => !ctx.gdpr_applies, + 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 } } @@ -503,110 +507,30 @@ pub(crate) fn allows_eid_persistence(ctx: &ConsentContext) -> bool { // 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) } // --------------------------------------------------------------------------- @@ -626,13 +550,19 @@ fn log_consent_signals(signals: &RawConsentSignals) { } /// Logs a one-time warning when request geolocation is unavailable. +/// +/// It says what happens next rather than only that geo is missing, because an +/// operator reading it wants to know whether identity and advertising are +/// still running. With no location the caller supplies the declared +/// jurisdiction, which is the permission policy's top node, so they are. A +/// lookup that fails is the different case, and that one does fail closed. fn log_missing_geo_warning_once() { if MISSING_GEO_WARNING_LOGGED.swap(true, Ordering::Relaxed) { return; } log::warn!( - "Geo lookup returned no data; consent jurisdiction will be unknown and EC creation will fail closed" + "Geo lookup returned no location, so the jurisdiction is the one the permission policy declares rather than one resolved from the request. Country and region rules cannot match. A geo lookup that fails, as opposed to resolving nothing, is treated as unknown and fails closed." ); } @@ -682,11 +612,10 @@ mod tests { use http::Request; use super::{ - ConsentPipelineInput, allows_ec_creation, allows_eid_persistence, apply_expiration_check, - apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, gate_eids_by_consent, 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, }; @@ -785,94 +714,6 @@ mod tests { } } - #[test] - fn eid_persistence_matches_gate_eids_by_consent() { - let gpp_only = |allows_eids: bool| GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf(0, allows_eids)), - us_sale_opt_out: None, - }; - let cases = [ - ( - "Purpose 1 + 4 granted", - ConsentContext { - gdpr_applies: true, - tcf: Some(make_tcf(0, true)), - ..ConsentContext::default() - }, - true, - ), - ( - "Purpose 1 only", - ConsentContext { - gdpr_applies: true, - tcf: Some(make_tcf(0, false)), - ..ConsentContext::default() - }, - false, - ), - ( - "Purpose 4 only", - ConsentContext { - gdpr_applies: true, - tcf: Some( - TcfBuilder::new() - .with_storage(false) - .with_personalized_ads(true) - .build(), - ), - ..ConsentContext::default() - }, - false, - ), - ( - "GPP EU TCF section grants Purpose 1 + 4", - ConsentContext { - gdpr_applies: true, - gpp: Some(gpp_only(true)), - ..ConsentContext::default() - }, - true, - ), - ( - "GPP EU TCF section denies Purpose 4", - ConsentContext { - gdpr_applies: true, - gpp: Some(gpp_only(false)), - ..ConsentContext::default() - }, - false, - ), - ( - "GDPR applies without TCF", - ConsentContext { - gdpr_applies: true, - ..ConsentContext::default() - }, - false, - ), - ( - "GDPR does not apply without TCF", - ConsentContext::default(), - true, - ), - ]; - - for (label, ctx, expected) in cases { - assert_eq!( - allows_eid_persistence(&ctx), - expected, - "should decide EID persistence for case: {label}" - ); - assert_eq!( - gate_eids_by_consent(Some(vec![1_u8]), Some(&ctx)).is_some(), - expected, - "should match gate_eids_by_consent for case: {label}" - ); - } - } - #[test] fn auction_allowed_for_known_non_gdpr_jurisdiction_without_tcf_signal() { let ctx = ConsentContext { @@ -963,7 +804,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(); @@ -972,6 +813,8 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, + permission_maps: crate::permissions::PermissionMaps::sample(), }); assert_eq!( @@ -979,10 +822,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] @@ -999,6 +838,8 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, + permission_maps: crate::permissions::PermissionMaps::sample(), }); assert!( @@ -1027,6 +868,8 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, + permission_maps: crate::permissions::PermissionMaps::sample(), }); assert!( @@ -1135,434 +978,85 @@ 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 gate_eids_by_consent_strips_every_eid_when_personalization_is_denied() { - let context = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - gdpr_applies: true, - tcf: Some( - TcfBuilder::new() - .with_storage(true) - .with_personalized_ads(false) - .build(), - ), - ..ConsentContext::default() - }; - - // Gating is all-or-nothing across sources; LiveRamp is included here as - // the case that motivated this coverage, not as a special case. - let gated = gate_eids_by_consent( - Some(vec![ - ("liveramp.com", "opaque-test-envelope"), - ("sharedid.org", "shared-test-id"), - ]), - Some(&context), - ); - - assert!( - gated.is_none(), - "should remove every EID when personalization consent is denied" - ); - } - - #[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() - }; + 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::sample().resolve(Some("US"), |_| false); + let eids = Some(vec!["eid-1".to_owned()]); assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when TCF grants storage consent in US state" + 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 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() - }; + 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::sample().resolve(Some("FR"), |_| false); + let eids = Some(vec!["eid-1".to_owned()]); 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" + gate_eids_by_permissions(eids, &permissions).is_none(), + "EIDs should be stripped when a required permission is not set" ); } #[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() - }; + fn gate_eids_returns_none_for_empty_input() { + let permissions = + crate::permissions::PermissionMaps::sample().resolve(Some("US"), |_| false); assert!( - !allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=true should block EC" + gate_eids_by_permissions::(None, &permissions).is_none(), + "no EIDs should resolve to None" ); assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US sale opt-out should be treated as an explicit withdrawal" + gate_eids_by_permissions(Some(Vec::::new()), &permissions).is_none(), + "an empty EID list should resolve to None" ); } #[test] - fn ec_blocked_us_state_gpc_overrides_gpp_us() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), + fn has_storage_optout_signal_detects_us_style_opt_outs() { + let gpc = ConsentContext { 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" - ); - } + assert!(has_storage_optout_signal(&gpc), "GPC is a storage 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)), + let gpp_sale_opt_out = ConsentContext { gpp: Some(GppConsent { version: 1, - section_ids: vec![7], + section_ids: vec![8], 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" + has_storage_optout_signal(&gpp_sale_opt_out), + "a GPP US sale opt-out is a storage opt-out" ); - 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), - }), + let usp_opt_out = ConsentContext { us_privacy: Some(UsPrivacy { version: 1, notice_given: PrivacyFlag::Yes, opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, + lspa_covered: PrivacyFlag::No, }), ..ConsentContext::default() }; assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should block EC even when GPP US has no sale opt-out" + has_storage_optout_signal(&usp_opt_out), + "a US Privacy sale opt-out is a storage 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" + !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 7d1b1902aa..60d5dd4d5e 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -149,6 +149,28 @@ pub struct ConsentContext { } impl ConsentContext { + /// Keeps only the raw strings a configured module vouched for, so a + /// signal that was absent, unreadable, expired or not acted on is never + /// forwarded in a bid request or anywhere else this context is read. + /// + /// `signals` are the [`ValidSignal`]s on the assembled permission state, + /// matched by scheme. The decoded records are left alone, because a + /// decoded record only exists where the raw string was readable and the + /// modules have already read it. + pub fn keep_only(&mut self, signals: &[crate::permissions::ValidSignal]) { + let vouched = |scheme: &str| signals.iter().any(|signal| signal.scheme == scheme); + if !vouched("tcf") { + self.raw_tc_string = None; + } + if !vouched("gpp") { + self.raw_gpp_string = None; + self.gpp_section_ids = None; + } + if !vouched("us_privacy") { + self.raw_us_privacy = 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 fbef7efe82..530aa62b43 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)] @@ -81,8 +52,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(), } @@ -159,55 +128,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) // --------------------------------------------------------------------------- @@ -309,14 +229,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 // --------------------------------------------------------------------------- @@ -348,35 +260,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(); @@ -463,8 +346,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, @@ -479,8 +360,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 3b5a9b746b..126b4aea84 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -7,6 +7,13 @@ pub const COOKIE_TS_EC_PULL_COMPLETE: &str = "ts-ec-pull-complete"; /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; pub const COOKIE_TS_TESTER: &str = "ts-tester"; +/// The Model Terms for Marketing preference, one of the three words +/// `standard`, `personalized` and `non-marketing`, written as a first party +/// cookie by whatever preference platform asked the visitor and read by the +/// Model Terms for Marketing signal module. Not a Trusted Server name, so +/// that anyone can build a platform that sets it. Trusted Server reads this +/// cookie and never writes it. +pub const COOKIE_MTM_PREF: &str = "__mtm_pref"; pub const COOKIE_SHAREDID: &str = "sharedId"; pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id"); diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd291..57229d9b53 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,621 @@ -//! 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 module advertises the [`Permission`]s its data use +//! requires. [`assemble_permissions`] resolves which permissions are set for a +//! request, from the country it maps to and what the signal modules say, +//! and the context construction gates the module on that state. The EC +//! permission decision lives here, in the EC subsystem, and nowhere else, so +//! callers route every EC permission check through this module rather than +//! re-deriving one. +//! +//! No scheme is decoded here. Which signals count, and what each says about +//! a permission, is answered by the [`PermissionSignalModule`]s the adapter +//! hands in, every one of which is a crate outside core. This module asks +//! them in order and applies the country and region rules to what they +//! settle on. + +use std::sync::Arc; use crate::consent::ConsentContext; +use crate::consent::jurisdiction::Jurisdiction; +use crate::evidence::RequestInfo; +use crate::permission_signal::{self, PermissionSignalModule}; +use crate::permissions::{ + Acquisition, ConsentSignal, Permission, PermissionMaps, PermissionState, SignalPolicy, +}; +use crate::platform::GeoInfo; + +/// The outcome of the geo lookup for a request, separating "no location +/// resolved" from "the lookup failed". +/// +/// The two must not collapse: with no location (the module 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 module resolved a location. + Located(&'a GeoInfo), + /// The module 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, + } + } +} -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +/// The jurisdiction the consent gates apply to a request, from its resolved +/// location or, with none, from the top node of the policy in `maps`. /// -/// 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 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(maps: &PermissionMaps, geo: GeoStatus<'_>) -> Jurisdiction { + match geo { + GeoStatus::NoLocation => maps.default_jurisdiction(), + GeoStatus::Located(_) | GeoStatus::Failed => Jurisdiction::Unknown, + } +} + +/// Assembles the permission state for a request: the place baseline from the +/// rules tree of the policy in `maps`, amended by what the signal modules +/// say. +/// +/// Permissions exist without a consent model. With no module having an +/// opinion the result is simply the baseline for the request's country and +/// region. When the geo module 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. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// `modules` are the signal modules the adapter selected, in the order +/// they run. A scheme missing from the list does not run, so a publisher +/// removes one by leaving it out rather than by configuring it off, and an +/// empty slice runs none of them, which leaves every permission at its +/// country and region baseline. The same modules answer whether storage +/// was explicitly withdrawn, recorded on the state and read through +/// [`PermissionState::storage_withdrawn`], and declare the terms documents the +/// request's data is available under, read through +/// [`PermissionState::tdls`]. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn assemble_permissions( + maps: &PermissionMaps, + consent: &ConsentContext, + evidence: &dyn RequestInfo, + geo: GeoStatus<'_>, + modules: &[Arc], +) -> PermissionState { + let signal = permission_signal(consent, evidence, maps.signals(), modules); + let state = 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, + ) + } + }; + let withdrawn = permission_signal::withdrawn( + modules, + Permission::StoreOnDevice, + consent, + evidence, + maps.signals(), + storage_acquisition(maps, geo), + ); + state + // Only a permission some configured module could still grant is + // worth a page waiting for. The rest are unset, not pending. + .awaiting_only(permission_signal::answerable(modules, maps.signals())) + .with_storage_withdrawn(withdrawn) + .with_tdls(permission_signal::tdls(modules, consent, evidence)) + .with_signals(permission_signal::signals(modules, consent, evidence)) } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// The acquisition rule for Edge Cookie storage in the request's resolved +/// jurisdiction, used to scope destructive withdrawal. /// -/// This is intentionally stricter than [`ec_consent_granted`]. A fail-closed -/// result such as unknown jurisdiction or missing consent data must not be -/// treated as an authoritative withdrawal of an already-issued EC. +/// Resolves the same rules of the policy in `maps` 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_withdrawn(consent_context: &ConsentContext) -> bool { - crate::consent::has_explicit_ec_withdrawal(consent_context) +pub fn storage_acquisition(maps: &PermissionMaps, geo: GeoStatus<'_>) -> Acquisition { + match geo { + GeoStatus::Failed => Acquisition::RequiresSignal, + GeoStatus::Located(_) | GeoStatus::NoLocation => { + let info = geo.info(); + maps.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 request to a [`ConsentSignal`] for each permission, applying the +/// [`SignalPolicy`] the permission model parsed from `permissions.yaml`. +/// +/// This is the only place the EC subsystem consults the signal modules. The +/// policy, not this function, decides which schemes are authoritative and what +/// a US-style opt-out revokes, and each module decides what its own scheme +/// says. This function only hands each module the request, in order, so no +/// signal-to-permission rule lives in core. +/// +/// Each module amends what the ones before it settled on. The order is the +/// policy, and [`combine`] documents why. A record that arrived and could +/// not be read is its own scheme's module's to answer, in the same order, +/// and nothing here answers ahead of the modules. +/// +/// Whether an amendment changes anything is then decided by the country/region +/// map, which drops a `granted` baseline on a `Revoke` and has nothing to drop +/// where the permission is `requires_signal` or `denied`. +/// +/// [`combine`]: crate::permission_signal::combine +fn permission_signal<'a>( + consent: &'a ConsentContext, + evidence: &'a dyn RequestInfo, + signals: &'a SignalPolicy, + modules: &'a [Arc], +) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { + move |permission, baseline| { + permission_signal::combine(modules, permission, consent, evidence, signals, baseline) + } } #[cfg(test)] mod tests { + use http::HeaderMap; + use super::*; - use crate::consent::jurisdiction::Jurisdiction; + use crate::evidence::OwnedRequestInfo; + use crate::permission_signal::SignalInput; + use crate::permissions::{PermissionSet, ValidSignal}; + use crate::test_support::tests::create_test_settings; + + /// A module that grants every permission, standing in for a scheme that + /// answered a prompt, so the assembly rules can be exercised without any + /// real scheme in core. + struct Granting; + + impl PermissionSignalModule for Granting { + fn id(&self) -> &'static str { + "granting" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Grant + } + } + + /// A module that declares a terms document, standing in for a terms + /// scheme such as Model Terms for Marketing, which is the next module + /// and is not one of the four that ship here. + struct DeclaringTerms; + + impl PermissionSignalModule for DeclaringTerms { + fn id(&self) -> &'static str { + "declaring-terms" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn tdls( + &self, + _consent: &ConsentContext, + _evidence: &dyn crate::evidence::RequestInfo, + ) -> Vec { + vec![ + crate::tdl::Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ] + } + } + + fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::new(String::new(), HeaderMap::new()) + } + + fn granting() -> Vec> { + vec![Arc::new(Granting)] + } + + fn assembled( + consent: &ConsentContext, + geo: GeoStatus<'_>, + modules: &[Arc], + ) -> PermissionState { + assemble_permissions( + PermissionMaps::sample(), + consent, + &no_evidence(), + geo, + modules, + ) + } + + 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, + } + } + + /// A module that could grant storage and has not, standing in for a + /// prompt that has not been answered yet. + struct Undecided; + + impl PermissionSignalModule for Undecided { + fn id(&self) -> &'static str { + "undecided" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none().with(Permission::StoreOnDevice) + } + } #[test] - fn ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, + fn only_a_permission_a_module_could_still_grant_is_awaited() { + // Arrange: the top node requires a signal for storage and for the + // marketing channels, and the one module could grant storage only. + let modules: Vec> = vec![Arc::new(Undecided)]; + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &modules); + + // Assert: storage is awaited, and a channel nothing here could grant + // is neither set nor awaited. + assert!( + state.is_awaited(Permission::StoreOnDevice), + "should await the permission the module has yet to answer" + ); + let email = Permission::all() + .find(|permission| permission.as_str() == "advertising_marketing.communications.email") + .expect("the taxonomy should carry the email channel"); + assert!( + !state.is_awaited(email) && !state.is_set(email), + "should not tell a page to wait for a permission no module can grant" + ); + } + + #[test] + fn nothing_is_awaited_when_no_module_runs() { + // A publisher acting on no signal at all has nothing to wait for. + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); + assert!( + state.awaiting().is_empty(), + "with no module nothing can arrive, so nothing is awaited" + ); + } + + #[test] + fn a_module_declaring_terms_reaches_the_assembled_state() { + let consent = ConsentContext::default(); + let modules: Vec> = vec![Arc::new(DeclaringTerms)]; + let state = assemble_permissions( + PermissionMaps::sample(), + &consent, + &no_evidence(), + GeoStatus::NoLocation, + &modules, + ); + let addresses: Vec<&str> = state.tdls().iter().map(crate::tdl::Tdl::as_str).collect(); + assert_eq!( + addresses, + vec!["https://terms.example.com/marketing/2.txt"], + "should carry the terms the module declared through to whatever reads the state" + ); + } + + #[test] + fn a_state_assembled_from_the_shipped_kind_of_module_declares_no_terms() { + let consent = ConsentContext::default(); + let state = assemble_permissions( + PermissionMaps::sample(), + &consent, + &no_evidence(), + GeoStatus::NoLocation, + &granting(), + ); + assert!( + state.tdls().is_empty(), + "should declare nothing, because a scheme carrying no terms says nothing about them" + ); + } + + #[test] + fn hmac_module_is_blocked_without_a_storage_signal() { + let settings = create_test_settings(); + // The test settings select the HMAC module, which requires + // necessary.operations.storage. The policy's top node resolves storage + // as requires-signal, so with no module granting it the permission is + // not set and the module's requirement is not met. + let module = crate::ec::module::build_module(&settings.ec, None, None) + .expect("should build the configured module") + .expect("should select the hmac module"); + let state = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); + assert!( + !state.all_set(module.required_permissions()), + "the requires-signal default should not satisfy the HMAC module without a signal" + ); + } + + #[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 = assembled(&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 a_grant_sets_a_requires_signal_permission() { + // The top node requires a signal for storage, and a module granting + // it is what a signal arriving looks like from here. + let state = assembled( + &ConsentContext::default(), + GeoStatus::NoLocation, + &granting(), + ); + assert!( + state.is_set(Permission::StoreOnDevice), + "a module granting storage should set it under a requires-signal baseline" + ); + assert!( + !state.storage_withdrawn(), + "and a grant is the opposite of a withdrawal" + ); + } + + // ------------------------------------------------------------------ + // An unreadable record is its own scheme's module's to answer. Core + // answers nothing ahead of the modules, so with none configured the + // baseline stands whatever the request carries. + // ------------------------------------------------------------------ + + #[test] + fn core_answers_nothing_ahead_of_the_modules() { + let consent = ConsentContext { + raw_tc_string: Some("this is not a TC string".to_owned()), ..ConsentContext::default() }; - + let geo = us_ca_geo(); + let state = assembled(&consent, GeoStatus::Located(&geo), &[]); + assert!( + state.is_set(Permission::StoreOnDevice), + "with no module configured, what a TC string says or fails to say is nobody's \ + to answer, so the baseline stands" + ); assert!( - ec_consent_granted(&ctx), - "non-regulated requests should be allowed" + state.signals().is_empty(), + "and nothing vouches for the string, so it is not a valid signal" ); } + /// A module that vouches for whatever TC string the request carries, + /// standing in for a scheme that decoded it. + struct VouchingForTcf; + + impl PermissionSignalModule for VouchingForTcf { + fn id(&self) -> &'static str { + "vouching" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn valid_signal( + &self, + consent: &ConsentContext, + _evidence: &dyn crate::evidence::RequestInfo, + ) -> Option { + consent + .raw_tc_string + .as_deref() + .map(|raw| ValidSignal::new("vouching", "tcf", raw)) + } + } + #[test] - fn ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn the_assembled_state_carries_the_signals_the_modules_vouched_for() { + let consent = ConsentContext { + raw_tc_string: Some("CPxyz".to_owned()), ..ConsentContext::default() }; + let modules: Vec> = vec![Arc::new(VouchingForTcf)]; + let state = assembled(&consent, GeoStatus::NoLocation, &modules); + assert_eq!( + state.signals(), + &[ValidSignal::new("vouching", "tcf", "CPxyz")], + "should carry the signal as received, attributed to the module that read it" + ); + } + #[test] + fn the_context_keeps_only_what_was_vouched_for() { + let mut consent = ConsentContext { + raw_tc_string: Some("CPxyz".to_owned()), + raw_gpp_string: Some("not a GPP string".to_owned()), + gpp_section_ids: Some(vec![7]), + raw_us_privacy: Some("1YNN".to_owned()), + ..ConsentContext::default() + }; + consent.keep_only(&[ValidSignal::new("vouching", "tcf", "CPxyz")]); + assert_eq!(consent.raw_tc_string.as_deref(), Some("CPxyz")); + assert!( + consent.raw_gpp_string.is_none() && consent.gpp_section_ids.is_none(), + "a GPP string nobody vouched for goes no further, with its section ids" + ); assert!( - !ec_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + consent.raw_us_privacy.is_none(), + "a US Privacy string nobody vouched for goes no further" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn an_unreadable_record_is_not_a_withdrawal() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), ..ConsentContext::default() }; + let state = assembled(&consent, GeoStatus::NoLocation, &granting()); + assert!( + !state.storage_withdrawn(), + "an unreadable record fails closed by suppression, never destructively" + ); + } + + #[test] + fn running_no_modules_leaves_the_place_baseline() { + let geo = us_ca_geo(); + let state = assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]); + assert!( + state.is_set(Permission::StoreOnDevice), + "an empty list is a publisher acting on no signal at all, so only the country \ + and region rules apply" + ); + assert!(!state.storage_withdrawn(), "and nothing can have withdrawn"); + } + + // ------------------------------------------------------------------ + // 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!( + assembled(&ConsentContext::default(), GeoStatus::Located(&geo), &[]) + .is_set(Permission::StoreOnDevice), + "the located baseline must grant storage, or this test proves nothing" + ); + let state = assembled(&ConsentContext::default(), GeoStatus::Failed, &[]); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a lookup failure must not fall back to any node of the policy tree" + ); + assert_eq!( + storage_acquisition(PermissionMaps::sample(), 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 sample 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 = assembled(&ConsentContext::default(), GeoStatus::NoLocation, &[]); assert!( - !ec_consent_withdrawn(&ctx), - "unknown jurisdiction should block creation without revoking existing EC" + !state.is_set(Permission::StoreOnDevice), + "the top node requires a signal for storage" + ); + assert_eq!( + storage_acquisition(PermissionMaps::sample(), 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(PermissionMaps::sample(), GeoStatus::NoLocation), + Jurisdiction::Gdpr, + "no location should resolve the top node's declared jurisdiction" + ); + assert_eq!( + default_jurisdiction(PermissionMaps::sample(), GeoStatus::Failed), + Jurisdiction::Unknown, + "a failed lookup must not adopt the policy's declared jurisdiction" + ); + } + + #[test] + fn the_loaded_policy_decides_and_not_the_sample() { + // A policy whose whole world sets device storage without a signal. + let open = PermissionMaps::from_yaml( + "groups:\n open:\n default: granted\nrules:\n group: open\n jurisdiction: non-regulated\n", + ) + .expect("should parse the open policy"); + + let state = assemble_permissions( + &open, + &ConsentContext::default(), + &no_evidence(), + GeoStatus::NoLocation, + &[], + ); + assert!( + state.is_set(Permission::StoreOnDevice), + "the open policy should set storage without a signal" + ); + assert_eq!( + storage_acquisition(&open, GeoStatus::NoLocation), + Acquisition::Granted + ); + assert_eq!( + default_jurisdiction(&open, GeoStatus::NoLocation), + Jurisdiction::NonRegulated + ); + assert_ne!( + storage_acquisition(PermissionMaps::sample(), GeoStatus::NoLocation), + Acquisition::Granted, + "the sample should not grant storage without a signal, or this test proves nothing" ); } } diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 2d6c7ac614..6f017647f3 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -140,6 +140,14 @@ pub trait DeviceModule: Send + Sync { /// so this is infallible: a module 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 module's data use requires. + /// + /// The default is empty, so the built-in User-Agent-only module requires + /// no permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } } /// The built-in device module, the default. @@ -811,6 +819,14 @@ mod tests { } } + #[test] + fn builtin_device_module_requires_no_permissions() { + assert!( + BuiltinDeviceModule::new().required_permissions().is_empty(), + "the built-in User-Agent-only device module requires no permissions" + ); + } + #[test] fn build_device_module_defaults_to_builtin_and_selects_injected() { // The default selector returns the built-in module, ignoring the diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 0fe56dfcad..ac004d4b8a 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,12 +8,10 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use crate::consent::{ConsentContext, allows_eid_persistence}; use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; -use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::kv::{ CreateIfAbsentOutcome, EidCookieSyncOutcome, KvIdentityGraph, PartnerIdUpdate, @@ -28,7 +26,7 @@ use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; /// Finalizes EC response behavior for all routes. /// -/// Applies the resolved consent gate, cookie reconciliation, Prebid EID +/// Applies the resolved permission state, cookie reconciliation, Prebid EID /// ingestion, and cookie writes for new EC generation. /// /// When the request carries an explicit withdrawal signal (a storage opt-out or @@ -66,12 +64,14 @@ pub fn ec_finalize_response( ec_context.validate_pull_sync_marker(settings, registry); let ec_permitted = ec_context.ec_allowed(); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); if !ec_permitted { + // The modules answer withdrawal when the permissions are assembled, so + // the answer is read here rather than decoded again from the consent record. + let storage_withdrawn = ec_context.storage_withdrawn(); // Expire the request-local marker independently of the EC cookie: a // withdrawal must stop any pending pull-sync disclosure window. - if consent_withdrawn && ec_context.pull_sync_marker().was_present() { + if storage_withdrawn && ec_context.pull_sync_marker().was_present() { expire_marker(ec_context.pull_sync_marker_mut(), response); } @@ -80,7 +80,7 @@ pub fn ec_finalize_response( ec_context, kv, registry, - consent_withdrawn, + storage_withdrawn, response, ); return; @@ -105,7 +105,7 @@ pub fn ec_finalize_response( }) .unwrap_or_default(); let had_eid_updates = !collected.is_empty(); - let updates = gate_eid_updates_by_consent(collected, ec_context.consent()); + let updates = gate_eid_updates_by_permissions(collected, ec_context); // `sync_eid_cookie_updates` early-returns without refreshing the // snapshot when `updates` is empty, which is correct when there // was never anything to write (an unconfigured registry, no sync @@ -156,14 +156,14 @@ pub fn ec_finalize_response( return; }; - let updates = gate_eid_updates_by_consent( + let updates = gate_eid_updates_by_permissions( collect_eid_updates( eids_cookie, sharedid_cookie, ec_context.client_eids(), registry, ), - ec_context.consent(), + ec_context, ); sync_eid_cookie_updates(graph, ec_context, &kv_key, &updates, EidSyncSource::NewEc); if ec_context.kv_snapshot().entry_for(&kv_key).is_some() { @@ -260,27 +260,27 @@ fn reconcile_pull_sync_marker( ); } -/// Withholds EID-derived KV updates when consent does not allow EID -/// persistence, on top of the Purpose 1 (EC) gate `ec_finalize_response` -/// already enforces before reaching this point. +/// Withholds EID-derived KV updates unless the request's permissions allow +/// sharing, on top of the storage gate `ec_finalize_response` already enforces +/// before reaching this point. /// -/// `consent_allows_ec` only requires Purpose 1 (device storage), but EIDs -/// additionally require Purpose 4 (personalized ads) — the same rule -/// [`gate_eids_by_consent`](crate::consent::gate_eids_by_consent) applies to -/// the outbound `/auction` bid request, evaluated here through -/// [`allows_eid_persistence`]. Without this, a user who denies Purpose 4 -/// would have their EIDs correctly stripped from the bid request but still -/// written to the identity graph from the `ts-eids`/`sharedId` cookies or the -/// `/auction` request body. -fn gate_eid_updates_by_consent( +/// The storage permission alone lets the identifier exist. Partner UIDs also +/// need the personalised-ad permission, the pair +/// [`gate_eids_by_permissions`](crate::consent::gate_eids_by_permissions) +/// applies to the outbound `/auction` bid request, answered here by +/// [`EcContext::ec_sharing_allowed`]. Without this, a reader who withheld the +/// personalised-ad permission would have partner UIDs stripped from the bid +/// request but still written to the identity graph from the `ts-eids` and +/// `sharedId` cookies or the `/auction` request body. +fn gate_eid_updates_by_permissions( updates: Vec, - consent: &ConsentContext, + ec_context: &EcContext, ) -> Vec { - if updates.is_empty() || allows_eid_persistence(consent) { + if updates.is_empty() || ec_context.ec_sharing_allowed() { return updates; } log::debug!( - "EC KV: withholding {} EID updates, EID consent (TCF Purpose 1 + 4) missing", + "EC KV: withholding {} EID updates, the storage and personalised-ad permissions are not both set", updates.len() ); Vec::new() @@ -814,14 +814,18 @@ mod tests { // identity this deployment never issued must not put a row in the // identity graph. let ec_id = sample_ec_id("zz9999"); + // A TCF record refusing storage under the requires-signal floor is + // the withdrawal trigger. The TCF module answers that at assembly, + // and core links no module, so the answer is stated here. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); let kv = KvIdentityGraph::in_memory("test-store"); let mut response = empty_response(); let registry = PartnerRegistry::from_config(&[]).expect("should build registry"); @@ -855,14 +859,18 @@ mod tests { fn finalize_withdrawal_tombstones_a_held_identity() { let settings = create_test_settings(); let ec_id = sample_ec_id("held01"); + // A TCF record refusing storage under the requires-signal floor is + // the withdrawal trigger. The TCF module answers that at assembly, + // and core links no module, so the answer is stated here. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); let kv = KvIdentityGraph::stale_lookup("test-store", 1); kv.create( &ec_id, @@ -911,14 +919,18 @@ mod tests { // marker is exactly what goes missing. let settings = create_test_settings(); let ec_id = sample_ec_id("dead01"); + // A TCF record refusing storage under the requires-signal floor is + // the withdrawal trigger. The TCF module answers that at assembly, + // and core links no module, so the answer is stated here. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); ec_context.set_kv_snapshot(EcKvSnapshot::Present { ec_id: ec_id.clone(), entry: Box::new(live_entry()), @@ -958,14 +970,20 @@ 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, under a + // storage baseline at the requires-signal floor, where refusing the + // signal storage depends on is destructive. The TCF module answers + // that at assembly, and core links no module, so the answer is + // stated here and what finalization does with it is what is tested. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; let mut ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -1011,6 +1029,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 mut 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, + &mut 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(); @@ -1185,7 +1262,8 @@ mod tests { purpose1_only_consent, Some("192.0.2.11".to_owned()), ) - .with_module_for_test(crate::ec::tests::hmac_module()); + .with_module_for_test(crate::ec::tests::hmac_module()) + .with_permissions_for_test(crate::test_support::tests::storage_only_permissions()); ec_context.set_recovery_eligible(true); ec_context.set_eid_sync_source(EidSyncSource::Navigation); ec_context.set_client_eids(vec![Eid { @@ -1670,7 +1748,8 @@ mod tests { false, purpose1_only_consent, true, - ); + ) + .with_permissions_for_test(crate::test_support::tests::storage_only_permissions()); ec_context.set_kv_snapshot(EcKvSnapshot::Missing { ec_id: ec_id.clone(), }); @@ -2280,10 +2359,10 @@ mod tests { } #[test] - fn the_consent_gate_decides_whether_a_generated_ec_cookie_is_written() { + fn the_permission_gate_decides_whether_a_generated_ec_cookie_is_written() { // The generated identifier has a backing row and a snapshot bound to // it, so the backing-row guard would let the cookie through and the - // consent gate is the only thing that can withhold it. + // permission gate is the only thing that can withhold it. let settings = create_test_settings(); for (case, gate_open) in [("an open gate", true), ("a closed gate", false)] { let ec_id = sample_ec_id("gated1"); @@ -2324,7 +2403,7 @@ mod tests { .any(|cookie| cookie.starts_with("ts-ec=") && cookie.contains(&ec_id)); assert_eq!( wrote_ec_cookie, gate_open, - "{case}: the ts-ec cookie should be written only when the consent gate is open" + "{case}: the ts-ec cookie should be written only when the permission gate is open" ); } } @@ -2335,15 +2414,24 @@ mod tests { // land on the key the live row uses. Written under the raw cookie // value it creates a second row nothing reads, and the revocation // never takes effect for a module whose canonical form differs. + // + // Destructive withdrawal is narrow, so the trigger here is a TCF record + // refusing storage, the same one + // `finalize_withdrawal_clears_cookie_and_headers` uses. An opt-out such + // as GPC suppresses use without destroying an issued identifier, so it + // would write no tombstone for this test to place. let settings = create_test_settings(); let graph = graph_with_live_canonical_row(); let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; - let mut ec_context = canonicalizing_context(true, false, consent, false); + // The TCF module answers the withdrawal at assembly, and core links + // no module, so the answer is stated here. + let mut ec_context = canonicalizing_context(true, false, consent, false) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); ec_finalize_response( @@ -2613,6 +2701,8 @@ mod tests { // created, but the deployment now runs a module with a different // code, so read-back treats the cookie as absent and the active // identifier is empty. + // The TCF module answers the withdrawal at assembly, and core links + // no module, so the answer is stated here. let mut ec_context = make_context_with_consent( None, Some(CANONICAL_COOKIE_VALUE), @@ -2621,7 +2711,8 @@ mod tests { consent, false, ) - .with_module_for_test(std::sync::Arc::new(SwitchedModule)); + .with_module_for_test(std::sync::Arc::new(SwitchedModule)) + .with_storage_withdrawn_for_test(true); let mut response = empty_response(); ec_finalize_response( @@ -2814,9 +2905,12 @@ mod tests { let settings = create_test_settings(); let active_ec = sample_ec_id("activ2"); let cookie_ec = sample_ec_id("cook2e"); + // A TCF record refusing storage under the requires-signal floor is + // the withdrawal trigger. The TCF module answers that at assembly, + // and core links no module, so the answer is stated here. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; @@ -2827,7 +2921,8 @@ mod tests { false, consent, false, - ); + ) + .with_storage_withdrawn_for_test(true); // Carry a snapshot only for the active ID; the other ID must be looked up // independently and never created if absent. let graph = KvIdentityGraph::in_memory("test_store"); @@ -2879,7 +2974,8 @@ mod tests { false, consent, false, - ); + ) + .with_storage_withdrawn_for_test(true); let graph = KvIdentityGraph::in_memory("test_store"); graph .create( @@ -2973,7 +3069,8 @@ mod tests { ..Default::default() }; let mut ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false) + .with_storage_withdrawn_for_test(true); ec_context.set_pull_sync_marker_for_test( crate::ec::pull_sync_marker::PullSyncMarkerState::Invalid, ); @@ -3108,7 +3205,8 @@ mod tests { source: ConsentSource::Cookie, ..Default::default() }; - let mut ec_context = make_context_with_consent(None, None, false, false, consent, false); + let mut ec_context = make_context_with_consent(None, None, false, false, consent, false) + .with_storage_withdrawn_for_test(true); ec_context.set_pull_sync_marker_for_test( crate::ec::pull_sync_marker::PullSyncMarkerState::Invalid, ); diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index cf017a04a5..7e514e11de 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -61,7 +61,11 @@ pub fn handle_identify( ); }; - if !ec_context.ec_allowed() { + // Identify returns the partner's UID for this visitor, which is sharing + // the identity beyond the edge, so it needs the same permission pair as + // bidstream EIDs (storage plus personalised-ad selection), not only the + // module's storage permission. + if !ec_context.ec_sharing_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 13ce8614f6..8f49fbfa97 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -17,7 +17,7 @@ //! - [`admin`]: operator endpoints that show the stored identity state //! (`GET /_ts/admin/ec`) //! - [`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 //! - [`finalize`]: the cookie and identity-graph writes made on the response //! after routing @@ -89,6 +89,7 @@ use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; use crate::openrtb::Eid; +use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -252,10 +253,14 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, - /// Whether Edge Cookie creation is allowed for this request. Resolved once - /// at construction: closed when no module is selected, otherwise from the - /// request's consent context. Read via [`ec_allowed`](Self::ec_allowed). + /// Whether the configured Edge Cookie module's required permissions are + /// set for this request. Resolved once at construction through the + /// permission model and read via [`ec_allowed`](Self::ec_allowed). ec_allowed: bool, + /// The permissions resolved for this request: the country/region baseline + /// augmented by the session's signals. Assembled once at construction and + /// read via [`permissions`](Self::permissions). + permissions: PermissionState, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -340,15 +345,71 @@ impl EcContext { services: &RuntimeServices, geo_info: Option<&GeoInfo>, ) -> Result> { + Self::read_from_request_with_geo_status( + settings, + req, + services, + consent::GeoStatus::from(geo_info), + ) + } + + /// Reads the EC context, resolving the location through the configured geo + /// module 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 module + /// cannot be built, the same as + /// [`read_from_request_with_geo`](Self::read_from_request_with_geo). + pub fn read_from_request_resolving_geo( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + ) -> Result> { + let lookup = services.geo().lookup(services.client_info().client_ip); + let geo_info = match &lookup { + Ok(info) => info.clone(), + Err(error) => { + log::error!( + "geo lookup failed; resolving permissions at the requires-signal floor: {error:?}" + ); + None + } + }; + let status = match (&lookup, &geo_info) { + (Err(_), _) => consent::GeoStatus::Failed, + (Ok(_), Some(info)) => consent::GeoStatus::Located(info), + (Ok(_), None) => consent::GeoStatus::NoLocation, + }; + Self::read_from_request_with_geo_status(settings, req, services, status) + } + + fn read_from_request_with_geo_status( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo_status: consent::GeoStatus<'_>, + ) -> Result> { + let geo_info = geo_status.info(); let parsed = parse_ec_from_request(req)?; // Take the selected module once. It is used here to decide whether - // the incoming cookie value is a usable identifier, and again by - // generation, which reuses this one rather than asking for another. - // `request_module` 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 - // module built from request evidence reads this request's evidence. + // the incoming cookie value is a usable identifier, to read the + // module's required permissions, and again by generation, which + // reuses this one rather than asking for another. `request_module` + // 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 module built from + // request evidence reads this request's evidence. A module that needs + // a service the host did not supply fails to resolve, which stops the + // request. let selected_module: Option> = module::request_module(&settings.ec, services)?; @@ -396,22 +457,54 @@ impl EcContext { .client_ip .map(generation::normalize_ip); + // The policy the request's domain loaded decides its permissions. + let maps = services.permission_policy().maps(); + // 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(maps, geo_status), + permission_maps: maps, }); - // Gate Edge Cookie creation and use on the request's consent context - // (jurisdiction and consent signals). With no module selected nothing - // may create or use an identifier, so the gate is closed rather than - // open by default. Downstream consumers read the stored result via - // [`EcContext::ec_allowed`] rather than re-deriving it. + // Assemble the permission state once, here, through the permission + // model, building the country/region baseline amended by what the + // signal modules the adapter selected say about the request. + // Downstream consumers read the stored result via + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. The modules see the request as evidence, the same + // abstraction the Edge Cookie and device modules read, so a scheme + // core has never heard of can read its own signal from it. + let evidence = crate::evidence::BorrowedRequestInfo::new( + client_ip.as_deref().unwrap_or_default(), + Some(req.headers()), + ) + .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let permissions = consent::assemble_permissions( + maps, + &consent, + &evidence, + geo_status, + services.permission_signal_modules(), + ); + // A signal no module vouched for goes no further. The module for + // its scheme has already decided what its absence means, so what is + // forwarded is exactly what the permissions were built from. + let mut consent = consent; + consent.keep_only(permissions.signals()); + // With no module selected nothing may create or use an identifier, so + // the gate is closed rather than open by default. let ec_allowed = selected_module .as_ref() - .is_some_and(|_| consent::ec_consent_granted(&consent)); + .is_some_and(|selected| permissions.all_set(selected.required_permissions())); log::info!( "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", @@ -428,6 +521,7 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, client_ip, geo_info: geo_info.cloned(), device_signals: None, @@ -492,7 +586,7 @@ impl EcContext { Jurisdiction::Unknown => "unknown", }; log::info!( - "EC generation skipped: EC creation not permitted (jurisdiction={jurisdiction})" + "EC generation skipped: required permissions not set (jurisdiction={jurisdiction})" ); return Ok(()); } @@ -537,6 +631,7 @@ impl EcContext { ec_module: &dyn EdgeCookieModule, ) -> Result, Report> { let input = IdentityInput { + permissions: Some(&self.permissions), consent: Some(&self.consent), }; let request_info = BorrowedRequestInfo::new( @@ -618,7 +713,7 @@ impl EcContext { /// colliding identifier never overwrites another identity's row and the /// next attempt asks the module again. The row is keyed by the module's /// canonical form of the identifier, and the request snapshot is bound to - /// that key. The skip guards (existing EC, consent gate) stay in + /// that key. The skip guards (existing EC, permission gate) stay in /// [`generate_if_needed`](Self::generate_if_needed). /// /// The response headers a module response asks for stay on the context @@ -809,8 +904,9 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed), - /// which is resolved once at construction. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed) or + /// [`permissions`](Self::permissions), which are resolved once at + /// construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -946,16 +1042,57 @@ impl EcContext { self.selected_module.clone() } - /// Returns whether Edge Cookie creation is allowed for this request. + /// Returns whether the configured Edge Cookie module's required + /// permissions are set for this request. /// - /// Resolved once at construction: closed when no module is selected, - /// otherwise from the request's consent context (see - /// [`consent::ec_consent_granted`]). + /// Resolved once at construction through the permission model (see + /// [`consent::assemble_permissions`]). #[must_use] pub fn ec_allowed(&self) -> bool { self.ec_allowed } + /// Whether the request carries an explicit signal withdrawing Edge Cookie + /// storage, scoped to the jurisdiction's storage baseline. + /// + /// Answered by the signal modules at construction and recorded on the + /// permission state, see [`PermissionState::storage_withdrawn`]. Of the + /// schemes that ship, only a TCF record refusing storage withdraws, and + /// only where the storage baseline is not `granted`. Suppression (the + /// permission merely not set) is reported by + /// [`ec_allowed`](Self::ec_allowed) being `false` instead. + #[must_use] + pub fn storage_withdrawn(&self) -> bool { + self.permissions.storage_withdrawn() + } + + /// 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 + /// module'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 module execution on these, and a + /// consumer may read them for its own logic. + #[must_use] + pub fn permissions(&self) -> &PermissionState { + &self.permissions + } + /// Returns the existing EC cookie value for revocation handling. /// /// When consent is withdrawn, this value is needed to identify the @@ -988,21 +1125,19 @@ impl EcContext { self } - /// Creates a test-only `EcContext` whose creation gate is derived from the - /// consent context, matching the production construction path. + /// Creates a test-only `EcContext` with the permission gate open. /// /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs - /// an explicit gate. + /// the gate closed. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { - let ec_allowed = consent::ec_consent_granted(&consent); - Self::new_for_test_gated(ec_value, consent, ec_allowed) + Self::new_for_test_gated(ec_value, consent, true) } - /// Creates a test-only `EcContext` with an explicit creation gate. + /// Creates a test-only `EcContext` with an explicit permission gate. /// - /// `ec_allowed` stands in for the gating decision the production path + /// `ec_allowed` stands in for the permission decision the production path /// resolves at construction, so a test can exercise the gate-open and /// gate-closed branches directly. #[cfg(test)] @@ -1012,6 +1147,7 @@ impl EcContext { consent: ConsentContext, ec_allowed: bool, ) -> Self { + let permissions = permissions_for_test(ec_allowed); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), @@ -1019,6 +1155,7 @@ impl EcContext { ec_generated: false, consent, ec_allowed, + permissions, client_ip: None, geo_info: None, device_signals: None, @@ -1035,6 +1172,21 @@ impl EcContext { } } + /// Creates a test-only `EcContext` whose request explicitly withdrew + /// device storage, rather than merely not setting it. + /// + /// Withdrawal is destructive, expiring the browser cookie, where + /// suppression is not, so a test of the destructive path has to say which + /// of the two it means. [`new_for_test_gated`](Self::new_for_test_gated) + /// with `false` gives suppression. + #[cfg(test)] + #[must_use] + pub fn new_for_test_withdrawn(ec_value: Option, consent: ConsentContext) -> Self { + let mut context = Self::new_for_test_gated(ec_value, consent, false); + context.permissions = PermissionState::default().with_storage_withdrawn(true); + context + } + /// Creates a test-only [`EcContext`] with explicit client IP. #[cfg(test)] #[must_use] @@ -1043,14 +1195,14 @@ impl EcContext { consent: ConsentContext, client_ip: Option, ) -> Self { - let ec_allowed = consent::ec_consent_granted(&consent); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, - ec_allowed, + ec_allowed: true, + permissions: permissions_for_test(true), client_ip, geo_info: None, device_signals: None, @@ -1086,6 +1238,7 @@ impl EcContext { ec_generated, consent, ec_allowed, + permissions: permissions_for_test(ec_allowed), client_ip: None, geo_info: None, device_signals: None, @@ -1101,6 +1254,45 @@ impl EcContext { eid_sync_source: None, } } + + /// The same context with the permissions a test states, in place of the + /// ones its constructor stands in for. + #[cfg(test)] + #[must_use] + pub fn with_permissions_for_test(mut self, permissions: PermissionState) -> Self { + self.permissions = permissions; + self + } + + /// The same context, recording that the request explicitly withdrew + /// storage, as assembly records it when a signal module answers so. + /// + /// Core links no module, so a core test cannot derive the withdrawal + /// from a consent record the way a deployment does through the TCF + /// module. It states the answer instead and tests what finalization does + /// with it. That a TCF refusal produces this answer is proved where the + /// modules are linked, in the Axum adapter's `permission_signals` test. + #[cfg(test)] + #[must_use] + pub fn with_storage_withdrawn_for_test(mut self, withdrawn: bool) -> Self { + self.permissions = self.permissions.with_storage_withdrawn(withdrawn); + self + } +} + +/// The permissions a test context stands in for: storage and personalised-ad +/// selection when the gate is open, none when it is closed. +#[cfg(test)] +fn permissions_for_test(ec_allowed: bool) -> PermissionState { + if ec_allowed { + PermissionState::new( + [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] + .into_iter() + .collect(), + ) + } else { + PermissionState::default() + } } /// Returns the current Unix timestamp in seconds, falling back to zero on clock failure. @@ -1347,7 +1539,13 @@ pub(crate) mod tests { } /// A geo that resolves to the non-regulated jurisdiction (US, no region), - /// so the consent gate is open and generation runs in module tests. + /// so the permission gate is open and generation runs. + /// + /// A test that drives a built-in module needs this. The test default + /// country is FR, whose baseline requires a signal before + /// `StoreOnDevice` is set, so the built-in modules 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(), @@ -1613,8 +1811,7 @@ pub(crate) mod tests { // No existing cookie, so the edge creates one and persists it. let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, Some(&graph)) .expect("should generate and persist"); @@ -1661,8 +1858,7 @@ pub(crate) mod tests { .expect("should build request"); let services = noop_services_with_ec_module_without_client_ip(module.clone()); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); assert_eq!( ec.client_ip(), @@ -1751,8 +1947,7 @@ pub(crate) mod tests { settings.ec.module = Some(EcModuleSelection::from("illegal")); let services = noop_services_with_ec_module(Arc::new(IllegalIdModule)); let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); let err = ec @@ -1825,8 +2020,7 @@ pub(crate) mod tests { settings.ec.module = Some(EcModuleSelection::from("header_setting")); let services = noop_services_with_ec_module(Arc::new(module)); let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); let outcome = ec.generate_if_needed(&settings, graph); (settings, ec, outcome) @@ -2170,8 +2364,7 @@ pub(crate) mod tests { let services = noop_services_with_ec_module(Arc::new(CanonicalizingModule)); let graph = KvIdentityGraph::in_memory("test-ec-store"); let req = create_test_request(&[]); - let geo = non_regulated_geo(); - let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + let mut ec = EcContext::read_from_request(&settings, &req, &services) .expect("should read EC context"); ec.generate_if_needed(&settings, Some(&graph)) .expect("should generate and persist"); @@ -2201,8 +2394,10 @@ pub(crate) mod tests { #[test] fn hmac_creates_a_coded_identifier_and_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 geo = non_regulated_geo(); let services = crate::platform::test_support::noop_services_with_client_ip( std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), ); @@ -2221,8 +2416,9 @@ pub(crate) mod tests { 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(&settings, &req, &noop_services()) - .expect("should read EC context"); + 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()), @@ -2291,6 +2487,115 @@ pub(crate) mod tests { ); } + /// A geo module whose lookup fails, the state the permission model's + /// fail-closed rule exists for. + /// + /// No geo module shipped in this workspace can fail: the Fastly SDK's + /// `geo_lookup` returns an `Option`, the Cloudflare module reads request + /// headers, and the Axum and Spin modules resolve nothing at all. The + /// `Result` on [`PlatformGeo::lookup`] is there for a module 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_module_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 module 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 module gate is open"); + assert!( + !ec.ec_sharing_allowed(), + "storage alone must not allow sharing beyond the edge" + ); + } + #[test] fn kv_snapshot_distinguishes_non_present_states() { assert!(EcKvSnapshot::NotRead.entry_for("ec-1").is_none()); diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index 126888aa33..59a5dfdfd0 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -34,6 +34,7 @@ use serde::{Deserialize, Serialize}; use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::{HostSignals, RequestInfo}; +use crate::permissions::{Permission, PermissionSet, PermissionState}; use crate::redacted::Redacted; use crate::settings::{Ec, EcModuleBlock}; @@ -152,15 +153,19 @@ pub const HMAC_MODULE_CODE: ModuleCode = crate::module_code!(HMAC_MODULE_KEY); /// [`EdgeCookieModule::generate`], not through this struct and not through /// anything injected into the module's constructor. This struct carries only /// the per-request gating context a module may read for behavior beyond -/// gating. On the organic request path the gate has confirmed Edge Cookie -/// storage is allowed before `generate` is called. A test calling +/// gating. On the organic request path the gate has confirmed the module's +/// required permissions are set before `generate` is called. A test calling /// `edge_cookie::generate_ec_id` reaches `generate` without that gate. #[derive(Default)] pub struct IdentityInput<'a> { + /// The permissions resolved for this request, when the calling path carries + /// them. A module 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 module-specific - /// logic. The core gates generation before calling the module, so a - /// module reads this only to forward or record consent. [`HmacModule`] - /// ignores it. + /// logic. The core gates on permissions, not consent, so a module reads + /// this only to forward or record consent. [`HmacModule`] ignores it. pub consent: Option<&'a ConsentContext>, } @@ -554,9 +559,12 @@ impl<'a> AcceptedModules<'a> { /// A strategy for deriving an Edge Cookie identifier. /// -/// Implementations are selected by configuration. A module derives the -/// identifier at the edge in [`generate`](Self::generate), and the page -/// response sets the `ts-ec` cookie. +/// Implementations are selected by configuration and come in two types, which +/// reach the same outcome (a `ts-ec` cookie) by different routes: +/// +/// - **Server-side** (for example [`HmacModule`]): derives the identifier at +/// the edge in [`generate`](Self::generate), and the page response sets the +/// cookie. Nothing client-side is involved. /// /// A module that cannot derive an identifier at the edge returns a /// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so @@ -600,6 +608,7 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// Derives an Edge Cookie identifier from the request evidence in /// `request_info` and the gating context in `input`. /// + /// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. @@ -650,6 +659,17 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { fn normalize_id_for_kv(&self, value: &str) -> String { generation::normalize_ec_id_for_kv(value) } + + /// The permissions this module's data use requires. + /// + /// Trusted Server executes the module only when every permission returned + /// here is set. The default is empty, so a vendor-neutral module requires + /// no permission. A module 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 module. @@ -706,6 +726,13 @@ impl EdgeCookieModule for HmacModule { response_headers: Vec::new(), }) } + + fn required_permissions(&self) -> PermissionSet { + // The HMAC module 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 module. + PermissionSet::none().with(Permission::StoreOnDevice) + } } /// The built-in host-signal Edge Cookie module. @@ -770,6 +797,12 @@ impl EdgeCookieModule for HostSignalModule { 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 module. + PermissionSet::none().with(Permission::StoreOnDevice) + } } /// Refuses an injected module that claims an implementation core supplies @@ -1112,6 +1145,10 @@ impl EdgeCookieModule for SharedModule { 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)] @@ -1532,6 +1569,7 @@ mod tests { "an identifier with another module's code is never owned" ); } + use crate::permissions::PermissionMaps; use crate::redacted::Redacted; fn test_passphrase() -> Redacted { @@ -1779,7 +1817,21 @@ mod tests { } #[test] - fn host_signal_module_creates_from_signals() { + fn hmac_module_requires_store_on_device() { + let module = HmacModule::new(test_passphrase()); + let required = module.required_permissions(); + assert!( + required.contains(Permission::StoreOnDevice), + "the HMAC module writes a cookie, so it requires necessary.operations.storage" + ); + assert!( + !required.contains(Permission::SelectPersonalisedAds), + "the HMAC module requires no advertising permissions" + ); + } + + #[test] + fn host_signal_module_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()), @@ -1793,6 +1845,71 @@ mod tests { generated.id.is_some(), "the host-signal module should create an identifier from the signals" ); + assert!( + module + .required_permissions() + .contains(Permission::StoreOnDevice), + "the host-signal module writes a cookie, so it requires necessary.operations.storage" + ); + } + + /// A minimal module that overrides nothing optional, used to prove the + /// trait defaults. + #[derive(Debug)] + struct MinimalModule; + + impl EdgeCookieModule for MinimalModule { + fn id(&self) -> &'static str { + "minimal" + } + + fn code(&self) -> ModuleCode { + crate::module_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_module_requires_no_permissions_by_default() { + // MinimalModule does not override required_permissions, so it + // inherits the trait default of none and requires no permission. + assert!( + MinimalModule.required_permissions().is_empty(), + "a vendor-neutral module requires nothing by default" + ); + } + + #[test] + fn the_edge_cookie_gate_blocks_until_the_permission_is_set() { + let required = HmacModule::new(test_passphrase()).required_permissions(); + // Empty maps with no default: every permission is the requires-signal + // floor. + let maps = PermissionMaps::empty(); + + // No signal: the module'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 module without the permission set" + ); + + // A grant signal for necessary.operations.storage: the module's permission is now set. + assert!( + maps.resolve(None, |p| p == Permission::StoreOnDevice) + .all_set(required), + "the Edge Cookie module runs once necessary.operations.storage is set" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index a8cdc774a5..eb54dfd332 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -13,7 +13,6 @@ use http::{Method, StatusCode, header}; use serde::Deserialize; use url::Url; -use crate::consent::allows_eid_persistence; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, @@ -62,23 +61,22 @@ struct PullSyncResponse { /// Builds post-send pull-sync context from the route EC context. /// -/// Returns `None` when there are no pull-enabled partners, when consent denies -/// EC, when there is no active EC ID or no module this deployment reads owns -/// it, or when the request snapshot holds no consented row that is still -/// missing at least one pull-partner UID. +/// Returns `None` when there are no pull-enabled partners, when sharing is not +/// permitted, when there is no active EC ID or no module this deployment reads +/// owns it, or when the request snapshot holds no consented row that is still +/// missing at least one pull-partner UID. 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 module's storage permission. #[must_use] pub fn build_pull_sync_context( ec_context: &EcContext, registry: &PartnerRegistry, ) -> Option { - // Without EID-persistence consent (TCF Purpose 1 + 4), finalization - // withholds browser-supplied partner UIDs. That leaves partner slots empty, - // which pull sync would otherwise read as eligible and disclose the EC ID - // to partners for exactly the users who opted out. - if registry.pull_enabled_partners().is_empty() - || !ec_context.ec_allowed() - || !allows_eid_persistence(ec_context.consent()) - { + // Without storage and personalised-ad permissions, finalization withholds + // browser-supplied partner UIDs. That leaves partner slots empty, which pull + // sync would otherwise read as eligible and disclose the EC ID to partners + // for exactly the readers who withheld them. + if registry.pull_enabled_partners().is_empty() || !ec_context.ec_sharing_allowed() { return None; } @@ -686,26 +684,27 @@ mod tests { #[test] fn build_pull_sync_context_skips_when_eid_persistence_is_denied() { - // Purpose 1 granted keeps the EC, but Purpose 4 denied withholds EID - // writes and leaves partner slots empty. Pull sync must not read those + // Storage allowed keeps the EC, but no personalised-ad selection withholds + // EID writes and leaves partner slots empty. Pull sync must not read those // empty slots as eligible and disclose the EC ID to partners. let consent = purpose_one_only_consent(); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); + let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent) + .with_permissions_for_test(crate::test_support::tests::storage_only_permissions()); let graph = KvIdentityGraph::in_memory("pull_store"); ec_context.set_kv_snapshot(seed_present_snapshot(&graph, &ec_id)); let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); assert!( ec_context.ec_allowed(), - "should keep the EC allowed when Purpose 1 is granted" + "should keep the EC allowed when storage is allowed" ); let context = build_pull_sync_context(&ec_context, ®istry); assert!( context.is_none(), - "should skip pull sync when TCF Purpose 4 is denied" + "should skip pull sync when personalised-ad selection is not allowed" ); } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 2e7ed7c786..9814fcef7a 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -64,8 +64,9 @@ pub fn generate_ec_id( // host signals takes them from the injected `HostSignals` service rather // than from this request info. let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); - // This helper skips the consent gate, and the built-in module does not - // read the consent context, so it is not threaded here. + // This helper skips the permission gate, and the built-in module reads + // neither the resolved permissions nor the consent context, so they are + // not threaded here. let generated = module.generate(&request_info, &IdentityInput::default())?; let generated = crate::ec::module::GeneratedEdgeCookie { id: generated diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 1a95787399..fe6be30d61 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -68,6 +68,23 @@ pub trait RequestInfo: Send + Sync + core::fmt::Debug { url::form_urlencoded::parse(self.query().as_bytes()) .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) } + + /// The value of request cookie `name`, or `None` when the request does not + /// carry it. + /// + /// Defaulted, parsing the `Cookie` header, so a module that needs a + /// cookie does not write its own splitting and trimming and get it subtly + /// different from the next one. An implementation holding a parsed jar may + /// override it. Core keeps no list of which cookies belong to which + /// scheme, which is what lets a permission signal module for a scheme + /// core has never heard of read its own signal. + fn cookie(&self, name: &str) -> Option<&str> { + let header = self.header("cookie")?; + header.split(';').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key.trim() == name).then(|| value.trim()) + }) + } } /// An owned [`RequestInfo`] built from a request snapshot. @@ -250,6 +267,69 @@ pub trait HostSignals: Send + Sync + core::fmt::Debug { fn h2(&self) -> Option<&str>; } +#[cfg(test)] +mod cookie_tests { + use http::HeaderMap; + + use super::*; + + fn with_cookie_header(value: &'static str) -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert("cookie", http::HeaderValue::from_static(value)); + OwnedRequestInfo::new(String::new(), headers) + } + + #[test] + fn reads_a_cookie_by_name_from_among_several() { + let info = with_cookie_header("a=1; euconsent-v2=CP-abc; b=2"); + assert_eq!( + info.cookie("euconsent-v2"), + Some("CP-abc"), + "the named cookie is found wherever it sits in the header" + ); + } + + #[test] + fn trims_the_spaces_browsers_put_around_pairs() { + let info = with_cookie_header(" a = 1 ; b=2 "); + assert_eq!( + info.cookie("a"), + Some("1"), + "spaces around the name and value are not part of either" + ); + assert_eq!( + info.cookie("b"), + Some("2"), + "and so are the spaces around a later pair" + ); + } + + #[test] + fn answers_nothing_for_a_cookie_that_is_absent() { + let info = with_cookie_header("a=1"); + assert_eq!( + info.cookie("b"), + None, + "an absent cookie is None, not an empty value" + ); + assert_eq!( + OwnedRequestInfo::default().cookie("a"), + None, + "and so is a request with no Cookie header at all" + ); + } + + #[test] + fn matches_the_whole_name_and_not_a_prefix() { + let info = with_cookie_header("session_id=abc; id=xyz"); + assert_eq!( + info.cookie("id"), + Some("xyz"), + "`id` must not match `session_id`" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 3889d7d592..dea93ce3a2 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -79,6 +79,14 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub integrations: IntegrationRegistry, + /// Pre-computed + /// ``. + /// 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, @@ -118,6 +126,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, @@ -146,6 +155,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 @@ -268,6 +288,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(); @@ -300,10 +321,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); @@ -745,6 +773,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, @@ -1550,6 +1579,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, @@ -1627,6 +1657,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, @@ -1666,6 +1697,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, @@ -1704,6 +1736,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, @@ -1760,6 +1793,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, @@ -1790,6 +1824,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, @@ -1815,6 +1850,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, @@ -2076,6 +2112,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 a7c55bf864..1f5ca06353 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -866,6 +866,7 @@ mod tests { services, request, geo_info: None, + permissions: None, is_integration_route: false, })) }) @@ -906,6 +907,7 @@ mod tests { services, request: &mut request, geo_info, + permissions: None, is_integration_route: false, }, )); @@ -1099,6 +1101,7 @@ mod tests { services: &services, request: &mut request, geo_info: None, + permissions: None, is_integration_route: false, }, )) @@ -1685,6 +1688,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/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index e1e7c87e88..882048980d 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -616,7 +616,7 @@ mod tests { http_client: Arc, geo_result: GeoResult, ) -> RuntimeServices { - RuntimeServices::builder() + crate::platform::test_support::test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) 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 8dec90335f..4dfa5c5684 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -3076,6 +3076,9 @@ passphrase = "test-secret-key-32-bytes-minimum" 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 @@ -3110,6 +3113,9 @@ 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/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 6b301bf935..b1bea7cd84 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -640,6 +640,7 @@ mod tests { max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, suppress_datadome_client_side_tag: false, + permissions_script: None, }); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -1051,6 +1052,9 @@ mod tests { [ec] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index cab022ff43..62129a4782 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2726,8 +2726,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(), @@ -3741,7 +3742,7 @@ mod tests { } fn services_with_backend(backend: impl PlatformBackend + 'static) -> RuntimeServices { - RuntimeServices::builder() + crate::platform::test_support::test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -3997,6 +3998,9 @@ module = "hmac" [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +assume_single_jurisdiction = true "#; fn parse_browser_prebid_toml_result( diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index ba1876513f..e298a9004d 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -365,6 +365,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, } @@ -445,6 +450,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`]. @@ -984,6 +993,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()); @@ -995,6 +1005,7 @@ impl IntegrationRegistry { services, request: req, geo_info, + permissions, is_integration_route, }) .await?; @@ -1418,6 +1429,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}; @@ -1539,6 +1551,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") + .clone() + } + } + + #[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.cloned()); + Ok(RequestFilterDecision::Continue( + RequestFilterEffects::default(), + )) + } + } + struct EchoProxy; #[async_trait(?Send)] @@ -1725,6 +1776,7 @@ mod tests { services: &services, req: &mut req, geo_info: None, + permissions: None, })) .expect("should run request filter"); @@ -1751,6 +1803,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/lib.rs b/crates/trusted-server-core/src/lib.rs index 138493005f..a3a4177735 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -58,6 +58,8 @@ pub mod integrations; pub mod models; pub mod module_name; pub mod openrtb; +pub mod permission_signal; +pub mod permissions; pub mod platform; pub mod price_bucket; pub mod proxy; @@ -73,6 +75,7 @@ pub mod settings; pub mod settings_data; pub mod streaming_processor; pub mod streaming_replacer; +pub mod tdl; pub mod test_support; pub mod tester_cookie; pub mod tsjs; diff --git a/crates/trusted-server-core/src/permission_signal/README.md b/crates/trusted-server-core/src/permission_signal/README.md new file mode 100644 index 0000000000..7c2c8d25aa --- /dev/null +++ b/crates/trusted-server-core/src/permission_signal/README.md @@ -0,0 +1,257 @@ +# Permission signals + +A permission is the primitive. This is the seam that decides whether one is +set, and it is open so that a new way of learning a permission can be added +without changing core. + +"Signal" rather than "consent" because consent is only one of the things +arriving here. Global Privacy Control is a setting a browser sends, not an +answer anyone gave to a question, and a jurisdiction rule is neither. Consent +is one kind of signal, so the seam takes the wider name and the consent +subsystem keeps the narrower one. + +## What core holds + +The reasons the schemes live outside core are on the docs page, +docs/guide/permission-signals.md. + +Core holds the trait, the ordering, the country baseline, and the policy +vocabulary for what a deployment decides about the shipped schemes, being +whether a TCF record answers, which signals count as a US-style opt-out and +what an opt-out takes away. It holds no scheme's wire format and no scheme's +meaning. Every scheme lives in its own crate outside core, including the five +that ship by default, so none of them is privileged by being the one that +happens to be built in. Core does not know what a TCF purpose is, because the +mapping from purpose to Data Use lives in the TCF crate, and a deployment +that runs no TCF carries no table of another scheme's numbers. The purposes +are the IAB TCF Europe purposes and what they grant are IAB Tech Lab Privacy +Taxonomy Data Uses, so a reader checks the mapping against the industry's own +documents rather than against us, and docs/guide/permission-model.md lists it +in full. Two purposes have no Data Use yet, so the crate carries a proposed +key for one and the TCF identifier for the other until the taxonomy adds +them. + +A scheme that is not one of the five is added the same way, as a crate that +reads its own signal from the request, without a change to core. The fifth, +Model Terms for Marketing (MTM), is the first terms scheme, where a publisher +and the parties it passes data to agree to be bound by a published set of +terms, and what the module reads is the visitor's answer to a preference +platform, one of three words in the first party cookie +`__mtm_pref`, a name any platform may set. It is one of many terms schemes +rather than the only one, because a publisher, a trade body or a regulator can +each publish terms and each set becomes a module. The five here are a +starting set and not the list. + +## The hierarchy + +Permissions are resolved in layers, each amending the one before. + +```text + country / region rules the baseline: granted, requires-signal, denied + | + v + module 1 (configured order) may amend + | + v + module 2 may amend + | + v + ... may amend + | + v + the permission state for this request +``` + +The baseline comes from `permissions.yaml`, keyed by country and region, with +the top node of the rules tree standing in for a request whose place is +unknown. A geo module supplies the place. No geo module means no country, +so every request resolves at that top node, and only a lookup that failed +resolves at the requires-signal floor, because a place that could not be +determined must not be treated as the declared default. + +Modules are then asked in order. Each sees what the modules before it +settled on and may amend it. A module with no opinion returns `Neutral` and +leaves the prior value standing, which is different from refusing. + +## The order is the policy + +The last module with an opinion decides, so the order is the policy. It is +a deployment's to set, not this code's to assume. + +```toml +[permission-signal] +modules = ["gpc", "gpp", "us-privacy", "tcf", "mtm"] +``` + +A module not on the list does not run, and there is no separate switch. A +publisher who does not want to act on Global Privacy Control removes `"gpc"` +from the list, and the module that reads the header then does not run. One +caveat: core's consent pipeline can also synthesize a US Privacy opt-out from +that header for a visitor in a US state, when the consent settings say to, +which they do by default, and the `us-privacy` module then acts on the +record it produced. A publisher who wants the header to have no effect at all +turns that setting off as well. Leaving `modules` out, or the section +entirely, runs every module the adapter offers, in the order it offers them, +so a signal is never quietly ignored because someone forgot to list it. An +unknown or repeated name is refused at startup, so a typo cannot silently stop +a scheme being honored. + +A module is named by its crate folder below `crates/permission-signal`, so +`gpp` and `us-privacy` are the folders those crates live in, and a name may +also be written in full, as `permission-signal.gpp`. A module that gains +settings will take them in a `[permission-signal.]` +block named for it. None of the five here has settings, so `modules` is the +only key the section accepts, and a block or any other key is refused as an +unknown field rather than ignored. + +The default order asks the signal with no interface of its own first and the +ones carrying a choice made through an interface after. Global Privacy +Control is a browser setting, so it revokes personalization on arrival, +whereas a GPP sale opt-out, a US Privacy string and a TCF record each carry +an answer a person gave, so they are asked later and amend it. A deployment +wanting the browser setting to stand over a later answer puts `gpc` last. + +Trusted Server takes no view on which scheme should win. That is a question +about a jurisdiction and a publisher. + +## A module can see the others + +Amending well sometimes needs to know who set the prior value. A module is +given the whole ordered list and its own position in it, so it can look up a +peer by name, see whether a peer it cares about is configured at all, and ask +a peer directly what that peer makes of a permission. + +That is what makes a rule like "personalization is off, but only because +Global Privacy Control set it, so my answer supersedes it" expressible. The +rule itself belongs to whichever module wants it. This seam only makes the +information available. + +Consulting a peer goes one level deep. A module answering a consultation +cannot consult in turn, so two modules asking each other cannot loop. + +## The terms the data is available under + +A module may also declare the terms documents the request's data is available +under, through `tdls` on the trait, and core carries what every configured +module declared on the permission state. Whoever receives the data reads them +to decide whether those are terms they accept, and whether they may pass the +data on. No declaration means no terms were declared, which is not the same as +terms permitting anything, so a recipient needing a basis and finding none has +none. + +Each entry is the address of a published document a person can read, and the +document must never be edited once published, which is why a version belongs in +its address. A document that can be rewritten tomorrow means a recipient can +never prove what it agreed to, and one edit silently rewrites the basis of every +transaction already sent under it. That is a property of how the document is +published, so the [`Tdl`](crate::tdl::Tdl) type refuses only an address nothing +could fetch. + +Of the five schemes here only MTM carries terms, declaring the versioned Model +Terms document whenever a PMP answer is present, and it is the first of many +rather than the only one. The name matches the `tdl` member the Data Labels work puts on a +node of an `OpenRTB` request, which is where these travel once a bid request +carries them. + +## Who could still answer + +A permission whose baseline requires a signal, and for which every module +answered `Neutral`, is not set, and it is also not refused. Resolution records +it as awaited, and the page reads the list as `awaiting` beside `set`, so a +prompt that has not run yet can be told from a visitor who said no. + +Waiting is only right for a permission some configured module could grant. +So each module declares, through `grants` on the trait, which permissions it +can ever answer `Grant` for under the policy it is given. An opt-out revokes and +declares nothing. TCF declares every Data Use a purpose maps to, and nothing +when the policy silences the record. The assembly keeps as awaited only what +some module declared, and everything else that requires a signal and got none +is simply unset. + +## The signals that were valid + +Each module also says, through `valid_signal` on the trait, which signal it +read from the request and used, as it was received. Core carries what every +configured module vouched for on the permission state, and the page reads +the list as `signals` beside `set`, `awaiting` and `tdls`. A page, a bid +request or a person reading the state relies on exactly those signals and no +other. + +A signal that was absent, could not be read, has expired, or that no +configured module acts on is not in the list, and nothing says which. What +each of those means for the permissions is the decision of the module for +that scheme, taken in `signal` and taken silently. Nothing is logged, because +an unreadable record is a visitor's preference and not an operational fault. +The shipped modules read their own unreadable record as the refusal it +may have carried, on the permissions their scheme covers, and say nothing +about any other scheme. Core answers nothing ahead of the modules, so the +order decides what a readable record from one scheme means beside an +unreadable one from another, as it decides everything else. + +A signal nobody vouched for goes no further. After assembly the consent +context keeps only the raw strings the valid signals name, so a corrupt or +expired record never reaches a bid request or anything else Trusted Server +sends on. + +## Withdrawal is a separate question + +A module may also say that the request explicitly _withdraws_ a permission, +which is different from not granting it. A withdrawal of storage expires the +browser cookie and writes the authoritative tombstone against the identifier. +A permission that is merely not set strips the response headers and leaves an +already-issued identifier alone, so a returning visitor is not permanently +withdrawn before they get to answer. Most schemes have no such notion, a +browser setting and a sale opt-out included, and only TCF answers it. Core +then scopes the answer to the jurisdiction, so a refusal only withdraws where +the storage baseline did not grant storage outright, because where it did +the identifier never depended on the record. + +## Writing a module + +Implement `PermissionSignalModule` in a crate that depends on core, and give +it its name through `module_name!()`, which is the name configuration uses. Answer +`Neutral` for a permission the module has no opinion on, including when the +signal it reads is absent from the request. Returning `Revoke` for an absent +signal turns silence into refusal and would revoke the permission on every +request not carrying that scheme, which is most of them. + +Read the request through `SignalInput::evidence`, which offers headers, +cookies, the path and the query, so a scheme core has never heard of can read +its own signal. The decoded consent record is offered too, for the schemes +core's consent pipeline already decodes, caches against the identifier and +expires. Prefer the record where it exists, because a module that +re-decodes the wire would skip the cached record on a returning visitor and +answer differently from every other reader of the same request. + +Read the policy for what is a deployment's decision rather than the scheme's, +such as which signals count as an opt-out and what an opt-out takes away, so +that a deployment can change those without changing a module. + +Register the crate at the adapter's composition root, where every adapter +builds its `RuntimeServices`. The adapter lists the modules it links, in the +default order, and `build_permission_signal_modules` selects and orders them +from configuration. + +## The modules supplied + +Five crates ship, under `crates/permission-signal/`, and a deployment +configuring nothing gets all five in this order: + +| Name | Crate | Reads | +| ------------ | ------------ | --------------------------------------------------- | +| `gpc` | `gpc` | The `Sec-GPC` header, Global Privacy Control | +| `gpp` | `gpp` | The US sale opt-out in a GPP string | +| `us-privacy` | `us-privacy` | The sale opt-out in a US Privacy string | +| `tcf` | `tcf` | A TCF v2 record, with its purpose mapping in code | +| `mtm` | `mtm` | The PMP answer, under the Model Terms for Marketing | + +The three opt-outs are separate rather than one so that a publisher who does +not act on Global Privacy Control can remove it and keep the other two. + +## What is not a module + +Nothing. Core reads no scheme and answers for none, an unreadable record +included. A publisher chooses which signals to act on by listing modules, +and each module decides for its own scheme what an absent, unreadable or +expired signal means. A scheme that is not listed does not run, whatever the +request carries for it. diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs new file mode 100644 index 0000000000..fcb5f534fa --- /dev/null +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -0,0 +1,1144 @@ +#![doc = include_str!("README.md")] + +use std::sync::Arc; + +use error_stack::Report; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; +use crate::permissions::{ + Acquisition, ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, +}; +use crate::settings::Settings; +use crate::tdl::Tdl; + +/// What a signal module may read about a request. +/// +/// A struct rather than a parameter list, so a module needing something new +/// does not change every implementation. +pub struct SignalInput<'a> { + /// The decoded consent record for this request. + /// + /// Offered because the schemes that ship by default already decode into + /// it, core keeps it against the Edge Cookie identifier between requests, + /// and the rest of the system reads it. A module is not required to use + /// it, and a scheme core has never heard of will not appear in it. Such a + /// module reads [`evidence`](Self::evidence) instead and decodes whatever + /// its scheme needs. + pub consent: &'a ConsentContext, + /// The request itself, as evidence. + /// + /// This is the open half of the seam, and it is [`RequestInfo`], the same + /// abstraction the Edge Cookie and device modules already read. A + /// module asks for the header, cookie, path or query parameter its own + /// scheme uses, so core holds no list of which evidence a scheme may read. + pub evidence: &'a dyn RequestInfo, + /// The policy from `permissions.yaml`, which decides what a signal means + /// rather than leaving each module to invent its own meaning. + pub policy: &'a SignalPolicy, + /// What the country and region rules say about this permission, before any + /// module is asked. A module amends this rather than deciding alone. + pub baseline: Acquisition, + /// What the modules asked before this one settled on. + /// + /// [`ConsentSignal::Neutral`] means none of them had an opinion, so the + /// baseline still stands. + pub settled: ConsentSignal, + /// Every module in configured order, this one included. + modules: &'a [Arc], + /// Where in that order the module being asked sits. + position: usize, + /// Whether this module may consult a peer. + /// + /// False while answering a consultation, which is what stops two modules + /// that consult each other from looping. + may_ask: bool, +} + +impl<'a> SignalInput<'a> { + /// An input for a module asked on its own, outside an ordered run. + #[must_use] + pub fn new( + consent: &'a ConsentContext, + evidence: &'a dyn RequestInfo, + policy: &'a SignalPolicy, + baseline: Acquisition, + ) -> Self { + Self { + consent, + evidence, + policy, + baseline, + settled: ConsentSignal::Neutral, + modules: &[], + position: 0, + may_ask: true, + } + } + + /// Every module in configured order, this one included. + /// + /// A module consults the list to decide whether a peer it cares about is + /// configured at all, and where it sits relative to this one. + #[must_use] + pub fn modules(&self) -> &[Arc] { + self.modules + } + + /// Where the module being asked sits in that order. + #[must_use] + pub const fn position(&self) -> usize { + self.position + } + + /// Whether a module with this identifier is configured. + #[must_use] + pub fn has(&self, id: &str) -> bool { + self.modules.iter().any(|module| module.id() == id) + } + + /// What a peer makes of `permission`, asked directly. + /// + /// The peer answers as if it were first, so the reply is that peer's own + /// opinion rather than what the run has settled on so far. That is the + /// useful question, because a module wanting to know whether the prior + /// value came from a particular peer asks that peer what it says. + /// + /// Returns `None` when no module carries the identifier, when the caller + /// names itself, and when this input is itself answering a consultation. + /// A module must handle `None` rather than assume a peer is present. + #[must_use] + pub fn ask(&self, id: &str, permission: Permission) -> Option { + if !self.may_ask { + return None; + } + let names: Vec<&str> = self.modules.iter().map(|module| module.id()).collect(); + let name = crate::module_name::resolve(MODULE_TYPE, id, &names)?; + let (position, module) = self + .modules + .iter() + .enumerate() + .find(|(position, module)| module.id() == name && *position != self.position)?; + let input = Self { + consent: self.consent, + evidence: self.evidence, + policy: self.policy, + baseline: self.baseline, + settled: ConsentSignal::Neutral, + modules: self.modules, + position, + may_ask: false, + }; + Some(module.signal(permission, &input)) + } +} + +/// The type folder of every permission signal crate, which a name written in +/// `[permission-signal] modules` may leave off. +pub const MODULE_TYPE: &str = "permission-signal"; + +/// The name a module reports on the page and in logs, being its name without +/// the type folder, such as `example` for `permission-signal.example`. +#[must_use] +pub fn short_name(module: &dyn PermissionSignalModule) -> &'static str { + crate::module_name::short_form(MODULE_TYPE, module.id()) +} + +/// A module of permission signals. +/// +/// An implementation answers for one signaling scheme. It reads the request, +/// applies whatever the policy says about its own scheme, and returns how it +/// would amend one permission. +/// +/// # Contract +/// +/// Answer [`ConsentSignal::Neutral`] for a permission this module has no +/// opinion on, including when the signal it reads is absent from the request. +/// Returning [`ConsentSignal::Revoke`] for an absent signal would turn silence +/// into refusal and revoke the permission on every request that did not carry +/// this scheme. +pub trait PermissionSignalModule: Send + Sync { + /// The module's name, used in configuration, in logs, and by a peer + /// looking this module up through [`SignalInput::ask`]. + /// + /// A module in a crate takes its crate folder's name through + /// [`crate::module_name!`], such as `permission-signal.example`, which + /// `[permission-signal] modules` may write as `example`. + fn id(&self) -> &'static str; + + /// How this module would amend `permission` for this request. + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; + + /// The permissions this module can ever answer [`ConsentSignal::Grant`] + /// for under `policy`, so a permission a signal could still set can be + /// told from one nothing in this deployment will ever set. + /// + /// Resolution records a permission that requires a signal and got none as + /// awaited, and a page holds what depends on an awaited permission until + /// an answer arrives. That is right only for a permission some configured + /// module could grant. For any other, waiting is waiting for ever, so + /// the assembly narrows the awaited list to the union of these + /// declarations. + /// + /// A scheme that only ever revokes, which is every opt-out, leaves this + /// at its default of nothing. A scheme that grants declares exactly what + /// it maps, under the policy it is given, so a record the policy does not + /// let answer declares nothing either. + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none() + } + + /// Whether the request carries an explicit withdrawal of `permission` + /// under this scheme, as opposed to merely not granting it. + /// + /// The difference is destructive. A withdrawal of storage expires the + /// browser cookie and writes the authoritative tombstone against the + /// identifier, where a permission that is simply not set for this request + /// strips the response headers and leaves the identifier alone, so that a + /// returning visitor is not permanently withdrawn before they ever get to + /// answer. Most schemes have no such notion, a browser setting and a sale + /// opt-out included, and leave this at its default of `false`. Whether a + /// withdrawal is acted on at all is core's decision from the jurisdiction's + /// baseline, so a refusal only withdraws where the baseline did not grant + /// the permission outright, because where it did the permission never + /// depended on the record. + fn withdraws(&self, _permission: Permission, _input: &SignalInput<'_>) -> bool { + false + } + + /// The signal this module read from the request and used, as it was + /// received, or `None` when there is none it can use. + /// + /// Absent, unreadable, expired and not-acted-on all answer `None`, and + /// nothing here says which. What each of those means for the + /// permissions is this module's decision in [`signal`](Self::signal), + /// taken silently. Core carries what every module vouched for on the + /// permission state, and a signal nobody vouched for is dropped from + /// everything Trusted Server sends on, so a corrupt record never reaches + /// a page or a bid request. + fn valid_signal( + &self, + _consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + None + } + + /// The terms documents this module says the request's data is + /// available under, empty when it declares none. + /// + /// A locator tells whoever receives the data what terms cover it, so + /// they can decide whether those are terms they accept and whether they + /// may pass the data on. Most schemes carry no terms of their own and + /// leave this at its default, and a module for a terms scheme returns the + /// document that applies to this request. Model Terms for Marketing (MTM) + /// is the first such scheme and one of many rather than the only one. Core + /// does not read the documents, it carries the locators, so what a document + /// says stays between the parties bound by it. + /// + /// A locator must point at a document that is never edited once + /// published, which [`Tdl`] documents and cannot enforce. + fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + Vec::new() + } +} + +/// Asks every module in order and returns what they settle on together. +/// +/// See the module documentation for the layering and why the order is the +/// configuration. +#[must_use] +pub(crate) fn combine( + modules: &[Arc], + permission: Permission, + consent: &ConsentContext, + evidence: &dyn RequestInfo, + policy: &SignalPolicy, + baseline: Acquisition, +) -> ConsentSignal { + let mut settled = ConsentSignal::Neutral; + for (position, module) in modules.iter().enumerate() { + let input = SignalInput { + consent, + evidence, + policy, + baseline, + settled, + modules, + position, + may_ask: true, + }; + // Every module is asked, because a later one may amend what an + // earlier one settled. Stopping at the first answer would make the + // order mean the opposite of what it says. + match module.signal(permission, &input) { + ConsentSignal::Neutral => {} + answer => settled = answer, + } + } + settled +} + +/// Whether the request explicitly withdraws `permission`, scoped to the +/// jurisdiction's baseline for it. +/// +/// A withdrawal only counts where the baseline did not grant the permission +/// outright. Under a `requires_signal` baseline a refusal is the visitor +/// declining the very signal the permission depended on, so it is +/// destructive. Under a `granted` baseline the permission never depended on +/// the record, so the same refusal suppresses use for the request without +/// destroying anything. Any one module answering [`withdraws`] is enough, +/// and no module answering it, or none configured, is never a withdrawal. +/// +/// [`withdraws`]: PermissionSignalModule::withdraws +#[must_use] +pub(crate) fn withdrawn( + modules: &[Arc], + permission: Permission, + consent: &ConsentContext, + evidence: &dyn RequestInfo, + policy: &SignalPolicy, + baseline: Acquisition, +) -> bool { + if matches!(baseline, Acquisition::Granted) { + return false; + } + modules.iter().enumerate().any(|(position, module)| { + let input = SignalInput { + consent, + evidence, + policy, + baseline, + settled: ConsentSignal::Neutral, + modules, + position, + may_ask: true, + }; + module.withdraws(permission, &input) + }) +} + +/// The terms documents the configured modules declare for this request. +/// +/// Asked in the same order the modules answer in, so the list reads the +/// way the deployment is configured, and a document named by two modules +/// is carried once. No module declaring anything leaves the list empty, +/// which says no terms were declared rather than that any terms apply. +#[must_use] +pub(crate) fn tdls( + modules: &[Arc], + consent: &ConsentContext, + evidence: &dyn RequestInfo, +) -> Arc<[Tdl]> { + let mut declared: Vec = Vec::new(); + for module in modules { + for tdl in module.tdls(consent, evidence) { + if !declared.contains(&tdl) { + declared.push(tdl); + } + } + } + Arc::from(declared) +} + +/// Every permission some module in `modules` declares it can grant under +/// `policy`, which is the most a signal arriving later could still set. +#[must_use] +pub fn answerable( + modules: &[Arc], + policy: &SignalPolicy, +) -> PermissionSet { + modules + .iter() + .map(|module| module.grants(policy)) + .fold(PermissionSet::none(), PermissionSet::union) +} + +/// The signals the configured modules read and found valid, in the order +/// the modules are asked. +/// +/// Each module vouches for at most one signal, its own scheme's, so the +/// list reads the way the deployment is configured. No module vouching +/// for anything leaves the list empty, which says the request carried +/// nothing a configured module could use. +#[must_use] +pub(crate) fn signals( + modules: &[Arc], + consent: &ConsentContext, + evidence: &dyn RequestInfo, +) -> Arc<[ValidSignal]> { + modules + .iter() + .filter_map(|module| module.valid_signal(consent, evidence)) + .collect() +} + +/// The modules a deployment named, in the order it named them, drawn from +/// the ones the build makes available. +/// +/// `None` means nothing was configured, which runs every available module +/// in the order the adapter offered them. That is deliberate, so a publisher +/// gets every scheme the build knows about until they say otherwise and a +/// signal is never quietly ignored because someone forgot to list it. An +/// empty list is a publisher acting on no signal at all, and is accepted. +/// +/// # Errors +/// +/// A name matching no available module, or a name given twice, is refused +/// rather than ignored, so a typo cannot silently stop a scheme being honored +/// and a scheme cannot run twice at two places in the order. +pub(crate) fn select( + available: &[Arc], + configured: Option<&[String]>, +) -> Result>, Report> { + let Some(names) = configured else { + return Ok(available.to_vec()); + }; + let mut selected = Vec::with_capacity(names.len()); + for (position, name) in names.iter().enumerate() { + if names[..position].contains(name) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal module `{name}` is named more than once in \ + [permission-signal] modules. Each module runs once, at one place \ + in the order" + ), + })); + } + let offered = ids(available); + let Some(module) = crate::module_name::resolve(MODULE_TYPE, name, &offered) + .and_then(|resolved| available.iter().find(|module| module.id() == resolved)) + else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Permission signal module `{name}` is not available in this build. \ + Available modules are {}", + offered + .iter() + .map(|id| crate::module_name::short_form(MODULE_TYPE, id)) + .collect::>() + .join(", ") + ), + })); + }; + selected.push(Arc::clone(module)); + } + Ok(selected) +} + +/// The available modules a configured list leaves out, for the startup log. +#[must_use] +pub(crate) fn omitted<'a>( + available: &'a [Arc], + configured: Option<&[String]>, +) -> Vec<&'a str> { + let Some(names) = configured else { + return Vec::new(); + }; + available + .iter() + .map(|module| module.id()) + .filter(|id| !names.iter().any(|name| name == id)) + .collect() +} + +/// The identifiers of `modules`, in order. +#[must_use] +pub(crate) fn ids(modules: &[Arc]) -> Vec<&str> { + modules.iter().map(|module| module.id()).collect() +} + +/// Selects the modules a deployment runs from the ones an adapter makes +/// available, and says so in the log once at startup. +/// +/// This is the adapter's composition point. Core supplies no module of its +/// own, so an adapter hands in every scheme crate it links, in the order that +/// stands when configuration names none, and receives back the ordered list +/// the request path asks. The list is shared rather than owned, so handing it +/// to every request's services is one reference count and no copy. +/// +/// # Errors +/// +/// A configured name that matches nothing available, or a name given twice, +/// fails startup with a message naming what is available, so a typo cannot +/// silently stop a scheme being honored. +pub fn build_permission_signal_modules( + settings: &Settings, + available: &[Arc], +) -> Result]>, Report> { + let configured = settings.permission_signal.modules.as_deref(); + let selected = select(available, configured)?; + match configured { + None => log::info!( + "Permission signals: acting on every module this build offers, [{}], no \ + [permission-signal] modules configured", + ids(&selected).join(", ") + ), + Some([]) => log::info!( + "Permission signals: acting on no module, [permission-signal] modules is \ + empty, so every permission stays at its country and region baseline" + ), + Some(_) => log::info!( + "Permission signals: acting on [{}], asked in that order", + ids(&selected).join(", ") + ), + } + let left_out = omitted(available, configured); + if !left_out.is_empty() { + log::warn!( + "Permission signals: not acting on [{}], which are not in [permission-signal] \ + modules. A signal this deployment does not act on is read from the request \ + and then ignored", + left_out.join(", ") + ); + } + Ok(Arc::from(selected)) +} + +#[cfg(test)] +mod tests { + use http::HeaderMap; + + use super::*; + use crate::evidence::OwnedRequestInfo; + + /// A module that always answers the same thing, for testing the rule + /// rather than any particular scheme. + struct Fixed(&'static str, ConsentSignal); + + impl PermissionSignalModule for Fixed { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + self.1 + } + } + + /// A module that answers by consulting a peer, which is the behavior the + /// peer visibility exists for. + struct Consulting { + id: &'static str, + peer: &'static str, + } + + impl PermissionSignalModule for Consulting { + fn id(&self) -> &'static str { + self.id + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + match input.ask(self.peer, permission) { + // The peer refused, and this module takes the opposite view + // of the same request, which is the override the seam allows. + Some(ConsentSignal::Revoke) => ConsentSignal::Grant, + _ => ConsentSignal::Neutral, + } + } + } + + /// A module that declares a terms document, which is what a scheme like + /// Model Terms for Marketing does and most schemes do not. + struct Declaring(&'static str, &'static str); + + impl PermissionSignalModule for Declaring { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + vec![Tdl::new(self.1).expect("should accept the test locator")] + } + } + + /// A module that withdraws storage, for testing the scoping rule. + struct Withdrawing; + + impl PermissionSignalModule for Withdrawing { + fn id(&self) -> &'static str { + "withdrawing" + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Revoke + } + + fn withdraws(&self, permission: Permission, _input: &SignalInput<'_>) -> bool { + permission == Permission::StoreOnDevice + } + } + + fn no_evidence() -> OwnedRequestInfo { + OwnedRequestInfo::new(String::new(), HeaderMap::new()) + } + + fn fixed(id: &'static str, signal: ConsentSignal) -> Arc { + Arc::new(Fixed(id, signal)) + } + + /// A module that declares it can grant one permission, whatever it then + /// answers, standing in for a scheme with a mapping. + struct Granting(&'static str, Permission); + + impl PermissionSignalModule for Granting { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none().with(self.1) + } + } + + #[test] + fn a_module_declares_nothing_grantable_unless_it_says_otherwise() { + // An opt-out only ever revokes, so the default declaration is empty + // and a deployment of opt-outs alone can grant nothing. + let policy = SignalPolicy::default(); + assert!( + fixed("opt_out", ConsentSignal::Revoke) + .grants(&policy) + .is_empty() + ); + assert!(answerable(&[fixed("a", ConsentSignal::Grant)], &policy).is_empty()); + } + + #[test] + fn what_is_answerable_is_the_union_of_every_declaration() { + let policy = SignalPolicy::default(); + let modules: Vec> = vec![ + Arc::new(Granting("storage", Permission::StoreOnDevice)), + fixed("opt_out", ConsentSignal::Revoke), + Arc::new(Granting("profiling", Permission::CreateAdsProfile)), + ]; + assert_eq!( + answerable(&modules, &policy), + PermissionSet::none() + .with(Permission::StoreOnDevice) + .with(Permission::CreateAdsProfile), + "should collect what every module declares, in any order" + ); + } + + /// A module that vouches for a signal, standing in for a scheme that + /// read its string and could decode it. + struct Vouching(&'static str, &'static str); + + impl PermissionSignalModule for Vouching { + fn id(&self) -> &'static str { + self.0 + } + + fn signal(&self, _permission: Permission, _input: &SignalInput<'_>) -> ConsentSignal { + ConsentSignal::Neutral + } + + fn valid_signal( + &self, + _consent: &ConsentContext, + _evidence: &dyn RequestInfo, + ) -> Option { + Some(ValidSignal::new(self.0, self.0, self.1)) + } + } + + #[test] + fn the_valid_signals_are_what_each_module_vouched_for_in_order() { + let consent = ConsentContext::default(); + let modules: Vec> = vec![ + Arc::new(Vouching("first", "one")), + fixed("silent", ConsentSignal::Revoke), + Arc::new(Vouching("second", "two")), + ]; + let valid = signals(&modules, &consent, &no_evidence()); + assert_eq!( + &*valid, + &[ + ValidSignal::new("first", "first", "one"), + ValidSignal::new("second", "second", "two"), + ], + "should carry what was vouched for, in configured order, and nothing else" + ); + assert!( + signals( + &[fixed("silent", ConsentSignal::Grant)], + &consent, + &no_evidence() + ) + .is_empty(), + "a module vouches for nothing unless it says otherwise" + ); + } + + #[test] + fn a_module_declaring_no_terms_leaves_the_list_empty() { + let consent = ConsentContext::default(); + let declared = tdls( + &[fixed("quiet", ConsentSignal::Grant)], + &consent, + &no_evidence(), + ); + assert!( + declared.is_empty(), + "should declare nothing, because the IAB schemes carry no terms" + ); + } + + #[test] + fn terms_are_collected_in_the_order_the_modules_are_asked() { + let consent = ConsentContext::default(); + let modules: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + fixed("quiet", ConsentSignal::Neutral), + Arc::new(Declaring("second", "https://terms.example.com/b/1.txt")), + ]; + let declared = tdls(&modules, &consent, &no_evidence()); + let addresses: Vec<&str> = declared.iter().map(Tdl::as_str).collect(); + assert_eq!( + addresses, + vec![ + "https://terms.example.com/a/1.txt", + "https://terms.example.com/b/1.txt" + ], + "should read in the configured order, so the list matches the deployment" + ); + } + + #[test] + fn one_document_named_by_two_modules_is_carried_once() { + let consent = ConsentContext::default(); + let modules: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + Arc::new(Declaring("second", "https://terms.example.com/a/1.txt")), + ]; + let declared = tdls(&modules, &consent, &no_evidence()); + assert_eq!( + declared.len(), + 1, + "should carry the same document once, not once per module naming it" + ); + } + + #[test] + fn versions_of_one_document_are_both_carried() { + let consent = ConsentContext::default(); + let modules: Vec> = vec![ + Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), + Arc::new(Declaring("second", "https://terms.example.com/a/2.txt")), + ]; + let declared = tdls(&modules, &consent, &no_evidence()); + assert_eq!( + declared.len(), + 2, + "should keep both, because a recipient agreed to one version and not the other" + ); + } + + #[test] + fn no_modules_declare_nothing() { + let consent = ConsentContext::default(); + assert!( + tdls(&[], &consent, &no_evidence()).is_empty(), + "should declare nothing when no module runs, rather than implying terms" + ); + } + + fn combined(modules: &[Arc]) -> ConsentSignal { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + combine( + modules, + Permission::StoreOnDevice, + &consent, + &no_evidence(), + &policy, + Acquisition::RequiresSignal, + ) + } + + /// The same, for a run whose modules differ only in what they answer. + fn combined_signals(signals: &[ConsentSignal]) -> ConsentSignal { + let modules: Vec> = signals + .iter() + .map(|signal| fixed("test", *signal)) + .collect(); + combined(&modules) + } + + fn withdrawn_under(modules: &[Arc], baseline: Acquisition) -> bool { + let consent = ConsentContext::default(); + let policy = SignalPolicy::default(); + withdrawn( + modules, + Permission::StoreOnDevice, + &consent, + &no_evidence(), + &policy, + baseline, + ) + } + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + // ------------------------------------------------------------------ + // Combining answers in order. + // ------------------------------------------------------------------ + + #[test] + fn no_modules_leaves_the_place_baseline_alone() { + assert_eq!( + combined_signals(&[]), + ConsentSignal::Neutral, + "with no module configured nothing amends the place baseline" + ); + } + + #[test] + fn the_last_module_with_an_opinion_decides() { + assert_eq!( + combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Grant]), + ConsentSignal::Grant, + "a visitor who answers a prompt after arriving with an opt-out header has \ + their answer applied over it, which is why the order is the policy" + ); + assert_eq!( + combined_signals(&[ConsentSignal::Grant, ConsentSignal::Revoke]), + ConsentSignal::Revoke, + "and a deployment that wants the opt-out to win puts it last" + ); + } + + #[test] + fn silence_leaves_an_earlier_answer_standing() { + // The failure this guards is a later module overwriting a settled + // answer with its own absence, which would let adding a module nobody + // uses undo the one that was working. + assert_eq!( + combined_signals(&[ConsentSignal::Grant, ConsentSignal::Neutral]), + ConsentSignal::Grant, + "a later module with no opinion leaves an earlier grant standing" + ); + assert_eq!( + combined_signals(&[ConsentSignal::Revoke, ConsentSignal::Neutral]), + ConsentSignal::Revoke, + "and a later silence leaves an earlier refusal standing too" + ); + } + + #[test] + fn silence_from_every_module_is_not_a_refusal() { + // The failure this guards is a module that reads an absent signal as + // a refusal. It would revoke the permission on every request that did + // not carry that scheme, which is most of them. + assert_eq!( + combined_signals(&[ConsentSignal::Neutral, ConsentSignal::Neutral]), + ConsentSignal::Neutral, + "no module having an opinion is not a refusal" + ); + } + + #[test] + fn a_module_sees_what_the_earlier_ones_settled() { + struct Recording; + + impl PermissionSignalModule for Recording { + fn id(&self) -> &'static str { + "recording" + } + + fn signal(&self, _permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + assert_eq!( + input.settled, + ConsentSignal::Revoke, + "a module is asked with the value the modules before it settled on" + ); + assert_eq!(input.position(), 1, "and with its own place in the order"); + assert_eq!(input.modules().len(), 2, "and with the whole list"); + assert_eq!( + input.baseline, + Acquisition::RequiresSignal, + "and with what the place rules said before anyone was asked" + ); + ConsentSignal::Neutral + } + } + + let modules: Vec> = + vec![fixed("opt-out", ConsentSignal::Revoke), Arc::new(Recording)]; + assert_eq!( + combined(&modules), + ConsentSignal::Revoke, + "the recording module has no opinion, so the opt-out stands" + ); + } + + #[test] + fn a_module_can_override_a_peer_by_consulting_it() { + // The worked example from the README: a later module overrides a + // refusal because of who made it, not merely that one was made. + let modules: Vec> = vec![ + fixed("gpc", ConsentSignal::Revoke), + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + ]; + assert_eq!( + combined(&modules), + ConsentSignal::Grant, + "a later module overrides a refusal made by the peer it consulted" + ); + + // The same module leaves the refusal alone when it came from a peer + // it was not told to override. + let modules: Vec> = vec![ + fixed("other", ConsentSignal::Revoke), + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + ]; + assert_eq!( + combined(&modules), + ConsentSignal::Revoke, + "and leaves a refusal from any other peer standing" + ); + } + + #[test] + fn asking_reaches_a_peer_wherever_it_sits_in_the_order() { + // A module may consult one configured after it, not only before, so + // a reordering does not silently change what a module can see. + let modules: Vec> = vec![ + Arc::new(Consulting { + id: "prompt", + peer: "gpc", + }), + fixed("gpc", ConsentSignal::Revoke), + ]; + assert_eq!( + combined(&modules), + ConsentSignal::Revoke, + "the consultation succeeded, and the later opt-out then settled it" + ); + } + + #[test] + fn asking_for_a_module_that_is_not_configured_answers_nothing() { + struct Absent; + + impl PermissionSignalModule for Absent { + fn id(&self) -> &'static str { + "absent" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + assert!( + input.ask("not-configured", permission).is_none(), + "a module must be able to tell a missing peer from a silent one" + ); + assert!( + !input.has("not-configured"), + "and sees that the peer is not in the list" + ); + assert!(input.has("absent"), "and can see itself in the list"); + ConsentSignal::Neutral + } + } + + let modules: Vec> = vec![Arc::new(Absent)]; + assert_eq!( + combined(&modules), + ConsentSignal::Neutral, + "a module that finds its peer missing leaves the permission unsettled" + ); + } + + #[test] + fn a_module_cannot_consult_itself() { + struct SelfAsking; + + impl PermissionSignalModule for SelfAsking { + fn id(&self) -> &'static str { + "self-asking" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + // Without the guard this recurses until the stack is gone. + assert!( + input.ask("self-asking", permission).is_none(), + "a module asking itself gets no answer" + ); + ConsentSignal::Grant + } + } + + let modules: Vec> = vec![Arc::new(SelfAsking)]; + assert_eq!( + combined(&modules), + ConsentSignal::Grant, + "a module refused its own consultation still answers for itself" + ); + } + + #[test] + fn two_modules_that_consult_each_other_do_not_loop() { + // Each consults the other, and the one answering a consultation is + // refused a consultation of its own, so the pair settles instead of + // recursing. + let modules: Vec> = vec![ + Arc::new(Consulting { + id: "first", + peer: "second", + }), + Arc::new(Consulting { + id: "second", + peer: "first", + }), + ]; + assert_eq!( + combined(&modules), + ConsentSignal::Neutral, + "two modules consulting each other settle instead of looping" + ); + } + + // ------------------------------------------------------------------ + // Withdrawal scoping. + // ------------------------------------------------------------------ + + #[test] + fn a_withdrawal_counts_only_where_the_baseline_did_not_grant() { + let modules: Vec> = vec![Arc::new(Withdrawing)]; + assert!( + withdrawn_under(&modules, Acquisition::RequiresSignal), + "refusing the signal the permission depended on is destructive" + ); + assert!( + withdrawn_under(&modules, Acquisition::Denied), + "and so is refusing under a baseline that never allowed it" + ); + assert!( + !withdrawn_under(&modules, Acquisition::Granted), + "where the permission never depended on the record, the refusal suppresses \ + without destroying" + ); + } + + #[test] + fn a_module_that_merely_revokes_does_not_withdraw() { + // Revoke and withdraw are different questions. A sale opt-out revokes + // and must never destroy an identifier. + let modules: Vec> = + vec![fixed("opt-out", ConsentSignal::Revoke)]; + assert!( + !withdrawn_under(&modules, Acquisition::RequiresSignal), + "a module that only revokes never withdraws" + ); + } + + #[test] + fn no_modules_never_withdraw() { + assert!( + !withdrawn_under(&[], Acquisition::RequiresSignal), + "with no module configured nothing can withdraw" + ); + } + + // ------------------------------------------------------------------ + // Selecting which modules run. + // ------------------------------------------------------------------ + + fn four() -> Vec> { + vec![ + fixed("gpc", ConsentSignal::Neutral), + fixed("gpp", ConsentSignal::Neutral), + fixed("us-privacy", ConsentSignal::Neutral), + fixed("tcf", ConsentSignal::Neutral), + ] + } + + #[test] + fn naming_nothing_runs_every_available_module_in_the_offered_order() { + let selected = select(&four(), None).expect("should accept no configuration"); + assert_eq!( + ids(&selected), + vec!["gpc", "gpp", "us-privacy", "tcf"], + "a publisher who configures nothing acts on every scheme the build knows, so \ + one is never ignored because they forgot to list it" + ); + } + + #[test] + fn the_configured_order_is_the_order_they_run_in() { + let reversed = names(&["tcf", "us-privacy", "gpp", "gpc"]); + let selected = select(&four(), Some(&reversed)).expect("should accept known names"); + assert_eq!( + ids(&selected), + vec!["tcf", "us-privacy", "gpp", "gpc"], + "the list is the order, not merely the membership" + ); + } + + #[test] + fn an_empty_list_is_acting_on_no_signal_and_is_accepted() { + let selected = select(&four(), Some(&[])).expect("should accept an empty list"); + assert!(selected.is_empty(), "an empty list selects no module"); + assert_eq!( + omitted(&four(), Some(&[])).len(), + 4, + "and every module is reported left out" + ); + } + + #[test] + fn a_name_matching_no_available_module_is_refused() { + // Matched rather than `expect_err`, because the success type holds + // trait objects that are deliberately not `Debug`. + let Err(error) = select(&four(), Some(&names(&["gpc", "not-a-module"]))) else { + panic!("should refuse a name this build does not offer"); + }; + let message = format!("{error:?}"); + assert!( + message.contains("not-a-module") && message.contains("gpc, gpp"), + "the refusal names the bad entry and what is available: {message}" + ); + } + + #[test] + fn naming_a_module_twice_is_refused() { + let Err(error) = select(&four(), Some(&names(&["gpc", "tcf", "gpc"]))) else { + panic!("should refuse a module named twice"); + }; + assert!( + format!("{error:?}").contains("more than once"), + "a module runs once, at one place in the order" + ); + } + + #[test] + fn a_left_out_module_is_reported_as_omitted() { + let configured = names(&["gpc", "tcf"]); + assert_eq!( + omitted(&four(), Some(&configured)), + vec!["gpp", "us-privacy"], + "the modules the list leaves out are reported in the offered order" + ); + assert!( + omitted(&four(), None).is_empty(), + "configuring nothing omits nothing" + ); + } +} diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs new file mode 100644 index 0000000000..3bbb8baff4 --- /dev/null +++ b/crates/trusted-server-core/src/permissions.rs @@ -0,0 +1,2795 @@ +//! Module permissions: a technical permission model gating module execution. +//! +//! A module 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 +//! module when its required permissions are not set. The device and geo +//! modules 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. The tree is +//! the publisher's policy file, which a domain's settings name in +//! `[permissions] policy` and [`load_policy`] checks and parses when the +//! domain loads. No adapter build carries a policy. +//! +//! 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 module, +//! 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 module 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`]). + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; + +use error_stack::Report; +use serde::Deserialize; +use serde_yaml_ng::Value; +use sha2::{Digest as _, Sha256}; + +use crate::consent::jurisdiction::Jurisdiction; +use crate::error::TrustedServerError; +use crate::tdl::Tdl; + +/// A technical permission a module 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 module. +#[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 + /// every shipped Edge Cookie module. 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. No shipped module + /// gates on it. + 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, and no shipped module gates on them. + 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 shipped module +/// gates on these. 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 module 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 + } + + /// The permissions in both this set and `other`. + #[must_use] + pub const fn intersect(self, other: PermissionSet) -> Self { + Self(self.0 & other.0) + } + + /// The permissions in either this set or `other`. + #[must_use] + pub const fn union(self, other: PermissionSet) -> Self { + Self(self.0 | other.0) + } + + /// Iterates the permissions in the set, in bit-index order. + /// + /// The built-ins read nothing from the full set; this serves a module 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 module-keyed: a module 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), +} + +/// What a deployment decides about the shipped signal schemes, parsed from the +/// `signals` section of `permissions.yaml`. +/// +/// The permission model holds this as data so a deployment changes it without +/// changing a module. It is jurisdiction-free, and it carries only the +/// decisions that are a deployment's to make: whether a TCF record answers at +/// all, which signals count as a US-style opt-out, and what an opt-out takes +/// away. What each scheme's own signal means, such as which TCF purpose grants +/// which Data Use, is that scheme's module crate's, not this policy's. The +/// country/region baseline decides the rest. +#[derive(Debug, Clone, Default)] +pub struct SignalPolicy { + /// Whether a present TCF record's grants and revokes apply. Whether a + /// consenting record then stands over an opt-out, or the opt-out over it, + /// is decided by the order the modules are asked in, which is + /// `[permission-signal] modules`, not by this flag. + tcf_authoritative: bool, + /// 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. + #[must_use] + pub fn tcf_authoritative(&self) -> bool { + self.tcf_authoritative + } + + /// The signals that constitute a US-style opt-out. + #[must_use] + pub fn opt_out_sources(&self) -> &[OptOutSource] { + &self.opt_out_sources + } + + /// Whether a US-style opt-out revokes `permission`. + #[must_use] + pub fn opt_out_revokes(&self, permission: Permission) -> bool { + match &self.opt_out_revokes { + RevokeSet::None => false, + RevokeSet::All => true, + RevokeSet::Set(set) => set.contains(permission), + } + } +} + +/// Builds a validated [`SignalPolicy`] from the parsed `signals` section, +/// erroring when it names an unknown Data Use or revoke rule. +fn build_signal_policy(spec: &SignalsSpec) -> Result { + let mut policy = SignalPolicy::default(); + if let Some(tcf) = &spec.tcf { + policy.tcf_authoritative = tcf.authoritative; + } + 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 module 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, +} + +/// 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] + // Public because the permission signal module crates live outside core + // and their tests read the decisions of the repository's sample + // rather than a policy built by hand. + pub 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 module'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 + /// module 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() + } + + /// 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 module 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, Acquisition) -> 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 module 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, Acquisition) -> 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, Acquisition) -> ConsentSignal, + ) -> PermissionState { + let acquisition = + |permission| rules.map_or(Acquisition::RequiresSignal, |r| r.rule_for(permission)); + let mut set = PermissionSet::none(); + let mut awaiting = PermissionSet::none(); + for permission in Permission::all() { + // The baseline is passed to the signal as well as applied to its + // answer, because a source amends the place rules and cannot + // amend what it cannot see. + let baseline = acquisition(permission); + match (baseline, signal(permission, baseline)) { + (Acquisition::Denied, _) => {} + (Acquisition::Granted, ConsentSignal::Revoke) => {} + (Acquisition::Granted, _) => set = set.with(permission), + (Acquisition::RequiresSignal, ConsentSignal::Grant) => set = set.with(permission), + (Acquisition::RequiresSignal, ConsentSignal::Revoke) => {} + // Requires a signal and nobody gave one. Not set, and not + // refused either, which the page has to be able to tell apart. + (Acquisition::RequiresSignal, ConsentSignal::Neutral) => { + awaiting = awaiting.with(permission); + } + } + } + PermissionState::new(set).with_awaiting(awaiting) + } + + /// 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 policy file a domain runs +// --------------------------------------------------------------------------- + +/// The SHA-256 that pins a policy file: the digest of its exact bytes, in +/// lower-case hexadecimal. +/// +/// A checkout that converts line endings changes the bytes and so the +/// digest, so the file has to reach the push byte for byte. +#[must_use] +pub fn policy_sha256(text: &str) -> String { + hex::encode(Sha256::digest(text.as_bytes())) +} + +/// The policy file a domain's settings name, pinned to the exact bytes the +/// push supplied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyReference { + /// The file's stem, as `[permissions] policy` names it. + pub policy: String, + /// The SHA-256 of the file, as [`policy_sha256`] forms it. + pub sha256: String, +} + +/// A domain's permission policy: the file its settings name, checked +/// against the digest they pin and parsed. +#[derive(Debug)] +pub struct PermissionPolicy { + reference: PolicyReference, + maps: Arc, +} + +impl PermissionPolicy { + /// The policy's name, the stem of its file. + #[must_use] + pub fn name(&self) -> &str { + &self.reference.policy + } + + /// The SHA-256 of the policy's file. + #[must_use] + pub fn sha256(&self) -> &str { + &self.reference.sha256 + } + + /// The rules and signal decisions the policy declares. + #[must_use] + pub fn maps(&self) -> &PermissionMaps { + &self.maps + } +} + +/// Checks `text` against the digest `reference` pins and parses it into a +/// domain's policy. +/// +/// A digest never changes what it names, so the parsed rules are kept for +/// as long as the instance runs, and a later load of the same digest parses +/// nothing. +/// +/// # Errors +/// +/// [`TrustedServerError::Configuration`] when the SHA-256 of `text` is not +/// the one `reference` pins, or `text` is not a valid policy. +pub fn load_policy( + reference: PolicyReference, + text: &str, +) -> Result, Report> { + let sha256 = policy_sha256(text); + if sha256 != reference.sha256 { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "the permission policy `{}` is not the file that was pushed: its SHA-256 is {sha256}, and the configuration pins {}", + reference.policy, reference.sha256 + ), + })); + } + let maps = parsed_policy(&sha256, text).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "the permission policy `{}` is not valid: {error}", + reference.policy + ), + }) + })?; + Ok(Arc::new(PermissionPolicy { reference, maps })) +} + +/// The policy `reference` pins when this instance already holds the rules +/// of its digest, so a load need not read the file. +/// +/// Held rules were checked against their digest when they were first +/// loaded. +#[must_use] +pub(crate) fn held_policy(reference: &PolicyReference) -> Option> { + held_maps(&reference.sha256).map(|maps| { + Arc::new(PermissionPolicy { + reference: reference.clone(), + maps, + }) + }) +} + +/// The rules of the policy file whose SHA-256 is `sha256`, parsed from +/// `text` the first time this instance meets the digest. +fn parsed_policy(sha256: &str, text: &str) -> Result, PermissionsError> { + if let Some(maps) = held_maps(sha256) { + return Ok(maps); + } + // Parsed outside the lock, so a long parse holds up no other load. + let maps = Arc::new(PermissionMaps::from_yaml(text)?); + Ok(Arc::clone( + parsed_policies() + .lock() + .unwrap_or_else(PoisonError::into_inner) + .entry(sha256.to_owned()) + .or_insert(maps), + )) +} + +/// The rules this instance holds for the digest `sha256`. +fn held_maps(sha256: &str) -> Option> { + parsed_policies() + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(sha256) + .cloned() +} + +/// The rules this instance has parsed, by the SHA-256 of their file, kept +/// for as long as the instance runs. +fn parsed_policies() -> &'static Mutex>> { + static PARSED: OnceLock>>> = OnceLock::new(); + PARSED.get_or_init(Mutex::default) +} + +/// The repository's sample policy, for tests. No build a deployment runs +/// carries it. +#[cfg(any(test, feature = "test-utils"))] +pub const SAMPLE_POLICY_TEXT: &str = include_str!("../../../config/permissions/sample.yaml"); + +/// The name a test's settings give the repository's sample policy. +#[cfg(any(test, feature = "test-utils"))] +pub const SAMPLE_POLICY_NAME: &str = "sample"; + +/// The repository's sample, loaded once as a domain that names it would load +/// it. +#[cfg(any(test, feature = "test-utils"))] +fn sample_policy() -> &'static Arc { + static SAMPLE: OnceLock> = OnceLock::new(); + SAMPLE.get_or_init(|| { + load_policy( + PolicyReference { + policy: SAMPLE_POLICY_NAME.to_owned(), + sha256: policy_sha256(SAMPLE_POLICY_TEXT), + }, + SAMPLE_POLICY_TEXT, + ) + .expect("should load the repository's sample policy") + }) +} + +#[cfg(any(test, feature = "test-utils"))] +impl PermissionPolicy { + /// The repository's sample policy, for tests. + /// + /// # Panics + /// + /// Panics when the sample does not load, which its own tests catch. + #[must_use] + pub fn sample() -> Arc { + Arc::clone(sample_policy()) + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl PermissionMaps { + /// The rules of the repository's sample policy, for tests. + /// + /// # Panics + /// + /// Panics when the sample does not load, which its own tests catch. + #[must_use] + pub fn sample() -> &'static Self { + sample_policy().maps() + } +} + +/// A signal a module read from the request and found valid, as it was +/// received. +/// +/// The permission state carries one of these for every signal a configured +/// module used, so whatever reads the state can rely on exactly those +/// signals and no other. A signal that was absent, could not be read, or +/// that no configured module acts on is not here, and nothing says why, +/// because the module for that scheme has already decided what its +/// absence means for the permissions. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ValidSignal { + /// The module that read and used the signal, by its configured id. + pub module: &'static str, + /// The scheme the signal belongs to, for example `tcf` or `gpp`, so a + /// reader can find a scheme without knowing which module read it. + pub scheme: &'static str, + /// The signal as it was received. + pub value: String, +} + +impl ValidSignal { + /// A valid signal of `scheme`, read by `module`, as received. + #[must_use] + pub fn new(module: &'static str, scheme: &'static str, value: impl Into) -> Self { + Self { + module, + scheme, + value: value.into(), + } + } +} + +/// The permissions Trusted Server currently has set for a request. +/// +/// A module executes only when [`all_set`](Self::all_set) of its required +/// permissions returns `true`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PermissionState { + set: PermissionSet, + /// The permissions whose baseline requires a signal and for which no + /// configured module gave one. See [`awaiting`](Self::awaiting). + awaiting: PermissionSet, + /// Whether the request explicitly withdrew device storage, as opposed to + /// storage merely not being set. See + /// [`storage_withdrawn`](Self::storage_withdrawn). + storage_withdrawn: bool, + /// The terms documents the data for this request is available under. See + /// [`tdls`](Self::tdls). Shared rather than owned because the state is + /// cloned along the request path and the list is the same list. + tdls: Arc<[Tdl]>, + /// The signals the configured modules read and found valid. See + /// [`signals`](Self::signals). + signals: Arc<[ValidSignal]>, +} + +impl PermissionState { + /// Builds a state in which exactly the permissions in `set` are set, and + /// nothing is withdrawn, for tests and callers that compute the set + /// directly. + #[must_use] + pub fn new(set: PermissionSet) -> Self { + Self { + set, + awaiting: PermissionSet::none(), + storage_withdrawn: false, + tdls: Arc::default(), + signals: Arc::default(), + } + } + + /// The same state, recording which permissions are still waiting for a + /// signal. Set by resolution from the baseline and the modules' answers. + #[must_use] + pub fn with_awaiting(self, awaiting: PermissionSet) -> Self { + Self { awaiting, ..self } + } + + /// The same state, awaiting only the permissions in `answerable`. + /// + /// Resolution records every permission that requires a signal and got + /// none. Some of those no configured module could ever grant, for + /// example a marketing channel opt-in in a deployment whose modules can + /// grant none, and a page told to wait for them would wait for ever. So + /// the assembly narrows the list to what some module declares it can + /// grant, and the rest are simply not set. + #[must_use] + pub fn awaiting_only(self, answerable: PermissionSet) -> Self { + Self { + awaiting: self.awaiting.intersect(answerable), + ..self + } + } + + /// The permissions whose baseline requires a signal and for which every + /// configured module answered neutral, so nobody has answered yet. + /// + /// This is different from a permission that is not set. A refused + /// permission and one nobody has answered look the same in + /// [`permissions`](Self::permissions), and a page that cannot tell them + /// apart treats a prompt that never ran as a visitor who said no. A + /// permission is never both set and awaited. + #[must_use] + pub const fn awaiting(&self) -> PermissionSet { + self.awaiting + } + + /// Whether a permission is still waiting for a signal. + #[must_use] + pub const fn is_awaited(&self, permission: Permission) -> bool { + self.awaiting.contains(permission) + } + + /// The same state, recording whether device storage was explicitly + /// withdrawn. Set by assembly from what the signal modules answered, + /// scoped to the jurisdiction's storage baseline. + #[must_use] + pub fn with_storage_withdrawn(self, storage_withdrawn: bool) -> Self { + Self { + storage_withdrawn, + ..self + } + } + + /// The same state, carrying the terms documents the data for this request + /// is available under. Set by assembly from what the signal modules + /// declared, in the order they are asked. + #[must_use] + pub fn with_tdls(self, tdls: Arc<[Tdl]>) -> Self { + Self { tdls, ..self } + } + + /// The same state, carrying the signals the modules read and found + /// valid. Set by assembly, in the order the modules are asked. + #[must_use] + pub fn with_signals(self, signals: Arc<[ValidSignal]>) -> Self { + Self { signals, ..self } + } + + /// The signals the configured modules read from the request and + /// found valid, each as it was received. + /// + /// This is the list a page, a bid request or a person reading the state + /// relies on. A signal that is not here was absent, could not be read, or + /// is one no configured module acts on, and the module for its + /// scheme has already decided what that means for the permissions, so + /// nothing downstream needs to reason about the signal itself. + #[must_use] + pub fn signals(&self) -> &[ValidSignal] { + &self.signals + } + + /// The terms documents the data for this request is available under, in + /// the order the modules were asked. + /// + /// Whoever receives the data reads these to decide whether the terms are + /// ones they accept, and whether they may pass the data on. An empty list + /// says no terms were declared, which is not the same as terms that permit + /// anything, so a recipient that needs a basis and finds none has none. + #[must_use] + pub fn tdls(&self) -> &[Tdl] { + &self.tdls + } + + /// Whether the request carries an explicit signal withdrawing device + /// storage, rather than merely lacking the permission. + /// + /// The difference is destructive. A withdrawal expires the browser cookie + /// and writes the authoritative identity-graph tombstone, where a + /// permission that is simply not set strips the Edge Cookie response + /// headers and leaves an already-issued identifier alone, so a returning + /// visitor is not permanently withdrawn before they ever get to answer. + /// Which scheme can withdraw is each module's to say, and only where the + /// jurisdiction's storage baseline did not grant storage outright. + #[must_use] + pub const fn storage_withdrawn(&self) -> bool { + self.storage_withdrawn + } + + /// Whether a single permission is set. + #[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 module 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 module 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. `awaiting` names the permissions still waiting for a + /// signal, so a page can hold what depends on them rather than read + /// "nobody has answered" as "refused". `tdls` carries the terms documents + /// the data is available under, in the order the modules were asked, so + /// a page module reads the terms alongside the permissions. `signals` + /// carries the signals the modules read and found valid, each as it was + /// received, so a page relies on exactly what the state was built from. + /// An empty state renders as + /// `{"awaiting":[],"set":[],"signals":[],"tdls":[]}`, and all four are + /// answers (nothing is set, nothing is awaited, no signal was valid, no + /// terms were declared) rather than missing values, so page code never + /// has to tell them apart. + /// + /// This is the only place the page shape is spelled, so no caller writes + /// the JSON by hand. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::permissions::{ + /// Permission, PermissionSet, PermissionState, + /// }; + /// + /// let state = PermissionState::new( + /// PermissionSet::none().with(Permission::StoreOnDevice), + /// ); + /// assert_eq!( + /// state.page_json(), + /// r#"{"awaiting":[],"set":["necessary.operations.storage"],"signals":[],"tdls":[]}"# + /// ); + /// + /// assert_eq!( + /// PermissionState::default().page_json(), + /// r#"{"awaiting":[],"set":[],"signals":[],"tdls":[]}"# + /// ); + /// ``` + #[must_use] + pub fn page_json(&self) -> String { + let mut names: Vec<&'static str> = self.set.iter().map(Permission::as_str).collect(); + names.sort_unstable(); + let mut awaiting: Vec<&'static str> = + self.awaiting.iter().map(Permission::as_str).collect(); + awaiting.sort_unstable(); + let tdls: Vec<&str> = self.tdls.iter().map(Tdl::as_str).collect(); + serde_json::json!({ + "set": names, + "awaiting": awaiting, + "signals": &*self.signals, + "tdls": tdls, + }) + .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. +/// +/// Only whether a TCF record answers for this deployment. Which purpose grants +/// which Data Use is the TCF scheme's own knowledge and lives in the TCF +/// permission signal module crate, so this file carries no table of another +/// scheme's numbers and a deployment running no TCF configures none. +#[derive(Debug, Deserialize)] +struct TcfSignalSpec { + /// Whether a present TCF record's grants and revokes apply. + #[serde(default = "default_true")] + authoritative: bool, +} + +/// 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 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 sample's 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 module 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(()) +} + +/// 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 module 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 module 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 }, + /// 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::*; + use crate::test_support::tests::policy_only_this_test_loads; + + #[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", + ], + "awaiting": [], + "signals": [], + "tdls": [], + }) + .to_string(), + "should list every set permission by Data Use name, sorted" + ); + } + + #[test] + fn page_json_carries_the_terms_the_data_is_available_under() { + // Arrange: the state a request resolves to when a terms scheme + // declared the document its data is offered under. + let state = PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)) + .with_tdls(Arc::from(vec![ + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ])); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ + "set": ["necessary.operations.storage"], + "awaiting": [], + "signals": [], + "tdls": ["https://terms.example.com/marketing/2.txt"], + }) + .to_string(), + "page code should read the terms alongside the permissions" + ); + } + + #[test] + fn page_json_of_an_empty_state_is_an_empty_set() { + // Arrange + let state = PermissionState::default(); + + // Act + let json = state.page_json(); + + // Assert + assert_eq!( + json, + json!({ "set": [], "awaiting": [], "signals": [], "tdls": [] }).to_string(), + "an empty state should render as an empty set and no declared terms, not as nothing" + ); + } + + #[test] + fn a_permission_requiring_a_signal_that_nobody_gave_is_awaited() { + // Arrange: the requires-signal floor, where every permission needs a + // signal, and modules that answer nothing. + let state = PermissionMaps::floor_with(|_, _| ConsentSignal::Neutral); + + // Assert: nothing is set and everything is awaited, which is the + // state a page must hold on rather than read as a refusal. + assert!( + state.permissions().is_empty(), + "should set nothing without a signal" + ); + assert!( + state.is_awaited(Permission::StoreOnDevice), + "should record that storage is still waiting for a signal" + ); + assert_eq!( + state.page_json(), + json!({ + "set": [], + "awaiting": Permission::all().map(Permission::as_str).collect::>(), + "signals": [], + "tdls": [], + }) + .to_string(), + "the page should be told which permissions are still awaited" + ); + } + + #[test] + fn awaiting_is_narrowed_to_what_a_module_could_still_grant() { + // Arrange: the floor, where everything is awaited, and a deployment + // whose modules can only ever grant storage. + let state = PermissionMaps::floor_with(|_, _| ConsentSignal::Neutral) + .awaiting_only(PermissionSet::none().with(Permission::StoreOnDevice)); + + // Assert: storage is still awaited, and a permission nobody could + // grant is not, because no page should wait for it. + assert!(state.is_awaited(Permission::StoreOnDevice)); + assert!( + !state.is_awaited(Permission::SelectPersonalisedAds), + "should not await a permission no configured module can grant" + ); + assert!( + state.permissions().is_empty(), + "narrowing what is awaited should set nothing" + ); + } + + #[test] + fn an_answered_or_baselined_permission_is_never_awaited() { + // Arrange: the floor again, with a module that grants storage and + // revokes personalised advertising. + let state = PermissionMaps::floor_with(|permission, _| match permission { + Permission::StoreOnDevice => ConsentSignal::Grant, + Permission::SelectPersonalisedAds => ConsentSignal::Revoke, + _ => ConsentSignal::Neutral, + }); + + // Assert: a grant is set and not awaited, a refusal is neither set + // nor awaited, and a permission nobody answered is awaited. + assert!(state.is_set(Permission::StoreOnDevice)); + assert!( + !state.is_awaited(Permission::StoreOnDevice), + "a grant is an answer" + ); + assert!(!state.is_set(Permission::SelectPersonalisedAds)); + assert!( + !state.is_awaited(Permission::SelectPersonalisedAds), + "a refusal is an answer too, and must not look like no answer" + ); + assert!(state.is_awaited(Permission::SelectBasicAds)); + } + + #[test] + fn the_floor_sets_a_permission_only_when_a_signal_grants_it() { + // Empty maps and no top node: every permission is the requires-signal + // 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 module 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 sample_maps_eu_requires_signal_and_uk_grants_storage() { + let maps = PermissionMaps::sample(); + 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 sample_maps_us_and_australia_grant_storage_by_default() { + let maps = PermissionMaps::sample(); + 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::sample(); + 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::sample().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" + ); + } + + /// Every way `PermissionMaps::from_yaml` refuses a policy, each with the + /// error it must report. + #[test] + fn from_yaml_rejects_each_malformed_policy() { + type Refusal = fn(&PermissionsError) -> bool; + // A policy given one YAML line to an element, with or without the test + // groups ahead of it. + let tree = |lines: &[&str]| format!("{TEST_GROUPS}{}\n", lines.join("\n")); + let policy = |lines: &[&str]| format!("{}\n", lines.join("\n")); + let cases: [(&str, String, Refusal); 15] = [ + ( + "a block without a group", + tree(&[ + "rules:", + " group: g", + " jurisdiction: gdpr", + " US:", + " jurisdiction: non-regulated", + ]), + |err| matches!(err, PermissionsError::MissingGroup { path } if path == "US"), + ), + ( + "a top node without a group", + tree(&["rules:", " jurisdiction: gdpr", " US: g"]), + |err| matches!(err, PermissionsError::MissingGroup { .. }), + ), + ( + "a top node without a jurisdiction", + tree(&["rules:", " group: g", " US: g"]), + |err| matches!(err, PermissionsError::MissingJurisdiction), + ), + ( + "an unknown jurisdiction", + tree(&["rules:", " group: g", " jurisdiction: ccpa"]), + |err| matches!(err, PermissionsError::UnknownJurisdiction { .. }), + ), + ( + // `us-state` names no code of its own, so only a region can + // carry it. + "us-state at the top of the tree", + tree(&["rules:", " group: g", " jurisdiction: us-state"]), + |err| matches!(err, PermissionsError::MisplacedUsState { .. }), + ), + ( + "us-state on a country", + tree(&[ + "rules:", + " group: g", + " jurisdiction: gdpr", + " US:", + " group: g", + " jurisdiction: us-state", + ]), + |err| matches!(err, PermissionsError::MisplacedUsState { .. }), + ), + ( + "a place written under a region", + tree(&[ + "rules:", + " group: g", + " jurisdiction: gdpr", + " US:", + " group: g", + " CA:", + " group: g", + " LA:", + " group: g", + ]), + |err| matches!(err, PermissionsError::NestedTooDeep { .. }), + ), + ( + "a rule naming a group that is not defined", + tree(&["rules:", " group: missing", " jurisdiction: gdpr"]), + |err| matches!(err, PermissionsError::UnknownGroup { name } if name == "missing"), + ), + ( + "a rule that is neither a group name nor a block", + tree(&[ + "rules:", + " group: g", + " jurisdiction: gdpr", + " US: [1, 2]", + ]), + |err| matches!(err, PermissionsError::InvalidRule { path } if path == "US"), + ), + ( + // A group with no `default` must list every permission, so this + // one, naming only storage, is rejected rather than silently + // leaving every other permission unset. + "an incomplete group without a default", + policy(&[ + "groups:", + " g:", + " necessary.operations.storage: granted", + "rules: {}", + ]), + |err| matches!(err, PermissionsError::IncompleteGroup { .. }), + ), + ( + "an unknown permission", + policy(&[ + "groups:", + " g:", + " default: granted", + " not-a-permission: denied", + "rules: {}", + ]), + |err| matches!(err, PermissionsError::UnknownPermission { .. }), + ), + ( + "an unknown acquisition", + policy(&["groups:", " g:", " default: maybe", "rules: {}"]), + |err| matches!(err, PermissionsError::UnknownAcquisition { .. }), + ), + ( + "an override that is not an acquisition", + policy(&[ + "groups:", + " g:", + " default: granted", + "rules:", + " group: g", + " jurisdiction: unknown", + " US:", + " group: g", + " permissions:", + " necessary.operations.storage: enabled", + ]), + |err| matches!(err, PermissionsError::UnknownAcquisition { .. }), + ), + ( + "two spellings of one country", + policy(&[ + "groups:", + " g:", + " default: granted", + "rules:", + " group: g", + " jurisdiction: unknown", + " us: g", + " US: g", + ]), + |err| matches!(err, PermissionsError::DuplicateRule { .. }), + ), + ( + "an unknown revoke keyword", + policy(&[ + "groups:", + " g:", + " default: requires_signal", + "rules:", + " group: g", + " jurisdiction: gdpr", + " FR: g", + "signals:", + " us_opt_out:", + " sources: [gpc]", + " revokes: everything", + ]), + |err| matches!(err, PermissionsError::UnknownRevoke { .. }), + ), + ]; + for (case, yaml, refusal) in cases { + let Err(err) = PermissionMaps::from_yaml(&yaml) else { + panic!("{case} should be rejected"); + }; + assert!(refusal(&err), "{case}: wrong refusal, 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_sample_policy_reproduces_the_retired_consent_lists() { + // The policy tree carries the 31 GDPR countries and the 20 US + // privacy states, in place of compiled defaults in the consent + // configuration, so the sample must resolve each to its regime. + let maps = PermissionMaps::sample(); + 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::sample(); + 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_accepts_an_explicit_group_listing_every_permission() { + // The sample's 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 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 sample 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::sample(); + 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 + 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!( + 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 the_policy_digest_is_the_lower_case_hex_sha256_of_the_exact_bytes() { + assert_eq!( + policy_sha256("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "should be the SHA-256 of the bytes in lower-case hexadecimal" + ); + assert_ne!( + policy_sha256("groups: {}\n"), + policy_sha256("groups: {}\r\n"), + "a converted line ending should change the digest" + ); + } + + #[test] + fn a_file_that_is_not_the_pinned_file_does_not_load() { + let reference = PolicyReference { + policy: "standard".to_owned(), + sha256: policy_sha256("a different file"), + }; + + let error = load_policy(reference, SAMPLE_POLICY_TEXT) + .expect_err("a file the digest does not pin should not load"); + + let message = format!("{error:?}"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error: {message}" + ); + assert!( + message.contains("`standard`") + && message.contains(&policy_sha256(SAMPLE_POLICY_TEXT)) + && message.contains(&policy_sha256("a different file")), + "should name the policy and both digests: {message}" + ); + } + + #[test] + fn a_pinned_file_that_is_not_a_valid_policy_does_not_load() { + // The top of the tree names no jurisdiction. + let text = "groups:\n open:\n default: granted\nrules:\n group: open\n"; + let reference = PolicyReference { + policy: "broken".to_owned(), + sha256: policy_sha256(text), + }; + + let error = load_policy(reference, text).expect_err("an invalid policy should not load"); + + let message = format!("{error:?}"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error: {message}" + ); + assert!( + message.contains("`broken` is not valid") && message.contains("jurisdiction"), + "should name the policy and the parser's reason: {message}" + ); + } + + #[test] + fn two_loads_of_one_digest_parse_the_file_once() { + let text = policy_only_this_test_loads("parsed-once"); + let reference = |policy: &str| PolicyReference { + policy: policy.to_owned(), + sha256: policy_sha256(&text), + }; + + let first = load_policy(reference("parsed-once"), &text).expect("should load"); + let second = load_policy(reference("parsed-once"), &text).expect("should load again"); + + assert!( + Arc::ptr_eq(&first.maps, &second.maps), + "the second load should keep the rules the first one parsed" + ); + } + + #[test] + fn one_file_under_two_names_keeps_each_name() { + let text = policy_only_this_test_loads("two-names"); + let load = |policy: &str| { + load_policy( + PolicyReference { + policy: policy.to_owned(), + sha256: policy_sha256(&text), + }, + &text, + ) + .expect("should load") + }; + + let first = load("standard"); + let second = load("copy-of-standard"); + + assert_eq!(first.name(), "standard", "should keep the first name"); + assert_eq!( + second.name(), + "copy-of-standard", + "should keep the second name" + ); + assert!( + Arc::ptr_eq(&first.maps, &second.maps), + "one digest should parse once whatever it is called" + ); + } + + #[test] + fn the_sample_policy_loads_under_its_own_digest() { + let sample = PermissionPolicy::sample(); + + assert_eq!(sample.name(), SAMPLE_POLICY_NAME); + assert_eq!(sample.sha256(), policy_sha256(SAMPLE_POLICY_TEXT)); + assert!( + std::ptr::eq(sample.maps(), PermissionMaps::sample()), + "the sample's rules should be parsed once for every test" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/error.rs b/crates/trusted-server-core/src/platform/error.rs index 699eb7d483..30f7259bec 100644 --- a/crates/trusted-server-core/src/platform/error.rs +++ b/crates/trusted-server-core/src/platform/error.rs @@ -23,6 +23,9 @@ pub enum PlatformError { /// Geo lookup failed. #[display("geo lookup error")] Geo, + /// A permission policy file could not be read. + #[display("permission policy source error")] + PolicySource, /// Operation is not supported by this platform adapter. #[display("unsupported platform operation")] Unsupported, diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 6f9b113a6a..e4a836f47e 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -12,6 +12,7 @@ //! - [`PlatformBackend`] — dynamic backend registration //! - [`PlatformHttpClient`] — outbound HTTP client //! - [`PlatformGeo`] — geographic information lookup +//! - [`PlatformPolicySource`] (permission policy files kept outside the blob) //! - [`PlatformTemplateAssembler`] — cold-response shared-template assembly //! - [`PlatformTemplateCache`] — shared transformed-template caching //! @@ -39,6 +40,7 @@ mod error; mod http; mod image_optimizer; mod kv; +mod policy_source; mod template_assembly; mod template_cache; #[cfg(test)] @@ -61,6 +63,7 @@ pub use image_optimizer::{ PlatformImageOptimizerParams, PlatformImageOptimizerRegion, }; pub use kv::UnavailableKvStore; +pub use policy_source::{PlatformPolicySource, PolicyOrigin}; pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, @@ -90,10 +93,12 @@ use crate::settings::Settings; /// A geo module that resolves nothing. /// -/// Installed when `[geo] module = "none"` is selected, so a client IP is -/// never sent to any host geo service. Every geo consumer already treats -/// [`GeoInfo`] as optional, so a `None` result degrades gracefully (the -/// jurisdiction is unknown, the auction omits geo, and so on). +/// Installed when no geo module 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 { @@ -104,12 +109,12 @@ impl PlatformGeo for DisabledGeo { /// Selects the geo module named by the `[geo] module` selector. /// -/// The host platform's geo lookup is the default: with no selector, -/// `host_default` (the adapter's platform geo implementation) resolves the -/// location, matching the behavior before the selector existed, and -/// `module = "platform"` spells the same choice explicitly. Selecting -/// `module = "none"` returns [`DisabledGeo`] instead, so a client IP is -/// never sent to any host geo service. A selected-but-unknown module is +/// Returns [`DisabledGeo`] when no module 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. `module = "none"` spells the +/// same choice explicitly. The host platform's own geo lookup is opt-in: +/// `module = "platform"` returns `host_default`, which the adapter passes +/// as its platform geo implementation. A selected-but-unknown module is /// rejected at startup by /// [`GeoConfig::validate_module_selection`](crate::settings::GeoConfig::validate_module_selection). #[must_use] @@ -118,8 +123,8 @@ pub fn build_geo_module( host_default: Arc, ) -> Arc { match settings.geo.module.as_deref() { - Some("none") => Arc::new(DisabledGeo), - _ => host_default, + Some("platform") => host_default, + _ => Arc::new(DisabledGeo), } } @@ -190,6 +195,14 @@ mod tests { { } + #[test] + fn disabled_geo_requires_no_permissions() { + assert!( + DisabledGeo.required_permissions().is_empty(), + "the default disabled geo module requires no permissions" + ); + } + #[test] fn runtime_services_can_be_constructed_and_cloned() { let services = noop_services(); @@ -216,18 +229,25 @@ mod tests { } #[test] - fn build_geo_module_defaults_to_the_host_geo() { + fn build_geo_module_defaults_to_no_geo() { let settings = Settings::default(); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); assert!( - Arc::ptr_eq(&host, &selected), - "default settings should use the host geo" + !Arc::ptr_eq(&host, &selected), + "default settings should not use the host geo" + ); + assert!( + selected + .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .expect("disabled geo lookup should not fail") + .is_none(), + "the default geo module should resolve nothing" ); } #[test] - fn build_geo_module_none_selects_no_geo() { + fn build_geo_module_none_selects_no_geo_explicitly() { let mut settings = Settings::default(); settings.geo.module = Some("none".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); @@ -236,13 +256,6 @@ mod tests { !Arc::ptr_eq(&host, &selected), "module none should not use the host geo" ); - assert!( - selected - .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) - .expect("disabled geo lookup should not fail") - .is_none(), - "the disabled geo module should resolve nothing" - ); } #[test] diff --git a/crates/trusted-server-core/src/platform/policy_source.rs b/crates/trusted-server-core/src/platform/policy_source.rs new file mode 100644 index 0000000000..60313c55e1 --- /dev/null +++ b/crates/trusted-server-core/src/platform/policy_source.rs @@ -0,0 +1,32 @@ +//! Where a domain's permission policy file is read from. +//! +//! On most adapters the push writes the file into the domain's blob, so the +//! runtime loader takes it from there. An adapter that keeps policy files +//! somewhere of its own reads them through a [`PlatformPolicySource`]. + +use error_stack::Report; + +use super::PlatformError; +use crate::permissions::PolicyReference; + +/// Reads the permission policy file a domain's settings name. +pub trait PlatformPolicySource: Send + Sync { + /// The text of the file `reference` names. + /// + /// The runtime loader checks the text against the digest the reference + /// pins, so a source returns the file as it finds it. + /// + /// # Errors + /// + /// [`PlatformError::PolicySource`] when the file cannot be read. + fn policy_text(&self, reference: &PolicyReference) -> Result>; +} + +/// Where the runtime loader takes a domain's policy file from. +#[derive(Clone, Copy)] +pub enum PolicyOrigin<'a> { + /// The domain's blob, as `[permissions] text`. + Blob, + /// A source of the adapter's own. Any text the blob carries is not used. + Source(&'a dyn PlatformPolicySource), +} diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 55ee5573be..334de7eb6b 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -865,6 +865,12 @@ impl PlatformGeo for NoopGeo { } } +/// A [`RuntimeServices`] builder carrying the repository's sample policy, +/// which the core tests decide permissions under. +pub(crate) fn test_services_builder() -> super::RuntimeServicesBuilder { + RuntimeServices::builder().permission_policy(crate::permissions::PermissionPolicy::sample()) +} + /// Build a [`RuntimeServices`] instance with a custom config store and a custom secret store. /// /// Use this when a test exercises code that reads from config AND secret stores, @@ -873,7 +879,7 @@ pub(crate) fn build_services_with_config_and_secret( config_store: impl PlatformConfigStore + 'static, secret_store: impl PlatformSecretStore + 'static, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(config_store)) .secret_store(Arc::new(secret_store)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -889,7 +895,7 @@ pub(crate) fn build_services_with_config_and_secret_and_client_ip( secret_store: impl PlatformSecretStore + 'static, client_ip: IpAddr, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(config_store)) .secret_store(Arc::new(secret_store)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -926,7 +932,7 @@ pub(crate) fn build_request_signing_services() -> RuntimeServices { pub(crate) fn build_services_with_config( config_store: impl PlatformConfigStore + 'static, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(config_store)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -941,6 +947,24 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] with an injected geo module, 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 { + test_services_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 module, threaded the /// way a composition root threads the module it resolved, so a test can /// exercise the seam a vendor module reaches core through and check that the @@ -972,7 +996,7 @@ fn noop_services_with_ec_module_and_ip( ec_module: Arc, client_ip: Option, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -993,7 +1017,7 @@ fn noop_services_with_ec_module_and_ip( pub(crate) fn noop_services_with_telemetry_sink( auction_telemetry_sink: Arc, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -1023,7 +1047,7 @@ pub(crate) fn build_services_with_http_client_and_client_ip( http_client: Arc, client_ip: IpAddr, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -1038,7 +1062,7 @@ pub(crate) fn build_services_with_http_client_and_client_ip( } pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -1067,7 +1091,7 @@ pub(crate) fn build_services_with_backend_and_http_client( backend: Arc, http_client: Arc, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -1089,7 +1113,7 @@ pub(crate) fn build_services_with_config_secret_and_http_client( secret_store: impl PlatformSecretStore + 'static, http_client: Arc, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(config_store)) .secret_store(Arc::new(secret_store)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) @@ -1105,7 +1129,7 @@ pub(crate) fn build_services_with_secret_http_client_and_client_ip( http_client: Arc, client_ip: Option, ) -> RuntimeServices { - RuntimeServices::builder() + test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(secret_store)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index d38c7b9c41..ab2ef16f33 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -152,4 +152,12 @@ pub trait PlatformGeo: Send + Sync { /// Returns [`PlatformError::Geo`] when the platform geo lookup fails /// unexpectedly. Returns `Ok(None)` when no data is available for the IP. fn lookup(&self, client_ip: Option) -> Result, Report>; + + /// The permissions this module's data use requires. + /// + /// The default is empty, so the default (disabled) geo module 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 34d0d67c03..10387b31ad 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -11,6 +11,8 @@ use super::{ }; use crate::ec::module::EdgeCookieModule; use crate::evidence::HostSignals; +use crate::permission_signal::PermissionSignalModule; +use crate::permissions::PermissionPolicy; /// Geographic information extracted from a request. /// @@ -202,6 +204,16 @@ pub struct RuntimeServices { /// path resolves the selection itself, which is what a deployment that /// selects no module, the Axum adapter, and the core tests all do. pub(crate) resolved_ec_module: Option>, + /// The permission signal modules this deployment runs, in the order + /// they are asked, selected at the composition root from the scheme + /// crates the adapter links. Empty when the adapter offers none, in which + /// case every permission stays at its country and region baseline. Shared, + /// so building the services for a request bumps a reference count rather + /// than copying the list, and cloning the services does the same. + pub(crate) permission_signal_modules: Arc<[Arc]>, + /// The permission policy the request's domain loaded, which decides its + /// permissions. + pub(crate) permission_policy: Arc, } impl RuntimeServices { @@ -222,6 +234,7 @@ impl RuntimeServices { /// .http_client(Arc::new(MyHttpClient)) /// .geo(Arc::new(MyGeo)) /// .client_info(client_info) + /// .permission_policy(policy) /// .build(); /// ``` #[must_use] @@ -316,6 +329,18 @@ impl RuntimeServices { self.resolved_ec_module.clone() } + /// The permission signal modules this deployment runs, in order. + #[must_use] + pub fn permission_signal_modules(&self) -> &[Arc] { + &self.permission_signal_modules + } + + /// The permission policy the request's domain loaded. + #[must_use] + pub fn permission_policy(&self) -> &Arc { + &self.permission_policy + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -355,6 +380,19 @@ impl RuntimeServices { } } + /// Returns a clone of this instance carrying the permission policy the + /// request's domain loaded. + /// + /// An adapter hands each request the policy its domain's settings carry, + /// whichever services the request is given. + #[must_use] + pub fn with_permission_policy(self, permission_policy: Arc) -> Self { + Self { + permission_policy, + ..self + } + } + /// Returns a clone of this instance with the template cache replaced. #[must_use] pub fn with_template_cache(self, cache: Arc) -> Self { @@ -402,6 +440,8 @@ pub struct RuntimeServicesBuilder { client_info: Option, host_signals: Option>, resolved_ec_module: Option>, + permission_signal_modules: Arc<[Arc]>, + permission_policy: Option>, } impl RuntimeServicesBuilder { @@ -419,6 +459,8 @@ impl RuntimeServicesBuilder { client_info: None, host_signals: None, resolved_ec_module: None, + permission_signal_modules: Arc::default(), + permission_policy: None, } } @@ -521,6 +563,33 @@ impl RuntimeServicesBuilder { self } + /// Set the permission signal modules this deployment runs, in the order + /// they are asked. + /// + /// Optional, and empty when unset. An adapter hands in the shared list + /// [`build_permission_signal_modules`] selected from the scheme crates it + /// links, so the request path asks exactly the modules configuration + /// named, in that order, and core supplies none of its own. + /// + /// [`build_permission_signal_modules`]: + /// crate::permission_signal::build_permission_signal_modules + #[must_use] + pub fn permission_signal_modules( + mut self, + modules: Arc<[Arc]>, + ) -> Self { + self.permission_signal_modules = modules; + self + } + + /// Set the permission policy the request's domain loaded, which every + /// request needs to decide its permissions. + #[must_use] + pub fn permission_policy(mut self, policy: Arc) -> Self { + self.permission_policy = Some(policy); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -563,6 +632,10 @@ impl RuntimeServicesBuilder { .expect("should set client_info before building RuntimeServices"), host_signals: self.host_signals, resolved_ec_module: self.resolved_ec_module, + permission_signal_modules: self.permission_signal_modules, + permission_policy: self + .permission_policy + .expect("should set permission_policy before building RuntimeServices"), } } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 9d6b4b14f7..36e34cb244 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -56,7 +56,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_SHAREDID, COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT, HOST_SIGNAL_HEADERS, }; @@ -73,6 +73,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, TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, VarySpec, contains_publisher_esi_directive, @@ -635,6 +636,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, @@ -678,6 +682,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, @@ -762,6 +767,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, @@ -1319,6 +1325,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, @@ -1475,6 +1484,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 deferred_inline_seam_token( settings: &Settings, shared_template_authorized: bool, @@ -1527,6 +1550,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) @@ -1702,6 +1726,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, @@ -2096,7 +2128,17 @@ fn template_fingerprint_with_build_digest(settings: &Settings, build_digest: &st // Array order is preserved: set-valued settings must serialize deterministically // themselves (for example, `allowed_context_keys` uses a `BTreeSet`). let canonical = serde_json::to_value(settings) - .map(|value| edgezero_core::canonical_form::canonical_data_sha256(&value)) + .map(|mut value| { + // The SHA-256 already names the policy file, so settings that pin + // one file fingerprint the same whether or not they carry its text. + if let Some(permissions) = value + .get_mut("permissions") + .and_then(serde_json::Value::as_object_mut) + { + permissions.remove("text"); + } + edgezero_core::canonical_form::canonical_data_sha256(&value) + }) .expect("should serialize typed settings infallibly"); hasher.update(canonical); hex::encode(hasher.finalize()) @@ -2120,21 +2162,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, 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, @@ -2142,6 +2191,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, @@ -2156,6 +2206,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, @@ -2907,6 +2958,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, @@ -2916,6 +2968,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, @@ -3021,6 +3074,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, @@ -3304,8 +3358,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() @@ -4483,17 +4537,21 @@ 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(); - // The active EC ID drives the internal snapshot preload and finalization — - // including consent-withdrawal tombstoning — so it must NOT be filtered by - // consent. The auction/EID identity is the consent-filtered view: under an - // explicit withdrawal `ec_allowed` is false, so auction dispatch forwards no - // EC while the origin-overlapped snapshot read still happens for the active - // ID, keeping the withdrawal CAS off the post-origin latency path. + // The active EC ID drives the internal snapshot preload and finalization, + // including withdrawal tombstoning, so it is not filtered by permission. + // 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 personalized-ad selection), not only the module's gate. + // Under an explicit withdrawal that pair is unset, so auction dispatch + // forwards no EC while the origin-overlapped snapshot read still happens + // for the active ID, keeping the withdrawal CAS off the post-origin + // latency path. let active_ec_id_owned = ec_context.ec_value().map(str::to_owned); let active_ec_id = active_ec_id_owned.as_deref(); // The identity-graph key for the active EC ID, the owning module's @@ -4501,13 +4559,20 @@ pub async fn handle_publisher_request( // under this key, the key the row was written under, rather than under the // identifier as issued. let active_kv_key = ec_context.ec_kv_key(); - let ec_id_owned = active_ec_id_owned.clone().filter(|_| ec_allowed); + let ec_id_owned = active_ec_id_owned + .clone() + .filter(|_| ec_context.ec_sharing_allowed()); let ec_id = ec_id_owned.as_deref(); let cookie_jar = handle_request_cookies(&req)?; if let Some(registry) = auction.registry { ec_context.validate_pull_sync_marker(settings, registry); } 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 { @@ -4840,7 +4905,7 @@ pub async fn handle_publisher_request( .registry .is_some_and(|registry| !registry.is_empty()), eid_cookie_may_need_persistence, - privacy_needs_row: crate::ec::consent::ec_consent_withdrawn(&consent_context), + privacy_needs_row: ec_context.storage_withdrawn(), }); let mut pending_origin = None; // A shared-template hit skips the origin entirely, and an in-flight request @@ -5125,6 +5190,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(); @@ -5514,6 +5580,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, @@ -5579,10 +5646,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 ); } @@ -6032,6 +6099,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 @@ -6041,17 +6109,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, @@ -6737,6 +6828,61 @@ pub(crate) fn template_ad_slots_script( } } +/// The permission-state `", + escaped + ) +} + +/// The permission state as page JSON, substituting the empty state for an unset +/// value. +/// +/// [`PermissionState::page_json`] never returns an empty string, so this only +/// covers a params value nothing filled in. `JSON.parse("")` throws, and a +/// thrown head script takes the rest of the snippet with it, so an unset value +/// renders as the empty state rather than as broken JavaScript. +fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { + if permissions_json.is_empty() { + Cow::Owned(PermissionState::default().page_json()) + } else { + Cow::Borrowed(permissions_json) + } +} + /// Build the `tsjs.adSlots` ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/tools/permissions-inspector/wasm/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock new file mode 100644 index 0000000000..1ea2997e59 --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -0,0 +1,2523 @@ +# 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?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b" +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?rev=683202c66948146ec360f490126f1d60f920288b#683202c66948146ec360f490126f1d60f920288b" +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", + "trusted-server-permission-signal-gpc", + "trusted-server-permission-signal-gpp", + "trusted-server-permission-signal-tcf", + "trusted-server-permission-signal-us-privacy", +] + +[[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", + "cssparser", + "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 = "trusted-server-permission-signal-gpc" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-gpp" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-tcf" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "trusted-server-permission-signal-us-privacy" +version = "0.1.0" +dependencies = [ + "trusted-server-core", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml new file mode 100644 index 0000000000..2871aad40c --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -0,0 +1,29 @@ +[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" } +# The inspector runs the same permission signal modules an adapter ships, +# in the same default order, so the page answers as a deployment would. +trusted-server-permission-signal-gpc = { path = "../../../crates/permission-signal/gpc" } +trusted-server-permission-signal-gpp = { path = "../../../crates/permission-signal/gpp" } +trusted-server-permission-signal-tcf = { path = "../../../crates/permission-signal/tcf" } +trusted-server-permission-signal-us-privacy = { path = "../../../crates/permission-signal/us-privacy" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 + +# Deliberately its own workspace, like the integration-tests crate, so the +# main workspace's wasm32-wasip1 default target does not apply to it. +[workspace] diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs new file mode 100644 index 0000000000..5e8f11cb32 --- /dev/null +++ b/tools/permissions-inspector/wasm/build.rs @@ -0,0 +1,46 @@ +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Reads the workspace version from the repository root manifest, so the page +/// reports the same version as the trusted-server crates it runs. +fn workspace_version() -> String { + let root = std::fs::read_to_string("../../../Cargo.toml").unwrap_or_default(); + let mut in_package = false; + for line in root.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_package = line == "[workspace.package]"; + } else if in_package && line.starts_with("version") { + if let Some(version) = line.split('"').nth(1) { + return version.to_string(); + } + } + } + String::from("unknown") +} + +fn main() { + println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); + println!( + "cargo:rustc-env=TS_CORE_COMMIT={}", + git(&["rev-parse", "--short=9", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_DATE={}", + git(&["show", "-s", "--format=%cs", "HEAD"]) + ); + println!( + "cargo:rustc-env=TS_CORE_BRANCH={}", + git(&["rev-parse", "--abbrev-ref", "HEAD"]) + ); + println!("cargo:rerun-if-changed=../../../Cargo.toml"); +} diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs new file mode 100644 index 0000000000..2031a4a09d --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,192 @@ +//! 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 std::sync::{Arc, OnceLock}; + +use serde::Deserialize; +use serde_json::json; +use trusted_server_core::consent::build_context_from_signals; +use trusted_server_core::consent::types::RawConsentSignals; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::permission_signal::PermissionSignalModule; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The signal modules the inspector asks, in the order every adapter offers +/// them when `[permission-signal] modules` names none. +fn modules() -> Vec> { + vec![ + Arc::new(trusted_server_permission_signal_gpc::GpcModule::new()), + Arc::new(trusted_server_permission_signal_gpp::GppSaleOptOutModule::new()), + Arc::new(trusted_server_permission_signal_us_privacy::UsPrivacyModule::new()), + Arc::new(trusted_server_permission_signal_tcf::TcfModule::new()), + ] +} + +/// 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, +} + +/// The repository's sample policy, which the page's repository samples tab +/// evaluates. +const SAMPLE_POLICY: &str = include_str!("../../../../config/permissions/sample.yaml"); + +/// The sample's rules, parsed once. +fn sample_maps() -> &'static PermissionMaps { + static MAPS: OnceLock = OnceLock::new(); + MAPS.get_or_init(|| { + PermissionMaps::from_yaml(SAMPLE_POLICY) + .expect("should parse the repository's sample policy") + }) +} + +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); + // The page carries no request, only the consent signals above, and each of + // the four modules answers from the consent record rather than from + // request evidence, so empty evidence changes none of their answers. A + // module that read a header or a cookie would need real evidence here. + let evidence = OwnedRequestInfo::default(); + let modules = modules(); + // The page's repository mode evaluates the repository's sample. + let maps = sample_maps(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(maps, &ctx, &evidence, GeoStatus::Failed, &modules); + (state, "unknown".to_string()) + } + "none" => { + let state = + assemble_permissions(maps, &ctx, &evidence, GeoStatus::NoLocation, &modules); + (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(maps, &ctx, &evidence, GeoStatus::Located(&info), &modules); + 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(), + // No `malformed_record`. Whether an absent, unreadable or expired + // signal changes the permissions is each module's own decision, + // taken in its answer, so nothing at this level can report one and + // `false` would read as "none was found". + }) + .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 39570594b5..95ddac485f 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -144,11 +144,23 @@ pull_sync_concurrency = 3 # [device] # module = "builtin" -# Geo / IP intelligence module selection. The platform host geo is the -# default. Set module = "none" to resolve no location and make no host geo -# call. -# [geo] +[geo] +# Geo / IP intelligence module selection. No module is the default: +# Trusted Server resolves no location and makes no host geo call. The host +# platform's own lookup is opt-in. # module = "platform" +# +# The permission baseline for a request the geo module leaves unmatched +# (and, with no geo module, for every request) is the top of the rules tree +# in the policy file [permissions] policy names. Edit that file, and push it +# again, to change it. +# +# With no geo module, 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 module without a geo module must +# acknowledge that by uncommenting the line below, or select a geo module +# instead. +# assume_single_jurisdiction = true # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] @@ -194,11 +206,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? @@ -209,6 +220,68 @@ pull_sync_concurrency = 3 # mode = "restrictive" # "restrictive" | "newest" | "permissive" # freshness_threshold_days = 30 +# The permission policy file this domain runs. Name the publisher's own +# policy file here by its stem, so standard.yaml is "standard", and supply the +# file when the settings are pushed, with +# `ts config push --permissions `. The push writes the file's SHA-256 +# into the blob, and on Fastly, Cloudflare and Spin the file itself, so leave +# `sha256` and `text` out. Axum reads the file from the folder +# TRUSTED_SERVER_PERMISSIONS_DIR names. A domain whose settings name no +# policy, or whose file is missing or is not the file that was pushed, does +# not start, and answers 500. config/permissions/sample.yaml is a sample for +# testing and evaluation only. +# +# [permissions] +# policy = "standard" + +# Which permission signal modules run, and in what order. +# +# Signals compose rather than select. A request can carry a TCF string and a +# Global Privacy Control header at once, and both have something to say, so +# modules here is a list, whereas in [ec], [geo] and [device] module names +# one module. The order is the policy, because the last module with an +# opinion decides. +# +# Listed below is every module the shipped adapters link, which is also +# exactly what runs when modules is left out. Each is a crate under +# crates/permission-signal, outside the core, and is named by its folder +# there. Remove the ones this +# publisher does not want to act on. A module that is not on the list does +# not run, and there is no separate switch to turn one off. An unknown or +# repeated name is refused at startup rather than quietly ignored. +# +# [permission-signal] +# modules = [ +# "gpc", # the Sec-GPC request header, Global Privacy Control +# "gpp", # a GPP US sale opt-out +# "us-privacy", # a US Privacy string sale opt-out +# "tcf", # TCF v2, with its purpose mapping in the crate +# ] +# +# Each name is the module's crate folder below crates/permission-signal, and +# may be written in full, as permission-signal.gpp. +# +# A module that gains settings will take them in a [permission-signal.] +# block named for it. None of these four has settings, so such a block is +# refused rather than ignored. +# +# The three opt-outs are separate entries so that a publisher who does not act +# on Global Privacy Control can remove "gpc" and keep the GPP and US Privacy +# opt-outs working. +# +# A consent record that arrives and cannot be read revokes, whichever modules +# are configured. That is error handling rather than a module to choose, so +# it is not in this list and cannot be removed. +# +# The default order asks Global Privacy Control first, being a browser setting +# with no interface of its own, and the three that carry a choice someone made +# through an interface after, so an answer given at a prompt amends the header +# the visitor arrived with. Reorder the list to change that. +# +# The layering, what a module may consult, and how to add a scheme are +# documented in crates/trusted-server-core/src/permission_signal/README.md +# and in the Permission Signals guide. + [integrations.js_asset_proxy] enabled = false # Uncomment to override upstream cache headers for every asset below.