diff --git a/.env.example b/.env.example index a7f5973cdb..87dac42196 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # Trusted Server development environment variables # Copy this file to .env.dev, .env.staging, or .env.production and fill in -# non-secret values. App-config secrets are key names in the pushed blob and -# their values belong in the platform secret store; see the configuration guide. +# non-secret values. App config contains no credential values or key names. +# Provision generated native bindings separately; see docs/guide/secrets.md. # For Axum runtime loading, export the config blob as: # TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG= -# and export one secret per key name as: -# TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_= +# Use `ts secrets plan --adapter axum` for exact generated environment names. +# Inject their raw values through the launcher, not a tracked environment file. # The commented examples below are CLI overlays for ordinary fields only. # Fastly example: map logical app-config secrets to physical `ts_secrets`. # EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets @@ -13,9 +13,9 @@ # ============================================================================= # Publisher Settings # ============================================================================= -TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com -TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.publisher.com -TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com +TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com +TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.example.com +TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.example.com # ============================================================================= # Synthetic ID Settings diff --git a/.tool-versions b/.tool-versions index d29345dba2..4493ea3266 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,7 @@ fastly 16.0.0 rust 1.98.1 nodejs 24.20.0 +wrangler 4.83.0 viceroy 0.21.0 wasmtime 48.0.1 spin 4.1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 303e17e587..2d805d27ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** App credentials use context-derived native bindings, not operator-selected secret references. Remove all twelve credential properties, including on disabled features, and provision raw values through reviewed `ts secrets plan` output. Partner inbound auth uses boolean `api_token_enabled`; optional S3 session credentials use boolean `session_token_enabled`, both default false. Enabled credentials and compiled destinations load as one snapshot. First-release direct `ts secrets set` supports Fastly only; all four adapters have native rendering. Cloudflare/Spin presence checks remain unsupported. See the provisioning, protected CI, and migration guides. New keys do not revoke retired contexts, warm states need recreation, and an old-binary rollback restores the original attack. Native/provider/IAM and migration rehearsals remain release gates; this entry is not a completed security-release claim. + - **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 e0c9fb406c..9495f47198 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,18 @@ dependencies = [ "inout", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -369,12 +381,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - [[package]] name = "base64" version = "0.22.1" @@ -1530,12 +1536,11 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ - "base16ct 1.0.0", "serde", "serde_json", - "sha2 0.11.1", + "sha2 0.10.9", "toml", "walkdir", ] @@ -1543,7 +1548,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "anyhow", "async-trait", @@ -1571,7 +1576,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "anyhow", "async-trait", @@ -1594,12 +1599,12 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "anyhow", "async-stream", "async-trait", - "base16ct 1.0.0", + "base64", "brotli", "bytes", "chrono", @@ -1615,7 +1620,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", - "sha2 0.11.1", + "sha2 0.10.9", "thiserror 2.0.18", "toml_edit 0.25.12+spec-1.1.0", "walkdir", @@ -1624,7 +1629,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "anyhow", "async-trait", @@ -1651,7 +1656,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "chrono", "clap", @@ -1666,7 +1671,7 @@ dependencies = [ "log", "serde", "serde_json", - "similar 3.2.0", + "similar", "simple_logger", "thiserror 2.0.18", "toml", @@ -1676,13 +1681,12 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "anyhow", "async-compression", "async-stream", "async-trait", - "base16ct 1.0.0", "bytes", "edgezero-macros", "futures", @@ -1696,7 +1700,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha2 0.11.1", + "sha2 0.10.9", "thiserror 2.0.18", "toml", "tower-service", @@ -1708,7 +1712,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.10#85d933b8044dec29fe9e4685cae194eaf0290911" +source = "git+https://github.com/IABTechLab/edgezero?rev=4f645b1e705d295117c93f4c74db12e5692ffb34#4f645b1e705d295117c93f4c74db12e5692ffb34" dependencies = [ "log", "proc-macro2", @@ -1738,7 +1742,7 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", + "base16ct", "crypto-bigint", "digest 0.10.7", "ff", @@ -1832,7 +1836,8 @@ dependencies = [ [[package]] name = "esi" version = "0.7.2" -source = "git+https://github.com/stackpop/esi?rev=75bfbb396d4cb7792610f1287cbec61675f13f38#75bfbb396d4cb7792610f1287cbec61675f13f38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "026264c3022569f7bc5052f9452362537e923b34ecc9248b803437c2b89cdd81" dependencies = [ "atoi", "base64", @@ -1884,9 +1889,9 @@ dependencies = [ [[package]] name = "fastly" -version = "0.13.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" dependencies = [ "anyhow", "bytes", @@ -1897,6 +1902,7 @@ dependencies = [ "fastly-sys", "http", "itertools 0.13.0", + "lazy_static", "mime", "serde", "serde_json", @@ -1911,9 +1917,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.13.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" dependencies = [ "proc-macro2", "quote", @@ -1922,9 +1928,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.13.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" dependencies = [ "bitflags 1.3.2", "http", @@ -1932,9 +1938,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.13.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -3160,6 +3166,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -3183,6 +3192,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.11.1" @@ -3879,9 +3897,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.38.2" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -3935,9 +3953,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "log-fastly" -version = "0.13.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" dependencies = [ "fastly", "log", @@ -5260,13 +5278,14 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.2" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", + "hashlink 0.9.1", "libsqlite3-sys", "smallvec", ] @@ -5485,7 +5504,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", + "base16ct", "der", "generic-array", "subtle", @@ -5839,15 +5858,6 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" -[[package]] -name = "similar" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" -dependencies = [ - "bstr", -] - [[package]] name = "simple_logger" version = "5.2.0" @@ -5896,9 +5906,9 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spin-macro" -version = "7.0.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" dependencies = [ "proc-macro2", "quote", @@ -5907,9 +5917,9 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "7.0.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" dependencies = [ "anyhow", "bytes", @@ -6663,7 +6673,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "similar 2.7.0", + "similar", "temp-env", "tempfile", "time", @@ -6986,11 +6996,12 @@ dependencies = [ [[package]] name = "validator" -version = "0.21.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" dependencies = [ "idna", + "once_cell", "regex", "serde", "serde_derive", @@ -7070,9 +7081,9 @@ dependencies = [ [[package]] name = "wasip3" -version = "0.7.1+wasi-0.3.0" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" dependencies = [ "bytes", "http", @@ -7719,7 +7730,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.11.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 72fecdf6b5..6f72b437e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,16 +56,19 @@ cssparser = "0.36" derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.10", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.10", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.10", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.10", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.10" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.10", default-features = false } +# The implicit-secret APIs require this unreleased EdgeZero revision. Keep +# fastly, log-fastly, spin-sdk and validator aligned with it until that branch +# incorporates EdgeZero's dependency upgrade. +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", rev = "4f645b1e705d295117c93f4c74db12e5692ffb34", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "4f645b1e705d295117c93f4c74db12e5692ffb34", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "4f645b1e705d295117c93f4c74db12e5692ffb34", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "4f645b1e705d295117c93f4c74db12e5692ffb34", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "4f645b1e705d295117c93f4c74db12e5692ffb34" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "4f645b1e705d295117c93f4c74db12e5692ffb34", default-features = false } env_logger = "0.11" error-stack = "0.6" esi = "0.7.2" -fastly = "0.13" +fastly = "0.12" fern = "0.7.1" flate2 = "1.1" futures = "0.3" @@ -87,7 +90,7 @@ jose-jwk = "0.1.2" js-sys = "0.3" libc = "0.2" log = "0.4.29" -log-fastly = "0.13" +log-fastly = "0.12" lol_html = "2.7.2" matchit = "0.9" mime = "0.3" @@ -107,7 +110,7 @@ serde_yaml_ng = "0.10" sha2 = "0.11" similar = "2.7" simple_logger = "5" -spin-sdk = { version = "~7.0", default-features = false, features = ["http", "key-value", "variables"] } +spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } subtle = "2.6" temp-env = "0.3.6" tempfile = "3.24" @@ -125,20 +128,13 @@ trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } -validator = { version = "0.21", features = ["derive"] } +validator = { version = "0.20", features = ["derive"] } web-time = "1" webpki-roots = "1" which = "8" worker = { version = "0.8", default-features = false, features = ["http"] } x509-parser = "0.16" -# `esi` 0.7.2 pins `fastly = "^0.12"` while EdgeZero requires 0.13, and both -# meet in `trusted-server-adapter-fastly`. The fork carries the range bump -# and no source change (stackpop/esi@chore/fastly-0.13); drop this patch -# once the fix is released upstream. See issue #1122. -[patch.crates-io] -esi = { git = "https://github.com/stackpop/esi", rev = "75bfbb396d4cb7792610f1287cbec61675f13f38" } - [workspace.lints.clippy] # Correctness - Force explicit error handling in production code unwrap_used = "deny" diff --git a/TESTING.md b/TESTING.md index 7565ca7dde..dd0dafe098 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,5 +1,29 @@ # Testing auction orchestration +## Secret-binding release evidence + +App config must contain no removed credential properties. Provision fictional +generated bindings from the same compiler/renderer as runtime; direct resolved +`Settings` unit fixtures are internal, not public TOML examples. See +[provisioning](docs/guide/secrets.md), [CI](docs/guide/secrets-ci.md), and +[migration](docs/guide/secret-binding-migration.md). + +Host Rust tests, native-name rendering, metadata presence, and route smoke tests +are different evidence. Release still requires nonempty shipping Rust test runs, +actual reads and HTTPS/no-follow flows on Axum, Viceroy/Fastly, Wrangler/workerd, +and Spin, isolated Fastly create/replace/read-through tests, secure CLI/CI input +tests, config-only versus secret-writer IAM proof, and migration/retirement/ +rollback rehearsals. No credentials or provider access were used to write these +guides. Unavailable environments are blockers, not passes. + +Rehearse all four native walkthroughs with fictional values in isolated resources. +Test config/plan/overlay/compiler/manifest/service/store drift before input and +with zero mutations, repeated-role selection, unsupported adapter writes, +cancellation, invalid bytes/newlines, provider failures and uncertain outcomes. +Inspect argv/stdin/output without printing sentinel credentials. Test same-context +replacement, fresh versus retained states, S3 maintenance, and old-context replay +after deletion. Do not claim Tinybird response observation or fleet-wide refresh. + ## Start the local server Configure at least one reachable provider in `trusted-server.toml`, then start the diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index cff5bc47ef..5aa20fd257 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -70,7 +70,7 @@ fn build_state() -> Result, Report> { &config_key, &trusted_server_core::settings_data::default_secret_store_name(), )?; - build_state_with_settings(settings) + build_state_with_settings(settings.settings().clone()) } /// Build the application state from explicit settings. @@ -497,8 +497,7 @@ fn named_route_handler( Ok(admin_ec_lookup_not_supported()) } NamedRouteHandler::AdminEidsLookup => { - let partner_registry = - PartnerRegistry::from_config(&state.settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(&state.settings)?; handle_admin_eids_lookup(&partner_registry, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index f3f9a4e11b..eb2fae4f1e 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -37,11 +37,7 @@ pub fn config_env_var(store_name: &str, key: &str) -> String { } fn secret_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_SECRET_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) + trusted_server_core::secret_bindings::axum_secret_name(store_name, key) } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index b6e49f561f..c8c9c9f36a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -157,6 +157,7 @@ fn settings_from_cloudflare_config_json() -> Result [Method; 7] { ] } -/// Returns a [`RouterService`] that responds to every route with the startup error. +/// Returns a [`RouterService`] with the safe startup-error response. +/// The operator diagnostic includes only the outer context, never native causes. fn startup_error_router(e: &Report) -> RouterService { + #[cfg(target_arch = "wasm32")] + worker::console_error!("failed to build application state: {}", e.current_context()); + #[cfg(not(target_arch = "wasm32"))] + log::error!("failed to build application state: {}", e.current_context()); + let message = Arc::new(format!("{}\n", e.current_context().user_message())); let status = e.current_context().status_code(); @@ -449,10 +456,7 @@ impl Hooks for TrustedServerApp { 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); - } + Err(ref e) => return startup_error_router(e), }; build_router(&state) @@ -649,7 +653,7 @@ fn build_router(state: &Arc) -> RouterService { .get( "/_ts/admin/eids", make_handler(Arc::clone(&state), |s, _services, req| async move { - let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(&s.settings)?; handle_admin_eids_lookup(&partner_registry, &req) }), ) @@ -779,6 +783,40 @@ fn build_router(state: &Arc) -> RouterService { mod tests { use super::*; + #[tokio::test] + async fn startup_error_router_keeps_native_sources_out_of_response() { + let report = Report::new(TrustedServerError::BadRequest { + message: "NATIVE_CREDENTIAL_SENTINEL".to_owned(), + }) + .change_context(TrustedServerError::Configuration { + message: "Cloudflare TRUSTED_SERVER_CONFIG is required".to_owned(), + }) + .attach("NATIVE_BINDING_NAME_SENTINEL"); + let diagnostic = report.current_context().to_string(); + assert!(diagnostic.contains("Cloudflare TRUSTED_SERVER_CONFIG is required")); + assert!(!diagnostic.contains("NATIVE_CREDENTIAL_SENTINEL")); + assert!(!diagnostic.contains("NATIVE_BINDING_NAME_SENTINEL")); + + let request = edgezero_core::http::request_builder() + .method("GET") + .uri("/") + .body(edgezero_core::body::Body::empty()) + .expect("should build an anonymous startup-error request"); + let response = startup_error_router(&report) + .oneshot(request) + .await + .expect("should serve the anonymous startup-error response"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response + .into_body() + .into_bytes() + .expect("should read the startup-error response body") + .as_ref(), + b"An internal error occurred\n" + ); + } + fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index 9992db7123..67a4ba93a4 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -15,11 +15,12 @@ id = "ci-local-kv" # contains the runtime Trusted Server app-config blob envelope. TRUSTED_SERVER_CONFIG = "{}" -# 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. -integration_admin_password = "integration-admin-password-32-bytes-ok" -integration_proxy_secret = "integration-test-proxy-secret-32-bytes-ok" -integration_ec_passphrase = "integration-test-ec-secret-padded-32" -integration_partner_token_alpha = "integration-test-token-alpha-32-bytes-ok" -integration_partner_token_bravo = "integration-test-token-bravo-32-bytes-ok" +# Fictional local-only bindings generated by the integration fixture harness. +# Production values must be provisioned as Worker secrets, never committed here. +# BEGIN GENERATED APP SECRET FIXTURES +ts1_74zinuwuutkjsas3cg336heosfeyulrdobiczs7gc6kh3x2opeiq = "integration-test-token-alpha-32-bytes-ok" +ts1_ifbfog3ctboxre6cj7ldvs5xxwsqj2ulupnudwcb73a7odcuuuqa = "integration-test-token-bravo-32-bytes-ok" +ts1_juuqz3tbywbbzruyd2bvt56qmxuiokdflb4ii2dfmjvw7ypyjwbq = "integration-test-ec-secret-padded-32" +ts1_kw4dni7h367mab3ywnml5mqvumkwivjjxzduonjz7mw3mihm42da = "integration-admin-password-32-bytes-ok" +ts1_mu4d7mg5sfuleogirpka2zbh2b33lsocgyl6wcovawmjs4nfmi6a = "integration-test-proxy-secret-32-bytes-ok" +# END GENERATED APP SECRET FIXTURES diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index f3b5ef6aa3..beccfe0214 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -27,6 +27,6 @@ id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" # envelope before deploying or running `wrangler dev` against real traffic. TRUSTED_SERVER_CONFIG = '{"trusted_server_config":""}' -# App-config secret values are provisioned as Worker secrets with -# `wrangler secret put `. The pushed blob contains only those key -# names; never add secret values to this file. +# Generate native identities with `ts secrets plan --adapter cloudflare`. +# Provision production values as Worker secrets at those generated names. +# App config contains feature settings, never secret values or key names. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index c9269bf88d..11041c9356 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -213,6 +213,7 @@ pub(crate) fn load_settings_from_config_store( &stores.config_key, &stores.secret_store_name, ) + .map(|loaded| loaded.settings().clone()) } pub(crate) fn build_state_from_settings( @@ -225,7 +226,7 @@ pub(crate) fn build_state_from_settings( let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; - let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings); + let auction_telemetry_sink = crate::tinybird::auction_sink_from_settings(&settings)?; let default_kv_store = Arc::new(UnavailableKvStore) as Arc; Ok(Arc::new(AppState { @@ -578,7 +579,7 @@ async fn execute_named( handler, NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup ) { - let response = PartnerRegistry::from_config(&state.settings.ec.partners) + let response = PartnerRegistry::from_settings(&state.settings) .and_then(|registry| match handler { NamedRouteHandler::AdminEcLookup => { // Deliberately do not use an EC request-state graph: that @@ -659,7 +660,7 @@ async fn run_named_route( cors_preflight_identify(&state.settings, &req) } else { let kv = crate::require_identity_graph(&state.settings)?; - let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(&state.settings)?; handle_identify( &state.settings, &kv, @@ -673,7 +674,7 @@ async fn run_named_route( NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), NamedRouteHandler::Auction => { ec.ec_context.set_eid_sync_source(EidSyncSource::Auction); - let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(&state.settings)?; let registry_ref = if partner_registry.is_empty() { None } else { @@ -697,7 +698,7 @@ async fn run_named_route( if req.method() == Method::OPTIONS { return Ok(page_bids_preflight_denied()); } - let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(&state.settings)?; let registry_ref = if partner_registry.is_empty() { None } else { @@ -744,7 +745,7 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); let result = crate::require_identity_graph(&state.settings).and_then(|kv| { - let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(&state.settings)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); handle_batch_sync(&kv, &partner_registry, &limiter, req) }); @@ -866,7 +867,7 @@ async fn dispatch_fallback( // EC identity-graph KV (`ec.kv_graph`) enrich the bid request with // server-side EIDs, same as the legacy auction. let slots = state.settings.creative_opportunity_slots(); - match PartnerRegistry::from_config(&state.settings.ec.partners) { + match PartnerRegistry::from_settings(&state.settings) { Ok(partner_registry) => { let auction = AuctionDispatch { integration_registry: &state.registry, diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b8f814a01c..de59c1826d 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -597,7 +597,7 @@ fn apply_edgezero_ec_finalize( ec_state: &mut EcFinalizeState, response: &mut HttpResponse, ) -> Result> { - let partner_registry = PartnerRegistry::from_config(&settings.ec.partners)?; + let partner_registry = PartnerRegistry::from_settings(settings)?; let finalize_kv_graph = if ec_state.use_finalize_kv { maybe_identity_graph(settings) } else { diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 504dbd9fba..057085cfb3 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -5,73 +5,92 @@ use std::time::Duration; use edgezero_core::body::Body; use edgezero_core::http::{HeaderValue, Method, header, request_builder}; -use error_stack::{Report, ResultExt as _}; +use error_stack::Report; use trusted_server_core::auction::telemetry::{ AuctionEventBatch, AuctionTelemetrySink, NoopAuctionTelemetrySink, }; use trusted_server_core::error::TrustedServerError; use trusted_server_core::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; -use trusted_server_core::redacted::Redacted; -use trusted_server_core::settings::{Settings, TinybirdSettings}; +use trusted_server_core::secret_bindings::{CompiledTarget, ResolvedBinding}; +use trusted_server_core::settings::Settings; -const TINYBIRD_EVENTS_PATH: &str = "/v0/events"; const TINYBIRD_NDJSON_CONTENT_TYPE: &str = "application/x-ndjson"; const TINYBIRD_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(2); const TINYBIRD_BETWEEN_BYTES_TIMEOUT: Duration = Duration::from_secs(2); const TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH: usize = 512; -/// Build the configured auction telemetry sink. -#[must_use] -pub(crate) fn auction_sink_from_settings(settings: &Settings) -> Arc { - if settings.tinybird.enabled { - Arc::new(FastlyTinybirdAuctionTelemetrySink::new( - settings.tinybird.clone(), - )) - } else { - Arc::new(NoopAuctionTelemetrySink) +/// Build the configured auction telemetry sink from a loaded credential use. +/// +/// # Errors +/// Returns a configuration error if enabled telemetry lacks a matching loaded +/// credential binding or a valid compiled target. +pub(crate) fn auction_sink_from_settings( + settings: &Settings, +) -> Result, Report> { + if !settings.tinybird.enabled { + return Ok(Arc::new(NoopAuctionTelemetrySink)); } + Ok(Arc::new(FastlyTinybirdAuctionTelemetrySink::new(settings)?)) } #[derive(Debug, Clone)] struct FastlyTinybirdAuctionTelemetrySink { - enabled: bool, target: TinybirdEventsTarget, } #[derive(Debug, Clone)] struct TinybirdEventsTarget { - api_host: String, - dataset: String, - append_token: Redacted, + binding: ResolvedBinding, uri: String, backend_spec: PlatformBackendSpec, max_body_bytes: usize, } impl TinybirdEventsTarget { - fn from_config(config: TinybirdSettings) -> Self { - let uri = tinybird_events_uri(&config.api_host, &config.auction_dataset); - let backend_spec = tinybird_backend_spec(&config.api_host); - Self { - api_host: config.api_host, - dataset: config.auction_dataset, - append_token: config - .auction_token_secret - .expect("should contain a resolved Tinybird auction token when enabled"), - uri, + fn from_settings(settings: &Settings) -> Result> { + let binding = settings + .resolved_bindings + .require_for_settings( + settings, + "tinybird.auction", + "tinybird.auction_token_secret", + )? + .clone(); + let CompiledTarget::Tinybird { endpoint, dataset } = binding.compiled().target() else { + return Err(Report::new(TrustedServerError::Configuration { + message: "tinybird: invalid loaded target".to_owned(), + })); + }; + let mut url = url::Url::parse(endpoint).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "tinybird: invalid loaded endpoint".to_owned(), + }) + })?; + let backend_spec = PlatformBackendSpec { + scheme: "https".to_owned(), + host: url.host_str().unwrap_or_default().to_owned(), + port: url.port(), + host_header_override: None, + certificate_check: true, + first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: TINYBIRD_BETWEEN_BYTES_TIMEOUT, + discriminator: None, + }; + url.query_pairs_mut().append_pair("name", dataset); + Ok(Self { + uri: url.into(), + binding, backend_spec, - max_body_bytes: config.max_body_bytes, - } + max_body_bytes: settings.tinybird.max_body_bytes, + }) } } impl FastlyTinybirdAuctionTelemetrySink { - fn new(config: TinybirdSettings) -> Self { - let enabled = config.enabled; - Self { - enabled, - target: TinybirdEventsTarget::from_config(config), - } + fn new(settings: &Settings) -> Result> { + Ok(Self { + target: TinybirdEventsTarget::from_settings(settings)?, + }) } fn validate_batch(batch: &AuctionEventBatch) -> Result<(), Report> { @@ -101,19 +120,13 @@ impl FastlyTinybirdAuctionTelemetrySink { services .backend() .ensure(&self.target.backend_spec) - .change_context(TrustedServerError::Proxy { - message: "Tinybird backend registration failed".to_owned(), + .map_err(|_| { + Report::new(TrustedServerError::Proxy { + message: "Tinybird backend registration failed".to_owned(), + }) }) } - fn authorization_header(token: &str) -> Result> { - HeaderValue::from_str(&format!("Bearer {token}")).change_context( - TrustedServerError::InvalidHeaderValue { - message: "invalid Tinybird authorization header".to_owned(), - }, - ) - } - fn build_events_request( &self, body: String, @@ -125,8 +138,10 @@ impl FastlyTinybirdAuctionTelemetrySink { .header(header::AUTHORIZATION, auth_header) .header(header::CONTENT_TYPE, TINYBIRD_NDJSON_CONTENT_TYPE) .body(Body::from(body)) - .change_context(TrustedServerError::Proxy { - message: "failed to build Tinybird Events API request".to_owned(), + .map_err(|_| { + Report::new(TrustedServerError::Proxy { + message: "failed to build Tinybird Events API request".to_owned(), + }) }) } @@ -139,8 +154,10 @@ impl FastlyTinybirdAuctionTelemetrySink { .http_client() .send_async(PlatformHttpRequest::new(request, backend_name)) .await - .change_context(TrustedServerError::Proxy { - message: "failed to start Tinybird Events API request".to_owned(), + .map_err(|_| { + Report::new(TrustedServerError::Proxy { + message: "failed to start Tinybird Events API request".to_owned(), + }) })?; drop(pending); Ok(()) @@ -150,7 +167,7 @@ impl FastlyTinybirdAuctionTelemetrySink { #[async_trait::async_trait(?Send)] impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { fn is_enabled(&self) -> bool { - self.enabled + true } async fn emit_auction_events( @@ -158,57 +175,46 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { services: &RuntimeServices, batch: AuctionEventBatch, ) -> Result<(), Report> { - if !self.enabled || batch.is_empty() { + if batch.is_empty() { return Ok(()); } Self::validate_batch(&batch)?; let body = self.serialize_batch(&batch)?; let body_len = body.len(); - let auth_header = Self::authorization_header(self.target.append_token.expose())?; - let backend_name = self.ensure_backend(services)?; + let auth_header = self + .target + .binding + .bearer_header() + .cloned() + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "tinybird: invalid credential representation".to_owned(), + }) + })?; let request = self.build_events_request(body, auth_header)?; + let backend_name = self.ensure_backend(services)?; log::info!( - "sending auction telemetry to Tinybird dataset={} rows={} bytes={} host={} backend={}", - self.target.dataset, + "sending auction telemetry to Tinybird rows={} bytes={}", batch.row_count(), - body_len, - self.target.api_host, - backend_name + body_len ); Self::send_fire_and_forget(services, request, backend_name).await } } -fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { - PlatformBackendSpec { - scheme: "https".to_owned(), - host: api_host.to_owned(), - port: None, - host_header_override: None, - certificate_check: true, - first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, - between_bytes_timeout: TINYBIRD_BETWEEN_BYTES_TIMEOUT, - discriminator: None, - } -} - -fn tinybird_events_uri(api_host: &str, dataset: &str) -> String { - format!( - "https://{api_host}{TINYBIRD_EVENTS_PATH}?name={}", - urlencoding::encode(dataset) - ) -} - #[cfg(test)] mod tests { use std::collections::HashMap; use std::sync::{Arc, Mutex}; + use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; use trusted_server_core::auction::telemetry::{AuctionEventBatch, AuctionEventRow}; + use trusted_server_core::config::TrustedServerAppConfig; + use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::platform::{ ClientInfo, PlatformBackend, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, @@ -428,40 +434,112 @@ mod tests { .build() } - fn enabled_config() -> TinybirdSettings { - TinybirdSettings { - enabled: true, - api_host: "api.us-east.aws.tinybird.co".to_owned(), - secret_store: None, - auction_dataset: "auction_events_raw".to_owned(), - auction_token_secret: Some(Redacted::new("append-token".to_owned())), - access_enabled: false, - access_dataset: "access_logs_raw".to_owned(), - access_token_secret: None, - access_sample_rate: 0.0, - max_body_bytes: 1024 * 1024, - } + fn enabled_config() -> Settings { + enabled_config_for_host("api.us-east.aws.tinybird.co") + } + + fn enabled_config_for_host(api_host: &str) -> Settings { + let config: TrustedServerAppConfig = serde_json::from_value(serde_json::json!({ + "publisher": { + "domain": "publisher.example.com", + "cookie_domain": ".publisher.example.com", + "origin_url": "https://origin.publisher.example.com" + }, + "handlers": [{ "path": "^/_ts/admin", "username": "admin" }], + "tinybird": { + "enabled": true, + "api_host": api_host, + "auction_dataset": "auction_events_raw", + "max_body_bytes": 1024 * 1024 + } + })) + .expect("should parse the fictional Tinybird feature configuration"); + let plan = trusted_server_core::secret_bindings::compile_bindings(config.settings()) + .expect("should compile the fictional Tinybird credential lookups"); + let store = MapSecretStore( + plan.entries() + .iter() + .map(|entry| { + let value = if entry.scope() == "tinybird" { + "append-token" + } else { + "fictional-binding-value-with-32-bytes" + }; + (entry.key().to_owned(), value.as_bytes().to_vec()) + }) + .collect(), + ); + let data = serde_json::to_value(&config) + .expect("should serialize the unresolved Tinybird feature configuration"); + let envelope = + serde_json::to_string(&BlobEnvelope::new(data, "2026-10-26T00:00:00Z".to_owned())) + .expect("should encode the fictional Tinybird config envelope"); + settings_from_config_blob( + &envelope, + &store, + &StoreName::from(trusted_server_core::secret_bindings::SECRET_STORE_ID), + ) + .expect("should fully load the fictional Tinybird config and native credentials") + .settings() + .clone() } #[test] fn events_uri_targets_dataset_on_region_host() { + let target = TinybirdEventsTarget::from_settings(&enabled_config()) + .expect("should construct target"); assert_eq!( - tinybird_events_uri("api.us-east.aws.tinybird.co", "auction_events_raw"), + target.uri, "https://api.us-east.aws.tinybird.co/v0/events?name=auction_events_raw" ); } #[test] - fn events_uri_urlencodes_dataset_name() { - assert_eq!( - tinybird_events_uri("api.us-east.aws.tinybird.co", "auction events/raw"), - "https://api.us-east.aws.tinybird.co/v0/events?name=auction%20events%2Fraw" - ); + fn events_target_uses_compiled_port_and_normalizes_default_https_port() { + for (authority, expected_uri, expected_port) in [ + ( + "api.example.com:443", + "https://api.example.com/v0/events?name=auction_events_raw", + None, + ), + ( + "api.example.com:8443", + "https://api.example.com:8443/v0/events?name=auction_events_raw", + Some(8443), + ), + ] { + let settings = enabled_config_for_host(authority); + let target = TinybirdEventsTarget::from_settings(&settings) + .expect("should construct compiled target"); + assert_eq!(target.uri, expected_uri); + assert_eq!(target.backend_spec.host, "api.example.com"); + assert_eq!(target.backend_spec.port, expected_port); + assert!(target.backend_spec.certificate_check); + } + } + + #[test] + fn settings_only_enabled_sink_is_rejected() { + let mut settings = enabled_config(); + settings.resolved_bindings = Default::default(); + assert!(auction_sink_from_settings(&settings).is_err()); + } + + #[test] + fn mutable_target_and_dataset_cannot_reuse_loaded_use() { + let mut settings = enabled_config(); + settings.tinybird.api_host = "other.example.com".to_owned(); + assert!(auction_sink_from_settings(&settings).is_err()); + settings = enabled_config(); + settings.tinybird.auction_dataset = "other_dataset".to_owned(); + assert!(auction_sink_from_settings(&settings).is_err()); } #[test] fn backend_spec_uses_matching_tls_host() { - let spec = tinybird_backend_spec("api.us-east.aws.tinybird.co"); + let target = TinybirdEventsTarget::from_settings(&enabled_config()) + .expect("should construct target"); + let spec = target.backend_spec; assert_eq!(spec.scheme, "https"); assert_eq!(spec.host, "api.us-east.aws.tinybird.co"); assert_eq!(spec.host_header_override, None); @@ -477,7 +555,8 @@ mod tests { Arc::clone(&http_client), HashMap::new(), ); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(&enabled_config()) + .expect("should construct loaded sink"); futures::executor::block_on( sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), @@ -534,8 +613,9 @@ mod tests { HashMap::new(), ); let mut config = enabled_config(); - config.enabled = false; - let sink = FastlyTinybirdAuctionTelemetrySink::new(config); + config.tinybird.enabled = false; + config.resolved_bindings = Default::default(); + let sink = auction_sink_from_settings(&config).expect("should build disabled sink"); futures::executor::block_on( sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), @@ -565,7 +645,8 @@ mod tests { Arc::clone(&http_client), HashMap::new(), ); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(&enabled_config()) + .expect("should construct loaded sink"); futures::executor::block_on( sink.emit_auction_events(&services, AuctionEventBatch::new(Vec::new())), @@ -598,7 +679,8 @@ mod tests { b"append-token".to_vec(), )]), ); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); + let sink = FastlyTinybirdAuctionTelemetrySink::new(&enabled_config()) + .expect("should construct loaded sink"); let rows = vec![test_row(); TINYBIRD_MAX_ROWS_PER_AUCTION_BATCH + 1]; let result = futures::executor::block_on( diff --git a/crates/trusted-server-adapter-spin/spin.toml b/crates/trusted-server-adapter-spin/spin.toml index 7055f9563d..babba686e4 100644 --- a/crates/trusted-server-adapter-spin/spin.toml +++ b/crates/trusted-server-adapter-spin/spin.toml @@ -17,17 +17,22 @@ version = "0.1.0" # # Operators enabling request_signing must declare one encoded secret variable for # each private signing key. Public signing metadata remains in the KV store. -[variables] -# These declared variables match the example config's secret key names. Regenerate -# or extend them for deployment-specific keys, including handler key names such as -# `admin_password` or `api_handler_password`. Replace the empty defaults with values -# supplied by the deployment's secret provider; never commit values here. -v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = { default = "", secret = true } -v_trusted_x5fserver_x5fsecrets_v_trusted_x5fclient_x5fip_x5fshared_x5fsecret = { default = "", secret = true } -v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = { default = "", secret = true } -v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = { default = "", secret = true } -v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = { default = "", secret = true } -v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = { default = "", secret = true } +# Declarations compiled from trusted-server.example.toml by the shared renderer. +# Supply values through a protected provider. `secret = true` is not encryption. +# Regenerate with scripts/generate-integration-viceroy-configs.sh --local-manifests. +# BEGIN GENERATED APP SECRET VARIABLES +[variables.v_trusted_x5fserver_x5fsecrets_v_ts1_x5fjuuqz3tbywbbzruyd2bvt56qmxuiokdflb4ii2dfmjvw7ypyjwbq] +required = true +secret = true + +[variables.v_trusted_x5fserver_x5fsecrets_v_ts1_x5fkw4dni7h367mab3ywnml5mqvumkwivjjxzduonjz7mw3mihm42da] +required = true +secret = true + +[variables.v_trusted_x5fserver_x5fsecrets_v_ts1_x5fmu4d7mg5sfuleogirpka2zbh2b33lsocgyl6wcovawmjs4nfmi6a] +required = true +secret = true +# END GENERATED APP SECRET VARIABLES [[trigger.http]] route = "/..." @@ -44,12 +49,11 @@ allowed_outbound_hosts = ["https://*:*", "http://*:*"] key_value_stores = ["default"] [component.trusted-server.variables] -v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = "{{ v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret }}" -v_trusted_x5fserver_x5fsecrets_v_trusted_x5fclient_x5fip_x5fshared_x5fsecret = "{{ v_trusted_x5fserver_x5fsecrets_v_trusted_x5fclient_x5fip_x5fshared_x5fsecret }}" -v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = "{{ v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase }}" -v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = "{{ v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken }}" -v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = "{{ v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken }}" -v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = "{{ v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword }}" +# BEGIN GENERATED APP SECRET COMPONENT +v_trusted_x5fserver_x5fsecrets_v_ts1_x5fjuuqz3tbywbbzruyd2bvt56qmxuiokdflb4ii2dfmjvw7ypyjwbq = "{{ v_trusted_x5fserver_x5fsecrets_v_ts1_x5fjuuqz3tbywbbzruyd2bvt56qmxuiokdflb4ii2dfmjvw7ypyjwbq }}" +v_trusted_x5fserver_x5fsecrets_v_ts1_x5fkw4dni7h367mab3ywnml5mqvumkwivjjxzduonjz7mw3mihm42da = "{{ v_trusted_x5fserver_x5fsecrets_v_ts1_x5fkw4dni7h367mab3ywnml5mqvumkwivjjxzduonjz7mw3mihm42da }}" +v_trusted_x5fserver_x5fsecrets_v_ts1_x5fmu4d7mg5sfuleogirpka2zbh2b33lsocgyl6wcovawmjs4nfmi6a = "{{ v_trusted_x5fserver_x5fsecrets_v_ts1_x5fmu4d7mg5sfuleogirpka2zbh2b33lsocgyl6wcovawmjs4nfmi6a }}" +# END GENERATED APP SECRET COMPONENT [component.trusted-server.build] command = "cargo build --target wasm32-wasip1 --release -p trusted-server-adapter-spin --features spin" diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 674b1ba0b6..550fe2fa7c 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -105,6 +105,7 @@ fn load_startup_settings() -> Result> { })?; let secret_store = SpinSecretStoreAdapter; settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) + .map(|loaded| loaded.settings().clone()) } #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] @@ -502,11 +503,34 @@ fn legacy_admin_alias_denied() -> Response { // Startup error fallback // --------------------------------------------------------------------------- +fn startup_error_diagnostic(report: &Report) -> String { + // Loader contexts contain safe schema/category messages. Never format the + // report itself: native errors and values can occur in its source chain. + report + .current_context() + .to_string() + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + /// 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. +/// 503 Service Unavailable. The outer startup context goes to operator stderr, +/// never the response body, so anonymous callers cannot inspect deployment state. fn startup_error_router(e: &Report) -> RouterService { - log::error!("startup failed, serving error fallback: {:?}", e); + #[allow(clippy::print_stderr, reason = "Spin startup has no installed logger")] + { + eprintln!( + "startup failed, serving error fallback: {}", + startup_error_diagnostic(e) + ); + } let handler = |_ctx: RequestContext| { let body = edgezero_core::body::Body::from("Service Unavailable\n"); @@ -553,10 +577,7 @@ impl Hooks for TrustedServerApp { 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); - } + Err(ref e) => return startup_error_router(e), }; build_router(&state) @@ -652,7 +673,7 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let req = ctx.into_request(); - let result = PartnerRegistry::from_config(&s.settings.ec.partners) + let result = PartnerRegistry::from_settings(&s.settings) .and_then(|registry| handle_admin_eids_lookup(®istry, &req)); Ok::(result.unwrap_or_else(|e| http_error(&e))) } @@ -1339,6 +1360,49 @@ mod tests { ); } + #[test] + fn startup_diagnostic_excludes_native_sources_and_keeps_one_line() { + let report = Report::new(TrustedServerError::BadRequest { + message: "NATIVE_CREDENTIAL_SENTINEL".to_owned(), + }) + .change_context(TrustedServerError::Configuration { + message: "safe startup category\nconfiguration unavailable".to_owned(), + }) + .attach("NATIVE_BINDING_NAME_SENTINEL"); + let diagnostic = startup_error_diagnostic(&report); + assert!(diagnostic.contains("safe startup category")); + assert!(!diagnostic.contains("NATIVE_CREDENTIAL_SENTINEL")); + assert!(!diagnostic.contains("NATIVE_BINDING_NAME_SENTINEL")); + assert!(diagnostic.contains("safe startup category configuration unavailable")); + assert!(diagnostic.chars().all(|character| !character.is_control())); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn startup_error_router_keeps_diagnostic_out_of_response() { + let report = Report::new(TrustedServerError::Configuration { + message: "trusted_server: native credential unavailable".to_owned(), + }) + .attach("NATIVE_CREDENTIAL_SENTINEL"); + let req = edgezero_core::http::request_builder() + .method("GET") + .uri("/") + .body(edgezero_core::body::Body::empty()) + .expect("should build a request for the startup-error fallback"); + let response = startup_error_router(&report) + .oneshot(req) + .await + .expect("should serve the anonymous startup-error response"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response + .into_body() + .into_bytes() + .expect("should read the startup-error response body") + .as_ref(), + b"Service Unavailable\n" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn startup_error_router_serves_503_for_all_fallback_methods() { // The degraded router must answer every publisher-fallback method diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 9d6b25571c..4aa1f2abc5 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -37,7 +37,7 @@ type HeaderPairs = Vec<(String, Vec)>; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] type BufferedResponseParts = (HeaderPairs, Vec); -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[cfg(test)] const SPIN_VARIABLE_HEX: &[u8; 16] = b"0123456789abcdef"; // --------------------------------------------------------------------------- @@ -133,7 +133,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[cfg(test)] fn spin_variable_name( key: &str, error_context: PlatformError, @@ -172,7 +172,7 @@ fn spin_variable_name( Ok(out) } -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[cfg(test)] fn push_spin_variable_escape(out: &mut String, byte: u8) { out.push('_'); out.push('x'); @@ -185,10 +185,8 @@ fn spin_secret_variable_name( store_name: &StoreName, key: &str, ) -> Result> { - let store_variable = spin_variable_name(store_name.as_ref(), PlatformError::SecretStore)?; - let key_variable = spin_variable_name(key, PlatformError::SecretStore)?; - - Ok(format!("{store_variable}_{key_variable}")) + trusted_server_core::secret_bindings::spin_secret_name(store_name.as_ref(), key) + .change_context(PlatformError::SecretStore) } /// Bridges edgezero's [`KvHandle`] to [`PlatformKvStore`]. diff --git a/crates/trusted-server-cli/src/app_config.rs b/crates/trusted-server-cli/src/app_config.rs index bee536146b..2d791f6c35 100644 --- a/crates/trusted-server-cli/src/app_config.rs +++ b/crates/trusted-server-cli/src/app_config.rs @@ -13,20 +13,25 @@ use edgezero_core::manifest::ManifestLoader; use trusted_server_core::config::TrustedServerAppConfig; use trusted_server_core::settings::Settings; -/// Shared local app-config flags accepted by every config/audit ad-template command. +/// Shared local app-config flags for config, audit, and secret planning commands. #[derive(Clone, Debug, Args)] pub struct AppConfigArgs { /// Path to `trusted-server.toml`. Defaults to `.toml` beside `edgezero.toml`. #[arg(long)] pub app_config: Option, - /// Path to `edgezero.toml`. - #[arg(long, default_value = "edgezero.toml")] + /// Path to `edgezero.toml`. Defaults to `EDGEZERO_MANIFEST`, then `edgezero.toml`. + #[arg(long, default_value_os_t = default_manifest_path())] pub manifest: PathBuf, /// Skip app-config environment overlay. #[arg(long)] pub no_env: bool, } +fn default_manifest_path() -> PathBuf { + std::env::var_os("EDGEZERO_MANIFEST") + .map_or_else(|| PathBuf::from("edgezero.toml"), PathBuf::from) +} + /// Effective settings plus the resolved app-config path they were loaded from. #[derive(Debug)] pub struct LoadedSettings { diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 010522f8fb..7116e9e395 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -1614,10 +1614,17 @@ fn validate_page_patterns(patterns: &[String]) -> CliResult<()> { #[cfg(test)] mod tests { use std::cell::{Cell, RefCell}; + use std::collections::BTreeMap; use std::io; use std::rc::Rc; + use edgezero_core::blob_envelope::BlobEnvelope; + use error_stack::Report; use tempfile::TempDir; + use trusted_server_core::config::TrustedServerAppConfig; + use trusted_server_core::config_payload::{LoadedConfig, settings_from_config_blob}; + use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; + use trusted_server_core::secret_bindings::{SECRET_STORE_ID, compile_bindings}; use super::*; use crate::app_config::AppConfigArgs; @@ -3795,22 +3802,10 @@ mod tests { toml::from_str::(&written).expect("rewritten config is valid TOML"); } - /// A config with fictional resolved secrets and non-placeholder publisher - /// values, so both source validation and runtime loading can be exercised. + /// Feature-only config with non-placeholder publisher settings. + /// Native credentials belong to the separate runtime fixture below. fn loadable_config() -> String { EXAMPLE_CONFIG - .replace( - "password = \"handler_password\"", - "password = \"test-admin-password-32-bytes-minimum\"", - ) - .replace( - "passphrase = \"ec_passphrase\"", - "passphrase = \"test-ec-passphrase-32-bytes-minimum\"", - ) - .replace( - "proxy_secret = \"publisher_proxy_secret\"", - "proxy_secret = \"test-proxy-secret-32-bytes-minimum\"", - ) .replace("\"example.com\"", "\"publisher.example.com\"") .replace("\".example.com\"", "\".publisher.example.com\"") .replace( @@ -3819,6 +3814,64 @@ mod tests { ) } + struct FixtureSecrets(BTreeMap>); + + impl PlatformSecretStore for FixtureSecrets { + fn get_bytes( + &self, + store: &StoreName, + key: &str, + ) -> Result, Report> { + if store.as_ref() != SECRET_STORE_ID { + return Err(Report::new(PlatformError::SecretStore)); + } + self.0 + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::SecretStore)) + } + + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore)) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore)) + } + } + + /// Exercise the real envelope/compiler/native-read boundary without adding + /// credentials or former reference properties to generated config files. + fn load_runtime_config(source: &str) -> LoadedConfig { + let config: TrustedServerAppConfig = + toml::from_str(source).expect("should deserialize feature-only source config"); + let config = TrustedServerAppConfig::new(config.into_settings()) + .expect("should validate source config before native resolution"); + let plan = + compile_bindings(config.settings()).expect("should compile implicit credentials"); + let secrets = FixtureSecrets( + plan.entries() + .iter() + .map(|entry| { + ( + entry.key().to_owned(), + format!("fictional-native-credential-{}", entry.key()).into_bytes(), + ) + }) + .collect(), + ); + let data = serde_json::to_value(&config).expect("should serialize feature-only config"); + let envelope = BlobEnvelope::new(data, "2026-10-26T00:00:00Z".to_owned()); + let text = serde_json::to_string(&envelope).expect("should serialize valid envelope"); + let loaded = settings_from_config_blob(&text, &secrets, &StoreName::from(SECRET_STORE_ID)) + .expect("should load generated config through actual native bindings"); + assert_eq!( + loaded.resolved_bindings.entries().len(), + plan.entries().len() + ); + loaded + } + #[test] fn a_crawl_writes_a_section_template_and_per_section_patterns() { // The end-to-end payoff: crawl sections, reconcile the slot across them, @@ -3905,8 +3958,7 @@ mod tests { ); // The whole point of the gate: what was written must actually load. - trusted_server_core::settings::Settings::from_toml(&written) - .expect("generated config must load through the runtime path"); + load_runtime_config(&written); let report = String::from_utf8(err).expect("should produce UTF-8 output"); assert!( @@ -4196,8 +4248,7 @@ mod tests { let temp = TempDir::new().expect("should create temp dir"); let config_path = temp.path().join("trusted-server.toml"); let baseline = loadable_config(); - trusted_server_core::settings::Settings::from_toml(&baseline) - .expect("test baseline must itself be loadable or the gate is not exercised"); + load_runtime_config(&baseline); fs::write(&config_path, &baseline).expect("should write config"); let collector = FakeCollector::new(collected_page_with_header_slot()); let mut out = Vec::new(); @@ -4221,10 +4272,10 @@ mod tests { .expect("should update slots"); let written = fs::read_to_string(&config_path).expect("should read config"); - let settings = trusted_server_core::settings::Settings::from_toml(&written) - .expect("generated config must load through the runtime path"); + let settings = load_runtime_config(&written); let creative = settings .creative_opportunities + .as_ref() .expect("generated config should carry creative opportunities"); assert_eq!( creative.slot.len(), diff --git a/crates/trusted-server-cli/src/commands/audit/generate/validate.rs b/crates/trusted-server-cli/src/commands/audit/generate/validate.rs index 4334cb398d..ceb6741442 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/validate.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/validate.rs @@ -15,15 +15,15 @@ use crate::error::{CliResult, cli_error}; /// /// Runs [`TrustedServerAppConfig::new`], the push-time validation path, after /// deserializing the source TOML. This checks slot and template compilation, -/// provider configuration, and secret key references without I/O. Secret values -/// are resolved and validated separately at runtime; treating source key names -/// as resolved secrets would incorrectly reject otherwise valid baselines. +/// provider configuration, and implicit credential contexts without I/O. Native +/// credentials are resolved and validated separately at runtime, not serialized +/// into the source document. /// /// `baseline` is the config as it was read from disk. When the baseline is /// *already* unloadable, this run cannot be blamed for it: the candidate is /// accepted and the pre-existing error is returned as a warning instead. Without -/// that escape hatch a freshly bootstrapped config carrying placeholder secrets -/// could never be updated by `generate`. +/// that escape hatch a freshly bootstrapped config carrying placeholder publisher +/// settings could never be updated by `generate`. /// /// # Errors /// @@ -49,9 +49,10 @@ pub(super) fn check_candidate(candidate: &str, baseline: &str) -> CliResult CliResult<()> { let config: TrustedServerAppConfig = toml::from_str(source).map_err(|error| { error.span().map_or_else( @@ -68,7 +69,7 @@ fn validate_source_config(source: &str) -> CliResult<()> { mod tests { use super::*; - /// A source config containing secret references, as an operator would edit it. + /// Feature-only source config, with credentials supplied separately at runtime. fn baseline() -> String { crate::commands::config::init::EXAMPLE_CONFIG .replace("\"example.com\"", "\"publisher.example.com\"") @@ -149,8 +150,11 @@ mod tests { } #[test] - fn schema_failure_reports_a_load_failure() { - let source = baseline().replace("\"publisher.example.com\"", "true"); + fn schema_failure_reports_a_safe_load_failure() { + let source = baseline().replace( + "\"publisher.example.com\"", + "[\"FICTIONAL_SECRET_SENTINEL\"]", + ); toml::from_str::(&source).expect("should be syntactically valid TOML"); let error = validate_source_config(&source).expect_err("should reject wrong field type"); assert!( @@ -158,9 +162,20 @@ mod tests { "should cover schema as well as syntax errors" ); assert!( - error.contains("byte offset"), - "should keep the parser location" + !error.contains("FICTIONAL_SECRET_SENTINEL"), + "should not echo schema values" ); + if let Some(offset) = error.strip_prefix("config could not be loaded at byte offset ") { + assert!( + offset.parse::().is_ok(), + "should expose only a numeric source offset" + ); + } else { + assert_eq!( + error, "config could not be loaded", + "should keep a safe category when the schema boundary has no source span" + ); + } } #[test] diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index 0f4354f007..c99a80c10a 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod audit; pub(crate) mod config; pub(crate) mod pbs; +pub(crate) mod secrets; // `dev` is `pub` so the macOS-gated `tests/proxy_e2e.rs` suite can reach // `commands::dev::proxy`, `origin` is `pub` so `tests/origin_probe.rs` can drive the // shareability probe against a local fixture, and `cache` is `pub` so diff --git a/crates/trusted-server-cli/src/commands/secrets/fastly.rs b/crates/trusted-server-cli/src/commands/secrets/fastly.rs new file mode 100644 index 0000000000..763c122edb --- /dev/null +++ b/crates/trusted-server-cli/src/commands/secrets/fastly.rs @@ -0,0 +1,272 @@ +use std::fs; +use std::path::Path; + +use edgezero_cli::fastly_secrets::{ + self, FASTLY_SECRET_MAX_BYTES, FastlySecretResources, SecretWriteAction, +}; +use edgezero_core::canonical_form::canonical_data_sha256; +use edgezero_core::manifest::ManifestLoader; +use serde_json::{Value, json}; + +use super::{Adapter, NativeBinding, Provider, SECRET_STORE_ID, TargetArgs}; + +pub(super) struct NativeProvider<'a> { + pub(super) transport: Option<&'a dyn fastly_secrets::FastlySecretTransport>, +} + +impl Provider for NativeProvider<'_> { + fn resources(&self, target: &TargetArgs) -> Result { + if target.adapter == Adapter::Fastly { + let context = fastly_secrets::context_from_manifest( + &target.config.manifest, + target.service_id.as_deref(), + )?; + let resources = match self.transport { + Some(transport) => { + fastly_secrets::resolve_resources_with_transport(&context, transport)? + } + None => fastly_secrets::resolve_resources(&context)?, + }; + ensure_secret_store(&resources)?; + return serde_json::to_value(resources) + .map_err(|_| "could not encode resolved deployment resources".to_owned()); + } + native_resources(target) + } + + fn exists(&self, resources: &Value, key: &str) -> Result { + let resources = fastly_resources(resources)?; + let resource_id = &ensure_secret_store(&resources)?.resource_id; + match self.transport { + Some(transport) => { + fastly_secrets::secret_exists_with_transport(resource_id, key, transport) + } + None => fastly_secrets::secret_exists(resource_id, key), + } + } + + fn write( + &self, + resources: &Value, + key: &str, + value: &[u8], + replace: bool, + ) -> Result<(), String> { + let resources = fastly_resources(resources)?; + let resource_id = &ensure_secret_store(&resources)?.resource_id; + let action = if replace { + SecretWriteAction::Replace + } else { + SecretWriteAction::Create + }; + match self.transport { + Some(transport) => fastly_secrets::write_secret_with_transport( + resource_id, + key, + value, + action, + transport, + ), + None => fastly_secrets::write_secret(resource_id, key, value, action), + } + } + + fn max_secret_bytes(&self) -> usize { + FASTLY_SECRET_MAX_BYTES + } +} + +fn fastly_resources(resources: &Value) -> Result { + // Only freshly resolved in-process metadata reaches this function. Reviewed + // plan contents are compared, never deserialized into write authority. + serde_json::from_value(resources.clone()) + .map_err(|_| "could not read resolved deployment resources".to_owned()) +} + +fn ensure_secret_store( + resources: &FastlySecretResources, +) -> Result<&fastly_secrets::FastlyStoreResource, String> { + resources + .stores + .iter() + .find(|store| store.kind == "secrets" && store.logical_id == SECRET_STORE_ID) + .ok_or_else(|| "deployment does not declare trusted_server_secrets".to_owned()) +} + +pub(super) fn spin_component(target: &TargetArgs) -> Result, String> { + if target.adapter != Adapter::Spin { + return Ok(None); + } + let loader = ManifestLoader::from_path(&target.config.manifest) + .map_err(|_| "could not load deployment manifest".to_owned())?; + let (_, adapter) = loader + .manifest() + .adapter_entry("spin") + .ok_or_else(|| "Spin is not declared in the deployment manifest".to_owned())?; + let path = loader + .manifest() + .root() + .unwrap_or_else(|| Path::new(".")) + .join(adapter.adapter.manifest.as_deref().ok_or_else(|| { + "Spin secret planning requires an explicit adapter manifest".to_owned() + })?); + edgezero_cli::resolve_spin_component(&path, adapter.adapter.component.as_deref()) + .map(Some) + .map_err(|_| { + "could not resolve selected Spin component from the existing manifests".to_owned() + }) +} + +pub(super) fn validate_native_declarations( + target: &TargetArgs, + bindings: &[&NativeBinding], +) -> Result<(), String> { + let loader = ManifestLoader::from_path(&target.config.manifest) + .map_err(|_| "could not load deployment manifest".to_owned())?; + let manifest = loader.manifest(); + let environment = manifest.environment_for(target.adapter.name()); + for binding in bindings { + if environment + .variables + .iter() + .chain(&environment.secrets) + .any(|declaration| declaration.env == binding.native_name) + { + return Err( + "generated native identity collides with another environment declaration" + .to_owned(), + ); + } + } + let (_, adapter) = manifest + .adapter_entry(target.adapter.name()) + .ok_or_else(|| "adapter is not declared in the deployment manifest".to_owned())?; + let Some(path) = &adapter.adapter.manifest else { + return Ok(()); + }; + let path = manifest.root().unwrap_or_else(|| Path::new(".")).join(path); + let text = + fs::read_to_string(path).map_err(|_| "could not read adapter manifest".to_owned())?; + let document: toml::Value = + toml::from_str(&text).map_err(|_| "could not parse adapter manifest".to_owned())?; + let selected_component = spin_component(target)?; + for binding in bindings { + if target.adapter == Adapter::Cloudflare + && worker_collision(&document, &binding.native_name) + { + return Err("generated native secret collides with another Worker binding or plaintext variable".to_owned()); + } + if target.adapter == Adapter::Spin { + if let Some(variable) = document + .get("variables") + .and_then(|vars| vars.get(&binding.native_name)) + && (variable.get("secret").and_then(toml::Value::as_bool) != Some(true) + || variable.get("required").and_then(toml::Value::as_bool) != Some(true) + || variable.get("default").is_some()) + { + return Err( + "generated Spin identity collides with an incompatible application variable" + .to_owned(), + ); + } + if let Some(variable) = document + .get("component") + .and_then(|components| { + components.get(selected_component.as_deref().unwrap_or("trusted-server")) + }) + .and_then(|component| component.get("variables")) + .and_then(|vars| vars.get(&binding.native_name)) + && variable.as_str() != Some(format!("{{{{ {} }}}}", binding.native_name).as_str()) + { + return Err( + "generated Spin identity collides with an incompatible component variable" + .to_owned(), + ); + } + } + } + Ok(()) +} + +fn worker_collision(value: &toml::Value, name: &str) -> bool { + match value { + toml::Value::Table(table) => { + if table.get("binding").and_then(toml::Value::as_str) == Some(name) { + return true; + } + table.iter().any(|(key, value)| { + let named_map = matches!( + key.as_str(), + "vars" | "wasm_modules" | "text_blobs" | "data_blobs" + ) && value.get(name).is_some(); + let named_binding = key == "bindings" + && value.as_array().is_some_and(|items| { + items.iter().any(|item| { + item.get("name").and_then(toml::Value::as_str) == Some(name) + }) + }); + named_map || named_binding || worker_collision(value, name) + }) + } + toml::Value::Array(items) => items.iter().any(|item| worker_collision(item, name)), + _ => false, + } +} + +pub(super) fn runtime_secret_store(target: &TargetArgs) -> Result { + if !matches!(target.adapter, Adapter::Axum | Adapter::Spin) { + return Ok(SECRET_STORE_ID.to_owned()); + } + let loader = ManifestLoader::from_path(&target.config.manifest) + .map_err(|_| "could not load deployment manifest".to_owned())?; + edgezero_cli::effective_manifest_environment( + loader.manifest(), + target.adapter.name(), + std::env::vars(), + ) + .store_name_checked("secrets", SECRET_STORE_ID) + .map_err(|_| "invalid trusted runtime secret-store selector".to_owned()) +} + +fn native_resources(target: &TargetArgs) -> Result { + let loader = ManifestLoader::from_path(&target.config.manifest) + .map_err(|_| "could not load deployment manifest".to_owned())?; + let manifest = loader.manifest(); + let (_, adapter) = manifest + .adapter_entry(target.adapter.name()) + .ok_or_else(|| "adapter is not declared in the deployment manifest".to_owned())?; + let secrets = manifest + .stores + .secrets + .as_ref() + .ok_or_else(|| "deployment does not declare trusted_server_secrets".to_owned())?; + if !secrets.ids.iter().any(|id| id == SECRET_STORE_ID) { + return Err("deployment does not declare trusted_server_secrets".to_owned()); + } + let runtime_store = runtime_secret_store(target)?; + let path = adapter + .adapter + .manifest + .as_ref() + .map(|path| manifest.root().unwrap_or_else(|| Path::new(".")).join(path)); + let adapter_manifest = path + .as_ref() + .map(|path| { + let text = fs::read_to_string(path) + .map_err(|_| "could not read adapter manifest".to_owned())?; + let value: toml::Value = + toml::from_str(&text).map_err(|_| "could not parse adapter manifest".to_owned())?; + serde_json::to_value(value).map_err(|_| "could not encode adapter manifest".to_owned()) + }) + .transpose()? + .unwrap_or(Value::Null); + let component = spin_component(target)?; + Ok(json!({ + "logical_store": SECRET_STORE_ID, + "runtime_secret_store": runtime_store, + "resource_resolution": "native provider resource IDs are not resolved by this adapter", + "adapter_manifest_sha256": canonical_data_sha256(&adapter_manifest), + "component": component, + "application": adapter_manifest.get("name").or_else(|| adapter_manifest.pointer("/application/name")), + })) +} diff --git a/crates/trusted-server-cli/src/commands/secrets/mod.rs b/crates/trusted-server-cli/src/commands/secrets/mod.rs new file mode 100644 index 0000000000..06422ea4a6 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/secrets/mod.rs @@ -0,0 +1,480 @@ +//! Reviewed implicit-credential plans and explicit provisioning. + +mod fastly; +#[cfg(test)] +mod tests; + +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, IsTerminal as _, Read as _}; +use std::path::PathBuf; + +use clap::{Args, Subcommand, ValueEnum}; +use edgezero_core::canonical_form::canonical_data_sha256; +use serde::Serialize; +use serde_json::{Value, json}; +use trusted_server_core::secret_bindings::{ + BINDING_ABI_VERSION, CompiledBinding, NativeBinding, SECRET_STORE_ID, compile_bindings, + render_native_with_mapping, validate_credential, +}; + +use crate::app_config::{AppConfigArgs, load_settings}; +use crate::run::RunOutcome; + +const PLAN_VERSION: u32 = 1; +const NATIVE_MAPPING_VERSION: &str = "trusted-server-native-v1"; + +/// Native runtime selected for credential planning and presence checks. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Adapter { + /// Fastly secret-store keys and reviewed direct writes. + Fastly, + /// Axum process-environment credential bindings. + Axum, + /// Cloudflare Worker secret bindings. + Cloudflare, + /// Spin application and component variables. + Spin, +} + +impl Adapter { + fn name(self) -> &'static str { + match self { + Self::Fastly => "fastly", + Self::Axum => "axum", + Self::Cloudflare => "cloudflare", + Self::Spin => "spin", + } + } +} + +/// Existing deployment selection and unresolved app-config inputs. +#[derive(Debug, Args)] +pub(crate) struct TargetArgs { + #[command(flatten)] + config: AppConfigArgs, + /// Native runtime whose actual Trusted Server secret mapping to render. + #[arg(long, value_enum)] + adapter: Adapter, + /// Existing Fastly service selection, as for managed deployment. + #[arg(long)] + service_id: Option, +} + +/// Arguments for rendering a secret-free native binding plan. +#[derive(Debug, Args)] +pub(crate) struct PlanArgs { + #[command(flatten)] + target: TargetArgs, + /// Write secret-free JSON metadata to this file; otherwise print it. + #[arg(long)] + out: Option, +} + +/// Arguments for checking reviewed-plan agreement and native presence. +#[derive(Debug, Args)] +pub(crate) struct CheckArgs { + #[command(flatten)] + target: TargetArgs, + /// Complete previously reviewed plan. A plan is not authorization. + #[arg(long)] + plan: PathBuf, +} + +/// Arguments for one explicitly approved Fastly credential write. +#[derive(Debug, Args)] +pub(crate) struct SetArgs { + /// Active fixed purpose selector, for example tinybird.auction. + role: String, + #[command(flatten)] + target: TargetArgs, + /// Complete previously reviewed plan. Recompiled before credential input. + #[arg(long)] + plan: PathBuf, + /// Tool-generated entry ID from the reviewed plan for a repeated purpose. + #[arg(long)] + binding: Option, + /// Read exact credential bytes from stdin, without trimming. Requires --yes. + #[arg(long, requires = "yes")] + stdin: bool, + /// Approve the displayed operation noninteractively; does not authorize a plan. + #[arg(long)] + yes: bool, +} + +/// Review credential bindings, check presence, or provision one approved value. +#[derive(Debug, Subcommand)] +pub(crate) enum SecretsCommand { + /// Compile active roles and render secret-free native identities and declarations. + /// + /// Every owner remains in the plan. Only the first identical native request + /// carries declaration snippets. Merge Spin component assignments under one header. + Plan(PlanArgs), + /// Check reviewed inputs and presence only, never runtime credential validity. + Check(CheckArgs), + /// Create or replace a reviewed Fastly binding through hidden input or stdin. + Set(SetArgs), +} + +/// Read-only target resolution and narrowly scoped writes. Tests replace transport, +/// not the compiler, plan comparison, selection, or command dispatch. +trait Provider { + fn resources(&self, target: &TargetArgs) -> Result; + fn exists(&self, resources: &Value, key: &str) -> Result; + fn write( + &self, + resources: &Value, + key: &str, + value: &[u8], + replace: bool, + ) -> Result<(), String>; + fn max_secret_bytes(&self) -> usize; +} + +trait Interaction { + fn interactive(&self) -> bool; + fn line(&mut self) -> Result; + fn hidden(&mut self) -> Result, String>; + fn stdin(&mut self, max_bytes: usize) -> Result, String>; +} + +struct Terminal; + +impl Interaction for Terminal { + fn interactive(&self) -> bool { + io::stdin().is_terminal() && io::stderr().is_terminal() + } + + fn line(&mut self) -> Result { + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .map_err(|_| "could not read confirmation".to_owned())?; + Ok(line) + } + + fn hidden(&mut self) -> Result, String> { + rpassword::prompt_password("Credential, input hidden: ") + .map(String::into_bytes) + .map_err(|_| "credential input cancelled or unavailable".to_owned()) + } + + fn stdin(&mut self, max_bytes: usize) -> Result, String> { + let mut value = Vec::new(); + io::stdin() + .take(max_bytes.saturating_add(1) as u64) + .read_to_end(&mut value) + .map_err(|_| "could not read credential input".to_owned())?; + Ok(value) + } +} + +#[derive(Serialize)] +struct PlanEntry<'a> { + entry_id: String, + compiled: &'a CompiledBinding, + /// Later owners retain native identity metadata but share the first owner's declarations. + native: NativeBinding, +} + +struct Prepared { + document: Value, + bindings: trusted_server_core::secret_bindings::BindingPlan, + resources: Value, + native_by_key: BTreeMap, +} + +fn entry_id(binding: &CompiledBinding) -> Result { + let metadata = serde_json::to_value(binding) + .map_err(|_| "could not encode compiled binding".to_owned())?; + Ok(format!("binding_{}", canonical_data_sha256(&metadata))) +} + +fn coalesce_native_declarations<'a>( + requests: &mut BTreeMap, + compiled: &'a CompiledBinding, + native: &mut NativeBinding, +) -> Result<(), String> { + if let Some((previous, previous_native)) = requests.get(&native.native_name) { + // The owning schema path is intentionally excluded. Every context, + // target, store/key pair and full native rendering must still match. + if !previous.same_authorization(compiled) || previous_native != &*native { + return Err("distinct credential contexts collide in the native namespace".to_owned()); + } + native.declarations.clear(); + } else { + requests.insert(native.native_name.clone(), (compiled, native.clone())); + } + Ok(()) +} + +fn prepare(target: &TargetArgs, provider: &dyn Provider) -> Result { + if target.adapter != Adapter::Fastly && target.service_id.is_some() { + return Err("--service-id is supported only with --adapter fastly".to_owned()); + } + let loaded = load_settings(&target.config) + .map_err(|_| "could not load effective unresolved app configuration".to_owned())?; + let bindings = compile_bindings(&loaded.settings) + .map_err(|_| "could not compile active credential bindings".to_owned())?; + let effective = serde_json::to_value(&loaded.settings) + .map_err(|_| "could not encode effective unresolved app configuration".to_owned())?; + let spin_component = fastly::spin_component(target)?; + let runtime_store = fastly::runtime_secret_store(target)?; + let mut native_requests = BTreeMap::new(); + let mut native_by_key = BTreeMap::new(); + let entries = bindings + .entries() + .iter() + .map(|binding| { + let mut native = render_native_with_mapping( + target.adapter.name(), + SECRET_STORE_ID, + binding.key(), + &runtime_store, + ) + .map_err(|_| "could not render native credential binding".to_owned())?; + if let Some(component) = &spin_component { + let header = format!( + "[component.{}.variables]", + toml::Value::String(component.clone()) + ); + for declaration in &mut native.declarations { + *declaration = + declaration.replace("[component.trusted-server.variables]", &header); + } + } + coalesce_native_declarations(&mut native_requests, binding, &mut native)?; + native_by_key + .entry(binding.key().to_owned()) + .or_insert_with(|| native.clone()); + Ok(PlanEntry { + entry_id: entry_id(binding)?, + compiled: binding, + native, + }) + }) + .collect::, String>>()?; + fastly::validate_native_declarations( + target, + &entries + .iter() + .map(|entry| &entry.native) + .collect::>(), + )?; + let resources = provider.resources(target)?; + let document = json!({ + "plan_version": PLAN_VERSION, + "compiler_version": env!("CARGO_PKG_VERSION"), + "binding_abi": BINDING_ABI_VERSION, + "native_mapping_version": NATIVE_MAPPING_VERSION, + "adapter": target.adapter, + "effective_config_sha256": canonical_data_sha256(&effective), + "environment_overlay": !target.config.no_env, + "resources": resources, + "entries": entries, + }); + Ok(Prepared { + document, + bindings, + resources, + native_by_key, + }) +} + +fn verify_plan(path: &PathBuf, fresh: &Value) -> Result<(), String> { + let bytes = fs::read(path).map_err(|_| "could not read reviewed binding plan".to_owned())?; + let reviewed: Value = serde_json::from_slice(&bytes) + .map_err(|_| "reviewed binding plan is not valid JSON".to_owned())?; + if &reviewed != fresh { + return Err("reviewed plan does not match current config, overlays, versions, native mappings or deployment resources; generate and review a new plan".to_owned()); + } + Ok(()) +} + +fn select_binding<'a>( + args: &SetArgs, + prepared: &'a Prepared, + interaction: &mut dyn Interaction, + out: &mut dyn io::Write, +) -> Result<&'a CompiledBinding, String> { + let candidates = prepared + .bindings + .entries() + .iter() + .filter(|binding| binding.descriptor_id() == args.role) + .collect::>(); + if candidates.is_empty() { + return Err("purpose is unknown or inactive in the current configuration".to_owned()); + } + if let Some(id) = &args.binding { + return candidates + .into_iter() + .find(|binding| entry_id(binding).is_ok_and(|candidate| &candidate == id)) + .ok_or_else(|| { + "--binding must identify an active entry of this purpose in the reviewed plan" + .to_owned() + }); + } + if let [binding] = candidates.as_slice() { + return Ok(binding); + } + if !interaction.interactive() || args.stdin || args.yes { + return Err("repeated purpose requires an explicit tool-generated --binding entry ID from the reviewed plan".to_owned()); + } + for binding in &candidates { + writeln!( + out, + "{} {}", + entry_id(binding)?, + metadata(binding.subject())? + ) + .map_err(output_error)?; + } + writeln!(out, "Enter the complete binding entry ID:").map_err(output_error)?; + out.flush().map_err(output_error)?; + let choice = interaction.line()?; + candidates + .into_iter() + .find(|binding| entry_id(binding).is_ok_and(|id| id == choice.trim())) + .ok_or_else(|| "no listed binding selected; write cancelled".to_owned()) +} + +fn metadata(value: &(impl Serialize + ?Sized)) -> Result { + serde_json::to_string(value).map_err(|_| "could not encode reviewed metadata".to_owned()) +} + +fn output_error(_: io::Error) -> String { + "could not write command output".to_owned() +} + +fn execute( + command: &SecretsCommand, + provider: &dyn Provider, + interaction: &mut dyn Interaction, + out: &mut dyn io::Write, +) -> Result { + match command { + SecretsCommand::Plan(args) => { + let prepared = prepare(&args.target, provider)?; + let mut text = serde_json::to_vec_pretty(&prepared.document) + .map_err(|_| "could not encode secret-free plan".to_owned())?; + text.push(b'\n'); + if let Some(path) = &args.out { + fs::write(path, text).map_err(|_| "could not write secret-free plan".to_owned())?; + } else { + out.write_all(&text).map_err(output_error)?; + } + Ok(RunOutcome::Success) + } + SecretsCommand::Check(args) => { + let prepared = prepare(&args.target, provider)?; + verify_plan(&args.plan, &prepared.document)?; + let mut missing = false; + let mut unsupported = false; + for binding in prepared.bindings.entries() { + let native = prepared + .native_by_key + .get(binding.key()) + .ok_or_else(|| "compiled native identity is unavailable".to_owned())?; + let status = match args.target.adapter { + Adapter::Fastly => Some(provider.exists(&prepared.resources, binding.key())?), + Adapter::Axum => Some(std::env::var_os(&native.native_name).is_some()), + Adapter::Cloudflare | Adapter::Spin => None, + }; + let label = match status { + Some(true) => "present", + Some(false) => { + missing = true; + "missing" + } + None => { + unsupported = true; + "unsupported" + } + }; + writeln!(out, "{} {} {label}", entry_id(binding)?, native.native_name) + .map_err(output_error)?; + } + writeln!(out, "Presence only. Credential validity, delivery, TLS and retained runtime state were not checked.") + .map_err(output_error)?; + if unsupported { + return Err("native presence checks are unsupported for this adapter; generated declarations do not prove provider binding existence".to_owned()); + } + Ok(if missing { + RunOutcome::AssertionFailed + } else { + RunOutcome::Success + }) + } + SecretsCommand::Set(args) => { + // This must precede config loading, provider access, and any interaction. + if args.target.adapter != Adapter::Fastly { + return Err("direct secret set is supported only for Fastly; use the reviewed native identities and your runtime's secure provisioning tools".to_owned()); + } + if args.stdin && !args.yes { + return Err("--stdin requires --yes".to_owned()); + } + let prepared = prepare(&args.target, provider)?; + verify_plan(&args.plan, &prepared.document)?; + let binding = select_binding(args, &prepared, interaction, out)?; + let replace = provider.exists(&prepared.resources, binding.key())?; + writeln!( + out, + "Purpose: {}\nSubject: {}\nExport: {}\nDeployment: {}\nAction: {}", + binding.descriptor_id(), + metadata(binding.subject())?, + metadata(binding.export())?, + metadata(&prepared.resources)?, + if replace { "replace" } else { "create" } + ) + .map_err(output_error)?; + if !args.yes { + if !interaction.interactive() { + return Err( + "hidden input requires a terminal; automation must use --stdin --yes" + .to_owned(), + ); + } + writeln!(out, "Type yes to approve this exact operation:").map_err(output_error)?; + out.flush().map_err(output_error)?; + if interaction.line()?.trim() != "yes" { + return Err("secret write cancelled".to_owned()); + } + } + if !args.stdin && !interaction.interactive() { + return Err("hidden credential input requires a terminal".to_owned()); + } + let value = if args.stdin { + interaction.stdin(provider.max_secret_bytes())? + } else { + interaction.hidden()? + }; + if value.len() > provider.max_secret_bytes() { + return Err("credential exceeds the provider value-size limit".to_owned()); + } + validate_credential(binding.scope(), &value) + .map_err(|_| "credential representation is invalid for the selected purpose; contents withheld".to_owned())?; + provider.write(&prepared.resources, binding.key(), &value, replace)?; + writeln!(out, "Storage confirmed: {}. No deployment, activation, links or runtime refresh performed.", + if replace { "replaced existing binding" } else { "created binding" }) + .map_err(output_error)?; + Ok(RunOutcome::Success) + } + } +} + +/// Execute a credential command with native transport and terminal interaction. +/// +/// # Errors +/// Returns a sanitized error for invalid inputs, plan drift, unsupported operations, +/// unavailable interaction, or failed provider operations. +pub(crate) fn run(command: &SecretsCommand) -> Result { + let mut out = io::stdout().lock(); + execute( + command, + &fastly::NativeProvider { transport: None }, + &mut Terminal, + &mut out, + ) +} diff --git a/crates/trusted-server-cli/src/commands/secrets/tests.rs b/crates/trusted-server-cli/src/commands/secrets/tests.rs new file mode 100644 index 0000000000..170bddb5b3 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/secrets/tests.rs @@ -0,0 +1,1071 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; + +use clap::Parser; +use edgezero_cli::fastly_secrets::{FastlySecretResponse, FastlySecretTransport}; +use tempfile::TempDir; +use trusted_server_core::secret_bindings::{axum_secret_name, spin_secret_name}; + +use super::*; + +#[test] +fn secrets_cli_hidden_input_preserves_permitted_whitespace_and_requires_confirmation() { + let fixture = Fixture::new(); + fixture.plan(); + let mut input = FakeInput { + value: b" fictional proxy credential ".to_vec(), + interactive: true, + lines: vec!["yes\n".to_owned()], + ..FakeInput::default() + }; + let (result, output) = fixture.set("publisher.proxy", &[], &mut input); + result.expect("should accept confirmed hidden input"); + assert_eq!(input.reads, 1); + assert!(!output.contains("fictional proxy credential")); + assert_eq!( + fixture + .transport + .values + .borrow() + .values() + .next() + .map(String::as_str), + Some("ICBmaWN0aW9uYWwgcHJveHkgY3JlZGVudGlhbCAg") + ); +} + +#[test] +fn secrets_cli_real_overlay_policy_and_service_selection_changes_precede_input() { + let fixture = Fixture::new(); + fixture.plan(); + let path = fixture.temp.path().join("plan.json").display().to_string(); + let mut command = fixture.parse(&[ + "set", + "tinybird.auction", + "--adapter", + "fastly", + "--plan", + &path, + "--stdin", + "--yes", + ]); + let SecretsCommand::Set(args) = &mut command else { + panic!("should parse set") + }; + args.target.config.no_env = false; + let mut input = FakeInput::default(); + assert!( + execute( + &command, + &fastly::NativeProvider { + transport: Some(&fixture.transport) + }, + &mut input, + &mut Vec::new() + ) + .is_err() + ); + assert_eq!(input.reads, 0); + assert!( + fixture + .set( + "tinybird.auction", + &[ + "--stdin", + "--yes", + "--service-id", + "DifferentFictionalService" + ], + &mut input + ) + .0 + .is_err() + ); + assert_eq!(input.reads, 0); + assert!(fixture.transport.mutation_paths.borrow().is_empty()); +} + +#[test] +fn secrets_cli_wrong_store_selection_never_reads_input_or_creates_resources() { + let fixture = Fixture::new(); + fixture.plan(); + let path = fixture.temp.path().join("edgezero.toml"); + let mut manifest = fs::read_to_string(&path).expect("should read fixture manifest"); + manifest.push_str( + r#" +[[environment.variables]] +name = "secret-store-selector" +env = "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME" +value = "different-fictional-store" +"#, + ); + fs::write(path, manifest).expect("should set existing deployment selector"); + let mut input = FakeInput::default(); + let error = fixture + .set("tinybird.auction", &["--stdin", "--yes"], &mut input) + .0 + .expect_err("should reject an unprovisioned selected store"); + assert!(error.contains("does not exist")); + assert_eq!(input.reads, 0); + assert!(fixture.transport.mutation_paths.borrow().is_empty()); +} + +#[test] +fn secrets_cli_worker_plaintext_and_spin_default_collisions_fail_planning() { + let fixture = Fixture::new(); + for adapter in ["cloudflare", "spin"] { + let (result, output) = + fixture.run(&["plan", "--adapter", adapter], &mut FakeInput::default()); + result.expect("should compile native plan"); + let plan: Value = serde_json::from_str(&output).expect("should emit native metadata"); + let name = plan["entries"][0]["native"]["native_name"] + .as_str() + .expect("should have native identity"); + let declaration = if adapter == "spin" { + format!( + "[component.trusted-server]\nsource='fictional.wasm'\n[variables.{name}]\nsecret=true\nrequired=true\ndefault='fictional-default'\n" + ) + } else { + format!("[vars]\n{name}='fictional-plaintext'\n") + }; + fs::write( + fixture.temp.path().join(if adapter == "spin" { + "spin.toml" + } else { + "wrangler.toml" + }), + declaration, + ) + .expect("should write conflicting declaration"); + assert!( + fixture + .run(&["plan", "--adapter", adapter], &mut FakeInput::default()) + .0 + .expect_err("should reject final native name collision") + .contains("collid") + ); + } +} + +#[test] +fn secrets_cli_spin_requires_explicit_manifest_before_plan_or_check() { + let fixture = Fixture::new(); + let (result, output) = fixture.run(&["plan", "--adapter", "spin"], &mut FakeInput::default()); + result.expect("should plan the explicitly selected Spin manifest"); + let plan = fixture.temp.path().join("spin-plan.json"); + fs::write(&plan, output).expect("should save the reviewed Spin plan"); + let path = fixture.temp.path().join("edgezero.toml"); + let manifest = fs::read_to_string(&path) + .expect("should read deployment manifest") + .replace("manifest = \"spin.toml\"\n", ""); + fs::write(path, manifest).expect("should remove the explicit Spin manifest path"); + assert!(fixture.temp.path().join("spin.toml").is_file()); + let plan = plan.display().to_string(); + + for args in [ + vec!["plan", "--adapter", "spin"], + vec!["check", "--adapter", "spin", "--plan", &plan], + ] { + let mut input = FakeInput::default(); + let (result, output) = fixture.run(&args, &mut input); + assert_eq!( + result.expect_err("should reject an implicit Spin manifest before using the plan"), + "Spin secret planning requires an explicit adapter manifest" + ); + assert!(output.is_empty(), "should not publish an unchecked plan"); + assert_eq!(input.reads, 0, "should not request a credential"); + } + assert!(fixture.transport.methods.borrow().is_empty()); +} + +#[test] +fn secrets_cli_spin_declarations_use_selected_component_and_parse_as_toml() { + let fixture = Fixture::new(); + let path = fixture.temp.path().join("edgezero.toml"); + let manifest = fs::read_to_string(&path) + .expect("should read manifest") + .replace( + "component = \"trusted-server\"", + "component = \"fictional-component\"", + ); + fs::write(path, manifest).expect("should select existing component"); + fs::write( + fixture.temp.path().join("spin.toml"), + "[component.fictional-component]\nsource='fictional.wasm'\n", + ) + .expect("should declare selected component"); + let (result, output) = fixture.run(&["plan", "--adapter", "spin"], &mut FakeInput::default()); + result.expect("should render selected component"); + let plan: Value = serde_json::from_str(&output).expect("should emit plan"); + for entry in plan["entries"].as_array().expect("should contain entries") { + for snippet in entry["native"]["declarations"] + .as_array() + .expect("should contain declarations") + { + let parsed: toml::Value = + toml::from_str(snippet.as_str().expect("should render snippet")) + .expect("should render valid native TOML"); + if let Some(components) = parsed.get("component") { + assert!(components.get("fictional-component").is_some()); + } + } + } +} + +#[test] +fn secrets_cli_axum_and_spin_render_effective_trusted_store_mapping() { + let fixture = Fixture::new(); + let path = fixture.temp.path().join("edgezero.toml"); + let mut manifest = fs::read_to_string(&path).expect("should read manifest"); + manifest.push_str( + r#" +[[environment.variables]] +name = "secret-store-selector" +env = "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME" +value = "fictional-stage.secrets" +"#, + ); + fs::write(path, manifest).expect("should configure trusted native mapping"); + for adapter in ["axum", "spin"] { + let (result, output) = + fixture.run(&["plan", "--adapter", adapter], &mut FakeInput::default()); + result.expect("should render effective selected native store"); + let plan: Value = serde_json::from_str(&output).expect("should emit plan"); + assert_eq!( + plan["resources"]["runtime_secret_store"], + "fictional-stage.secrets" + ); + for entry in plan["entries"].as_array().expect("should have entries") { + let key = entry["compiled"]["key"] + .as_str() + .expect("should have derived key"); + let expected = if adapter == "axum" { + axum_secret_name("fictional-stage.secrets", key) + } else { + spin_secret_name("fictional-stage.secrets", key) + .expect("should render actual Spin mapping") + }; + assert_eq!(entry["native"]["native_name"], expected); + assert_eq!(entry["native"]["store_id"], SECRET_STORE_ID); + if adapter == "spin" { + for snippet in entry["native"]["declarations"] + .as_array() + .expect("should have declarations") + { + assert!( + snippet + .as_str() + .expect("should have TOML snippet") + .contains(&expected) + ); + } + } + } + } +} + +#[test] +fn secrets_cli_plan_identity_is_portable_between_checkout_paths() { + let first = Fixture::new(); + let second = Fixture::new(); + assert_eq!(first.plan(), second.plan()); +} + +#[test] +fn secrets_cli_spin_implicit_and_ambiguous_component_selection_reuses_lifecycle_rules() { + let fixture = Fixture::new(); + let path = fixture.temp.path().join("edgezero.toml"); + let manifest = fs::read_to_string(&path) + .expect("should read manifest") + .replace("component = \"trusted-server\"", ""); + fs::write(path, manifest).expect("should remove explicit selector"); + let path = fixture.temp.path().join("spin.toml"); + fs::write( + &path, + "[component.only-component]\nsource='fictional.wasm'\n", + ) + .expect("should declare a sole component"); + let (result, output) = fixture.run(&["plan", "--adapter", "spin"], &mut FakeInput::default()); + result.expect("should resolve a sole component implicitly"); + let plan: Value = serde_json::from_str(&output).expect("should emit plan"); + assert_eq!(plan["resources"]["component"], "only-component"); + assert!( + plan["entries"][0]["native"]["declarations"][1] + .as_str() + .expect("should render component declaration") + .contains("only-component") + ); + fs::write( + path, + "[component.one]\nsource='one.wasm'\n[component.two]\nsource='two.wasm'\n", + ) + .expect("should declare ambiguous components"); + assert!( + fixture + .run(&["plan", "--adapter", "spin"], &mut FakeInput::default()) + .0 + .is_err() + ); + assert!(fixture.transport.methods.borrow().is_empty()); +} + +#[test] +fn secrets_cli_identical_handler_owners_share_physical_declarations() { + let fixture = Fixture::new(); + fs::write( + fixture.temp.path().join("trusted-server.toml"), + format!("{CONFIG}\n[[handlers]]\npath='^/_ts/admin'\nusername='admin'\n[[handlers]]\npath='^/_ts/admin'\nusername='admin'\n"), + ).expect("should configure identical handler owners"); + for adapter in ["spin", "cloudflare"] { + let (result, output) = + fixture.run(&["plan", "--adapter", adapter], &mut FakeInput::default()); + result.expect("should plan every owner of a shared native request"); + let plan: Value = serde_json::from_str(&output).expect("should decode shared-binding plan"); + let entries = plan["entries"] + .as_array() + .expect("should retain compiled owners"); + assert_eq!(entries.len(), 5); + let handlers = entries + .iter() + .filter(|entry| entry["compiled"]["descriptor_id"] == "handler.password") + .collect::>(); + assert_eq!(handlers.len(), 2); + assert_ne!(handlers[0]["entry_id"], handlers[1]["entry_id"]); + assert_ne!( + handlers[0]["compiled"]["field_path"], + handlers[1]["compiled"]["field_path"] + ); + for field in [ + "descriptor_id", + "scope", + "subject", + "export", + "target", + "key", + ] { + assert_eq!( + handlers[0]["compiled"][field], + handlers[1]["compiled"][field] + ); + } + for field in ["adapter", "store_id", "key", "native_name"] { + assert_eq!(handlers[0]["native"][field], handlers[1]["native"][field]); + } + let names = entries + .iter() + .map(|entry| { + entry["native"]["native_name"] + .as_str() + .expect("should retain native identity metadata") + }) + .collect::>(); + assert_eq!(names.len(), 4); + let snippets = entries + .iter() + .flat_map(|entry| { + entry["native"]["declarations"] + .as_array() + .expect("should retain a declaration list for every owner") + }) + .map(|snippet| snippet.as_str().expect("should render declaration text")) + .collect::>(); + if adapter == "spin" { + assert_eq!( + handlers[0]["native"]["declarations"] + .as_array() + .expect("should declare first owner") + .len(), + 2 + ); + assert!( + handlers[1]["native"]["declarations"] + .as_array() + .expect("should retain later owner metadata") + .is_empty() + ); + assert_eq!(snippets.len(), names.len() * 2); + let mut application = String::new(); + let mut component_header = None; + let mut assignments = String::new(); + for snippet in snippets { + if snippet.starts_with("[component.") { + let (header, body) = snippet + .split_once('\n') + .expect("should separate component header and assignment"); + if let Some(previous) = component_header { + assert_eq!(header, previous); + } else { + component_header = Some(header); + } + assignments.push_str(body); + assignments.push('\n'); + } else { + application.push_str(snippet); + application.push('\n'); + } + } + // Follow the operator guide: keep application tables and merge all + // component assignments beneath one component header. + let merged = format!( + "{application}\n{}\n{assignments}", + component_header.expect("should render a component header") + ); + let document: toml::Value = toml::from_str(&merged) + .expect("should parse all deduplicated Spin declarations together"); + let variables = document["variables"] + .as_table() + .expect("should declare application variables"); + let component = document["component"]["trusted-server"]["variables"] + .as_table() + .expect("should declare component variables"); + assert_eq!(variables.len(), names.len()); + assert_eq!(component.len(), names.len()); + for name in names { + assert_eq!(variables[name]["required"].as_bool(), Some(true)); + assert_eq!(variables[name]["secret"].as_bool(), Some(true)); + assert_eq!( + component[name].as_str(), + Some(format!("{{{{ {name} }}}}").as_str()) + ); + } + } else { + // Cloudflare plans expose every owner identity but never emit + // plaintext [vars] declarations or credential values. + assert!(snippets.is_empty()); + let document: toml::Value = toml::from_str(&snippets.join("\n")) + .expect("should parse the empty Cloudflare declaration block"); + assert!( + document + .as_table() + .expect("should retain a metadata-only plan") + .is_empty() + ); + } + } + assert!(fixture.transport.methods.borrow().is_empty()); +} + +#[test] +fn secrets_cli_declaration_coalescing_checks_full_context_store_key_and_rendering() { + let fixture = Fixture::new(); + fs::write( + fixture.temp.path().join("trusted-server.toml"), + format!("{CONFIG}\n[[handlers]]\npath='^/one'\nusername='one'\n[[handlers]]\npath='^/two'\nusername='two'\n"), + ).expect("should configure distinct handler contexts"); + let SecretsCommand::Plan(args) = fixture.parse(&["plan", "--adapter", "spin"]) else { + panic!("should parse the native plan command"); + }; + let loaded = + load_settings(&args.target.config).expect("should load real handler configuration"); + let plan = + compile_bindings(&loaded.settings).expect("should compile distinct handler contexts"); + let handlers = plan + .entries() + .iter() + .filter(|binding| binding.descriptor_id() == "handler.password") + .collect::>(); + assert_eq!(handlers.len(), 2); + let native = + render_native_with_mapping("spin", SECRET_STORE_ID, handlers[0].key(), SECRET_STORE_ID) + .expect("should render the first native request"); + let mut requests = BTreeMap::new(); + coalesce_native_declarations(&mut requests, handlers[0], &mut native.clone()) + .expect("should record the complete first request"); + let mut wrong_context = native.clone(); + assert!(coalesce_native_declarations(&mut requests, handlers[1], &mut wrong_context).is_err()); + let mut wrong_store = native.clone(); + wrong_store.store_id = "other-fictional-store".to_owned(); + let mut wrong_key = native.clone(); + wrong_key.key = handlers[1].key().to_owned(); + let mut wrong_adapter = native.clone(); + wrong_adapter.adapter = "cloudflare".to_owned(); + let mut wrong_declarations = native.clone(); + wrong_declarations + .declarations + .push("incompatible declaration".to_owned()); + for mut different in [wrong_store, wrong_key, wrong_adapter, wrong_declarations] { + let original = different.clone(); + assert!(coalesce_native_declarations(&mut requests, handlers[0], &mut different).is_err()); + assert_eq!(different, original); + } + let mut identical = native.clone(); + coalesce_native_declarations(&mut requests, handlers[0], &mut identical) + .expect("should share declarations only after exact request equality"); + assert!(identical.declarations.is_empty()); + assert_eq!(requests.len(), 1); + assert_eq!( + requests + .get(&native.native_name) + .expect("should retain the original complete rendering") + .1, + native + ); +} + +#[test] +fn secrets_cli_plan_help_documents_shared_owner_declarations() { + let help = TestCli::try_parse_from(["secrets", "plan", "--help"]) + .expect_err("should display native planning help") + .to_string(); + assert!(help.contains("Every owner")); + assert!(help.contains("Only the first")); + assert!(help.contains("Spin component")); +} + +const CONFIG: &str = r#" +[publisher] +domain = "publisher.example.com" +cookie_domain = ".publisher.example.com" +origin_url = "https://upstream.publisher.example.com" + +[tinybird] +enabled = true +api_host = "api.example.com" +auction_dataset = "auction_events" +"#; + +#[derive(Debug, Parser)] +struct TestCli { + #[command(subcommand)] + command: SecretsCommand, +} + +#[derive(Default)] +struct FakeTransport { + values: RefCell>, + methods: RefCell>, + mutation_paths: RefCell>, + failure: RefCell>, + resource_id: RefCell>, +} + +impl FastlySecretTransport for FakeTransport { + fn request( + &self, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + self.methods.borrow_mut().push(method.to_owned()); + if method != "GET" { + self.mutation_paths.borrow_mut().push(path.to_owned()); + } + let body = if method == "GET" && path.starts_with("/resources/stores/secret?") { + json!({"data":[{"id": self.resource_id.borrow().as_deref().unwrap_or("fictional-secret-store"), "name":"trusted_server_secrets"}], "meta":{}}) + } else if method == "GET" && path.starts_with("/resources/stores/config?") { + json!({"data":[{"id":"fictional-config-store", "name":"trusted_server_config"}], "meta":{}}) + } else if method == "GET" && path.starts_with("/resources/stores/kv?") { + json!({"data":[{"id":"fictional-kv-store", "name":"trusted_server_kv"}], "meta":{}}) + } else if method == "GET" && path.contains("/secrets?") { + json!({"data":self.values.borrow().keys().map(|name| json!({"name":name})).collect::>(), "meta":{}}) + } else if matches!(method, "POST" | "PATCH") { + let payload: Value = serde_json::from_str(body.expect("should have a request body")) + .expect("should encode a provider JSON request"); + let key = payload["name"] + .as_str() + .expect("should have a key") + .to_owned(); + let value = payload["secret"] + .as_str() + .expect("should have base64 bytes") + .to_owned(); + let exists = self.values.borrow().contains_key(&key); + if (method == "POST" && exists) || (method == "PATCH" && !exists) { + return Ok(FastlySecretResponse { + status: 409, + body: "fictional conflict".to_owned(), + }); + } + self.values.borrow_mut().insert(key, value); + // A provider can commit and then lose the response. The CLI must not retry. + if let Some(failure) = self.failure.borrow().as_ref() { + return Err(failure.clone()); + } + json!({}) + } else { + return Err(format!( + "unexpected fictional provider operation {method} {path}" + )); + }; + Ok(FastlySecretResponse { + status: 200, + body: body.to_string(), + }) + } +} + +#[derive(Default)] +struct FakeInput { + value: Vec, + reads: usize, + interactive: bool, + lines: Vec, +} + +impl Interaction for FakeInput { + fn interactive(&self) -> bool { + self.interactive + } + fn line(&mut self) -> Result { + if self.lines.is_empty() { + return Err("cancelled".to_owned()); + } + Ok(self.lines.remove(0)) + } + fn hidden(&mut self) -> Result, String> { + self.reads += 1; + Ok(self.value.clone()) + } + fn stdin(&mut self, _: usize) -> Result, String> { + self.reads += 1; + Ok(self.value.clone()) + } +} + +struct Fixture { + temp: TempDir, + transport: FakeTransport, +} + +impl Fixture { + fn new() -> Self { + let temp = TempDir::new().expect("should create fictional fixture"); + fs::write( + temp.path().join("edgezero.toml"), + r#" +[app] +name = "trusted-server" +[stores.secrets] +ids = ["trusted_server_secrets"] +default = "trusted_server_secrets" +[stores.config] +ids = ["trusted_server_config"] +default = "trusted_server_config" +[stores.kv] +ids = ["trusted_server_kv"] +default = "trusted_server_kv" +[adapters.fastly.adapter] +manifest = "fastly.toml" +[adapters.axum.adapter] +manifest = "axum.toml" +[adapters.cloudflare.adapter] +manifest = "wrangler.toml" +[adapters.spin.adapter] +manifest = "spin.toml" +component = "trusted-server" +"#, + ) + .expect("should write manifest"); + fs::write( + temp.path().join("fastly.toml"), + "service_id = 'FictionalService1037'\n", + ) + .expect("should write Fastly identity"); + fs::write(temp.path().join("trusted-server.toml"), CONFIG).expect("should write config"); + for path in ["axum.toml", "wrangler.toml"] { + fs::write(temp.path().join(path), "name = 'fictional-app'\n") + .expect("should write native fixture"); + } + fs::write(temp.path().join("spin.toml"), "[application]\nname='fictional-app'\n[component.trusted-server]\nsource='fictional.wasm'\n") + .expect("should write actual Spin component fixture"); + Self { + temp, + transport: FakeTransport::default(), + } + } + + fn parse(&self, args: &[&str]) -> SecretsCommand { + let mut argv = vec!["secrets".to_owned()]; + argv.extend(args.iter().map(|arg| (*arg).to_owned())); + argv.extend([ + "--manifest".to_owned(), + self.temp.path().join("edgezero.toml").display().to_string(), + "--app-config".to_owned(), + self.temp + .path() + .join("trusted-server.toml") + .display() + .to_string(), + "--no-env".to_owned(), + ]); + TestCli::try_parse_from(argv) + .expect("should parse real secrets arguments") + .command + } + + fn run(&self, args: &[&str], input: &mut FakeInput) -> (Result, String) { + let command = self.parse(args); + let mut output = Vec::new(); + let result = execute( + &command, + &fastly::NativeProvider { + transport: Some(&self.transport), + }, + input, + &mut output, + ); + ( + result, + String::from_utf8(output).expect("should emit UTF-8 metadata"), + ) + } + + fn plan(&self) -> Value { + let (result, output) = + self.run(&["plan", "--adapter", "fastly"], &mut FakeInput::default()); + result.expect("should compile plan through actual compiler and provider resolver"); + let plan: Value = serde_json::from_str(&output).expect("should emit a secret-free plan"); + fs::write(self.temp.path().join("plan.json"), output).expect("should write reviewed plan"); + plan + } + + fn set( + &self, + role: &str, + extra: &[&str], + input: &mut FakeInput, + ) -> (Result, String) { + let plan = self.temp.path().join("plan.json").display().to_string(); + let mut args = vec!["set", role, "--adapter", "fastly", "--plan", &plan]; + args.extend_from_slice(extra); + self.run(&args, input) + } +} + +#[test] +fn secrets_cli_plan_and_stdin_create_replace_use_actual_shared_transport() { + let fixture = Fixture::new(); + let plan = fixture.plan(); + assert_eq!( + plan["entries"] + .as_array() + .expect("should list implicit roles") + .len(), + 3 + ); + let mut input = FakeInput { + value: b"fictional-token-123".to_vec(), + ..FakeInput::default() + }; + let (result, output) = fixture.set("tinybird.auction", &["--stdin", "--yes"], &mut input); + assert_eq!( + result.expect("should create reviewed binding"), + RunOutcome::Success + ); + assert!(output.contains("created binding")); + assert!(!output.contains("fictional-token-123")); + assert_eq!(input.reads, 1); + let entry = plan["entries"] + .as_array() + .expect("should list roles") + .iter() + .find(|entry| entry["compiled"]["descriptor_id"] == "tinybird.auction") + .expect("should have Tinybird"); + let key = entry["compiled"]["key"] + .as_str() + .expect("should have generated key"); + assert_eq!( + fixture + .transport + .values + .borrow() + .get(key) + .map(String::as_str), + Some("ZmljdGlvbmFsLXRva2VuLTEyMw==") + ); + let (result, output) = fixture.set("tinybird.auction", &["--stdin", "--yes"], &mut input); + result.expect("should replace the same deterministic key"); + assert!(output.contains("replaced existing binding")); + assert_eq!(fixture.transport.values.borrow().len(), 1); + assert_eq!( + fixture.transport.mutation_paths.borrow().as_slice(), + [ + "/resources/stores/secret/fictional-secret-store/secrets", + "/resources/stores/secret/fictional-secret-store/secrets" + ] + ); + assert_eq!( + fixture + .transport + .methods + .borrow() + .iter() + .filter(|method| method.as_str() == "POST") + .count(), + 1 + ); + assert_eq!( + fixture + .transport + .methods + .borrow() + .iter() + .filter(|method| method.as_str() == "PATCH") + .count(), + 1 + ); +} + +#[test] +fn secrets_cli_tampering_stale_config_versions_overlay_and_resources_precede_input() { + for mutation in [ + "plan_version", + "binding_abi", + "native_mapping_version", + "environment_overlay", + "effective_config_sha256", + "resources", + "entries", + ] { + let fixture = Fixture::new(); + let mut plan = fixture.plan(); + plan[mutation] = json!("tampered"); + fs::write( + fixture.temp.path().join("plan.json"), + serde_json::to_vec(&plan).expect("should encode mutation"), + ) + .expect("should write mutated plan"); + let mut input = FakeInput { + value: b"fictional-token-123".to_vec(), + ..FakeInput::default() + }; + assert!( + fixture + .set("tinybird.auction", &["--stdin", "--yes"], &mut input) + .0 + .is_err() + ); + assert_eq!(input.reads, 0); + assert!(fixture.transport.mutation_paths.borrow().is_empty()); + } + let fixture = Fixture::new(); + fixture.plan(); + fs::write( + fixture.temp.path().join("trusted-server.toml"), + CONFIG.replace("auction_events", "different_events"), + ) + .expect("should mutate target dataset"); + let mut input = FakeInput::default(); + assert!( + fixture + .set("tinybird.auction", &["--stdin", "--yes"], &mut input) + .0 + .is_err() + ); + assert_eq!(input.reads, 0); + let fixture = Fixture::new(); + fixture.plan(); + *fixture.transport.resource_id.borrow_mut() = Some("recreated-secret-store".to_owned()); + assert!( + fixture + .set("tinybird.auction", &["--stdin", "--yes"], &mut input) + .0 + .is_err() + ); + assert_eq!(input.reads, 0); + assert!(fixture.transport.mutation_paths.borrow().is_empty()); +} + +#[test] +fn secrets_cli_unsupported_direct_set_rejects_before_provider_or_input() { + let fixture = Fixture::new(); + for adapter in ["axum", "cloudflare", "spin"] { + let mut input = FakeInput::default(); + let (result, _) = fixture.run( + &[ + "set", + "tinybird.auction", + "--adapter", + adapter, + "--plan", + "does-not-exist", + "--stdin", + "--yes", + ], + &mut input, + ); + assert!( + result + .expect_err("should reject unsupported adapter") + .contains("only for Fastly") + ); + assert_eq!(input.reads, 0); + assert!(fixture.transport.methods.borrow().is_empty()); + } +} + +#[test] +fn secrets_cli_exact_invalid_bytes_and_provider_limits_never_mutate() { + let fixture = Fixture::new(); + fixture.plan(); + for value in [ + b"fictional-token\n".to_vec(), + vec![0xff], + Vec::new(), + vec![b'a'; 65_537], + ] { + let mut input = FakeInput { + value, + ..FakeInput::default() + }; + let (result, output) = fixture.set("tinybird.auction", &["--stdin", "--yes"], &mut input); + assert!(result.is_err()); + assert!(!output.contains("fictional-token")); + assert!(fixture.transport.mutation_paths.borrow().is_empty()); + } +} + +#[test] +fn secrets_cli_interactive_cancel_and_inactive_role_do_not_read_credentials() { + let fixture = Fixture::new(); + fixture.plan(); + let mut input = FakeInput { + interactive: true, + lines: vec!["no\n".to_owned()], + ..FakeInput::default() + }; + assert!(fixture.set("tinybird.auction", &[], &mut input).0.is_err()); + assert_eq!(input.reads, 0); + assert!( + fixture + .set("ec.pull", &["--stdin", "--yes"], &mut input) + .0 + .is_err() + ); + assert_eq!(input.reads, 0); + assert!(fixture.transport.mutation_paths.borrow().is_empty()); +} + +#[test] +fn secrets_cli_provider_errors_are_sanitized_without_automatic_retry() { + let fixture = Fixture::new(); + fixture.plan(); + *fixture.transport.failure.borrow_mut() = Some("reflected fictional-token-123".to_owned()); + let mut input = FakeInput { + value: b"fictional-token-123".to_vec(), + ..FakeInput::default() + }; + let (result, output) = fixture.set("tinybird.auction", &["--stdin", "--yes"], &mut input); + let error = result.expect_err("should report uncertain outcome"); + assert!(error.contains("unknown")); + assert!(!error.contains("fictional-token-123")); + assert!(!output.contains("fictional-token-123")); + assert_eq!(fixture.transport.mutation_paths.borrow().len(), 1); + assert_eq!(fixture.transport.values.borrow().len(), 1); +} + +#[test] +fn secrets_cli_all_four_native_plans_and_presence_only_checks() { + let fixture = Fixture::new(); + for adapter in ["fastly", "axum", "cloudflare", "spin"] { + let (result, output) = + fixture.run(&["plan", "--adapter", adapter], &mut FakeInput::default()); + result.expect("should render actual native mapping"); + let plan: Value = serde_json::from_str(&output).expect("should emit native plan"); + let entries = plan["entries"].as_array().expect("should have entries"); + assert_eq!(entries.len(), 3); + let name = entries[0]["native"]["native_name"] + .as_str() + .expect("should render native identity"); + match adapter { + "axum" => { + assert!(name.starts_with("TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_TS1_")) + } + "spin" => { + assert_eq!(name.len(), 92); + assert_eq!( + entries[0]["native"]["declarations"] + .as_array() + .expect("should have declarations") + .len(), + 2 + ); + } + _ => assert!(name.starts_with("ts1_")), + } + fs::write(fixture.temp.path().join("native-plan.json"), output).expect("should save plan"); + let path = fixture + .temp + .path() + .join("native-plan.json") + .display() + .to_string(); + let (result, output) = fixture.run( + &["check", "--adapter", adapter, "--plan", &path], + &mut FakeInput::default(), + ); + assert!(output.contains("Presence only")); + if matches!(adapter, "cloudflare" | "spin") { + assert!( + result + .expect_err("should distinguish unsupported presence check") + .contains("unsupported") + ); + } else { + assert_eq!( + result.expect("should distinguish missing binding"), + RunOutcome::AssertionFailed + ); + } + } + assert!(fixture.transport.mutation_paths.borrow().is_empty()); +} + +#[test] +fn secrets_cli_repeated_role_requires_reviewed_entry_id_not_array_order() { + let fixture = Fixture::new(); + fs::write(fixture.temp.path().join("trusted-server.toml"), format!("{CONFIG}\n[[handlers]]\npath='^/one'\nusername='one'\n[[handlers]]\npath='^/two'\nusername='two'\n")) + .expect("should add repeated implicit purpose"); + let plan = fixture.plan(); + let mut input = FakeInput { + value: b"fictional-handler-password".to_vec(), + ..FakeInput::default() + }; + let error = fixture + .set("handler.password", &["--stdin", "--yes"], &mut input) + .0 + .expect_err("should not choose first repeated role"); + assert!(error.contains("--binding")); + assert_eq!(input.reads, 0); + let entry = plan["entries"] + .as_array() + .expect("should list entries") + .iter() + .filter(|entry| entry["compiled"]["descriptor_id"] == "handler.password") + .nth(1) + .expect("should have second handler"); + let raw_key = entry["compiled"]["key"] + .as_str() + .expect("should have generated key"); + assert!( + fixture + .set( + "handler.password", + &["--stdin", "--yes", "--binding", raw_key], + &mut input + ) + .0 + .is_err() + ); + assert_eq!(input.reads, 0); + let id = entry["entry_id"] + .as_str() + .expect("should have generated entry ID"); + fixture + .set( + "handler.password", + &["--stdin", "--yes", "--binding", id], + &mut input, + ) + .0 + .expect("should write exact selected handler binding"); + assert_eq!(input.reads, 1); + assert!( + fixture.transport.values.borrow().contains_key( + entry["compiled"]["key"] + .as_str() + .expect("should have derived key") + ) + ); +} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 05b4779f8f..67db9da8b5 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -48,6 +48,9 @@ enum Command { Rollback(RollbackArgs), /// Serve the project locally through a target adapter. Serve(ServeArgs), + /// Review implicit credential bindings and provision approved native secrets. + #[command(subcommand)] + Secrets(crate::commands::secrets::SecretsCommand), /// Local developer tools (e.g. the macOS/Linux production-hostname proxy). #[command(subcommand)] Dev(crate::commands::dev::DevCommand), @@ -169,6 +172,7 @@ fn dispatch(args: Args) -> Result { } Command::Rollback(args) => edgezero_cli::run_rollback(&args).map(|()| RunOutcome::Success), Command::Serve(args) => edgezero_cli::run_serve(&args).map(|()| RunOutcome::Success), + Command::Secrets(command) => crate::commands::secrets::run(&command), Command::Dev(command) => crate::commands::dev::run(command).map(|()| RunOutcome::Success), Command::Cache(command) => { let stdout = std::io::stdout(); @@ -195,6 +199,80 @@ mod tests { Args::try_parse_from(args).expect("should parse args") } + #[test] + fn secrets_commands_parse_without_value_or_raw_key_overrides() { + for args in [ + vec!["ts", "secrets", "plan", "--adapter", "spin"], + vec![ + "ts", + "secrets", + "check", + "--adapter", + "fastly", + "--plan", + "bindings.json", + ], + vec![ + "ts", + "secrets", + "set", + "tinybird.auction", + "--adapter", + "fastly", + "--plan", + "bindings.json", + "--stdin", + "--yes", + ], + ] { + assert!(matches!(parse(&args).command, Command::Secrets(_))); + } + for flag in ["--value", "--key", "--store", "--target", "--deployment"] { + assert!( + Args::try_parse_from([ + "ts", + "secrets", + "set", + "tinybird.auction", + "--adapter", + "fastly", + "--plan", + "bindings.json", + flag, + "fictional-input" + ]) + .is_err() + ); + } + assert!( + Args::try_parse_from([ + "ts", + "secrets", + "set", + "tinybird.auction", + "--adapter", + "fastly", + "--plan", + "bindings.json", + "--stdin" + ]) + .is_err() + ); + } + + #[test] + fn secrets_help_describes_existence_limits_and_secure_input() { + let check = Args::try_parse_from(["ts", "secrets", "check", "--help"]) + .expect_err("should display check help") + .to_string(); + assert!(check.contains("never runtime credential validity")); + let set = Args::try_parse_from(["ts", "secrets", "set", "--help"]) + .expect_err("should display set help") + .to_string(); + assert!(set.contains("without trimming")); + assert!(set.contains("Tool-generated entry ID")); + } + #[test] fn top_level_version_flag_is_available() { let err = Args::try_parse_from(["ts", "--version"]) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..d94b8f8c31 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -693,6 +693,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: crate::settings::EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(crate::redacted::Redacted::new(format!( "token-{source_domain}-32-bytes-minimum-value" ))), diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index f8a439c6ee..e5eb28d922 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -8,7 +8,9 @@ use std::borrow::Cow; -use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; +use edgezero_core::app_config::SecretField; +#[cfg(test)] +use edgezero_core::app_config::SecretPathSegment; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator::{Validate, ValidationError, ValidationErrors}; @@ -32,7 +34,9 @@ use crate::integrations::{ sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; +#[cfg(test)] +use crate::settings::AssetOriginAuth; +use crate::settings::{IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -101,7 +105,9 @@ impl Serialize for TrustedServerAppConfig { where S: Serializer, { - self.settings.serialize(serializer) + unresolved_settings_value(&self.settings) + .map_err(serde::ser::Error::custom)? + .serialize(serializer) } } @@ -110,10 +116,24 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { where D: Deserializer<'de>, { - let mut settings = Settings::deserialize(deserializer)?; + let value = serde_json::Value::deserialize(deserializer)?; + if value + .pointer("/auction/providers") + .is_some_and(serde_json::Value::is_array) + { + return Err(serde::de::Error::custom( + "auction.providers: removed list schema; see CHANGELOG.md for migration guidance", + )); + } + crate::secret_bindings::reject_removed_references(&value) + .and_then(|()| crate::secret_bindings::validate_original_endpoints(&value)) + .map_err(|error| serde::de::Error::custom(error.to_string()))?; + let mut settings: Settings = serde_json::from_value(value).map_err(|_| { + serde::de::Error::custom("invalid Trusted Server configuration structure") + })?; settings .normalize_deserialized() - .map_err(serde::de::Error::custom)?; + .map_err(|_| serde::de::Error::custom("invalid Trusted Server configuration"))?; Ok(Self { settings }) } } @@ -137,119 +157,58 @@ impl Validate for TrustedServerAppConfig { impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { fn secret_fields() -> Vec { - let field = |path: Vec, optional| SecretField { - kind: SecretKind::KeyInDefault, - optional, - path, - }; - let object = |name: &'static str| SecretPathSegment::Field(Cow::Borrowed(name)); - let optional_object = - |name: &'static str| SecretPathSegment::OptionalField(Cow::Borrowed(name)); - - vec![ - field(vec![object("publisher"), object("proxy_secret")], false), - field(vec![object("ec"), object("passphrase")], false), - field( - vec![ - object("ec"), - optional_object("partners"), - SecretPathSegment::ArrayEach, - object("api_token"), - ], - true, - ), - field( - vec![ - object("ec"), - optional_object("partners"), - SecretPathSegment::ArrayEach, - object("ts_pull_token"), - ], - true, - ), - field( - vec![ - object("handlers"), - SecretPathSegment::ArrayEach, - object("password"), - ], - false, - ), - field( - vec![ - optional_object("trusted_client_ip"), - object("shared_secret"), - ], - false, - ), - field( - vec![optional_object("tinybird"), object("auction_token_secret")], - true, - ), - field( - vec![ - optional_object("integrations"), - optional_object("datadome"), - object("server_side_key_secret_name"), - ], - true, - ), - field( - vec![ - optional_object("integrations"), - optional_object("datadome"), - optional_object("protection_test_bypass"), - object("credential_secret_name"), - ], - true, - ), - field( - vec![ - optional_object("proxy"), - optional_object("asset_routes"), - SecretPathSegment::ArrayEach, - optional_object("auth"), - object("access_key_id"), - ], - true, - ), - field( - vec![ - optional_object("proxy"), - optional_object("asset_routes"), - SecretPathSegment::ArrayEach, - optional_object("auth"), - object("secret_access_key"), - ], - true, - ), - field( - vec![ - optional_object("proxy"), - optional_object("asset_routes"), - SecretPathSegment::ArrayEach, - optional_object("auth"), - object("session_token"), - ], - true, - ), - ] + crate::secret_bindings::secret_fields() + } + + fn implicit_secret_lookups( + &self, + ) -> Result>, String> { + let plan = crate::secret_bindings::compile_bindings(&self.settings) + .map_err(|error| error.to_string())?; + Ok(Some( + plan.entries() + .iter() + .map(|entry| edgezero_core::app_config::ImplicitSecretLookup { + descriptor_id: entry.descriptor_id().to_owned(), + field_path: entry.field_path().to_owned(), + store_id: crate::secret_bindings::SECRET_STORE_ID.to_owned(), + key: entry.key().to_owned(), + }) + .collect(), + )) + } + + fn render_implicit_secret_name( + &self, + adapter: &str, + lookup: &edgezero_core::app_config::ImplicitSecretLookup, + environment: &edgezero_core::env_config::EnvConfig, + ) -> Result { + let mapped_store = environment.store_name("secrets", &lookup.store_id); + crate::secret_bindings::render_native_with_mapping( + adapter, + &lookup.store_id, + &lookup.key, + &mapped_store, + ) + .map(|binding| binding.native_name) + .map_err(|error| error.to_string()) } } /// Runs Trusted Server push-time validation for app config. /// -/// Secret fields contain secret-store key names at this stage, so this function -/// deliberately excludes checks that require resolved values. The `EdgeZero` CLI -/// additionally calls [`edgezero_core::app_config::validate_excluding_secrets`] -/// to remove validators attached to those leaves. +/// Feature configuration implies credential roles, but contains no references or +/// values. This function compiles those roles and validates ordinary settings. +/// `EdgeZero` excludes resolved-value validators using the shared descriptor +/// metadata, then validates generated native names through the implicit hook. /// /// # Errors /// -/// Returns [`TrustedServerError`] when non-secret configuration or a secret key -/// reference is invalid. +/// Returns [`TrustedServerError`] when ordinary configuration or an implicit +/// credential context is invalid. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { - validate_secret_key_references(settings)?; + crate::secret_bindings::compile_bindings(settings)?; validate_non_secret_deploy_placeholders(settings)?; validate_js_asset_proxy_config(settings)?; @@ -263,7 +222,7 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report Result<(), Report> { - validate_secret_key_reference( - "publisher.proxy_secret", - settings.publisher.proxy_secret.expose(), - )?; - validate_secret_key_reference("ec.passphrase", settings.ec.passphrase.expose())?; - - for (index, partner) in settings.ec.partners.iter().enumerate() { - if let Some(token) = &partner.api_token { - validate_secret_key_reference( - &format!("ec.partners[{index}].api_token"), - token.expose(), - )?; - } - if let Some(token) = &partner.ts_pull_token { - validate_secret_key_reference( - &format!("ec.partners[{index}].ts_pull_token"), - token.expose(), - )?; - } - } - - for (index, handler) in settings.handlers.iter().enumerate() { - validate_secret_key_reference( - &format!("handlers[{index}].password"), - handler.password.expose(), - )?; - } - - if let Some(trusted_client_ip) = &settings.trusted_client_ip { - validate_secret_key_reference( - "trusted_client_ip.shared_secret", - trusted_client_ip.shared_secret.expose(), - )?; - } - - if settings.tinybird.enabled { - let token = settings - .tinybird - .auction_token_secret - .as_ref() - .ok_or_else(|| missing_secret_key_reference("tinybird.auction_token_secret"))?; - validate_secret_key_reference("tinybird.auction_token_secret", token.expose())?; - } - - if let Some(datadome) = settings.integration_config::("datadome")? { - if datadome.enable_protection { - let key = datadome - .server_side_key_secret_name - .as_ref() - .ok_or_else(|| { - missing_secret_key_reference( - "integrations.datadome.server_side_key_secret_name", - ) - })?; - validate_secret_key_reference( - "integrations.datadome.server_side_key_secret_name", - key.expose(), - )?; - } +/// Serialize only unresolved feature configuration, never resolved credentials. +/// +/// # Errors +/// Returns a safe configuration error if settings cannot be represented as JSON. +pub fn unresolved_settings_value( + settings: &Settings, +) -> Result> { + let mut value = serde_json::to_value(settings).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "failed to serialize unresolved configuration".to_owned(), + }) + })?; + if let Some(datadome) = value + .pointer_mut("/integrations/datadome") + .and_then(serde_json::Value::as_object_mut) + { + datadome.remove("server_side_key_secret_name"); if let Some(bypass) = datadome - .protection_test_bypass - .as_ref() - .filter(|bypass| bypass.enabled) + .get_mut("protection_test_bypass") + .and_then(serde_json::Value::as_object_mut) { - let credential = bypass.credential_secret_name.as_ref().ok_or_else(|| { - missing_secret_key_reference( - "integrations.datadome.protection_test_bypass.credential_secret_name", - ) - })?; - validate_secret_key_reference( - "integrations.datadome.protection_test_bypass.credential_secret_name", - credential.expose(), - )?; - } - } - - for (index, route) in settings.proxy.asset_routes.iter().enumerate() { - let Some(AssetOriginAuth::S3SigV4(auth)) = route.auth.as_ref() else { - continue; - }; - validate_secret_key_reference( - &format!("proxy.asset_routes[{index}].auth.access_key_id"), - auth.access_key_id.expose(), - )?; - validate_secret_key_reference( - &format!("proxy.asset_routes[{index}].auth.secret_access_key"), - auth.secret_access_key.expose(), - )?; - if let Some(token) = &auth.session_token { - validate_secret_key_reference( - &format!("proxy.asset_routes[{index}].auth.session_token"), - token.expose(), - )?; + bypass.remove("credential_secret_name"); } } - - Ok(()) -} - -fn validate_secret_key_reference( - path: &str, - key_name: &str, -) -> Result<(), Report> { - if key_name.trim().is_empty() { - return Err(missing_secret_key_reference(path)); - } - Ok(()) -} - -fn missing_secret_key_reference(path: &str) -> Report { - Report::new(TrustedServerError::Configuration { - message: format!("secret key reference at `{path}` must not be empty"), - }) + Ok(value) } fn report_to_validation_error( @@ -527,7 +397,33 @@ mod tests { use crate::auction_config_types::{NotificationConfig, ProviderConfig, RoutingMode}; use crate::redacted::Redacted; use crate::settings::{ProxyAssetRoute, S3SigV4AuthConfig, TrustedClientIpConfig}; - use crate::test_support::tests::crate_test_settings_str; + use crate::test_support::tests::crate_test_settings_str as resolved_test_settings_str; + + fn crate_test_settings_str() -> String { + let mut value: toml::Table = + toml::from_str(&resolved_test_settings_str()).expect("should parse resolved fixture"); + value + .get_mut("publisher") + .and_then(toml::Value::as_table_mut) + .expect("should find publisher") + .remove("proxy_secret"); + value + .get_mut("ec") + .and_then(toml::Value::as_table_mut) + .expect("should find EC") + .remove("passphrase"); + for handler in value + .get_mut("handlers") + .and_then(toml::Value::as_array_mut) + .expect("should find handlers") + { + handler + .as_table_mut() + .expect("should find handler") + .remove("password"); + } + toml::to_string(&value).expect("should encode feature-only fixture") + } use edgezero_core::app_config::AppConfigMeta; use edgezero_core::blob_envelope::BlobEnvelope; @@ -581,7 +477,7 @@ formats = [{ width = 300, height = 250 }] fn valid_settings() -> Settings { let mut settings = - Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + Settings::from_toml(&resolved_test_settings_str()).expect("should parse test settings"); settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; settings } @@ -671,22 +567,21 @@ formats = [{ width = 300, height = 250 }] "/../../trusted-server.example.toml" )); - /// Returns the template with required secret-store key references replaced - /// by resolved test values, so direct [`Settings`] parsing can exercise the - /// optional blocks this module uncomments. + /// Supply resolved local-comparison shadows only for direct Settings tests. + /// The public template itself contains no credential properties. fn template_with_resolved_required_secrets() -> String { EXAMPLE_TEMPLATE .replace( - "password = \"handler_password\"", - "password = \"unit-test-resolved-handler-password-0001\"", + "\n[[handlers]]\n", + "\n[[handlers]]\npassword = \"fictional-handler-password-0123456789\"\n", ) .replace( - "proxy_secret = \"publisher_proxy_secret\"", - "proxy_secret = \"unit-test-resolved-publisher-proxy-secret-0001\"", + "\n[publisher]\n", + "\n[publisher]\nproxy_secret = \"fictional-publisher-secret-0123456789\"\n", ) .replace( - "passphrase = \"ec_passphrase\"", - "passphrase = \"unit-test-resolved-ec-passphrase-secret-0001\"", + "\n[ec]\n", + "\n[ec]\npassphrase = \"fictional-ec-passphrase-0123456789-abcdef\"\n", ) } @@ -710,7 +605,18 @@ formats = [{ width = 300, height = 250 }] .strip_prefix("# ") .or_else(|| line.strip_prefix('#')) .unwrap_or(line); - out.push(bare.to_owned()); + // Explanatory comments beside the example remain comments. + let assignment = bare.split_once('=').is_some_and(|(key, _)| { + !key.trim().is_empty() + && key.trim().bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.') + }) + }); + if bare.trim_start().starts_with('[') || assignment { + out.push(bare.to_owned()); + } else { + out.push(line.to_owned()); + } } else { out.push(line.to_owned()); } @@ -840,18 +746,9 @@ formats = [{ width = 300, height = 250 }] .expect_err("should reject colliding app-config keys"); for message in [toml_error.to_string(), json_error.to_string()] { - for expected in [ - "image_optimizer.profile_sets", - "default_images", - "collide after trimming", - &format!("{first:?}"), - &format!("{second:?}"), - ] { - assert!( - message.contains(expected), - "should preserve collision context with {expected:?}: {message}" - ); - } + assert!(message.contains("invalid Trusted Server configuration")); + assert!(!message.contains(&format!("{first:?}"))); + assert!(!message.contains(&format!("{second:?}"))); } } } @@ -879,7 +776,7 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn push_validation_accepts_secret_key_names() { + fn push_validation_ignores_resolved_shadows_and_never_serializes_them() { let mut settings = valid_settings(); settings.publisher.proxy_secret = Redacted::new("publisher_proxy".to_owned()); settings.ec.passphrase = Redacted::new("ec_key".to_owned()); @@ -890,8 +787,9 @@ formats = [{ width = 300, height = 250 }] let serialized = serde_json::to_string(&app_config).expect("should serialize key-name-only app config"); - assert!(serialized.contains("publisher_proxy")); + assert!(!serialized.contains("publisher_proxy")); assert!(!serialized.contains("unit-test-proxy-secret")); + assert!(!serialized.contains("proxy_secret")); } #[test] @@ -957,16 +855,16 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn omitted_s3_secret_references_materialize_as_defaults() { + fn s3_credentials_are_implicit_and_optional_session_defaults_off() { let auth: S3SigV4AuthConfig = - toml::from_str("region = \"us-east-1\"").expect("should apply S3 secret defaults"); - - assert_eq!(auth.access_key_id.expose(), "access_key_id"); - assert_eq!(auth.secret_access_key.expose(), "secret_access_key"); - + toml::from_str("region = \"us-east-1\"").expect("should parse feature-only S3 auth"); + assert!(auth.access_key_id.expose().is_empty()); + assert!(auth.secret_access_key.expose().is_empty()); + assert!(!auth.session_token_enabled); let serialized = serde_json::to_value(auth).expect("should serialize S3 auth"); - assert_eq!(serialized["access_key_id"], "access_key_id"); - assert_eq!(serialized["secret_access_key"], "secret_access_key"); + assert!(serialized.get("access_key_id").is_none()); + assert!(serialized.get("secret_access_key").is_none()); + assert!(serialized.get("session_token").is_none()); } #[test] @@ -997,6 +895,7 @@ formats = [{ width = 300, height = 250 }] access_key_id: Redacted::new("s3-access-key".to_string()), secret_access_key: Redacted::new("s3-secret-key".to_string()), session_token: None, + session_token_enabled: false, origin_query: None, })); settings.proxy.asset_routes.push(route); @@ -1124,21 +1023,25 @@ gam_network_id = "99999" } #[test] - fn app_config_new_rejects_empty_secret_key_reference() { + fn app_config_requires_no_secret_reference_or_value() { let mut settings = valid_settings(); - settings.publisher.proxy_secret = Redacted::new(String::new()); - - let err = TrustedServerAppConfig::new(settings) - .expect_err("should reject an empty secret key reference"); - - assert!( - err.to_string().contains("publisher.proxy_secret"), - "error should identify the empty secret reference: {err:?}" - ); + settings.publisher.proxy_secret = Redacted::default(); + settings.ec.passphrase = Redacted::default(); + for handler in &mut settings.handlers { + handler.password = Redacted::default(); + } + let config = TrustedServerAppConfig::new(settings) + .expect("should validate unresolved implicit credentials"); + let lookups = config + .implicit_secret_lookups() + .expect("should compile implicit lookups") + .expect("should opt in"); + assert_eq!(lookups.len(), 4); + assert!(lookups.iter().all(|lookup| lookup.key.starts_with("ts1_"))); } #[test] - fn app_config_new_accepts_trusted_client_ip_secret_key_reference() { + fn app_config_compiles_trusted_client_ip_credential_without_serializing_shadow() { let mut settings = valid_settings(); settings.trusted_client_ip = Some(TrustedClientIpConfig { ip_header: "x-ts-client-ip".to_owned(), @@ -1151,17 +1054,17 @@ gam_network_id = "99999" } #[test] - fn app_config_new_rejects_whitespace_secret_key_reference() { - let mut settings = valid_settings(); - settings.publisher.proxy_secret = Redacted::new(" \t ".to_owned()); - - let err = TrustedServerAppConfig::new(settings) - .expect_err("should reject a whitespace-only secret key reference"); - - assert!( - err.to_string().contains("publisher.proxy_secret"), - "error should identify the whitespace-only secret reference: {err:?}" - ); + fn raw_config_rejects_reference_properties_even_when_empty() { + let source: serde_json::Value = + serde_json::to_value(valid_settings()).expect("should serialize feature config"); + for value in ["", " \t ", "fictional-native-secret-key"] { + let mut source = source.clone(); + source["publisher"]["proxy_secret"] = serde_json::json!(value); + let error = serde_json::from_value::(source) + .expect_err("should reject removed reference property"); + assert!(error.to_string().contains("publisher.proxy_secret")); + assert!(!error.to_string().contains("fictional-native-secret-key")); + } } #[test] @@ -1540,35 +1443,36 @@ password = "production-admin-password-32-bytes" } #[test] - fn deploy_validation_rejects_invalid_datadome_test_bypass() { - for (enable_protection, name, expected_message) in [ - (false, "datadome_test_bypass", "requires enable_protection"), - (true, "", "credential_secret_name"), - ] { - let mut settings = valid_settings(); - settings - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": enable_protection, - "server_side_key_secret_name": "datadome_server_side_key", - "protection_test_bypass": { - "enabled": true, - "credential_secret_name": name, - }, - }), - ) - .expect("should insert DataDome config"); - - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject invalid DataDome test bypass"); - assert!( - format!("{err:?}").contains(expected_message), - "error should mention the invalid bypass setting: {err:?}" - ); - } + fn deploy_validation_checks_bypass_activation_without_credential_references() { + let mut settings = valid_settings(); + settings.integrations.insert( + "datadome".to_owned(), + serde_json::json!({ + "enabled": true, "enable_protection": false, + "protection_test_bypass": {"enabled": true} + }), + ); + let error = validate_settings_for_deploy(&settings) + .expect_err("should reject bypass without Protection"); + assert!(format!("{error:?}").contains("requires enable_protection")); + settings + .integrations + .get_mut("datadome") + .expect("should find integration")["enable_protection"] = serde_json::json!(true); + validate_settings_for_deploy(&settings) + .expect("should validate implicit DataDome roles without reading credentials"); + let plan = crate::secret_bindings::compile_bindings(&settings) + .expect("should compile implicit roles"); + assert!( + plan.entries() + .iter() + .any(|entry| entry.descriptor_id() == "datadome.server") + ); + assert!( + plan.entries() + .iter() + .any(|entry| entry.descriptor_id() == "datadome.bypass") + ); } #[test] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index c9690b01f5..179ea2696e 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -1,9 +1,6 @@ -//! Runtime helpers for Trusted Server blob app-config payloads. -//! -//! The `ts` CLI delegates blob construction and config-store writes to -//! `EdgeZero`'s typed config push path. Runtime loading only needs to verify the -//! stored [`edgezero_core::blob_envelope::BlobEnvelope`] and reconstruct -//! [`Settings`] from its data value. +//! Verified app-config loading and atomic native credential snapshots. + +use std::ops::Deref; use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; @@ -11,858 +8,496 @@ use error_stack::Report; use crate::config::TrustedServerAppConfig; use crate::error::TrustedServerError; use crate::platform::{PlatformSecretStore, StoreName}; -use crate::secret_resolution::resolve_secret_references; -use crate::settings::Settings; +use crate::redacted::Redacted; +use crate::secret_bindings::{ResolvedBinding, resolve_bindings}; +use crate::settings::{AssetOriginAuth, Settings}; -/// Canonical logical secret store used by Trusted Server app-config secrets. +/// Logical native store for app credentials. Physical mapping is deployment-owned. pub const DEFAULT_SECRET_STORE_ID: &str = "trusted_server_secrets"; - -/// Default logical config-store id, from `[stores.config].default` in `edgezero.toml`. -/// -/// Derived at build time so every adapter uses the repository manifest's default. +/// Default logical config-store ID, derived from the portable manifest. pub const DEFAULT_CONFIG_STORE_ID: &str = env!("TRUSTED_SERVER_DEFAULT_CONFIG_STORE_ID"); - -/// Default config-store key containing the Trusted Server app-config blob. -/// -/// Intentionally matches the logical store ID: an ordinary `ts config push` -/// writes there unless `--key` selects another key. This constant does not apply -/// runtime overrides; use [`crate::settings_data::config_key`] with the adapter's -/// runtime configuration, or [`crate::settings_data::default_config_key`] for -/// process-environment overrides. +/// Config blob entry shared by production, staging, and local logical mappings. pub const CONFIG_BLOB_KEY: &str = DEFAULT_CONFIG_STORE_ID; -/// Reconstruct runtime [`Settings`] from a serialized config blob envelope. +/// An atomically loaded settings and credential/target snapshot. /// -/// Secret references are resolved after envelope verification and before -/// deserialization. The envelope data itself is never mutated or rewritten. +/// Only successful production loading constructs this type. It deliberately has +/// no serialization or mutable access. Cloned settings retain the same private +/// capabilities; exporters recheck their compiled identity before using them. +#[derive(Clone, Debug)] +pub struct LoadedConfig { + settings: Settings, +} + +impl LoadedConfig { + /// Read the settings belonging to this successful snapshot. + #[must_use] + pub fn settings(&self) -> &Settings { + &self.settings + } +} + +impl Deref for LoadedConfig { + type Target = Settings; + + fn deref(&self) -> &Self::Target { + &self.settings + } +} + +fn invalid(path: &str, category: &str) -> Report { + Report::new(TrustedServerError::Configuration { + message: format!("{path}: {category}"), + }) +} + +/// Verify feature configuration, resolve its implicit native bindings, and +/// publish settings and compiled credential uses together. /// /// # Errors -/// -/// Returns [`TrustedServerError::Configuration`] when the envelope cannot be -/// parsed, fails integrity verification, secret resolution fails, or resolved -/// settings are invalid. +/// Rejects invalid envelopes, removed reference properties, malformed feature +/// configuration, missing/invalid native bindings, or invalid resolved settings. +/// Diagnostics never attach provider errors, values, or untrusted input snippets. pub fn settings_from_config_blob( envelope_json: &str, secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, -) -> Result> { - let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to parse Trusted Server app-config blob envelope".to_string(), - }) - .attach(error.to_string()) - })?; - envelope.verify().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "Trusted Server app-config blob failed integrity verification".to_string(), - }) - .attach(error.to_string()) - })?; - - let mut data = envelope.into_data(); - remove_inactive_secret_references(&mut data); - resolve_secret_references::( - &mut data, - secret_store, - default_secret_store_name, - )?; - let settings = Settings::from_json_value(data)?; - crate::config::validate_settings_for_runtime(&settings)?; - Ok(settings) +) -> Result> { + let envelope: BlobEnvelope = serde_json::from_str(envelope_json) + .map_err(|_| invalid("trusted_server", "invalid config envelope"))?; + envelope + .verify() + .map_err(|_| invalid("trusted_server", "config integrity verification failed"))?; + let config: TrustedServerAppConfig = serde_json::from_value(envelope.into_data()) + .map_err(|error| invalid("trusted_server", &error.to_string()))?; + edgezero_core::app_config::validate_excluding_secrets(&config) + .map_err(|_| invalid("trusted_server", "invalid unresolved configuration"))?; + + let mut settings = config.into_settings(); + let bindings = resolve_bindings(&settings, secret_store, default_secret_store_name)?; + for binding in bindings.entries() { + install_shadow(&mut settings, binding)?; + } + settings.resolved_bindings = bindings; + let settings = Settings::finalize_deserialized(settings, "Configuration") + .map_err(|_| invalid("trusted_server", "invalid resolved configuration"))?; + crate::config::validate_settings_for_runtime(&settings) + .map_err(|_| invalid("trusted_server", "invalid resolved configuration"))?; + Ok(LoadedConfig { settings }) } -fn remove_inactive_secret_references(data: &mut serde_json::Value) { - if data - .pointer("/tinybird/enabled") - .and_then(serde_json::Value::as_bool) - != Some(true) - && let Some(tinybird) = data - .get_mut("tinybird") - .and_then(serde_json::Value::as_object_mut) - { - tinybird.remove("auction_token_secret"); - tinybird.remove("access_token_secret"); - } +fn index(path: &str, prefix: &str) -> Result> { + path.strip_prefix(prefix) + .and_then(|suffix| suffix.split_once(']')) + .and_then(|(index, _)| index.parse().ok()) + .ok_or_else(|| invalid("secret_bindings", "invalid compiled schema path")) +} - if let Some(partners) = data - .pointer_mut("/ec/partners") - .and_then(serde_json::Value::as_array_mut) - { - for partner in partners { - let Some(partner) = partner.as_object_mut() else { - continue; +// These shadows support existing local validators and non-exporting consumers. +// They are never an outbound authority and cannot appear in serialized config. +fn install_shadow( + settings: &mut Settings, + binding: &ResolvedBinding, +) -> Result<(), Report> { + let compiled = binding.compiled(); + let value = || Redacted::new(binding.credential().to_owned()); + match compiled.descriptor_id() { + "publisher.proxy" => settings.publisher.proxy_secret = value(), + "ec.passphrase" => settings.ec.passphrase = value(), + "ec.inbound" | "ec.pull" => { + let partner = settings + .ec + .partners + .get_mut(index(compiled.field_path(), "ec.partners[")?) + .ok_or_else(|| invalid("ec.partners", "invalid compiled identity"))?; + if compiled.descriptor_id() == "ec.inbound" { + partner.api_token = Some(value()); + } else { + partner.ts_pull_token = Some(value()); + } + } + "handler.password" => { + settings + .handlers + .get_mut(index(compiled.field_path(), "handlers[")?) + .ok_or_else(|| invalid("handlers", "invalid compiled identity"))? + .password = value(); + } + "clientip.shared" => { + settings + .trusted_client_ip + .as_mut() + .ok_or_else(|| invalid("trusted_client_ip", "invalid compiled identity"))? + .shared_secret = value(); + } + "tinybird.auction" => settings.tinybird.auction_token_secret = Some(value()), + // DataDome consumes private uses directly. Never copy native values into + // its serializable, dynamically typed integration configuration. + "datadome.server" | "datadome.bypass" => {} + "s3.access-id" | "s3.secret" | "s3.session" => { + let route = settings + .proxy + .asset_routes + .get_mut(index(compiled.field_path(), "proxy.asset_routes[")?) + .ok_or_else(|| invalid("proxy.asset_routes", "invalid compiled identity"))?; + let Some(AssetOriginAuth::S3SigV4(auth)) = route.auth.as_mut() else { + return Err(invalid("proxy.asset_routes", "invalid compiled identity")); }; - if !json_bool_or_string_is_true(partner.get("pull_sync_enabled")) { - partner.remove("ts_pull_token"); + match compiled.descriptor_id() { + "s3.access-id" => auth.access_key_id = value(), + "s3.secret" => auth.secret_access_key = value(), + _ => auth.session_token = Some(value()), } } + _ => return Err(invalid("secret_bindings", "unknown compiled purpose")), } - - let Some(datadome) = data - .pointer_mut("/integrations/datadome") - .and_then(serde_json::Value::as_object_mut) - else { - return; - }; - let integration_enabled = - datadome.get("enabled").and_then(serde_json::Value::as_bool) == Some(true); - let protection_enabled = integration_enabled - && datadome - .get("enable_protection") - .and_then(serde_json::Value::as_bool) - == Some(true); - if !protection_enabled { - datadome.remove("server_side_key_secret_name"); - } - - let bypass_enabled = protection_enabled - && datadome - .get("protection_test_bypass") - .and_then(serde_json::Value::as_object) - .and_then(|bypass| bypass.get("enabled")) - .and_then(serde_json::Value::as_bool) - == Some(true); - if !bypass_enabled - && let Some(bypass) = datadome - .get_mut("protection_test_bypass") - .and_then(serde_json::Value::as_object_mut) - { - bypass.remove("credential_secret_name"); - } -} - -fn json_bool_or_string_is_true(value: Option<&serde_json::Value>) -> bool { - matches!(value, Some(serde_json::Value::Bool(true))) - || matches!(value, Some(serde_json::Value::String(value)) if value == "true") + Ok(()) } #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use std::sync::Mutex; + use super::*; - use crate::integrations::didomi::DidomiIntegrationConfig; use crate::platform::{PlatformError, StoreId}; - use crate::redacted::Redacted; - use crate::settings::{ - AssetOriginAuth, EcPartner, ProxyAssetRoute, S3SigV4AuthConfig, TrustedClientIpConfig, - }; + use crate::secret_bindings::compile_bindings; use crate::test_support::tests::crate_test_settings_str; - 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 - } - - struct EchoSecretStore; - - impl PlatformSecretStore for EchoSecretStore { - fn get_bytes( - &self, - _store_name: &StoreName, - key: &str, - ) -> Result, Report> { - let value = match key { - "placeholder_proxy" => "change-me-proxy-secret", - "unit-test-proxy-secret" => "unit-test-proxy-secret-32-bytes-ok", - _ => key, - }; - Ok(value.as_bytes().to_vec()) + #[derive(Default)] + struct Store { + values: BTreeMap>, + reads: Mutex>, + } + + impl PlatformSecretStore for Store { + fn get_bytes(&self, _: &StoreName, key: &str) -> Result, Report> { + self.reads + .lock() + .expect("should lock read spy") + .push(key.to_owned()); + self.values.get(key).cloned().ok_or_else(|| { + Report::new(PlatformError::SecretStore) + .attach("fictional-provider-diagnostic-must-not-leak") + }) } - - fn create( - &self, - _store_id: &StoreId, - _name: &str, - _value: &str, - ) -> Result<(), Report> { - Ok(()) + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + unreachable!("loader never writes native credentials") } - - fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { - Ok(()) + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + unreachable!("loader never deletes native credentials") } } - struct UnifiedSecretStore; - - impl PlatformSecretStore for UnifiedSecretStore { - fn get_bytes( - &self, - store_name: &StoreName, - key: &str, - ) -> Result, Report> { - if store_name.as_ref() != "ts_secrets" || key.starts_with("unused-") { - return Err(Report::new(PlatformError::SecretStore)); - } - let value = match key { - "unit-test-proxy-secret" => "unit-test-proxy-secret-32-bytes-ok", - "tinybird-token-key" => "resolved-tinybird-token", - "datadome-server-key" => "resolved-datadome-server-key", - "datadome-bypass-key" => "resolved-datadome-bypass-credential-32-bytes", - "access_key_id" | "s3-access-key" => "AKIAIOSFODNN7EXAMPLE", - "secret_access_key" | "s3-secret-key" => "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - "s3-session-key" => "resolved-session-token", - "partner-api-token-key" => "resolved-partner-api-token-32-bytes-ok", - "partner-pull-token-key" => "resolved-partner-pull-token-32-bytes-ok", - "trusted-client-ip-key" => "resolved-trusted-client-ip-secret-32-bytes", - _ => key, + fn fixture() -> (serde_json::Value, Store) { + let mut settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse resolved unit settings"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + let data = crate::config::unresolved_settings_value(&settings) + .expect("should serialize feature config"); + let mut store = Store::default(); + for entry in compile_bindings(&settings) + .expect("should compile roles") + .entries() + { + let value = match entry.scope() { + "proxy" => "fictional-proxy-credential-0123456789", + "ecpass" => "fictional-passphrase-credential-0123456789", + "handler" => "fictional-handler-credential-0123456789", + _ => panic!("unexpected active fixture scope"), }; - Ok(value.as_bytes().to_vec()) - } - - fn create( - &self, - _store_id: &StoreId, - _name: &str, - _value: &str, - ) -> Result<(), Report> { - Ok(()) + store + .values + .insert(entry.key().to_owned(), value.as_bytes().to_vec()); } - - fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { - Ok(()) - } - } - - fn partner_with_pull_sync(enabled: bool, token_key: &str) -> EcPartner { - let mut value = serde_json::json!({ - "name": "Example Partner", - "source_domain": "partner.example.com", - "api_token": "partner-api-token-key", - "pull_sync_enabled": enabled, - "ts_pull_token": token_key, - }); - if enabled { - value["pull_sync_url"] = - serde_json::Value::String("https://partner.example.com/sync".to_string()); - value["pull_sync_allowed_domains"] = serde_json::json!(["partner.example.com"]); - } - serde_json::from_value(value).expect("should build pull-sync partner") - } - - 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()); - serde_json::to_string(&envelope).expect("should serialize envelope") - } - - fn load_settings(envelope_json: &str) -> Result> { - settings_from_config_blob( - envelope_json, - &EchoSecretStore, - &StoreName::from("trusted_server_secrets"), - ) + (data, store) } - fn settings_with_browser_bidder_overlap(auction_enabled: bool) -> Settings { - let mut settings = test_settings(); - settings.proxy.allowed_domains = vec!["*.example".to_string()]; - settings.auction.enabled = auction_enabled; - settings.auction.providers = crate::auction::AuctionConfig::legacy_provider_map(&["pbs"]); - settings.auction.bidders.insert( - "exampleBidder" - .parse() - .expect("should parse server-side bidder"), - crate::auction::BidderRouteConfig { - provider: "pbs".parse().expect("should parse provider"), - }, - ); - let mut prebid = settings - .integration_config::("prebid") - .expect("should parse Prebid config") - .expect("should have enabled Prebid config"); - prebid.client_side_bidders = vec!["exampleBidder".to_string()]; - settings - .integrations - .insert_config("prebid", &prebid) - .expect("should replace Prebid config"); - settings - } - - #[test] - fn payload_round_trips_through_blob_envelope() { - let original = test_settings(); - let reconstructed = - load_settings(&envelope_json(&original)).expect("should reconstruct settings"); - - assert_eq!( - reconstructed.publisher.domain, original.publisher.domain, - "should preserve publisher domain" - ); - assert_eq!( - reconstructed.ec.pull_sync_concurrency, original.ec.pull_sync_concurrency, - "should preserve numeric fields" - ); - assert_eq!( - reconstructed.handlers.len(), - original.handlers.len(), - "should preserve arrays" - ); - } - - #[test] - fn didomi_geo_query_parameters_survive_blob_round_trip() { - let mut original = test_settings(); - original - .integrations - .insert_config( - "didomi", - &DidomiIntegrationConfig { - enabled: true, - geo_query_parameters: true, - proxy_path: None, - sdk_origin: "https://sdk.example.com".to_string(), - api_origin: "https://api.example.com".to_string(), - }, - ) - .expect("should insert Didomi configuration"); - - let reconstructed = - load_settings(&envelope_json(&original)).expect("should reconstruct settings"); - let config = reconstructed - .integration_config::("didomi") - .expect("should read Didomi configuration") - .expect("should enable Didomi"); - - assert!( - config.geo_query_parameters, - "should preserve Didomi geo opt-in" - ); - } - - #[test] - fn resolves_all_static_credentials_from_the_mapped_default_store() { - let mut original = test_settings(); - original.tinybird.enabled = true; - original.tinybird.api_host = "api.example.com".to_string(); - original.tinybird.auction_token_secret = - Some(Redacted::new("tinybird-token-key".to_string())); - original - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": true, - "server_side_key_secret_name": "datadome-server-key", - "protection_test_bypass": { - "enabled": true, - "credential_secret_name": "datadome-bypass-key", - }, - }), - ) - .expect("should configure DataDome references"); - let mut route = ProxyAssetRoute::new( - "/assets/", - "https://examplebucket.s3.us-east-1.amazonaws.com", - ); - route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { - region: "us-east-1".to_string(), - secret_store: Some("legacy-s3-store".to_string()), - access_key_id: Redacted::new("s3-access-key".to_string()), - secret_access_key: Redacted::new("s3-secret-key".to_string()), - session_token: Some(Redacted::new("s3-session-key".to_string())), - origin_query: None, - })); - original.proxy.asset_routes.push(route); - original - .ec - .partners - .push(partner_with_pull_sync(true, "partner-pull-token-key")); - - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should resolve every static credential from the mapped store"); - - assert_eq!( - reconstructed - .tinybird - .auction_token_secret - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-tinybird-token") - ); - let datadome = reconstructed - .integration_config::("datadome") - .expect("should parse DataDome config") - .expect("should enable DataDome"); - assert_eq!( - datadome - .server_side_key_secret_name - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-datadome-server-key") - ); - let bypass = datadome - .protection_test_bypass - .as_ref() - .expect("should configure bypass"); - assert_eq!( - bypass - .credential_secret_name - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-datadome-bypass-credential-32-bytes") - ); - let auth = reconstructed.proxy.asset_routes[0] - .auth - .as_ref() - .expect("should preserve S3 auth"); - let AssetOriginAuth::S3SigV4(auth) = auth; - assert_eq!(auth.access_key_id.expose(), "AKIAIOSFODNN7EXAMPLE"); - assert_eq!( - auth.secret_access_key.expose(), - "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" - ); - assert_eq!( - auth.session_token - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-session-token") - ); - assert!(auth.secret_store.is_none()); - assert_eq!( - reconstructed.ec.partners[0] - .api_token - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-partner-api-token-32-bytes-ok") - ); - assert_eq!( - reconstructed.ec.partners[0] - .ts_pull_token - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-partner-pull-token-32-bytes-ok") - ); - } - - #[test] - fn resolves_trusted_client_ip_shared_secret_from_default_store() { - let mut original = test_settings(); - original.trusted_client_ip = Some(TrustedClientIpConfig { - ip_header: "x-ts-client-ip".to_owned(), - auth_header: "x-ts-client-ip-auth".to_owned(), - shared_secret: Redacted::new("trusted-client-ip-key".to_owned()), - }); - - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should resolve the trusted client IP shared secret"); - - assert_eq!( - reconstructed - .trusted_client_ip - .as_ref() - .expect("should retain trusted client IP configuration") - .shared_secret - .expose(), - "resolved-trusted-client-ip-secret-32-bytes", - "should replace the key reference with the resolved shared secret" - ); - } - - #[test] - fn missing_trusted_client_ip_shared_secret_fails_resolution() { - let mut original = test_settings(); - original.trusted_client_ip = Some(TrustedClientIpConfig { - ip_header: "x-ts-client-ip".to_owned(), - auth_header: "x-ts-client-ip-auth".to_owned(), - shared_secret: Redacted::new("unused-trusted-client-ip-key".to_owned()), - }); - - let error = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect_err("should reject a missing trusted client IP shared secret"); - let message = error.to_string(); - - assert!( - message.contains("trusted_client_ip.shared_secret"), - "should identify the unresolved secret field: {error:?}" - ); - assert!( - !message.contains("unused-trusted-client-ip-key"), - "should not expose the secret key reference: {error:?}" - ); + fn envelope(data: serde_json::Value) -> String { + serde_json::to_string(&BlobEnvelope::new(data, "2026-10-26T00:00:00Z".to_owned())) + .expect("should encode a valid-checksum envelope") } - #[test] - fn omitted_s3_secret_references_resolve_default_store_keys() { - let mut original = test_settings(); - let mut route = ProxyAssetRoute::new( - "/default-s3/", - "https://examplebucket.s3.us-east-1.amazonaws.com", - ); + fn all_roles_fixture() -> (serde_json::Value, Store) { + let mut settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse resolved unit settings"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + settings.ec.partners = vec![ + serde_json::from_value(serde_json::json!({ + "name": "Fictional partner", "source_domain": "partner.example.com", + "api_token_enabled": true, "pull_sync_enabled": true, + "pull_sync_url": "https://sync.example.com/pull", + "pull_sync_allowed_domains": ["sync.example.com"] + })) + .expect("should parse partner features"), + ]; + settings.trusted_client_ip = Some( + serde_json::from_value(serde_json::json!({ + "ip_header": "x-fictional-client-ip", "auth_header": "x-fictional-auth" + })) + .expect("should parse trusted client IP features"), + ); + settings.tinybird.enabled = true; + settings.tinybird.api_host = "api.tinybird.example.com".to_owned(); + settings.integrations.insert( + "datadome".to_owned(), + serde_json::json!({ + "enabled": true, "enable_protection": true, + "sdk_origin": "https://sdk.example.com", "api_origin": "https://api.example.com", + "protection_api_origin": "https://protection.example.com", + "protection_test_bypass": {"enabled": true} + }), + ); + let mut route = + crate::settings::ProxyAssetRoute::new("/assets/", "https://bucket.example.com"); route.auth = Some(AssetOriginAuth::S3SigV4( - toml::from_str("region = \"us-east-1\"").expect("should apply S3 secret defaults"), + serde_json::from_value(serde_json::json!({ + "region": "us-east-1", "session_token_enabled": true + })) + .expect("should parse S3 features"), )); - original.proxy.asset_routes.push(route); - - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should resolve default S3 secret keys"); - - let AssetOriginAuth::S3SigV4(auth) = reconstructed.proxy.asset_routes[0] - .auth - .as_ref() - .expect("should preserve S3 auth"); - assert_eq!(auth.access_key_id.expose(), "AKIAIOSFODNN7EXAMPLE"); - assert_eq!( - auth.secret_access_key.expose(), - "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" - ); - } - - #[test] - fn partner_without_api_token_loads_without_secret_resolution() { - let mut original = test_settings(); - let partner = serde_json::from_value(serde_json::json!({ - "name": "Example Partner", - "source_domain": "partner.example.com", - "bidstream_enabled": true, - })) - .expect("should build partner without API token"); - original.ec.partners.push(partner); - - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should load partner without resolving an API token"); - - assert!( - reconstructed.ec.partners[0].api_token.is_none(), - "should preserve omitted API token" - ); - } - - #[test] - fn string_true_pull_sync_flag_retains_and_resolves_its_token() { - let mut original = test_settings(); - original - .ec - .partners - .push(partner_with_pull_sync(true, "partner-pull-token-key")); - let mut data = serde_json::to_value(original).expect("should serialize settings"); - data["ec"]["partners"][0]["pull_sync_enabled"] = - serde_json::Value::String("true".to_owned()); - let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); - let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); - - let reconstructed = settings_from_config_blob( - &envelope_json, - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), + settings.proxy.asset_routes = vec![route]; + let mut store = Store::default(); + for entry in compile_bindings(&settings) + .expect("should compile all roles") + .entries() + { + store.values.insert( + entry.key().to_owned(), + format!("fictional-credential-{}-{}", entry.scope(), entry.key()).into_bytes(), + ); + } + ( + crate::config::unresolved_settings_value(&settings) + .expect("should serialize feature config"), + store, ) - .expect("should resolve a pull token enabled by a string boolean"); - - assert_eq!( - reconstructed.ec.partners[0] - .ts_pull_token - .as_ref() - .map(Redacted::expose) - .map(String::as_str), - Some("resolved-partner-pull-token-32-bytes-ok"), - "should retain and resolve the active pull token" - ); } - #[test] - fn string_false_pull_sync_flag_removes_a_stale_token() { - let mut original = test_settings(); - original - .ec - .partners - .push(partner_with_pull_sync(false, "unused-partner-pull-token")); - let mut data = serde_json::to_value(original).expect("should serialize settings"); - data["ec"]["partners"][0]["pull_sync_enabled"] = - serde_json::Value::String("false".to_owned()); - let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); - let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); - - let reconstructed = settings_from_config_blob( - &envelope_json, - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), + fn feature_pointer(field_path: &str) -> String { + format!( + "/{}", + field_path + .replace('[', "/") + .replace(']', "") + .replace('.', "/") ) - .expect("should skip a pull token disabled by a string boolean"); - - assert!( - reconstructed.ec.partners[0].ts_pull_token.is_none(), - "should remove the inactive pull token" - ); } #[test] - fn active_partner_pull_sync_fails_when_its_token_is_missing() { - let mut original = test_settings(); - original - .ec - .partners - .push(partner_with_pull_sync(true, "unused-partner-pull-token")); - - let error = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect_err("should reject a missing active pull-sync token"); - - assert!(error.to_string().contains("ec.partners[0].ts_pull_token")); - } - - #[test] - fn inactive_optional_features_do_not_resolve_stale_secret_references() { - let mut original = test_settings(); - original.tinybird.auction_token_secret = - Some(Redacted::new("unused-tinybird-key".to_string())); - original - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enabled": true, - "enable_protection": false, - "server_side_key_secret_name": "unused-datadome-key", - "protection_test_bypass": { - "enabled": false, - "credential_secret_name": "unused-bypass-key", - }, - }), - ) - .expect("should configure inactive references"); - original - .ec - .partners - .push(partner_with_pull_sync(false, "unused-partner-pull-token")); - - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should skip inactive optional feature references"); - - assert!(reconstructed.tinybird.auction_token_secret.is_none()); - assert!(reconstructed.ec.partners[0].ts_pull_token.is_none()); - let datadome = reconstructed - .integration_config::("datadome") - .expect("should parse inactive DataDome config") - .expect("client-side DataDome remains enabled"); - assert!(datadome.server_side_key_secret_name.is_none()); + fn secret_scoping_attacker_all_twelve_roles_load_through_verified_envelope() { + let (data, store) = all_roles_fixture(); + let loaded = + settings_from_config_blob(&envelope(data), &store, &DEFAULT_SECRET_STORE_ID.into()) + .expect("should load every implicit role through production reconstruction"); + let scopes: std::collections::BTreeSet<_> = loaded + .resolved_bindings + .entries() + .iter() + .map(|binding| binding.compiled().scope()) + .collect(); + assert_eq!(scopes.len(), 12); + let output = serde_json::to_string(loaded.settings()) + .expect("should serialize feature settings without native values"); + assert!(!output.contains("fictional-credential-")); assert!( - datadome - .protection_test_bypass - .as_ref() - .is_some_and(|bypass| bypass.credential_secret_name.is_none()) + store + .reads + .lock() + .expect("should lock spy") + .iter() + .all(|key| key.starts_with("ts1_")) ); } #[test] - fn omitted_datadome_enabled_does_not_resolve_stale_protection_references() { - let mut original = test_settings(); - original - .integrations - .insert_config( - "datadome", - &serde_json::json!({ - "enable_protection": true, - "server_side_key_secret_name": "unused-datadome-key", - "protection_test_bypass": { - "enabled": true, - "credential_secret_name": "unused-bypass-key", - }, - }), + fn secret_scoping_attacker_reference_injection_rejects_all_twelve_roles_before_reads() { + let (data, _) = all_roles_fixture(); + let config: TrustedServerAppConfig = + serde_json::from_value(data.clone()).expect("should parse feature fixture"); + let plan = compile_bindings(config.settings()).expect("should compile fixture"); + for entry in plan.entries() { + let (_, store) = all_roles_fixture(); + let mut attack = data.clone(); + let pointer = feature_pointer(entry.field_path()); + let (parent, field) = pointer.rsplit_once('/').expect("should split field path"); + attack + .pointer_mut(parent) + .and_then(serde_json::Value::as_object_mut) + .expect("should find role owner") + .insert( + field.to_owned(), + serde_json::json!("SENTINEL-unrelated-publisher-secret-key"), + ); + let error = settings_from_config_blob( + &envelope(attack), + &store, + &DEFAULT_SECRET_STORE_ID.into(), ) - .expect("should configure disabled DataDome references"); - - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should skip stale DataDome protection references"); - - assert!( - reconstructed - .integration_config::("datadome") - .expect("should parse disabled DataDome config") - .is_none() - ); - } - - #[test] - fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { - let data = - serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); - let auction = data - .get("auction") - .and_then(serde_json::Value::as_object) - .expect("should serialize auction settings as an object"); - assert!( - !auction.contains_key("rewrite_creatives"), - "should omit the default rewrite setting from the payload" - ); - let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); - let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); - - let reconstructed = - load_settings(&envelope_json).expect("should reconstruct legacy settings"); - - assert!( - reconstructed.auction.rewrite_creatives, - "should enable creative rewriting for legacy blobs" - ); - } - - #[test] - fn disabled_rewrite_creatives_survives_blob_round_trip() { - let mut original = test_settings(); - original.auction.rewrite_creatives = false; - - let reconstructed = load_settings(&envelope_json(&original)) - .expect("should reconstruct disabled rewriting"); - - assert!( - !reconstructed.auction.rewrite_creatives, - "should preserve the explicit rewrite opt-out" - ); + .expect_err("should reject config-only reference injection"); + assert!(store.reads.lock().expect("should lock spy").is_empty()); + assert!(!format!("{error:?}").contains("SENTINEL-unrelated-publisher")); + } } #[test] - fn strings_that_look_like_json_scalars_round_trip_as_strings() { - let mut original = test_settings(); - original.publisher.proxy_secret = - Redacted::new("12345678901234567890123456789012".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); - original.handlers[0].password = Redacted::new("true".to_string()); - - let reconstructed = - load_settings(&envelope_json(&original)).expect("should reconstruct settings"); - - assert_eq!( - reconstructed.publisher.proxy_secret.expose(), - original.publisher.proxy_secret.expose(), - "numeric-looking proxy secret should remain a string" - ); - assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), - "numeric-looking passphrase should remain a string" - ); - assert_eq!( - reconstructed.handlers[0].password.expose(), - original.handlers[0].password.expose(), - "boolean-looking handler password should remain a string" - ); + fn secret_scoping_attacker_retargeting_never_looks_up_the_old_credential() { + for (pointer, value, descriptor, role_path) in [ + ( + "/ec/partners/0/pull_sync_url", + "https://attacker.example.com/pull", + "ec.pull", + "ec.partners[0].ts_pull_token", + ), + ( + "/ec/partners/0/source_domain", + "other.example.com", + "ec.inbound", + "ec.partners[0].api_token", + ), + ( + "/handlers/0/username", + "other-user", + "handler.password", + "handlers[0].password", + ), + ( + "/tinybird/api_host", + "attacker.example.com", + "tinybird.auction", + "tinybird.auction_token_secret", + ), + ( + "/tinybird/auction_dataset", + "other_dataset", + "tinybird.auction", + "tinybird.auction_token_secret", + ), + ( + "/integrations/datadome/protection_api_origin", + "https://attacker.example.com", + "datadome.server", + "integrations.datadome.server_side_key_secret_name", + ), + ( + "/proxy/asset_routes/0/origin_url", + "https://attacker.example.com", + "s3.access-id", + "proxy.asset_routes[0].auth.access_key_id", + ), + ( + "/proxy/asset_routes/0/auth/region", + "eu-west-1", + "s3.access-id", + "proxy.asset_routes[0].auth.access_key_id", + ), + ] { + let (mut data, store) = all_roles_fixture(); + let config: TrustedServerAppConfig = + serde_json::from_value(data.clone()).expect("should parse feature fixture"); + let plan = compile_bindings(config.settings()).expect("should compile fixture"); + let original_key = plan + .entries() + .iter() + .find(|entry| { + entry.descriptor_id() == descriptor && entry.field_path() == role_path + }) + .expect("should find original role") + .key() + .to_owned(); + *data + .pointer_mut(pointer) + .expect("should find mutable feature") = serde_json::json!(value); + if descriptor == "ec.pull" { + data["ec"]["partners"][0]["pull_sync_allowed_domains"] = + serde_json::json!(["attacker.example.com"]); + } + let _ = + settings_from_config_blob(&envelope(data), &store, &DEFAULT_SECRET_STORE_ID.into()) + .expect_err( + "should reject new unprovisioned authorization despite valid checksum", + ); + assert!( + !store + .reads + .lock() + .expect("should lock spy") + .contains(&original_key) + ); + } } #[test] - fn runtime_validation_accepts_short_resolved_proxy_secret() { - let mut settings = test_settings(); - settings.publisher.proxy_secret = Redacted::new("short_proxy".to_owned()); - - let reconstructed = load_settings(&envelope_json(&settings)) - .expect("should accept an existing short proxy secret"); - + fn implicit_bindings_publish_one_nonserializable_snapshot() { + let (data, store) = fixture(); + let before = data.clone(); + let loaded = + settings_from_config_blob(&envelope(data), &store, &DEFAULT_SECRET_STORE_ID.into()) + .expect("should load implicit native bindings"); assert_eq!( - reconstructed.publisher.proxy_secret.expose(), - "short_proxy", - "should preserve the resolved proxy secret" + loaded.publisher.proxy_secret.expose(), + "fictional-proxy-credential-0123456789" ); - } - - #[test] - fn runtime_blob_rejects_enabled_browser_bidder_ownership_conflict() { - let original = settings_with_browser_bidder_overlap(true); - let error = load_settings(&envelope_json(&original)) - .expect_err("should reject enabled browser bidder ownership conflict"); - - assert!(error.to_string().contains("exampleBidder")); - assert!( - error - .to_string() - .contains("both client-side and server-side") - ); - } - - #[test] - fn runtime_validation_rejects_short_resolved_passphrase() { - let mut settings = test_settings(); - settings.ec.passphrase = Redacted::new("short_key".to_owned()); - - let err = load_settings(&envelope_json(&settings)) - .expect_err("should reject a short resolved passphrase"); - - assert!( - err.to_string().contains("short_passphrase") || err.to_string().contains("validation"), - "error should indicate runtime validation: {err:?}" - ); - assert!( - !err.to_string().contains("short_key"), - "error should not expose the secret value" - ); - } - - #[test] - fn placeholder_rejection_happens_after_secret_resolution() { - let mut settings = test_settings(); - settings.publisher.proxy_secret = Redacted::new("placeholder_proxy".to_owned()); - - let err = load_settings(&envelope_json(&settings)) - .expect_err("should reject a placeholder resolved from the secret store"); - assert!( - err.to_string().contains("Insecure default"), - "error should identify the insecure default: {err:?}" + store + .reads + .lock() + .expect("should lock read spy") + .iter() + .all(|key| key.starts_with("ts1_")) ); - assert!( - !err.to_string().contains("change-me-proxy-secret"), - "error should not expose the resolved secret value" + assert_eq!( + crate::config::unresolved_settings_value(&loaded).expect("should serialize features"), + before ); + let output = serde_json::to_string(loaded.settings()) + .expect("should serialize only feature settings"); + assert!(!output.contains("fictional-proxy-credential")); + assert!(!output.contains("fictional-handler-credential")); } #[test] - fn runtime_blob_accepts_disabled_browser_bidder_ownership_overlap() { - let original = settings_with_browser_bidder_overlap(false); - - load_settings(&envelope_json(&original)) - .expect("runtime should accept disabled browser bidder ownership overlap"); + fn removed_references_reject_before_even_the_first_lookup() { + for path in ["/publisher/proxy_secret", "/ec/passphrase"] { + let (mut data, store) = fixture(); + let (parent, field) = path.rsplit_once('/').expect("should split fixture path"); + data.pointer_mut(parent) + .expect("should find object") + .as_object_mut() + .expect("should be object") + .insert( + field.to_owned(), + serde_json::json!("fictional-sensitive-reference"), + ); + let error = + settings_from_config_blob(&envelope(data), &store, &DEFAULT_SECRET_STORE_ID.into()) + .expect_err("should reject removed reference"); + assert!(store.reads.lock().expect("should lock read spy").is_empty()); + assert!(!format!("{error:?}").contains("fictional-sensitive-reference")); + } } #[test] - fn tampered_blob_hash_is_rejected() { - let mut envelope: BlobEnvelope = - serde_json::from_str(&envelope_json(&test_settings())).expect("should parse envelope"); - envelope.sha256 = "ff".repeat(32); - let tampered = - serde_json::to_string(&envelope).expect("should serialize tampered envelope"); - - let err = load_settings(&tampered).expect_err("should reject hash mismatch"); - + fn failed_load_does_not_fall_back_or_publish_partial_values() { + let (data, mut store) = fixture(); + store.values.clear(); + store.values.insert( + "publisher_proxy_secret".to_owned(), + b"unrelated-legacy-value".to_vec(), + ); + let error = + settings_from_config_blob(&envelope(data), &store, &DEFAULT_SECRET_STORE_ID.into()) + .expect_err("should reject missing generated binding"); assert!( - err.to_string().contains("integrity verification"), - "error should mention integrity verification" - ); + store + .reads + .lock() + .expect("should lock read spy") + .iter() + .all(|key| key.starts_with("ts1_")) + ); + let diagnostic = format!("{error:?}"); + assert!(!diagnostic.contains("fictional-provider-diagnostic")); + assert!(!diagnostic.contains("unrelated-legacy-value")); } } diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index e21fab702c..c58c31625b 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -698,6 +698,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled, + api_token_enabled: true, api_token: Some(Redacted::new(format!("test-token-{source_domain:-<32}"))), batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, diff --git a/crates/trusted-server-core/src/ec/auth.rs b/crates/trusted-server-core/src/ec/auth.rs index b7db6f1fa8..a6a0bdfaea 100644 --- a/crates/trusted-server-core/src/ec/auth.rs +++ b/crates/trusted-server-core/src/ec/auth.rs @@ -52,6 +52,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(api_token.to_owned())), batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, @@ -130,6 +131,7 @@ mod tests { fn authenticate_bearer_rejects_token_for_partner_without_api_access() { let mut partner = make_test_partner("ssp.example.com", VALID_API_TOKEN); partner.api_token = None; + partner.api_token_enabled = false; let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry without token"); let req = Request::builder() diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 4903589b29..ddfcfcf91b 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -401,6 +401,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(api_token.to_owned())), batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1359f75091..e3f66ea84a 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -154,6 +154,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(format!( "token-{source_domain}-32-bytes-minimum-value" ))), diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 019ef2aa40..71ffccf738 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -607,6 +607,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(format!( "token-{source_domain}-32-bytes-minimum-value" ))), @@ -1178,8 +1179,14 @@ mod tests { partner.pull_sync_enabled = true; partner.pull_sync_url = Some("https://sync.example.com/pull".to_owned()); partner.pull_sync_allowed_domains = vec!["sync.example.com".to_owned()]; - partner.ts_pull_token = Some(Redacted::new("pull-token".to_owned())); - let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry"); + partner.ts_pull_token = Some(Redacted::new( + "fictional-pull-token-with-32-bytes-minimum".to_owned(), + )); + let mut settings = settings; + settings.ec.partners = vec![partner]; + crate::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should resolve fixture bindings"); + let registry = PartnerRegistry::from_settings(&settings).expect("should build registry"); let graph = KvIdentityGraph::in_memory("test_store"); let mut response = empty_response(); @@ -2303,8 +2310,14 @@ mod tests { partner.pull_sync_enabled = true; partner.pull_sync_url = Some("https://sync.example.com/pull".to_owned()); partner.pull_sync_allowed_domains = vec!["sync.example.com".to_owned()]; - partner.ts_pull_token = Some(Redacted::new("pull-token".to_owned())); - let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry"); + partner.ts_pull_token = Some(Redacted::new( + "fictional-pull-token-with-32-bytes-minimum".to_owned(), + )); + let mut settings = settings; + settings.ec.partners = vec![partner]; + crate::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should resolve fixture bindings"); + let registry = PartnerRegistry::from_settings(&settings).expect("should build registry"); let mut ec_context = make_context( Some(&ec_id), Some(&ec_id), diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 94464d1a57..a5f7a122c6 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -367,6 +367,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(api_token.to_owned())), batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index d823cb32fb..ebaa30ba81 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -352,6 +352,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(format!( "token-{source_domain}-32-bytes-minimum-value" ))), diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 191e6c6d24..e90a02eea8 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -109,10 +109,6 @@ pub fn build_pull_sync_context( /// costs no KV operation, and the fresh snapshot is reused for the write-back /// instead of the stale one. /// -/// # Panics -/// -/// Panics if the HTTP request builder produces an invalid request, which -/// cannot happen with the hardcoded method and well-formed URI used here. pub fn dispatch_pull_sync( settings: &Settings, kv: &KvIdentityGraph, @@ -216,11 +212,12 @@ pub fn dispatch_pull_sync( } } - let Some(token) = partner.ts_pull_token.as_ref() else { - log::warn!( - "Pull sync: partner '{}' enabled but missing ts_pull_token", - partner.source_domain - ); + let Some(binding) = partner.pull_binding.as_ref() else { + log::warn!("Pull sync: loaded credential binding unavailable"); + continue; + }; + let Some(auth_header) = binding.bearer_header() else { + log::warn!("Pull sync: invalid credential representation"); continue; }; @@ -229,44 +226,44 @@ pub fn dispatch_pull_sync( let host = request_url.host_str().unwrap_or_default().to_string(); let port = request_url.port(); + let request = match http::Request::builder() + .method(Method::GET) + .uri(request_url.as_str()) + .header(header::AUTHORIZATION, auth_header.clone()) + .body(EdgeBody::empty()) + { + Ok(request) => request, + Err(_) => { + log::warn!("Pull sync: request preparation failed"); + continue; + } + }; + let backend_name = match services.backend().ensure(&PlatformBackendSpec { scheme, host, port, host_header_override: None, - certificate_check: settings.proxy.certificate_check, + certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, discriminator: None, }) { Ok(name) => name, - Err(err) => { - log::warn!( - "Pull sync: failed to resolve backend for partner '{}': {err:?}", - partner.source_domain - ); + Err(_) => { + log::warn!("Pull sync: backend resolution failed"); continue; } }; - let request = http::Request::builder() - .method(Method::GET) - .uri(request_url.as_str()) - .header("authorization", format!("Bearer {}", token.expose())) - .body(EdgeBody::empty()) - .expect("should build pull sync request"); - let pending = match futures::executor::block_on( services .http_client() .send_async(PlatformHttpRequest::new(request, backend_name)), ) { Ok(pending) => pending, - Err(err) => { - log::warn!( - "Pull sync: failed to dispatch partner '{}': {err:?}", - partner.source_domain - ); + Err(_) => { + log::warn!("Pull sync: dispatch failed"); continue; } }; @@ -316,53 +313,9 @@ fn is_partner_pull_eligible(partner: &PartnerConfig, kv_entry: Option<&KvEntry>) } fn validated_pull_sync_url(partner: &PartnerConfig) -> Option { - let pull_sync_url = partner.pull_sync_url.as_deref()?; - let parsed = match Url::parse(pull_sync_url) { - Ok(url) => url, - Err(err) => { - log::error!( - "Pull sync: partner '{}' has invalid pull_sync_url '{}': {err}", - partner.source_domain, - pull_sync_url - ); - return None; - } - }; - - if parsed.scheme() != "https" { - log::error!( - "Pull sync: partner '{}' pull_sync_url must use HTTPS, got scheme '{}'", - partner.source_domain, - parsed.scheme() - ); - return None; - } - - let Some(hostname) = parsed.host_str() else { - log::error!( - "Pull sync: partner '{}' pull_sync_url has no hostname: {}", - partner.source_domain, - pull_sync_url - ); - return None; - }; - - let hostname = hostname.trim_end_matches('.').to_ascii_lowercase(); - if !partner.pull_sync_allowed_domains.iter().any(|domain| { - domain - .trim() - .trim_end_matches('.') - .eq_ignore_ascii_case(&hostname) - }) { - log::error!( - "Pull sync: partner '{}' URL host '{}' not in pull_sync_allowed_domains", - partner.source_domain, - hostname - ); - return None; - } - - Some(parsed) + // The registry captured this inseparable credential/target use from one load. + // Loose partner URLs, allowlists and token shadows are not export authority. + Url::parse(partner.pull_binding.as_ref()?.endpoint()?).ok() } fn build_pull_request_url(mut base_url: Url, ec_id: &str) -> Url { @@ -386,11 +339,8 @@ fn drain_pull_batch( let response = match futures::executor::block_on(services.http_client().wait(pending.pending)) { Ok(response) => response, - Err(err) => { - log::warn!( - "Pull sync: request failed for partner '{}': {err:?}", - source_domain - ); + Err(_) => { + log::warn!("Pull sync: request failed"); continue; } }; @@ -481,11 +431,8 @@ fn extract_pull_uid(response: PlatformResponse, source_domain: &str) -> Option(&body) { Ok(payload) => payload, - Err(err) => { - log::warn!( - "Pull sync: partner '{}' returned invalid JSON body: {err}", - source_domain - ); + Err(_) => { + log::warn!("Pull sync: invalid response representation"); return None; } }; @@ -559,7 +506,10 @@ mod tests { pull_sync_allowed_domains: vec!["sync.partner.test".to_owned()], pull_sync_ttl_sec: ttl_sec, pull_sync_rate_limit: 20, - ts_pull_token: Some(Redacted::new("token".to_owned())), + ts_pull_token: Some(Redacted::new( + "fictional-outbound-token-with-32-bytes".to_owned(), + )), + pull_binding: None, } } @@ -573,7 +523,7 @@ mod tests { let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); 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")]) + let registry = loaded_registry(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); let context = build_pull_sync_context(&ec_context, ®istry) @@ -592,7 +542,7 @@ mod tests { ..ConsentContext::default() }; let ec_context = EcContext::new_for_test(Some("invalid-ec".to_owned()), consent); - let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) + let registry = loaded_registry(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); let context = build_pull_sync_context(&ec_context, ®istry); @@ -612,7 +562,7 @@ mod tests { let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); 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")]) + let registry = loaded_registry(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); assert!( ec_context.ec_allowed(), @@ -637,7 +587,7 @@ mod tests { let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); let graph = KvIdentityGraph::in_memory("pull_store"); let mut snapshot = seed_present_snapshot(&graph, &ec_id); - let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) + let registry = loaded_registry(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); assert!( @@ -688,7 +638,7 @@ mod tests { #[test] fn completeness_requires_all_pull_partner_ids() { - let registry = PartnerRegistry::from_config(&[ + let registry = loaded_registry(&[ pull_enabled_ec_partner("a.example.com"), pull_enabled_ec_partner("b.example.com"), ]) @@ -738,26 +688,31 @@ mod tests { } #[test] - fn validated_pull_sync_url_accepts_normalized_allowlist_match() { - let mut partner = pull_partner(3600); - partner.pull_sync_url = Some("https://SYNC.PARTNER.TEST./pull".to_owned()); - partner.pull_sync_allowed_domains = vec!["sync.partner.test".to_owned()]; - - let validated = validated_pull_sync_url(&partner); - assert!( - validated.is_some(), - "should accept allowlist match after hostname normalization" + fn validated_pull_sync_url_uses_loaded_target_not_mutable_shadows() { + let registry = loaded_registry(&[pull_enabled_ec_partner("sync.partner.test")]) + .expect("should load partner"); + let mut partner = registry + .get("sync.partner.test") + .expect("should find partner") + .clone(); + partner.pull_sync_url = Some("https://unapproved.example/pull".to_owned()); + partner.pull_sync_allowed_domains = vec!["unapproved.example".to_owned()]; + partner.ts_pull_token = Some(Redacted::new("untrusted-shadow".to_owned())); + assert_eq!( + validated_pull_sync_url(&partner) + .expect("should retain bound endpoint") + .as_str(), + "https://sync.partner.test/sync" ); } #[test] fn build_pull_request_url_appends_ec_id() { - let url = Url::parse("https://sync.partner.test/pull?x=1").expect("should parse URL"); + let url = Url::parse("https://sync.partner.test/pull").expect("should parse URL"); let result = build_pull_request_url(url, "ecid123"); let query = result.query().expect("should have query string"); - assert!(query.contains("x=1"), "should preserve existing query"); - assert!(query.contains("ec_id=ecid123"), "should append ec_id"); + assert_eq!(query, "ec_id=ecid123", "should append exactly one ec_id"); assert!( !query.contains("ip="), "should not forward client IP to partners" @@ -895,12 +850,71 @@ mod tests { use crate::ec::kv::TombstoneOutcome; use crate::error::TrustedServerError; - use crate::platform::test_support::{StubHttpClient, build_services_with_http_client}; + use crate::platform::test_support::{ + RecordingBackend, StubHttpClient, build_services_with_http_client, + }; use crate::settings::EcPartner; use crate::test_support::tests::create_test_settings; use error_stack::Report; use std::sync::Arc; + #[test] + fn pull_secret_bindings_force_tls_and_never_follow_redirects() { + for status in [301, 302, 303, 307, 308] { + let mut settings = create_test_settings(); + settings.proxy.certificate_check = false; + let registry = loaded_registry(&[pull_enabled_ec_partner("sync.example.com")]) + .expect("should load registry"); + let graph = KvIdentityGraph::in_memory("pull_store"); + let ec_id = snapshot_ec_id(); + let snapshot = seed_present_snapshot(&graph, &ec_id); + let backend = Arc::new(RecordingBackend::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + status, + Vec::new(), + vec![("location", "https://unapproved.example/stolen")], + ); + let services = RuntimeServices::builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(backend.clone()) + .http_client(http.clone()) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build(); + dispatch_pull_sync( + &settings, + &graph, + ®istry, + &AllowAllRateLimiter, + &PullSyncContext { + ec_id: ec_id.clone(), + snapshot, + }, + &services, + ); + assert_eq!( + http.recorded_request_uris(), + vec![format!("https://sync.example.com/sync?ec_id={ec_id}")] + ); + let specs = backend.specs(); + assert_eq!(specs.len(), 1); + assert!(specs[0].certificate_check); + assert_eq!(specs[0].host_header_override, None); + assert!( + graph + .get(&ec_id) + .expect("should read row") + .expect("should have row") + .0 + .ids + .is_empty() + ); + } + } + struct AllowAllRateLimiter; impl RateLimiter for AllowAllRateLimiter { @@ -919,6 +933,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(format!( "{source_domain}-api-token-32-bytes-minimum" ))), @@ -928,10 +943,21 @@ mod tests { pull_sync_allowed_domains: vec![source_domain.to_owned()], pull_sync_ttl_sec: 3600, pull_sync_rate_limit: 100, - ts_pull_token: Some(Redacted::new("outbound-token".to_owned())), + ts_pull_token: Some(Redacted::new( + "fictional-outbound-token-with-32-bytes".to_owned(), + )), } } + fn loaded_registry( + partners: &[EcPartner], + ) -> Result> { + let mut settings = create_test_settings(); + settings.ec.partners = partners.to_vec(); + crate::test_support::secret_bindings::install_test_bindings(&mut settings)?; + PartnerRegistry::from_settings(&settings) + } + fn snapshot_ec_id() -> String { format!("{}.ABC123", "a".repeat(64)) } @@ -948,7 +974,7 @@ mod tests { let mut settings = create_test_settings(); // Force one partner per concurrency batch so responses span batches. settings.ec.pull_sync_concurrency = 1; - let registry = PartnerRegistry::from_config(&[ + let registry = loaded_registry(&[ pull_enabled_ec_partner("alpha.example.com"), pull_enabled_ec_partner("beta.example.com"), ]) @@ -1001,7 +1027,7 @@ mod tests { fn dispatch_pull_sync_calls_and_persists_only_missing_partner() { let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; - let registry = PartnerRegistry::from_config(&[ + let registry = loaded_registry(&[ pull_enabled_ec_partner("alpha.example.com"), pull_enabled_ec_partner("beta.example.com"), ]) @@ -1064,9 +1090,8 @@ mod tests { fn dispatch_pull_sync_skips_non_present_snapshots() { let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; - let registry = - PartnerRegistry::from_config(&[pull_enabled_ec_partner("alpha.example.com")]) - .expect("should build registry"); + let registry = loaded_registry(&[pull_enabled_ec_partner("alpha.example.com")]) + .expect("should build registry"); let graph = KvIdentityGraph::in_memory("pull_store"); let ec_id = snapshot_ec_id(); let stub = Arc::new(StubHttpClient::new()); @@ -1109,9 +1134,8 @@ mod tests { fn dispatch_pull_sync_skips_tombstone_snapshot() { let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; - let registry = - PartnerRegistry::from_config(&[pull_enabled_ec_partner("alpha.example.com")]) - .expect("should build registry"); + let registry = loaded_registry(&[pull_enabled_ec_partner("alpha.example.com")]) + .expect("should build registry"); let graph = KvIdentityGraph::in_memory("pull_store"); let ec_id = snapshot_ec_id(); let snapshot = EcKvSnapshot::Present { @@ -1149,9 +1173,8 @@ mod tests { // identity the user just withdrew. let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; - let registry = - PartnerRegistry::from_config(&[pull_enabled_ec_partner("alpha.example.com")]) - .expect("should build registry"); + let registry = loaded_registry(&[pull_enabled_ec_partner("alpha.example.com")]) + .expect("should build registry"); let graph = KvIdentityGraph::in_memory("pull_store"); let ec_id = snapshot_ec_id(); let snapshot = seed_present_snapshot(&graph, &ec_id); @@ -1204,9 +1227,8 @@ mod tests { // a KV operation. let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; - let registry = - PartnerRegistry::from_config(&[pull_enabled_ec_partner("alpha.example.com")]) - .expect("should build registry"); + let registry = loaded_registry(&[pull_enabled_ec_partner("alpha.example.com")]) + .expect("should build registry"); let graph = KvIdentityGraph::in_memory("pull_store"); let ec_id = snapshot_ec_id(); let mut entry = KvEntry::tombstone(1000); diff --git a/crates/trusted-server-core/src/ec/pull_sync_marker.rs b/crates/trusted-server-core/src/ec/pull_sync_marker.rs index 11df47296f..6fa87e2f4c 100644 --- a/crates/trusted-server-core/src/ec/pull_sync_marker.rs +++ b/crates/trusted-server-core/src/ec/pull_sync_marker.rs @@ -341,6 +341,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(Redacted::new(format!( "token-{source_domain}-32-bytes-minimum-value" ))), @@ -350,15 +351,19 @@ mod tests { pull_sync_allowed_domains: vec![format!("sync.{source_domain}")], pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), - ts_pull_token: Some(Redacted::new("pull-token".to_owned())), + ts_pull_token: Some(Redacted::new( + "fictional-pull-token-with-32-bytes-minimum".to_owned(), + )), } } fn settings_and_registry(domains: &[&str]) -> (Settings, PartnerRegistry) { let mut settings = create_test_settings(); settings.ec.partners = domains.iter().map(|domain| pull_partner(domain)).collect(); - let registry = PartnerRegistry::from_config(&settings.ec.partners) - .expect("should build pull partner registry"); + crate::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should resolve fixture bindings"); + let registry = + PartnerRegistry::from_settings(&settings).expect("should build pull partner registry"); (settings, registry) } diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index c4637b4310..ce9acfcc5d 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -10,7 +10,8 @@ use error_stack::{Report, ResultExt as _}; use crate::error::TrustedServerError; use crate::redacted::Redacted; -use crate::settings::EcPartner; +use crate::secret_bindings::{ResolvedBinding, normalize_pull_endpoint}; +use crate::settings::{EcPartner, Settings}; use super::partner::{hash_api_key, normalize_partner_source_domain}; @@ -45,8 +46,10 @@ pub struct PartnerConfig { pub pull_sync_ttl_sec: u64, /// Max pull sync calls per EC hash per partner per hour. pub pull_sync_rate_limit: u32, - /// Outbound bearer token for pull sync requests. + /// Redacted compatibility shadow, never outbound authorization. pub ts_pull_token: Option>, + /// Loaded credential and recipient, never manufactured from loose fields. + pub(crate) pull_binding: Option, } /// In-memory partner registry with O(1) lookups by source domain and API key hash. @@ -64,8 +67,8 @@ impl PartnerRegistry { /// Validates partner structure without inspecting secret values. /// /// This is the push-time half of partner validation. API-token length, - /// placeholder, and collision checks remain in [`Self::from_config`], - /// after secret references have been resolved. + /// placeholder, and collision checks remain in [`Self::from_settings`], + /// after the native credential bindings have been resolved. /// /// # Errors /// @@ -75,7 +78,6 @@ impl PartnerRegistry { partners: &[EcPartner], ) -> Result<(), Report> { let mut source_domains = HashSet::with_capacity(partners.len()); - let mut api_token_key_references = HashMap::with_capacity(partners.len()); for partner in partners { let normalized_source = normalize_partner_source_domain(&partner.source_domain) @@ -91,18 +93,6 @@ impl PartnerRegistry { })); } - if let Some(api_token) = &partner.api_token - && let Some(previous_source) = - api_token_key_references.insert(api_token.expose(), normalized_source.clone()) - { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "ec.partners: API token key reference is shared by source_domain \ - '{previous_source}' and '{normalized_source}'" - ), - })); - } - validate_rate_limits_values(partner.batch_rate_limit, partner.pull_sync_rate_limit) .map_err(|error| { Report::new(TrustedServerError::Configuration { @@ -113,20 +103,13 @@ impl PartnerRegistry { })?; if partner.pull_sync_enabled { - validate_pull_sync_fields( - partner.pull_sync_url.as_deref(), - &partner.pull_sync_allowed_domains, - partner - .ts_pull_token - .as_ref() - .map(|token| token.expose().as_str()), - false, - ) - .change_context(TrustedServerError::Configuration { - message: format!( - "ec.partners: pull sync config invalid for '{normalized_source}'" - ), - })?; + normalize_pull_endpoint(partner, "ec.partners.pull_sync_url").change_context( + TrustedServerError::Configuration { + message: format!( + "ec.partners: pull sync config invalid for '{normalized_source}'" + ), + }, + )?; } } @@ -141,6 +124,64 @@ impl PartnerRegistry { /// invalid source domain, duplicate source domain, duplicate API token hash, /// or invalid pull sync configuration. pub fn from_config(partners: &[EcPartner]) -> Result> { + if partners.iter().any(|partner| partner.pull_sync_enabled) { + return Err(Report::new(TrustedServerError::Configuration { + message: "ec.partners: enabled pull requires loaded credential bindings".to_owned(), + })); + } + Self::build(partners) + } + + /// Builds a registry from one validated settings/binding snapshot. + /// + /// # Errors + /// Returns an error if an enabled use has no matching loaded capability. + /// + /// # Examples + /// ``` + /// use trusted_server_core::{ec::registry::PartnerRegistry, settings::Settings}; + /// # fn inspect(settings: &Settings) { + /// let registry = PartnerRegistry::from_settings(settings); + /// # } + /// ``` + pub fn from_settings(settings: &Settings) -> Result> { + let plan = crate::secret_bindings::compile_bindings(settings)?; + let mut partners = settings.ec.partners.clone(); + for (index, partner) in partners.iter_mut().enumerate() { + partner.api_token = if partner.api_token_enabled { + let binding = settings.resolved_bindings.require_for_plan( + &plan, + "ec.inbound", + &format!("ec.partners[{index}].api_token"), + )?; + Some(Redacted::new(binding.credential().to_owned())) + } else { + None + }; + } + let mut registry = Self::build(&partners)?; + for (index, partner) in settings.ec.partners.iter().enumerate() { + if !partner.pull_sync_enabled { + continue; + } + let binding = settings.resolved_bindings.require_for_plan( + &plan, + "ec.pull", + &format!("ec.partners[{index}].ts_pull_token"), + )?; + let source = normalize_partner_source_domain(&partner.source_domain).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "ec.partners: invalid source_domain".to_owned(), + }) + })?; + if let Some(config) = registry.by_source_domain.get_mut(&source) { + config.pull_binding = Some(binding.clone()); + } + } + Ok(registry) + } + + fn build(partners: &[EcPartner]) -> Result> { let mut by_source_domain = HashMap::with_capacity(partners.len()); let mut by_api_key_hash = HashMap::with_capacity(partners.len()); @@ -158,7 +199,13 @@ impl PartnerRegistry { })); } - let api_key_hash = if let Some(api_token) = &partner.api_token { + let api_key_hash = if partner.api_token_enabled { + let api_token = partner.api_token.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "ec.partners.api_token: required enabled credential unavailable" + .to_owned(), + }) + })?; validate_api_token(&normalized_source, api_token.expose())?; let api_key_hash = hash_api_key(api_token.expose()); @@ -185,12 +232,14 @@ impl PartnerRegistry { })?; if config.pull_sync_enabled { - validate_pull_sync(&config).change_context(TrustedServerError::Configuration { - message: format!( - "ec.partners: pull sync config invalid for '{}'", - config.source_domain - ), - })?; + normalize_pull_endpoint(partner, "ec.partners.pull_sync_url").change_context( + TrustedServerError::Configuration { + message: format!( + "ec.partners: pull sync config invalid for '{}'", + config.source_domain + ), + }, + )?; } if let Some(api_key_hash) = api_key_hash { @@ -305,6 +354,7 @@ fn build_partner_config( pull_sync_ttl_sec: partner.pull_sync_ttl_sec, pull_sync_rate_limit: partner.pull_sync_rate_limit, ts_pull_token: partner.ts_pull_token.clone(), + pull_binding: None, } } @@ -333,82 +383,6 @@ fn validate_rate_limits_values( Ok(()) } -fn validate_pull_sync(config: &PartnerConfig) -> Result<(), Report> { - validate_pull_sync_fields( - config.pull_sync_url.as_deref(), - &config.pull_sync_allowed_domains, - config - .ts_pull_token - .as_ref() - .map(|token| token.expose().as_str()), - true, - ) -} - -fn validate_pull_sync_fields( - url: Option<&str>, - allowed_domains: &[String], - token_value: Option<&str>, - require_nonempty_token: bool, -) -> Result<(), Report> { - let url_str = url.unwrap_or(""); - if url_str.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: "pull_sync_url is required when pull_sync_enabled is true".to_owned(), - })); - } - - if token_value.is_none() { - return Err(Report::new(TrustedServerError::Configuration { - message: "ts_pull_token is required when pull_sync_enabled is true".to_owned(), - })); - } - - let parsed = url::Url::parse(url_str).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("pull_sync_url is not a valid URL: {e}"), - }) - })?; - - if parsed.scheme() != "https" { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "pull_sync_url must use HTTPS, got scheme '{}'", - parsed.scheme() - ), - })); - } - - let host = parsed - .host_str() - .ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: "pull_sync_url has no hostname".to_owned(), - }) - })? - .trim_end_matches('.') - .to_ascii_lowercase(); - - let domain_match = allowed_domains.iter().any(|d| { - let normalized = d.trim_end_matches('.').to_ascii_lowercase(); - host == normalized - }); - - if !domain_match { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("pull_sync_url hostname '{host}' not in pull_sync_allowed_domains"), - })); - } - - if require_nonempty_token && token_value.is_some_and(|value| value.trim().is_empty()) { - return Err(Report::new(TrustedServerError::Configuration { - message: "ts_pull_token must not be empty when pull sync is enabled".to_owned(), - })); - } - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -424,6 +398,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: false, + api_token_enabled: true, api_token: Some(Redacted::new(api_token.to_owned())), batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, @@ -477,6 +452,7 @@ mod tests { fn partner_without_api_token_is_only_indexed_by_source_domain() { let mut partner = make_partner("ssp.example.com", &valid_api_token("unused")); partner.api_token = None; + partner.api_token_enabled = false; let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry without token"); @@ -526,19 +502,15 @@ mod tests { } #[test] - fn deploy_validation_rejects_duplicate_api_token_key_references() { + fn deploy_validation_ignores_legacy_shadow_reference_equality() { let shared_key = "partner_api_token"; let partners = vec![ make_partner("first.example.com", shared_key), make_partner("second.example.com", shared_key), ]; - let error = PartnerRegistry::validate_config_for_deploy(&partners) - .expect_err("should reject duplicate API token key references"); - let message = error.to_string(); - - assert!(message.contains("first.example.com")); - assert!(message.contains("second.example.com")); + PartnerRegistry::validate_config_for_deploy(&partners) + .expect("should ignore equal credential shadows during deploy validation"); } #[test] @@ -569,20 +541,92 @@ mod tests { assert!(result.is_err(), "should reject invalid source domain"); } + fn settings_with_test_bindings(partners: &[EcPartner]) -> Settings { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.ec.partners = partners.to_vec(); + crate::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should resolve fixture bindings"); + settings + } + + fn registry_with_test_bindings(partners: &[EcPartner]) -> PartnerRegistry { + PartnerRegistry::from_settings(&settings_with_test_bindings(partners)) + .expect("should build loaded registry") + } + + #[test] + fn plain_partner_fields_cannot_enable_pull() { + let mut partner = make_partner("ssp.example.com", &valid_api_token("shadow")); + partner.api_token_enabled = false; + assert!( + PartnerRegistry::from_config(&[partner.clone()]) + .expect("should allow a partner with inbound authentication disabled") + .get("ssp.example.com") + .expect("should have partner") + .api_key_hash + .is_none() + ); + partner.pull_sync_enabled = true; + partner.pull_sync_url = Some("https://sync.example.com/pull".to_owned()); + partner.ts_pull_token = Some(Redacted::new( + "fictional-pull-token-with-32-bytes-minimum".to_owned(), + )); + assert!( + PartnerRegistry::from_config(&[partner]).is_err(), + "loose fields cannot grant export authority" + ); + } + + #[test] + fn inbound_activation_and_loaded_authority_ignore_mutable_shadow() { + let partner = make_partner("ssp.example.com", &valid_api_token("shadow")); + let mut settings = settings_with_test_bindings(&[partner]); + let registry = PartnerRegistry::from_settings(&settings).expect("should build registry"); + let expected = registry + .get("ssp.example.com") + .expect("should have partner") + .api_key_hash + .clone(); + settings.ec.partners[0].api_token = Some(Redacted::new(valid_api_token("changed-shadow"))); + let actual = + PartnerRegistry::from_settings(&settings).expect("should ignore shadow change"); + assert_eq!( + actual + .get("ssp.example.com") + .expect("should have partner") + .api_key_hash, + expected + ); + settings.resolved_bindings = Default::default(); + assert!(PartnerRegistry::from_settings(&settings).is_err()); + settings.ec.partners[0].api_token_enabled = false; + assert!( + PartnerRegistry::from_settings(&settings) + .expect("should allow disabled auth") + .get("ssp.example.com") + .expect("should have partner") + .api_key_hash + .is_none() + ); + } + #[test] fn pull_enabled_partners_filters_correctly() { let mut pull_partner = make_partner("pull.example.com", &valid_api_token("token-p")); pull_partner.api_token = None; + pull_partner.api_token_enabled = false; pull_partner.pull_sync_enabled = true; pull_partner.pull_sync_url = Some("https://pull.example.com/sync".to_owned()); pull_partner.pull_sync_allowed_domains = vec!["pull.example.com".to_owned()]; - pull_partner.ts_pull_token = Some(Redacted::new("outbound-token".to_owned())); + pull_partner.ts_pull_token = Some(Redacted::new( + "fictional-outbound-token-with-32-bytes".to_owned(), + )); let partners = vec![ make_partner("nopull.example.com", &valid_api_token("token-np")), pull_partner, ]; - let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let registry = registry_with_test_bindings(&partners); let pull_enabled = registry.pull_enabled_partners(); assert_eq!( @@ -604,7 +648,7 @@ mod tests { .as_ref() .expect("should keep pull token") .expose(), - "outbound-token", + "fictional-outbound-token-with-32-bytes", "should preserve the token without unwrapping it in the registry" ); } @@ -615,9 +659,11 @@ mod tests { partner.pull_sync_enabled = true; partner.pull_sync_url = Some("https://pull.example.com/sync".to_owned()); partner.pull_sync_allowed_domains = vec!["pull.example.com".to_owned()]; - partner.ts_pull_token = Some(Redacted::new("outbound-token".to_owned())); + partner.ts_pull_token = Some(Redacted::new( + "fictional-outbound-token-with-32-bytes".to_owned(), + )); - let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry"); + let registry = registry_with_test_bindings(&[partner]); let configured = registry .get("pull.example.com") .expect("should find configured partner"); diff --git a/crates/trusted-server-core/src/integrations/datadome.rs b/crates/trusted-server-core/src/integrations/datadome.rs index 0935b4b1a3..472cf69bbc 100644 --- a/crates/trusted-server-core/src/integrations/datadome.rs +++ b/crates/trusted-server-core/src/integrations/datadome.rs @@ -79,6 +79,7 @@ use crate::integrations::{ }; use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::redacted::Redacted; +use crate::secret_bindings::ResolvedBinding; use crate::settings::{IntegrationConfig, Settings}; mod protection; @@ -139,10 +140,8 @@ pub struct ProtectionTestBypassConfig { #[serde(default)] pub credential_secret_store: Option, - /// Secret reference containing the bypass credential. - /// - /// Holds the store key name in app config and the resolved credential at - /// runtime. Treat it as secret material after settings are built. + /// Internal resolved credential shadow. Unresolved config rejects this + /// former property; the private loaded binding authorizes bypass use. #[serde(default)] pub credential_secret_name: Option>, } @@ -184,10 +183,8 @@ pub struct DataDomeConfig { #[serde(default)] pub server_side_key_secret_store: Option, - /// Secret reference containing the `DataDome` server-side key. - /// - /// Holds the store key name in app config and the resolved key at runtime. - /// Treat it as secret material after settings are built. + /// Internal resolved credential shadow. Unresolved config rejects this + /// former property; only the private loaded binding authorizes export. #[serde(default)] pub server_side_key_secret_name: Option>, @@ -378,12 +375,47 @@ impl IntegrationConfig for DataDomeConfig { pub struct DataDomeIntegration { config: DataDomeConfig, protection_scope: ProtectionScope, + protection_binding: Option, + bypass_binding: Option, } impl DataDomeIntegration { #[cfg(test)] fn new(config: DataDomeConfig) -> Arc { - Self::try_new(config).expect("should create DataDome integration") + Self::with_test_bindings(config).expect("should create DataDome integration") + } + + #[cfg(test)] + fn with_test_bindings(config: DataDomeConfig) -> Result, Report> { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.integrations.insert_config("datadome", &serde_json::json!({ + "enabled": config.enabled, + "enable_protection": config.enable_protection, + "protection_api_origin": config.protection_api_origin, + "server_side_key_secret_name": config.server_side_key_secret_name.as_ref().map(Redacted::expose), + "protection_test_bypass": { + "enabled": config.protection_test_bypass.as_ref().is_some_and(|bypass| bypass.enabled), + "credential_secret_name": config.protection_test_bypass.as_ref().and_then(|bypass| bypass.credential_secret_name.as_ref()).map(Redacted::expose) + } + }))?; + crate::test_support::secret_bindings::install_test_bindings(&mut settings)?; + let mut integration = Self::try_new(config)?; + let inner = Arc::get_mut(&mut integration).expect("should own integration"); + inner.protection_binding = settings + .resolved_bindings + .get( + "datadome.server", + "integrations.datadome.server_side_key_secret_name", + ) + .cloned(); + inner.bypass_binding = settings + .resolved_bindings + .get( + "datadome.bypass", + "integrations.datadome.protection_test_bypass.credential_secret_name", + ) + .cloned(); + Ok(integration) } fn try_new(mut config: DataDomeConfig) -> Result, Report> { @@ -392,12 +424,6 @@ impl DataDomeIntegration { "DataDome server_side_key_secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" ); } - config.server_side_key_secret_name = - config.server_side_key_secret_name.take().and_then(|value| { - let value = value.expose().trim().to_string(); - (!value.is_empty()).then(|| Redacted::new(value)) - }); - config.protection_api_origin = config.protection_api_origin.trim().to_string(); config.client_side_tag_url = config.client_side_tag_url.trim().to_string(); if let Some(bypass) = &mut config.protection_test_bypass && bypass.credential_secret_store.take().is_some() @@ -408,11 +434,6 @@ impl DataDomeIntegration { } if config.enable_protection { - if config.server_side_key_secret_name.is_none() { - return Err(Report::new(Self::error( - "server_side_key_secret_name is required when enable_protection is true", - ))); - } Self::validate_protection_api_origin(&config.protection_api_origin)?; } Self::validate_protection_test_bypass(&config)?; @@ -430,6 +451,8 @@ impl DataDomeIntegration { Ok(Arc::new(Self { config, protection_scope, + protection_binding: None, + bypass_binding: None, })) } @@ -497,7 +520,7 @@ impl DataDomeIntegration { fn validate_protection_test_bypass( config: &DataDomeConfig, ) -> Result<(), Report> { - let Some(bypass) = config + let Some(_bypass) = config .protection_test_bypass .as_ref() .filter(|bypass| bypass.enabled) @@ -510,15 +533,6 @@ impl DataDomeIntegration { "protection_test_bypass requires enable_protection to be true", ))); } - if bypass - .credential_secret_name - .as_ref() - .is_none_or(|credential| credential.expose().is_empty()) - { - return Err(Report::new(Self::error( - "protection_test_bypass credential_secret_name is required when enabled", - ))); - } Ok(()) } @@ -954,7 +968,47 @@ fn build( return Ok(None); }; - let integration = DataDomeIntegration::try_new(config)?; + let protection_binding = if config.enable_protection { + Some( + settings + .resolved_bindings + .require_for_settings( + settings, + "datadome.server", + "integrations.datadome.server_side_key_secret_name", + )? + .clone(), + ) + } else { + None + }; + let bypass_binding = if config.enable_protection + && config + .protection_test_bypass + .as_ref() + .is_some_and(|bypass| bypass.enabled) + { + Some( + settings + .resolved_bindings + .require_for_settings( + settings, + "datadome.bypass", + "integrations.datadome.protection_test_bypass.credential_secret_name", + )? + .clone(), + ) + } else { + None + }; + let mut integration = DataDomeIntegration::try_new(config)?; + let inner = Arc::get_mut(&mut integration).ok_or_else(|| { + Report::new(DataDomeIntegration::error( + "integration construction failed", + )) + })?; + inner.protection_binding = protection_binding; + inner.bypass_binding = bypass_binding; let protection_test_bypass_configured = integration .config .protection_test_bypass @@ -1277,7 +1331,7 @@ mod tests { credential_secret_name: credential.map(|value| Redacted::new(value.to_string())), }); - let err = match DataDomeIntegration::try_new(config) { + let err = match DataDomeIntegration::with_test_bindings(config) { Ok(_) => panic!("should reject invalid protection test bypass configuration"), Err(err) => err, }; @@ -1289,7 +1343,7 @@ mod tests { } #[test] - fn protection_test_bypass_accepts_short_resolved_credential() { + fn protection_test_bypass_rejects_short_resolved_credential() { let mut config = test_config(); config.enable_protection = true; config.server_side_key_secret_name = Some(Redacted::new("resolved-server-key".to_string())); @@ -1299,8 +1353,7 @@ mod tests { credential_secret_name: Some(Redacted::new("short".to_string())), }); - DataDomeIntegration::try_new(config) - .expect("should defer bypass credential strength enforcement to requests"); + assert!(DataDomeIntegration::with_test_bindings(config).is_err()); } #[test] @@ -1315,17 +1368,15 @@ mod tests { credential_secret_name: Some(Redacted::new(credential.to_owned())), }); - let integration = - DataDomeIntegration::try_new(config).expect("should create DataDome integration"); + let integration = DataDomeIntegration::with_test_bindings(config) + .expect("should create DataDome integration"); let resolved = integration - .config - .protection_test_bypass + .bypass_binding .as_ref() - .and_then(|bypass| bypass.credential_secret_name.as_ref()) - .expect("should retain the resolved bypass credential"); + .expect("should retain loaded bypass binding"); assert_eq!( - resolved.expose(), + resolved.credential(), credential, "should not normalize resolved secret material" ); @@ -1335,9 +1386,9 @@ mod tests { fn protection_enabled_requires_server_side_key_secret_name() { let mut config = test_config(); config.enable_protection = true; - config.server_side_key_secret_name = Some(Redacted::new(" ".to_string())); + config.server_side_key_secret_name = None; - let err = match DataDomeIntegration::try_new(config) { + let err = match DataDomeIntegration::with_test_bindings(config) { Ok(_) => panic!("should reject empty name"), Err(err) => err, }; diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index a7c55bf864..f6f79d1659 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -14,14 +14,14 @@ use crate::integrations::{ HeaderMutation, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, }; use crate::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; +#[cfg(test)] use crate::redacted::Redacted; -use super::DataDomeIntegration; use super::protection_scope::{ ProtectionRequestFacts, ProtectionScopeDecision, ProtectionSkipReason, }; +use super::{DataDomeIntegration, ResolvedBinding}; -const VALIDATE_REQUEST_PATH: &str = "/validate-request"; const REQUEST_MODULE_NAME: &str = "Trusted-Server-Rust"; const MODULE_VERSION: &str = env!("CARGO_PKG_VERSION"); const HEADER_DATADOME_RESPONSE: &str = "x-datadomeresponse"; @@ -79,9 +79,8 @@ impl DataDomeIntegration { &self, input: RequestFilterInput<'_>, ) -> Result { - let api_url = self.protection_validate_url(); - let backend_name = self - .ensure_protection_backend(input.services, &api_url) + let api_url = self + .protection_validate_url() .map_err(ProtectionRequestError::Setup)?; let server_side_key = self .server_side_key() @@ -91,7 +90,7 @@ impl DataDomeIntegration { let mut builder = request_builder() .method(Method::POST.as_str()) - .uri(api_url.as_str()) + .uri(api_url) .header( header::CONTENT_TYPE.as_str(), "application/x-www-form-urlencoded", @@ -107,17 +106,23 @@ impl DataDomeIntegration { let request = builder .body(EdgeBody::from(encoded_body)) - .change_context(Self::error( - "Failed to build DataDome Protection API request", - )) + .map_err(|_| { + Report::new(Self::error( + "Failed to build DataDome Protection API request", + )) + }) .map_err(ProtectionRequestError::Runtime)?; + // Build and encode every credential-bearing representation before a backend exists. + let backend_name = self + .ensure_protection_backend(input.services, api_url) + .map_err(ProtectionRequestError::Setup)?; let platform_response = input .services .http_client() .send(PlatformHttpRequest::new(request, backend_name)) .await - .change_context(Self::error("Failed to call DataDome Protection API")) + .map_err(|_| Report::new(Self::error("Failed to call DataDome Protection API"))) .map_err(ProtectionRequestError::Runtime)?; let status = platform_response.response.status(); @@ -183,7 +188,7 @@ impl DataDomeIntegration { if supplied_values.is_empty() { return false; } - let Some(bypass) = self.active_protection_test_bypass() else { + let Some(_bypass) = self.active_protection_test_bypass() else { return false; }; if supplied_values.len() != 1 { @@ -193,13 +198,17 @@ impl DataDomeIntegration { return false; } - let Some(credential) = bypass.credential_secret_name.as_ref() else { + let Some(credential) = self + .bypass_binding + .as_ref() + .map(ResolvedBinding::credential) + else { log::warn!( "[datadome] DataDome test bypass credential is unavailable; ignoring bypass header" ); return false; }; - if credential.expose().len() < super::MIN_TEST_BYPASS_CREDENTIAL_BYTES { + if credential.len() < super::MIN_TEST_BYPASS_CREDENTIAL_BYTES { log::warn!( "[datadome] DataDome test bypass credential does not meet security requirements; ignoring bypass header" ); @@ -207,16 +216,19 @@ impl DataDomeIntegration { } let actual = Sha256::digest(supplied_values[0].as_bytes()); - let expected = Sha256::digest(credential.expose().as_bytes()); + let expected = Sha256::digest(credential.as_bytes()); bool::from(actual.ct_eq(&expected)) } - fn protection_validate_url(&self) -> String { - format!( - "{}{}", - self.config.protection_api_origin.trim_end_matches('/'), - VALIDATE_REQUEST_PATH - ) + fn protection_validate_url(&self) -> Result<&str, Report> { + self.protection_binding + .as_ref() + .and_then(|binding| binding.endpoint()) + .ok_or_else(|| { + Report::new(Self::error( + "DataDome loaded credential binding unavailable", + )) + }) } fn ensure_protection_backend( @@ -240,18 +252,20 @@ impl DataDomeIntegration { discriminator: None, }; - services.backend().ensure(&spec).change_context(Self::error( - "Failed to register DataDome Protection API backend", - )) + services.backend().ensure(&spec).map_err(|_| { + Report::new(Self::error( + "Failed to register DataDome Protection API backend", + )) + }) } - fn server_side_key(&self) -> Result<&Redacted, Report> { - self.config - .server_side_key_secret_name + fn server_side_key(&self) -> Result<&str, Report> { + self.protection_binding .as_ref() + .map(ResolvedBinding::credential) .ok_or_else(|| { Report::new(Self::error( - "DataDome server-side key is unavailable after secret resolution", + "DataDome loaded credential binding unavailable", )) }) } @@ -259,7 +273,7 @@ impl DataDomeIntegration { fn build_protection_payload( &self, input: &RequestFilterInput<'_>, - server_side_key: &Redacted, + server_side_key: &str, ) -> ProtectionPayload { let req = &*input.request; let client_info = input.services.client_info(); @@ -273,7 +287,8 @@ impl DataDomeIntegration { header_client_id.clone() }; - push_field(&mut fields, "Key", server_side_key.expose()); + // Credentials are not subject to field truncation. + fields.push(("Key".to_owned(), server_side_key.to_owned())); push_field( &mut fields, "IP", @@ -840,7 +855,8 @@ mod tests { } fn protection_integration() -> Arc { - DataDomeIntegration::try_new(protection_config()).expect("should create integration") + DataDomeIntegration::with_test_bindings(protection_config()) + .expect("should create integration") } fn request_for_filter() -> Request { @@ -892,8 +908,8 @@ mod tests { geo_info: Option<&GeoInfo>, uri: &str, ) -> Request { - let integration = - DataDomeIntegration::try_new(config).expect("should create DataDome integration"); + let integration = DataDomeIntegration::with_test_bindings(config) + .expect("should create DataDome integration"); let settings = Settings::default(); let mut request = request_builder() .method(Method::GET.as_str()) @@ -923,6 +939,67 @@ mod tests { .is_some() } + #[test] + fn protection_secret_bindings_never_follow_redirects_or_reinterpret_shadow_target() { + for status in [301, 302, 303, 307, 308] { + let config = DataDomeConfig { + protection_api_origin: "https://protection.example.com".to_owned(), + ..protection_config() + }; + let mut integration = DataDomeIntegration::with_test_bindings(config) + .expect("should create fictional protection integration"); + Arc::get_mut(&mut integration) + .expect("should own integration") + .config + .protection_api_origin = "https://unapproved.example".to_owned(); + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + status, + Vec::new(), + vec![("location", "https://unapproved.example/stolen")], + ); + let services = + build_services_with_secret_and_http_client(NoopSecretStore, http.clone()); + let settings = Settings::default(); + let mut request = request_for_filter(); + let decision = futures::executor::block_on(integration.filter_protection_request( + RequestFilterInput { + settings: &settings, + services: &services, + request: &mut request, + geo_info: None, + is_integration_route: false, + }, + )); + assert!(matches!(decision, RequestFilterDecision::Continue(_))); + assert_eq!( + http.recorded_request_uris(), + vec!["https://protection.example.com/validate-request"] + ); + } + } + + #[test] + fn protection_settings_only_constructor_cannot_export() { + let integration = + DataDomeIntegration::try_new(protection_config()).expect("should validate structure"); + let http = Arc::new(StubHttpClient::new()); + let services = build_services_with_secret_and_http_client(NoopSecretStore, http.clone()); + let settings = Settings::default(); + let mut request = request_for_filter(); + let decision = futures::executor::block_on(integration.filter_protection_request( + RequestFilterInput { + settings: &settings, + services: &services, + request: &mut request, + geo_info: None, + is_integration_route: false, + }, + )); + assert!(matches!(decision, RequestFilterDecision::Continue(_))); + assert!(http.recorded_backend_names().is_empty()); + } + #[test] fn protection_test_bypass_skips_api_suppresses_tag_and_strips_header() { let config = DataDomeConfig { @@ -937,7 +1014,8 @@ mod tests { }), ..protection_config() }; - let integration = DataDomeIntegration::try_new(config).expect("should create integration"); + let integration = + DataDomeIntegration::with_test_bindings(config).expect("should create integration"); let mut secrets = HashMap::new(); secrets.insert( "datadome_test_bypass".to_string(), @@ -997,7 +1075,7 @@ mod tests { ..protection_config() }; let integration = - DataDomeIntegration::try_new(config).expect("should create integration"); + DataDomeIntegration::with_test_bindings(config).expect("should create integration"); let mut secrets = HashMap::new(); secrets.insert( "datadome_server_side_key".to_string(), @@ -1059,7 +1137,8 @@ mod tests { }), ..protection_config() }; - let integration = DataDomeIntegration::try_new(config).expect("should create integration"); + let integration = + DataDomeIntegration::with_test_bindings(config).expect("should create integration"); let mut secrets = HashMap::new(); secrets.insert( "datadome_server_side_key".to_string(), @@ -1149,7 +1228,8 @@ mod tests { }), ..protection_config() }; - let integration = DataDomeIntegration::try_new(config).expect("should create integration"); + let integration = + DataDomeIntegration::with_test_bindings(config).expect("should create integration"); let mut secrets = HashMap::new(); secrets.insert( "datadome_test_bypass".to_string(), @@ -1197,7 +1277,8 @@ mod tests { }), ..protection_config() }; - let integration = DataDomeIntegration::try_new(config).expect("should create integration"); + let integration = + DataDomeIntegration::with_test_bindings(config).expect("should create integration"); let mut secrets = HashMap::new(); secrets.insert( "datadome_server_side_key".to_string(), @@ -1262,7 +1343,8 @@ mod tests { }), ..protection_config() }; - let integration = DataDomeIntegration::try_new(config).expect("should create integration"); + let integration = + DataDomeIntegration::with_test_bindings(config).expect("should create integration"); let mut secrets = HashMap::new(); secrets.insert( "datadome_server_side_key".to_string(), @@ -1309,7 +1391,7 @@ mod tests { } #[test] - fn short_test_bypass_credential_is_ignored_without_failing_startup() { + fn short_test_bypass_credential_is_rejected_during_loading() { let config = DataDomeConfig { protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, @@ -1318,39 +1400,7 @@ mod tests { }), ..protection_config() }; - let integration = - DataDomeIntegration::try_new(config).expect("should accept short bypass credential"); - let http_client = Arc::new(StubHttpClient::new()); - http_client.push_response_with_headers( - 200, - Vec::new(), - vec![(HEADER_DATADOME_RESPONSE, "200")], - ); - let services = - build_services_with_secret_and_http_client(NoopSecretStore, http_client.clone()); - let settings = Settings::default(); - let mut request = request_for_filter(); - request.headers_mut().insert( - super::super::HEADER_DATADOME_TEST_BYPASS, - edgezero_core::http::HeaderValue::from_static("short"), - ); - - let decision = filter_with_staging(&integration, &settings, &services, &mut request); - - assert!(matches!(decision, RequestFilterDecision::Continue(_))); - assert!( - request - .headers() - .get(super::super::HEADER_DATADOME_TEST_BYPASS) - .is_none(), - "the invalid bypass credential should not reach the publisher origin" - ); - assert!(!has_client_tag_suppression_marker(&request)); - assert_eq!( - http_client.recorded_backend_names().len(), - 1, - "a short credential should not bypass the Protection API" - ); + assert!(DataDomeIntegration::with_test_bindings(config).is_err()); } #[test] @@ -1600,6 +1650,33 @@ mod tests { ); } + #[test] + fn server_side_key_preserves_form_characters_and_whitespace() { + let config = DataDomeConfig { + server_side_key_secret_name: Some(Redacted::new( + " key&with=syntax+and spaces ".to_owned(), + )), + ..protection_config() + }; + let integration = + DataDomeIntegration::with_test_bindings(config).expect("should load exact bytes"); + assert_eq!( + integration.server_side_key().expect("should have key"), + " key&with=syntax+and spaces " + ); + let encoded = form_encode(&[( + "Key".to_owned(), + integration + .server_side_key() + .expect("should have key") + .to_owned(), + )]); + let decoded = url::form_urlencoded::parse(encoded.as_bytes()).collect::>(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].0, "Key"); + assert_eq!(decoded[0].1, " key&with=syntax+and spaces "); + } + #[test] fn server_side_key_uses_resolved_config_value() { let integration = protection_integration(); @@ -1608,7 +1685,7 @@ mod tests { .server_side_key() .expect("should contain resolved server-side key"); - assert_eq!(key.expose(), "server-side-key"); + assert_eq!(key, "server-side-key"); } #[test] @@ -1619,7 +1696,7 @@ mod tests { }; assert!( - DataDomeIntegration::try_new(config).is_err(), + DataDomeIntegration::with_test_bindings(config).is_err(), "should reject a missing resolved server-side key" ); } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 82c5d808f3..4b79c73548 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -7578,7 +7578,7 @@ set = { keywords = { genre = "news" } } } #[test] - fn bid_param_override_numeric_strings_survive_runtime_config_roundtrip() { + fn bid_param_override_numeric_strings_survive_feature_config_roundtrip() { let toml_str = format!( r#"{} @@ -7602,11 +7602,16 @@ set = {{ placementId = "13579" }} ); let settings = Settings::from_toml(&toml_str).expect("should parse TOML settings"); - // This mirrors the typed data transition beneath `ts config push`: - // `BlobEnvelope` data is deserialized by `settings_from_config_blob`. - let serialized = serde_json::to_value(&settings).expect("should serialize settings"); - let runtime_settings = - Settings::from_json_value(serialized).expect("should parse runtime JSON settings"); + // This legacy Prebid feature-model test does not exercise the production + // app-config schema, which rejects its retired server_url/bidders fields. + // Round-trip feature data without requiring or serializing native credentials. + let serialized = + serde_json::to_value(&settings).expect("should serialize feature settings"); + let mut runtime_settings: Settings = + serde_json::from_value(serialized).expect("should deserialize feature settings"); + runtime_settings + .normalize_deserialized() + .expect("should normalize feature settings"); let config = runtime_settings .integration_config::(PREBID_INTEGRATION_ID) .expect("should parse Prebid config") diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 15a6486fd8..109aaddf6b 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -833,6 +833,21 @@ impl IntegrationRegistry { settings: &Settings, plan: Arc, ) -> Result> { + // State construction must reject enabled exporters even on adapters that + // do not currently emit that feature's traffic. + let bindings = crate::secret_bindings::compile_bindings(settings)?; + for binding in bindings.entries() { + if !matches!( + binding.target(), + crate::secret_bindings::CompiledTarget::None + ) { + settings.resolved_bindings.require_for_plan( + &bindings, + binding.descriptor_id(), + binding.field_path(), + )?; + } + } let mut inner = IntegrationRegistryInner::default(); let mut registrations = Vec::new(); if let Some(registration) = crate::integrations::prebid::register_for_plan(settings, &plan)? diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index e16232c69b..e44c0ddc50 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -67,7 +67,7 @@ pub(crate) mod response_header_rewrite; pub mod response_privacy; pub mod rsc_flight; pub(crate) mod s3_sigv4; -pub mod secret_resolution; +pub mod secret_bindings; pub mod settings; pub mod settings_data; pub mod streaming_processor; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 17813c77dc..70f9009521 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -197,6 +197,49 @@ impl PlatformBackend for StubBackend { } } +// --------------------------------------------------------------------------- +// RecordingBackend +// --------------------------------------------------------------------------- + +const RECORDING_BACKEND_NAME: &str = "recording-backend"; + +/// Backend fixture that records ensured specifications and returns one fixed name. +#[derive(Default)] +pub(crate) struct RecordingBackend { + specs: Mutex>, +} + +impl RecordingBackend { + /// Returns a snapshot of specifications captured by backend registration. + /// + /// # Panics + /// Panics if the recording lock is poisoned. + pub(crate) fn specs(&self) -> Vec { + self.specs + .lock() + .expect("should lock recorded backend specifications") + .clone() + } +} + +impl PlatformBackend for RecordingBackend { + fn naming_policy(&self) -> super::BackendNamingPolicy { + super::BackendNamingPolicy::Axum + } + + fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok(RECORDING_BACKEND_NAME.to_owned()) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.specs + .lock() + .expect("should record the ensured backend specification") + .push(spec.clone()); + Ok(RECORDING_BACKEND_NAME.to_owned()) + } +} + // --------------------------------------------------------------------------- // StubHttpClient // --------------------------------------------------------------------------- @@ -1298,6 +1341,37 @@ mod tests { assert_eq!(name, "stub-backend", "should return fixed name"); } + #[test] + fn recording_backend_records_registration_but_not_name_prediction() { + let backend = RecordingBackend::default(); + let spec = PlatformBackendSpec { + scheme: "https".to_owned(), + host: "backend.example.com".to_owned(), + port: Some(8443), + host_header_override: None, + certificate_check: true, + first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, + }; + let predicted = backend + .predict_name(&spec) + .expect("should predict the recording backend name"); + assert!( + backend.specs().is_empty(), + "name prediction must not register a backend" + ); + let ensured = backend + .ensure(&spec) + .expect("should record the ensured backend"); + assert_eq!(predicted, ensured); + let recorded = backend.specs(); + assert_eq!(recorded.len(), 1); + assert_eq!(recorded[0].host, "backend.example.com"); + assert_eq!(recorded[0].port, Some(8443)); + assert!(recorded[0].certificate_check); + } + #[test] fn build_services_with_config_and_secret_uses_provided_stores() { // Arrange: noop stores diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 82ececa744..e8f2a770ad 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -916,30 +916,81 @@ fn asset_origin_host_header( } fn apply_asset_origin_auth( + settings: &Settings, + route: &ProxyAssetRoute, method: &Method, target_url: &url::Url, headers: &mut http::HeaderMap, - auth: &AssetOriginAuth, ) -> Result<(), Report> { - match auth { - AssetOriginAuth::S3SigV4(config) => { - let credentials = S3Credentials { - access_key_id: config.access_key_id.expose().clone(), - secret_access_key: config.secret_access_key.clone(), - session_token: config.session_token.clone(), - }; - s3_sigv4::sign_headers( - method, - target_url, - headers, - &config.region, - &credentials, - // s3_sigv4 converts this via chrono's `DateTime::::from`, which - // only accepts `std::time::SystemTime`. - crate::ec::std_system_time_now(), - ) - } + let Some(AssetOriginAuth::S3SigV4(config)) = &route.auth else { + return Ok(()); + }; + let fail = || { + Report::new(TrustedServerError::Configuration { + message: "proxy.asset_routes.auth: loaded S3 authorization mismatch".to_owned(), + }) + }; + if !matches!(*method, Method::GET | Method::HEAD) { + return Err(fail()); + } + let index = settings + .proxy + .asset_routes + .iter() + .position(|candidate| candidate.prefix == route.prefix) + .ok_or_else(fail)?; + // Check the actual route passed by the caller, not only the same-index route + // in settings. Reinterpreting an old capability for a new route is forbidden. + let mut effective = settings.clone(); + effective.proxy.asset_routes[index] = route.clone(); + let plan = crate::secret_bindings::compile_bindings(&effective)?; + let id = settings.resolved_bindings.require_for_plan( + &plan, + "s3.access-id", + &format!("proxy.asset_routes[{index}].auth.access_key_id"), + )?; + let secret = settings.resolved_bindings.require_for_plan( + &plan, + "s3.secret", + &format!("proxy.asset_routes[{index}].auth.secret_access_key"), + )?; + let session = if config.session_token_enabled { + Some(settings.resolved_bindings.require_for_plan( + &plan, + "s3.session", + &format!("proxy.asset_routes[{index}].auth.session_token"), + )?) + } else { + None + }; + let origin = id.endpoint().ok_or_else(fail)?; + let bound = url::Url::parse(origin).map_err(|_| fail())?; + if target_url.scheme() != "https" + || target_url.origin() != bound.origin() + || !target_url.username().is_empty() + || target_url.password().is_some() + || target_url.fragment().is_some() + || headers.get(header::HOST) != Some(&asset_origin_host_header(target_url)?) + || secret.endpoint() != Some(origin) + || session.is_some_and(|binding| binding.endpoint() != Some(origin)) + { + return Err(fail()); } + let credentials = S3Credentials { + access_key_id: crate::redacted::Redacted::new(id.credential().to_owned()), + secret_access_key: crate::redacted::Redacted::new(secret.credential().to_owned()), + session_token: session + .map(|binding| crate::redacted::Redacted::new(binding.credential().to_owned())), + }; + let region = id.s3_region().ok_or_else(fail)?; + s3_sigv4::sign_headers( + method, + target_url, + headers, + region, + &credentials, + crate::ec::std_system_time_now(), + ) } fn build_asset_platform_request( @@ -989,8 +1040,10 @@ async fn send_asset_origin_request( .http_client() .send(platform_req) .await - .change_context(TrustedServerError::Proxy { - message: "Failed to proxy asset request".to_string(), + .map_err(|_| { + Report::new(TrustedServerError::Proxy { + message: "Failed to proxy asset request".to_string(), + }) })?; if stream_response { @@ -1023,6 +1076,7 @@ fn should_preflight_s3( } async fn preflight_s3_origin_for_image_optimizer( + settings: &Settings, services: &RuntimeServices, route: &ProxyAssetRoute, target_url: &url::Url, @@ -1030,7 +1084,7 @@ async fn preflight_s3_origin_for_image_optimizer( unsigned_headers: &http::HeaderMap, backend_name: &str, ) -> Result, Report> { - let Some(auth @ AssetOriginAuth::S3SigV4(_)) = route.auth.as_ref() else { + let Some(AssetOriginAuth::S3SigV4(_)) = route.auth.as_ref() else { return Ok(None); }; @@ -1038,7 +1092,13 @@ async fn preflight_s3_origin_for_image_optimizer( // HEAD preflight lets missing or unauthorized objects return raw S3 errors // without invoking IO on the failure path. let mut head_headers = unsigned_headers.clone(); - apply_asset_origin_auth(&Method::HEAD, target_url, &mut head_headers, auth)?; + apply_asset_origin_auth( + settings, + route, + &Method::HEAD, + target_url, + &mut head_headers, + )?; let head_response = send_asset_origin_request( services, backend_name, @@ -1061,7 +1121,7 @@ async fn preflight_s3_origin_for_image_optimizer( } let mut get_headers = unsigned_headers.clone(); - apply_asset_origin_auth(&Method::GET, target_url, &mut get_headers, auth)?; + apply_asset_origin_auth(settings, route, &Method::GET, target_url, &mut get_headers)?; let mut response = send_asset_origin_request( services, backend_name, @@ -1126,6 +1186,22 @@ pub async fn handle_asset_proxy_request( }) })?; + let mut outbound_headers = http::HeaderMap::new(); + for header_name in ASSET_PROXY_FORWARD_HEADERS { + if let Some(value) = req.headers().get(&header_name) { + outbound_headers.insert(header_name, value.clone()); + } + } + outbound_headers.insert(header::HOST, asset_origin_host_header(&target_url)?); + + apply_asset_origin_auth( + settings, + route, + req.method(), + &target_url, + &mut outbound_headers, + )?; + let backend_name = services .backend() .ensure(&PlatformBackendSpec { @@ -1133,7 +1209,7 @@ pub async fn handle_asset_proxy_request( host: host.to_string(), port: target_url.port(), host_header_override: None, - certificate_check: settings.proxy.certificate_check, + certificate_check: route.auth.is_some() || settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, discriminator: None, @@ -1142,16 +1218,9 @@ pub async fn handle_asset_proxy_request( message: "asset backend registration failed".to_string(), })?; - let mut outbound_headers = http::HeaderMap::new(); - for header_name in ASSET_PROXY_FORWARD_HEADERS { - if let Some(value) = req.headers().get(&header_name) { - outbound_headers.insert(header_name, value.clone()); - } - } - outbound_headers.insert(header::HOST, asset_origin_host_header(&target_url)?); - if should_preflight_s3(route, image_optimizer.is_some(), req.method()) && let Some(response) = preflight_s3_origin_for_image_optimizer( + settings, services, route, &target_url, @@ -1164,10 +1233,6 @@ pub async fn handle_asset_proxy_request( return Ok(response); } - if let Some(auth) = &route.auth { - apply_asset_origin_auth(req.method(), &target_url, &mut outbound_headers, auth)?; - } - let mut platform_req = build_asset_platform_request(req.method(), &target_url, &outbound_headers, &backend_name)?; if let Some(image_optimizer) = image_optimizer { @@ -1182,8 +1247,10 @@ pub async fn handle_asset_proxy_request( .http_client() .send(platform_req) .await - .change_context(TrustedServerError::Proxy { - message: "Failed to proxy asset request".to_string(), + .map_err(|_| { + Report::new(TrustedServerError::Proxy { + message: "Failed to proxy asset request".to_string(), + }) })?; let mut response = if stream_response { @@ -2250,12 +2317,13 @@ mod tests { use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::platform::test_support::{ - HashMapSecretStore, StubHttpClient, build_services_with_http_client, + HashMapSecretStore, RecordingBackend, StubHttpClient, build_services_with_http_client, build_services_with_secret_and_http_client, noop_services, }; use crate::platform::{ PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, + StoreName, }; use crate::redacted::Redacted; use crate::settings::{ @@ -5248,6 +5316,111 @@ mod tests { } } + fn credential_services( + backend: Arc, + http: Arc, + ) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(backend) + .http_client(http) + .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build() + } + + #[test] + fn s3_secret_bindings_reject_missing_or_mutated_policy_before_backend() { + let mut original = create_test_settings(); + let mut route = test_s3_image_optimizer_route(); + route.image_optimizer = None; + route.path_pattern = Some("^/(.*)$".to_owned()); + route.target_path = Some("/$1".to_owned()); + authorize_s3_test_route(&mut original, &route); + for mutation in 0..9 { + let mut settings = original.clone(); + let mut changed = route.clone(); + match mutation { + 0 => settings.resolved_bindings = Default::default(), + 1 => changed.origin_url = "https://unapproved.example".to_owned(), + 2 => changed.prefix = "/different/".to_owned(), + 3 => changed.path_pattern = Some("^/.images/(.*)$".to_owned()), + 4 => changed.target_path = Some("/different".to_owned()), + 5 => { + if let Some(AssetOriginAuth::S3SigV4(auth)) = changed.auth.as_mut() { + auth.region = "us-west-2".to_owned(); + } + } + 6 => { + if let Some(AssetOriginAuth::S3SigV4(auth)) = changed.auth.as_mut() { + auth.origin_query = Some(OriginQueryPolicy::Strip); + } + } + 8 => { + if let Some(AssetOriginAuth::S3SigV4(auth)) = changed.auth.as_mut() { + auth.session_token_enabled = true; + } + } + _ => {} + } + let backend = Arc::new(RecordingBackend::default()); + let http = Arc::new(StubHttpClient::new()); + let services = credential_services(backend.clone(), http.clone()); + let method = if mutation == 7 { + Method::POST + } else { + Method::GET + }; + let req = build_http_request(method, "https://www.example.com/.images/foo.jpg"); + assert!( + futures::executor::block_on(handle_asset_proxy_request( + &settings, &services, req, &changed + )) + .is_err() + ); + assert!( + backend.specs().is_empty(), + "rejection must precede backend creation" + ); + assert!( + http.recorded_backend_names().is_empty(), + "no unauthorized request" + ); + } + } + + #[test] + fn s3_secret_bindings_force_tls_and_never_follow_redirects() { + for status in [301, 302, 303, 307, 308] { + let mut settings = create_test_settings(); + settings.proxy.certificate_check = false; + let mut route = test_s3_image_optimizer_route(); + route.image_optimizer = None; + authorize_s3_test_route(&mut settings, &route); + let backend = Arc::new(RecordingBackend::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + status, + Vec::new(), + vec![("location", "https://unapproved.example/stolen")], + ); + let services = credential_services(backend.clone(), http.clone()); + let req = build_http_request(Method::GET, "https://www.example.com/.images/foo.jpg"); + let response = futures::executor::block_on(handle_asset_proxy_request( + &settings, &services, req, &route, + )) + .expect("should surface origin redirect"); + assert_eq!(response.response().status().as_u16(), status); + assert_eq!(http.recorded_request_uris().len(), 1); + let specs = backend.specs(); + assert_eq!(specs.len(), 1); + assert!(specs[0].certificate_check); + assert_eq!(specs[0].host_header_override, None); + } + } + fn test_s3_secrets() -> HashMap> { HashMap::from([ ( @@ -5261,6 +5434,12 @@ mod tests { ]) } + fn authorize_s3_test_route(settings: &mut Settings, route: &ProxyAssetRoute) { + settings.proxy.asset_routes = vec![route.clone()]; + crate::test_support::secret_bindings::install_test_bindings(settings) + .expect("should resolve fixture bindings"); + } + fn test_s3_image_optimizer_route() -> ProxyAssetRoute { let mut route = ProxyAssetRoute::new( "/.images/", @@ -5274,6 +5453,7 @@ mod tests { "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), ), session_token: None, + session_token_enabled: false, origin_query: None, })); route.image_optimizer = Some(AssetImageOptimizerConfig { @@ -5320,9 +5500,12 @@ mod tests { "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), ), session_token: None, + session_token_enabled: false, origin_query: Some(OriginQueryPolicy::Strip), })); + let mut settings = settings; + authorize_s3_test_route(&mut settings, &route); handle_asset_proxy_request(&settings, &services, req, &route) .await .expect("should proxy signed S3 asset request"); @@ -5386,9 +5569,12 @@ mod tests { "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), ), session_token: Some(Redacted::new("session-token".to_string())), + session_token_enabled: true, origin_query: None, })); + let mut settings = settings; + authorize_s3_test_route(&mut settings, &route); handle_asset_proxy_request(&settings, &services, req, &route) .await .expect("should proxy optimized S3 asset request"); @@ -5413,6 +5599,13 @@ mod tests { && value == "session-token")), "should sign both requests with the cached session token" ); + assert!( + headers.iter().all( + |sent| sent.iter().any(|(name, value)| name == "authorization" + && value.contains("/us-east-1/s3/aws4_request")) + ), + "should sign both requests using the compiled S3 region" + ); }); } @@ -5619,6 +5812,8 @@ mod tests { ); let route = test_s3_image_optimizer_route(); + let mut settings = settings; + authorize_s3_test_route(&mut settings, &route); let response = handle_asset_proxy_request(&settings, &services, req, &route) .await .expect("should proxy raw SVG asset through S3 route") @@ -5665,6 +5860,8 @@ mod tests { ); let route = test_s3_image_optimizer_route(); + let mut settings = settings; + authorize_s3_test_route(&mut settings, &route); let (response, stream_body) = handle_asset_proxy_request(&settings, &services, req, &route) .await @@ -5744,6 +5941,7 @@ mod tests { ); let route = test_s3_image_optimizer_route(); + authorize_s3_test_route(&mut settings, &route); let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) .await .expect("should return raw S3 error response"); @@ -5816,6 +6014,8 @@ mod tests { ); let route = test_s3_image_optimizer_route(); + let mut settings = settings; + authorize_s3_test_route(&mut settings, &route); let (response, stream_body) = handle_asset_proxy_request(&settings, &services, req, &route) .await diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 24e0ef82b0..8cbf38a8a9 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -8293,6 +8293,7 @@ mod tests { source_domain: source_domain.to_owned(), openrtb_atype: crate::settings::EcPartner::default_openrtb_atype(), bidstream_enabled: true, + api_token_enabled: true, api_token: Some(crate::redacted::Redacted::new(format!( "marker-{source_domain}-api-token-32-bytes-minimum" ))), @@ -8302,7 +8303,9 @@ mod tests { pull_sync_allowed_domains: vec![format!("sync.{source_domain}")], pull_sync_ttl_sec: crate::settings::EcPartner::default_pull_sync_ttl_sec(), pull_sync_rate_limit: crate::settings::EcPartner::default_pull_sync_rate_limit(), - ts_pull_token: Some(crate::redacted::Redacted::new("pull-token".to_owned())), + ts_pull_token: Some(crate::redacted::Redacted::new( + "fictional-pull-token-with-32-bytes-minimum".to_owned(), + )), } } @@ -8317,8 +8320,10 @@ mod tests { create_test_settings() }; settings.ec.partners = vec![marker_partner("ssp.example.com")]; - let registry = PartnerRegistry::from_config(&settings.ec.partners) - .expect("should build pull partner registry"); + crate::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should resolve fixture bindings"); + let registry = + PartnerRegistry::from_settings(&settings).expect("should build pull partner registry"); let http = Arc::new(StubHttpClient::new()); http.set_concurrent_fanout(true); http.push_response(200, b"ok".to_vec()); @@ -8352,9 +8357,12 @@ mod tests { ) } MarkerProbe::PartnerSetMismatch => { - let other_registry = - PartnerRegistry::from_config(&[marker_partner("other.example.com")]) - .expect("should build alternate registry"); + let mut other_settings = settings.clone(); + other_settings.ec.partners = vec![marker_partner("other.example.com")]; + crate::test_support::secret_bindings::install_test_bindings(&mut other_settings) + .expect("should resolve fixture bindings"); + let other_registry = PartnerRegistry::from_settings(&other_settings) + .expect("should build alternate registry"); crate::ec::pull_sync_marker::create_marker_for_test( &settings, &other_registry, @@ -19184,6 +19192,8 @@ mod tests { }), ) .expect("should configure DataDome integration"); + crate::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should resolve fixture bindings"); let registry = IntegrationRegistry::new(&settings) .expect("should create integration registry with DataDome"); let stub = Arc::new(StubHttpClient::new()); diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index d35e51a151..27ecb014bf 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -259,6 +259,18 @@ fn validate_kid_format(kid: &str) -> Result<(), Report> { fn validate_kid(kid: &str) -> Result<(), Report> { validate_kid_format(kid)?; + // Cloudflare has a flat secret namespace. Signing writes must never replace + // an app credential, including aliases under Axum's case normalization. + if kid + .to_ascii_lowercase() + .replace(['-', '.', ' '], "_") + .starts_with("ts1_") + { + return Err(Report::new(TrustedServerError::BadRequest { + message: "kid uses the reserved app-credential namespace".into(), + })); + } + if !kid.starts_with(|c: char| c.is_ascii_lowercase()) { return Err(Report::new(TrustedServerError::BadRequest { message: "kid must start with a lowercase ASCII letter".into(), @@ -1075,6 +1087,23 @@ mod tests { ); } + #[test] + fn app_binding_namespace_is_reserved_but_legacy_keys_remain_removable() { + for kid in [ + "ts1_fictional", + "ts1_FICTIONAL", + "tS1_fictional", + "tS1-fictional", + "ts1.fictional", + ] { + assert!( + validate_kid(kid).is_err(), + "should reject reserved namespace" + ); + validate_kid_format(kid).expect("should permit legacy binding removal"); + } + } + #[test] fn validate_kid_accepts_allowed_operator_supplied_ids() { validate_kid("azAZ09-_.:").expect("should accept allowed kid characters"); diff --git a/crates/trusted-server-core/src/s3_sigv4.rs b/crates/trusted-server-core/src/s3_sigv4.rs index dc4dc25ad2..ea54983702 100644 --- a/crates/trusted-server-core/src/s3_sigv4.rs +++ b/crates/trusted-server-core/src/s3_sigv4.rs @@ -37,7 +37,7 @@ const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; #[derive(Debug, Clone)] pub struct S3Credentials { /// `AWS` access key ID. - pub access_key_id: String, + pub access_key_id: Redacted, /// `AWS` secret access key. pub secret_access_key: Redacted, /// Optional `AWS` session token for temporary credentials. @@ -59,7 +59,7 @@ pub struct S3Credentials { /// /// Returns a proxy error when a required signing header is missing or a signing /// header value is invalid. -pub fn sign_headers( +pub(crate) fn sign_headers( method: &Method, url: &Url, headers: &mut HeaderMap, @@ -67,6 +67,22 @@ pub fn sign_headers( credentials: &S3Credentials, now: SystemTime, ) -> Result<(), Report> { + if !matches!(*method, Method::GET | Method::HEAD) || url.scheme() != "https" { + return Err(Report::new(TrustedServerError::Proxy { + message: "S3 signing requires HTTPS GET or HEAD".to_owned(), + })); + } + crate::secret_bindings::validate_credential( + "s3id", + credentials.access_key_id.expose().as_bytes(), + )?; + crate::secret_bindings::validate_credential( + "s3secret", + credentials.secret_access_key.expose().as_bytes(), + )?; + if let Some(token) = &credentials.session_token { + crate::secret_bindings::validate_credential("s3sess", token.expose().as_bytes())?; + } let datetime = DateTime::::from(now); let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string(); let date_stamp = datetime.format("%Y%m%d").to_string(); @@ -92,7 +108,7 @@ pub fn sign_headers( { headers.insert( "x-amz-security-token", - HeaderValue::from_str(token).change_context_invalid_header("x-amz-security-token")?, + sensitive_header(token).change_context_invalid_header("x-amz-security-token")?, ); } @@ -114,17 +130,23 @@ pub fn sign_headers( let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())); let authorization = format!( "{ALGORITHM} Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", - credentials.access_key_id, + credentials.access_key_id.expose(), ); headers.insert( header::AUTHORIZATION, - HeaderValue::from_str(&authorization).change_context_invalid_header("authorization")?, + sensitive_header(&authorization).change_context_invalid_header("authorization")?, ); Ok(()) } +fn sensitive_header(value: &str) -> Result { + let mut header = HeaderValue::from_str(value)?; + header.set_sensitive(true); + Ok(header) +} + fn canonical_headers(headers: &HeaderMap) -> Result<(String, String), Report> { let mut names = vec!["host", "x-amz-content-sha256", "x-amz-date"]; if headers.contains_key("x-amz-security-token") { @@ -264,7 +286,7 @@ mod tests { HeaderValue::from_static("examplebucket.s3.amazonaws.com"), ); let credentials = S3Credentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), + access_key_id: Redacted::new("AKIAIOSFODNN7EXAMPLE".to_string()), secret_access_key: Redacted::new( "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), ), @@ -304,7 +326,7 @@ mod tests { #[test] fn credentials_debug_redacts_secret_material() { let credentials = S3Credentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), + access_key_id: Redacted::new("AKIAIOSFODNN7EXAMPLE".to_string()), secret_access_key: Redacted::new("debug-secret-key".to_string()), session_token: Some(Redacted::new("debug-session-token".to_string())), }; @@ -312,8 +334,8 @@ mod tests { let debug_output = format!("{credentials:?}"); assert!( - debug_output.contains("AKIAIOSFODNN7EXAMPLE"), - "should leave non-secret access key ID visible" + !debug_output.contains("AKIAIOSFODNN7EXAMPLE"), + "should redact the access key ID" ); assert!( debug_output.contains("[REDACTED]"), diff --git a/crates/trusted-server-core/src/secret_bindings.rs b/crates/trusted-server-core/src/secret_bindings.rs new file mode 100644 index 0000000000..dd485aaf88 --- /dev/null +++ b/crates/trusted-server-core/src/secret_bindings.rs @@ -0,0 +1,1522 @@ +//! TS-owned, label-free credential contexts and validated runtime capabilities. +//! +//! Serialized plans explain lookups. They cannot be deserialized into authority. +//! Only a successful native read and representation validation creates a resolved use. + +use std::collections::BTreeMap; +use std::fmt; + +use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; +use error_stack::Report; +use http::HeaderValue; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::error::TrustedServerError; +use crate::platform::{PlatformSecretStore, StoreName}; +use crate::settings::{ + AssetOriginAuth, Ec, EcPartner, Handler, OriginQueryPolicy, Publisher, Settings, + TrustedClientIpConfig, +}; + +/// Versioned domain separator for the context-derived native-key ABI. +pub const BINDING_ABI_VERSION: &str = "trusted-server-secret-binding-v1"; +/// Logical native store containing all app credential bindings. +pub const SECRET_STORE_ID: &str = "trusted_server_secrets"; + +/// Fixed executable-owned purposes, also used for legacy-leaf validation metadata. +pub struct BindingDescriptor { + /// Fixed CLI role selector identifying this credential purpose. + pub id: &'static str, + /// Executable-owned purpose code included in key derivation. + pub scope: &'static str, + /// Former credential path used by validation metadata, with `[]` for collections. + pub schema_path: &'static str, + /// Whether the former validation leaf was optional, not permission to probe. + pub optional: bool, +} +/// Fixed credential purposes shared by compilation and validation metadata. +pub const DESCRIPTORS: &[BindingDescriptor] = &[ + BindingDescriptor { + id: "publisher.proxy", + scope: "proxy", + schema_path: "publisher.proxy_secret", + optional: false, + }, + BindingDescriptor { + id: "ec.passphrase", + scope: "ecpass", + schema_path: "ec.passphrase", + optional: false, + }, + BindingDescriptor { + id: "ec.inbound", + scope: "ecin", + schema_path: "ec.partners[].api_token", + optional: true, + }, + BindingDescriptor { + id: "ec.pull", + scope: "ecpull", + schema_path: "ec.partners[].ts_pull_token", + optional: true, + }, + BindingDescriptor { + id: "handler.password", + scope: "handler", + schema_path: "handlers[].password", + optional: false, + }, + BindingDescriptor { + id: "clientip.shared", + scope: "clientip", + schema_path: "trusted_client_ip.shared_secret", + optional: false, + }, + BindingDescriptor { + id: "tinybird.auction", + scope: "tinybird", + schema_path: "tinybird.auction_token_secret", + optional: true, + }, + BindingDescriptor { + id: "datadome.server", + scope: "ddserver", + schema_path: "integrations.datadome.server_side_key_secret_name", + optional: true, + }, + BindingDescriptor { + id: "datadome.bypass", + scope: "ddbypass", + schema_path: "integrations.datadome.protection_test_bypass.credential_secret_name", + optional: true, + }, + BindingDescriptor { + id: "s3.access-id", + scope: "s3id", + schema_path: "proxy.asset_routes[].auth.access_key_id", + optional: true, + }, + BindingDescriptor { + id: "s3.secret", + scope: "s3secret", + schema_path: "proxy.asset_routes[].auth.secret_access_key", + optional: true, + }, + BindingDescriptor { + id: "s3.session", + scope: "s3sess", + schema_path: "proxy.asset_routes[].auth.session_token", + optional: true, + }, +]; + +/// Metadata identifies protected validation leaves, not operator-configurable references. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::{DESCRIPTORS, secret_fields}; +/// assert_eq!(secret_fields().len(), DESCRIPTORS.len()); +/// ``` +#[must_use] +pub fn secret_fields() -> Vec { + DESCRIPTORS + .iter() + .map(|descriptor| { + let mut path = Vec::new(); + for component in descriptor.schema_path.split('.') { + let name = component.strip_suffix("[]").unwrap_or(component); + if matches!( + name, + "partners" + | "trusted_client_ip" + | "tinybird" + | "integrations" + | "datadome" + | "protection_test_bypass" + | "proxy" + | "asset_routes" + | "auth" + ) { + path.push(SecretPathSegment::OptionalField(name.into())); + } else { + path.push(SecretPathSegment::Field(name.into())); + } + if component.ends_with("[]") { + path.push(SecretPathSegment::ArrayEach); + } + } + SecretField { + kind: SecretKind::KeyInDefault, + optional: descriptor.optional, + path, + } + }) + .collect() +} + +type BindingResult = Result>; + +fn invalid(path: &str, category: &str) -> Report { + Report::new(TrustedServerError::Configuration { + message: format!("{path}: {category}"), + }) +} + +/// Fixed send target. Endpoints never come from resolved secret values. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CompiledTarget { + /// A credential use that does not authorize an outbound destination. + None, + /// An approved partner pull-sync endpoint. + Pull { + /// Normalized HTTPS endpoint before the runtime EC-ID query is added. + endpoint: String, + }, + /// An approved Tinybird Events endpoint and dataset. + Tinybird { + /// Normalized HTTPS Events endpoint without its dataset query. + endpoint: String, + /// Exact dataset used to build the Events request's `name` query. + dataset: String, + }, + /// An approved `DataDome` Protection endpoint. + DataDome { + /// Complete normalized HTTPS validation endpoint. + endpoint: String, + }, + /// An approved S3 origin and its compiled signing context. + S3 { + /// Normalized HTTPS origin restricting every signed request. + origin: String, + /// Validated signing region included in the bound route policy. + region: String, + }, +} + +/// Public, secret-free explanation of an executable-owned binding. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct CompiledBinding { + descriptor_id: &'static str, + scope: &'static str, + field_path: String, + subject: Vec, + export: Vec, + key: String, + target: CompiledTarget, +} + +impl CompiledBinding { + /// Return the fixed role selector for this binding. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// let role = binding.descriptor_id(); + /// assert!(!role.is_empty()); + /// # } + /// ``` + #[must_use] + pub fn descriptor_id(&self) -> &str { + self.descriptor_id + } + /// Return the executable-owned purpose code used in key derivation. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// assert!(!binding.scope().is_empty()); + /// # } + /// ``` + #[must_use] + pub fn scope(&self) -> &str { + self.scope + } + /// Return the concrete schema path of this credential use. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// assert!(!binding.field_path().is_empty()); + /// # } + /// ``` + #[must_use] + pub fn field_path(&self) -> &str { + &self.field_path + } + /// Return the normalized identity components included in key derivation. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// let identity = binding.subject(); + /// assert!(!identity.is_empty()); + /// # } + /// ``` + #[must_use] + pub fn subject(&self) -> &[String] { + &self.subject + } + /// Return the destination-policy components included in key derivation. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// let policy = binding.export(); + /// assert!(!policy.is_empty()); + /// # } + /// ``` + #[must_use] + pub fn export(&self) -> &[String] { + &self.export + } + /// Return the complete generated native lookup key. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// assert!(binding.key().starts_with("ts1_")); + /// # } + /// ``` + #[must_use] + pub fn key(&self) -> &str { + &self.key + } + /// Return the destination compiled together with this lookup identity. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::CompiledBinding) { + /// let approved_target = binding.target(); + /// # } + /// ``` + #[must_use] + pub fn target(&self) -> &CompiledTarget { + &self.target + } + + /// Compare authorization identity without comparing the owning schema location. + /// + /// This includes purpose, subject, export policy, derived key, and typed target. + /// Physical-request coalescers must separately compare complete native identities. + /// Runtime context validation instead uses full equality, including `field_path`. + /// + /// # Examples + /// ``` + /// # fn inspect(first: &trusted_server_core::secret_bindings::CompiledBinding, + /// # second: &trusted_server_core::secret_bindings::CompiledBinding) { + /// let equal_context = first.same_authorization(second); + /// # } + /// ``` + #[must_use] + pub fn same_authorization(&self, other: &Self) -> bool { + self.descriptor_id == other.descriptor_id + && self.scope == other.scope + && self.subject == other.subject + && self.export == other.export + && self.key == other.key + && self.target == other.target + } +} + +/// Secret-free compiled lookups for every active credential use. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct BindingPlan { + abi_version: &'static str, + entries: Vec, +} + +impl BindingPlan { + /// Return all active compiled uses, including repeated owners of an equal context. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::BindingPlan) { + /// for entry in binding.entries() { + /// assert!(entry.key().starts_with("ts1_")); + /// } + /// # } + /// ``` + #[must_use] + pub fn entries(&self) -> &[CompiledBinding] { + &self.entries + } +} + +/// Validated credential uses captured together in one loaded snapshot. +/// +/// These uses cannot be deserialized, serialized, or arbitrarily constructed. +/// The default value contains no capabilities. +#[derive(Clone, Default)] +pub struct ResolvedBindings { + entries: Vec, +} + +impl fmt::Debug for ResolvedBindings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedBindings") + .field("count", &self.entries.len()) + .finish_non_exhaustive() + } +} + +/// A validated native credential paired with its compiled identity and destination. +#[derive(Clone)] +pub struct ResolvedBinding { + compiled: CompiledBinding, + credential: String, + bearer_header: Option, +} + +impl fmt::Debug for ResolvedBinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedBinding") + .field("scope", &self.compiled.scope) + .field("field_path", &self.compiled.field_path) + .finish_non_exhaustive() + } +} + +impl ResolvedBinding { + /// Return the context that authorized this native credential lookup. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// assert!(binding.compiled().key().starts_with("ts1_")); + /// # } + /// ``` + #[must_use] + pub fn compiled(&self) -> &CompiledBinding { + &self.compiled + } + /// Borrow the exact validated credential value without normalization. + /// + /// The returned value is sensitive. Do not log it or serialize it into configuration. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// assert!(!binding.credential().is_empty()); + /// # } + /// ``` + #[must_use] + pub fn credential(&self) -> &str { + &self.credential + } + /// Borrow the prevalidated sensitive Bearer header for supported outbound roles. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// if let Some(header) = binding.bearer_header() { + /// assert!(header.is_sensitive()); + /// } + /// # } + /// ``` + #[must_use] + pub fn bearer_header(&self) -> Option<&HeaderValue> { + self.bearer_header.as_ref() + } + /// Return the identity components captured with this credential. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// assert_eq!(binding.subject(), binding.compiled().subject()); + /// # } + /// ``` + #[must_use] + pub fn subject(&self) -> &[String] { + self.compiled.subject() + } + /// Return the destination-policy components captured with this credential. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// assert_eq!(binding.export(), binding.compiled().export()); + /// # } + /// ``` + #[must_use] + pub fn export(&self) -> &[String] { + self.compiled.export() + } + /// Return the approved endpoint or S3 origin, if this use exports a credential. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// if let Some(endpoint) = binding.endpoint() { + /// assert!(endpoint.starts_with("https://")); + /// } + /// # } + /// ``` + #[must_use] + pub fn endpoint(&self) -> Option<&str> { + match &self.compiled.target { + CompiledTarget::None => None, + CompiledTarget::Pull { endpoint } + | CompiledTarget::Tinybird { endpoint, .. } + | CompiledTarget::DataDome { endpoint } => Some(endpoint), + CompiledTarget::S3 { origin, .. } => Some(origin), + } + } + + /// Return the named signing region captured with an S3 credential. + /// + /// Other credential roles have no signing region. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBinding) { + /// if let Some(region) = binding.s3_region() { + /// assert!(!region.is_empty()); + /// } + /// # } + /// ``` + #[must_use] + pub fn s3_region(&self) -> Option<&str> { + match &self.compiled.target { + CompiledTarget::S3 { region, .. } => Some(region), + _ => None, + } + } +} + +impl ResolvedBindings { + /// Return the validated credential uses belonging to this loaded snapshot. + /// + /// # Examples + /// ``` + /// # fn inspect(binding: &trusted_server_core::secret_bindings::ResolvedBindings) { + /// for loaded in binding.entries() { + /// assert!(!loaded.credential().is_empty()); + /// } + /// # } + /// ``` + #[must_use] + pub fn entries(&self) -> &[ResolvedBinding] { + &self.entries + } + /// Find a loaded use by fixed role selector and concrete schema path. + /// + /// This does not validate mutable settings against the captured context. + /// Exporter construction must use [`Self::require_for_settings`] instead. + /// + /// # Examples + /// ``` + /// # fn inspect(bindings: &trusted_server_core::secret_bindings::ResolvedBindings) { + /// let loaded = bindings.get("publisher.proxy", "publisher.proxy_secret"); + /// # } + /// ``` + #[must_use] + pub fn get(&self, descriptor_id: &str, field_path: &str) -> Option<&ResolvedBinding> { + self.entries.iter().find(|entry| { + entry.compiled.descriptor_id == descriptor_id && entry.compiled.field_path == field_path + }) + } + /// Reject changed identity, activation, route policy, or destination before export. + /// + /// # Errors + /// Returns a schema-only error for invalid settings or a changed/missing capability. + /// + /// # Examples + /// ``` + /// # fn inspect(bindings: &trusted_server_core::secret_bindings::ResolvedBindings, + /// # settings: &trusted_server_core::settings::Settings) { + /// let result = bindings.validated_for(settings, "ec.pull", "ec.partners[0].ts_pull_token"); + /// # } + /// ``` + pub fn validated_for( + &self, + settings: &Settings, + descriptor_id: &str, + field_path: &str, + ) -> BindingResult> { + let current = compile_bindings(settings)?; + self.validated_for_plan(¤t, descriptor_id, field_path) + } + + /// Compare a loaded capability against an immutable current compilation. + /// + /// This borrows one operation-local plan so repeated role checks do not + /// recompile unrelated identities, routes, or destinations. + /// + /// # Errors + /// Rejects missing or unequal current/loaded contexts with a schema-only error. + pub(crate) fn validated_for_plan( + &self, + current: &BindingPlan, + descriptor_id: &str, + field_path: &str, + ) -> BindingResult> { + let expected = current + .entries + .iter() + .find(|entry| entry.descriptor_id == descriptor_id && entry.field_path == field_path); + match (expected, self.get(descriptor_id, field_path)) { + (None, None) => Ok(None), + (Some(expected), Some(actual)) if expected == &actual.compiled => Ok(Some(actual)), + _ => Err(invalid(field_path, "resolved binding context mismatch")), + } + } + /// Require an active capability matching the current compiled context. + /// + /// # Errors + /// Returns a schema-only error for invalid settings or a changed/missing capability. + /// + /// # Examples + /// ``` + /// # fn inspect(bindings: &trusted_server_core::secret_bindings::ResolvedBindings, + /// # settings: &trusted_server_core::settings::Settings) { + /// let result = bindings.require_for_settings(settings, "publisher.proxy", "publisher.proxy_secret"); + /// # } + /// ``` + pub fn require_for_settings( + &self, + settings: &Settings, + descriptor_id: &str, + field_path: &str, + ) -> BindingResult<&ResolvedBinding> { + let current = compile_bindings(settings)?; + self.require_for_plan(¤t, descriptor_id, field_path) + } + + /// Require a loaded capability matching the borrowed operation-local plan. + /// + /// # Errors + /// Rejects inactive, absent, or unequal current/loaded credential contexts. + pub(crate) fn require_for_plan( + &self, + current: &BindingPlan, + descriptor_id: &str, + field_path: &str, + ) -> BindingResult<&ResolvedBinding> { + self.validated_for_plan(current, descriptor_id, field_path)? + .ok_or_else(|| invalid(field_path, "required resolved binding unavailable")) + } +} + +fn checked_u32(value: usize, path: &str) -> BindingResult { + u32::try_from(value).map_err(|_| invalid(path, "binding context too large")) +} +fn lp(out: &mut Vec, value: &str) -> BindingResult<()> { + out.extend_from_slice(&checked_u32(value.len(), "secret_bindings")?.to_be_bytes()); + out.extend_from_slice(value.as_bytes()); + Ok(()) +} +fn vector(out: &mut Vec, values: &[String]) -> BindingResult<()> { + out.extend_from_slice(&checked_u32(values.len(), "secret_bindings")?.to_be_bytes()); + for value in values { + lp(out, value)?; + } + Ok(()) +} +fn derive_key(scope: &str, subject: &[String], export: &[String]) -> BindingResult { + let mut payload = b"trusted-server-secret-binding-v1\0".to_vec(); + lp(&mut payload, scope)?; + vector(&mut payload, subject)?; + vector(&mut payload, export)?; + let digest = Sha256::digest(payload); + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut encoded = String::from("ts1_"); + let mut bits = 0_u32; + let mut available = 0_u32; + for byte in digest { + bits = (bits << 8) | u32::from(byte); + available += 8; + while available >= 5 { + available -= 5; + encoded.push(ALPHABET[((bits >> available) & 31) as usize] as char); + } + } + if available > 0 { + encoded.push(ALPHABET[((bits << (5 - available)) & 31) as usize] as char); + } + Ok(encoded) +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() +} + +/// Validate original syntax before URL parsing can erase forbidden distinctions. +/// +/// Errors contain the supplied schema path, never the input endpoint. +/// +/// # Errors +/// Rejects syntax outside the v1 HTTPS DNS endpoint grammar. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::normalize_endpoint; +/// let endpoint = normalize_endpoint("HTTPS://SYNC.EXAMPLE.COM:443/pull", "ec.partners[0].pull_sync_url") +/// .expect("should normalize the approved HTTPS endpoint"); +/// assert_eq!(endpoint, "https://sync.example.com/pull"); +/// ``` +pub fn normalize_endpoint(input: &str, field_path: &str) -> BindingResult { + let fail = || invalid(field_path, "invalid credential endpoint"); + if !input.is_ascii() + || input + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + || input.contains(['\\', '?', '#', '%']) + { + return Err(fail()); + } + let (scheme, rest) = input.split_once("://").ok_or_else(fail)?; + if !scheme.eq_ignore_ascii_case("https") { + return Err(fail()); + } + let (authority, path) = rest + .find('/') + .map_or((rest, "/"), |index| (&rest[..index], &rest[index..])); + if authority.is_empty() || authority.contains(['@', '[', ']']) { + return Err(fail()); + } + let (host, port) = match authority.split_once(':') { + Some((host, port)) => { + if port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) { + return Err(fail()); + } + let value = port.parse::().map_err(|_| fail())?; + if value == 0 { + return Err(fail()); + } + (host, (value != 443).then_some(value)) + } + None => (authority, None), + }; + if host.ends_with('.') + || host.len() > 253 + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || !label.as_bytes()[0].is_ascii_alphanumeric() + || !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + || !label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + { + return Err(fail()); + } + // URL's host classification catches nonstandard numeric IPv4 spellings too. + let parsed = url::Url::parse(input).map_err(|_| fail())?; + if !matches!(parsed.host(), Some(url::Host::Domain(_))) { + return Err(fail()); + } + if !path + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"/-._~".contains(&b)) + || path.split('/').any(|part| part == "." || part == "..") + { + return Err(fail()); + } + let host = host.to_ascii_lowercase(); + Ok(match port { + Some(port) => format!("https://{host}:{port}{path}"), + None => format!("https://{host}{path}"), + }) +} + +/// Compile a pull destination and its optional hostname assertion. +/// +/// # Errors +/// Rejects missing/invalid endpoints or mismatched allowlist assertions. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::normalize_pull_endpoint; +/// # fn inspect(partner: &trusted_server_core::settings::EcPartner) { +/// let endpoint = normalize_pull_endpoint(partner, "ec.partners[0].pull_sync_url"); +/// # } +/// ``` +pub fn normalize_pull_endpoint(partner: &EcPartner, field_path: &str) -> BindingResult { + let raw = partner + .pull_sync_url + .as_deref() + .ok_or_else(|| invalid(field_path, "required credential endpoint unavailable"))?; + let endpoint = normalize_endpoint(raw, field_path)?; + let host = url::Url::parse(&endpoint) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .ok_or_else(|| invalid(field_path, "invalid credential endpoint"))?; + for allowed in &partner.pull_sync_allowed_domains { + if allowed.trim() != allowed || !allowed.eq_ignore_ascii_case(&host) { + return Err(invalid(field_path, "pull endpoint assertion mismatch")); + } + } + Ok(endpoint) +} + +/// Compile a Tinybird DNS authority into its fixed HTTPS Events API endpoint. +/// +/// Callers retain their stage-specific activation and empty-disabled-host rules. +/// +/// # Errors +/// Rejects schemes, paths, or syntax outside the shared credential endpoint grammar. +pub(crate) fn tinybird_events_endpoint(authority: &str) -> BindingResult { + if authority.contains('/') { + return Err(invalid("tinybird.api_host", "invalid credential endpoint")); + } + normalize_endpoint( + &format!("https://{authority}/v0/events"), + "tinybird.api_host", + ) +} + +/// Validate the existing exact-byte Tinybird datasource identifier grammar. +/// +/// # Errors +/// Rejects empty, over-128-byte, or non-ASCII-alphanumeric/underscore identifiers. +pub(crate) fn validate_tinybird_dataset(value: &str, field_path: &str) -> BindingResult<()> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(invalid( + field_path, + "must be a non-empty datasource identifier", + )); + } + Ok(()) +} + +/// Compile active roles from effective ordinary settings without reading secrets. +/// +/// # Errors +/// Rejects malformed active identities, destinations, activation, and route policy. +/// +/// # Examples +/// ``` +/// use trusted_server_core::{secret_bindings::compile_bindings, settings::Settings}; +/// let plan = compile_bindings(&Settings::default()).expect("should compile the required app roles"); +/// assert_eq!(plan.entries().len(), 2); +/// ``` +pub fn compile_bindings(settings: &Settings) -> BindingResult { + let mut entries = Vec::new(); + let mut add = |descriptor_id: &'static str, + scope: &'static str, + field_path: String, + subject: Vec, + export: Vec, + target: CompiledTarget| + -> BindingResult<()> { + if !DESCRIPTORS + .iter() + .any(|descriptor| descriptor.id == descriptor_id && descriptor.scope == scope) + { + return Err(invalid("secret_bindings", "unsupported binding descriptor")); + } + let key = derive_key(scope, &subject, &export)?; + entries.push(CompiledBinding { + descriptor_id, + scope, + field_path, + subject, + export, + key, + target, + }); + Ok(()) + }; + add( + "publisher.proxy", + "proxy", + "publisher.proxy_secret".into(), + strings(&["app"]), + strings(&["none"]), + CompiledTarget::None, + )?; + add( + "ec.passphrase", + "ecpass", + "ec.passphrase".into(), + strings(&["app"]), + strings(&["none"]), + CompiledTarget::None, + )?; + let mut domains = std::collections::BTreeSet::new(); + for (index, partner) in settings.ec.partners.iter().enumerate() { + let path = format!("ec.partners[{index}]"); + EcPartner::validate_source_domain(&partner.source_domain) + .map_err(|_| invalid(&format!("{path}.source_domain"), "invalid partner identity"))?; + let domain = partner + .source_domain + .trim_end_matches('.') + .to_ascii_lowercase(); + if !domains.insert(domain.clone()) { + return Err(invalid("ec.partners", "duplicate partner identity")); + } + let subject = strings(&["partner", &domain]); + if partner.api_token_enabled { + add( + "ec.inbound", + "ecin", + format!("{path}.api_token"), + subject.clone(), + strings(&["none"]), + CompiledTarget::None, + )?; + } + if partner.pull_sync_enabled { + let endpoint = normalize_pull_endpoint(partner, &format!("{path}.pull_sync_url"))?; + add( + "ec.pull", + "ecpull", + format!("{path}.ts_pull_token"), + subject, + strings(&["pull", &endpoint]), + CompiledTarget::Pull { endpoint }, + )?; + } + } + for (index, handler) in settings.handlers.iter().enumerate() { + let path = format!("handlers[{index}].password"); + if handler.path.is_empty() + || regex::Regex::new(&handler.path).is_err() + || handler.username.expose().is_empty() + { + return Err(invalid(&path, "invalid handler identity")); + } + add( + "handler.password", + "handler", + path, + strings(&["handler", &handler.path, handler.username.expose()]), + strings(&["none"]), + CompiledTarget::None, + )?; + } + if settings.trusted_client_ip.is_some() { + add( + "clientip.shared", + "clientip", + "trusted_client_ip.shared_secret".into(), + strings(&["app"]), + strings(&["none"]), + CompiledTarget::None, + )?; + } + if settings.tinybird.access_enabled { + return Err(invalid( + "tinybird.access_enabled", + "unsupported telemetry activation", + )); + } + if settings.tinybird.enabled { + let endpoint = tinybird_events_endpoint(&settings.tinybird.api_host)?; + let dataset = &settings.tinybird.auction_dataset; + validate_tinybird_dataset(dataset, "tinybird.auction_dataset")?; + add( + "tinybird.auction", + "tinybird", + "tinybird.auction_token_secret".into(), + strings(&["app"]), + strings(&["tinybird", &endpoint, dataset]), + CompiledTarget::Tinybird { + endpoint, + dataset: dataset.clone(), + }, + )?; + } + if let Some(raw) = settings.integrations.get("datadome") { + if !raw.is_object() { + return Err(invalid( + "integrations.datadome", + "expected configuration object", + )); + } + // Validate the complete present dynamic schema before any native read. + let datadome_config = + serde_json::from_value::(raw.clone()) + .map_err(|_| invalid("integrations.datadome", "invalid integration structure"))?; + let enabled = bool_value(raw, "enabled", false, "integrations.datadome.enabled")?; + let protection = bool_value( + raw, + "enable_protection", + false, + "integrations.datadome.enable_protection", + )?; + let bypass = match raw.get("protection_test_bypass") { + None | Some(Value::Null) => false, + Some(value) if value.is_object() => bool_value( + value, + "enabled", + false, + "integrations.datadome.protection_test_bypass.enabled", + )?, + _ => { + return Err(invalid( + "integrations.datadome.protection_test_bypass", + "expected configuration object", + )); + } + }; + if enabled && protection { + let normalized = normalize_endpoint( + &datadome_config.protection_api_origin, + "integrations.datadome.protection_api_origin", + )?; + if url::Url::parse(&normalized).map_or(true, |url| url.path() != "/") { + return Err(invalid( + "integrations.datadome.protection_api_origin", + "protection endpoint must use origin only", + )); + } + let endpoint = format!("{}validate-request", normalized); + add( + "datadome.server", + "ddserver", + "integrations.datadome.server_side_key_secret_name".into(), + strings(&["app"]), + strings(&["datadome", &endpoint]), + CompiledTarget::DataDome { endpoint }, + )?; + if bypass { + add( + "datadome.bypass", + "ddbypass", + "integrations.datadome.protection_test_bypass.credential_secret_name".into(), + strings(&["app"]), + strings(&["none"]), + CompiledTarget::None, + )?; + } + } + } + for (index, route) in settings.proxy.asset_routes.iter().enumerate() { + let Some(AssetOriginAuth::S3SigV4(auth)) = &route.auth else { + continue; + }; + let path = format!("proxy.asset_routes[{index}]"); + let endpoint = normalize_endpoint(&route.origin_url, &format!("{path}.origin_url"))?; + let url = url::Url::parse(&endpoint) + .map_err(|_| invalid(&path, "invalid credential endpoint"))?; + if url.path() != "/" { + return Err(invalid( + &format!("{path}.origin_url"), + "authenticated origin must not contain path", + )); + } + let origin = endpoint.trim_end_matches('/').to_owned(); + if route.prefix.is_empty() + || !route.prefix.starts_with('/') + || auth.region.is_empty() + || !auth + .region + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(invalid(&path, "invalid authenticated route policy")); + } + if route.path_pattern.is_some() != route.target_path.is_some() + || route + .path_pattern + .as_ref() + .is_some_and(|pattern| regex::Regex::new(pattern).is_err()) + { + return Err(invalid(&path, "invalid authenticated route policy")); + } + let (pattern_presence, pattern) = route + .path_pattern + .as_deref() + .map_or(("absent", ""), |value| ("present", value)); + let (target_presence, target) = route + .target_path + .as_deref() + .map_or(("absent", ""), |value| ("present", value)); + let query = match route.origin_query_policy() { + OriginQueryPolicy::Preserve => "preserve", + OriginQueryPolicy::Strip => "strip", + }; + if route.image_optimizer_enabled() && query == "preserve" { + return Err(invalid(&path, "invalid authenticated route query policy")); + } + let subject = strings(&[ + "route", + &route.prefix, + &origin, + pattern_presence, + pattern, + target_presence, + target, + &auth.region, + query, + ]); + let target = CompiledTarget::S3 { + origin, + region: auth.region.clone(), + }; + add( + "s3.access-id", + "s3id", + format!("{path}.auth.access_key_id"), + subject.clone(), + strings(&["s3"]), + target.clone(), + )?; + add( + "s3.secret", + "s3secret", + format!("{path}.auth.secret_access_key"), + subject.clone(), + strings(&["s3"]), + target.clone(), + )?; + if auth.session_token_enabled { + add( + "s3.session", + "s3sess", + format!("{path}.auth.session_token"), + subject, + strings(&["s3"]), + target, + )?; + } + } + // Stable across hash-map iteration and ordinary config property ordering. + entries.sort_by(|a, b| { + a.descriptor_id + .cmp(b.descriptor_id) + .then(a.subject.cmp(&b.subject)) + .then(a.export.cmp(&b.export)) + .then(a.field_path.cmp(&b.field_path)) + }); + Ok(BindingPlan { + abi_version: BINDING_ABI_VERSION, + entries, + }) +} + +fn bool_value(value: &Value, key: &str, default: bool, path: &str) -> BindingResult { + match value.get(key) { + None => Ok(default), + Some(Value::Bool(value)) => Ok(*value), + _ => Err(invalid(path, "expected boolean")), + } +} + +/// Shared runtime/provisioning credential representation validator. Never trims. +/// +/// # Errors +/// Rejects unknown scopes, invalid UTF-8, empty/placeholder values, and invalid wire representation. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::validate_credential; +/// assert!(validate_credential("tinybird", b"fictional-token").is_ok()); +/// assert!(validate_credential("tinybird", b"fictional-token\n").is_err()); +/// ``` +pub fn validate_credential(scope: &str, bytes: &[u8]) -> BindingResult<()> { + validate_value(scope, bytes, "secret_bindings") +} +fn validate_value(scope: &str, bytes: &[u8], path: &str) -> BindingResult<()> { + let value = std::str::from_utf8(bytes) + .map_err(|_| invalid(path, "invalid credential representation"))?; + if value.is_empty() { + return Err(invalid(path, "empty credential")); + } + let placeholder = match scope { + "proxy" => Publisher::is_placeholder_proxy_secret(value), + "ecpass" => Ec::is_placeholder_passphrase(value), + "ecin" | "ecpull" => EcPartner::is_placeholder_api_token(value), + "handler" => Handler::is_placeholder_password(value), + "clientip" => TrustedClientIpConfig::is_placeholder_shared_secret(value), + "tinybird" | "ddserver" | "ddbypass" | "s3id" | "s3secret" | "s3sess" => false, + _ => return Err(invalid(path, "unsupported credential scope")), + }; + if placeholder { + return Err(invalid(path, "placeholder credential")); + } + if matches!( + scope, + "ecpass" | "ecin" | "ecpull" | "clientip" | "ddbypass" + ) && bytes.len() < 32 + { + return Err(invalid(path, "credential below minimum length")); + } + if scope == "clientip" && !bytes.iter().all(|byte| matches!(*byte, b'!'..=b'~')) { + return Err(invalid(path, "invalid client IP credential")); + } + if scope == "ecin" && value.trim().is_empty() { + return Err(invalid(path, "empty credential")); + } + if matches!(scope, "ecpull" | "tinybird") { + let token = value.trim_end_matches('='); + if token.is_empty() + || !token + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-._~+/".contains(&b)) + || !value[token.len()..].bytes().all(|b| b == b'=') + { + return Err(invalid(path, "invalid bearer credential")); + } + HeaderValue::from_str(&format!("Bearer {value}")) + .map_err(|_| invalid(path, "invalid bearer credential"))?; + } + if matches!(scope, "s3id" | "s3sess") { + let header = HeaderValue::from_str(value) + .map_err(|_| invalid(path, "invalid signing credential"))?; + if value.bytes().any(|byte| byte.is_ascii_control()) || header.to_str().is_err() { + return Err(invalid(path, "invalid signing credential")); + } + } + Ok(()) +} + +/// Atomic resolution. Identical contexts are read once per load, with no fallback. +/// +/// # Errors +/// Rejects invalid contexts, missing native values, and invalid credentials. +/// The store is selected by trusted loader/runtime mappings, never app config. +pub(crate) fn resolve_bindings( + settings: &Settings, + store: &dyn PlatformSecretStore, + store_name: &StoreName, +) -> BindingResult { + let plan = compile_bindings(settings)?; + let mut reads: BTreeMap = BTreeMap::new(); + let mut entries = Vec::with_capacity(plan.entries.len()); + for compiled in plan.entries { + let credential = if let Some((previous, value)) = reads.get(&compiled.key) { + if previous.scope != compiled.scope + || previous.subject != compiled.subject + || previous.export != compiled.export + { + return Err(invalid(&compiled.field_path, "binding identity collision")); + } + value.clone() + } else { + let bytes = store + .get_bytes(store_name, &compiled.key) + .map_err(|_| invalid(&compiled.field_path, "native credential unavailable"))?; + validate_value(compiled.scope, &bytes, &compiled.field_path)?; + let value = String::from_utf8(bytes) + .map_err(|_| invalid(&compiled.field_path, "invalid credential representation"))?; + reads.insert(compiled.key.clone(), (compiled.clone(), value.clone())); + value + }; + let bearer_header = if matches!(compiled.scope, "ecpull" | "tinybird") { + let mut value = HeaderValue::from_str(&format!("Bearer {credential}")) + .map_err(|_| invalid(&compiled.field_path, "invalid bearer credential"))?; + value.set_sensitive(true); + Some(value) + } else { + None + }; + entries.push(ResolvedBinding { + compiled, + credential, + bearer_header, + }); + } + Ok(ResolvedBindings { entries }) +} + +/// Reject old config inputs even when a feature is inactive or value is null. +/// +/// # Errors +/// Returns only a schema path and fixed migration category for removed inputs. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::reject_removed_references; +/// let value = serde_json::json!({"publisher": {"proxy_secret": "removed-label"}}); +/// assert!(reject_removed_references(&value).is_err()); +/// ``` +pub fn reject_removed_references(value: &Value) -> BindingResult<()> { + for (path, field) in [ + ("publisher", "proxy_secret"), + ("ec", "passphrase"), + ("trusted_client_ip", "shared_secret"), + ("tinybird", "auction_token_secret"), + ("integrations.datadome", "server_side_key_secret_name"), + ( + "integrations.datadome.protection_test_bypass", + "credential_secret_name", + ), + ] { + let node = path.split('.').try_fold(value, |node, part| node.get(part)); + if node.is_some_and(|node| node.get(field).is_some()) { + return Err(invalid( + &format!("{path}.{field}"), + "removed credential reference; migrate configuration", + )); + } + } + for (path, fields) in [ + ("ec.partners", &["api_token", "ts_pull_token"][..]), + ("handlers", &["password"][..]), + ( + "proxy.asset_routes", + &["access_key_id", "secret_access_key", "session_token"][..], + ), + ] { + let node = path.split('.').try_fold(value, |node, part| node.get(part)); + if let Some(node) = node { + let items = collection_items(node, path)?; + for (index, item) in items.iter().enumerate() { + let (item, suffix) = if path == "proxy.asset_routes" { + (item.get("auth"), ".auth") + } else { + (Some(item), "") + }; + for field in fields { + if item.is_some_and(|item| item.get(*field).is_some()) { + return Err(invalid( + &format!("{path}[{index}]{suffix}.{field}"), + "removed credential reference; migrate configuration", + )); + } + } + } + } + } + Ok(()) +} + +/// Preserve original endpoint syntax checks before ordinary normalization. +/// +/// # Errors +/// Rejects malformed endpoint syntax or collections, including inactive features. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::validate_original_endpoints; +/// let value = serde_json::json!({"tinybird": {"api_host": "api.example.com:8443"}}); +/// assert!(validate_original_endpoints(&value).is_ok()); +/// ``` +pub fn validate_original_endpoints(value: &Value) -> BindingResult<()> { + if let Some(partners) = value.pointer("/ec/partners") { + let items = collection_items(partners, "ec.partners")?; + for (index, partner) in items.iter().enumerate() { + if let Some(endpoint) = partner.get("pull_sync_url") + && !endpoint.is_null() + { + normalize_endpoint( + endpoint + .as_str() + .ok_or_else(|| invalid("ec.partners", "invalid credential endpoint"))?, + &format!("ec.partners[{index}].pull_sync_url"), + )?; + } + } + } + if let Some(endpoint) = value.pointer("/integrations/datadome/protection_api_origin") { + normalize_endpoint( + endpoint.as_str().ok_or_else(|| { + invalid( + "integrations.datadome.protection_api_origin", + "invalid credential endpoint", + ) + })?, + "integrations.datadome.protection_api_origin", + )?; + } + if let Some(host) = value.pointer("/tinybird/api_host") { + let host = host + .as_str() + .ok_or_else(|| invalid("tinybird.api_host", "invalid credential endpoint"))?; + if !host.is_empty() { + tinybird_events_endpoint(host)?; + } + } + if let Some(routes) = value.pointer("/proxy/asset_routes") { + let items = collection_items(routes, "proxy.asset_routes")?; + for (index, route) in items.iter().enumerate() { + if route.get("auth").is_some_and(|auth| !auth.is_null()) { + let path = format!("proxy.asset_routes[{index}].origin_url"); + let endpoint = route + .get("origin_url") + .and_then(Value::as_str) + .ok_or_else(|| invalid(&path, "invalid credential endpoint"))?; + let normalized = normalize_endpoint(endpoint, &path)?; + if url::Url::parse(&normalized).map_or(true, |url| url.path() != "/") { + return Err(invalid(&path, "authenticated origin must not contain path")); + } + } + } + } + Ok(()) +} + +fn collection_items(node: &Value, path: &str) -> BindingResult> { + match node { + Value::Array(items) => Ok(items.clone()), + Value::Object(items) => Ok(items.values().cloned().collect()), + Value::String(raw) => { + let parsed: Value = serde_json::from_str(raw) + .map_err(|_| invalid(path, "invalid configuration collection"))?; + if !parsed.is_array() { + return Err(invalid(path, "invalid configuration collection")); + } + collection_items(&parsed, path) + } + _ => Err(invalid(path, "invalid configuration collection")), + } +} + +/// Exact adapter-native identity for the fixed TS logical store. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct NativeBinding { + /// Runtime adapter whose native identity this entry describes. + pub adapter: String, + /// Logical credential-store identity, independent of its physical mapping. + pub store_id: String, + /// Generated context-derived lookup key before native name mapping. + pub key: String, + /// Exact native store key, environment name, or component-variable lookup name. + pub native_name: String, + /// Native configuration declarations required for this plan entry. + pub declarations: Vec, +} + +/// Render the exact TS-native lookup identity and required declarations. +/// +/// # Errors +/// Rejects unknown adapters, non-TS logical stores, and non-generated keys. +/// +/// # Examples +/// ``` +/// use trusted_server_core::{secret_bindings::{SECRET_STORE_ID, compile_bindings, render_native}, settings::Settings}; +/// let plan = compile_bindings(&Settings::default()).expect("should compile required app bindings"); +/// let native = render_native("axum", SECRET_STORE_ID, plan.entries()[0].key()) +/// .expect("should render the generated Axum identity"); +/// assert!(native.native_name.starts_with("TRUSTED_SERVER_SECRET_")); +/// ``` +pub fn render_native( + adapter: &str, + logical_store: &str, + key: &str, +) -> BindingResult { + render_native_with_mapping(adapter, logical_store, key, logical_store) +} + +/// Render using the trusted deployment/runtime store-name mapping. +/// +/// Fastly uses logical resource links and Cloudflare ignores the store argument. +/// Axum and Spin encode the selected mapped store in the native identity. +/// +/// # Errors +/// Rejects invalid role identities or a mapping unsupported by the native bridge. +/// +/// # Examples +/// ``` +/// use trusted_server_core::{secret_bindings::{SECRET_STORE_ID, compile_bindings, render_native_with_mapping}, settings::Settings}; +/// let plan = compile_bindings(&Settings::default()).expect("should compile required app bindings"); +/// let native = render_native_with_mapping("axum", SECRET_STORE_ID, plan.entries()[0].key(), "stage_secrets") +/// .expect("should render the selected trusted store mapping"); +/// assert!(native.native_name.starts_with("TRUSTED_SERVER_SECRET_STAGE_SECRETS_")); +/// ``` +pub fn render_native_with_mapping( + adapter: &str, + logical_store: &str, + key: &str, + mapped_store: &str, +) -> BindingResult { + if logical_store != SECRET_STORE_ID { + return Err(invalid( + "secret_bindings", + "unsupported logical secret store", + )); + } + if key.len() != 56 + || !key.starts_with("ts1_") + || !key[4..] + .bytes() + .all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)) + { + return Err(invalid("secret_bindings", "invalid generated binding key")); + } + let (native_name, declarations) = match adapter { + "fastly" | "cloudflare" => (key.to_owned(), Vec::new()), + "axum" => (axum_secret_name(mapped_store, key), Vec::new()), + "spin" => { + let name = spin_secret_name(mapped_store, key)?; + let declarations = vec![ + format!("[variables.{name}]\nrequired = true\nsecret = true"), + format!("[component.trusted-server.variables]\n{name} = \"{{{{ {name} }}}}\""), + ]; + (name, declarations) + } + _ => return Err(invalid("secret_bindings", "unsupported native adapter")), + }; + Ok(NativeBinding { + adapter: adapter.to_owned(), + store_id: SECRET_STORE_ID.to_owned(), + key: key.to_owned(), + native_name, + declarations, + }) +} + +/// Native Axum environment mapping, shared with the runtime bridge. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::axum_secret_name; +/// assert_eq!(axum_secret_name("example-store", "example-key"), +/// "TRUSTED_SERVER_SECRET_EXAMPLE_STORE_EXAMPLE_KEY"); +/// ``` +#[must_use] +pub fn axum_secret_name(store: &str, key: &str) -> String { + let normalize = |value: &str| value.to_uppercase().replace(['-', '.', ' '], "_"); + format!( + "TRUSTED_SERVER_SECRET_{}_{}", + normalize(store), + normalize(key) + ) +} + +/// Native Spin application/component mapping, shared with the runtime bridge. +/// +/// # Errors +/// Rejects identity components outside the existing Spin encoder grammar. +/// +/// # Examples +/// ``` +/// use trusted_server_core::secret_bindings::spin_secret_name; +/// let name = spin_secret_name("example_store", "example-key") +/// .expect("should encode the native Spin identity"); +/// assert_eq!(name, "v_example_x5fstore_v_example_x2dkey"); +/// ``` +pub fn spin_secret_name(store: &str, key: &str) -> BindingResult { + Ok(format!( + "{}_{}", + spin_component(store)?, + spin_component(key)? + )) +} + +fn spin_component(value: &str) -> BindingResult { + if !value.starts_with(|ch: char| ch.is_ascii_lowercase()) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte)) + { + return Err(invalid( + "secret_bindings", + "invalid native variable identity", + )); + } + let mut result = String::from("v_"); + for byte in value.bytes() { + if byte.is_ascii_lowercase() || byte.is_ascii_digit() { + result.push(char::from(byte)); + } else { + result.push_str(&format!("_x{byte:02x}")); + } + } + Ok(result) +} + +#[cfg(test)] +#[path = "secret_bindings_tests.rs"] +mod tests; diff --git a/crates/trusted-server-core/src/secret_bindings_tests.rs b/crates/trusted-server-core/src/secret_bindings_tests.rs new file mode 100644 index 0000000000..377d2913ca --- /dev/null +++ b/crates/trusted-server-core/src/secret_bindings_tests.rs @@ -0,0 +1,1101 @@ +use super::*; +use crate::platform::{PlatformError, StoreId}; +use crate::settings::{ProxyAssetRoute, S3SigV4AuthConfig}; +use serde_json::json; +use std::sync::Mutex; + +fn partner(domain: &str) -> EcPartner { + serde_json::from_value(json!({ + "name": "Fictional partner", "source_domain": domain, + "api_token_enabled": true, "pull_sync_enabled": true, + "pull_sync_url": "https://sync.example.com/pull" + })) + .expect("should deserialize partner feature settings") +} + +fn s3_route() -> ProxyAssetRoute { + let mut route = ProxyAssetRoute::new("/assets/", "https://bucket.example.com"); + route.auth = Some(AssetOriginAuth::S3SigV4( + serde_json::from_value::(json!({ + "region": "us-east-1", "session_token_enabled": true + })) + .expect("should deserialize S3 authentication policy"), + )); + route +} + +fn all_roles() -> Settings { + let mut settings = Settings::default(); + settings.ec.partners.push(partner("partner.example.com")); + settings.handlers.push( + serde_json::from_value(json!({ + "path": "^/admin/.*$", "username": "Jöhn" + })) + .expect("should deserialize the exact handler identity"), + ); + settings.trusted_client_ip = Some( + serde_json::from_value(json!({ + "ip_header": "x-fictional-client-ip", "auth_header": "x-fictional-auth" + })) + .expect("should deserialize trusted-client-IP feature settings"), + ); + settings.tinybird.enabled = true; + settings.tinybird.api_host = "api.tinybird.example.com".into(); + settings.integrations.insert( + "datadome".into(), + json!({ + "enabled": true, "enable_protection": true, + "protection_api_origin": "https://protection.example.com", + "protection_test_bypass": {"enabled": true} + }), + ); + settings.proxy.asset_routes.push(s3_route()); + settings +} + +struct SpyStore { + values: BTreeMap>, + reads: Mutex>, +} +impl SpyStore { + fn for_settings(settings: &Settings) -> Self { + Self { + values: compile_bindings(settings) + .expect("should compile fixture credential bindings") + .entries() + .iter() + .map(|entry| { + ( + entry.key().to_owned(), + b"fictional-credential-0123456789-ABCDEFGHIJ".to_vec(), + ) + }) + .collect(), + reads: Mutex::new(Vec::new()), + } + } +} +impl PlatformSecretStore for SpyStore { + fn get_bytes(&self, store: &StoreName, key: &str) -> Result, Report> { + self.reads + .lock() + .expect("should lock the native-read spy") + .push((store.as_ref().to_owned(), key.to_owned())); + self.values.get(key).cloned().ok_or_else(|| { + Report::new(PlatformError::SecretStore) + .attach("SENTINEL-native-diagnostic-must-not-escape") + }) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + unreachable!("read-only fixture") + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + unreachable!("read-only fixture") + } +} +fn resolve(settings: &Settings, store: &SpyStore) -> BindingResult { + resolve_bindings(settings, store, &StoreName::from(SECRET_STORE_ID)) +} + +#[test] +fn all_twelve_golden_contexts_match_independent_sha256_base32_vectors() { + // Independently generated using Bun's node:crypto SHA256 and an LP/VEC encoder. + let golden = [ + ( + "proxy", + "ts1_mu4d7mg5sfuleogirpka2zbh2b33lsocgyl6wcovawmjs4nfmi6a", + ), + ( + "ecpass", + "ts1_juuqz3tbywbbzruyd2bvt56qmxuiokdflb4ii2dfmjvw7ypyjwbq", + ), + ( + "ecin", + "ts1_k3wrjithgzt3ebzhyh75w7zte6hk5afym2inqwq76hkekmbdascq", + ), + ( + "ecpull", + "ts1_2hsjuldwgmfhyotnokgn5mqrao6xnkyo3dwva33hez4vx43yvcjq", + ), + ( + "handler", + "ts1_4xxbylw4mueqnarjo7xebdfkai2zsqqxg4uovsaonf2vkpsoggea", + ), + ( + "clientip", + "ts1_c5xmtldm34soypp5vwjijlo5f2nhbh5whlhplffr4y2rldgpoida", + ), + ( + "tinybird", + "ts1_mo6xycw4jkjdmzy36w7n4uyskkyjdrcyqxxdpof25mmklaofqvfq", + ), + ( + "ddserver", + "ts1_rm3vdfl6uwxihluj7uyftndcygyevzacjjqkuwtigqbosoa2czna", + ), + ( + "ddbypass", + "ts1_pu57gijeer2zxfusydlssdiz5lgns423kilp25zp3crzz3p2tzsq", + ), + ( + "s3id", + "ts1_c7h6zs7vr5ru73ij46hztdctlvzcjvgkxoopcirtc3qqxuyzzzea", + ), + ( + "s3secret", + "ts1_wvbtvxuoqctkntcdraq7gzigeq5m5hw2kkuwtjjzecan4iilkosq", + ), + ( + "s3sess", + "ts1_x4xisdyxzwrk446nxsn62rpjyozb5i3ng3xqgr6xp2sdnnr54cpa", + ), + ]; + let plan = compile_bindings(&all_roles()).expect("should compile all twelve credential roles"); + assert_eq!(plan.entries().len(), 12); + for (scope, key) in golden { + let entry = plan + .entries() + .iter() + .find(|entry| entry.scope() == scope) + .expect("should find the golden-vector scope in the compiled plan"); + assert_eq!(entry.key(), key, "scope {scope}"); + assert_eq!(entry.key().len(), 56); + } + assert_eq!(secret_fields().len(), 12); + assert_eq!(DESCRIPTORS.len(), 12); +} + +#[test] +fn framing_uses_utf8_byte_lengths_and_unambiguous_vectors() { + let mut encoded = Vec::new(); + lp(&mut encoded, "é").expect("should frame the UTF-8 string using its byte length"); + assert_eq!(encoded, [0, 0, 0, 2, 0xc3, 0xa9]); + assert_ne!( + derive_key("proxy", &strings(&["ab", "c"]), &strings(&["none"])) + .expect("should derive a key for separate ab and c subject components"), + derive_key("proxy", &strings(&["a", "bc"]), &strings(&["none"])) + .expect("should derive a key for separate a and bc subject components") + ); + assert_ne!( + derive_key("proxy", &strings(&[""]), &[]) + .expect("should derive a key for one empty subject component"), + derive_key("proxy", &[], &strings(&[""])) + .expect("should derive a key for one empty export component") + ); + assert_ne!( + derive_key("proxy", &strings(&["app"]), &strings(&["none"])) + .expect("should derive the publisher proxy key"), + derive_key("ecpass", &strings(&["app"]), &strings(&["none"])) + .expect("should derive the EC passphrase key") + ); + assert_eq!( + checked_u32(u32::MAX as usize, "fixture") + .expect("should accept the largest encodable component length"), + u32::MAX + ); + #[cfg(target_pointer_width = "64")] + assert!(checked_u32((u32::MAX as usize) + 1, "fixture").is_err()); +} + +#[test] +fn endpoint_normalization_preserves_only_specified_equivalences() { + for (raw, expected) in [ + ( + "HTTPS://Sync.Example.COM:443/pull", + "https://sync.example.com/pull", + ), + ("https://sync.example.com", "https://sync.example.com/"), + ( + "https://sync.example.com:8443/A//b_/~x-", + "https://sync.example.com:8443/A//b_/~x-", + ), + ] { + assert_eq!( + normalize_endpoint(raw, "fixture.endpoint") + .expect("should normalize the accepted HTTPS endpoint"), + expected + ); + } + let variants = [ + "https://sync.example.com/pull", + "https://sync.example.com/pull/", + "https://sync.example.com//pull", + "https://sync.example.com/Pull", + "https://sync.example.com:8443/pull", + ]; + let keys: std::collections::BTreeSet<_> = variants + .iter() + .map(|raw| { + derive_key( + "ecpull", + &strings(&["partner", "partner.example.com"]), + &strings(&[ + "pull", + &normalize_endpoint(raw, "fixture") + .expect("should normalize the endpoint before deriving its key"), + ]), + ) + .expect("should derive the endpoint-specific pull key") + }) + .collect(); + assert_eq!(keys.len(), variants.len()); +} + +#[test] +fn original_endpoint_rejections_precede_parser_cleanup_and_are_redacted() { + for endpoint in [ + "http://sync.example.com/pull", + "https://@sync.example.com/pull", + "https://a@sync.example.com/pull", + "https://sync.example.com/pull?", + "https://sync.example.com/pull?token=SENTINEL", + "https://sync.example.com/pull#", + "https://sync.example.com./pull", + "https://127.0.0.1/pull", + "https://127.1/pull", + "https://2130706433/pull", + "https://0x7f000001/pull", + "https://[::1]/pull", + "https://é.example.com/pull", + "https://sync.example.com/a/../pull", + "https://sync.example.com/./pull", + "https://sync.example.com/%70ull", + "https://sync.example.com/with space", + " https://sync.example.com/pull", + "https://sync.example.com/pull\n", + "https://sync.example.com\\evil", + "https://sync.example.com:0/pull", + "https://sync.example.com:65536/pull", // allow-domain: sync.example.com:65536 + "https://sync.example.com:/pull", + "https://-bad.example.com/pull", + "https://sync..example.com/pull", + ] { + let error = normalize_endpoint(endpoint, "ec.partners[0].pull_sync_url") + .expect_err("should reject forbidden original endpoint syntax"); + let diagnostic = format!("{error:?}"); + assert!(diagnostic.contains("ec.partners[0].pull_sync_url")); + assert!(!diagnostic.contains(endpoint)); + assert!(!diagnostic.contains("SENTINEL")); + } +} + +#[test] +fn optional_activation_avoids_probes_and_does_not_treat_missing_as_disabled() { + let mut settings = Settings::default(); + assert_eq!( + compile_bindings(&settings) + .expect("should compile only the always-required roles by default") + .entries() + .len(), + 2 + ); + let mut inactive = partner("partner.example.com"); + inactive.api_token_enabled = false; + inactive.pull_sync_enabled = false; + inactive.pull_sync_url = None; + settings.ec.partners.push(inactive); + assert_eq!( + compile_bindings(&settings) + .expect("should compile no credentials for the inactive partner") + .entries() + .len(), + 2 + ); + let store = SpyStore::for_settings(&settings); + assert_eq!( + resolve(&settings, &store) + .expect("should resolve only the active required roles") + .entries() + .len(), + 2 + ); + assert_eq!( + store + .reads + .lock() + .expect("should lock the native-read spy") + .len(), + 2 + ); + settings.ec.partners[0].api_token_enabled = true; + assert!(resolve(&settings, &store).is_err()); + settings.ec.partners[0].pull_sync_enabled = true; + assert!(compile_bindings(&settings).is_err()); +} + +#[test] +fn new_activation_flags_are_boolean_only_and_default_false() { + let partner: EcPartner = + serde_json::from_value(json!({"name":"Fictional", "source_domain":"partner.example.com"})) + .expect("should deserialize a partner with default activation flags"); + assert!(!partner.api_token_enabled); + assert!(serde_json::from_value::(json!({"name":"Fictional", "source_domain":"partner.example.com", "api_token_enabled":"true"})).is_err()); + let auth: S3SigV4AuthConfig = serde_json::from_value(json!({"region":"us-east-1"})) + .expect("should deserialize S3 auth with its default session-token flag"); + assert!(!auth.session_token_enabled); + assert!( + serde_json::from_value::( + json!({"region":"us-east-1", "session_token_enabled":"true"}) + ) + .is_err() + ); +} + +#[test] +fn pull_allowlist_is_only_an_optional_endpoint_assertion() { + let mut partner = partner("PARTNER.EXAMPLE.COM."); + assert_eq!( + normalize_pull_endpoint(&partner, "fixture") + .expect("should normalize the pull endpoint without an allowlist assertion"), + "https://sync.example.com/pull" + ); + partner.pull_sync_allowed_domains = vec!["SYNC.EXAMPLE.COM".into()]; + assert!(normalize_pull_endpoint(&partner, "fixture").is_ok()); + for domain in [ + "example.com", + "evil.example.com", + "sync.example.com.", + " sync.example.com", + "sync.example.com:443", + ] { + partner.pull_sync_allowed_domains = vec![domain.into()]; + assert!(normalize_pull_endpoint(&partner, "fixture").is_err()); + } +} + +#[test] +fn collection_order_does_not_change_identity_keys() { + let mut settings = Settings::default(); + settings.ec.partners = vec![ + partner("partner.example.com"), + partner("second.example.com"), + ]; + let keyset = |settings: &Settings| -> std::collections::BTreeSet { + compile_bindings(settings) + .expect("should compile the partner identity key set") + .entries() + .iter() + .map(|entry| entry.key().to_owned()) + .collect() + }; + let original = keyset(&settings); + settings.ec.partners.reverse(); + assert_eq!(keyset(&settings), original); + settings.ec.partners[0].source_domain = "SECOND.EXAMPLE.COM.".into(); + assert_eq!(keyset(&settings), original); + settings.ec.partners[1].source_domain = "second.example.com".into(); + assert!(compile_bindings(&settings).is_err()); +} + +#[test] +fn equal_contexts_are_read_once_and_not_arbitrary_references() { + let mut settings = Settings::default(); + let handler: Handler = + serde_json::from_value(json!({"path":"^/private", "username":"fictional"})) + .expect("should deserialize the repeated handler identity"); + settings.handlers = vec![handler.clone(), handler]; + let store = SpyStore::for_settings(&settings); + let bindings = + resolve(&settings, &store).expect("should resolve repeated equal handler contexts"); + assert_eq!(bindings.entries().len(), 4); + assert_eq!( + store + .reads + .lock() + .expect("should lock the native-read spy") + .len(), + 3 + ); + assert!( + store + .reads + .lock() + .expect("should lock the native-read spy") + .iter() + .all(|(store, key)| store == SECRET_STORE_ID + && key.starts_with("ts1_") + && key.len() == 56) + ); +} + +#[test] +fn authorization_equality_ignores_only_owner_location() { + let mut settings = Settings::default(); + let handler: Handler = + serde_json::from_value(json!({"path":"^/private", "username":"fictional"})) + .expect("should deserialize a repeated-handler authorization fixture"); + settings.handlers = vec![handler.clone(), handler]; + let plan = compile_bindings(&settings).expect("should compile both repeated handler owners"); + let handlers: Vec<_> = plan + .entries() + .iter() + .filter(|entry| entry.descriptor_id() == "handler.password") + .collect(); + assert_eq!(handlers.len(), 2); + assert_ne!(handlers[0], handlers[1]); + assert!(handlers[0].same_authorization(handlers[1])); + assert!(handlers[1].same_authorization(handlers[0])); + settings.handlers[1].username = crate::redacted::Redacted::new("different-user".to_owned()); + let changed = compile_bindings(&settings).expect("should compile the distinct handler subject"); + let changed_owner = changed + .entries() + .iter() + .find(|entry| entry.field_path() == "handlers[1].password") + .expect("should retain the changed handler owning path"); + assert!(!handlers[0].same_authorization(changed_owner)); + let mut changed_target = (*handlers[0]).clone(); + changed_target.target = CompiledTarget::Pull { + endpoint: "https://fictional.example.com/pull".to_owned(), + }; + assert!(!handlers[0].same_authorization(&changed_target)); +} + +#[test] +fn trusted_mapped_default_store_is_used_exactly_without_fallback() { + let settings = Settings::default(); + let store = SpyStore::for_settings(&settings); + let selected = StoreName::from("fictional_mapped_secrets"); + assert_eq!( + resolve_bindings(&settings, &store, &selected) + .expect("should resolve credentials from the selected trusted store") + .entries() + .len(), + 2 + ); + assert!( + store + .reads + .lock() + .expect("should lock the native-read spy") + .iter() + .all(|(name, _)| name == "fictional_mapped_secrets") + ); +} + +#[test] +fn native_read_failures_invalid_utf8_and_invalid_values_are_safe() { + let settings = all_roles(); + let mut store = SpyStore::for_settings(&settings); + let key = compile_bindings(&settings) + .expect("should compile the fixture before replacing its native value") + .entries()[0] + .key() + .to_owned(); + for invalid in [Vec::new(), vec![0xff], b"SENTINEL\ninvalid-token".to_vec()] { + store.values.insert(key.clone(), invalid); + let diagnostic = format!( + "{:?}", + resolve(&settings, &store).expect_err("should reject invalid native credential bytes") + ); + assert!(!diagnostic.contains("SENTINEL")); + } + store.values.remove(&key); + let diagnostic = format!( + "{:?}", + resolve(&settings, &store).expect_err("should reject a missing generated native binding") + ); + assert!(!diagnostic.contains("SENTINEL")); + assert!(!diagnostic.contains(&key)); + assert!(!diagnostic.contains("https://")); +} + +#[test] +fn exact_bytes_survive_resolution_and_debug_is_redacted() { + let settings = all_roles(); + let mut store = SpyStore::for_settings(&settings); + let plan = compile_bindings(&settings) + .expect("should compile all roles before seeding exact credential bytes"); + for entry in plan.entries() { + let value: &[u8] = match entry.scope() { + "proxy" | "ecpass" | "handler" | "ddserver" | "ddbypass" => { + b" SENTINEL-exact-credential-0123456789 " + } + "s3id" | "s3secret" | "s3sess" => b" SENTINEL-exact-signing-material ", + _ => b"SENTINELexactBearertoken0123456789==", + }; + store.values.insert(entry.key().into(), value.to_vec()); + } + let resolved = resolve(&settings, &store) + .expect("should resolve credentials without changing their bytes"); + for binding in resolved.entries() { + assert_eq!( + binding.credential().as_bytes(), + store + .values + .get(binding.compiled().key()) + .expect("should find the exact native value for the resolved key") + ); + let diagnostic = format!("{binding:?}"); + assert!(!diagnostic.contains("SENTINEL")); + assert!(!diagnostic.contains("Jöhn")); + assert!(!diagnostic.contains("https://")); + if let Some(header) = binding.bearer_header() { + assert!(header.is_sensitive()); + } + } + assert!(!format!("{resolved:?}").contains("SENTINEL")); +} + +#[test] +fn bearer_grammar_is_rfc6750_and_never_trims() { + for scope in ["ecpull", "tinybird"] { + for value in [ + "abcdefghijklmnopqrstuvwxyz0123456789", + "abcdefghijklmnopqrstuvwxyz0123456789==", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-._~+/123456789", + ] { + assert!(validate_credential(scope, value.as_bytes()).is_ok()); + } + for value in [ + "", + "===", + "short token 012345678901234567890123456789", + "a=b012345678901234567890123456789", + "abcdefghijklmnopqrstuvwxy0123456789\n", + " abcdefghijklmnopqrstuvwxy0123456789", + "abcdefghijklmnopqrstuvwxy0123456789:", + "abcdefghijklmnopqrstuvwxy0123456789é", + ] { + assert!(validate_credential(scope, value.as_bytes()).is_err()); + } + } + assert!(validate_credential("ecpull", b"short").is_err()); + assert!(validate_credential("unknown", b"fictional").is_err()); + assert!(validate_credential("clientip", b" fictional-credential-0123456789-ABCD ").is_err()); + assert!(validate_credential("ecin", &[b' '; 32]).is_err()); + assert!(validate_credential("s3id", b"fictional\r\nheader").is_err()); + assert!(validate_credential("s3sess", "fictional-é".as_bytes()).is_err()); + assert!(validate_credential("s3secret", " fictional-é\r\nHMAC-key ".as_bytes()).is_ok()); + // DataDome form encoding must preserve characters rather than trim them. + assert!(validate_credential("ddserver", " value&=+é\n".as_bytes()).is_ok()); +} + +#[test] +fn loaded_credential_target_cannot_be_reinterpreted_by_mutating_settings() { + let mut settings = all_roles(); + let store = SpyStore::for_settings(&settings); + let resolved = resolve(&settings, &store) + .expect("should resolve the original credential and target snapshot"); + let pull = resolved + .require_for_settings(&settings, "ec.pull", "ec.partners[0].ts_pull_token") + .expect("should find the loaded pull capability for its original context") + .clone(); + settings.ec.partners[0].pull_sync_url = Some("https://attacker.example.com/pull".into()); + settings.ec.partners[0].pull_sync_allowed_domains = vec!["attacker.example.com".into()]; + assert!( + resolved + .require_for_settings(&settings, "ec.pull", "ec.partners[0].ts_pull_token") + .is_err() + ); + assert_eq!(pull.endpoint(), Some("https://sync.example.com/pull")); + assert_eq!(pull.subject(), strings(&["partner", "partner.example.com"])); + assert!( + ResolvedBindings::default() + .require_for_settings(&settings, "ec.pull", "ec.partners[0].ts_pull_token") + .is_err() + ); +} + +#[test] +fn operation_plan_validation_matches_settings_checks_and_rejects_changed_contexts() { + let mut settings = all_roles(); + let store = SpyStore::for_settings(&settings); + let loaded = + resolve(&settings, &store).expect("should resolve operation-plan fixture credentials"); + let current = compile_bindings(&settings).expect("should compile one current validation plan"); + for binding in loaded.entries() { + let id = binding.compiled().descriptor_id(); + let path = binding.compiled().field_path(); + let from_plan = loaded + .require_for_plan(¤t, id, path) + .expect("should validate each loaded role against the shared plan"); + let from_settings = loaded + .require_for_settings(&settings, id, path) + .expect("should preserve settings convenience validation semantics"); + assert!(std::ptr::eq(binding, from_plan)); + assert!(std::ptr::eq(from_plan, from_settings)); + } + assert!( + loaded + .validated_for_plan(¤t, "absent.role", "absent.path") + .expect("should return no capability for an inactive absent role") + .is_none() + ); + assert!( + loaded + .require_for_plan(¤t, "absent.role", "absent.path") + .is_err() + ); + assert!( + ResolvedBindings::default() + .require_for_plan(¤t, "ec.pull", "ec.partners[0].ts_pull_token") + .is_err() + ); + settings.ec.partners[0].pull_sync_url = Some("https://changed.example.com/pull".into()); + let changed = + compile_bindings(&settings).expect("should compile the changed destination context"); + assert!( + loaded + .require_for_plan(&changed, "ec.pull", "ec.partners[0].ts_pull_token") + .is_err() + ); + assert!( + loaded + .require_for_settings(&settings, "ec.pull", "ec.partners[0].ts_pull_token") + .is_err() + ); + assert!( + loaded + .require_for_plan(¤t, "ec.pull", "ec.partners[0].ts_pull_token") + .is_ok() + ); +} + +#[test] +fn s3_named_region_is_bound_without_runtime_subject_decoding() { + let mut settings = all_roles(); + let store = SpyStore::for_settings(&settings); + let loaded = + resolve(&settings, &store).expect("should resolve credentials with their typed targets"); + let current = compile_bindings(&settings).expect("should compile typed S3 targets"); + for binding in loaded.entries() { + if binding.compiled().scope().starts_with("s3") { + assert_eq!(binding.s3_region(), Some("us-east-1")); + assert!( + matches!(binding.compiled().target(), CompiledTarget::S3 { origin, region } + if origin == "https://bucket.example.com" && region == "us-east-1") + ); + } else { + assert_eq!(binding.s3_region(), None); + } + } + let path = "proxy.asset_routes[0].auth.access_key_id"; + let binding = loaded + .require_for_plan(¤t, "s3.access-id", path) + .expect("should retrieve the original typed S3 access-ID binding"); + let mut altered_target = current.clone(); + let entry = altered_target + .entries + .iter_mut() + .find(|entry| entry.descriptor_id() == "s3.access-id") + .expect("should locate the S3 access-ID entry for equality coverage"); + let CompiledTarget::S3 { region, .. } = &mut entry.target else { + unreachable!() + }; + *region = "us-west-2".into(); + assert!( + loaded + .require_for_plan(&altered_target, "s3.access-id", path) + .is_err() + ); + let Some(AssetOriginAuth::S3SigV4(auth)) = &mut settings.proxy.asset_routes[0].auth else { + unreachable!() + }; + auth.region = "us-west-2".into(); + let changed = compile_bindings(&settings).expect("should compile a new signing-region context"); + assert!( + loaded + .require_for_plan(&changed, "s3.access-id", path) + .is_err() + ); + assert_eq!(binding.s3_region(), Some("us-east-1")); +} + +#[test] +fn all_send_affecting_s3_route_policy_mutations_change_keys() { + let mut settings = Settings::default(); + settings.proxy.asset_routes.push(s3_route()); + let key = |settings: &Settings| { + compile_bindings(settings) + .expect("should compile the authenticated S3 route policy") + .entries() + .iter() + .find(|entry| entry.scope() == "s3id") + .expect("should find the route access-ID binding") + .key() + .to_owned() + }; + let original = key(&settings); + let mut changed = settings.clone(); + changed.proxy.asset_routes[0].prefix = "/other/".into(); + assert_ne!(key(&changed), original); + let mut changed = settings.clone(); + changed.proxy.asset_routes[0].origin_url = "https://different.example.com".into(); + assert_ne!(key(&changed), original); + let mut changed = settings.clone(); + changed.proxy.asset_routes[0].origin_url = "https://bucket.example.com:8443".into(); + assert_ne!(key(&changed), original); + let mut changed = settings.clone(); + changed.proxy.asset_routes[0].path_pattern = Some("^/assets/(.*)".into()); + changed.proxy.asset_routes[0].target_path = Some("/$1".into()); + assert_ne!(key(&changed), original); + let mut changed = settings.clone(); + let Some(AssetOriginAuth::S3SigV4(auth)) = &mut changed.proxy.asset_routes[0].auth else { + unreachable!() + }; + auth.region = "us-west-2".into(); + assert_ne!(key(&changed), original); + let mut changed = settings.clone(); + let Some(AssetOriginAuth::S3SigV4(auth)) = &mut changed.proxy.asset_routes[0].auth else { + unreachable!() + }; + auth.origin_query = Some(OriginQueryPolicy::Strip); + assert_ne!(key(&changed), original); + let plan = + compile_bindings(&settings).expect("should compile all required S3 credential roles"); + let s3: Vec<_> = plan + .entries() + .iter() + .filter(|entry| entry.scope().starts_with("s3")) + .collect(); + assert_eq!(s3.len(), 3); + assert_eq!(s3[0].subject(), s3[1].subject()); + assert_eq!(s3[1].subject(), s3[2].subject()); + assert_ne!(s3[0].key(), s3[1].key()); + assert_eq!(s3[0].subject()[7], "us-east-1"); + assert_eq!(s3[0].subject()[8], "preserve"); +} + +#[test] +fn s3_aliases_defaults_and_effective_query_policy_agree() { + let route = |kind: &str| { + serde_json::from_value::(json!({ + "prefix":"/assets/", "origin_url":"HTTPS://BUCKET.EXAMPLE.COM:443/", + "auth":{"type":kind,"region":"us-east-1"} + })) + .expect("should deserialize the accepted S3 auth alias") + }; + let mut a = Settings::default(); + a.proxy.asset_routes.push(route("s3_sigv4")); + let mut b = Settings::default(); + b.proxy.asset_routes.push(route("s3_sig_v4")); + assert_eq!( + compile_bindings(&a).expect("should compile the first effective S3 policy"), + compile_bindings(&b).expect("should compile the alias-equivalent S3 policy") + ); + let Some(AssetOriginAuth::S3SigV4(auth)) = &mut b.proxy.asset_routes[0].auth else { + unreachable!() + }; + auth.origin_query = Some(OriginQueryPolicy::Preserve); + assert_eq!( + compile_bindings(&a).expect("should compile the default S3 query policy"), + compile_bindings(&b).expect("should compile the equivalent explicit preserve policy") + ); + b.proxy.asset_routes[0].image_optimizer = Some( + serde_json::from_value(json!({"region":"us-east-1", "profile_set":"fictional"})) + .expect("should deserialize the image-optimizer policy"), + ); + let Some(AssetOriginAuth::S3SigV4(auth)) = &mut b.proxy.asset_routes[0].auth else { + unreachable!() + }; + auth.origin_query = None; + assert_ne!( + compile_bindings(&a).expect("should compile the non-optimized S3 route"), + compile_bindings(&b).expect("should compile the image-optimizer effective query policy") + ); +} + +#[test] +fn tinybird_authority_supports_https_ports_without_expanding_endpoint_syntax() { + let mut settings = all_roles(); + let baseline = + compile_bindings(&settings).expect("should compile the default Tinybird authority"); + settings.tinybird.api_host = "API.TINYBIRD.EXAMPLE.COM:443".into(); + assert_eq!( + compile_bindings(&settings) + .expect("should compile the equivalent explicit HTTPS default port"), + baseline + ); + assert!( + validate_original_endpoints( + &json!({"tinybird":{"api_host":"API.TINYBIRD.EXAMPLE.COM:443"}}) + ) + .is_ok() + ); + settings.tinybird.api_host = "api.tinybird.example.com:8443".into(); + let changed = + compile_bindings(&settings).expect("should compile the nondefault Tinybird HTTPS port"); + let entry = changed + .entries() + .iter() + .find(|entry| entry.scope() == "tinybird") + .expect("should find the port-specific Tinybird binding"); + assert_eq!( + entry.target(), + &CompiledTarget::Tinybird { + endpoint: "https://api.tinybird.example.com:8443/v0/events".into(), + dataset: "auction_events_raw".into() + } + ); + assert_ne!( + entry.key(), + baseline + .entries() + .iter() + .find(|entry| entry.scope() == "tinybird") + .expect("should find the baseline Tinybird binding") + .key() + ); + assert!( + validate_original_endpoints( + &json!({"tinybird":{"api_host":"api.tinybird.example.com:8443"}}) + ) + .is_ok() + ); + for authority in [ + "https://api.tinybird.example.com", + "api.tinybird.example.com/path", + "@api.tinybird.example.com", + "api.tinybird.example.com?", + "api.tinybird.example.com#", + "api.tinybird.example.com:0", + "api.tinybird.example.com:65536", + "api.tinybird.example.com:8443 ", + "api.tinybird.example.com\\evil", + ] { + settings.tinybird.api_host = authority.into(); + assert!(compile_bindings(&settings).is_err()); + assert!( + validate_original_endpoints( + &json!({"tinybird":{"enabled":false,"api_host":authority}}) + ) + .is_err() + ); + } +} + +#[test] +fn handler_and_dataset_identity_are_exact_not_silently_trimmed() { + let settings = all_roles(); + let keys = |settings: &Settings, scope: &str| { + compile_bindings(settings) + .expect("should compile the exact handler and dataset identities") + .entries() + .iter() + .find(|entry| entry.scope() == scope) + .expect("should find the requested identity scope") + .key() + .to_owned() + }; + let mut changed = settings.clone(); + changed.handlers[0].path.push(' '); + assert_ne!(keys(&changed, "handler"), keys(&settings, "handler")); + let mut changed = settings.clone(); + changed.handlers[0].username = crate::redacted::Redacted::new("Jöhn ".into()); + assert_ne!(keys(&changed, "handler"), keys(&settings, "handler")); + let mut changed = settings.clone(); + changed.tinybird.auction_dataset.push(' '); + assert!(compile_bindings(&changed).is_err()); + let mut changed = settings.clone(); + changed.tinybird.auction_dataset = "other_events".into(); + assert_ne!(keys(&changed, "tinybird"), keys(&settings, "tinybird")); +} + +#[test] +fn removed_reference_properties_reject_regardless_of_activation_or_value() { + for mut value in [ + json!({"publisher":{"proxy_secret":null}}), + json!({"ec":{"passphrase":"SENTINEL"}}), + json!({"ec":{"partners":[{"api_token":"ts1_generated", "api_token_enabled":false}]}}), + json!({"ec":{"partners":[{"ts_pull_token":"SENTINEL", "pull_sync_enabled":false}]}}), + json!({"handlers":[{"password":"SENTINEL"}]}), + json!({"trusted_client_ip":{"shared_secret":"SENTINEL"}}), + json!({"tinybird":{"enabled":false,"auction_token_secret":"SENTINEL"}}), + json!({"integrations":{"datadome":{"enabled":false,"server_side_key_secret_name":"SENTINEL"}}}), + json!({"integrations":{"datadome":{"protection_test_bypass":{"enabled":false,"credential_secret_name":"SENTINEL"}}}}), + json!({"proxy":{"asset_routes":[{"auth":{"type":"s3_sigv4","access_key_id":"SENTINEL"}}]}}), + json!({"proxy":{"asset_routes":[{"auth":{"type":"s3_sig_v4","secret_access_key":"SENTINEL"}}]}}), + json!({"proxy":{"asset_routes":[{"auth":{"type":"s3_sigv4","session_token":null,"session_token_enabled":false}}]}}), + ] { + let diagnostic = format!( + "{:?}", + reject_removed_references(&value) + .expect_err("should reject the removed credential property") + ); + assert!(!diagnostic.contains("SENTINEL")); + assert!(!diagnostic.contains("ts1_generated")); + assert!(diagnostic.contains("removed credential reference")); + // Numeric overlay maps have the same protection. + if let Some(array) = value.get_mut("handlers") { + *array = json!({"0":{"password":"SENTINEL"}}); + assert!(reject_removed_references(&value).is_err()); + } + } + assert!( + reject_removed_references( + &json!({"tinybird":{"enabled":false,"access_token_secret":"discarded-placeholder"}}) + ) + .is_ok() + ); + assert!( + reject_removed_references( + &json!({"handlers":"[{\"path\":\"^/\",\"password\":\"SENTINEL\"}]"}) + ) + .is_err() + ); +} + +#[test] +fn malformed_dynamic_activation_fails_before_any_read() { + for raw in [ + Value::Null, + json!([]), + json!({"enabled":"false"}), + json!({"enabled":false,"enable_protection":"false"}), + json!({"enabled":false,"protection_test_bypass":[]}), + ] { + let mut settings = Settings::default(); + settings.integrations.insert("datadome".into(), raw); + let store = SpyStore { + values: BTreeMap::new(), + reads: Mutex::new(Vec::new()), + }; + assert!(resolve(&settings, &store).is_err()); + assert!( + store + .reads + .lock() + .expect("should lock the native-read spy") + .is_empty() + ); + } +} + +#[test] +fn original_preflight_covers_disabled_features_and_collection_overlays() { + for value in [ + json!({"ec":{"partners":{"0":{"pull_sync_enabled":false,"pull_sync_url":"https://sync.example.com/a/../b"}}}}), + json!({"tinybird":{"enabled":false,"api_host":" api.example.com"}}), + json!({"integrations":{"datadome":{"enabled":false,"protection_api_origin":"https://protection.example.com?"}}}), + json!({"proxy":{"asset_routes":[{"origin_url":"https://bucket.example.com/a/../", "auth":{"type":"s3_sigv4"}}]}}), + ] { + assert!(validate_original_endpoints(&value).is_err()); + } +} + +#[test] +fn native_rendering_matches_actual_ts_bridges_and_spin_declarations() { + let key = "ts1_2hsjuldwgmfhyotnokgn5mqrao6xnkyo3dwva33hez4vx43yvcjq"; + for adapter in ["fastly", "cloudflare"] { + assert_eq!( + render_native(adapter, SECRET_STORE_ID, key) + .expect("should render the generated key in the flat native namespace") + .native_name, + key + ); + } + assert_eq!( + render_native("axum", SECRET_STORE_ID, key) + .expect("should render the Axum environment identity") + .native_name, + format!( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_{}", + key.to_uppercase() + ) + ); + let spin = render_native("spin", SECRET_STORE_ID, key) + .expect("should render both Spin declaration levels"); + assert_eq!( + spin.native_name, + format!("v_trusted_x5fserver_x5fsecrets_v_ts1_x5f{}", &key[4..]) + ); + assert_eq!(spin.native_name.len(), 92); + assert_eq!(spin.declarations.len(), 2); + assert!(spin.declarations[0].contains("secret = true")); + assert!(spin.declarations[1].contains(&format!("{{{{ {} }}}}", spin.native_name))); + for adapter in ["fastly", "axum", "cloudflare", "spin"] { + assert!(render_native(adapter, "untrusted", key).is_err()); + assert!(render_native(adapter, SECRET_STORE_ID, "raw-reference").is_err()); + } + assert!(render_native("unknown", SECRET_STORE_ID, key).is_err()); +} + +#[test] +fn renderer_tracks_trusted_native_mapping_without_changing_logical_authority() { + let key = "ts1_2hsjuldwgmfhyotnokgn5mqrao6xnkyo3dwva33hez4vx43yvcjq"; + let mapped = "fictional_mapped_secrets"; + for adapter in ["axum", "spin"] { + let binding = render_native_with_mapping(adapter, SECRET_STORE_ID, key, mapped) + .expect("should render the selected trusted native store mapping"); + assert_eq!(binding.store_id, SECRET_STORE_ID); + let expected = if adapter == "axum" { + axum_secret_name(mapped, key) + } else { + spin_secret_name(mapped, key).expect("should encode the selected Spin store and key") + }; + assert_eq!(binding.native_name, expected); + assert_ne!( + binding.native_name, + render_native(adapter, SECRET_STORE_ID, key) + .expect("should render the default logical-store mapping") + .native_name + ); + if adapter == "spin" { + assert!( + binding + .declarations + .iter() + .all(|declaration| declaration.contains(&binding.native_name)) + ); + } + } + for adapter in ["fastly", "cloudflare"] { + assert_eq!( + render_native_with_mapping(adapter, SECRET_STORE_ID, key, mapped) + .expect("should render the flat namespace independently of the mapped store"), + render_native(adapter, SECRET_STORE_ID, key) + .expect("should render the flat namespace with the logical store") + ); + } +} + +#[test] +fn shared_native_mappers_preserve_existing_generic_bridge_semantics() { + assert_eq!( + axum_secret_name("trusted-server.secrets", "Mixed Case-Key"), + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_MIXED_CASE_KEY" + ); + assert_eq!( + spin_secret_name("trusted_server_secrets", "fixture-Mixed.Key:one") + .expect("should encode mixed-case and punctuation bytes in the Spin identity"), + "v_trusted_x5fserver_x5fsecrets_v_fixture_x2d_x4dixed_x2e_x4bey_x3aone" + ); + assert_ne!( + spin_secret_name("store", "fixture_key") + .expect("should encode the underscore-containing Spin key"), + spin_secret_name("store", "fixture-key") + .expect("should encode the hyphen-containing Spin key") + ); + for invalid in [ + "", + "1fixture", + "Fixture", + "fixture/key", + "fixture value", + "fïxture", + ] { + assert!(spin_secret_name("store", invalid).is_err()); + } +} + +#[test] +fn resolved_plan_serialization_contains_only_metadata() { + let settings = all_roles(); + let plan = compile_bindings(&settings).expect("should compile the secret-free binding plan"); + let serialized = serde_json::to_value(plan).expect("should serialize only binding metadata"); + assert_eq!(serialized["abi_version"], BINDING_ABI_VERSION); + assert!( + serialized["entries"] + .as_array() + .expect("should expose the serialized plan entries") + .iter() + .all(|entry| entry.get("credential").is_none()) + ); + assert!( + serialized["entries"] + .as_array() + .expect("should expose the serialized plan entries") + .iter() + .all(|entry| entry.get("key").is_some()) + ); +} diff --git a/crates/trusted-server-core/src/secret_resolution.rs b/crates/trusted-server-core/src/secret_resolution.rs deleted file mode 100644 index f362a0b7b2..0000000000 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! Runtime resolution of `EdgeZero` app-config secret references. -//! -//! Config blobs carry secret-store key names at rest. This module walks the -//! public `EdgeZero` metadata contract and replaces those names only in the -//! in-memory value used to build runtime [`crate::settings::Settings`]. - -use edgezero_core::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; -use error_stack::Report; -use serde_json::Value; - -use crate::error::TrustedServerError; -use crate::platform::{PlatformSecretStore, StoreName}; - -/// Resolve all secret references in a serialized Trusted Server app config. -/// -/// The input is mutated in memory; the verified envelope is never rewritten. -/// Secret values are not included in structural or platform errors. -/// -/// # Errors -/// -/// Returns [`TrustedServerError::Configuration`] when a required path or key -/// is malformed, a secret is unavailable, is not valid UTF-8, or resolves to an -/// empty value. -pub fn resolve_secret_references( - data: &mut Value, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, -) -> Result<(), Report> { - let mut resolved_data = data.clone(); - for field in C::secret_fields() { - if matches!(field.kind, SecretKind::StoreRef) { - continue; - } - resolve_field( - &mut resolved_data, - &field, - &field.path, - "", - secret_store, - default_store_name, - )?; - } - *data = resolved_data; - Ok(()) -} - -fn resolve_field( - node: &mut Value, - field: &SecretField, - remaining: &[SecretPathSegment], - rendered_path: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, -) -> Result<(), Report> { - match remaining.split_first() { - Some((SecretPathSegment::Field(name), [])) => resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ), - Some((SecretPathSegment::OptionalField(name), [])) => { - if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { - return Ok(()); - } - resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::Field(name), rest)) => { - let next_path = join_field(rendered_path, name.as_ref()); - let child = node - .as_object_mut() - .and_then(|object| object.get_mut(name.as_ref())) - .ok_or_else(|| missing_path(&next_path))?; - if child.is_null() { - return Err(missing_path(&next_path)); - } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::OptionalField(name), rest)) => { - let next_path = join_field(rendered_path, name.as_ref()); - let Some(child) = node - .as_object_mut() - .and_then(|object| object.get_mut(name.as_ref())) - else { - return Ok(()); - }; - if child.is_null() { - return Ok(()); - } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::ArrayEach, rest)) => { - let items = node.as_array_mut().ok_or_else(|| { - configuration_error(format!("expected an array at `{rendered_path}`")) - })?; - for (index, item) in items.iter_mut().enumerate() { - let indexed_path = format!("{rendered_path}[{index}]"); - resolve_field( - item, - field, - rest, - &indexed_path, - secret_store, - default_store_name, - )?; - } - Ok(()) - } - Some(_) => Err(configuration_error(format!( - "unsupported secret path segment in `{}`", - field.dotted_path() - ))), - None => Ok(()), - } -} - -fn resolve_leaf( - parent: &mut Value, - field: &SecretField, - key: &str, - rendered_parent: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, -) -> Result<(), Report> { - let leaf_path = join_field(rendered_parent, key); - let object = parent.as_object_mut().ok_or_else(|| { - configuration_error(format!("expected an object containing `{leaf_path}`")) - })?; - - let key_name = match object.get(key) { - Some(Value::String(value)) if !value.is_empty() => value.clone(), - Some(Value::Null) | None if field.optional => return Ok(()), - Some(Value::Null) | None => return Err(missing_path(&leaf_path)), - Some(Value::String(_)) => { - return Err(configuration_error(format!( - "secret key reference at `{leaf_path}` must not be empty" - ))); - } - _ => { - return Err(configuration_error(format!( - "secret key reference at `{leaf_path}` must be a string" - ))); - } - }; - - let resolved = secret_store - .get_string(default_store_name, &key_name) - .map_err(|_| { - configuration_error(format!( - "failed to resolve secret reference at `{leaf_path}` from secret store \ - `{default_store_name}`" - )) - })?; - if resolved.is_empty() { - return Err(configuration_error(format!( - "resolved secret at `{leaf_path}` must not be empty" - ))); - } - - object.insert(key.to_owned(), Value::String(resolved)); - Ok(()) -} - -fn join_field(prefix: &str, field: &str) -> String { - if prefix.is_empty() { - field.to_owned() - } else { - format!("{prefix}.{field}") - } -} - -fn missing_path(path: &str) -> Report { - configuration_error(format!("missing required secret path `{path}`")) -} - -fn configuration_error(message: String) -> Report { - Report::new(TrustedServerError::Configuration { message }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::platform::{PlatformError, StoreId}; - use std::collections::BTreeMap; - - struct MemorySecretStore { - values: BTreeMap>, - } - - impl PlatformSecretStore for MemorySecretStore { - fn get_bytes( - &self, - _store_name: &StoreName, - key: &str, - ) -> Result, Report> { - self.values.get(key).cloned().ok_or_else(|| { - Report::new(PlatformError::SecretStore) - .attach(format!("missing test secret for key `{key}`")) - }) - } - - fn create( - &self, - _store_id: &StoreId, - _name: &str, - _value: &str, - ) -> Result<(), Report> { - Ok(()) - } - - fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { - Ok(()) - } - } - - struct Fixture; - - impl AppConfigMeta for Fixture { - fn secret_fields() -> Vec { - vec![ - SecretField { - kind: SecretKind::KeyInDefault, - optional: false, - path: vec![ - SecretPathSegment::Field("outer".into()), - SecretPathSegment::ArrayEach, - SecretPathSegment::Field("token".into()), - ], - }, - SecretField { - kind: SecretKind::KeyInDefault, - optional: true, - path: vec![ - SecretPathSegment::Field("outer".into()), - SecretPathSegment::ArrayEach, - SecretPathSegment::Field("optional".into()), - ], - }, - SecretField { - kind: SecretKind::KeyInDefault, - optional: false, - path: vec![ - SecretPathSegment::OptionalField("feature".into()), - SecretPathSegment::Field("credential".into()), - ], - }, - ] - } - } - - fn store() -> MemorySecretStore { - MemorySecretStore { - values: BTreeMap::from([ - ("token-a".to_owned(), b"resolved-a".to_vec()), - ("token-b".to_owned(), b"resolved-b".to_vec()), - ("feature-key".to_owned(), b"resolved-feature".to_vec()), - ]), - } - } - - #[test] - fn resolves_nested_array_values_and_skips_optional_nulls() { - let mut data = serde_json::json!({ - "outer": [ - {"token": "token-a", "optional": null}, - {"token": "token-b"} - ] - }); - - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) - .expect("should resolve nested array secrets"); - - assert_eq!(data["outer"][0]["token"], "resolved-a"); - assert_eq!(data["outer"][1]["token"], "resolved-b"); - assert!(data["outer"][0]["optional"].is_null()); - } - - #[test] - fn resolves_present_and_skips_absent_optional_intermediate() { - let mut absent = serde_json::json!({ - "outer": [{"token": "token-a"}] - }); - resolve_secret_references::(&mut absent, &store(), &StoreName::from("secrets")) - .expect("should skip absent optional intermediate"); - - let mut present = serde_json::json!({ - "outer": [{"token": "token-a"}], - "feature": {"credential": "feature-key"} - }); - resolve_secret_references::(&mut present, &store(), &StoreName::from("secrets")) - .expect("should resolve present optional intermediate"); - - assert_eq!(present["feature"]["credential"], "resolved-feature"); - } - - #[test] - fn rejects_missing_required_path_without_secret_values() { - for mut data in [ - serde_json::json!({"outer": [{}]}), - serde_json::json!({"outer": [{"token": null}]}), - ] { - let err = resolve_secret_references::( - &mut data, - &store(), - &StoreName::from("secrets"), - ) - .expect_err("should reject missing required secret path"); - - assert!(err.to_string().contains("missing required secret path")); - assert!(err.to_string().contains("outer[0].token")); - assert!(!err.to_string().contains("resolved-a")); - } - } - - #[test] - fn rejects_non_string_required_leaf() { - let mut data = serde_json::json!({"outer": [{"token": true}]}); - let err = - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) - .expect_err("should reject non-string secret reference"); - - assert!(err.to_string().contains("must be a string")); - assert!(err.to_string().contains("outer[0].token")); - } - - #[test] - fn failed_lookup_reports_safe_reference_context_without_secret_values() { - let plaintext_blob_value = "legacy-plaintext-credential"; - let mut data = serde_json::json!({"outer": [{"token": plaintext_blob_value}]}); - let store = MemorySecretStore { - values: BTreeMap::from([( - "fixture-secret-key".to_owned(), - b"fixture-secret-value".to_vec(), - )]), - }; - - let err = - resolve_secret_references::(&mut data, &store, &StoreName::from("secrets")) - .expect_err("should reject a missing secret key"); - let diagnostic = format!("{err:?}"); - - assert!(diagnostic.contains("outer[0].token")); - assert!(diagnostic.contains("secrets")); - assert!(!diagnostic.contains(plaintext_blob_value)); - assert!(!diagnostic.contains("missing test secret")); - assert!(!diagnostic.contains("fixture-secret-value")); - } - - #[test] - fn rejects_malformed_array_path_without_resolving_values() { - let mut data = serde_json::json!({"outer": {"token": "token-a"}}); - let err = - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) - .expect_err("should reject a non-array intermediate path"); - - assert!(err.to_string().contains("expected an array")); - assert!(!err.to_string().contains("resolved-a")); - } - - #[test] - fn rejects_invalid_utf8_and_empty_resolved_values() { - let mut invalid = store(); - invalid.values.insert("token-a".to_owned(), vec![0xff]); - let mut data = serde_json::json!({"outer": [{"token": "token-a"}]}); - let err = - resolve_secret_references::(&mut data, &invalid, &StoreName::from("secrets")) - .expect_err("should reject invalid UTF-8"); - assert!(err.to_string().contains("outer[0].token")); - - let empty = MemorySecretStore { - values: BTreeMap::from([("token-a".to_owned(), Vec::new())]), - }; - let mut data = serde_json::json!({"outer": [{"token": "token-a"}]}); - let err = - resolve_secret_references::(&mut data, &empty, &StoreName::from("secrets")) - .expect_err("should reject empty resolved value"); - assert!(err.to_string().contains("outer[0].token")); - } - - #[test] - fn does_not_mutate_data_when_resolution_fails() { - let mut data = serde_json::json!({"outer": [{"token": "missing"}]}); - let original = data.clone(); - let result = - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")); - assert!(result.is_err(), "should fail for missing secret key"); - assert_eq!(data, original, "should preserve unresolved data on failure"); - } -} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index ff188a929a..724e2910cd 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -24,6 +24,7 @@ use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; use crate::redacted::Redacted; +use crate::secret_bindings::{tinybird_events_endpoint, validate_tinybird_dataset}; #[cfg(test)] pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; @@ -53,6 +54,7 @@ pub struct Publisher { pub origin_host_header_override: Option, /// Secret used to encrypt/decrypt proxied URLs in `/first-party/proxy`. /// Keep this secret stable to allow existing links to decode. + #[serde(default, skip_serializing)] #[validate(custom(function = validate_redacted_not_empty))] pub proxy_secret: Redacted, /// Maximum number of bytes buffered when a publisher origin response is @@ -420,9 +422,9 @@ impl DerefMut for IntegrationSettings { /// A partner (SSP, DSP, identity vendor) configured in `[[ec.partners]]`. /// -/// Partners are defined statically in `trusted-server.toml` rather than -/// registered via API. At startup, each configured `api_token` is hashed -/// (SHA-256) for O(1) auth lookups; the plaintext is never stored at runtime. +/// Partners are defined in `trusted-server.toml`. When `api_token_enabled` is +/// true, startup resolves the partner's implicit credential and hashes it for +/// O(1) registry authentication; config never supplies its key or value. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct EcPartner { @@ -445,10 +447,13 @@ pub struct EcPartner { pub bidstream_enabled: bool, /// Plaintext API token used by inbound batch sync and identify requests. /// - /// When present, the token is hashed at startup for auth lookups. Omitting - /// it disables inbound partner API authentication for this partner. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// This resolved shadow is not config authority. `api_token_enabled` controls + /// activation, and the registry authenticates using the loaded capability. + #[serde(default, skip_serializing)] pub api_token: Option>, + /// Require this partner's implicit inbound credential binding. + #[serde(default)] + pub api_token_enabled: bool, /// Max batch sync API requests per partner per minute. #[serde( default = "EcPartner::default_batch_rate_limit", @@ -480,8 +485,8 @@ pub struct EcPartner { deserialize_with = "from_value_or_str" )] pub pull_sync_rate_limit: u32, - /// Outbound bearer token for pull sync requests. - #[serde(default)] + /// Resolved outbound bearer token shadow; not app-config authority. + #[serde(default, skip_serializing)] pub ts_pull_token: Option>, } @@ -584,6 +589,7 @@ impl EcPartner { #[serde(deny_unknown_fields)] pub struct Ec { /// Publisher passphrase used as HMAC key for EC generation. + #[serde(default, skip_serializing)] #[validate(custom(function = Ec::validate_passphrase))] pub passphrase: Redacted, @@ -720,6 +726,7 @@ pub struct Handler { pub path: String, #[validate(custom(function = validate_redacted_not_empty))] pub username: Redacted, + #[serde(default, skip_serializing)] #[validate(custom(function = validate_redacted_not_empty))] pub password: Redacted, #[serde(skip, default)] @@ -820,14 +827,6 @@ fn default_request_signing_enabled() -> bool { false } -fn default_s3_access_key_id() -> Redacted { - Redacted::new("access_key_id".to_string()) -} - -fn default_s3_secret_access_key() -> Redacted { - Redacted::new("secret_access_key".to_string()) -} - fn default_asset_image_optimizer_enabled() -> bool { true } @@ -912,8 +911,8 @@ impl AssetOriginAuth { /// AWS Signature Version 4 configuration for `S3` asset origins. /// /// The route `origin_url` must use the same `S3` host that `AWS` validates in -/// the `SigV4` canonical request. Credential fields hold secret-store key names -/// in app config and resolved values at runtime. +/// the `SigV4` canonical request. Config supplies policy only. Resolved credential +/// fields are validation shadows; loaded route bindings authorize signing. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct S3SigV4AuthConfig { @@ -922,15 +921,18 @@ pub struct S3SigV4AuthConfig { /// Deprecated per-route store selector accepted for migration only. #[serde(default, skip_serializing)] pub secret_store: Option, - /// Secret reference containing the `AWS` access key ID. - #[serde(default = "default_s3_access_key_id")] + /// Resolved access ID shadow; the loaded route binding owns export authority. + #[serde(default, skip_serializing)] pub access_key_id: Redacted, - /// Secret reference containing the `AWS` secret access key. - #[serde(default = "default_s3_secret_access_key")] + /// Resolved secret-key shadow, never serialized into app config. + #[serde(default, skip_serializing)] pub secret_access_key: Redacted, - /// Optional secret reference containing an `AWS` session token. - #[serde(default)] + /// Resolved optional session-token shadow. + #[serde(default, skip_serializing)] pub session_token: Option>, + /// Require the route's implicit session-token binding. + #[serde(default)] + pub session_token_enabled: bool, /// Query-string handling policy for the signed `S3` origin request. /// /// Set this to `strip` when request query parameters are transformation @@ -954,12 +956,7 @@ impl S3SigV4AuthConfig { "S3 secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" ); } - self.access_key_id = Redacted::new(self.access_key_id.expose().trim().to_string()); - self.secret_access_key = Redacted::new(self.secret_access_key.expose().trim().to_string()); - self.session_token = self.session_token.take().and_then(|value| { - let value = value.expose().trim().to_string(); - (!value.is_empty()).then(|| Redacted::new(value)) - }); + // Credential bytes are opaque. Only the loader validates resolved values. } fn prepare_runtime(&self) -> Result<(), Report> { @@ -975,12 +972,8 @@ impl S3SigV4AuthConfig { .to_string(), })); } - if self.access_key_id.expose().is_empty() || self.secret_access_key.expose().is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: "proxy.asset_routes auth s3_sigv4 credentials must not be empty after secret resolution" - .to_string(), - })); - } + // This preparation also runs for unresolved config. Required credentials + // are enforced by the binding loader, not by mutable reference fields. Ok(()) } } @@ -1946,7 +1939,7 @@ pub struct TinybirdSettings { /// Master enablement for auction telemetry ingestion. #[serde(default)] pub enabled: bool, - /// Regional Tinybird API host, without scheme or path. + /// Regional Tinybird DNS authority, optionally with an HTTPS port. #[serde(default)] pub api_host: String, /// Deprecated feature-specific store selector accepted for migration only. @@ -1955,8 +1948,8 @@ pub struct TinybirdSettings { /// Auction Events API datasource name. #[serde(default = "default_tinybird_auction_dataset")] pub auction_dataset: String, - /// Secret reference containing the auction datasource APPEND token. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Resolved APPEND-token shadow, omitted from app-config serialization. + #[serde(default, skip_serializing)] pub auction_token_secret: Option>, /// Reserved for future access-log telemetry. /// @@ -2015,11 +2008,7 @@ impl TinybirdSettings { "tinybird.secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" ); } - self.auction_dataset = self.auction_dataset.trim().to_owned(); - self.auction_token_secret = self.auction_token_secret.take().and_then(|value| { - let value = value.expose().trim().to_owned(); - (!value.is_empty()).then(|| Redacted::new(value)) - }); + // Dataset identity and resolved credential bytes must remain exact. self.access_dataset = self.access_dataset.trim().to_owned(); self.access_token_secret = None; } @@ -2044,51 +2033,13 @@ impl TinybirdSettings { if !self.enabled { return Ok(()); } - validate_tinybird_api_host(&self.api_host)?; + tinybird_events_endpoint(&self.api_host)?; validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; - let token = self.auction_token_secret.as_ref().ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: - "tinybird.auction_token_secret is required when Tinybird telemetry is enabled" - .to_owned(), - }) - })?; - validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret") - } -} - -fn validate_tinybird_api_host(host: &str) -> Result<(), Report> { - if host.is_empty() - || host.contains('/') - || host.contains(':') - || host.chars().any(char::is_control) - || host.starts_with("http://") - || host.starts_with("https://") - { - return Err(Report::new(TrustedServerError::Configuration { - message: "tinybird.api_host must be a regional host without scheme, port, or path" - .to_owned(), - })); - } - validate_host_header_override_value(host).map_err(|reason| { - Report::new(TrustedServerError::Configuration { - message: format!("tinybird.api_host {reason}"), - }) - }) -} - -fn validate_tinybird_dataset(value: &str, setting: &str) -> Result<(), Report> { - if value.is_empty() - || value.len() > 128 - || !value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') - { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("{setting} must be a non-empty datasource identifier"), - })); + if let Some(token) = &self.auction_token_secret { + validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret")?; + } + Ok(()) } - Ok(()) } fn validate_tinybird_secret(value: &str, setting: &str) -> Result<(), Report> { @@ -2899,6 +2850,7 @@ pub struct TrustedClientIpConfig { /// Header containing the shared-secret authentication value. pub auth_header: String, /// Shared secret required before accepting the forwarded client IP address. + #[serde(default, skip_serializing)] #[validate(custom(function = validate_trusted_client_ip_shared_secret))] pub shared_secret: Redacted, } @@ -3000,6 +2952,10 @@ fn validate_trusted_client_ip_shared_secret( #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Settings { + /// Validated credential/target capabilities, constructed only by native loading. + #[serde(skip)] + #[validate(skip)] + pub resolved_bindings: crate::secret_bindings::ResolvedBindings, #[validate(nested)] pub publisher: Publisher, #[serde(default)] @@ -3066,8 +3022,9 @@ impl Settings { /// Creates a new [`Settings`] instance from a JSON value. /// - /// Runtime config-store loading uses this after verifying the `app_config` - /// blob envelope and extracting the same typed settings shape. + /// This standalone helper expects resolved local-comparison values. It does + /// not create export authority. Production config-store loading instead + /// verifies feature configuration and resolves native credential bindings. /// /// # Errors /// @@ -4087,14 +4044,14 @@ mod tests { } #[test] - fn serialized_default_config_stays_readable_by_the_base_revision_schema() { + fn serialized_top_level_keys_stay_within_the_base_revision_key_set() { let settings = Settings::from_toml(&crate_test_settings_str()) .expect("should parse settings without trusted client IP configuration"); let value = serde_json::to_value(&settings).expect("should serialize settings"); serde_json::from_value::(value) - .expect("base revision schema should accept a config blob with no trusted client IP"); + .expect("should accept serialized fields within the base revision top-level key set"); } #[test] @@ -4172,6 +4129,7 @@ mod tests { openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: false, api_token: Some(Redacted::new(CANARY_EC_PARTNER_API_TOKEN.to_string())), + api_token_enabled: true, batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: false, pull_sync_url: None, @@ -4194,6 +4152,7 @@ mod tests { access_key_id: Redacted::new(CANARY_S3_ACCESS_KEY_ID.to_string()), secret_access_key: Redacted::new(CANARY_S3_SECRET_ACCESS_KEY.to_string()), session_token: Some(Redacted::new(CANARY_S3_SESSION_TOKEN.to_string())), + session_token_enabled: true, origin_query: None, })); settings.proxy.asset_routes = vec![asset_route]; @@ -4890,6 +4849,135 @@ mod tests { ); } + #[test] + fn tinybird_shared_helpers_match_active_compiler_and_settings_validation() { + for (authority, endpoint) in [ + ("api.example.com", "https://api.example.com/v0/events"), + ("API.EXAMPLE.COM:443", "https://api.example.com/v0/events"), + ( + "api.example.com:8443", + "https://api.example.com:8443/v0/events", + ), + ] { + let mut tinybird = TinybirdSettings { + enabled: true, + api_host: authority.to_owned(), + ..TinybirdSettings::default() + }; + assert_eq!( + tinybird_events_endpoint(authority) + .expect("should compile an accepted Tinybird authority"), + endpoint + ); + crate::secret_bindings::validate_original_endpoints( + &json!({"tinybird":{"api_host":authority}}), + ) + .expect("should accept the same original authority before normalization"); + tinybird + .prepare_runtime() + .expect("should validate accepted Tinybird settings through shared helpers"); + let settings = Settings { + tinybird, + ..Default::default() + }; + let plan = crate::secret_bindings::compile_bindings(&settings) + .expect("should compile active settings with the same Tinybird authority"); + let binding = plan + .entries() + .iter() + .find(|entry| entry.descriptor_id() == "tinybird.auction") + .expect("should include the active Tinybird auction binding"); + assert_eq!(binding.export()[1], endpoint); + } + for authority in [ + "https://api.example.com", + "api.example.com/events", + "@api.example.com", + "api.example.com?", + "api.example.com:0", + ] { + assert!(tinybird_events_endpoint(authority).is_err()); + assert!( + crate::secret_bindings::validate_original_endpoints( + &json!({"tinybird":{"api_host":authority}}) + ) + .is_err() + ); + let mut tinybird = TinybirdSettings { + enabled: true, + api_host: authority.to_owned(), + ..TinybirdSettings::default() + }; + assert!(tinybird.prepare_runtime().is_err()); + let settings = Settings { + tinybird, + ..Default::default() + }; + assert!(crate::secret_bindings::compile_bindings(&settings).is_err()); + } + for dataset in [ + "events_1".to_owned(), + "a".repeat(128), + String::new(), + " events".to_owned(), + "é".to_owned(), + "a".repeat(129), + "events-1".to_owned(), + ] { + let expected = validate_tinybird_dataset(&dataset, "tinybird.auction_dataset").is_ok(); + let mut tinybird = TinybirdSettings { + enabled: true, + api_host: "api.example.com".to_owned(), + auction_dataset: dataset, + ..TinybirdSettings::default() + }; + assert_eq!(tinybird.prepare_runtime().is_ok(), expected); + let settings = Settings { + tinybird, + ..Default::default() + }; + assert_eq!( + crate::secret_bindings::compile_bindings(&settings).is_ok(), + expected + ); + } + } + + #[test] + fn tinybird_shared_helpers_preserve_stage_specific_activation_and_original_checks() { + let mut disabled = TinybirdSettings::default(); + disabled + .prepare_runtime() + .expect("should retain disabled Tinybird settings with an empty authority"); + let settings = Settings::default(); + assert_eq!( + crate::secret_bindings::compile_bindings(&settings) + .expect("should compile only required app roles when Tinybird is disabled") + .entries() + .len(), + 2 + ); + crate::secret_bindings::validate_original_endpoints( + &json!({"tinybird":{"enabled":false,"api_host":""}}), + ) + .expect("should preserve the disabled empty-authority raw-config case"); + assert!(tinybird_events_endpoint("").is_err()); + for authority in [ + " api.example.com", + "api.example.com ", + "api.example.com/path", + ] { + assert!( + crate::secret_bindings::validate_original_endpoints( + &json!({"tinybird":{"enabled":false,"api_host":authority}}) + ) + .is_err() + ); + } + disabled.enabled = true; + assert!(disabled.prepare_runtime().is_err()); + } + #[test] fn tinybird_defaults_to_disabled_placeholders() { let settings = Settings::from_toml(&crate_test_settings_str()) @@ -5984,6 +6072,7 @@ source_domain = "partner.example.com" openrtb_atype: EcPartner::default_openrtb_atype(), bidstream_enabled: false, api_token: api_token.map(|token| Redacted::new(token.to_owned())), + api_token_enabled: api_token.is_some(), batch_rate_limit: EcPartner::default_batch_rate_limit(), pull_sync_enabled: true, pull_sync_url: Some("https://partner.example.com/sync".to_owned()), @@ -7152,8 +7241,9 @@ source_domain = "partner.example.com" AssetOriginAuth::S3SigV4(config) => { assert_eq!(config.region, "us-east-1"); assert_eq!(config.secret_store, None); - assert_eq!(config.access_key_id.expose(), "access_key_id"); - assert_eq!(config.secret_access_key.expose(), "secret_access_key"); + assert!(config.access_key_id.expose().is_empty()); + assert!(config.secret_access_key.expose().is_empty()); + assert!(!config.session_token_enabled); } } } @@ -7420,7 +7510,12 @@ source_domain = "partner.example.com" toml::from_str(&toml).expect("should deserialize unnormalized settings"); let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); - for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { + let mut unresolved: Settings = serde_json::from_value(value) + .expect("should deserialize credential-free feature settings"); + unresolved + .normalize_deserialized() + .expect("should normalize feature settings without credential values"); + for result in [Settings::from_toml(&toml), Ok(unresolved)] { let settings = result.expect("should accept non-colliding keys"); assert_eq!( settings.image_optimizer.profile_sets.len(), diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 86616ad69d..37b2dcf57b 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -1,13 +1,12 @@ use edgezero_core::env_config::EnvConfig; -use error_stack::{Report, ResultExt}; +use error_stack::Report; use serde::Deserialize; use sha2::{Digest as _, Sha256}; pub use crate::config_payload::DEFAULT_CONFIG_STORE_ID; -use crate::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; +use crate::config_payload::{DEFAULT_SECRET_STORE_ID, LoadedConfig, settings_from_config_blob}; use crate::error::TrustedServerError; use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; -use crate::settings::Settings; const FASTLY_CHUNK_POINTER_KIND: &str = "fastly_config_chunks"; const FASTLY_CONFIG_ENTRY_LIMIT: usize = 8_000; @@ -68,7 +67,7 @@ pub fn default_secret_store_name() -> StoreName { StoreName::from(EnvConfig::from_env().store_name("secrets", DEFAULT_SECRET_STORE_ID)) } -/// Loads [`Settings`] from a platform config store and key. +/// Loads an atomic [`LoadedConfig`] from a platform config store and key. /// /// # Errors /// @@ -81,7 +80,7 @@ pub fn get_settings_from_config_store( store_name: &StoreName, key: &str, default_secret_store_name: &StoreName, -) -> Result> { +) -> Result> { let raw_value = read_config_entry(config_store, store_name, key)?; let envelope_json = resolve_fastly_chunk_pointer(config_store, store_name, &raw_value)?; settings_from_config_blob(&envelope_json, secret_store, default_secret_store_name) @@ -92,12 +91,11 @@ fn read_config_entry( store_name: &StoreName, key: &str, ) -> Result> { - let message = format!( - "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" - ); - config_store - .get(store_name, key) - .change_context(TrustedServerError::Configuration { message }) + config_store.get(store_name, key).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "trusted_server: config entry unavailable".to_owned(), + }) + }) } fn resolve_fastly_chunk_pointer( @@ -286,6 +284,7 @@ mod tests { key, &StoreName::from("trusted_server_secrets"), ) + .map(|loaded| loaded.settings().clone()) } #[test] @@ -479,8 +478,9 @@ mod tests { .expect_err("should fail when blob is missing"); assert!( - err.to_string().contains(CONFIG_BLOB_KEY), - "error should mention missing blob key" + err.to_string() + .contains("trusted_server: config entry unavailable"), + "error should expose only the safe schema path and category" ); } } diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index cb2eda85c2..b46a862c84 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -1,3 +1,7 @@ +/// Fictional native credential fixtures using the production binding compiler. +#[cfg(any(test, feature = "test-utils"))] +pub mod secret_bindings; + #[cfg(test)] pub(crate) mod logging; diff --git a/crates/trusted-server-core/src/test_support/secret_bindings.rs b/crates/trusted-server-core/src/test_support/secret_bindings.rs new file mode 100644 index 0000000000..7e0d60ebab --- /dev/null +++ b/crates/trusted-server-core/src/test_support/secret_bindings.rs @@ -0,0 +1,102 @@ +//! Native-store fixtures for exercising the real credential compiler in tests. + +use std::collections::BTreeMap; + +use error_stack::Report; + +use crate::error::TrustedServerError; +use crate::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; +use crate::secret_bindings::{SECRET_STORE_ID, compile_bindings, resolve_bindings}; +use crate::settings::{AssetOriginAuth, Settings}; + +struct FixtureStore(BTreeMap>); + +impl PlatformSecretStore for FixtureStore { + fn get_bytes(&self, _: &StoreName, key: &str) -> Result, Report> { + self.0 + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::SecretStore)) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + unreachable!("should never write fixture native credentials") + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + unreachable!("should never delete fixture native credentials") + } +} + +fn index(path: &str) -> Option { + path.split_once('[')?.1.split_once(']')?.0.parse().ok() +} + +/// Resolve existing fictional shadow values through actual generated native keys. +/// +/// Available only to test/test-utils builds. This does not construct a private +/// capability directly or supply a fallback value for an invalid fixture. +/// +/// # Errors +/// Returns a configuration error if any active fixture role lacks a valid value. +pub fn install_test_bindings(settings: &mut Settings) -> Result<(), Report> { + let plan = compile_bindings(settings)?; + let mut values = BTreeMap::new(); + for entry in plan.entries() { + let value = match entry.descriptor_id() { + "publisher.proxy" => Some(settings.publisher.proxy_secret.expose().as_str()), + "ec.passphrase" => Some(settings.ec.passphrase.expose().as_str()), + "handler.password" => index(entry.field_path()) + .and_then(|i| settings.handlers.get(i)) + .map(|handler| handler.password.expose().as_str()), + "ec.inbound" => index(entry.field_path()) + .and_then(|i| settings.ec.partners.get(i)) + .and_then(|partner| partner.api_token.as_ref()) + .map(|value| value.expose().as_str()), + "ec.pull" => index(entry.field_path()) + .and_then(|i| settings.ec.partners.get(i)) + .and_then(|partner| partner.ts_pull_token.as_ref()) + .map(|value| value.expose().as_str()), + "clientip.shared" => settings + .trusted_client_ip + .as_ref() + .map(|config| config.shared_secret.expose().as_str()), + "tinybird.auction" => settings + .tinybird + .auction_token_secret + .as_ref() + .map(|value| value.expose().as_str()), + "datadome.server" => settings + .integrations + .get("datadome") + .and_then(|config| config.get("server_side_key_secret_name")) + .and_then(serde_json::Value::as_str), + "datadome.bypass" => settings + .integrations + .get("datadome") + .and_then(|config| config.pointer("/protection_test_bypass/credential_secret_name")) + .and_then(serde_json::Value::as_str), + "s3.access-id" | "s3.secret" | "s3.session" => index(entry.field_path()) + .and_then(|i| settings.proxy.asset_routes.get(i)) + .and_then(|route| route.auth.as_ref()) + .and_then(|auth| match auth { + AssetOriginAuth::S3SigV4(auth) => match entry.descriptor_id() { + "s3.access-id" => Some(auth.access_key_id.expose().as_str()), + "s3.secret" => Some(auth.secret_access_key.expose().as_str()), + _ => auth + .session_token + .as_ref() + .map(|value| value.expose().as_str()), + }, + }), + _ => None, + }; + if let Some(value) = value { + values.insert(entry.key().to_owned(), value.as_bytes().to_vec()); + } + } + settings.resolved_bindings = resolve_bindings( + settings, + &FixtureStore(values), + &StoreName::from(SECRET_STORE_ID), + )?; + Ok(()) +} diff --git a/crates/trusted-server-integration-tests/fixtures/configs/secret-bindings.all-roles.toml b/crates/trusted-server-integration-tests/fixtures/configs/secret-bindings.all-roles.toml new file mode 100644 index 0000000000..bb649338bd --- /dev/null +++ b/crates/trusted-server-integration-tests/fixtures/configs/secret-bindings.all-roles.toml @@ -0,0 +1,43 @@ +# Fictional compiler/renderer fixture only. Never send traffic to these endpoints. +[[handlers]] +path = "^/_ts/admin" +username = "admin" + +[publisher] +domain = "publisher.example.com" +cookie_domain = "publisher.example.com" +origin_url = "https://origin.example.com" + +[ec] +[[ec.partners]] +name = "Fictional partner" +source_domain = "partner.example.com" +api_token_enabled = true +pull_sync_enabled = true +pull_sync_url = "https://sync.example.com/pull" + +[trusted_client_ip] +ip_header = "x-fictional-client-ip" +auth_header = "x-fictional-client-auth" + +[tinybird] +enabled = true +api_host = "api.tinybird.example.com" +auction_dataset = "auction_events_raw" + +[integrations.datadome] +enabled = true +enable_protection = true +protection_api_origin = "https://protection.example.com" + +[integrations.datadome.protection_test_bypass] +enabled = true + +[[proxy.asset_routes]] +prefix = "/assets/" +origin_url = "https://bucket.example.com" + +[proxy.asset_routes.auth] +type = "s3_sigv4" +region = "us-east-1" +session_token_enabled = true diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index c3d9ff53d8..552da3db32 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -1,16 +1,13 @@ [[handlers]] path = "^/_ts/admin" username = "admin" -password = "integration_admin_password" [publisher] domain = "localhost" cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" -proxy_secret = "integration_proxy_secret" [ec] -passphrase = "integration_ec_passphrase" ec_store = "ec_identity_store" pull_sync_concurrency = 3 @@ -18,13 +15,13 @@ pull_sync_concurrency = 3 name = "Integration Test Partner" source_domain = "inttest.example.com" bidstream_enabled = true -api_token = "integration_partner_token_alpha" +api_token_enabled = true [[ec.partners]] name = "Integration Test Partner 2" source_domain = "inttest2.example.com" bidstream_enabled = true -api_token = "integration_partner_token_bravo" +api_token_enabled = true [request_signing] enabled = false diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 551d275d98..08e5ca78f7 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -66,21 +66,9 @@ key = "api_key" data = "test-api-key" - [[local_server.secret_stores.trusted_server_secrets]] - key = "integration_admin_password" - data = "integration-admin-password-32-bytes-ok" - [[local_server.secret_stores.trusted_server_secrets]] - key = "integration_proxy_secret" - data = "integration-test-proxy-secret-32-bytes-ok" - [[local_server.secret_stores.trusted_server_secrets]] - key = "integration_ec_passphrase" - data = "integration-test-ec-secret-padded-32" - [[local_server.secret_stores.trusted_server_secrets]] - key = "integration_partner_token_alpha" - data = "integration-test-token-alpha-32-bytes-ok" - [[local_server.secret_stores.trusted_server_secrets]] - key = "integration_partner_token_bravo" - data = "integration-test-token-bravo-32-bytes-ok" +# BEGIN GENERATED APP SECRET FIXTURES +# Populated by generate-viceroy-config using the production binding compiler. +# END GENERATED APP SECRET FIXTURES [local_server.config_stores] # Generated integration configs inject the manifest-default app-config blob diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index aada5dcf54..e73faf6268 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use edgezero_core::blob_envelope::BlobEnvelope; use trusted_server_core::config::TrustedServerAppConfig; use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; +use trusted_server_core::settings::Settings; +use trusted_server_integration_tests::{fastly_fixture_blocks, native_fixtures}; const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const GENERATED_STORES_MARKER: &str = " # GENERATED_TRUSTED_SERVER_CONFIG_STORES"; @@ -18,6 +20,8 @@ struct Args { app_config: PathBuf, output: PathBuf, origin_url: Option, + adapter: String, + bindings_only: bool, } fn main() -> Result<(), DynError> { @@ -25,12 +29,6 @@ fn main() -> Result<(), DynError> { } fn run(args: &Args) -> Result<(), DynError> { - let template = fs::read_to_string(&args.template).map_err(|error| { - error_box(format!( - "failed to read Viceroy template `{}`: {error}", - args.template.display() - )) - })?; let app_config = fs::read_to_string(&args.app_config).map_err(|error| { error_box(format!( "failed to read Trusted Server app config `{}`: {error}", @@ -38,8 +36,30 @@ fn run(args: &Args) -> Result<(), DynError> { )) })?; - let envelope_json = build_app_config_envelope(&app_config, args.origin_url.as_deref())?; - let generated_config = inject_generated_config_stores(&template, &envelope_json)?; + let settings = fixture_settings(&app_config, args.origin_url.as_deref())?; + if args.bindings_only { + let entries = native_fixtures(&settings, &args.adapter) + .map_err(error_box)? + .into_iter() + .map(|fixture| { + serde_json::json!({ + "descriptor_id": fixture.descriptor_id, + "native": fixture.native, + "value": fixture.value, + }) + }) + .collect::>(); + fs::write(&args.output, serde_json::to_vec_pretty(&entries)?)?; + return Ok(()); + } + let template = fs::read_to_string(&args.template)?; + let template = inject_native_fixtures(&template, &settings, &args.adapter)?; + let generated_config = if template.contains(GENERATED_STORES_MARKER) { + let envelope_json = build_app_config_envelope(&app_config, args.origin_url.as_deref())?; + inject_generated_config_stores(&template, &envelope_json)? + } else { + template + }; if let Some(parent) = args.output.parent() { fs::create_dir_all(parent).map_err(|error| { @@ -64,6 +84,8 @@ fn parse_args(args: impl IntoIterator) -> Result let mut app_config = None; let mut output = None; let mut origin_url = None; + let mut adapter = "fastly".to_owned(); + let mut bindings_only = false; let mut iter = args.into_iter(); while let Some(arg) = iter.next() { @@ -72,6 +94,8 @@ fn parse_args(args: impl IntoIterator) -> Result "--app-config" => app_config = Some(next_path_arg(&mut iter, "--app-config")?), "--output" => output = Some(next_path_arg(&mut iter, "--output")?), "--origin-url" => origin_url = Some(next_string_arg(&mut iter, "--origin-url")?), + "--adapter" => adapter = next_string_arg(&mut iter, "--adapter")?, + "--bindings-only" => bindings_only = true, "--help" | "-h" => return Err(error_box(usage())), other => { return Err(error_box(format!( @@ -83,12 +107,17 @@ fn parse_args(args: impl IntoIterator) -> Result } Ok(Args { - template: template - .ok_or_else(|| error_box(format!("missing --template\n\n{}", usage())))?, + template: if bindings_only { + template.unwrap_or_default() + } else { + template.ok_or_else(|| error_box(format!("missing --template\n\n{}", usage())))? + }, app_config: app_config .ok_or_else(|| error_box(format!("missing --app-config\n\n{}", usage())))?, output: output.ok_or_else(|| error_box(format!("missing --output\n\n{}", usage())))?, origin_url, + adapter, + bindings_only, }) } @@ -108,22 +137,16 @@ fn next_string_arg( } fn usage() -> String { - "usage: generate-viceroy-config --template --app-config --output [--origin-url ]".to_string() + "usage: generate-viceroy-config --template --app-config --output [--origin-url ] [--adapter fastly|axum|cloudflare|spin] [--bindings-only, no template required]".to_string() } fn build_app_config_envelope( app_config_toml: &str, origin_url: Option<&str>, ) -> Result { - let app_config: TrustedServerAppConfig = toml::from_str(app_config_toml) - .map_err(|error| error_box(format!("invalid Trusted Server app config: {error}")))?; - let mut settings = app_config.into_settings(); - if let Some(origin_url) = origin_url { - settings.publisher.origin_url = origin_url.to_string(); - } + let settings = fixture_settings(app_config_toml, origin_url)?; let app_config = TrustedServerAppConfig::new(settings) .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; - let data = serde_json::to_value(&app_config).map_err(|error| { error_box(format!( "failed to serialize Trusted Server app config to JSON: {error}" @@ -134,6 +157,86 @@ fn build_app_config_envelope( .map_err(|error| error_box(format!("failed to serialize app-config envelope: {error}"))) } +fn fixture_settings(app_config_toml: &str, origin_url: Option<&str>) -> Result { + let app_config: TrustedServerAppConfig = toml::from_str(app_config_toml) + .map_err(|error| error_box(format!("invalid Trusted Server app config: {error}")))?; + let mut settings = app_config.into_settings(); + if let Some(origin_url) = origin_url { + settings.publisher.origin_url = origin_url.to_string(); + } + // Native-only fixtures may be compiled from the public starter template's + // ordinary publisher placeholders. Deserialization still enforces the new + // schema and endpoint syntax; native_fixtures enforces binding policy and + // credential validators. Envelope generation separately validates the full + // deployable configuration through TrustedServerAppConfig::new. + Ok(settings) +} + +fn replace_fixture_section( + template: &str, + section: &str, + generated: &str, +) -> Result { + let begin = format!("# BEGIN GENERATED {section}"); + let end = format!("# END GENERATED {section}"); + let (before, rest) = template + .split_once(&begin) + .ok_or_else(|| error_box(format!("missing fixture marker {begin}")))?; + let (_, after) = rest + .split_once(&end) + .ok_or_else(|| error_box(format!("missing fixture marker {end}")))?; + Ok(format!("{before}{begin}\n{generated}\n{end}{after}")) +} + +fn inject_native_fixtures( + template: &str, + settings: &Settings, + adapter: &str, +) -> Result { + match adapter { + "fastly" => replace_fixture_section( + template, + "APP SECRET FIXTURES", + &fastly_fixture_blocks(settings).map_err(error_box)?, + ), + "cloudflare" => { + let fixtures = native_fixtures(settings, adapter).map_err(error_box)?; + let values = fixtures + .into_iter() + .map(|fixture| { + format!( + "{} = {}", + fixture.native.native_name, + toml::Value::String(fixture.value) + ) + }) + .collect::>() + .join("\n"); + replace_fixture_section(template, "APP SECRET FIXTURES", &values) + } + "spin" => { + let fixtures = native_fixtures(settings, adapter).map_err(error_box)?; + let application = fixtures + .iter() + .map(|fixture| fixture.native.declarations[0].clone()) + .collect::>() + .join("\n\n"); + let component = fixtures + .iter() + .map(|fixture| { + fixture.native.declarations[1] + .strip_prefix("[component.trusted-server.variables]\n") + .ok_or_else(|| error_box("unexpected native component declaration")) + }) + .collect::, _>>()? + .join("\n"); + let template = replace_fixture_section(template, "APP SECRET VARIABLES", &application)?; + replace_fixture_section(&template, "APP SECRET COMPONENT", &component) + } + _ => Err(error_box("unsupported fixture adapter")), + } +} + fn inject_generated_config_stores(template: &str, envelope_json: &str) -> Result { let marker_count = template.matches(GENERATED_STORES_MARKER).count(); if marker_count != 1 { @@ -202,29 +305,14 @@ mod tests { } fn integration_secret_store() -> IntegrationSecretStore { + let settings = fixture_settings(APP_CONFIG, None) + .expect("should deserialize the integration fixture settings"); IntegrationSecretStore { - values: HashMap::from([ - ( - "integration_admin_password".to_owned(), - b"integration-admin-password-32-bytes-ok".to_vec(), - ), - ( - "integration_proxy_secret".to_owned(), - b"integration-test-proxy-secret-32-bytes-ok".to_vec(), - ), - ( - "integration_ec_passphrase".to_owned(), - b"integration-test-ec-secret-padded-32".to_vec(), - ), - ( - "integration_partner_token_alpha".to_owned(), - b"integration-test-token-alpha-32-bytes-ok".to_vec(), - ), - ( - "integration_partner_token_bravo".to_owned(), - b"integration-test-token-bravo-32-bytes-ok".to_vec(), - ), - ]), + values: native_fixtures(&settings, "fastly") + .expect("should compile the integration native credentials") + .into_iter() + .map(|fixture| (fixture.native.key, fixture.value.into_bytes())) + .collect(), } } @@ -265,7 +353,9 @@ mod tests { template: PathBuf::from("template.toml"), app_config: PathBuf::from("trusted-server.toml"), output: PathBuf::from("generated.toml"), - origin_url: Some("http://127.0.0.1:9999".to_string()) + origin_url: Some("http://127.0.0.1:9999".to_string()), + adapter: "fastly".to_owned(), + bindings_only: false }, "should parse expected args" ); @@ -325,11 +415,202 @@ mod tests { .expect("should verify blob"); assert_eq!( - settings.publisher.origin_url, "http://127.0.0.1:9999", + settings.settings().publisher.origin_url, + "http://127.0.0.1:9999", "should apply origin override before envelope creation" ); } + #[test] + fn generated_native_fixtures_load_the_production_envelope() { + let settings = fixture_settings(APP_CONFIG, None) + .expect("should deserialize the integration fixture settings"); + let envelope = build_app_config_envelope(APP_CONFIG, None) + .expect("should build the validated integration config envelope"); + let native = inject_native_fixtures(TEMPLATE, &settings, "fastly") + .expect("should inject compiler-generated Fastly fixture blocks"); + let parsed: toml::Value = + toml::from_str(&native).expect("should parse the generated Fastly fixture TOML"); + let values = parsed["local_server"]["secret_stores"]["trusted_server_secrets"] + .as_array() + .expect("should expose the generated logical secret-store entries") + .iter() + .map(|entry| { + ( + entry["key"] + .as_str() + .expect("should expose the generated native key") + .to_owned(), + entry["data"] + .as_str() + .expect("should expose the fictional raw credential value") + .as_bytes() + .to_vec(), + ) + }) + .collect(); + let loaded = settings_from_config_blob( + &envelope, + &IntegrationSecretStore { values }, + &StoreName::from("trusted_server_secrets"), + ) + .expect("should load the production envelope using generated native values"); + assert!( + loaded + .ec + .partners + .iter() + .all(|partner| partner.api_token_enabled && partner.api_token.is_some()) + ); + assert_eq!( + native, + inject_native_fixtures(&native, &settings, "fastly") + .expect("should leave generated fixture blocks unchanged on a second injection") + ); + } + + #[test] + fn spin_fixtures_declare_both_native_variable_levels_without_values() { + let settings = fixture_settings(APP_CONFIG, None) + .expect("should deserialize the integration fixture settings"); + let template = "# BEGIN GENERATED APP SECRET VARIABLES\n# END GENERATED APP SECRET VARIABLES\n[component.trusted-server.variables]\n# BEGIN GENERATED APP SECRET COMPONENT\n# END GENERATED APP SECRET COMPONENT\n"; + let generated = inject_native_fixtures(template, &settings, "spin") + .expect("should generate both Spin declaration levels"); + let parsed: toml::Value = toml::from_str(&generated) + .expect("should parse the generated Spin declarations as TOML"); + for fixture in native_fixtures(&settings, "spin") + .expect("should compile the native Spin fixture identities") + { + let name = fixture.native.native_name; + assert_eq!(parsed["variables"][&name]["secret"].as_bool(), Some(true)); + assert_eq!(parsed["variables"][&name]["required"].as_bool(), Some(true)); + assert_eq!( + parsed["component"]["trusted-server"]["variables"][&name].as_str(), + Some(format!("{{{{ {name} }}}}").as_str()) + ); + assert!(!generated.contains(&fixture.value)); + } + } + + #[test] + fn equal_handler_owners_generate_valid_cloudflare_and_spin_manifests() { + let config = + format!("{APP_CONFIG}\n[[handlers]]\npath = \"^/_ts/admin\"\nusername = \"admin\"\n"); + let settings = fixture_settings(&config, None) + .expect("should parse integration configuration with two equal handler owners"); + let plan = trusted_server_core::secret_bindings::compile_bindings(&settings) + .expect("should preserve both equal handler owners in the compiled plan"); + assert_eq!(plan.entries().len(), 6); + + let cloudflare = inject_native_fixtures( + include_str!("../../../trusted-server-adapter-cloudflare/wrangler.ci.toml"), + &settings, + "cloudflare", + ) + .expect("should emit each equal Cloudflare native binding once"); + let cloudflare: toml::Value = toml::from_str(&cloudflare) + .expect("should parse the complete Cloudflare manifest with equal handlers"); + let fixtures = native_fixtures(&settings, "cloudflare") + .expect("should compile five physical Cloudflare fixtures for six owner rows"); + assert_eq!(fixtures.len(), 5); + let vars = cloudflare["vars"] + .as_table() + .expect("should retain the Cloudflare vars table"); + assert_eq!( + vars.keys().filter(|name| name.starts_with("ts1_")).count(), + 5 + ); + for fixture in fixtures { + assert_eq!( + vars[&fixture.native.native_name].as_str(), + Some(fixture.value.as_str()) + ); + } + + let spin = inject_native_fixtures( + include_str!("../../../trusted-server-adapter-spin/spin.toml"), + &settings, + "spin", + ) + .expect("should emit both Spin declaration levels once per physical request"); + let spin: toml::Value = toml::from_str(&spin) + .expect("should parse the complete Spin manifest with equal handlers"); + let application = spin["variables"] + .as_table() + .expect("should retain the Spin application variables table"); + let component = spin["component"]["trusted-server"]["variables"] + .as_table() + .expect("should retain the Spin component variables table"); + assert_eq!(application.len(), 5); + assert_eq!(component.len(), 5); + for fixture in native_fixtures(&settings, "spin") + .expect("should compile the coalesced Spin fixture identities") + { + let name = fixture.native.native_name; + assert_eq!(application[&name]["secret"].as_bool(), Some(true)); + assert_eq!(application[&name]["required"].as_bool(), Some(true)); + assert_eq!( + component[&name].as_str(), + Some(format!("{{{{ {name} }}}}").as_str()) + ); + } + } + + #[test] + fn bindings_only_does_not_require_a_template() { + let args = parse_args( + [ + "--bindings-only", + "--adapter", + "axum", + "--app-config", + "fixture.toml", + "--output", + "bindings.json", + ] + .map(str::to_owned), + ) + .expect("should parse bindings-only output without a template"); + assert!(args.bindings_only); + assert_eq!(args.adapter, "axum"); + } + + #[test] + fn public_starter_generates_native_fixtures_but_not_a_deployable_envelope() { + const EXAMPLE: &str = include_str!("../../../../trusted-server.example.toml"); + let settings = + fixture_settings(EXAMPLE, None).expect("should deserialize the public starter schema"); + for adapter in ["fastly", "axum", "cloudflare", "spin"] { + let fixtures = native_fixtures(&settings, adapter) + .expect("should compile binding policy despite ordinary starter placeholders"); + assert_eq!( + fixtures.len(), + 3, + "starter requires exactly three credential roles" + ); + } + let fastly = + inject_native_fixtures(include_str!("../../../../fastly.toml"), &settings, "fastly") + .expect("should render local Fastly fixtures from the public starter"); + toml::from_str::(&fastly) + .expect("should parse the generated local Fastly manifest"); + let spin = inject_native_fixtures( + include_str!("../../../trusted-server-adapter-spin/spin.toml"), + &settings, + "spin", + ) + .expect("should render local Spin declarations from the public starter"); + toml::from_str::(&spin) + .expect("should parse the generated local Spin manifest"); + let error = build_app_config_envelope(EXAMPLE, None) + .expect_err("should reject starter placeholders when building a deployable envelope"); + let message = error.to_string(); + assert!(message.contains("Insecure default")); + assert!(message.contains("publisher.domain")); + assert!(message.contains("publisher.cookie_domain")); + assert!(message.contains("publisher.origin_url")); + } + #[test] fn invalid_app_config_fails() { let result = build_app_config_envelope("not valid toml", None); diff --git a/crates/trusted-server-integration-tests/src/lib.rs b/crates/trusted-server-integration-tests/src/lib.rs new file mode 100644 index 0000000000..4f51a488fa --- /dev/null +++ b/crates/trusted-server-integration-tests/src/lib.rs @@ -0,0 +1,253 @@ +//! Fictional native credentials compiled through the production binding policy. +//! +//! These public local values are not provisioning inputs for deployed resources. + +use std::collections::BTreeMap; + +use trusted_server_core::secret_bindings::{ + CompiledBinding, NativeBinding, SECRET_STORE_ID, compile_bindings, render_native, + validate_credential, +}; +use trusted_server_core::settings::Settings; + +/// One generated native fixture. Values here are deliberately public test data. +pub struct NativeFixture { + /// Fixed credential-role selector associated with this fixture. + pub descriptor_id: String, + /// Generated native identity and declarations for the fictional credential. + pub native: NativeBinding, + /// Public fictional credential value, never a production provisioning input. + pub value: String, +} + +/// Compile local physical fixtures using the same compiler and renderer as TS. +/// +/// Equal compiled requests share one native fixture. Owning-field rows remain +/// separate in the production compiler; only physical output is coalesced here. +/// +/// # Errors +/// Returns a safe message if configuration or native rendering fails, or if +/// different requests or values collide at a final native identity. +/// +/// # Examples +/// ``` +/// use trusted_server_core::settings::Settings; +/// use trusted_server_integration_tests::native_fixtures; +/// let fixtures = native_fixtures(&Settings::default(), "axum") +/// .expect("should compile fictional app credential fixtures"); +/// assert_eq!(fixtures.len(), 2); +/// ``` +pub fn native_fixtures(settings: &Settings, adapter: &str) -> Result, String> { + let plan = compile_bindings(settings).map_err(|_| "invalid fixture binding context")?; + let fixtures = plan + .entries() + .iter() + .map(|entry| { + let value = match entry.descriptor_id() { + "publisher.proxy" => "integration-test-proxy-secret-32-bytes-ok".to_owned(), + "ec.passphrase" => "integration-test-ec-secret-padded-32".to_owned(), + "handler.password" => "integration-admin-password-32-bytes-ok".to_owned(), + "ec.inbound" => match entry.subject().get(1).map(String::as_str) { + Some("inttest.example.com") => { + "integration-test-token-alpha-32-bytes-ok".to_owned() + } + Some("inttest2.example.com") => { + "integration-test-token-bravo-32-bytes-ok".to_owned() + } + _ => format!("fictional-inbound-{}", entry.key()), + }, + "ec.pull" | "tinybird.auction" => "fictional-export-token-32-bytes-ok".to_owned(), + "clientip.shared" => "fictional-client-ip-shared-secret-32-bytes".to_owned(), + "datadome.server" => "0123456789ABCDEF0123456789ABCDEF".to_owned(), + "datadome.bypass" => "fictional-datadome-bypass-32-bytes-ok".to_owned(), + "s3.access-id" => "AKIAFICTIONALTESTONLY".to_owned(), + "s3.secret" => "fictional-s3-signing-secret-40-bytes-long-ok".to_owned(), + "s3.session" => "fictional-s3-session-token-32-bytes-ok".to_owned(), + _ => return Err("unsupported fictional fixture role".to_owned()), + }; + validate_credential(entry.scope(), value.as_bytes()) + .map_err(|_| "invalid fictional fixture credential")?; + let native = render_native(adapter, SECRET_STORE_ID, entry.key()) + .map_err(|_| "invalid fixture native identity")?; + Ok(( + entry, + NativeFixture { + descriptor_id: entry.descriptor_id().to_owned(), + native, + value, + }, + )) + }) + .collect::, String>>()?; + coalesce_native_fixtures(fixtures) +} + +fn coalesce_native_fixtures<'a>( + fixtures: impl IntoIterator, +) -> Result, String> { + let mut requests: BTreeMap = BTreeMap::new(); + let mut physical: Vec = Vec::new(); + for (entry, fixture) in fixtures { + if let Some((previous, index)) = requests.get(&fixture.native.native_name) { + // An owning array index is not part of a request's authorization. + // Compare all executable context and native store/key information, + // never just the derived key or final native name. + let same_request = previous.same_authorization(entry); + let existing = &physical[*index]; + if !same_request + || existing.descriptor_id != fixture.descriptor_id + || existing.native != fixture.native + || existing.value != fixture.value + { + return Err("conflicting native fixture identity".to_owned()); + } + continue; + } + requests.insert(fixture.native.native_name.clone(), (entry, physical.len())); + physical.push(fixture); + } + Ok(physical) +} + +/// Render Viceroy secret blocks without maintaining physical key strings. +/// +/// # Errors +/// Returns a safe message if the production compiler rejects the fixture. +/// +/// # Examples +/// ``` +/// use trusted_server_core::settings::Settings; +/// use trusted_server_integration_tests::fastly_fixture_blocks; +/// let blocks = fastly_fixture_blocks(&Settings::default()) +/// .expect("should render fictional native fixture blocks"); +/// assert!(blocks.contains("local_server.secret_stores.trusted_server_secrets")); +/// ``` +pub fn fastly_fixture_blocks(settings: &Settings) -> Result { + let mut output = String::new(); + for fixture in native_fixtures(settings, "fastly")? { + output.push_str(&format!( + "\n[[local_server.secret_stores.{SECRET_STORE_ID}]]\nkey = {}\ndata = {}\n", + toml::Value::String(fixture.native.native_name), + toml::Value::String(fixture.value), + )); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use trusted_server_core::config::TrustedServerAppConfig; + + fn equal_handler_settings() -> Settings { + let config: TrustedServerAppConfig = toml::from_str(include_str!( + "../fixtures/configs/secret-bindings.all-roles.toml" + )) + .expect("should parse the all-role fixture before duplicating its handler"); + let mut settings = config.into_settings(); + settings.handlers.push(settings.handlers[0].clone()); + settings + } + + #[test] + fn equal_handler_owners_share_one_physical_fixture_on_every_adapter() { + let settings = equal_handler_settings(); + let plan = compile_bindings(&settings).expect("should compile both equal handler owners"); + assert_eq!(plan.entries().len(), 13); + let handlers = plan + .entries() + .iter() + .filter(|entry| entry.descriptor_id() == "handler.password") + .collect::>(); + assert_eq!(handlers.len(), 2); + assert_ne!(handlers[0].field_path(), handlers[1].field_path()); + assert_eq!(handlers[0].key(), handlers[1].key()); + for adapter in ["axum", "fastly", "cloudflare", "spin"] { + let fixtures = native_fixtures(&settings, adapter) + .expect("should coalesce equal physical requests without losing compiler owners"); + assert_eq!( + fixtures.len(), + 12, + "one fixture per exact request on {adapter}" + ); + assert_eq!( + fixtures + .iter() + .filter(|fixture| fixture.descriptor_id == "handler.password") + .count(), + 1 + ); + } + } + + #[test] + fn colliding_native_names_require_equal_context_store_key_and_value() { + let mut settings = equal_handler_settings(); + settings.handlers[1].username = "other-admin".to_owned().into(); + let plan = compile_bindings(&settings).expect("should compile distinct handler subjects"); + let handlers = plan + .entries() + .iter() + .filter(|entry| entry.descriptor_id() == "handler.password") + .collect::>(); + let fixtures = + native_fixtures(&settings, "spin").expect("should render distinct handler fixtures"); + let original = fixtures + .iter() + .find(|fixture| fixture.native.key == handlers[0].key()) + .expect("should find the first handler physical fixture"); + let copy = || NativeFixture { + descriptor_id: original.descriptor_id.clone(), + native: original.native.clone(), + value: original.value.clone(), + }; + assert!( + coalesce_native_fixtures([(handlers[0], copy()), (handlers[1], copy())]).is_err(), + "different compiled contexts must not coalesce at a forced equal native name" + ); + for changed in ["store", "key", "value"] { + let mut conflicting = copy(); + match changed { + "store" => conflicting.native.store_id = "another_store".to_owned(), + "key" => conflicting.native.key = "another_key".to_owned(), + "value" => conflicting.value = "another-fictional-value".to_owned(), + _ => unreachable!("should only mutate the listed native request components"), + } + assert!( + coalesce_native_fixtures([(handlers[0], copy()), (handlers[0], conflicting)]) + .is_err(), + "a conflicting {changed} must not coalesce at an equal native name" + ); + } + } + + #[test] + fn all_twelve_roles_generate_valid_fictional_native_inputs_on_every_adapter() { + let config: TrustedServerAppConfig = toml::from_str(include_str!( + "../fixtures/configs/secret-bindings.all-roles.toml" + )) + .expect("should deserialize the all-role feature fixture"); + let settings = config.into_settings(); + for adapter in ["axum", "fastly", "cloudflare", "spin"] { + let fixtures = native_fixtures(&settings, adapter) + .expect("should compile valid native fixtures for the selected adapter"); + assert_eq!(fixtures.len(), 12, "all roles must be active for {adapter}"); + let names = fixtures + .iter() + .map(|fixture| &fixture.native.native_name) + .collect::>(); + assert_eq!( + names.len(), + 12, + "distinct contexts must retain distinct native identities" + ); + if adapter == "spin" { + assert!( + fixtures + .iter() + .all(|fixture| fixture.native.declarations.len() == 2) + ); + } + } + } +} diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index db4e011c5e..24a979d14e 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -8,6 +8,23 @@ use crate::common::runtime::{TestError, TestResult}; const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); +/// Compile fictional native credentials for the integration feature configuration. +/// +/// # Errors +/// Returns a config-generation error if the fixture or native rendering is invalid. +/// +/// # Examples +/// Use `integration_native_fixtures("spin")` to obtain the fixture's generated +/// Spin variable names and public test values. +pub fn integration_native_fixtures( + adapter: &str, +) -> TestResult> { + let config: TrustedServerAppConfig = + toml::from_str(APP_CONFIG).map_err(|_| Report::new(TestError::ConfigGeneration))?; + trusted_server_integration_tests::native_fixtures(&config.into_settings(), adapter) + .map_err(|_| Report::new(TestError::ConfigGeneration)) +} + pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { app_config_envelope(origin_port, |_| Ok(())) } @@ -125,12 +142,31 @@ mod tests { ("fastly.toml", FASTLY_CONFIG), ("Viceroy integration template", VICEROY_TEMPLATE), ] { + let config = if name == "Viceroy integration template" { + let app: trusted_server_core::config::TrustedServerAppConfig = + toml::from_str(super::APP_CONFIG) + .expect("should deserialize the integration feature configuration"); + let blocks = + trusted_server_integration_tests::fastly_fixture_blocks(&app.into_settings()) + .expect("should compile the Fastly native fixture blocks"); + let (before, rest) = config + .split_once("# BEGIN GENERATED APP SECRET FIXTURES") + .expect("should find the generated app-secret section start marker"); + let (_, after) = rest + .split_once("# END GENERATED APP SECRET FIXTURES") + .expect("should find the generated app-secret section end marker"); + format!("{before}{blocks}{after}") + } else { + config.to_owned() + }; let parsed: toml::Value = - toml::from_str(config).expect("should parse Fastly configuration"); + toml::from_str(&config).expect("should parse Fastly configuration"); let local_server = &parsed["local_server"]; assert!( - local_server["secret_stores"][LOGICAL_SECRET_STORE_ID].is_array(), + local_server["secret_stores"] + .get(LOGICAL_SECRET_STORE_ID) + .is_some_and(toml::Value::is_array), "{name} should expose the secret store under its logical ID" ); assert!( @@ -147,22 +183,25 @@ mod tests { } #[test] - fn local_fastly_config_defines_starter_secret_references() { + fn local_fastly_config_defines_compiled_starter_credentials() { let parsed: toml::Value = toml::from_str(FASTLY_CONFIG).expect("should parse root fastly.toml"); let entries = parsed["local_server"]["secret_stores"][LOGICAL_SECRET_STORE_ID] .as_array() .expect("fastly.toml should define trusted_server_secrets"); - for key in [ - "publisher_proxy_secret", - "ec_passphrase", - "handler_password", - ] { + let config: trusted_server_core::config::TrustedServerAppConfig = + toml::from_str(include_str!("../../../../trusted-server.example.toml")) + .expect("should deserialize the public example configuration"); + let fixtures = + trusted_server_integration_tests::native_fixtures(&config.into_settings(), "fastly") + .expect("should compile native fixtures for the public example"); + for fixture in fixtures { + let key = fixture.native.key; assert!( entries .iter() - .any(|entry| entry["key"].as_str() == Some(key)), + .any(|entry| entry["key"].as_str() == Some(key.as_str())), "fastly.toml should define local value for starter secret `{key}`" ); } diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 6e1388cea3..c23bf5fbc2 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -1,4 +1,4 @@ -use crate::common::config::integration_app_config_envelope; +use crate::common::config::{integration_app_config_envelope, integration_native_fixtures}; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; @@ -7,35 +7,10 @@ use std::io::{BufRead as _, BufReader}; use std::path::Path; use std::process::{Child, Command, Stdio}; use trusted_server_adapter_axum::platform::config_env_var; -use trusted_server_core::settings_data::{default_config_key, default_config_store_name}; /// Default port the Axum dev server binds to when no `PORT` env var is supplied. const AXUM_DEFAULT_PORT: u16 = 8787; -/// Secret-store entries referenced by the integration app-config fixture. -const INTEGRATION_SECRET_ENV: &[(&str, &str)] = &[ - ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_ADMIN_PASSWORD", - "integration-admin-password-32-bytes-ok", - ), - ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PROXY_SECRET", - "integration-test-proxy-secret-32-bytes-ok", - ), - ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_EC_PASSPHRASE", - "integration-test-ec-secret-padded-32", - ), - ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_ALPHA", - "integration-test-token-alpha-32-bytes-ok", - ), - ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_BRAVO", - "integration-test-token-bravo-32-bytes-ok", - ), -]; - /// Axum native dev-server runtime environment. /// /// Spawns the pre-built `trusted-server-axum` binary directly (no WASM, no @@ -59,14 +34,29 @@ impl RuntimeEnvironment for AxumDevServer { let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); let app_config = integration_app_config_envelope(origin_port())?; - let store_name = default_config_store_name(); - let config_key = default_config_key(); - let config_variable = config_env_var(store_name.as_ref(), &config_key); + let config_variable = config_env_var("trusted_server_config", "trusted_server_config"); + let fixtures = integration_native_fixtures("axum")?; let mut child = Command::new(&binary) .env("PORT", port.to_string()) + .env( + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME", + "trusted_server_config", + ) + .env( + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY", + "trusted_server_config", + ) + .env( + "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME", + "trusted_server_secrets", + ) .env(config_variable, app_config) - .envs(INTEGRATION_SECRET_ENV.iter().copied()) + .envs( + fixtures + .into_iter() + .map(|fixture| (fixture.native.native_name, fixture.value)), + ) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index 72328f42f1..1f68b12c36 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -1,4 +1,4 @@ -use crate::common::config::cloudflare_config_json; +use crate::common::config::{cloudflare_config_json, integration_native_fixtures}; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; @@ -36,6 +36,20 @@ fn write_generated_ci_config(wrangler_dir: &Path, config_json: &str) -> TestResu template_path.display() ))?; let generated = inject_cloudflare_config(&template, config_json)?; + let mut generated: toml::Value = + toml::from_str(&generated).map_err(|_| Report::new(TestError::ConfigGeneration))?; + let vars = generated + .get_mut("vars") + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| Report::new(TestError::ConfigGeneration))?; + for fixture in integration_native_fixtures("cloudflare")? { + vars.insert( + fixture.native.native_name, + toml::Value::String(fixture.value), + ); + } + let generated = + toml::to_string(&generated).map_err(|_| Report::new(TestError::ConfigGeneration))?; let output_path = wrangler_dir.join(GENERATED_CI_CONFIG); std::fs::write(&output_path, generated) .change_context(TestError::RuntimeSpawn) @@ -67,11 +81,8 @@ impl RuntimeEnvironment for CloudflareWorkers { fn spawn(&self, _wasm_path: &Path) -> TestResult { let wrangler_dir = self.wrangler_dir(); - let config = if std::env::var("CI").is_ok() { - write_generated_ci_config(&wrangler_dir, &cloudflare_config_json(origin_port())?)? - } else { - "wrangler.toml".to_string() - }; + let config = + write_generated_ci_config(&wrangler_dir, &cloudflare_config_json(origin_port())?)?; self.spawn_wrangler(&wrangler_dir, &config) } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 90263c320d..f74b1b255a 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -38,24 +38,27 @@ use trusted_server_core::test_support::nextjs_auction; /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. fn test_settings() -> Settings { - Settings::from_toml( + let mut settings: Settings = toml::from_str( r#" [[handlers]] path = "^/_ts/admin" username = "admin" - password = "admin-pass" [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" origin_url = "https://origin.test-publisher.example.com" - proxy_secret = "parity-test-proxy-secret" [ec] - passphrase = "test-secret-key-32-bytes-minimum" "#, ) - .expect("should parse parity test settings") + .expect("should parse parity test settings"); + settings.handlers[0].password = "admin-pass".to_owned().into(); + settings.publisher.proxy_secret = "parity-test-proxy-secret".to_owned().into(); + settings.ec.passphrase = "test-secret-key-32-bytes-minimum".to_owned().into(); + trusted_server_core::test_support::secret_bindings::install_test_bindings(&mut settings) + .expect("should load parity credentials through the production compiler"); + settings } /// Build the Axum adapter router from the shared test settings. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4220851797..04504e04ef 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -100,6 +100,12 @@ export default withMermaid( items: [ { text: 'EdgeZero Lifecycle', link: '/guide/edgezero' }, { text: 'Configuration', link: '/guide/configuration' }, + { text: 'App credentials', link: '/guide/secrets' }, + { text: 'CI provisioning', link: '/guide/secrets-ci' }, + { + text: 'Secret binding migration', + link: '/guide/secret-binding-migration', + }, { text: 'CLI', link: '/guide/cli' }, { text: 'Dev Proxy', link: '/guide/ts-dev-proxy' }, { text: 'Testing', link: '/guide/testing' }, diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index f6c8f72d31..eba4e7ce10 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -463,7 +463,7 @@ Redirects are followed for 301/302/303/307/308, with at most four hops. Cache and CORS are derived from the sanitized upstream response and response finalization; there is no endpoint CORS grant or rate limiter. This endpoint validates an existing signature—it does not mint one. Signing depends on -`publisher.proxy_secret`; server-side fetches also enforce +the loaded `publisher.proxy` binding; server-side fetches also enforce `proxy.allowed_domains`. **Example:** @@ -533,7 +533,7 @@ returns `403`, an oversized POST returns `413`, and malformed/unsupported URLs or JSON use the current shared error mapping. The endpoint sets JSON content type but no dedicated cache or CORS policy and has no rate limiter. It mints a new proxy signature; it does not validate a caller-supplied `tstoken`. -Signing depends on `publisher.proxy_secret` and checks +Signing depends on the loaded `publisher.proxy` binding and checks `proxy.allowed_domains` before minting. **Request Methods:** GET or POST @@ -591,7 +591,7 @@ attempt to overwrite an existing query parameter uses the shared error mapping; oversized bodies return `413`. There is no dedicated CORS grant or rate limiter. Rebuild validates the old signature before minting its replacement and never permits `tsurl`, `tstoken`, or `tsexp` mutation. -Validation and re-signing depend on `publisher.proxy_secret`. +Validation and re-signing depend on the loaded `publisher.proxy` binding. **Request Body:** @@ -1242,16 +1242,16 @@ Endpoints under protected paths require HTTP Basic Authentication: [[handlers]] path = "^/_ts/admin" username = "admin" -password = "admin_password" ``` -`password` is a key in the Trusted Server secret store. Provision the actual -Basic Authentication password under `admin_password`. +Provision the generated `handler.password` binding for this exact regex and +username from a [reviewed plan](./secrets). No password property belongs in TOML. **Usage:** ```bash -curl -u 'admin:' https://edge.example.com/_ts/admin/keys/rotate +curl -u admin https://edge.example.com/_ts/admin/keys/rotate +# curl prompts for the password without placing it on argv. ``` **Protected Endpoints:** diff --git a/docs/guide/asset-routes.md b/docs/guide/asset-routes.md index 405bc1d1bb..634e4a0eef 100644 --- a/docs/guide/asset-routes.md +++ b/docs/guide/asset-routes.md @@ -58,39 +58,44 @@ Add an auth block when the asset origin is a private S3 bucket. ```toml [[proxy.asset_routes]] prefix = "/.image/" -origin_url = "https://bucket.s3.us-east-1.amazonaws.com" +origin_url = "https://assets.example.com" [proxy.asset_routes.auth] type = "s3_sigv4" region = "us-east-1" origin_query = "strip" -access_key_id = "s3_access_key_id" -secret_access_key = "s3_secret_access_key" -# session_token = "s3_session_token" +session_token_enabled = false ``` ### S3 requirements - `origin_url` must be the real S3 host that AWS validates in the SigV4 canonical request. -- Use `https` origins for authenticated routes. `http` origins are accepted by the generic asset route validator, but they would send SigV4 headers in clear text. +- Authenticated routes require HTTPS and certificate verification regardless of `proxy.certificate_check`. The example host is fictional; use the real approved S3 origin in private config. - S3 support is for `GET` and `HEAD` asset reads. - Signing uses header-based AWS SigV4, not presigned URLs. - The signer uses `x-amz-content-sha256: UNSIGNED-PAYLOAD`. -- Credential references resolve from the logical `trusted_server_secrets` store while runtime settings are built. Signing performs no request-time secret-store reads. +- Generated bindings resolve from the logical `trusted_server_secrets` store while runtime settings are built. Signing performs no request-time secret-store reads. The loaded credential remains paired with its compiled origin/policy across GET/HEAD and preflight paths; redirects are not followed. - Successful authenticated S3 responses preserve the origin `Cache-Control`; configure object cache headers intentionally. - Existing client `Authorization` and `x-amz-*` signing headers are replaced before signing. ### Secret store values -Credential fields contain secret key references: - -| Config field | Default key | Resolved value | -| ------------------- | ------------------- | ------------------------------------ | -| `access_key_id` | `access_key_id` | AWS access key ID | -| `secret_access_key` | `secret_access_key` | AWS secret access key | -| `session_token` | unset | Optional AWS temporary session token | - -Place those values in the logical `trusted_server_secrets` store. Adapter configuration maps that logical ID to an environment-specific physical store such as Fastly `ts_secrets`. The legacy `secret_store` field is accepted for one migration release but ignored and omitted from newly pushed config. +S3 auth implies required `s3.access-id` and `s3.secret` bindings. +Boolean `session_token_enabled` defaults to false; true also requires +`s3.session`. No credential-name properties belong in public TOML. + +All three generated contexts include the route prefix, HTTPS origin, optional +regex/rewrite, region, and effective query policy, with separate purposes. +Changing those inputs requires newly reviewed provisioning, not reuse of the +old credential under mutable config. Use [the shared plan and native instructions](./secrets). +Do not derive keys manually. + +Adapter deployment maps `trusted_server_secrets` to the physical store. +Deprecated `secret_store` input is ignored and omitted from new config. +For rotation, pause affected state creation/traffic while updating the full +required set. Native multi-key writes are not transactional. Verify the set, +recreate state, exercise GET/HEAD and preflight, then resume. See +[migration and retirement](./secret-binding-migration). ## Origin query policy diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index 82c18cd5d8..d2c964014a 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -28,12 +28,17 @@ the temporary `.edgezero/` directory; the Axum process does not read that file directly. The script reads the envelope and exports it as `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG`. -Secrets use `TRUSTED_SERVER_SECRET_{STORE}_{KEY}`. The exact required variables -are: - -- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD` -- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET` -- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE` +App credentials use generated names from +`ts secrets plan --adapter axum --app-config trusted-server.toml --no-env`. +The renderer uses the runtime's actual environment mapping. Do not reuse +`HANDLER_PASSWORD`, `PUBLISHER_PROXY_SECRET`, or `EC_PASSPHRASE` labels. +Follow the [native input walkthrough](./secrets#axum-native-environment-input): +inject each raw value into the shell or supervisor that actually launches Axum. +A child process cannot export into its parent. + +`ts secrets check --adapter axum` checks only its own environment for presence, +not remote process injection, credential validity, or delivery. Direct `set` +is unsupported. Native reads and TLS/no-follow rehearsal remain release gates. Before the positive request, the smoke omits the config and each secret in separate process starts. Each request must return 500 and its log must identify diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 9466e1f3df..81df5f7741 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -3,6 +3,35 @@ The Trusted Server CLI binary is `ts`. It is a host-target operator tool for configuration, page audits, and EdgeZero-backed lifecycle commands. +## App credential commands + +`ts secrets plan` renders active context-derived native identities for +`fastly`, `axum`, `cloudflare`, and `spin`. Direct `set` supports Fastly only. + +```bash +ts secrets plan --adapter fastly --app-config trusted-server.toml --no-env --out bindings.json +ts secrets set tinybird.auction --adapter fastly --app-config trusted-server.toml --no-env --plan bindings.json +ts secrets check --adapter fastly --app-config trusted-server.toml --no-env --plan bindings.json +``` + +Shared flags are `--adapter`, `--app-config`, `--manifest`, `--no-env`, and +Fastly-only `--service-id`. `--manifest` defaults to `EDGEZERO_MANIFEST`, +otherwise `edgezero.toml`; the app-config default is `.toml` beside +it. `plan --out` writes secret-free JSON; without it the plan goes to stdout. + +`set ROLE` requires `--plan`, accepts an optional generated `--binding` +entry ID for repeated roles, and uses confirmation followed by hidden input. +Automation uses `--stdin --yes`; no value argument exists. Unsupported adapter +writes reject before input. A noninteractive repeated role requires +`--binding`. Full-plan drift rejects before credential input or mutation. + +`check --plan` is presence-only. Fastly uses metadata without reading values, +Axum checks the current process environment, and Cloudflare/Spin report +unsupported with a nonzero result. It does not prove credential validity, +delivery, TLS/no-follow, or state refresh. See [Provision app credentials](./secrets), +[protected CI](./secrets-ci), and [migration](./secret-binding-migration). +Native/provider/IAM rehearsals remain release gates. + ## Command index The table summarizes native `--help` output on Linux and macOS. Platform-only @@ -123,12 +152,10 @@ fields to existing configs before relying on their overrides. Pass `--no-env` for file-only operation. See [Configuration](/guide/configuration#environment-variable-overrides-typed-cli) for migration and rollback guidance. -`config diff`, `config push --dry-run`, and the confirmation preview shown by -`config push` render resolved app-config values. Store-backed secret fields are -key names at this stage, but deliberately inline values such as -`trusted_client_ip.shared_secret` can appear verbatim. Use `--no-diff` for a -push when terminal output or CI logs are not an approved place for inline -configuration secrets; `--no-diff` does not change validation or publication. +`config diff`, `config push --dry-run`, and the confirmation preview render +ordinary effective config. App credential properties are absent, not inline or +key-name values. Review deployment metadata access too. `--no-diff` suppresses +the push preview; it does not change validation or publication. `config push` publishes a single EdgeZero `BlobEnvelope` containing the validated Trusted Server settings JSON. This blob model is intentional because full diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index 73448d8cf1..44116ec0ea 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -32,8 +32,9 @@ value of `app_config` in a JSON object; that outer JSON is assigned to the `TRUSTED_SERVER_CONFIG` Worker variable. This double encoding is required by the current startup reader. -The generated local Wrangler files define `handler_password`, -`publisher_proxy_secret`, and `ec_passphrase` separately. The smoke launches +The generated local Wrangler files must define the native names for +`handler.password`, `publisher.proxy`, and `ec.passphrase` from the shared +compiler/renderer, not legacy labels. The smoke launches `wrangler dev` once without the config binding and once for each omitted secret. Each case requires a 500 response and verifies the exact missing binding against Wrangler's runtime binding inventory. The positive publisher @@ -49,10 +50,18 @@ the checked-in manifests. Local `[vars]` are only for the isolated smoke. For a deployed Worker, obtain the validated envelope from the target KV namespace, construct the same nested `TRUSTED_SERVER_CONFIG` JSON, and configure it in the deployment environment. -Store the three credential values with `wrangler secret put -handler_password`, `wrangler secret put publisher_proxy_secret`, and `wrangler -secret put ec_passphrase`; do not commit them to a Wrangler manifest. Deploy -only after all four bindings are present. +Generate a reviewed `ts secrets plan --adapter cloudflare` and supply each +raw credential with `wrangler secret put` using its generated native name. +Follow the [complete native walkthrough](./secrets#cloudflare-native-worker-secrets), +including the matching Wrangler manifest/environment and nested config bridge. +Do not commit values in a Wrangler manifest. Wrangler secret writes can deploy +a new Worker version, so coordinate migration in isolation. + +Direct `ts secrets set` and native presence `check` are unsupported for this +adapter; `check` reports unsupported and exits nonzero. Inspect provider +name inventory and verify fresh-state requests separately. A config-only +publisher must not hold Worker deployment/secret permissions. Native binding, +HTTPS/no-follow, and IAM rehearsals remain release gates. A successful `ts config push` alone does not configure this runtime: the Worker currently does not open `TRUSTED_SERVER_KV` during startup. Request-time diff --git a/docs/guide/collective-sync.md b/docs/guide/collective-sync.md index 109e46769b..83c5f74dc0 100644 --- a/docs/guide/collective-sync.md +++ b/docs/guide/collective-sync.md @@ -37,6 +37,15 @@ sequenceDiagram | **Object Store** | Source of truth | Durable, supports range queries, sync endpoint | | **Secret Key** | Shared salt | Distributed out-of-band, enables a data sharing agreement | +## App credential boundary + +This collective-sync description is not a new secret-binding selector or provider +writer. For the implemented partner pull flow, `ec.pull` binds the normalized +partner domain and exact HTTPS endpoint; inbound APIs use separate `ec.inbound` +authentication with `api_token_enabled = true`. Use [reviewed provisioning](./secrets), +not a mutable allowlist or shared credential reference, to authorize a target. +Request-signing/management stores remain separate. + ## Configuration Add the following to your `trusted-server.toml`: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a0c2b1b93a..44dd5c0749 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -6,166 +6,75 @@ Learn how to configure Trusted Server for your deployment. Trusted Server uses a flexible configuration system based on: -1. **TOML Files** - `trusted-server.toml` for ordinary configuration and secret key names +1. **TOML Files** - `trusted-server.toml` for ordinary feature, identity, and destination settings 2. **Environment Variables** - Typed CLI overrides with the `TRUSTED_SERVER__` prefix 3. **EdgeZero Stores** - Config and secret stores for the pushed blob and runtime secret values ## Quick Start -### Minimal Configuration +### Minimal configuration -Create `trusted-server.toml` in your project root. Generate both secret values -first with `openssl rand -base64 32`; the placeholders below are intentionally -rejected until replaced. +Start from `trusted-server.example.toml`. App config has no credential properties: ```toml +[[handlers]] +path = "^/_ts/admin" +username = "admin" + [publisher] -domain = "publisher.com" -cookie_domain = ".publisher.com" -origin_url = "https://origin.publisher.com" -proxy_secret = "publisher_proxy_secret" +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" [ec] -passphrase = "ec_passphrase" +ec_store = "ec_identity_store" ``` -### Environment Variable Overrides +Provision generated `publisher.proxy`, `ec.passphrase`, and `handler.password` bindings before startup. These are fixed CLI selectors, not TOML fields. See [Provision app credentials](./secrets) for plan review, secure input, and native instructions. -Environment variables are merged into existing TOML values by the typed -`ts config validate`, `ts config diff`, and `ts config push` flows. They are not -read by the deployed application at request time. +### Environment variable overrides + +Typed `ts config validate`, `diff`, and `push` apply ordinary `TRUSTED_SERVER__...` scalar overlays unless `--no-env` is set. They are not request-time runtime settings. Never use overlays for credential values or names. ```bash -# Format: TRUSTED_SERVER__SECTION__FIELD -export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com -export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -# Secret overrides, when needed, are key names—not secret values. -export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret -export TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase - -# Replace the rejected placeholder values in trusted-server.toml, then validate. +export TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com +export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.example.com ts config validate -ts config push --adapter fastly -``` - -### Static secret references - -Static app-config credentials contain stable key names only. This includes -publisher, trusted-client-IP, EC, handler, Tinybird, DataDome, and S3 fields: - -- `publisher.proxy_secret` -- `trusted_client_ip.shared_secret`, when trusted client-IP forwarding is configured -- `ec.passphrase` -- `ec.partners[*].api_token`, when inbound identify or batch sync is used -- `ec.partners[*].ts_pull_token`, when pull sync is enabled -- `handlers[*].password` -- `tinybird.auction_token_secret`, when Tinybird auction telemetry is enabled -- `integrations.datadome.server_side_key_secret_name`, when protection is enabled -- `integrations.datadome.protection_test_bypass.credential_secret_name`, when the bypass is enabled -- `proxy.asset_routes[*].auth.access_key_id`, `secret_access_key`, and optional `session_token` - -Their values belong in the logical `trusted_server_secrets` store and are -resolved only while an instance builds runtime settings. An adapter can map the -logical ID to a different physical name. For example, Fastly commonly maps -`trusted_server_secrets` to physical store `ts_secrets`. - -Two accepted secret-shaped fields are deliberately different: - -- `trusted_client_ip.shared_secret` is an inline value in the app-config blob; - it is redacted by debug formatting but is not resolved from a secret store. -- `tinybird.access_token_secret` is deprecated input. It is accepted for - migration, then discarded and omitted from serialized config; use - `tinybird.auction_token_secret` as the store key name instead. - -The four deprecated `secret_store` selectors under Tinybird, DataDome, its -protection-test bypass, and S3 route authentication are also accepted and -discarded. Store selection comes from the adapter's EdgeZero mapping. - -::: warning CLI output and inline secrets -`ts config diff`, `ts config push --dry-run`, and the interactive push preview -can print deliberately inline values. Use `ts config push --no-diff` when that -output is not safe for the current terminal or CI log. The flag suppresses the -diff; it does not move inline values to a secret store. -::: +``` + +### Context-derived secret bindings -The following table records the independent lifecycle, key-identity, -serialization, runtime, and secret axes for every exceptional field: - -| Path | Lifecycle | Key identity | Serialization | Runtime | Secret handling | -| ------------------------------------------------------------ | ---------- | ----------------------------------- | ------------- | -------------------- | ------------------------ | -| `AssetOriginAuth.s3_sig_v4` | deprecated | alias of `AssetOriginAuth.s3_sigv4` | skipped | deserialization only | none | -| `DataDomeConfig.server_side_key_secret_name` | canonical | canonical | serialized | active | store resolved | -| `DataDomeConfig.server_side_key_secret_store` | deprecated | canonical | skipped | normalized away | none | -| `DataDomeProtectionTestBypassConfig.credential_secret_name` | canonical | canonical | serialized | active | store resolved | -| `DataDomeProtectionTestBypassConfig.credential_secret_store` | deprecated | canonical | skipped | normalized away | none | -| `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | -| `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | -| `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | -| `Handler.password` | canonical | canonical | serialized | active | store resolved | -| `Publisher.proxy_secret` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.access_key_id` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.secret_access_key` | canonical | canonical | serialized | active | store resolved | -| `S3SigV4AuthConfig.secret_store` | deprecated | canonical | skipped | normalized away | none | -| `S3SigV4AuthConfig.session_token` | canonical | canonical | serialized | active | store resolved | -| `TinybirdSettings.access_token_secret` | deprecated | canonical | skipped | normalized away | accepted, then discarded | -| `TinybirdSettings.auction_token_secret` | canonical | canonical | serialized | active | store resolved | -| `TinybirdSettings.secret_store` | deprecated | canonical | skipped | normalized away | none | -| `TrustedClientIpConfig.shared_secret` | canonical | canonical | serialized | active | deliberately inline | - -Prepare an initial reference-based deployment in this order: - -1. Create the physical store and configure its `trusted_server_secrets` mapping. -2. Choose a stable key name for each active credential field in the app config. -3. Write each credential value under its referenced key without exposing it in - command arguments, shell history, logs, or CI output. -4. Run `ts config validate`, then `ts config push --adapter fastly`. -5. Start or deploy instances after the store and pushed config are both ready. - -Migrate an existing deployment in this order: - -1. Populate the physical store mapped from `trusted_server_secrets` with the - existing credential values without printing them in shell history, logs, or - CI output. -2. Replace each active credential value with a stable key name and remove the - legacy Tinybird, DataDome, and S3 `secret_store` selectors. -3. Run `ts config validate`, then `ts config push --adapter fastly --no-diff`. -4. Restart/redeploy instances as needed to load the new values. Rotation is - startup-scoped; changing a store value does not alter already-built state. - -Keep `publisher.proxy_secret` and `ec.passphrase` stable unless intentionally -rotating signed URLs or EC identifiers. On Spin, the app-config blob is stored -under the `trusted_server_config` key in Spin's built-in `default` key-value -store. Set the corresponding CLI store mapping before pushing so the write -matches the runtime lookup: +All active app credentials use the logical `trusted_server_secrets` store and trusted native deployment mapping. Config supplies feature activation, identity, and destination; core derives the only allowed lookup. Enabled roles require their binding. Disabled roles make no secret reads. + +All twelve removed credential properties reject even on disabled features. See the [migration table](./secret-binding-migration#removed-properties) and new optional-credential flags. No inline values, secret references, generated-key overrides, labels, or per-field store selectors belong in new config. + +Trusted-client-IP credentials are store-backed, not inline. The former inline description was wrong. Deprecated Tinybird `access_token_secret` remains discarded; `access_enabled = true` is rejected. Deprecated Tinybird/DataDome/S3 store selectors remain ignored and absent from serialization. `s3_sig_v4` remains a deserialize alias for `s3_sigv4`. + +Runtime publishes credentials and compiled targets as one snapshot. Native value replacement does not refresh existing states. Request-signing and management credentials retain their separate fixed-store paths. + +### Secret store migration + +Follow [Migrate app secret bindings](./secret-binding-migration). Review the new config and plan, provision generated identities first, deploy matching binary/config in isolation, recreate state, and retire legacy/context keys within an owned window. Old/new binaries require separate compatible config snapshots/stores during mixed rollout. No raw-key fallback exists. + +Spin still reads app config from its built-in `default` KV store: ```bash export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default ts config push --adapter spin ``` -For local Spin development, add `--local` to the push command. Also declare a -component variable for each chosen secret key name using the encoder documented -in `spin.toml`. Missing stores, keys, invalid UTF-8, and empty values fail -closed; inline plaintext fallback is not supported. +Use [generated application and component declarations](./secrets#spin-native-variables), not hand-encoded labels. ### Tinybird auction telemetry -Tinybird uses the same typed secret-reference path as the other static -credentials. Do not configure a feature-specific store: - ```toml [tinybird] enabled = true -api_host = "api.example.com" +api_host = "api.tinybird.example.com" auction_dataset = "auction_events_raw" -auction_token_secret = "tinybird_auction_append_token" ``` -Store the APPEND token value under `tinybird_auction_append_token` in the -physical store mapped from `trusted_server_secrets`. The token is resolved once -at startup. Disabled Tinybird telemetry does not require or resolve the token. -The legacy `tinybird.secret_store` field is accepted for one migration release, -but it is ignored and omitted from newly pushed config. +Obtain a restricted APPEND token outside TS and provision the generated `tinybird.auction` binding for its exact endpoint and dataset. Do not add a token-name property. See the [Fastly tutorial](./secrets#first-fastly-setup-with-tinybird). ### Generate Secure Secrets @@ -214,18 +123,19 @@ fail and the service will return its startup-error response. ## Example: Production Setup -Generate and substitute every `replace-with-*` value before validation or -deployment. +Review ordinary deployment settings and provision generated credentials separately. ```toml +[[handlers]] +path = "^/_ts/admin" +username = "admin" + [publisher] -domain = "publisher.com" -cookie_domain = ".publisher.com" -origin_url = "https://origin.publisher.com" -proxy_secret = "publisher_proxy_secret" +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" [ec] -passphrase = "ec_passphrase" [request_signing] enabled = true @@ -273,8 +183,8 @@ rerunning validation and pushing the resolved config, not rebuilding the binary. The pinned EdgeZero loader only overrides leaves that already exist in the parsed TOML; it does not create missing fields. Add newly introduced defaulted fields to an existing config before relying on their environment overrides. -Secret overlays still contain key names, never secret values. Pass `--no-env` -to use file values without the overlay. +Credential properties are absent from app config and cannot be supplied through +overlays. Pass `--no-env` to use ordinary file values without the overlay. ### Format @@ -315,7 +225,6 @@ Core publisher settings for domain, origin, and proxy configuration. | `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | | `origin_url` | String | Yes | Full URL of publisher origin server | | `origin_host_header_override` | String | No | Outbound Host header to send while connecting to `origin_url` | -| `proxy_secret` | String | Yes | Secret-store key name for the proxy URL secret | | `max_buffered_body_bytes` | Integer | No | Buffered-body cap / Fastly stream raw+decoded byte ceiling (default 16 MiB) | | `allow_async_html_finish` | Boolean | No | Explicit permission for async HTML EOF work and its delivery cost (default false) | | `html_finalization_timeout_ms` | Integer | No | Shared optional EOF budget after decoder/parser EOF (default 5000 ms; range 1–60000) | @@ -323,26 +232,24 @@ Core publisher settings for domain, origin, and proxy configuration. > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and > do not use `cookie_domain`. The `cookie_domain` field is used by other cookie helpers. -**Example** (replace the rejected secret placeholder before validation): +**Example**: ```toml [publisher] -domain = "publisher.com" -cookie_domain = ".publisher.com" -origin_url = "https://origin.publisher.com" +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" # Optional: connect to origin_url but send this outbound Host header. -# origin_host_header_override = "www.publisher.com" -proxy_secret = "publisher_proxy_secret" +# origin_host_header_override = "www.example.com" ``` **Environment Override**: ```bash -TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com -TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.publisher.com -TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -TRUSTED_SERVER__PUBLISHER__ORIGIN_HOST_HEADER_OVERRIDE=www.publisher.com -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret +TRUSTED_SERVER__PUBLISHER__DOMAIN=example.com +TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.example.com +TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.example.com +TRUSTED_SERVER__PUBLISHER__ORIGIN_HOST_HEADER_OVERRIDE=www.example.com TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 TRUSTED_SERVER__PUBLISHER__ALLOW_ASYNC_HTML_FINISH=false TRUSTED_SERVER__PUBLISHER__HTML_FINALIZATION_TIMEOUT_MS=5000 @@ -361,10 +268,10 @@ TRUSTED_SERVER__PUBLISHER__HTML_FINALIZATION_TIMEOUT_MS=5000 **Format**: Hostname without protocol or path -- ✅ `publisher.com` -- ✅ `www.publisher.com` -- ❌ `https://publisher.com` -- ❌ `publisher.com/path` +- ✅ `example.com` +- ✅ `www.example.com` +- ❌ `https://example.com` +- ❌ `example.com/path` #### `cookie_domain` @@ -377,10 +284,10 @@ TRUSTED_SERVER__PUBLISHER__HTML_FINALIZATION_TIMEOUT_MS=5000 **Format**: Domain with optional leading dot -- `.publisher.com` - Shares across all subdomains -- `publisher.com` - Exact domain only +- `.example.com` - Shares across all subdomains +- `example.com` - Exact domain only -**Best Practice**: Use leading dot (`.publisher.com`) for subdomain sharing. +**Best Practice**: Use leading dot (`.example.com`) for subdomain sharing. #### `origin_url` @@ -394,10 +301,10 @@ TRUSTED_SERVER__PUBLISHER__HTML_FINALIZATION_TIMEOUT_MS=5000 **Format**: Full URL with protocol -- ✅ `https://origin.publisher.com` -- ✅ `https://origin.publisher.com:8080` +- ✅ `https://origin.example.com` +- ✅ `https://origin.example.com:8080` - ✅ `http://192.168.1.1:9000` -- ❌ `origin.publisher.com` (missing protocol) +- ❌ `origin.example.com` (missing protocol) **Port Handling**: Includes port if non-standard (not 80/443). @@ -414,18 +321,16 @@ connecting to the host in `origin_url`. **Format**: Hostname with optional port, without protocol, path, query, or fragment -- ✅ `www.publisher.com` -- ✅ `www.publisher.com:8443` -- ❌ `https://www.publisher.com` -- ❌ `www.publisher.com/path` +- ✅ `www.example.com` +- ✅ `www.example.com:8443` +- ❌ `https://www.example.com` +- ❌ `www.example.com/path` **Default**: When omitted, Trusted Server sends the host from `origin_url`. -#### `proxy_secret` +#### Proxy signing credential -**Purpose**: Secret-store key name for the HMAC-SHA256 value used to sign proxy URLs. - -The referenced value is resolved from `trusted_server_secrets` at startup. +The implicit `publisher.proxy` binding supplies the HMAC-SHA256 value used to sign proxy URLs. It is resolved from `trusted_server_secrets` at startup. Generate it with a cryptographically secure random source; at least 32 random bytes are recommended. Keep that value confidential, rotate it only intentionally, and never put it in the TOML file or pushed app-config blob. @@ -438,7 +343,7 @@ intentionally, and never put it in the TOML file or pushed app-config blob. - Prevents URL tampering ::: danger Security Warning -Changing `proxy_secret` invalidates all existing signed URLs. Plan rotations carefully and use graceful transition periods. +Changing the proxy credential value invalidates all existing signed URLs. Plan rotations carefully and use graceful transition periods. ::: #### `allow_async_html_finish` @@ -548,13 +453,12 @@ it but keep using their own runtime client address. ### `[trusted_client_ip]` -| Field | Type | Required | Description | -| --------------- | ------ | -------- | ---------------------------------------------------------------- | -| `ip_header` | String | Yes | Header containing exactly one reader IP address | -| `auth_header` | String | Yes | Header containing exactly one shared-secret value | -| `shared_secret` | String | Yes | Key in `trusted_server_secrets` for the front-door shared secret | +| Field | Type | Required | Description | +| ------------- | ------ | -------- | ------------------------------------------------- | +| `ip_header` | String | Yes | Header containing exactly one reader IP address | +| `auth_header` | String | Yes | Header containing exactly one shared-secret value | -All three fields are required when the section exists. When the section is +Both header fields and the generated `clientip.shared` native binding are required when the section exists. When the section is absent, Trusted Server continues to use the immediate peer address, and `ts config push` omits the section from the published config blob so instances running an older binary keep accepting the blob. @@ -572,7 +476,6 @@ this order wrong takes the service down rather than degrading it. [trusted_client_ip] ip_header = "x-ts-client-ip" auth_header = "x-ts-client-ip-auth" -shared_secret = "trusted_client_ip_shared_secret" ``` Prefer a dedicated `x-` name for `ip_header`, as shown. `fastly-client-ip` is @@ -584,7 +487,7 @@ front-door configuration this section depends on. The front door must overwrite both headers on every request it forwards to Trusted Server, and must remove client-supplied copies on its other routes. -Trusted Server resolves `shared_secret` from `trusted_server_secrets` at startup. +Trusted Server resolves the implicit `clientip.shared` binding from `trusted_server_secrets` at startup. It accepts the forwarded address only when the request has exactly one `auth_header` value that matches the resolved secret byte-for-byte and exactly one `ip_header` value that parses directly as IPv4 or IPv6. Values are not @@ -606,8 +509,7 @@ Trusted Server removes the configured headers before routing. Generate the referenced secret value with a cryptographically secure random generator, encode it as hex or base64url, and store the same value only in the -front door and the physical store mapped from `trusted_server_secrets`. Put only -the key name in Trusted Server configuration. The resolved value must contain at +front door and the generated `clientip.shared` binding in the mapped native store. No credential property belongs in app config. The resolved value must contain at least 32 ASCII graphic bytes (`!` through `~`) with no whitespace, controls, DEL, or non-ASCII bytes. @@ -627,11 +529,10 @@ client-IP resolution. ```bash TRUSTED_SERVER__TRUSTED_CLIENT_IP__IP_HEADER=x-ts-client-ip TRUSTED_SERVER__TRUSTED_CLIENT_IP__AUTH_HEADER=x-ts-client-ip-auth -TRUSTED_SERVER__TRUSTED_CLIENT_IP__SHARED_SECRET=trusted_client_ip_shared_secret ``` Because the typed environment overlay cannot create a missing section, add -`[trusted_client_ip]` and all three fields to the TOML before using these +`[trusted_client_ip]` and both header fields to the TOML before using these overrides. ## Tester Cookie Configuration @@ -693,12 +594,10 @@ Legacy consent-store records are not read or migrated into `ec.ec_store`. Their ### `[ec]` -`passphrase` is a key name in `trusted_server_secrets`; the resolved value must -be at least 32 bytes. Keep it stable to preserve EC identifier continuity. +The implicit `ec.passphrase` binding supplies a value of at least 32 bytes. Keep it stable to preserve EC identifier continuity. | Field | Type | Required | Description | | ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- | -| `passphrase` | String | Yes | Publisher passphrase used as HMAC key | | `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | | `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | | `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | @@ -709,48 +608,34 @@ be at least 32 bytes. Keep it stable to preserve EC identifier continuity. `source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key. ::: -`api_token` is optional. Set it to a key in `trusted_server_secrets` only when -the partner calls the inbound identify or batch-sync APIs. A partner without -`api_token` remains available for source-domain lookup, bidstream EIDs, and -outbound pull sync, but cannot authenticate to those inbound APIs. +Set boolean `api_token_enabled = true` when the partner calls inbound identify or batch-sync APIs. It requires the generated `ec.inbound` binding. The default is false. A partner without inbound auth remains available for source-domain lookup, bidstream EIDs, and outbound pull sync, which requires its separate `ec.pull` binding. **Example**: ```toml [ec] -passphrase = "ec_passphrase" ec_store = "ec_identity_store" [[ec.partners]] name = "Mocktioneer SSP" -source_domain = "mocktioneer.example" +source_domain = "partner.example.com" bidstream_enabled = true -# api_token = "partner_api_token" # only for inbound identify or batch sync -# ts_pull_token = "partner_ts_pull_token" # required when pull sync is enabled +api_token_enabled = false +pull_sync_enabled = false +# pull_sync_url = "https://sync.example.com/pull" ``` **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` ### Field Details -#### `passphrase` - -**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation. - -**Security**: - -- The key name is stored in app config; the value is stored in `trusted_server_secrets` -- Keep the value stable unless intentionally rotating EC identifiers -- Do not place the value in environment overlays or the pushed blob +#### EC derivation credential -**Validation**: Application startup fails if: - -- Empty string +Provision `ec.passphrase` through a reviewed plan, outside config. Keep its value stable unless intentionally rotating EC identifiers. Missing, short, or placeholder values fail loading. Never put values in ordinary overlays or the pushed blob. ## Consent Configuration @@ -988,7 +873,6 @@ Path-based HTTP Basic Authentication. | ---------- | -------------- | -------- | --------------------------------- | | `path` | String (Regex) | Yes | Regular expression matching paths | | `username` | String | Yes | HTTP Basic Auth username | -| `password` | String | Yes | HTTP Basic Auth password | **Example**: @@ -997,33 +881,21 @@ Path-based HTTP Basic Authentication. [[handlers]] path = "^/_ts/admin" username = "admin" -password = "admin_password" # Multiple handlers [[handlers]] path = "^/secure" username = "user1" -password = "secure_handler_password" [[handlers]] path = "^/api/private" username = "api-user" -password = "api_handler_password" ``` -**Environment Override**: - -```bash -# Handler 0 -TRUSTED_SERVER__HANDLERS__0__PATH="^/_ts/admin" -TRUSTED_SERVER__HANDLERS__0__USERNAME="admin" -TRUSTED_SERVER__HANDLERS__0__PASSWORD="admin_password" - -# Handler 1 -TRUSTED_SERVER__HANDLERS__1__PATH="^/api/private" -TRUSTED_SERVER__HANDLERS__1__USERNAME="api-user" -TRUSTED_SERVER__HANDLERS__1__PASSWORD="api_handler_password" -``` +Each handler requires its generated `handler.password` binding. The exact regex +and username determine the context. Edit handler arrays in TOML; the scalar-only +environment overlay cannot edit them. Provision repeated roles using the +reviewed generated entry ID. See [credential provisioning](./secrets). ### Path Patterns @@ -1101,8 +973,8 @@ scheduled for removal **Password Storage**: -- `handlers[*].password` is a key name in `trusted_server_secrets` -- Store the resolved password only in the platform secret store +- Each handler implies a generated `handler.password` binding +- Store the raw password only at its reviewed native identity - Rotate passwords through the store and restart/redeploy instances **Limitations**: @@ -1114,8 +986,8 @@ scheduled for removal ::: warning Production Use Do not put handler passwords in `trusted-server.toml`, environment overlays, or -app-config blobs. Provision the referenced key in `trusted_server_secrets` -before pushing the config. +app-config blobs. Provision the generated binding in `trusted_server_secrets` +before pushing the matching config. ::: ## URL Rewrite Configuration @@ -1314,34 +1186,30 @@ target_path = "/image/upload/$1.$2" ### `[proxy.asset_routes.auth]` -The first supported origin auth type is `s3_sigv4`. +The first supported origin auth type is `s3_sigv4`. Boolean `session_token_enabled` defaults to false; true also requires the generated session-token binding. -| Field | Type | Required | Default | Description | -| ------------------- | ------ | -------- | ------------------- | ------------------------------------------------------------ | -| `type` | String | Yes | none | Must be `s3_sigv4` | -| `region` | String | Yes | none | AWS region used in the SigV4 credential scope | -| `access_key_id` | String | No | `access_key_id` | Default-store secret reference for the AWS access key ID | -| `secret_access_key` | String | No | `secret_access_key` | Default-store secret reference for the AWS secret access key | -| `session_token` | String | No | unset | Optional secret key containing a session token | -| `origin_query` | String | No | route default | `preserve` or `strip` | +| Field | Type | Required | Default | Description | +| ----------------------- | ------- | -------- | ------------- | --------------------------------------------- | +| `type` | String | Yes | none | Must be `s3_sigv4` | +| `region` | String | Yes | none | AWS region used in the SigV4 credential scope | +| `session_token_enabled` | Boolean | No | `false` | Require the generated session-token binding | +| `origin_query` | String | No | route default | `preserve` or `strip` | **Example**: ```toml [[proxy.asset_routes]] prefix = "/.image/" -origin_url = "https://bucket.s3.us-east-1.amazonaws.com" +origin_url = "https://assets.example.com" [proxy.asset_routes.auth] type = "s3_sigv4" region = "us-east-1" origin_query = "strip" -access_key_id = "s3_access_key_id" -secret_access_key = "s3_secret_access_key" -# session_token = "s3_session_token" +session_token_enabled = false ``` -S3 auth uses header-based AWS SigV4 with `UNSIGNED-PAYLOAD`. It is scoped to read-only asset requests and expects `origin_url` to use the S3 host that AWS validates. Credential references resolve from `trusted_server_secrets` at startup, and request signing performs no secret-store reads. +S3 auth uses header-based AWS SigV4 with `UNSIGNED-PAYLOAD`. It is scoped to read-only asset requests and expects `origin_url` to use the S3 host that AWS validates. Implicit `s3.access-id` and `s3.secret` bindings are required; `session_token_enabled = true` also requires `s3.session`. All bind the effective route policy. Request signing performs no secret-store reads. See [S3 provisioning](./asset-routes#secret-store-values). Effective `origin_query` precedence is auth-level `origin_query`, then enabled Image Optimizer `origin_query`, then the route default. @@ -1442,21 +1310,26 @@ emitter is active only when `enabled = true`; access-log emission is not wired. ### `[tinybird]` -| Field | Type | Default | Contract | -| ---------------------- | -------------- | ---------------------- | ------------------------------------------------------------------ | -| `enabled` | Boolean | `false` | Enable auction telemetry | -| `api_host` | String | `""` | Required when enabled; regional host without scheme, port, or path | -| `auction_dataset` | String | `"auction_events_raw"` | 1–128 ASCII letters, digits, or `_` | -| `auction_token_secret` | String or null | `null` | Store key required when enabled; resolved value must be nonempty | -| `access_enabled` | Boolean | `false` | Reserved; `true` fails startup because no emitter is wired | -| `access_dataset` | String | `"access_logs_raw"` | Reserved access-log dataset name | -| `access_sample_rate` | Number | `0.0` | `0.0..=1.0`; reserved while access emission is disabled | -| `max_body_bytes` | Integer | `1048576` | At least `1024` bytes | +| Field | Type | Default | Contract | +| -------------------- | ------- | ---------------------- | ------------------------------------------------------------------------ | +| `enabled` | Boolean | `false` | Enable auction telemetry | +| `api_host` | String | `""` | Required when enabled; DNS host with optional `:port`, no scheme or path | +| `auction_dataset` | String | `"auction_events_raw"` | 1–128 ASCII letters, digits, or `_` | +| `access_enabled` | Boolean | `false` | Reserved; `true` fails startup because no emitter is wired | +| `access_dataset` | String | `"access_logs_raw"` | Reserved access-log dataset name | +| `access_sample_rate` | Number | `0.0` | `0.0..=1.0`; reserved while access emission is disabled | +| `max_body_bytes` | Integer | `1048576` | At least `1024` bytes | `secret_store` and `access_token_secret` are deprecated compatibility inputs. Both are removed during normalization; neither reaches runtime or serialized -output. New configurations use only `auction_token_secret`, whose value is -resolved through `trusted_server_secrets`. +output. Enabled telemetry requires the implicit `tinybird.auction` binding for its endpoint and exact dataset. + +`api_host` accepts a DNS host with an optional HTTPS port, such as +`api.tinybird.example.com:8443`. Do not include a scheme, path, userinfo, query, +or fragment. The shared endpoint parser builds `https://{api_host}/v0/events`, +normalizes explicit port `443` away, and preserves other valid ports. It rejects +forbidden original syntax before normalization. A nondefault port is part of +the credential's approved destination and changes its binding. The complete enabled example appears in [Tinybird auction telemetry](#tinybird-auction-telemetry). @@ -1656,7 +1529,6 @@ and exclusion-rule syntax. This table covers every canonical top-level field: | `cache_ttl_seconds` | Integer | `3600` | `60..=86400` seconds | | `rewrite_sdk` | Boolean | `true` | Rewrite matching SDK URLs | | `enable_protection` | Boolean | `false` | Call the Protection API before route matching | -| `server_side_key_secret_name` | String or null | `null` | Secret-store key required when protection is enabled | | `protection_api_origin` | URL | `https://api-fastly.datadome.co` | Protection API origin | | `timeout_ms` | Integer | `1500` | `1..=10000` milliseconds | | `protection_excluded_methods` | Array[String] | `["OPTIONS"]` | Methods that bypass protection | @@ -1680,9 +1552,7 @@ matcher: `path_exact`, `path_prefix`, `path_regex`, `query_param_non_empty`, `asn`, `ip_cidr`, or `ip_cidr_source`. When `protection_test_bypass` is present, its `enabled` field defaults to -`false`; `credential_secret_name` identifies the secret-store key and becomes -required only when the bypass is enabled. The resolved credential must contain -at least 32 bytes. +`false`. Enabled Protection requires `datadome.server`; an enabled bypass also requires the separate `datadome.bypass` binding with at least 32 bytes. Neither section accepts a credential-name property. The deprecated `server_side_key_secret_store` and nested `credential_secret_store` inputs are accepted and discarded. Do not add them @@ -3004,7 +2874,7 @@ Configuration is validated at startup: **EC Validation**: -- The `passphrase` key name is non-empty at push time +- The implicit `ec.passphrase` role is included in the generated plan - The resolved passphrase is at least 32 bytes at runtime - Known placeholder values are rejected after resolution @@ -3046,13 +2916,12 @@ Configuration error: provider `pbs-main` endpoint must be an absolute HTTPS URL [publisher] domain = "localhost" origin_url = "http://localhost:3000" -proxy_secret = "publisher_proxy_secret" ``` **Staging and production**: -- Provision the same key names in the target `trusted_server_secrets` store. -- Keep only the key names in `trusted-server.toml` and environment overlays. +- Review a generated plan and provision its native identities in the selected deployment. +- Keep credential properties absent from `trusted-server.toml` and ordinary overlays. - Push the config after provisioning and restart/redeploy after rotation. ### Secret Management @@ -3063,7 +2932,7 @@ proxy_secret = "publisher_proxy_secret" - ✅ Rotate values deliberately and restart/redeploy instances - ✅ Generate values locally without printing them to logs - ✅ Use different values per environment when appropriate -- ✅ Keep stable key names for rotation +- ✅ Replace the same generated binding for unchanged-context rotation **Don't**: @@ -3110,9 +2979,9 @@ trusted-server.dev.toml # Development overrides **"Configuration field '...' is set to a known placeholder value"**: -- Confirm the referenced key exists in `trusted_server_secrets` +- Confirm the reviewed generated binding exists in the mapped native store - Ensure the resolved value is non-empty and not a known placeholder -- Do not replace the key name with a plaintext value in the app config +- Keep credential values and key-name properties absent from app config - Rotate the value in the platform secret store, then restart/redeploy **"Invalid regex"**: diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 46deed9dc6..58c269a4a9 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -987,7 +987,7 @@ Content-Security-Policy: ✅ **Do**: -- Use strong `proxy_secret` (32+ bytes random) +- Use a strong generated `publisher.proxy` binding value, at least 32 random bytes - Exclude trusted first-party domains - Set appropriate cache headers - Test rewrites before production diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index cccb04daee..cdf98fcc5f 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -19,32 +19,30 @@ This guide covers: ## 1) Required Configuration -Generate the passphrase and partner token independently with -`openssl rand -base64 32`, then set EC configuration in `trusted-server.toml`. -The `replace-with-*` values below are intentionally rejected placeholders: +Set ordinary EC configuration in `trusted-server.toml`, then provision the +implicit EC passphrase and partner inbound bindings from a reviewed plan. +Keep the raw values out of config and overlays: ```toml [ec] -passphrase = "ec_passphrase" ec_store = "ec_identity_store" [[ec.partners]] name = "Mocktioneer SSP" -source_domain = "formally-vital-lion.edgecompute.app" -api_token = "partner_api_token" +source_domain = "ssp.example.com" +api_token_enabled = true bidstream_enabled = true ``` -The `passphrase` and `api_token` fields contain keys in the Trusted Server -secret store, not the credential values. This workflow calls the inbound -identify and batch-sync APIs, so its partner needs `api_token`. Partners that -do not call either API may omit it. Provision high-entropy values under -`ec_passphrase` and `partner_api_token`; see -[Configuration](/guide/configuration#secret-store-migration). +This workflow calls inbound identify and batch-sync APIs, so set +`api_token_enabled = true` and provision `ec.inbound` for the reviewed +partner domain. It defaults to false. Provision `ec.passphrase` separately. +See [Provision app credentials](./secrets). No credential-name properties belong +in TOML; outbound pull uses a separate `ec.pull` binding and exact endpoint. Required behavior assumptions: -- The value stored under `ec_passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters +- The value provisioned for `ec.passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters - `ec_store` is linked to the active Fastly service version - `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones - Live consent is interpreted from request cookies, headers, geolocation, and policy defaults rather than a separate consent KV store @@ -60,7 +58,7 @@ MOCK_SSP_URL="https://ssp.example.com" PARTNER_SOURCE_DOMAIN="ssp.example.com" PARTNER_NAME="Mocktioneer SSP" -# Use the value provisioned under the partner_api_token secret-store key. +# Use the value provisioned for the reviewed ec.inbound binding. PARTNER_API_KEY="" # Optional: use a real browser EC if already present @@ -77,13 +75,13 @@ Partners are configured in `trusted-server.toml` and loaded at startup: ```toml [[ec.partners]] name = "Mocktioneer SSP" -source_domain = "formally-vital-lion.edgecompute.app" -api_token = "partner_api_token" +source_domain = "ssp.example.com" +api_token_enabled = true bidstream_enabled = true ``` -Provision the bearer token value under `partner_api_token`, then deploy or -restart after changing partner configuration. The token is required for this +Provision the reviewed `ec.inbound` binding, then recreate state after changing +partner configuration or replacing a credential. The token is required for this demo because it exercises the inbound partner APIs. ## 4) Acquire or Reuse EC Cookie diff --git a/docs/guide/edgezero.md b/docs/guide/edgezero.md index 2107f95f56..91ac4dd87c 100644 --- a/docs/guide/edgezero.md +++ b/docs/guide/edgezero.md @@ -46,9 +46,11 @@ logical store is wired for runtime reads on every target. ## Blob and chunk flow `config push` serializes validated settings into one EdgeZero -`BlobEnvelope`. Store-backed settings contain secret key names, not secret -values; the adapter resolves those keys from the logical secret store during -startup. +`BlobEnvelope`. App config contains ordinary feature, identity, and destination +settings, not credential values or references. Core compiles active purposes +and derives the only allowed native lookups in `trusted_server_secrets` during +startup. Use a [reviewed binding plan](./secrets) for provisioning; config +publication does not authorize new targets or write secrets. When a Fastly envelope exceeds one Config Store entry, the writer stores bounded chunks and replaces the root value with a versioned @@ -63,11 +65,12 @@ store before deletion, especially when using `--store` or `--no-env`. ## Safe operator sequence -1. Put required secret values in the physical store mapped from - `trusted_server_secrets`. +1. Review ordinary config and a generated credential plan, then provision its + native identities in the physical store mapped from `trusted_server_secrets`. + Use [secure input](./secrets); no credential properties belong in app config. 2. Run `ts config validate`. -3. Review `ts config diff --adapter ADAPTER`; use `--no-diff` when - deliberately inline configuration must not appear in logs. +3. Review `ts config diff --adapter ADAPTER`; use `--no-diff` to suppress the + push preview where ordinary deployment metadata must not appear in logs. 4. Run `ts config push --adapter ADAPTER`. 5. Start or deploy the adapter and verify a non-health publisher route. A healthy endpoint alone may not prove that application configuration loaded. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index c2ddcebbd9..1135182f5d 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -55,27 +55,26 @@ Missing required field: publisher.domain **Cause:** Required configuration field not provided -**Solution:** Add the missing field to `trusted-server.toml`. The secret below -is an intentionally rejected placeholder; replace it with `openssl rand -base64 32` -before validation. +**Solution:** Add the missing ordinary field to `trusted-server.toml`. Provision +required app credentials separately through a reviewed plan. ```toml [publisher] -domain = "your-publisher-domain.com" -origin_url = "https://origin.your-publisher-domain.com" -proxy_secret = "publisher_proxy_secret" +domain = "example.com" +origin_url = "https://origin.example.com" ``` -`proxy_secret` names an entry in the Trusted Server secret store. Provision a -high-entropy value under `publisher_proxy_secret`; do not put that value in the -TOML file. +Provision `publisher.proxy` through [the credential workflow](./secrets). +Do not put a value, reference label, or generated key in app config. **Required Fields:** - `publisher.domain` - `publisher.origin_url` -- `publisher.proxy_secret` -- `ec.passphrase` +- An admin-covering handler path and username + +The implicit `publisher.proxy`, `ec.passphrase`, and each `handler.password` +binding must also be provisioned. They are not config properties. --- @@ -149,22 +148,11 @@ Failed to generate EC ID: HMAC error **Solution:** -1. Ensure `passphrase` names a secret-store entry in `trusted-server.toml`: - -```toml -[ec] -passphrase = "ec_passphrase" -``` - -2. If using a typed CLI environment override, set the key name rather than the - passphrase value: - -```bash -TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase -``` - -3. Provision a high-entropy value of at least 32 characters under - `ec_passphrase` in the Trusted Server secret store. +1. Generate and review the current binding plan. +2. Provision the implicit `ec.passphrase` role in the mapped native store. +3. Use a high-entropy value of at least 32 bytes and recreate state. +4. Do not add a passphrase property or environment overlay. Missing enabled + bindings fail closed. See [provisioning troubleshooting](./secrets#checks-rotation-and-troubleshooting). --- @@ -197,11 +185,11 @@ manually named static Fastly backends. Invalid tstoken signature ``` -**Cause:** URL was modified after signing, or proxy_secret mismatch +**Cause:** URL was modified after signing, or a proxy credential mismatch **Solution:** -1. Verify `publisher.proxy_secret` matches across environments +1. Verify the intended `publisher.proxy` credential is loaded in the selected environment 2. Don't manually modify signed URLs 3. Use `/first-party/sign` endpoint to generate new signatures 4. Check for URL encoding issues diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 6cf1e5e38c..9e52fe393a 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -64,6 +64,33 @@ fastly profile create Follow the interactive prompts to paste your API token. +## App credential plans and writes + +Use [Provision app credentials](./secrets) and [CI provisioning](./secrets-ci). +`ts secrets plan`, `check`, and `set` use the existing EdgeZero manifest and +Fastly adapter manifest. `--manifest` defaults to `EDGEZERO_MANIFEST`, otherwise +`edgezero.toml`. `fastly.toml` supplies `service_id`; `FASTLY_SERVICE_ID` +must agree unless using supported explicit `--service-id` selection. Review +the resolved service and exact resource IDs in the plan. + +Managed direct API calls require `FASTLY_API_TOKEN` in the process environment. +A Fastly CLI profile alone does not supply it. Restrict its access to the reviewed +resources and verify that config publishers cannot write secrets or change links. + +Physical selectors are +`EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME`, +`EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME`, and +`EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME`. They select names, not IDs; +unset selectors use logical-name defaults. `ts provision --adapter fastly` +creates/reuses resources; managed application-release deployment links them +under logical IDs. Neither planning nor secret writes creates links, pushes config, +deploys, activates a version, or refreshes retained state. + +`--staging` alone does not isolate stores. Give each environment distinct +reviewed physical mappings. Do not add another deployment TOML or create logical +links by hand. Recreate state after provisioning or rotation, including reusable +sandboxes. Presence checks do not prove credential bytes or delivery. + ## Domain Configuration ::: tip @@ -108,7 +135,6 @@ Prefer header names that nothing else in the fronting service uses: [trusted_client_ip] ip_header = "x-ts-client-ip" auth_header = "x-ts-client-ip-auth" -shared_secret = "trusted_client_ip_shared_secret" ``` `ip_header` may also be @@ -151,10 +177,10 @@ Attach an edge dictionary named `ts_private_config` to the fronting service and store the secret under the key `trusted_client_ip_secret`. Create the dictionary as write-only so the value cannot be read back through the API or the web interface. Store the identical value in the physical store mapped from -`trusted_server_secrets`, under the key named by -`trusted_client_ip.shared_secret` (`trusted_client_ip_shared_secret` above). If -either key is absent, authentication cannot succeed and Trusted Server falls -back to the peer address. +`trusted_server_secrets`, under the generated `clientip.shared` binding from the reviewed plan. Use +`ts secrets set clientip.shared` with the same plan/config flags as the +[provisioning tutorial](./secrets). A missing runtime binding fails loading; +a missing or invalid request authentication header falls back to the peer address. Four details in that example carry weight: @@ -180,10 +206,10 @@ Four details in that example carry weight: different backend. On the ordinary path the host is unchanged and re-running writes the same values. -Configure the identical header names and the secret-store key name in Trusted -Server. Use a cryptographically random value of at least 32 ASCII graphic bytes, -encoded as hex or base64url with no whitespace, for the two store entries. The -app-config blob contains only `trusted_client_ip_shared_secret`, not that value. +Configure identical header names in Trusted Server and provision its implicit +`clientip.shared` binding. Use a cryptographically random value of at least 32 +ASCII graphic bytes, encoded as hex or base64url without whitespace, for both +store entries. No credential value or key-name property belongs in app config. ### What Trusted Server does with the result @@ -394,25 +420,17 @@ Create it: fastly kv-store create --name ec_identity_store ``` -Configure the secret-store key name in `trusted-server.toml`: +Configure ordinary EC settings in `trusted-server.toml`: ```toml [ec] -passphrase = "ec_passphrase" ec_store = "ec_identity_store" ``` -Store the high-entropy passphrase under that key in the physical store -selected for `trusted_server_secrets`. The resolved -value, rather than the key name, must contain at least 32 characters. Pipe the -value over standard input so it never appears in the process argument list: - -```bash -openssl rand -hex 32 | tr -d '\n' | fastly secret-store-entry create \ - --store-id= \ - --name=ec_passphrase \ - --stdin -``` +Provision the implicit `ec.passphrase` binding through a reviewed plan with +`ts secrets set ec.passphrase`. Its raw value must contain at least 32 bytes. +Do not add a passphrase property or hand-written native name. Follow +[Provision app credentials](./secrets) for secure input and required app roles. Verify stores exist: @@ -443,8 +461,10 @@ overrides, and runs strict validation. It then executes `ts config push --adapter fastly --local`, adds all three required entries to `[local_server.secret_stores.trusted_server_secrets]`, and starts `fastly compute serve` -through Viceroy. The required keys are `handler_password`, -`publisher_proxy_secret`, and `ec_passphrase`. +through Viceroy. Required app roles are `handler.password`, `publisher.proxy`, +and `ec.passphrase`; native keys must come from the compiler/renderer rather +than old labels. The smoke fixtures must match this schema before the result +can count as secret-binding evidence. The check deliberately proves both halves of startup. With no config entry, it requires `/health` to return 200 while the publisher route returns 500 with the @@ -459,7 +479,7 @@ project before pushing local configuration or adding synthetic secrets. The tracked manifests remain untouched, including when smoke runs overlap. Its trap stops Viceroy and the stub origin and removes the temporary project. For a deployed service, provision and link the stores described above and write the -three secrets through Fastly's secret-store interface. +required bindings through the [reviewed Fastly writer](./secrets). ## Next Steps diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 69d4ed7c6a..4bdee93d9c 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -264,7 +264,7 @@ This endpoint is designed for advanced scenarios like A/B testing where you need ## URL Signing & Validation -Trusted Server signs proxy and click URLs using the publisher `proxy_secret`. Signed URLs include a `tstoken` and may include `tsexp` for expiration. +Trusted Server signs proxy and click URLs using the implicit `publisher.proxy` credential. Signed URLs include a `tstoken` and may include `tsexp` for expiration. For the detailed signing algorithm, validation steps, and security notes, see [Proxy Signing](/guide/proxy-signing). @@ -440,14 +440,14 @@ Configure proxy behavior in `trusted-server.toml`: ```toml [publisher] -domain = "publisher.com" -cookie_domain = ".publisher.com" -origin_url = "https://origin.publisher.com" -proxy_secret = "publisher_proxy_secret" +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" ``` -`proxy_secret` is the key name in the Trusted Server secret store. Provision a -high-entropy value of at least 32 characters under `publisher_proxy_secret`. +Provision the implicit `publisher.proxy` binding through a [reviewed plan](./secrets). +No credential-name property belongs in TOML. Use a high-entropy value of at +least 32 bytes, keep it confidential, and recreate state after replacement. ### Asset Routes @@ -608,7 +608,7 @@ tstoken validation failed: signature mismatch **Solutions**: -- Verify `proxy_secret` matches signing configuration +- Verify the loaded `publisher.proxy` binding belongs to the intended deployment - Check URL reconstruction includes all parameters in correct order - Ensure no URL encoding issues @@ -658,13 +658,13 @@ Response: Returns last redirect response (302/307/308) without following. ### Token Security **Do**: -✅ Use cryptographically strong `proxy_secret` (32+ bytes random) +✅ Use a cryptographically strong proxy credential, at least 32 random bytes ✅ Rotate secrets periodically ✅ Validate expiration on all requests ✅ Use constant-time comparison for signatures **Don't**: -❌ Expose `proxy_secret` in client-side code +❌ Expose the proxy credential in client-side code ❌ Reuse secrets across environments ❌ Accept unsigned URLs ❌ Skip validation for "trusted" domains diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 8ff0b435cc..d37e702e51 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -63,8 +63,24 @@ ts config push --adapter fastly --local --yes --no-diff fastly compute serve ``` -The local manifest provides public development-only values for the starter -config's three secret references. Do not reuse them outside local development. +The local manifest must provide fictional values at the compiler-generated keys +for the starter config's `handler.password`, `publisher.proxy`, and +`ec.passphrase` contexts. Legacy key labels no longer work. Use +`./scripts/smoke-fastly.sh` for the isolated fixture path, and verify the checked-in +fixtures match your exact handler regex/username before serving. Do not reuse +local values outside development. Fastly `secrets plan` resolves provider +resources; it is not an offline Viceroy provisioning command. For local fictional +fixtures only, an offline Axum plan uses the same core keys: + +```bash +ts secrets plan --adapter axum --app-config trusted-server.toml --no-env --out bindings-local.json +jq -r '.entries[].compiled.key' bindings-local.json +``` + +Use these keys for fictional values in the isolated Viceroy +`[local_server.secret_stores.trusted_server_secrets]` fixture. An Axum plan does +not authorize Fastly `set`, resolve Fastly resources, or replace a reviewed +Fastly plan. Do not copy real credentials into local fixtures. The server will be available at `http://localhost:7676`. ### Option B — Axum dev server @@ -90,11 +106,16 @@ export TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG="$( jq -r '.trusted_server_config' .edgezero/local-config-trusted_server_config.json )" -# Populate the three secret references from the starter config for this shell. -# Use stable values only if you need existing proxy URLs or EC IDs to remain valid. -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET="$(openssl rand -base64 32)" -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE="$(openssl rand -base64 32)" -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD="$(openssl rand -base64 32)" +# Generate exact names for this config. Review every purpose/context first. +ts secrets plan --adapter axum --app-config trusted-server.toml --no-env --out bindings-axum.json +set +x +while IFS= read -r name; do + # Fictional local values only. Use protected input for existing credentials. + value="$(openssl rand -base64 32)" + export "$name=$value" + unset value +done < <(jq -r '.entries[].native.native_name' bindings-axum.json | sort -u) +ts secrets check --adapter axum --app-config trusted-server.toml --no-env --plan bindings-axum.json # Build and start the dev server in the same shell. cargo run -p trusted-server-adapter-axum @@ -108,7 +129,7 @@ The server will be available at `http://localhost:8787`. Set `PORT=` befor | Purpose | Pattern | Example | | ------------------ | ------------------------------------- | --------------------------------------------------------------------- | | Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=…` | -| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PROXY_KEY=…` | +| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | Generated `native_name` from the Axum plan | The repeated `TRUSTED_SERVER_CONFIG` segments in the example are intentional: `TRUSTED_SERVER_CONFIG_` is the adapter prefix, followed by the resolved store @@ -123,8 +144,8 @@ key. The local JSON filename still uses the logical store ID, even with a physical-name override. The config-store value is the verified app-config blob. Secret-store values are -looked up by the key names in that blob. Store names and key names are uppercased -with hyphens, dots, and spaces replaced by underscores. The quick-start exports +looked up only at context-derived generated identities, never names from the +blob. The plan renderer supplies the actual native environment names. The quick-start exports ephemeral secret-store values only into the current shell; do not put secret values in the TOML config, config-store blob, or a source-controlled environment file. @@ -169,7 +190,7 @@ ts audit generate https://publisher.example The audit command writes `js-assets.toml` plus a draft `trusted-server.toml`. The draft includes disabled JS Asset Proxy candidates for detected third-party -scripts. Review it, replace placeholders with stable secret key names, and enable +scripts. Review it, keep credential properties absent, provision generated bindings, and enable only the asset proxy entries you want to serve or block. Then validate it. Edit `trusted-server.toml` to configure: @@ -180,7 +201,7 @@ Edit `trusted-server.toml` to configure: - KV store mappings; - EC configuration; - consent settings (`[gdpr]`); and -- stable key names for `trusted_server_secrets`. +- a reviewed generated binding plan for the selected deployment. Do not put a Prebid Server URL or server bidder list under `[integrations.prebid]`, and do not put APS account/endpoint/timeout fields under @@ -188,7 +209,9 @@ Do not put a Prebid Server URL or server bidder list under fields and `profile_config`. Before the first push, provision the physical store mapped from logical -`trusted_server_secrets` with the credential values referenced by the config. +`trusted_server_secrets` with raw values at the generated identities from a +reviewed plan. Follow [Provision app credentials](./secrets); TS does not create +recipient-side tokens. On Fastly, `ts_secrets` is the documented example physical name. Then validate and push: @@ -202,7 +225,8 @@ mandatory target-aware fan-out and backend-name validation at startup. The EdgeZero callback needed for target-aware pre-write push validation is not yet available in this tree, so startup remains the final target gate. -Restart or redeploy instances after secret rotation. See +Recreate state after secret rotation. New keys do not revoke retired contexts; +follow [migration and retirement](./secret-binding-migration). See [Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. ## Deploy to Fastly diff --git a/docs/guide/integrations/datadome.md b/docs/guide/integrations/datadome.md index f289a77b98..065d8efc86 100644 --- a/docs/guide/integrations/datadome.md +++ b/docs/guide/integrations/datadome.md @@ -43,8 +43,7 @@ rewrite_sdk = true # Server-side Protection API layer enable_protection = false -# Required only when enable_protection = true. -server_side_key_secret_name = "datadome_server_side_key" +# Enabled Protection requires the generated datadome.server binding. protection_api_origin = "https://api-fastly.datadome.co" timeout_ms = 1500 protection_excluded_methods = ["OPTIONS"] @@ -76,7 +75,6 @@ patterns = ["(?i)\\.(avi|flv|mka|mkv|mov|mp4|mpeg|mpg|mp3|flac|ogg|ogm|opus|wav| | `cache_ttl_seconds` | integer | `3600` | Cache TTL for `tags.js` | | `rewrite_sdk` | boolean | `true` | Rewrite DataDome script URLs in HTML to first-party paths | | `enable_protection` | boolean | `false` | Call the Protection API before route matching | -| `server_side_key_secret_name` | string | none | Default-store secret reference required when protection is enabled | | `protection_api_origin` | string | `https://api-fastly.datadome.co` | Protection API origin | | `timeout_ms` | integer | `1500` | Dynamic backend first-byte timeout for Protection API calls | | `protection_excluded_methods` | array | `["OPTIONS"]` | HTTP methods skipped before the Protection API call | @@ -155,7 +153,7 @@ When `enable_protection = true`, Trusted Server calls DataDome before normal rou - **Challenge**: return the DataDome response directly without contacting the publisher origin. - **Fail-open condition**: continue routing without DataDome effects when the Protection API times out, returns malformed instructions, or returns an unexpected status. -`server_side_key_secret_name` is a key reference in the logical `trusted_server_secrets` store. It must resolve to a non-empty value when server-side protection is enabled. Missing or invalid credentials fail startup before requests are served. Protection API transport and response failures continue to fail open per request. +Enabled Protection requires the implicit `datadome.server` binding in the logical `trusted_server_secrets` store. Its generated key includes the exact HTTPS `/validate-request` endpoint. Provision from a reviewed plan through [secure input](../secrets), not a credential-name property. A changed endpoint needs a new binding. Missing or invalid credentials fail startup before requests are served. Credential-bearing requests require verified TLS and do not follow redirects; remote transport/response failures preserve per-request fail-open behavior. ### Protected traffic @@ -184,7 +182,6 @@ Protection API: # Runtime activation also requires FASTLY_IS_STAGING=1. [integrations.datadome.protection_test_bypass] enabled = true -credential_secret_name = "datadome_test_bypass" ``` `protection_test_bypass` requires `enable_protection = true`; it is disabled @@ -195,7 +192,7 @@ staging through the `X-TS-ENV: staging` response signal and the integration activation log, and verify production omits that response signal. A retained section cannot bypass protection in a production or other non-staging runtime. Store a randomly generated credential containing at least 32 bytes of -high-entropy material under the referenced key in `trusted_server_secrets`, configure this section +high-entropy material at the generated, separate `datadome.bypass` binding in `trusted_server_secrets`. Configure this section only while needed, protect the site with an outer access control such as Basic Auth, and remove the section when testing finishes. @@ -373,7 +370,6 @@ TRUSTED_SERVER__INTEGRATIONS__DATADOME__API_ORIGIN=https://api-js.datadome.co TRUSTED_SERVER__INTEGRATIONS__DATADOME__CACHE_TTL_SECONDS=3600 TRUSTED_SERVER__INTEGRATIONS__DATADOME__REWRITE_SDK=true TRUSTED_SERVER__INTEGRATIONS__DATADOME__ENABLE_PROTECTION=true -TRUSTED_SERVER__INTEGRATIONS__DATADOME__SERVER_SIDE_KEY_SECRET_NAME=datadome_server_side_key TRUSTED_SERVER__INTEGRATIONS__DATADOME__CLIENT_SIDE_KEY=your-client-side-key ``` @@ -418,7 +414,6 @@ Check that both fields are configured: [integrations.datadome] enabled = true enable_protection = true -server_side_key_secret_name = "datadome_server_side_key" ``` Also verify the request is not excluded by the default internal/static route exclusions or your custom inclusion/exclusion regexes. diff --git a/docs/guide/proxy-signing.md b/docs/guide/proxy-signing.md index 701a7621ba..65ed178850 100644 --- a/docs/guide/proxy-signing.md +++ b/docs/guide/proxy-signing.md @@ -5,7 +5,7 @@ proxy and click URLs. For usage and endpoints, see [First-Party Proxy](/guide/fi ## Signature Generation -Signatures use HMAC-SHA256 with the publisher's `proxy_secret`: +Signatures use HMAC-SHA256 with the publisher's implicit `publisher.proxy` credential: ``` 1. Reconstruct full URL: tsurl + query params (sorted) @@ -15,16 +15,13 @@ Signatures use HMAC-SHA256 with the publisher's `proxy_secret`: 5. Result = tstoken ``` -**Configuration**: +**Provisioning**: -```toml -[publisher] -proxy_secret = "publisher_proxy_secret" -``` - -The config value is a key in the Trusted Server secret store. Provision a -secure random signing value under `publisher_proxy_secret`; at least 32 random -bytes are recommended. +Provision the fixed `publisher.proxy` role from a [reviewed plan](./secrets). +No `proxy_secret` property belongs in public TOML. Use a confidential, +cryptographically random value, with at least 32 random bytes recommended. +Replacing it invalidates existing proxy URLs and requires state recreation. +This is separate from request-signing `signing_keys`/JWKS rotation. ## Signature Validation diff --git a/docs/guide/secret-binding-migration.md b/docs/guide/secret-binding-migration.md new file mode 100644 index 0000000000..2b907ffa79 --- /dev/null +++ b/docs/guide/secret-binding-migration.md @@ -0,0 +1,122 @@ +# Migrate app secret bindings + +This is a breaking config migration. Credentials remain raw native values, but app config no longer chooses secret names. TS derives each binding from a fixed purpose and its effective identity and destination. The new binary never retries legacy names or names from the earlier label-bearing design draft. + +::: danger Unsafe downgrade +Restoring the old binary restores the config-only secret-substitution and mutable-destination attack. Prefer rolling forward or disabling affected exporters. Rollback requires the old binary's compatible config snapshot and retained legacy bindings, not just an old package. +::: + +Native reads, TLS/no-follow tests, secure Fastly replacement, provider permissions, four-adapter walkthroughs, and migration/rollback rehearsals remain release gates. These instructions are not a claim that those environments have been verified. + +## Removed properties + +Remove these properties everywhere, including disabled features, JSON blobs, commented examples you intend to enable, and CLI overlay scripts. Presence is a migration error even if the property contains an inline value, old label, or generated key. Internal resolved Rust fixture fields are not public TOML syntax. + +| Removed config property | Fixed CLI selector | Replacement activation | +| --------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------- | +| `publisher.proxy_secret` | `publisher.proxy` | Always required | +| `ec.passphrase` | `ec.passphrase` | Always required | +| `ec.partners[*].api_token` | `ec.inbound` | Set boolean `api_token_enabled = true` if inbound auth was used | +| `ec.partners[*].ts_pull_token` | `ec.pull` | Existing `pull_sync_enabled` | +| `handlers[*].password` | `handler.password` | Each configured handler | +| `trusted_client_ip.shared_secret` | `clientip.shared` | Section present | +| `tinybird.auction_token_secret` | `tinybird.auction` | Tinybird enabled | +| `integrations.datadome.server_side_key_secret_name` | `datadome.server` | Integration and Protection enabled | +| `integrations.datadome.protection_test_bypass.credential_secret_name` | `datadome.bypass` | Integration, Protection, and bypass enabled | +| `proxy.asset_routes[*].auth.access_key_id` | `s3.access-id` | S3 auth | +| `proxy.asset_routes[*].auth.secret_access_key` | `s3.secret` | S3 auth | +| `proxy.asset_routes[*].auth.session_token` | `s3.session` | Set boolean `session_token_enabled = true` for temporary credentials | + +Both new flags default to `false` and accept booleans only, not strings. Do not forget inbound authentication when removing `api_token`. An enabled role missing its binding fails loading, rather than silently disabling authentication. Disabled roles make no native reads. Existing pull boolean/string activation follows its existing normalization; the string `"false"` does not enable it. + +Deprecated Tinybird/DataDome/S3 store selectors remain ignored, deserialize-only inputs; remove them. Tinybird's deprecated `access_token_secret` is still discarded, and `access_enabled = true` is still rejected. It is not a thirteenth role. Trusted-client-IP credentials already came from the native store; the old guide's inline-value description was wrong. + +## New-schema example + +```toml +[[handlers]] +path = "^/_ts/admin" +username = "admin" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "https://origin.example.com" + +[ec] +ec_store = "ec_identity_store" + +[[ec.partners]] +name = "Example partner" +source_domain = "partner.example.com" +api_token_enabled = true +pull_sync_enabled = true +pull_sync_url = "https://sync.example.com/pull" +pull_sync_allowed_domains = ["sync.example.com"] + +[tinybird] +enabled = true +api_host = "api.tinybird.example.com" +auction_dataset = "auction_events_raw" + +[[proxy.asset_routes]] +prefix = "/private/" +origin_url = "https://assets.example.com" + +[proxy.asset_routes.auth] +type = "s3_sigv4" +region = "us-east-1" +session_token_enabled = true +origin_query = "strip" +``` + +The S3 hostname is fictional; production must use the approved HTTPS S3 origin that validates SigV4. No secret properties, generated keys, labels, store overrides, owner IDs, or generation IDs belong in this config. `s3_sig_v4` remains a deserialize alias for `s3_sigv4`; both use the same effective route policy. A nonempty pull allowlist may contain only the endpoint hostname. It is no longer mandatory and cannot authorize a retargeted credential. + +## Provision first, cut over in isolation + +1. Assign an operator and rollback deadline. Inventory all active purposes, identities, endpoints, route policies, provider resources, retained states, and legacy names without printing values. Separate cross-role credential reuse. Verify config writers cannot write native secrets, change trusted deployment links, or run code with secret-writer permissions. +2. Save access-controlled old binary/config/deployment records for the approved rollback window. Create and review a new-schema config from trusted source, not an unreviewed live mutable blob. Blindly provisioning a plan from attacker-edited config would grant its requested authority. +3. Select isolated resources through the existing deployment inputs. Old and new binaries require separate compatible config snapshots/stores during mixed rollout. Do not expose the new config to an old binary or assume both schemas work in a shared blob. Fastly `--staging` alone does not select isolated stores. +4. Generate and approve the complete plan with the matching CLI and deployment selection: + + ```bash + ts secrets plan --adapter fastly --app-config trusted-server.toml \ + --no-env --out bindings.json + ``` + + Use `--adapter axum`, `cloudflare`, or `spin` for native rendering. Review all generated names, declarations, service/store IDs, contexts, and versions. Retain the secret-free plan with the release record. Plan checksums detect drift, not approval. + +5. Supply existing raw credential bytes securely to the generated bindings using [interactive Fastly provisioning](./secrets#first-fastly-setup-with-tinybird) or [native provisioning](./secrets#axum-native-environment-input). No JSON wrapping is required. Do not copy legacy names into new TOML. If validation rejects a former credential, arrange a recipient-side replacement rather than trimming bytes or weakening validation. Provision each required role, including separate inbound/pull tokens and all active S3 roles. +6. Add generated native declarations where required. Spin requires both application and component declarations with protected value sources. Regenerate/review plans after manifest changes. Confirm presence where supported; Cloudflare/Spin TS checks are explicitly unsupported and nonzero. Fastly metadata and Axum environment presence do not prove stored bytes or runtime behavior. +7. Deploy the matching new binary and config to the isolated version through the existing managed path. Recreate state, then exercise real approved requests and valid-checksum config-only mutations. Require zero unauthorized sends. Verify Tinybird at its approved dataset; do not interpret the fire-and-forget sink as response observation. Verify all four native mappings and config-writer permission separation before production cutover. +8. Switch traffic only when the release gates pass. Track retained/in-flight old states. Within the owned rollback window, remove legacy raw-name entries and unused declarations/links only after no active, staged, or rollback version depends on them. Revoke at recipients if prior exposure is suspected. + +## Rotate a value without changing context + +Replacing a value uses the same deterministic key. There is no independently selectable old/new generation. Re-running `set` explicitly replaces that binding; it does not allocate another name. Keep an external recipient overlap window where supported, recreate runtime state, verify the new value works, then revoke the old value at the recipient. If values cannot overlap, use a maintenance cutover. + +Do not blindly retry a write whose outcome is uncertain. Reconcile through the provider and a fresh-state test, then explicitly repeat the same reviewed operation if needed. Provider replacement/propagation semantics need isolated proof. A confirmed write does not instantly update warm instances. + +For S3, the access ID, secret key, and optional session token are separate writes and not transactional. Pause affected state creation and traffic, update and verify the full required set, recreate state, test GET/HEAD and signing preflight, then resume. If the required coordinated maintenance or provider swap is unavailable, rollout is blocked; do not accept partially updated credential sets. + +Axum normally needs a process restart. Fastly needs fresh state/sandboxes, including reusable-sandbox mode. Cloudflare and Spin need reconstruction through their existing runner lifecycle. No new watcher, TTL, global refresh, or immediate revocation mechanism is added. + +## Retire a context + +Changing a subject, endpoint, route policy, or binding ABI requires a new reviewed plan and newly provisioned bindings. Reordering entries does not. For each change: + +1. Compare old and new plans and approve the new destination/policy. +2. Provision the new keys/declarations before switching the matching config and recreating state. +3. Delete every retired binding/declaration within a named window, replace retained states, and account for in-flight operations. +4. Replay the retired config with a valid checksum against freshly created state. It must fail loading with no old credential export. +5. Revoke the retired credential at the recipient if exposure is suspected or cached states cannot be trusted. + +New keys do not revoke old context keys. Leaving the old key provisioned permits a config-only writer to select that complete old authorized identity on fresh state. Deleting it prevents fresh loads, not already captured in-flight use. Record deletion, state replacement, replay evidence, and recipient revocation separately. + +## Rollback record + +Before any rollback, identify the exact binary, compatible config snapshot, physical store mappings, native declarations, and values it needs. Restoring only the binary can fail startup or reconnect it to unintended stores. Keep old/new config stores separate during mixed rollout. + +After the rollback deadline, remove legacy bindings. A later rollback then requires a fresh explicit authorization decision and secure reprovisioning; do not restore a raw-name fallback in the new binary. Document that any old-binary downgrade restores the original attack. Prefer a forward fix or disabling exporters, and include post-upgrade withdrawals/retained legacy data in the wider rollback decision. + +Request-signing and management credentials remain on their existing separately audited paths. Do not migrate `signing_keys`, `jwks_store`, `current-kid`, or management API identities through this app-credential procedure. diff --git a/docs/guide/secrets-ci.md b/docs/guide/secrets-ci.md new file mode 100644 index 0000000000..8b95dfcce6 --- /dev/null +++ b/docs/guide/secrets-ci.md @@ -0,0 +1,130 @@ +# Provision secrets from CI + +Automation calls the same reviewed Fastly `ts secrets set` command as interactive provisioning. There is no second CI secret writer. Direct writes to Axum, Cloudflare, and Spin are unsupported; use their [native instructions](./secrets). + +::: warning Trust boundary +A config-only publisher must not control the workflow, CLI executable, shell scripts, or checkout revision executed with secret-writer credentials. Environment approval alone does not make attacker-controlled code safe. Use an administrator-controlled provisioning repository and dedicated runner when repository permissions cannot enforce this split. Do not run this job on untrusted pull requests, including `pull_request_target` checkouts of PR code. +::: + +## Deployment inputs + +Use existing `edgezero.toml` and adapter manifests. Do not add a deployment descriptor or a raw secret-store override to the command. + +| Input | Meaning | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `--manifest` | Explicit EdgeZero manifest path; otherwise `EDGEZERO_MANIFEST`, otherwise `edgezero.toml` | +| `--app-config` | Reviewed unresolved config; otherwise `.toml` beside the manifest | +| `--no-env` | Disable ordinary `TRUSTED_SERVER__...` config overlays, not provider authentication or deployment selectors | +| `fastly.toml` `service_id` | Existing service identity in the selected adapter manifest | +| `FASTLY_SERVICE_ID` | Existing environment service selection; a conflict with the manifest is rejected unless using supported explicit service selection | +| `--service-id` | Explicit Fastly selection; review the resulting service in the plan | +| `FASTLY_API_TOKEN` | Authentication for managed direct API calls; a Fastly CLI profile alone does not supply it | +| `EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME` | Physical app-secret store name | +| `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` | Physical app-config store name | +| `EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME` | Physical KV store name | + +Selectors contain names, not provider IDs. Unset selectors retain the logical-name defaults; the plan shows the selection and resolves exact Fastly resource IDs read-only. `ts provision --adapter fastly` creates/reuses resources. Managed deployment links them under their logical IDs. Secret writes do neither. + +`--staging` on lifecycle commands does not isolate stores. Use separately reviewed physical mappings for staging and production. Keep the manifest, service selection, selectors, config, overlay policy, CLI/compiler version, and complete plan fixed between review and execution. A recreated store with the same name is still a different provider resource and requires a new plan. + +## Bash stdin example + +Run this only in a trusted provisioning environment. It expects the token and approved deployment inputs to be injected by a protected secret mechanism, and `bindings.json` to have been generated and reviewed from this exact config/revision. The explicit selectors below deliberately require a reviewed value instead of relying on ambient defaults. + +```bash +set +x +set -euo pipefail +: "${TINYBIRD_AUCTION_TOKEN:?Missing Tinybird credential}" +: "${FASTLY_API_TOKEN:?Missing Fastly provisioning credential}" +: "${FASTLY_SERVICE_ID:?Missing reviewed Fastly service}" +: "${EDGEZERO_MANIFEST:?Missing reviewed manifest path}" +: "${EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME:?Missing reviewed secret store}" +: "${EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME:?Missing reviewed config store}" +: "${EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME:?Missing reviewed KV store}" +printf '%s' "$TINYBIRD_AUCTION_TOKEN" | + env -u TINYBIRD_AUCTION_TOKEN ts secrets set tinybird.auction \ + --adapter fastly --app-config trusted-server.toml --no-env \ + --plan bindings.json --stdin --yes +unset TINYBIRD_AUCTION_TOKEN +``` + +`printf` is a Bash builtin. The value is piped over stdin, not passed to a subprocess on argv. `env -u` removes the source token from the CLI child environment. Never enable `set -x`, use `echo`, or write the value to a temporary artifact. `printf '%s'` does not append a newline. The CLI preserves input bytes and rejects invalid Bearer representation rather than trimming it. + +`--stdin` requires `--yes`. This replaces the interactive confirmation, not plan review. All preflight comparison and input validation still apply. A repeated role such as `ec.inbound` or `handler.password` also needs `--binding` with the tool-generated `entry_id` chosen from the reviewed plan. Do not use a raw key, an array position, or an operator-created label. + +## Protected GitHub Actions example + +This example belongs in the separately controlled provisioning repository. It assumes an isolated self-hosted runner labelled `secret-writer` already has a reviewed immutable `ts` executable at `/opt/trusted-server/bin/ts`, `jq`, and Bash. Do not share that runner with untrusted builds. Replace the approved revision and CLI digest through administrator-controlled Environment variables before use; the checks reject empty or malformed values. This is a workflow template, not an executed CI rehearsal. + +The reviewed repository revision contains `trusted-server.toml`, `bindings.json`, `edgezero.toml`, and its adapter manifest. Generate the plan with the same CLI and protected deployment selection before approving that immutable revision. Keep the workflow itself under administrator control too. + +```yaml +name: Provision reviewed Tinybird binding +on: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: fastly-secret-provisioning + cancel-in-progress: false +jobs: + provision: + runs-on: [self-hosted, secret-writer] + environment: secret-provisioning + env: + APPROVED_REVISION: ${{ vars.PROVISIONING_REVISION }} + CLI_SHA256: ${{ vars.TS_CLI_SHA256 }} + EDGEZERO_MANIFEST: edgezero.toml + FASTLY_SERVICE_ID: ${{ vars.FASTLY_SERVICE_ID }} + EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME: ${{ vars.TS_SECRET_STORE }} + EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME: ${{ vars.TS_CONFIG_STORE }} + EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME: ${{ vars.TS_KV_STORE }} + steps: + - name: Validate immutable inputs + shell: bash + run: | + set +x + set -euo pipefail + [[ "$APPROVED_REVISION" =~ ^[0-9a-f]{40}$ ]] + [[ "$CLI_SHA256" =~ ^[0-9a-f]{64}$ ]] + printf '%s %s\n' "$CLI_SHA256" /opt/trusted-server/bin/ts | sha256sum --check --status + - name: Check out approved provisioning inputs + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ env.APPROVED_REVISION }} + persist-credentials: false + - name: Provision exact reviewed context + shell: bash + env: + FASTLY_API_TOKEN: ${{ secrets.FASTLY_PROVISIONING_TOKEN }} + TINYBIRD_AUCTION_TOKEN: ${{ secrets.TINYBIRD_AUCTION_TOKEN }} + run: | + set +x + set -euo pipefail + : "${TINYBIRD_AUCTION_TOKEN:?Missing Tinybird credential}" + : "${FASTLY_API_TOKEN:?Missing Fastly provisioning credential}" + : "${FASTLY_SERVICE_ID:?Missing reviewed Fastly service}" + : "${EDGEZERO_MANIFEST:?Missing reviewed manifest path}" + : "${EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME:?Missing reviewed secret store}" + : "${EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME:?Missing reviewed config store}" + : "${EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME:?Missing reviewed KV store}" + printf '%s' "$TINYBIRD_AUCTION_TOKEN" | + env -u TINYBIRD_AUCTION_TOKEN /opt/trusted-server/bin/ts secrets set tinybird.auction \ + --adapter fastly --app-config trusted-server.toml --no-env \ + --plan bindings.json --stdin --yes + unset TINYBIRD_AUCTION_TOKEN +``` + +Restrict Environment access and approved revisions to provisioning administrators, require reviewers who can assess target changes, and prevent config-only publishers from changing those controls. Pin action code to immutable commits. Do not download or build mutable CLI code after approval. GitHub masking is not a substitute for keeping values out of output, request dumps, panic reports, artifacts, and process arguments. + +The concurrency group serializes jobs in this repository. If other repositories or humans can write the same binding, use an operational lock shared with them. Do not assume GitHub concurrency is a provider-wide lock. + +## Failure, reruns, and verification + +Preflight failures must cause no write and no secret prompt. Changed config, overlays, plan, versions, manifest, service, or resolved stores require a newly reviewed plan. Unsupported adapters reject direct writes before accepting a value. + +Repeat execution at the same context replaces the same generated binding; it does not create a new generation or slot. Do not automatically retry uncertain writes. Reconcile the provider outcome and test a fresh runtime state before an explicit retry with the same approved inputs. Permission failures are not a reason to broaden the destination or copy values to another purpose. + +A confirmed write means storage only. Run `ts secrets check` for Fastly metadata presence, then recreate state and exercise an approved request with recipient-side verification. It does not prove the stored bytes, external acceptance, TLS/no-follow behavior, or warm-state refresh. Follow [rotation and retirement](./secret-binding-migration) for recipient overlap, S3 maintenance, retained states, and rollback. + +Before release, execute the Bash path through the real CLI with fictional provider transport, inspect argv/stdin/output, test missing inputs and plan/resource mismatches with zero mutation, then rehearse in isolated protected CI. Native/provider/IAM proof and these workflow rehearsals remain gates until recorded as executed evidence. diff --git a/docs/guide/secrets.md b/docs/guide/secrets.md new file mode 100644 index 0000000000..14b8dc66a3 --- /dev/null +++ b/docs/guide/secrets.md @@ -0,0 +1,226 @@ +# Provision app credentials + +App config contains feature settings, identities, and destinations. It contains no credential values or secret key names. Trusted Server derives a native key from each active purpose, identity, and approved destination or route policy. Provisioning that key authorizes that use. + +::: warning Release gate +The interfaces below describe the implementation in this branch. They are not evidence of a completed security release. Shipping Rust tests, isolated Fastly replacement/read-through tests, native reads and HTTPS/no-follow tests on all four runtimes, operator rehearsals, and config-writer IAM separation remain release gates. Do not migrate production based on documentation or a presence check alone. +::: + +## Before provisioning + +- Use a reviewed CLI build and matching binary, config, `edgezero.toml`, and adapter manifest. Install `jq` and the provider tools for native workflows. +- Separate config publishing from secret writing and deployment administration. A config-only publisher must not edit the trusted executable, deployment bindings, provider secrets, or code run with provisioning credentials. Verify that separation in the provider, not just in repository labels. +- Select an isolated deployment through the [existing deployment inputs](./secrets-ci#deployment-inputs). Keep one logical app-credential store, `trusted_server_secrets`. +- Obtain credentials outside TS. TS does not mint Tinybird tokens, partner tokens, DataDome keys, or AWS credentials. Do not reuse a credential value across purposes. +- Disable shell tracing and terminal/session recording before credential input. Never put values in command arguments, tracked files, plan files, or artifacts. + +## Purpose, identity, and target + +```mermaid +flowchart TD + A[Reviewed feature config] --> B[Compile active purpose identity and target] + B --> C[Secret-free plan and native declarations] + C --> D[Operator approves provisioning] + D --> E[Raw credential at generated native identity] + A --> F[Runtime compiles the same context] + E --> G[Load credential with compiled target] + F --> G + G --> H[Publish one loaded snapshot] + H --> I[Verified TLS and no redirect following] +``` + +Keys have a reserved `ts1_` namespace. Use the CLI renderer, not a hand-written hash or naming recipe. Array order does not select a credential. Changing a partner domain, handler regex/username, pull endpoint, Tinybird host/dataset, DataDome endpoint, or authenticated S3 route policy selects a different binding. Computing its name grants no access without a value provisioned there. + +| Fixed selector | Active when | Bound identity or target | +| ------------------ | ------------------------------------------ | ------------------------------------------------------------------- | +| `publisher.proxy` | Always | App, inbound proxy URL HMAC | +| `ec.passphrase` | Always | App, EC identity derivation | +| `ec.inbound` | Partner `api_token_enabled = true` | Normalized partner source domain | +| `ec.pull` | Partner pull sync enabled | Partner domain and exact HTTPS pull endpoint | +| `handler.password` | Each handler | Exact regex and username | +| `clientip.shared` | Trusted-client-IP section present | App, front-door authentication | +| `tinybird.auction` | Tinybird enabled | Events endpoint and exact dataset | +| `datadome.server` | Integration and Protection enabled | Exact Protection endpoint | +| `datadome.bypass` | Protection and test bypass enabled | App, separate inbound bypass | +| `s3.access-id` | S3 route auth | Route prefix, HTTPS origin, rewrite, region, effective query policy | +| `s3.secret` | S3 route auth | Same route policy, separate purpose | +| `s3.session` | S3 auth and `session_token_enabled = true` | Same route policy, separate purpose | + +Disabled uses make no secret reads. A missing enabled binding fails loading; it never silently turns authentication off. Removed credential properties are errors even on disabled features. See the [migration table](./secret-binding-migration#removed-properties). + +Outbound endpoints must use HTTPS DNS names. Do not use IP literals, userinfo, queries, fragments, percent escapes, dot segments, or whitespace. Pull paths preserve case and slash distinctions. A nonempty pull allowlist may contain only the endpoint hostname; it is an assertion, not authorization. Authenticated exports require certificate verification regardless of `proxy.certificate_check`, and never follow redirects. + +## First Fastly setup with Tinybird + +Start from `trusted-server.example.toml` copied to an operator-owned `trusted-server.toml`. It already supplies the publisher, EC, and admin-handler settings. Add: + +```toml +[tinybird] +enabled = true +api_host = "api.tinybird.example.com" +auction_dataset = "auction_events_raw" +``` + +The host is fictional. Use your reviewed regional Events API DNS host in private configuration, optionally followed by an HTTPS port, such as `api.tinybird.example.com:8443`. Do not include a scheme, path, userinfo, query, or fragment. The shared endpoint parser builds `https://{api_host}/v0/events`, removes explicit port `443`, and preserves other valid ports. It rejects forbidden original syntax before normalization. A changed nondefault port selects a different binding. Obtain an APPEND token restricted to that dataset through Tinybird administration. Do not add a token-name property. + +1. Set `service_id` in the selected `fastly.toml`, or supply `FASTLY_SERVICE_ID`. If both are set they must agree. Supply `FASTLY_API_TOKEN` through your protected credential mechanism, not shell history. Select physical stores explicitly for isolated environments. +2. Run `ts provision --adapter fastly` to create or reuse the selected resources. Secret planning resolves resources read-only; it cannot create them. +3. Generate and review the complete plan: + + ```bash + ts secrets plan --adapter fastly --app-config trusted-server.toml \ + --no-env --out bindings.json + jq . bindings.json + ``` + + Check every active entry, not just Tinybird. Review the purpose, owning path, subject, exact endpoint/dataset or route policy, native identity, service, physical store names and provider IDs. Unset store selectors use logical-name defaults, which appear in the plan. Plans are secret-free but disclose deployment metadata, so restrict access. + +4. Provision interactively: + + ```bash + ts secrets set tinybird.auction --adapter fastly \ + --app-config trusted-server.toml --no-env --plan bindings.json + ``` + + Confirm the displayed target and create/replace action, type `yes`, then paste the raw credential at the hidden prompt. There is no value argument. Provision the required `publisher.proxy`, `ec.passphrase`, and each `handler.password` binding too. For example: + + ```bash + ts secrets set publisher.proxy --adapter fastly \ + --app-config trusted-server.toml --no-env --plan bindings.json + ts secrets set ec.passphrase --adapter fastly \ + --app-config trusted-server.toml --no-env --plan bindings.json + ts secrets set handler.password --adapter fastly \ + --app-config trusted-server.toml --no-env --plan bindings.json + ``` + + Use independently generated high-entropy values. Keep the EC and proxy values stable unless intentionally rotating EC IDs or signed URLs. Multiple handlers cause an interactive subject list; automation must copy the generated `entry_id` into `--binding`. + +5. Check presence, push the matching config, and use the existing managed application-release deployment. Do not use plain `fastly compute publish` to bypass logical store linking. + + ```bash + ts secrets check --adapter fastly --app-config trusted-server.toml \ + --no-env --plan bindings.json + ts config push --adapter fastly --app-config trusted-server.toml --no-env --no-diff + ``` + + Use [Fastly deployment](./fastly) for the managed release command. `set` does not push config, link stores, deploy, activate a version, or refresh state. + +6. Recreate application state, including reusable Fastly sandboxes. Exercise an actual approved auction request and verify the expected row at the intended dataset. Tinybird emission is Fastly-only and fire-and-forget. A successful app response or storage write does not prove delivery; the sink does not observe Tinybird's response. Verify TLS and no-follow separately with isolated test endpoints. + +A plan is not a signature or approval. `set` and `check` recompile and compare the complete plan against current config, overlays, compiler/mapping versions, and resolved resources before credential input or writes. If anything changes, generate and approve a new plan. `--no-env` disables ordinary app-config overlays only, not deployment selectors or provider authentication. + +## Plan fields and repeated roles + +`plan` supports `fastly`, `axum`, `cloudflare`, and `spin`. Common flags are `--app-config`, `--manifest`, `--no-env`, and `--out`. `--manifest` defaults to `EDGEZERO_MANIFEST`, otherwise `edgezero.toml`. Without `--app-config`, the file is `.toml` beside that manifest. `--service-id` is Fastly-only. + +The JSON records `plan_version`, `compiler_version`, `binding_abi`, `native_mapping_version`, `effective_config_sha256`, `environment_overlay`, `resources`, and `entries`. Each entry contains `entry_id`, `compiled` context metadata, and `native` with `native_name` and `declarations`. Entry IDs select compiled bindings; they are not configurable labels or additional hash inputs. + +```bash +jq -r '.entries[] | [.entry_id, .compiled.descriptor_id, .native.native_name] | @tsv' bindings.json +``` + +For a repeated role, copy the reviewed ID. Never choose the first array element merely because it comes first: + +```bash +# BINDING_ID is the selected entry_id from the reviewed plan, not a secret key. +ts secrets set ec.inbound --binding "$BINDING_ID" --adapter fastly \ + --app-config trusted-server.toml --no-env --plan bindings.json +``` + +## Axum native environment input + +Direct `ts secrets set --adapter axum` is unsupported. Generate the actual runtime environment names: + +```bash +ts secrets plan --adapter axum --app-config trusted-server.toml \ + --no-env --out bindings-axum.json +jq -r '.entries[].native.native_name' bindings-axum.json +``` + +Review the plan and map each listed name to its intended credential. In a private Bash launching shell, load each value without printing it: + +```bash +set +x +set -euo pipefail +while IFS= read -r name; do + IFS= read -r -s -p "Credential for $name: " value /dev/tty + export "$name=$value" + unset value +done < <(jq -r '.entries[].native.native_name' bindings-axum.json | sort -u) +ts secrets check --adapter axum --app-config trusted-server.toml \ + --no-env --plan bindings-axum.json +# Start the reviewed prebuilt runtime from THIS shell, not a build subprocess. +exec trusted-server-axum +``` + +First install the matching reviewed `trusted-server-axum` binary on `PATH` and supply the config-envelope environment variable using the [Axum setup](./getting-started#option-b-axum-dev-server). `export` here is a Bash builtin, not a command carrying a credential on subprocess argv. Do not run this loop as a child script and expect it to export into its parent. A production supervisor must inject the generated names into the actual process environment through its protected secret mechanism. Avoid dumping process environments. `check` examines only its own process environment; it cannot inspect a remote process or verify credential bytes. Restart Axum to load replacements. + +## Cloudflare native Worker secrets + +Direct `set` and native presence `check` are unsupported for Cloudflare. Generate and review names from the selected Worker manifest: + +```bash +ts secrets plan --adapter cloudflare --app-config trusted-server.toml \ + --no-env --out bindings-cloudflare.json +jq -r '.entries[].native.native_name' bindings-cloudflare.json +``` + +For each reviewed binding, select its generated name and invoke Wrangler's hidden input. Use the same explicit Wrangler config and environment as the deployed Worker: + +```bash +# NATIVE_NAME is copied from the reviewed entry, never derived by hand. +wrangler secret put "$NATIVE_NAME" --config crates/trusted-server-adapter-cloudflare/wrangler.toml +``` + +Wrangler creates or updates a secret and may deploy a new Worker version. Coordinate this with the matching config and binary; do not accidentally update the live Worker during migration. Do not put credentials in `[vars]` or commit `.dev.vars`. Also configure the nested `TRUSTED_SERVER_CONFIG` bridge described in [Cloudflare](./cloudflare). Inspect provider secret-name inventory without retrieving values, then test a fresh Worker instance and an approved request. TS `check` exits nonzero with `unsupported`; generated names do not prove the Worker has bindings. + +A role able to deploy arbitrary Worker code can read its secrets. A config-only publishing role therefore cannot also hold Worker deployment/secret rights. Use a constrained publishing service where provider permissions cannot enforce that split. + +## Spin native variables + +Direct `set` and native presence `check` are unsupported for Spin. Generate a plan and extract declarations: + +```bash +ts secrets plan --adapter spin --app-config trusted-server.toml \ + --no-env --out bindings-spin.json +jq -r '.entries[].native.declarations[]' bindings-spin.json +``` + +Merge every generated `[variables.]` application declaration and every generated entry under `[component.trusted-server.variables]` into the selected `spin.toml`. TOML cannot repeat the same table header: combine all component entries in that one table. Keep `required = true`, `secret = true`, and no plaintext defaults. Regenerate and review the plan after changing the manifest, because its fingerprint changes. + +The generated name includes TS's existing store/key escaping; do not replace it with the raw `ts1_` key. Both declaration levels are required. `secret = true` does not encrypt storage. + +For an isolated local run, Spin's environment variable provider reads `SPIN_VARIABLE_` followed by the uppercase application variable name. Inject values into the launching shell: + +```bash +set +x +set -euo pipefail +while IFS= read -r name; do + IFS= read -r -s -p "Credential for $name: " value /dev/tty + export "SPIN_VARIABLE_${name^^}=$value" + unset value +done < <(jq -r '.entries[].native.native_name' bindings-spin.json | sort -u) +spin up --from crates/trusted-server-adapter-spin/spin.toml +``` + +Supply the matching config to Spin's `default` KV store with `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default`; see [Spin](./spin). For deployed use, configure a protected Spin variable provider through deployment administration and remove unprotected fallback/default values. Verify actual component lookup, generated-name limits, provider value limits, and config-writer permissions. These native rehearsals remain gated, not inferred from host tests. See [Spin application variables](https://spinframework.dev/v4/variables). + +## Checks, rotation, and troubleshooting + +| Operation | What it proves | What it does not prove | +| -------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------- | +| `plan` | Current compiled metadata and rendering | Operator approval or native runtime acceptance | +| Fastly `check` | Reviewed-input agreement and name presence through metadata | Stored bytes, recipient acceptance, TLS, delivery, state refresh | +| Axum `check` | Reviewed-input agreement and current-process environment presence | Remote launcher injection or runtime validity | +| Cloudflare/Spin `check` | Reviewed-input comparison, then unsupported nonzero result | Native binding presence | +| Actual fresh-state request | Only the exercised runtime path | All adapters, IAM separation, or global revocation | + +Same-context rotation replaces the value at the same generated name. There is no generation selector. Arrange recipient overlap where supported, otherwise a maintenance cutover. Recreate states; storage confirmation is not fleet-wide activation. Pause affected state creation/traffic for nontransactional S3 multi-key updates and verify the complete credential set before resuming. + +A target or identity change requires a new reviewed plan and binding. Delete retired keys/declarations within an owned window, replace retained states, account for in-flight requests, and verify retired-config replay fails on fresh state. New keys do not revoke old ones. Revoke at the recipient if earlier exposure is suspected. Follow the [migration and rollback procedure](./secret-binding-migration). + +For a stale-plan error, compare approved config, overlay policy, CLI version, manifest, service, and physical selectors. Never bypass it by copying a key into config. For missing credentials, provision the selected active role in the mapped store, not another purpose's key. Bearer credentials must be nonempty RFC 6750 b64tokens; a newline from `echo` is invalid, not trimmed. Use `printf '%s'` for [CI stdin](./secrets-ci). + +If a write fails after an uncertain provider outcome, stop. Reconcile through provider metadata and an isolated fresh-state test before explicitly retrying the same reviewed context. Do not blindly retry, delete the old value, or authorize a new target to work around a provider error. Request signing and key rotation keep their separate `signing_keys`/`jwks_store` paths; this workflow does not migrate them. diff --git a/docs/guide/spin.md b/docs/guide/spin.md index 1a0b5c5a7f..fa53385cbc 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -17,17 +17,18 @@ configuration. ## Prerequisites -Spin 4.1.0 or newer, pinned as `spin` in `.tool-versions`. `spin-sdk` 7 imports -`wasi:http/types@0.3.0`, which Spin 4.0.x does not provide, so an older runtime -cannot start the component. Install or upgrade from the -[Spin install guide](https://spinframework.dev/install). +Spin 4.1.0 or newer, pinned as `spin` in `.tool-versions`. This branch temporarily +uses `spin-sdk` 6 to match the unreleased EdgeZero secret-provisioning APIs; +the runtime minimum remains unchanged. `spin-sdk` 7 imports +`wasi:http/types@0.3.0`, which Spin 4.0.x does not provide. Install or upgrade +from the [Spin install guide](https://spinframework.dev/install). asdf has no first-party Spin plugin, so `asdf install` does not provide this one; install it directly, or add a community plugin if you want asdf to manage it. The pin records the required version and is what the smoke checks against. -Nothing in the build catches this. The component compiles, `cargo -test-spin` passes, and only `spin up` reports: +With `spin-sdk` 7, the build does not catch an older runtime. The component +compiles, `cargo test-spin` passes, and only `spin up` reports: ``` Error: component imports instance `wasi:http/types@0.3.0`, but a @@ -52,12 +53,12 @@ config push --adapter spin --local`. Without that mapping, the push can land in a store the runtime never reads. Local push writes the temporary `.spin/sqlite_key_value.db`; no repository-local Spin state is used. -Spin encodes `v__v_` names. The smoke supplies these exact -variables: - -- `v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword` -- `v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret` -- `v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase` +Generate exact names and both application/component declarations with +`ts secrets plan --adapter spin`. Follow the +[native variable walkthrough](./secrets#spin-native-variables). Merge component +entries into one table, retain required secret declarations without plaintext +defaults, and use protected provider input. `secret = true` alone does not +encrypt storage. The local smoke must use generated contexts, not old labels. It first launches `spin up` before config is pushed, then relaunches it while omitting each variable independently. Every publisher request must return 503, @@ -71,8 +72,10 @@ manifest, SQLite store, response captures, and logs. ## Runtime boundaries -Use the same store mapping and encoded variables in the deployment provider; -empty variable defaults fail closed. Spin's request-time store registry is +Use the same store mapping and generated declarations in the deployment provider. +Direct `set` and native presence `check` are unsupported; `check` exits nonzero. +Verify native component reads, provider/name limits, HTTPS/no-follow, and +config-writer permission separation before release. Host tests do not prove them. Spin's request-time store registry is currently unwired, so EC KV lookup, request-signing key variables, and key rotation are unavailable. The read-only `/_ts/admin/eids` diagnostic remains available. Forwarded client-IP headers are sanitized, and Spin derives its diff --git a/docs/guide/telemetry.md b/docs/guide/telemetry.md index a4ad08b93a..affe335fe2 100644 --- a/docs/guide/telemetry.md +++ b/docs/guide/telemetry.md @@ -35,9 +35,19 @@ misclassify them. ## Configuration -Set `tinybird.enabled = true`, provide the regional API host, select the -auction datasource, and set `auction_token_secret` to the key holding that -datasource's APPEND token in the default app-config secret store. The default +Set `tinybird.enabled = true`, provide the regional API DNS host with an optional +HTTPS port in `api_host`, and select the +auction datasource, then provision the generated `tinybird.auction` binding +from a reviewed plan with that dataset's restricted APPEND token. TS does not +mint the token. No token-name property belongs in app config. The exact HTTPS +Events endpoint and dataset determine the binding. `api_host` has no scheme, +path, userinfo, query, or fragment. Explicit port `443` normalizes away; other +valid ports, such as `8443`, remain part of the approved endpoint. Follow the +[interactive Fastly tutorial](./secrets#first-fastly-setup-with-tinybird), +recreate state after writes, and verify actual rows at the approved recipient. +All adapters validate an enabled binding, but this does not add emitters to +other adapters. The Fastly sink never follows redirects and does not observe +the response, so storage or app-request success does not prove delivery. The default datasource is `auction_events_raw`; the default body limit is 1 MiB and the minimum accepted limit is 1 KiB. @@ -47,5 +57,5 @@ wired. `access_dataset` and `access_sample_rate` are reserved. enable access telemetry. The exact settings and secret-handling dispositions are in -[Configuration](/guide/configuration#tinybird). The tracked Tinybird project is +[Configuration](/guide/configuration#tinybird-configuration). The tracked Tinybird project is described in [`tinybird/README.md`](https://github.com/IABTechLab/trusted-server/blob/main/tinybird/README.md). diff --git a/docs/superpowers/plans/2026-10-26-issue-1037-secret-bindings-implementation-plan.md b/docs/superpowers/plans/2026-10-26-issue-1037-secret-bindings-implementation-plan.md new file mode 100644 index 0000000000..ffef0f095f --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-issue-1037-secret-bindings-implementation-plan.md @@ -0,0 +1,276 @@ +# Context-derived secret bindings implementation plan + +## Governing contract and status + +Implement [Context-derived secret bindings](../specs/2026-10-16-issue-1037-context-derived-secret-bindings-design.md) for [issue #1037](https://github.com/IABTechLab/trusted-server/issues/1037). This plan supersedes the earlier slot/protected-JSON implementation plan. Credentials stay raw native values. Operators configure feature activation, identity, and destination, not credential names. Core derives lookup identities and publishes credentials with their compiled targets. First-release tooling includes secure Fastly writes using existing deployment configuration and secret-free rendering/native provisioning instructions for all four adapters. + +This plan was written against runtime base `5bc38862512c5c71085b5052bc3e528ee9b9987d`, documentation commit `ceaf43cdac230abfe48a6aa825c532f0b9b34ed9`, and EdgeZero v0.0.9 at `f7176742d46aa4d1a04ce4588b606afda388fd30`. Those are historical investigation inputs, not the implementation dependency pin. + +Implementation is now under review on `feature/1037-secret-bindings`. The compiler, loading/export paths, provisioning commands, guides, and native fixtures below exist. EdgeZero is pinned to published revision `4f645b1e705d295117c93f4c74db12e5692ffb34`, companion [draft PR #444](https://github.com/IABTechLab/edgezero/pull/444). Host and simulator checks do not replace native TLS/no-follow, IAM, protected CI, provider propagation/limits, or migration proof. Those unperformed checks remain release gates; this plan is not release evidence. + +Success means config-only mutations cannot read another role's credential or send an authorized credential to a new destination. Failed loading publishes no new snapshot and creates no backend or HTTP request. Provisioning success means the tool writes only the reviewed generated binding in the resolved deployment; it does not imply external credential validity or immediate runtime refresh. + +## Boundaries and exclusions + +- One core descriptor/compiler owns the twelve purposes, activation, subject extraction, endpoint policy, and versioned encoder. Tooling and runtime use it. +- Remove all twelve reference properties from unresolved config. Add only `ec.partners[*].api_token_enabled=false` and S3 `auth.session_token_enabled=false` for former optional credentials. Preserve existing activation elsewhere. +- Keep `trusted_server_secrets` and existing trusted physical mappings. Reuse `edgezero.toml`, `fastly.toml`, service-selection rules, deploy selectors, and provider authentication. Do not add a deployment descriptor or a second environment-selection system. +- The private loaded snapshot is outbound authority. Plain settings, public helper constructors, and resolved shadow fields cannot enable credential export. +- No JSON records, arbitrary labels, owner IDs, generation selectors, legacy-key retries, inline-value fallback, automatic rotation, credential discovery, background reconciliation, or secret-management service. +- Direct first-release `set` support is required for Fastly. Other adapters require real native lookup proof, generated declarations, and tested secure provisioning guides. Reject unsupported direct writes before reading input. Do not imply four-provider write automation has shipped. +- Preserve unrelated app behavior, request-signing/management paths, consent/KV/pull scheduling, and Tinybird fire-and-forget emission. Do not add Tinybird emitters to other adapters. +- Use fictional fixtures only. Never inspect real credentials or overwrite operator-owned `trusted-server.toml` while developing this change. + +## Delivery order + +Each numbered step should produce a reviewable commit or tightly related commits. Prerequisite experiments are not intermediate security releases. Do not deploy a partially scoped implementation that retains mutable credential destinations. + +```mermaid +flowchart TD + A[1 Baseline and feasibility] --> B[2 Label-free schema and compiler] + A --> C[3 EdgeZero planning and deployment helpers] + B --> D[4 Atomic loader and thin pull proof] + C --> D + B --> E[5 Native rendering and reviewed plans] + C --> E + E --> F[6 Secure Fastly provisioning and CI] + D --> G[7 Sibling exporters] + G --> H[8 Adapter construction and fixtures] + F --> I[9 Operator guides and migration rehearsal] + H --> J[10 Attacker and native runtime proof] + J --> I + I --> K[11 Combined gates and release decision] +``` + +### 1. Establish fixtures and prove prerequisite feasibility + +Read the spec, `AGENTS.md`, `.cargo/config.toml`, `TESTING.md`, manifests, current config loader/exporter code, and pinned EdgeZero source. Re-fetch the issue/comments if implementation starts after this planning revision. Record the exact source state, dependency revision, tools, and available provider environments. + +Inspect EdgeZero `edgezero-core/src/app_config.rs`, both raw walkers in `edgezero-cli/src/config.rs`, and `edgezero-adapter-spin/src/cli.rs`. Demonstrate that an opt-in plan can represent implicit required credentials with no scalar reference leaves. Validate it through real validate/push/diff consumers, not an isolated trait mock. + +Inspect Fastly service resolution, `effective_deploy_environment`, resource lookup/linking, secure provider transport, and existing provisioning in `edgezero-adapter-fastly/src/cli.rs`. Identify the smallest host-usable helper/operation to share with TS. Prove manifest/environment conflict handling, physical-name-to-ID resolution, and secure create/replace input with fictional data. Do not assume the Fastly CLI accepts a secret safely on argv or that a create operation implements replacement. Verify installed/provider-supported behavior before selecting the write mechanism. + +Check the actual TS native mappings in each adapter's `src/platform.rs`. Verify generated key/name lengths, application/component declarations, protected value sources, provider limits, and config-writer permission separation in isolated environments. The core key remains 56 ASCII bytes; the inspected Spin mapping produces a 92-byte name with the default logical store. These lengths are inputs to native tests, not proof of acceptance. + +Add counting store/backend/HTTP fixtures and a valid-checksum mutation helper using the production envelope constructor. Start with core load/pull tests and reuse existing fake services. Keep a prerequisite result table with source/path/executed/runtime evidence. Missing provider tools, permissions, or environments remain blockers, not assumed passes. + +Completion: the hook and native renderer have an executable fictional-data path; Fastly target resolution and a secure write strategy are established; unverified native/IAM facts are listed before substantial integration or tooling work. + +### 2. Implement the label-free schema and shared compiler + +Primary files are `crates/trusted-server-core/src/config.rs`, `settings.rs`, `secret_resolution.rs`, and a new `src/secret_bindings.rs` module registered in `src/lib.rs`. Use one module initially. Share ordinary settings models where safe, but separate unresolved config serialization/deserialization from resolved credential state. + +1. Add pre-normalization rejection of the twelve removed reference properties, including in dynamic DataDome config. Report only schema path and fixed migration category, even if a property contains a real secret or generated key. Reject known removed properties on disabled features too. Do not silently drop them through `serde(skip)` or unknown-field acceptance. +2. Add boolean-only `api_token_enabled` and `session_token_enabled`, default false. Preserve existing pull boolean/string normalization. Missing required enabled bindings are load failures, not feature-disable signals. Disabled uses perform no reads. Preserve malformed-parent validation and empty omitted partner arrays. +3. Remove legacy S3 reference defaults, materialize ordinary auth aliases/defaults and effective query policy, and preserve ignored deprecated store selectors and discarded Tinybird access-log placeholder behavior. +4. Define descriptors for the spec's twelve fixed CLI IDs/scopes with owning paths, activation, normalized subjects, exports, and credential validators. The descriptors represent implicit roles rather than existing secret leaves. Remove reference-string uniqueness checks; retain partner-domain and resolved-token-hash collision checks. +5. Compile private requests and targets. Implement the exact label-free `trusted-server-secret-binding-v1` framing, checked u32 lengths/counts, full SHA-256, and lowercase unpadded base32. Do not include array position, operator labels, CLI selection IDs, provider IDs, or credentials in the digest. +6. Implement shared strict endpoint parsing before URL parsing/trimming erases rejected syntax. Preserve exact handler regex/username and dataset bytes, normalized partner domains, and every bound S3 route-policy component. +7. Keep unresolved serialization feature-only. Resolved credentials never serialize to config envelopes. Existing resolved Rust fields may remain private/redacted validation state, not deserializable credential inputs or outbound authority. + +Tests cover every purpose, framing boundaries, UTF-8 byte lengths, checked integer conversion helpers, optional route markers, normalization cases, aliases/defaults, reordering, equal-context deduplication, both new flags, removed-property injection, and safe diagnostics. Confirm the revised fictional vector `ts1_2hsjuldwgmfhyotnokgn5mqrao6xnkyo3dwva33hez4vx43yvcjq` independently in Rust. Do not accept the earlier label-bearing draft vector. + +Completion: unresolved config with no secret names compiles deterministic active requests without secret reads, and serializes without values/references. Golden fixtures pin all twelve contexts and accepted/rejected normalization behavior. + +### 3. Ship the EdgeZero prerequisite and reuse deployment resolution + +Add a default-compatible opt-in active-credential lookup plan in EdgeZero core. Entries need fixed descriptor identity, concrete owning field path, logical store ID, and generated key. Exact public names follow upstream conventions. A missing hook preserves current behavior for other applications. An opted-in implicit-role app must not be forced to retain fake scalar leaves. + +Reconcile both `run_adapter_typed_checks` and `typed_secret_checks` with the plan. Keep metadata coverage, logical store declarations, selected/all-adapter validation, redaction, native-name validation, and collision checks. Validate push/diff/validate consumers and serialization. Core/TS owns activation and expected descriptor coverage; EdgeZero need not independently rediscover TS policy from raw TOML. + +Expose or extend narrow host-usable Fastly deployment helpers rather than copying private dependency internals into TS. Reuse service selection, checked physical selectors, native resource resolution, and sanitized provider transport. Preserve the existing distinction between provisioning's manifest/environment conflict check and explicit managed-deploy service selection. Do not change existing command precedence as a side effect of adding secrets commands. + +Publish the companion release and update every workspace EdgeZero pin plus `Cargo.lock` together. Record the actual tag/revision. Local path patches may support experiments only, not release claims. + +Tests cover legacy/default applications, implicit-role applications, descriptor completeness, omitted/disabled fields, duplicate exact versus colliding lookups, Spin final native names, unchanged feature-only serialization, and no credential-bearing error output. Test existing deployment inputs with manifest-only service, environment-only service, conflicting service values, explicit supported service selection, unset selector defaults, physical selector changes, and staging isolation. + +Completion: TS's real typed validate/push/diff paths handle implicit bindings, other apps retain existing behavior, and shared Fastly helpers resolve the same deployment used by existing lifecycle commands. The published pin is a release dependency. + +### 4. Build an atomic loader and prove the thin pull path + +Update `config_payload.rs`, `settings_data.rs`, `secret_resolution.rs`, `config.rs`, and runtime preparation in `settings.rs`. After checksum verification, reject removed fields, validate structure, materialize defaults, and compile all active requests/targets. Look up only generated keys in the trusted mapped default store. Validate credential bytes/representation and ordinary settings, then publish private nonserializable `LoadedConfig` with settings and resolved uses together. + +Prebuild sensitive Bearer headers and validate DataDome form/S3 material before backend creation. Preserve native error suppression, exact credential bytes through repeated preparation, all-or-nothing publication, and existing successful-state retention. No fallback to raw references, earlier draft names, alternate stores, or partially rebuilt state. + +Integrate `ec/registry.rs`, `ec/partner.rs`, and `ec/pull_sync.rs` first. Replace loose outbound URL/token authority with a validated optional resolved use associated with the partner's compiled identity. The presence of that use enables export; plain settings cannot construct it. Preserve inbound token hashing, consent, batching, rate limiting, EC-ID validation, and KV behavior. + +Build GET requests from the compiled endpoint and exactly one runtime `ec_id`. Force verified TLS independently of `proxy.certificate_check`; never follow redirects. Remove the request-builder panic path. Ensure registry rebuilds cannot manufacture a bound use from loose values. + +Tests run through production envelope reconstruction and dispatch with store/backend/HTTP spies. Mutating URL plus allowlist, partner domain, TLS toggle, or removed token property must not recover the old credential. Unauthorized loads create no backend/send. An approved initial request receiving 3xx is a separate one-send/no-follow test, not a load rejection. + +Completion: an actual load-to-pull path enforces private construction and snapshot pairing before broad sibling integration. + +### 5. Add native rendering and reviewed binding plans + +Add `Secrets` in `crates/trusted-server-cli/src/run.rs`, register `src/commands/secrets/` in `commands/mod.rs`, and reuse `src/app_config.rs` path/overlay helpers and the pure core compiler. Do not resolve secrets merely to generate a plan. If sharing requires an unresolved-loading helper, add that narrow helper rather than using resolved `Settings` as provisioning authority. + +Proposed interface: + +```bash +ts secrets plan --adapter fastly --app-config trusted-server.toml --out bindings.json +ts secrets check --adapter fastly --app-config trusted-server.toml --plan bindings.json +``` + +Support secret-free planning/rendering for Fastly, Axum, Cloudflare, and Spin. Reuse each TS adapter's real native mapping or a host-usable shared implementation with equivalence tests. Do not substitute EdgeZero's generic mapping for TS's runtime bridges. Validate final declarations and collisions with legacy/signing/management resources where namespaces overlap. + +Version the plan document separately from the key ABI. Include fixed descriptor IDs, concrete owning paths, tool-generated entry IDs for repeated roles, normalized context, derived/native identities, required declarations, compiler/ABI versions, effective unresolved config fingerprint and overlay policy, plus resolved deployment resources. Fingerprint effective serialized config, not arbitrary absolute checkout paths, so approved inputs can move between developer and CI workspaces. Do not snapshot unrelated environment variables, credentials, or provider tokens. + +Resolve the existing manifest selection and supported service flags using shared lifecycle helpers. Honor deployment environment selectors and their logical-name defaults. Resolve physical resource IDs from selected names before writing. Record service/store IDs in the plan so a renamed/recreated resource cannot silently change the target. Native resource lookup is read-only and cannot create/link stores during plan generation. + +On set/check, recompile and compare the full relevant plan, config, overlays, compiler/mapping versions, and resolved native identities. Reject mismatches before secret input or provider mutation. A plan checksum detects drift, not approval. Never evaluate shell fragments or commands stored in a plan. Repeated roles require unambiguous selection; an interactive subject list or explicit generated `--binding` ID is acceptable, not a new operator-chosen label or array index. + +`check` reports declaration/binding existence without retrieving values where supported. Distinguish unsupported checks, missing bindings, and provider errors with documented nonzero outcomes. Do not claim authentication, delivery, TLS, or warm-state validation from presence alone. + +Completion: operators can identify every binding without calculating a key, selecting a wrong implicit role, or exposing values. Changing config, overlay policy, service/store selectors, provider identities, or plan contents causes safe rejection. + +### 6. Implement secure Fastly writes and the CI entry point + +Proposed interface: + +```bash +ts secrets set tinybird.auction --adapter fastly --app-config trusted-server.toml --plan bindings.json +``` + +Use the existing Fastly service/store resolver and authenticated provider operation chosen in step 1. Show purpose, compiled subject, exact destination/dataset or route policy, selected service/physical store, and create/replace action. Confirm the reviewed operation, then accept hidden terminal input; `--stdin --yes` provides the noninteractive path. Reject unsupported adapters, inactive roles, ambiguous selections, stale plans, target conflicts, and missing provider authentication before accepting the value. + +Validate the supplied value with the role's shared validator and provider limits before mutation. Preserve bytes, including significant whitespace for credential kinds that allow it. Reject a trailing newline for Bearer roles rather than silently trimming it. The value must not appear in argv, environment copies created by the CLI, plan/declaration files, debug logs, request/response dumps, panic messages, or error-stack attachments. Reuse the existing Prebid Server secret command's interaction/testing patterns where helpful, not its AWS JSON credential format or deployment descriptor. + +Use exact resolved provider IDs for mutation. Same-context `set` is an explicit create/replace at the same deterministic name, not creation of another slot. Test repeat execution, cancellation, permission errors, invalid values, provider replacement/propagation, and failure after an uncertain write. Do not implement blind retries after uncertain outcomes or automatic writes to a newly inferred context. Keep provider errors sanitized and report whether storage was confirmed or remains uncertain. There is no deploy, resource linking, config push, recipient-side token creation, or automatic runtime refresh in this command. + +Add executable fictional-input CLI tests that intercept subprocess argv/stdin/output and count provider mutations. Add an isolated Fastly write/read-through-runtime smoke against the reviewed resource. Metadata/presence checks alone do not prove that the stored bytes are the supplied bytes. + +The CI path uses the same command, not a second provider integration. Add a tested example Bash script using `set +x`, `set -euo pipefail`, required-input checks, and `printf '%s'` piped to `--stdin --yes`. Provider authentication and store selectors come from the approved deployment environment. A protected GitHub Actions example must use trusted immutable CLI/workflow code, a reviewed config/plan revision, protected provisioning credentials, no untrusted PR execution, and serialization for a shared binding. Make the trust boundary concrete: config-only publishers cannot edit code that executes with secret-writer credentials. Use a separately controlled provisioning repository when repository permissions cannot enforce that split. + +Completion: an operator can provision Tinybird on Fastly without typing its native key; the same reviewed operation can run noninteractively without exposing values or authorizing a changed destination. Other providers have explicit unsupported-write behavior, not hidden fallback. + +### 7. Bind sibling exporters without changing unrelated behavior + +DataDome files are `crates/trusted-server-core/src/integrations/datadome.rs`, `integrations/datadome/protection.rs`, and necessary registry/startup plumbing. Remove config-populated credential-name authority and require the private loaded use for enabled Protection. Use its exact derived `/validate-request` target and safely form-encoded `Key`. Keep bypass in its separate inbound-only scope and preserve remote failure policy. Eliminate trimming of resolved bytes during repeated startup preparation. + +Tinybird files are `crates/trusted-server-adapter-fastly/src/tinybird.rs` and its `app.rs` construction. Replace `auction_sink_from_settings`/loose token-host authority with the loaded use. Build `/v0/events` plus exactly one encoded dataset query from the compiled target. Preserve batching, body-size limits, disabled no-op behavior, verified TLS, and fire-and-forget operation. Test no-follow without claiming response observation. Other adapters validate the enabled binding but do not acquire new emitters. + +S3 files are `crates/trusted-server-core/src/proxy.rs`, `s3_sigv4.rs`, route preparation in `settings.rs`, and image-optimizer preflight branches. Associate all active credential roles with the same compiled route subject and separate scopes. Check final origin/method before signing or backend creation, including HEAD/GET preflight. Host overrides and proxy rewriting cannot change bound authority. Require verified TLS for authenticated routes, preserve request-dependent object paths/permitted queries, and leave unauthenticated routes unchanged. Redact access IDs, signatures, and session/secret keys. + +Completion: no current outbound credential consumer enables itself from plain settings; all send methods/authorities/paths/queries match compiled contexts across primary and preflight paths. + +### 8. Integrate adapters, retained state, and fictional fixtures + +Update `crates/trusted-server-adapter-{fastly,axum,cloudflare,spin}/src/app.rs`, core loader callers, and Fastly `src/main.rs`. Carry the loaded snapshot through registries, exporter construction, diagnostics, fallback finalization, retained/reusable sandbox state, and post-response pull sync. A failed rebuild publishes nothing and cannot pair new settings with old credentials. In-flight requests keep their captured snapshot. + +Settings-only host/test helpers may build non-exporting state. Enabled exporters require fixtures that pass the production compiler/loader. Do not expose a public arbitrary-bound-use constructor for the integration-test crate; `cfg(test)` visibility in core does not automatically extend to that crate. + +Migrate relevant fixtures/comments in `trusted-server.example.toml`, `.env.example`, `fastly.toml`, adapter Wrangler/Spin manifests, `scripts/generate-integration-viceroy-configs.sh`, existing smoke scripts, and `crates/trusted-server-integration-tests/tests/common/config.rs` plus environment helpers. Use the compiler/renderer to generate keys, not hand-maintained hashes. Keep values fictional/raw, remove reference properties, set optional activation flags deliberately, and preserve one logical secret store. Do not edit operator-owned files or conflate request-signing fixtures with app credentials. + +Completion: all construction/reload paths enforce the loaded-use rule, examples represent the new schema, and real native lookup names match generated declarations. + +### 9. Write operator guides and rehearse migration + +Documentation is a required implementation milestone, not optional cleanup. Create these pages and link them from the Operator sidebar in `docs/.vitepress/config.mts`: + +| New guide | Required contents and verification | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs/guide/secrets.md` | Label-free TOML, purpose/identity/target model, prerequisites and permissions, fixed selectors, plan review, interactive Fastly setup, generated native provisioning for Axum/Cloudflare/Spin, presence checks, runtime verification, rotation/retirement, and troubleshooting. Rehearse every provider walkthrough with fictional values. | +| `docs/guide/secrets-ci.md` | Secure stdin Bash, protected GitHub Actions workflow, existing environment variables/service selection, trusted-code/approval boundary, immutable config/plan inputs, repeated-role selection, serialized writes, failure/retry handling, and no secrets in logs/artifacts. Parse examples and exercise the script through the real CLI with fake provider transport before isolated CI rehearsal. | +| `docs/guide/secret-binding-migration.md` | All twelve removed properties, optional-flag conversions, new-schema config and generated names/declarations, provisioning-first isolated cutover, mixed-version config separation, legacy cleanup, state recreation, same-context rotation limits, coordinated S3 maintenance, retired-context replay, recipient-side revocation, and unsafe downgrade warning. | + +Update existing `docs/guide/configuration.md`, `cli.md`, `fastly.md`, `axum-dev.md`, `cloudflare.md`, `spin.md`, `telemetry.md`, `integrations/datadome.md`, `asset-routes.md`, `collective-sync.md`, `ec-setup-guide.md`, onboarding/getting-started references as affected, `tinybird/README.md`, `TESTING.md`, example comments, CLI help, and `CHANGELOG.md`. Search by old property names and raw-reference instructions instead of mechanically editing every document. Correct the stale inline-secret description for trusted client IP. Keep request-signing/key-rotation docs clear about their separate store path. + +The Fastly guide must explain `fastly.toml` service identity, `FASTLY_SERVICE_ID` conflict behavior, `FASTLY_API_TOKEN`, `EDGEZERO_MANIFEST`, physical `__NAME` selectors, logical resource links, managed application-release deployment, and why `--staging` alone does not isolate stores. No new deployment TOML, manual hash derivation, manual logical-link creation, or profile-only authentication advice. + +The first tutorial starts from an enabled Tinybird section with no token-name property, obtains a restricted append token outside TS, reviews a generated plan, provides the value securely, provisions, recreates state, and verifies an actual approved request. Explain that TS does not mint a Tinybird token. Other providers must include complete generated-name/declaration/input steps, including Spin application/component declarations and secure providers. An Axum child process cannot export variables into its parent; demonstrate environment injection through its actual launcher instead. + +Rehearse migration against isolated resources, with a reviewed new config and existing raw values supplied securely. Old and new binaries require compatible separate config snapshots/stores during mixed rollout. New keys do not revoke old context keys. Delete retired bindings and replace retained states within an owned window, then verify old-config replay fails on fresh state. Same-context replacement has no selectable generation: use recipient overlap where supported or a maintenance cutover. Pause affected state creation/traffic during nontransactional S3 updates and verify the full set before resuming. + +Completion: an engineer following only the guides can provision a fictional deployment without inventing a secret key or guessing an environment/store, and can explain what checks do and do not prove. Every proposed interface matches implemented help/tests before docs describe it as available. + +### 10. Execute attacker, lifecycle, and native-runtime proof + +Add focused `secret_bindings` and `secret_scoping_attacker` tests. These are proposed filters; check that they execute nonzero tests. Use valid-checksum mutated envelopes and immutable native fixtures for config-only attacks. Keep separate trusted-provider/input failure tests. + +| Area | Mutations or scenario | Required evidence | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Removed inputs and scope confinement | Inject old reference properties containing publisher/inbound/handler/native/generated keys; enable a different purpose where only another scope is provisioned | Schema-only rejection or missing derived binding; no source-purpose lookup, backend, or HTTP request | +| Identity and target binding | Change partner domain, handler regex/username, pull URL plus allowlist, DataDome target, Tinybird host/dataset, or every S3 route-policy component | Changed context never reads the old key; zero unauthorized sends; complete already-provisioned identity selection is a positive case | +| Encoding/native mapping | Golden vectors, optional markers, defaults, reordering, aliases, final-name collisions, provider limits | CLI/runtime/native agreement; no silent truncation, renaming, merging, or legacy retry | +| Activation and structure | Both new boolean flags/defaults, missing enabled credential, disabled uses, absent arrays/objects, malformed parents, removed property on disabled feature | Exactly the required active lookups; missing required value never silently disables auth; structural/migration errors occur before reads | +| Transport and representation | Invalid Bearer/header bytes, DataDome form characters, S3 signing/preflight, Host override, TLS toggle, bad certificate, backend reuse, all redirect statuses | Preflight rejection before backend where applicable; verified TLS; one send per approved operation; zero sends to Location or wrong authority | +| Provisioning | Alter plan/config/overlay/compiler/manifest/service/store resource; ambiguous role; unsupported adapter; cancellation; provider auth/write failure; uncertain replacement | No preflight-failure mutation or secret input; exact reviewed ID on success; safe outcomes and no value on argv/logs/artifacts | +| Snapshot and lifecycle | Failed rebuild, old/new/in-flight state, same-name replacement, policy change/retirement, S3 maintenance, mixed-version config, rollback | No cross-snapshot pair; fresh state observes intended binding; replay of retired context fails after deletion; no immediate global refresh claim | +| Operator and CI | Guide walkthroughs, protected stdin workflow, deployment selectors, default-store fallback, missing CI inputs, repeat execution | Same command path and reviewed plan; no new names on repeat; isolated resources, no production mutation, safe transcripts | + +Use TLS fixtures with fictional DNS names and isolated trusted test roots where the runtime permits it. Never weaken production TLS policy to make a test pass. Separate TLS handshakes from credential-bearing HTTP requests, and separate a valid initial request returning 3xx from pre-request load rejection. Capture sensitive test values in memory and redact assertion failures. + +Run actual secret reads and HTTPS/no-follow flows on Axum, Viceroy/Fastly with isolated platform smoke, Wrangler/workerd, and Spin. Extend existing `scripts/smoke-{axum,fastly,cloudflare,spin}.sh` and integration harnesses rather than inventing a second full framework. Cloudflare/Spin host route tests and Noop stores do not execute native bindings. Test provider permissions, secure input, native limits, and mapping separately from simulator transport. An unavailable environment blocks its proof. + +Completion: every spec acceptance criterion maps to named passing tests or an explicit release blocker. No security release claim rests only on source inspection, compilation, model hashes, or host route parity. + +### 11. Run combined checks and decide release readiness + +At each coherent step run focused checks for the behavior just changed. Reuse current passing evidence while inputs are unchanged. Before implementation handoff, inspect the combined diff for raw app-config lookups, bypass constructors, loose credential consumers, unsafe subprocess arguments, logging, leftover draft/reference behavior, and local dependency patches. + +Existing focused commands include: + +```bash +cargo test -p trusted-server-core secret_resolution +cargo test -p trusted-server-core config_payload +cargo test -p trusted-server-core ec::registry +cargo test -p trusted-server-core ec::pull_sync +cargo test -p trusted-server-core integrations::datadome +cargo test -p trusted-server-core s3_sigv4 +cargo test -p trusted-server-core proxy::tests +./scripts/test-cli.sh +``` + +After adding the proposed suites: + +```bash +cargo test -p trusted-server-core secret_bindings +cargo test -p trusted-server-core secret_scoping_attacker +cargo test-fastly secret_bindings +cargo test-fastly tinybird +cargo test-axum secret_bindings +cargo test-cloudflare secret_bindings +cargo test-spin secret_bindings +``` + +These package-scoped core commands run natively with the inspected Cargo config. Fastly uses Viceroy; Cloudflare/Spin aliases above are host checks, not native-provider proof. Run the real smoke/rehearsal matrix separately. Before final implementation handoff, run the repository's target-matched gates: + +```bash +cargo fmt --all -- --check +cargo check-fastly +cargo check-axum +cargo check-cloudflare +cargo check-spin +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo test-fastly +cargo test-fastly-reuse +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +cargo test -p trusted-server-core --test template_build_digest +cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings +scripts/lint-wasm-clock.sh +(cd crates/trusted-server-js/lib && npx vitest run && npm run format && node build-all.mjs) +(cd docs && npm run lint && npm run format && npm run build) +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +``` + +Use the host-target CLI helper and pinned tools from the repository. Do not run cross-runtime `cargo clippy --workspace --all-features`. Install missing dependencies through repository conventions. Parse TOML/JSON/YAML examples, check Bash syntax, test all new CLI examples against implemented help/fixtures, and run the existing CLI URL-host linter with its actual current syntax if documentation tooling requires it. Do not claim an empty test filter or unavailable linter passed. + +## Release evidence and blockers + +Keep one evidence record with `check | tested revision and input fingerprints | environment | outcome | log`. Separate source/path inspection, host execution, simulator results, and native deployed-runtime proof. + +The two highest-risk facts remain unproven until implementation executes: every enabled export path is confined to one loaded credential/target snapshot, and actual native mappings/transports/provisioning preserve the reviewed authorization. The cheapest first proofs are the loader/store/backend spy slice and the real EdgeZero/native-renderer/Fastly-write prerequisite slice. + +Release requires the published dependency pin, all twelve active-role tests, zero unauthorized sends, real native reads and TLS/no-follow tests on all four runtimes, secure Fastly provisioning/replacement proof, tested CI input/trust boundaries, operator-guide rehearsals, migration/retirement/rollback evidence, and verified config-only versus secret-writer permission separation. Missing native environments, unsupported provider replacement constraints, or unavailable coordinated S3 cutover are blockers, not reasons to restore arbitrary names or raw-key fallback. + +Planning and documentation checks do not establish these runtime results. Stop before implementation until the user requests it. diff --git a/docs/superpowers/specs/2026-10-16-issue-1037-context-derived-secret-bindings-design.md b/docs/superpowers/specs/2026-10-16-issue-1037-context-derived-secret-bindings-design.md new file mode 100644 index 0000000000..a11b37e642 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-issue-1037-context-derived-secret-bindings-design.md @@ -0,0 +1,347 @@ +# Context-derived secret bindings + +## Decision and status + +For [issue #1037](https://github.com/IABTechLab/trusted-server/issues/1037), keep credentials as raw native secret values. Derive each physical secret key from its schema-owned purpose, actual identity, and approved destination or route policy. Operators configure the feature and provide its credential, but do not invent a reference label or physical key. Provisioning a value at the generated key authorizes that particular use. Changing an authorization-relevant input selects a different key, not the same credential with new permissions. + +Implementation is under review on `feature/1037-secret-bindings`. The shared compiler, atomic loader, exporters, secure Fastly CLI, guides, and native fixture generation are implemented. This is not a released security fix. Native HTTPS/no-follow behavior, IAM separation, protected CI execution, and migration rehearsals remain release gates. The [implementation plan](../plans/2026-10-26-issue-1037-secret-bindings-implementation-plan.md) defines the work sequence. + +This revision replaces the earlier label-bearing draft before any binding ABI has shipped. There is no compatibility lookup for that draft's names. + +The alternative considered was a protected JSON record containing the credential and its authorization, selected by new slot and owner IDs. Both approaches can enforce the required boundary. Context-derived bindings avoid a second stored policy representation, a credential-record parser, and new configuration identifiers. They instead make deterministic naming and normalization a provisioning compatibility contract. Operators need a reviewed secret-free binding plan to explain otherwise opaque names and retire former authorizations. + +## Issue and investigation base + +The issue requires field-owned secret scopes on Fastly, Axum, Cloudflare, and Spin. It separately requires preventing cross-purpose secret substitution and preventing mutable-config retargeting of legitimate pull credentials. It requires migration guidance and config-store-only attacker tests, and excludes a new config-envelope signature/authentication layer. + +The issue was read in full on 2026-10-16, including its empty comments collection. It was open with `updatedAt=2026-10-16T20:01:20Z`. Investigation base is `5bc38862512c5c71085b5052bc3e528ee9b9987d`. + +Source observations at that revision: + +| Source under `crates/` | Current behavior relevant to this design | +| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `trusted-server-core/src/secret_resolution.rs`, `resolve_leaf` | Passes configured strings directly to the default secret store. Resolution clones and commits only on success; native lookup errors are suppressed. | +| `trusted-server-core/src/config_payload.rs`, `settings_from_config_blob` | Verifies the checksum, removes inactive references, resolves secrets, then constructs and validates settings. | +| `trusted-server-core/src/config.rs`, `TrustedServerAppConfig` | Normalizes during deserialization and describes twelve secret paths without authorization context. | +| `trusted-server-core/src/ec/registry.rs`, `validate_config_for_deploy`, `from_config` | Deploy validation rejects reused inbound reference strings; runtime separately rejects duplicate resolved token hashes. Pull validation requires its mutable allowlist. | +| `trusted-server-core/src/settings.rs` | S3 defaults and aliases, Tinybird normalization, and effective asset-route query policy must be materialized before derivation. Existing trimming must not modify labels or resolved credentials. | +| `trusted-server-core/src/ec/pull_sync.rs`, `dispatch_pull_sync` | Uses loose URL/token fields, inherits mutable proxy TLS policy, creates a backend before building the Bearer header, and has a request-builder `expect`. | +| `trusted-server-core/src/integrations/datadome/protection.rs`; `trusted-server-adapter-fastly/src/tinybird.rs` | Export credentials to config-derived targets. Tinybird currently drops its pending handle after starting a fire-and-forget send. | +| `trusted-server-core/src/proxy.rs`; `trusted-server-core/src/s3_sigv4.rs` | S3 exports its access ID and optional session token, signs the final target, and has signing/preflight paths beyond the primary GET. | +| Adapter `app.rs` files; `trusted-server-adapter-fastly/src/main.rs` | Settings-only builders, registry construction, retained state, diagnostic loads, and finalization fallback reloads all need the loaded-use boundary. | + +At the investigation base, EdgeZero v0.0.9 at `f7176742d46aa4d1a04ce4588b606afda388fd30` exposed only path, kind, and optionality in secret metadata. Its CLI walks raw references in two places, and its Spin validator applies native-looking syntax and lowercased duplicate checks to those references. A companion lookup-plan change is required below. + +These are source observations, not executable security proof. An isolated fictional encoding model supports the naming contract, but no shipping Rust implementation or provider runtime has verified it. + +## Threat model and invariant + +The attacker can replace the app-config blob, change ordinary fields, and recompute a valid checksum. Assume they know native key names and complete provisioned identities. They cannot change the executable, trusted deployment mappings, native secret contents, native bindings, or their access controls. + +For every active secret field, core determines its purpose and authorization context. The only allowed lookup is the derived key for that context. An outbound credential is sent only using the same compiled target that determined its lookup. A missing or invalid binding fails loading before backend creation or outbound requests. + +The security authority is the set of native keys provisioned by a trusted operator. The hash is not a secret, signature, encryption, or authentication of config writers. Calculating another key name grants nothing without a credential provisioned there. + +This protects against config-only reassignment, not malicious code with process-wide secret-store access. It does not provide general config integrity, availability, immutable partner membership, or immediate revocation in retained states. Selecting another complete provisioned identity remains allowed. Secret-store writers, deployment administrators, compromised approved endpoints, trusted DNS/CA compromise, and credential values deliberately reused across separately authorized uses are outside the guarantee. Operators must not provision another role's credential value into an outbound binding. + +## Config contract + +Remove operator-chosen secret references from unresolved app config. The twelve former secret leaves in the inventory below identify roles being migrated, not fields that remain configurable. Do not accept inline values, arbitrary labels, generated keys, `slot:` markers, `secret_owner_id`, physical binding overrides, or new per-field store selectors. Reject the removed reference properties with a schema-path-only migration error whenever present, including on disabled features, rather than silently ignoring a likely incomplete migration. Existing deprecated store selectors remain deserialize-only, ignored, and safely warned about. Tinybird's already discarded `access_token_secret` remains discarded and `access_enabled=true` remains rejected. + +An enabled feature implies its required credential. Ordinary configuration supplies its identity and destination: + +```toml +[tinybird] +enabled = true +api_host = "api.tinybird.example.com" +auction_dataset = "auction_events_raw" + +[[ec.partners]] +name = "Example partner" +source_domain = "partner.example.com" +api_token_enabled = true +pull_sync_enabled = true +pull_sync_url = "https://sync.example.com/pull" +pull_sync_allowed_domains = ["sync.example.com"] +``` + +This is the proposed breaking schema, not configuration accepted by today's binary. Tinybird needs no token-name property. Partner inbound authentication uses a new boolean `api_token_enabled`, defaulting to false, to preserve the former optional-token behavior. Enabling it requires the partner's inbound binding; a missing credential never silently disables authentication. Pull sync keeps its existing activation flag and requires its separate pull binding. + +For S3 auth, `access_key_id` and `secret_access_key` become implicit required roles. The new boolean `session_token_enabled` under route `auth` defaults to false; when true it requires the session-token binding. No lookup probes for optional bindings. Both new flags accept booleans only. The existing boolean/string pull activation is normalized consistently without treating `"false"` as true. + +Publisher proxy and EC passphrase credentials are implicit required app roles. Each handler requires its password binding. Presence of trusted-client-IP configuration requires its shared-secret binding. Existing Tinybird, DataDome Protection, and bypass activation determine their required roles. Disabled uses make no secret reads; malformed present structures remain errors. An omitted partner array is empty. Deploy validation, lookup planning, and runtime loading share these rules. + +Materialize ordinary defaults and effective S3 route/query policy before planning. Remove legacy S3 reference defaults instead of treating them as credential bytes. `s3_sig_v4` and `s3_sigv4` use identical policy. Preserve ordinary collection identity validation, including duplicate normalized partner-domain rejection and runtime duplicate resolved-token-hash rejection. Replace reference-string uniqueness checks with compiled lookup identity checks. Replace the mandatory mutable pull-allowlist rule with the endpoint assertion contract below. + +Separate unresolved configuration from resolved credential state. Internal resolved fields may temporarily retain their existing Rust names for validation, but config deserialization cannot populate them and config serialization cannot emit them. A plain `Settings` object is not proof of a loaded credential binding. + +Correct existing guide statements describing `trusted_client_ip.shared_secret` as inline during implementation. Runtime already resolves that value from the store; removing its reference field is not an inline-to-store migration. + +## Deterministic naming contract + +One TS-owned policy module compiles each active credential role into a private request containing a fixed executable scope code, a normalized subject vector, and a normalized export vector. Only core constructs this request. Adapters map native identities and perform lookup, not authorization. The purpose is fixed in code, but an outbound credential's physical key is not fixed independently of its destination. + +Derive active credential metadata and the CLI lookup plan from the same descriptors. Descriptors identify the owning config path, fixed purpose, activation, subject, and export policy even though no reference leaf exists there. Unknown metadata or unsupported descriptor kinds fail closed. Keep the generic `PlatformSecretStore` interface for native access and separate signing/management uses; do not create an authorization engine in each adapter. + +### Encoding and versioning + +Use full SHA-256, not a truncated digest or language-dependent hash: + +```text +LP(s) = u32 big-endian UTF-8 byte length || UTF-8 bytes +VEC(xs) = u32 big-endian element count || LP(x1) || ... || LP(xn) + +payload = ASCII("trusted-server-secret-binding-v1\0") + || LP(scope) + || VEC(subject) + || VEC(export) + +key = "ts1_" || lowercase_unpadded_RFC4648_base32(SHA256(payload)) +``` + +The domain separator ends with one NUL byte. The base32 alphabet is `abcdefghijklmnopqrstuvwxyz234567`. The complete key is 56 ASCII bytes. Check every string byte length and vector element count before conversion to u32; overflow is a safe configuration error, never truncation. Use UTF-8 byte lengths, not character counts. + +Element counts and length prefixes distinguish all components, including empty optional values. Do not substitute delimiter-concatenated strings, generic object serialization, array indexes, or provider-specific hashing. The restricted key alphabet avoids Axum case/replacement aliases. Spin must still apply its existing native store/key mapping. + +The encoding, subject/export shapes, defaults, and normalization rules together form the v1 provisioning ABI. Pin shared golden fixtures before integration, including every scope, optional markers, UTF-8 subject components, and accepted/rejected normalization cases. CLI plans, unresolved serialization, runtime loading, and actual send targets must agree. A library upgrade must not silently change v1 names or expand endpoint equivalence. Intentional incompatible changes require a new version/namespace and migration, not an old-key retry. + +One fictional model vector to confirm independently in the shipping implementation is: + +```text +scope: ecpull +subject: ["partner", "partner.example.com"] +export: ["pull", "https://sync.example.com/pull"] +key: ts1_2hsjuldwgmfhyotnokgn5mqrao6xnkyo3dwva33hez4vx43yvcjq +``` + +A matching model vector proves only those encoding bytes, not native compatibility, collision resistance, or exporter confinement. + +### Complete context inventory + +The CLI selectors are fixed descriptor IDs, not operator-chosen names or additional hash inputs. Repeated roles also require selection of a compiled subject from the plan. + +| Former reference property | CLI selector | Fixed scope | Subject | Export | Activation | +| --------------------------------------------------------------------- | ------------------ | ----------- | ------- | --------------------------- | ------------------------------------------- | +| `publisher.proxy_secret` | `publisher.proxy` | `proxy` | App | None | Required | +| `ec.passphrase` | `ec.passphrase` | `ecpass` | App | None | Required | +| `ec.partners[*].api_token` | `ec.inbound` | `ecin` | Partner | None | `api_token_enabled=true` | +| `ec.partners[*].ts_pull_token` | `ec.pull` | `ecpull` | Partner | Pull endpoint | Pull sync enabled | +| `handlers[*].password` | `handler.password` | `handler` | Handler | None | Each configured handler | +| `trusted_client_ip.shared_secret` | `clientip.shared` | `clientip` | App | None | Trusted-client-IP configuration exists | +| `tinybird.auction_token_secret` | `tinybird.auction` | `tinybird` | App | Events endpoint and dataset | Tinybird auction telemetry enabled | +| `integrations.datadome.server_side_key_secret_name` | `datadome.server` | `ddserver` | App | Protection endpoint | Integration and Protection enabled | +| `integrations.datadome.protection_test_bypass.credential_secret_name` | `datadome.bypass` | `ddbypass` | App | None | Integration, Protection, and bypass enabled | +| `proxy.asset_routes[*].auth.access_key_id` | `s3.access-id` | `s3id` | Route | S3 | S3 auth | +| `proxy.asset_routes[*].auth.secret_access_key` | `s3.secret` | `s3secret` | Route | S3 | S3 auth | +| `proxy.asset_routes[*].auth.session_token` | `s3.session` | `s3sess` | Route | S3 | S3 auth and `session_token_enabled=true` | + +Vectors have these fixed shapes: + +```text +App: ["app"] +Partner: ["partner", normalized_source_domain] +Handler: ["handler", exact_validated_path_regex, exact_username] +Route: ["route", normalized_prefix, normalized_https_origin, + pattern_presence, pattern_value, target_presence, target_value, + normalized_region, effective_origin_query] + +None: ["none"] +Pull: ["pull", normalized_endpoint] +DataDome: ["datadome", normalized_validate_request_endpoint] +Tinybird: ["tinybird", normalized_events_endpoint, exact_dataset] +S3: ["s3"] +``` + +For optional route pattern/target fields, presence is the literal `absent` or `present`; absence has an empty value component. Encode effective query policy as the literal `preserve` or `strip`. Use effective normalized route values after defaults, not raw aliases. Partner domains use the existing normalized source-domain function. Handler paths use exact validated regex strings, not regex equivalence. Dataset must be nonempty and is not silently trimmed into another value. + +The subject is the identity, so there is no separately mutable owner ID. Reordering entries leaves keys unchanged. Changing a handler username, partner domain, or authenticated route policy changes keys. Equal purpose, subject, and export intentionally share one binding; existing collection uniqueness rules still apply. There is one value per effective context, not a configurable choice of credential generations. Copying a whole provisioned identity selects that identity, not another identity's credential. + +All three S3 fields use the same compiled route subject, but distinct scopes. Bind prefix, origin, regex/rewrite policy, region, and effective query policy. Request-dependent object paths and permitted incoming queries remain dynamic; this is not a bucket/object authorization system. Image-optimizer behavior must not change the bound effective query policy. Any future send-affecting field requires a context review, not an assumption that the current inventory remains complete. + +## Destination and send policy + +The issue explicitly requires pull recipient protection. This design also retains protection for DataDome's own server key, Tinybird's own append token/dataset, and S3's own route credentials. Those are verified sibling export paths, broader than the literal pull-specific acceptance criterion. Do not silently remove them or claim that cross-scope names alone prevent their own-credential retargeting. S3 route/signing integration should be its own testable milestone. + +### Endpoint normalization + +For pull, DataDome, and Tinybird base endpoints, use one shared core parser: + +- Require absolute HTTPS with an ASCII DNS hostname. Reject IP literals, internationalized host input, trailing-dot hosts, userinfo including empty userinfo, queries including empty `?`, fragments, backslashes, whitespace, and controls. +- Normalize scheme/host case and remove explicit port 443. Preserve other valid ports. An empty path becomes `/`. +- Paths contain only ASCII unreserved characters and `/`. Reject percent escapes and `.` or `..` segments before URL parsing can discard them. Preserve path case, trailing slash, and repeated slash distinctions. +- Hash and use the complete normalized scheme, hostname, port, and path. Host-only approval, suffix matches, and mutable allowlists are insufficient. + +The same parser supplies both derivation inputs and actual exporter targets. Never hash one representation and rebuild the target from loose settings later. + +### Export-specific rules + +- Pull uses GET and appends exactly one validated runtime `ec_id` through URL query APIs. Config cannot supply fixed query parameters. The legacy allowlist may be omitted; a nonempty allowlist may contain only the endpoint's normalized hostname. It is a consistency assertion, not authority. Changing URL and allowlist together selects a new, unprovisioned key. +- DataDome binds the exact derived `/validate-request` endpoint and uses POST with a safely form-encoded credential. +- Tinybird binds the exact HTTPS `/v0/events` endpoint and dataset, then builds one encoded `name` query from that dataset. It uses POST NDJSON. Actual event emission remains Fastly-specific unless another adapter already implements it; this work does not add telemetry exporters. +- S3 binds an HTTPS origin and route policy. Apply the same hostname/port checks and existing prohibition on origin paths/queries. Check final target origin before signing or backend creation. GET/HEAD SigV4 only. Host overrides and general proxy rewriting cannot change the bound authority. Unauthenticated routes retain their existing behavior. + +All credential-bearing flows require certificate and hostname verification independently of `proxy.certificate_check`. Backend reuse must not inherit weaker TLS settings. Each HTTP operation sends once and never follows redirects, even on the same host. Legitimate S3 HEAD/GET preflight operations may remain separate operations, all confined to the same bound origin; "one send" does not prohibit that existing flow. + +Exporters that observe a response treat 3xx as unsuccessful and never retry at `Location`. DataDome's recognized 301/302 challenge verdicts retain their existing challenge/failure handling; they never trigger an HTTP client redirect. Preserve Tinybird's existing fire-and-forget behavior in this change. Its guarantee is no automatic redirect following, not observed classification of a response whose pending handle is dropped. Claiming observed delivery/3xx failure would require a separately specified response-observation change. + +Validate header, form, and signing representation before backend creation. Pull and Tinybird require nonempty RFC 6750 b64token values plus existing length/placeholder checks. Build sensitive headers without panics. Safely encode DataDome form data, validate S3 header material, and redact access IDs as well as secret/session keys. Preserve credential bytes exactly. Native provider value limits still apply; there is no new JSON-record size limit. + +## Loading and runtime boundary + +### Unresolved and resolved normalization + +Separate unresolved config normalization from credential handling. Validate original endpoint syntax before URL parsing or trimming can erase forbidden input. Reject removed reference properties before unresolved serialization can discard them. Materialize S3 defaults and effective route/query policy before planning; never hash an incomplete raw tree. + +After lookup, preserve credential bytes through settings construction, runtime preparation, and exporter construction. Address current S3 normalization, Tinybird dataset normalization, DataDome startup normalization, and repeated `prepare_runtime` calls explicitly. Existing normalization cannot be reused unchanged if it trims a resolved credential or changes the compiled target. Apply activation consistently before secret reads without letting inactive cleanup hide malformed present structures. + +### Private construction and snapshots + +The loader returns a nonserializable `LoadedConfig` containing settings and outbound bindings as one successful snapshot. Its fields are private; access is read-only. A private resolved-use representation couples credentials, purpose/subject association, compiled target, and fixed send policy. Only successful core loading constructs enabled uses. No four separately named public exporter types are required. + +Registry/state builders and exporter constructors consume loaded bindings, not loose credential/URL fields in plain `Settings`. A settings-only helper may construct disabled exporters, but cannot enable credential export without a validated use. Resolved-settings shadow fields may remain temporarily for runtime validation, with redaction, but are not outbound authority. CLI serialization uses only unresolved config; loaded objects never serialize into envelopes. + +Prove this boundary first through a thin production load/pull-export path before substantial sibling integration. Cover all adapter state builders, registry rebuilds, retained Fastly state, diagnostic loads, Fastly fallback finalization, Tinybird construction, DataDome constructors, and S3 signing/image-optimizer preflight branches. Do not leave private construction as an unenforced convention. + +After checksum verification, validate unresolved structure, materialize defaults, and compile active requests and targets. Read only derived keys in the trusted mapped default store, validate values and ordinary settings, then atomically publish the snapshot. Preserve existing successful-state retention, but never combine settings from one load with credentials/targets from another. An in-flight request retains its captured state. No global credential cache, watcher, TTL, or new reload mechanism is introduced. Memoization is limited to identical requests within one load. + +```mermaid +flowchart TD + A[Untrusted config with valid checksum] --> B[Validate and compile purpose identity and target] + B --> C[Derive reserved native key] + P[Trusted operator provisions raw credential] --> D[Native secret lookup] + C --> D + D -->|Present and valid| E[Publish credential and compiled target together] + D -->|Missing or invalid| X[Reject load with zero backend and HTTP calls] + E --> F[Verified TLS and one send per operation to the bound target] +``` + +Errors expose only safe schema paths and fixed categories. Do not attach native lookup diagnostics, credentials, URLs/queries, usernames, request bodies, or header values. Provisioning commands may deliberately show reviewed subject/target metadata as specified below; runtime errors must not. Audit `Debug`, traces, request-builder errors, panic paths, form payload logging, and header sensitivity. No raw-key retry, alternate-store retry, plaintext fallback, or prefix/suffix bypass exists. + +Signing uses its existing fixed signing-store identity and separate `current-kid`; management uses its fixed credential identity and API host. Keep these separately audited paths outside app-config derivation. Do not redesign signing management or accidentally route app-config fields through a generic raw-reference extractor. + +## Native provisioning and CLI + +Keep the logical store `trusted_server_secrets`. Trusted runtime/deployment mappings select its physical store. Separate field stores are unnecessary and would not isolate Cloudflare, whose TS adapter ignores the store argument. + +| Adapter | Provisioning and proof | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly | Raw credential at the derived key in the trusted mapped store. Preserve startup and reload mappings. Verify actual Viceroy reads plus isolated platform smoke and TLS/backend reuse. | +| Axum | Raw credential in the environment variable produced by TS's store/key mapping, which uppercases and replaces hyphen/dot/space. Verify real environment reads and final-name collisions with fixed trusted store identity. | +| Cloudflare | Raw Worker secret at the derived key. Verify actual Worker binding lookup, HTTPS/no-follow behavior, and a config-only publishing role without Worker deployment/secret rights. A constrained publishing service may be necessary. Host tests do not execute `worker::Env`. | +| Spin | Raw value supplied securely to the application/component variable produced by TS's existing store/key escaping. Declare both levels and test actual component lookup. `secret=true` alone is not encrypted storage; use a protected provider. Keep one logical store to avoid the current single-secret-store gate. | + +A host-usable renderer must share TS adapters' actual native mapping implementation or prove equivalence. EdgeZero's generic secret-store behavior is not interchangeable with these TS bridges. Reserve the generated namespace and reject final native collisions with other declarations, including legacy/signing/management bindings where they share a flat namespace. Do not silently truncate, rename, or merge distinct contexts. Deduplicate only after confirming exact request and store/key equality. + +### Lookup-plan prerequisite + +The pinned EdgeZero CLI's `run_adapter_typed_checks` and `typed_secret_checks` both walk raw leaves; validate, push, and diff consume those paths. Its Spin adapter lowercases those leaves, validates native-looking names, and rejects duplicates without TS's store/key escaping. Old labels passing this check would still be validation of the wrong names. + +Add a default-compatible opt-in hook representing implicit active credentials with descriptor ID, owning `field_path`, `store_id`, and derived key. The exact public API can follow upstream conventions. For TS plans, replace both raw-leaf walkers with active/defaulted compiled lookups while retaining metadata and logical store declaration validation. The hook must not require a credential reference leaf to exist, reinsert dummy labels, or skip required roles whose old fields have disappeared. Native validation uses generated lookups and final TS rendering; unresolved serialization contains feature configuration only. Other applications retain existing behavior when the hook is absent. Do not disable Spin validation globally. + +Before substantial exporter/tooling work, demonstrate the hook through real validate/push/diff consumers and TS's host-usable renderer with fictional data. Cover omitted optional fields, defaults, inactive cleanup, metadata completeness, repeated identical requests, distinct contexts, and final binding collisions. Publish and pin the companion dependency before claiming CLI parity. + +The 56-byte key does not prove provider acceptance. Under the inspected TS Spin mapping, its name with `trusted_server_secrets` is 92 bytes. Verify actual generated names, declarations, value-size limits, secure provisioning, and config-writer IAM separation in isolated native environments. Unavailable environments remain blockers, not assumed passes. + +### Reuse existing deployment configuration + +Do not introduce `deploy/production.toml`, a new `--target` descriptor, or a second source of service/store mappings. TS already delegates lifecycle commands to EdgeZero. Reuse `edgezero.toml`, the adapter manifest, existing service-selection rules, and deploy-process or GitHub Environment variables. + +On Fastly, `fastly.toml` already supplies `service_id`. Provisioning also supports `FASTLY_SERVICE_ID` and rejects a conflict with the manifest. Managed deployment supports explicit `--service-id`; otherwise it resolves the verified manifest/environment selection. Expose any shared resolution needed for secrets through the companion dependency rather than maintaining another TS implementation. Existing physical store selectors are: + +```text +EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME +EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME +EDGEZERO__STORES__KV__TRUSTED_SERVER_KV__NAME +``` + +These select names, not provider IDs. Resolve and record the exact physical resources with the existing provider machinery. An unset selector retains the existing logical-name default, which must be visible in the plan. `FASTLY_API_TOKEN` supplies authentication for managed direct API calls; a CLI profile alone does not replace it. Honor existing manifest selection, including `EDGEZERO_MANIFEST`, without adding conflicting path precedence. + +`ts provision --adapter fastly` creates or reuses resources. Managed `ts deploy` links the selected physical stores to the target service version under logical IDs such as `trusted_server_secrets`. The runtime opens that logical ID and does not read deploy selectors from a Config Store. `--staging` is not a store selector; production and staging must supply different physical-store mappings where isolation is required. Secret writes do not deploy, activate a version, create resource links, or refresh retained state. + +### First-release operator tooling + +Provide a secret-free plan and native rendering/checking for all four adapters, plus a context-aware Fastly `set` workflow in the first release. The command interface below is proposed, not available today: + +```bash +ts secrets plan --adapter fastly --app-config trusted-server.toml --out bindings.json +ts secrets set tinybird.auction --adapter fastly --app-config trusted-server.toml --plan bindings.json +ts secrets check --adapter fastly --app-config trusted-server.toml --plan bindings.json +``` + +Reuse existing app-config path and overlay semantics. The plan must record the effective unresolved config identity, overlay policy, compiler/ABI version, descriptor IDs, owning schema paths, normalized subject/export contexts, derived keys, final native identities/declarations, and resolved deployment resources. Keep a complete reviewed plan with the release/config records. Plan creation does not authorize its contents, and its checksum is not an approval signature. + +`set` recompiles from the supplied config and current deployment inputs and rejects a stale, tampered, unsupported-version, or mismatched plan before reading a credential or making a provider mutation. Compare the full relevant plan and resolved provider identities, not just the selected entry's key. Select only an active compiled descriptor. Singleton `tinybird.auction` needs no extra name. For repeated roles, list the human-readable compiled subjects interactively; noninteractive callers must supply an unambiguous tool-generated `--binding` entry ID from that plan. Never choose the first array entry or accept a raw key/store override. + +Show the selected purpose, subject, exact endpoint/dataset or route policy, service, physical store, and intended create/replace action for confirmation. This is intentional metadata output, not a runtime diagnostic. A changed target or provider resource requires a new reviewed plan. Keep this metadata access-controlled even though it contains no credential values. + +Read the raw value through a hidden prompt by default or `--stdin` for automation. Require `--yes` with stdin; it replaces interactive approval only. Preserve bytes exactly, reject invalid credential representation and provider value limits before mutation, and fail without panics. Do not accept a value argument, print values, place them on subprocess argv, or write them to tracked files. Reuse provider tooling only if it supports secure input and sanitized errors; otherwise use a narrow adapter operation with equivalent handling. Do not execute commands embedded in a plan. + +Resolve native IDs before mutation and use those exact IDs for the write. Report create versus replacement safely. Re-running an identical logical write must not allocate new names. Verify provider replacement and propagation behavior in isolated tests; report uncertain outcomes without automatically granting another context or deleting the former binding. S3 multi-key writes are not transactional. Do not infer immediate fleet-wide activation from successful storage. + +`check` validates plan/config/deployment agreement and native binding/declaration existence where the provider exposes it without reading values. It must distinguish missing bindings, unsupported checks, and provider errors. It does not prove credential validity, external delivery, TLS behavior, or warm-state refresh. Unsupported direct `set` adapters fail explicitly before accepting input. Axum, Cloudflare, and Spin get generated native identities/declarations and tested secure native-tool instructions; do not imply that a subprocess can export variables into its parent shell or add a new persistent credential service. + +Native tools may still perform deletion and advanced provider operations using generated identities from the reviewed plan. No credential discovery, automatic rotation, background reconciliation, protected-record parser, or persistent runtime authorization/inventory state is introduced. This narrow explicit write workflow replaces the earlier draft's prohibition on all provider writes, not its exclusion of a general secret-management system. + +### Automation and provisioning guides + +A dedicated trusted provisioning workflow can call the same command through stdin. The following Bash example is proposed and assumes the provider credential and reviewed deployment selectors are already supplied by the environment: + +```bash +set +x +set -euo pipefail +: "${TINYBIRD_AUCTION_TOKEN:?Missing Tinybird credential}" +: "${FASTLY_API_TOKEN:?Missing Fastly provisioning credential}" +printf '%s' "$TINYBIRD_AUCTION_TOKEN" | + env -u TINYBIRD_AUCTION_TOKEN ts secrets set tinybird.auction \ + --adapter fastly --app-config trusted-server.toml \ + --plan bindings.json --stdin --yes +``` + +Do not provision automatically on each ordinary config push or from the live mutable blob. CI is a trusted secret writer and must run approved CLI/workflow code against a reviewed revision and plan. Use protected provisioning credentials and an approval boundary that config-only publishers cannot bypass, a separate admin-controlled repository where necessary, and serialized writes to the same binding. Do not expose these credentials to untrusted pull-request jobs. An environment approval does not make attacker-controlled workflow code safe. Missing inputs, changed overlays, changed service/store selectors, or changed config/plan must fail before provider mutation. CI logs and artifacts must not contain credential values. + +As implementation deliverables, add dedicated provisioning, CI, and migration guides and update configuration, CLI, Fastly, Axum, Cloudflare, Spin, Tinybird, DataDome, and S3 documentation plus examples and navigation. Include complete label-free TOML, interactive input, secure Bash stdin, a protected GitHub Actions workflow, environment-variable tables, provider-native walkthroughs, existence checks versus runtime smoke tests, rotation/retirement, and troubleshooting. Examples use fictional `example.com` endpoints and never real deployment identities. Mark proposed interfaces as proposed until executable help/tests and isolated rehearsals establish their behavior. + +## Migration, rotation, and retirement + +1. Inventory active uses and approved subjects/destinations without printing values. Separate cross-role credential reuse and verify config writers cannot modify native secrets or deployment bindings. +2. Create a reviewed new-schema config. Remove all twelve legacy reference properties. Set `api_token_enabled` for partners formerly using inbound tokens and `session_token_enabled` for S3 routes formerly using session tokens. Keep feature activation explicit; absence of a secret is not a disable signal. +3. Generate and approve the plan from that config and the existing deployment inputs. Blindly provisioning a plan derived from attacker-edited live config would grant the attacker new authority. +4. Copy existing raw values to the generated native identities through secure input. Use the Fastly `set` workflow or the tested generated/native instructions for other adapters. Update declarations and local fixtures, and retain the reviewed secret-free plan. No JSON wrapping or credential-value change is required unless validation or suspected compromise requires one. +5. Deploy the matching new config and binary to an isolated version. Old binaries and new binaries need separate compatible config snapshots/stores during a mixed rollout; sharing the new config with an old binary is unsafe. Exercise valid and attacker-modified blobs, recreate state, and switch traffic only after release gates pass. +6. Remove legacy raw-name entries after an explicitly owned rollback window. Revoke credentials at recipients if earlier exposure is suspected. + +For an endpoint, subject, route-policy, or derivation-version change, review old/new plans, provision the new binding/declarations, switch config and recreate state, then remove every retired key/declaration within an owned window. The old context remains authorized for fresh loads while its key exists. Replace retained old states, account for in-flight requests, and verify that replaying retired config fails on newly created state. + +Ordinary value rotation at an unchanged context updates the raw value at the same name and requires state recreation. Removing labels removes independently selectable same-context generations. Do not add a hidden label, generation ID, or raw-key override to recover that capability. Document provider replacement semantics and an overlap window at the external recipient where supported. If old and new values cannot overlap, use a coordinated maintenance cutover. For S3, update the required credential roles while affected state creation/traffic is paused, verify all writes, then recreate state and resume. Native multi-key updates are not a transaction; a provider requiring an unavailable atomic swap is a reported rollout blocker. + +Axum normally requires process restart. Fastly requires new state/sandboxes, including reusable-sandbox mode. Cloudflare and Spin follow existing runner app-state construction. No immediate warm-instance refresh or global revocation guarantee is added. + +Rollback requires the old binary's compatible config snapshot and retained legacy names and restores the original attack. Prefer rolling forward or disabling affected exporters. The new binary never reads legacy or earlier label-bearing draft names. + +## Acceptance and proof contract + +These are required executable results, not passing claims: + +| Issue criterion | Decision | Required evidence | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No unrestricted physical key from app config | Implicit typed roles and derived keys only | Spy-store coverage of all twelve roles; removed raw/generated-key reference properties reject before lookup | +| Consistent scopes on all four adapters | Shared descriptors/encoder; trusted native mapping | Golden vectors, native name/read tests, then Viceroy/Fastly, Axum HTTPS, Wrangler/workerd, and Spin smoke | +| Unrelated values cannot become pull Bearer tokens | Fixed purpose is in the hashed context | Inject legacy reference properties or mutate identities with valid checksums; no unrelated value returned, backend created, or HTTP send | +| Blob edits cannot redirect a pull credential | Exact subject/endpoint are in the key; fixed TLS/no-follow policy | Change URL and allowlist together, host/port/path, syntax, and TLS toggle; no lookup of the old key and zero sends. Approved 3xx sends once per operation and never to `Location` | +| Migration documents provisioning and required moves | Raw-value copy, approved plans, native declarations, retirement, unsafe rollback warning | Fictional four-adapter operator walkthrough, including retired-context replay | +| Config-only attacker rejected before any outbound request | Atomic loading and private resolved-use construction | Production envelope reconstruction with valid-checksum mutations, backend/HTTP counters, and native/runtime negative sends | + +Also test: + +- Field reordering, complete authorized identity selection, identical context deduplication, framing boundaries, UTF-8 lengths, checked integer conversion, optional markers, and native declaration collisions. Removed reference properties must reject even if they contain real generated keys. +- Malformed/optional/inactive structures, both new activation flags and their defaults, missing enabled credentials, S3 auth aliases/effective query policy, partner normalization, preserved resolved-token collision checks, handler subject changes, sibling retargeting, and every bound S3 route-policy mutation. +- Exact credential-byte preservation through all normalization and repeated runtime preparation; unresolved config contains no credential values or references; schema-only safe runtime diagnostics with fictional sentinel values. +- CLI validate/push/diff for implicit roles and feature-only serialization; default-compatible behavior for other EdgeZero users; actual send method/authority/path/query equal to compiled context, including Host/SNI and Tinybird dataset encoding. +- Plan/config/overlay/compiler/native-mapping/resource mismatch, manifest/environment service conflicts, staging selectors, wrong-store selection, ambiguous repeated-role selection, unsupported adapters, cancelled input, invalid bytes, secure stdin, sanitized provider failures, replacement/uncertain outcomes, and zero mutation on any preflight failure. Never publish credential values in argv, logs, plans, generated declarations, or CI artifacts. +- Protected CI approval and trusted-code boundaries, deterministic reruns, presence-only check limitations, independent runtime smoke, and fictional four-adapter provisioning-guide rehearsals. +- Mandatory TLS despite proxy toggles/backend reuse, no credential-bearing redirect following, DataDome form encoding, all S3 signing/HEAD/GET preflight branches, and Tinybird no-follow without claiming observed responses. +- State recreation and in-flight snapshots, same-name value rotation, coordinated S3 rotation without labels, policy change/retirement, replay rejection, mixed-version config isolation, and rollback behavior. + +Two high-risk facts remain unproven: every export-capable path can be confined to its loaded credential/target snapshot, and every native mapping/transport preserves that authorization. The cheapest next proofs are the production-loader/store/backend spy slice and the actual EdgeZero/TS renderer path, followed by native lookup and send tests. + +Release requires the published dependency pin, migration walkthrough, real native reads, isolated valid/invalid HTTPS and redirect tests on all four adapters, and verified config-writer IAM separation. Host routes using Noop stores, source inspection, and a toy hash demonstration do not meet those gates. A missing provider environment is a reported blocker, not a pass. diff --git a/fastly.toml b/fastly.toml index 19ec5a3627..7ca0e80cfa 100644 --- a/fastly.toml +++ b/fastly.toml @@ -49,26 +49,23 @@ build = """ key = "api_key" env = "FASTLY_KEY" - # Local-only values for the three secret references in - # trusted-server.example.toml. These are public development fixtures. - # App-config references use logical `trusted_server_secrets`. Viceroy - # exposes the store under that ID, matching the resource link a - # deployment creates for the selected physical store. - [[local_server.secret_stores.trusted_server_secrets]] - key = "publisher_proxy_secret" - data = "local-only-publisher-proxy-secret" - - [[local_server.secret_stores.trusted_server_secrets]] - key = "ec_passphrase" - data = "local-only-ec-passphrase-32-bytes" - - [[local_server.secret_stores.trusted_server_secrets]] - key = "handler_password" - data = "local-only-handler-password" - - [[local_server.secret_stores.trusted_server_secrets]] - key = "tinybird_auction_append_token" - data = "test-tinybird-auction-append-token" +# Local-only fictional values compiled from trusted-server.example.toml. +# Regenerate with scripts/generate-integration-viceroy-configs.sh --local-manifests. +# BEGIN GENERATED APP SECRET FIXTURES + +[[local_server.secret_stores.trusted_server_secrets]] +key = "ts1_juuqz3tbywbbzruyd2bvt56qmxuiokdflb4ii2dfmjvw7ypyjwbq" +data = "integration-test-ec-secret-padded-32" + +[[local_server.secret_stores.trusted_server_secrets]] +key = "ts1_kw4dni7h367mab3ywnml5mqvumkwivjjxzduonjz7mw3mihm42da" +data = "integration-admin-password-32-bytes-ok" + +[[local_server.secret_stores.trusted_server_secrets]] +key = "ts1_mu4d7mg5sfuleogirpka2zbh2b33lsocgyl6wcovawmjs4nfmi6a" +data = "integration-test-proxy-secret-32-bytes-ok" + +# END GENERATED APP SECRET FIXTURES [local_server.config_stores] [local_server.config_stores.trusted_server_config] diff --git a/scripts/generate-integration-viceroy-configs.sh b/scripts/generate-integration-viceroy-configs.sh index 761d06926e..5758ad1c4d 100755 --- a/scripts/generate-integration-viceroy-configs.sh +++ b/scripts/generate-integration-viceroy-configs.sh @@ -24,18 +24,34 @@ fi mkdir -p "$CONFIG_DIR" -cargo build \ - --manifest-path crates/trusted-server-integration-tests/Cargo.toml \ - --target-dir "$INTEGRATION_TARGET_DIR" \ - --target "$HOST_TARGET" \ - --bin generate-viceroy-config - -GENERATOR_BIN="$INTEGRATION_TARGET_DIR/$HOST_TARGET/debug/generate-viceroy-config" +# Cargo checks freshness for the default generator; explicit overrides are caller-owned. +if [ -z "${GENERATOR_BIN:-}" ]; then + cargo build \ + --manifest-path crates/trusted-server-integration-tests/Cargo.toml \ + --target-dir "$INTEGRATION_TARGET_DIR" \ + --target "$HOST_TARGET" \ + --bin generate-viceroy-config +fi +GENERATOR_BIN="${GENERATOR_BIN:-$INTEGRATION_TARGET_DIR/$HOST_TARGET/debug/generate-viceroy-config}" if [ ! -x "$GENERATOR_BIN" ]; then echo "Generator binary not found or not executable at $GENERATOR_BIN" >&2 exit 1 fi +if [ "${1:-}" = "--local-manifests" ]; then + "$GENERATOR_BIN" --adapter fastly --template fastly.toml \ + --app-config trusted-server.example.toml --output fastly.toml + "$GENERATOR_BIN" --adapter spin \ + --template crates/trusted-server-adapter-spin/spin.toml \ + --app-config trusted-server.example.toml \ + --output crates/trusted-server-adapter-spin/spin.toml + "$GENERATOR_BIN" --adapter cloudflare \ + --template crates/trusted-server-adapter-cloudflare/wrangler.ci.toml \ + --app-config "$APP_CONFIG_PATH" \ + --output crates/trusted-server-adapter-cloudflare/wrangler.ci.toml + exit 0 +fi + "$GENERATOR_BIN" \ --template "$TEMPLATE_PATH" \ --app-config "$APP_CONFIG_PATH" \ diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh index 3632dc3ff7..a02139547b 100755 --- a/scripts/smoke-axum.sh +++ b/scripts/smoke-axum.sh @@ -6,6 +6,7 @@ set -euo pipefail REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) # shellcheck source=scripts/smoke-common.sh . "$REPO_ROOT/scripts/smoke-common.sh" +smoke_use_local_store_mappings trusted_server_config smoke_require_command cargo smoke_require_command curl @@ -36,6 +37,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" +smoke_generate_bindings "$REPO_ROOT" "$WORKSPACE" axum sed \ -e "s|= \"crates/|= \"$REPO_ROOT/crates/|g" \ -e "s|manifest = \"fastly.toml\"|manifest = \"$REPO_ROOT/fastly.toml\"|" \ @@ -100,16 +102,16 @@ run_case() { run_case missing-config "$BASE_PORT" "" \ "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ - "env var '$SMOKE_CONFIG_ENV' not set" + "trusted_server: config entry unavailable" run_case missing-handler "$((BASE_PORT + 1))" "$ENVELOPE" \ "" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ - "failed to resolve secret reference at \`handlers[0].password\`" + "handlers[0].password: native credential unavailable" run_case missing-proxy "$((BASE_PORT + 2))" "$ENVELOPE" \ "$SMOKE_HANDLER_VALUE" "" "$SMOKE_EC_VALUE" 500 \ - "failed to resolve secret reference at \`publisher.proxy_secret\`" + "publisher.proxy_secret: native credential unavailable" run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" \ "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "" 500 \ - "failed to resolve secret reference at \`ec.passphrase\`" + "ec.passphrase: native credential unavailable" run_case positive "$((BASE_PORT + 4))" "$ENVELOPE" \ "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 diff --git a/scripts/smoke-cloudflare.sh b/scripts/smoke-cloudflare.sh index 161823217f..8c9141a782 100755 --- a/scripts/smoke-cloudflare.sh +++ b/scripts/smoke-cloudflare.sh @@ -6,6 +6,7 @@ set -euo pipefail REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) # shellcheck source=scripts/smoke-common.sh . "$REPO_ROOT/scripts/smoke-common.sh" +smoke_use_local_store_mappings TRUSTED_SERVER_KV smoke_require_command bash smoke_require_command cargo @@ -45,6 +46,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" +smoke_generate_bindings "$REPO_ROOT" "$WORKSPACE" cloudflare CLOUDFLARE_WORK="$WORKSPACE/cloudflare" mkdir -p "$CLOUDFLARE_WORK" @@ -88,14 +90,13 @@ write_runtime_config() { if [ -n "$config_json" ]; then config_line="TRUSTED_SERVER_CONFIG = '''$config_json'''" fi - CLOUDFLARE_SMOKE_CONFIG_LINE="$config_line" awk \ - -v missing_key="$missing_key" \ - -v handler="$SMOKE_HANDLER_VALUE" \ - -v proxy="$SMOKE_PROXY_VALUE" \ - -v ec="$SMOKE_EC_VALUE" ' + CLOUDFLARE_SMOKE_CONFIG_LINE="$config_line" awk ' BEGIN { config_line = ENVIRON["CLOUDFLARE_SMOKE_CONFIG_LINE"] } + /^# BEGIN GENERATED APP SECRET FIXTURES/ { skip = 1; next } + /^# END GENERATED APP SECRET FIXTURES/ { skip = 0; next } + skip { next } $0 == "TRUSTED_SERVER_CONFIG = \"{}\"" { placeholders++ if (config_line != "") { @@ -109,17 +110,10 @@ write_runtime_config() { print "Cloudflare smoke template must contain one config placeholder" > "/dev/stderr" exit 1 } - if (missing_key != "handler_password") { - print "handler_password = \"" handler "\"" - } - if (missing_key != "publisher_proxy_secret") { - print "publisher_proxy_secret = \"" proxy "\"" - } - if (missing_key != "ec_passphrase") { - print "ec_passphrase = \"" ec "\"" - } } ' "$CLOUDFLARE_WORK/wrangler.ci.toml" >"$destination" + jq -r --arg missing "$missing_key" '.[] | select(.native.native_name != $missing) | "\(.native.native_name) = \(.value | tojson)"' \ + "$SMOKE_BINDINGS" >>"$destination" } run_case() { @@ -160,7 +154,7 @@ run_case() { smoke_die "negative case unexpectedly exposed $missing_binding" fi if [ "$missing_binding" = "env.TRUSTED_SERVER_CONFIG" ]; then - grep --fixed-strings --quiet 'env.handler_password' "$log_path" || + grep --fixed-strings --quiet "env.$SMOKE_HANDLER_ENV" "$log_path" || smoke_die "missing-config case did not retain the secret control binding" else grep --fixed-strings --quiet 'env.TRUSTED_SERVER_CONFIG' "$log_path" || @@ -178,15 +172,15 @@ run_case() { run_case missing-config "$BASE_PORT" "" "" 500 \ 'env.TRUSTED_SERVER_CONFIG' \ 'Cloudflare TRUSTED_SERVER_CONFIG is required' -run_case missing-handler "$((BASE_PORT + 1))" "$CONFIG_JSON" handler_password 500 \ - 'env.handler_password' \ - "failed to resolve secret reference at \`handlers[0].password\` from secret store \`trusted_server_secrets\`" -run_case missing-proxy "$((BASE_PORT + 2))" "$CONFIG_JSON" publisher_proxy_secret 500 \ - 'env.publisher_proxy_secret' \ - "failed to resolve secret reference at \`publisher.proxy_secret\` from secret store \`trusted_server_secrets\`" -run_case missing-ec "$((BASE_PORT + 3))" "$CONFIG_JSON" ec_passphrase 500 \ - 'env.ec_passphrase' \ - "failed to resolve secret reference at \`ec.passphrase\` from secret store \`trusted_server_secrets\`" +run_case missing-handler "$((BASE_PORT + 1))" "$CONFIG_JSON" "$SMOKE_HANDLER_ENV" 500 \ + "env.$SMOKE_HANDLER_ENV" \ + "handlers[0].password: native credential unavailable" +run_case missing-proxy "$((BASE_PORT + 2))" "$CONFIG_JSON" "$SMOKE_PROXY_ENV" 500 \ + "env.$SMOKE_PROXY_ENV" \ + "publisher.proxy_secret: native credential unavailable" +run_case missing-ec "$((BASE_PORT + 3))" "$CONFIG_JSON" "$SMOKE_EC_ENV" 500 \ + "env.$SMOKE_EC_ENV" \ + "ec.passphrase: native credential unavailable" run_case positive "$((BASE_PORT + 4))" "$CONFIG_JSON" "" 200 "" "" echo "Cloudflare first-success smoke passed" diff --git a/scripts/smoke-common.sh b/scripts/smoke-common.sh index 4885472c47..33f91575f5 100644 --- a/scripts/smoke-common.sh +++ b/scripts/smoke-common.sh @@ -178,6 +178,14 @@ smoke_resolve_ts_binary() { SMOKE_TS_BIN="$candidate" } +# Keep local runs independent of deployment selectors inherited from the shell. +# The pure fixture renderer uses the logical secret-store name on every adapter. +smoke_use_local_store_mappings() { + export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME="$1" + export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY=trusted_server_config + export EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=trusted_server_secrets +} + smoke_initialize_config() { local repository="$1" local workspace="$2" @@ -194,9 +202,35 @@ smoke_initialize_config() { } SMOKE_CONFIG_ENV=TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG -SMOKE_HANDLER_ENV=TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD -SMOKE_PROXY_ENV=TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET -SMOKE_EC_ENV=TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE -SMOKE_HANDLER_VALUE=smoke-admin-password-32-bytes-ok -SMOKE_PROXY_VALUE=smoke-publisher-proxy-secret-32-bytes -SMOKE_EC_VALUE=smoke-edge-cookie-passphrase-32-bytes + +# This is the existing Rust integration generator, not a second hash/mapping +# implementation. All values emitted in this mode are public fictional fixtures. +smoke_generate_bindings() { + local repository="$1" + local workspace="$2" + local adapter="$3" + local host + host=$(rustc -vV | sed -n 's/^host: //p') + SMOKE_GENERATOR="${GENERATOR_BIN:-$repository/crates/trusted-server-integration-tests/target/$host/debug/generate-viceroy-config}" + # Cargo checks freshness for the default generator; explicit overrides are caller-owned. + if [ -z "${GENERATOR_BIN:-}" ]; then + cargo build --manifest-path "$repository/Cargo.toml" \ + -p trusted-server-integration-tests --bin generate-viceroy-config \ + --target-dir "$repository/crates/trusted-server-integration-tests/target" --target "$host" + fi + SMOKE_BINDINGS="$workspace/bindings.json" + "$SMOKE_GENERATOR" --bindings-only --adapter "$adapter" \ + --app-config "$SMOKE_APP_CONFIG" --output "$SMOKE_BINDINGS" + SMOKE_HANDLER_ENV=$(jq -er '.[] | select(.descriptor_id == "handler.password") | .native.native_name' "$SMOKE_BINDINGS") + SMOKE_PROXY_ENV=$(jq -er '.[] | select(.descriptor_id == "publisher.proxy") | .native.native_name' "$SMOKE_BINDINGS") + SMOKE_EC_ENV=$(jq -er '.[] | select(.descriptor_id == "ec.passphrase") | .native.native_name' "$SMOKE_BINDINGS") + SMOKE_HANDLER_VALUE=$(jq -er '.[] | select(.descriptor_id == "handler.password") | .value' "$SMOKE_BINDINGS") + SMOKE_PROXY_VALUE=$(jq -er '.[] | select(.descriptor_id == "publisher.proxy") | .value' "$SMOKE_BINDINGS") + SMOKE_EC_VALUE=$(jq -er '.[] | select(.descriptor_id == "ec.passphrase") | .value' "$SMOKE_BINDINGS") +} + +smoke_append_fastly_bindings() { + local destination="$1" + jq -r '.[] | "\n[[local_server.secret_stores.trusted_server_secrets]]\nkey = \(.native.native_name | tojson)\ndata = \(.value | tojson)"' \ + "$SMOKE_BINDINGS" >>"$destination" +} diff --git a/scripts/smoke-fastly.sh b/scripts/smoke-fastly.sh index 65635c2d23..d51a274cf3 100755 --- a/scripts/smoke-fastly.sh +++ b/scripts/smoke-fastly.sh @@ -6,10 +6,12 @@ set -euo pipefail REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) # shellcheck source=scripts/smoke-common.sh . "$REPO_ROOT/scripts/smoke-common.sh" +smoke_use_local_store_mappings trusted_server_config smoke_require_command cargo smoke_require_command curl smoke_require_command fastly +smoke_require_command jq smoke_require_command python3 smoke_require_command pgrep @@ -68,6 +70,7 @@ fi smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID smoke_initialize_config "$FASTLY_PROJECT" "$WORKSPACE" "$ORIGIN_PORT" +smoke_generate_bindings "$REPO_ROOT" "$WORKSPACE" fastly run_case() { local case_name="$1" @@ -137,7 +140,7 @@ write_without_secret() { } run_case missing-config "$BASE_PORT" 500 \ - "key 'trusted_server_config' not found in config store 'trusted_server_config'" + "trusted_server: config entry unavailable" ( cd "$FASTLY_PROJECT" @@ -149,32 +152,19 @@ run_case missing-config "$BASE_PORT" 500 \ --yes \ --no-diff ) -cat >>"$FASTLY_MANIFEST" <"$WORKSPACE/edgezero.toml" -SPIN_HANDLER_VAR=v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword -SPIN_PROXY_VAR=v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret -SPIN_EC_VAR=v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase +"$SMOKE_GENERATOR" --adapter spin --template "$SPIN_WORK/spin.toml" \ + --app-config "$SMOKE_APP_CONFIG" --output "$SPIN_WORK/spin.generated.toml" +# Only the isolated negative-test manifest permits empty defaults, so omission +# reaches core's native-value validation rather than failing at Spin startup. +sed 's/^required = true$/default = ""/' "$SPIN_WORK/spin.generated.toml" >"$SPIN_WORK/spin.toml" +SPIN_HANDLER_VAR=$SMOKE_HANDLER_ENV +SPIN_PROXY_VAR=$SMOKE_PROXY_ENV +SPIN_EC_VAR=$SMOKE_EC_ENV run_case() { local case_name="$1" @@ -77,15 +85,9 @@ run_case() { --truncate-logs ) smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - if [ "$missing_variable" != "$SPIN_HANDLER_VAR" ]; then - spin_args+=(--variable "$SPIN_HANDLER_VAR=$SMOKE_HANDLER_VALUE") - fi - if [ "$missing_variable" != "$SPIN_PROXY_VAR" ]; then - spin_args+=(--variable "$SPIN_PROXY_VAR=$SMOKE_PROXY_VALUE") - fi - if [ "$missing_variable" != "$SPIN_EC_VAR" ]; then - spin_args+=(--variable "$SPIN_EC_VAR=$SMOKE_EC_VALUE") - fi + while IFS=$'\t' read -r name value; do + [ "$name" = "$missing_variable" ] || spin_args+=(--variable "$name=$value") + done < <(jq -r '.[] | [.native.native_name, .value] | @tsv' "$SMOKE_BINDINGS") "${spin_args[@]}" >"$log_path" 2>&1 & APP_PID=$! @@ -129,11 +131,11 @@ EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default \ CONFIG_PUSHED=true run_case missing-handler "$((BASE_PORT + 1))" "$SPIN_HANDLER_VAR" 503 \ - "resolved secret at \`handlers[0].password\` must not be empty" + "handlers[0].password: empty credential" run_case missing-proxy "$((BASE_PORT + 2))" "$SPIN_PROXY_VAR" 503 \ - "resolved secret at \`publisher.proxy_secret\` must not be empty" + "publisher.proxy_secret: empty credential" run_case missing-ec "$((BASE_PORT + 3))" "$SPIN_EC_VAR" 503 \ - "resolved secret at \`ec.passphrase\` must not be empty" + "ec.passphrase: empty credential" run_case positive "$((BASE_PORT + 4))" "" 200 echo "Spin first-success smoke passed" diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index 01efe9a9fd..bce042b47d 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -31,6 +31,10 @@ if [ "$MODE" = "purge" ]; then CONFIG_MODE="esi" fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=scripts/smoke-common.sh +. "$REPO_ROOT/scripts/smoke-common.sh" +smoke_use_local_store_mappings trusted_server_config +smoke_require_command jq WORK="$(mktemp -d)" ORIGIN_PORT="${ORIGIN_PORT:-9099}" BID_PORT="${BID_PORT:-9100}" @@ -391,24 +395,18 @@ content = content.replace(marker, f"{marker}{backend}\n", 1) open(manifest, "w").write(content) PYEOF -python3 - "$WORK/fastly.toml" <<'PYEOF' -import sys - -with open(sys.argv[1], "a") as manifest: - manifest.write(''' -[[local_server.secret_stores.trusted_server_secrets]] -key = "publisher_proxy_secret" -data = "fictional-local-publisher-proxy-secret-value" - -[[local_server.secret_stores.trusted_server_secrets]] -key = "ec_passphrase" -data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.trusted_server_secrets]] -key = "handler_password" -data = "fictional-local-handler-password-secret-value" -''') -PYEOF +# Replace only copied app-credential fixtures. Signing/management fixtures and +# the cache/backend configuration remain unchanged. Keys and native names come +# from the production compiler/renderer, never a second hash implementation. +SMOKE_APP_CONFIG="$WORK/app.toml" +smoke_generate_bindings "$REPO_ROOT" "$WORK" fastly +awk ' + /^# BEGIN GENERATED APP SECRET FIXTURES/ { skip = 1; next } + /^# END GENERATED APP SECRET FIXTURES/ { skip = 0; next } + !skip { print } +' "$WORK/fastly.toml" >"$WORK/fastly.without-app-secrets.toml" +mv "$WORK/fastly.without-app-secrets.toml" "$WORK/fastly.toml" +smoke_append_fastly_bindings "$WORK/fastly.toml" ln -s "$REPO_ROOT/crates" "$WORK/crates" (cd "$WORK" && "$TS" config push --adapter fastly --local \ @@ -794,11 +792,10 @@ fi if [ "$MODE" = "purge" ]; then info "Purge invalidates the shared template" - # The config's `password = "handler_password"` is a secret-store *reference*; the basic - # auth value is the seeded secret itself. Read it back from the generated manifest so - # this cannot drift from the seeding block above. - ADMIN_PASSWORD=$(awk -F'"' '/^key = "handler_password"/ { found = 1; next } \ - found && /^data = / { print $2; exit }' "$WORK/fastly.toml") + # Read the public fictional value from the same compiler-generated fixture + # output that seeded Viceroy, selecting the role rather than a physical key. + ADMIN_PASSWORD=$(jq -er '.[] | select(.descriptor_id == "handler.password") | .value' \ + "$SMOKE_BINDINGS") if [ -z "$ADMIN_PASSWORD" ]; then bad "could not read the seeded admin password from the generated manifest" ADMIN_PASSWORD="unreadable" diff --git a/scripts/test-fixture-generator.sh b/scripts/test-fixture-generator.sh new file mode 100755 index 0000000000..30d6229bc9 --- /dev/null +++ b/scripts/test-fixture-generator.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Exercise generator freshness and executable overrides without compiling Rust. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKSPACE=$(mktemp -d) +trap 'rm -rf -- "$WORKSPACE"' EXIT + +mkdir -p "$WORKSPACE/bin" +cat > "$WORKSPACE/bin/rustc" <<'SH' +#!/usr/bin/env bash +printf 'host: fixture-host\n' +SH +cat > "$WORKSPACE/bin/cargo" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'build\n' >> "$GENERATOR_TEST_CARGO_LOG" +cp "$GENERATOR_TEST_CURRENT" "$GENERATOR_TEST_DEFAULT" +SH +cat > "$WORKSPACE/current-generator" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then + output="$2" + break + fi + shift +done +printf '%s\n' '[ + {"descriptor_id":"handler.password","native":{"native_name":"fixture_handler"},"value":"fictional-handler"}, + {"descriptor_id":"publisher.proxy","native":{"native_name":"fixture_proxy"},"value":"fictional-proxy"}, + {"descriptor_id":"ec.passphrase","native":{"native_name":"fixture_ec"},"value":"fictional-passphrase"} +]' > "$output" +SH +chmod +x "$WORKSPACE/bin/"* "$WORKSPACE/current-generator" + +run_case() ( + local script="$1" + local state="$2" + local repository="$WORKSPACE/$script-$state" + local workspace="$repository/workspace" + local generator="$repository/crates/trusted-server-integration-tests/target/fixture-host/debug/generate-viceroy-config" + mkdir -p "$repository/scripts" "$(dirname "$generator")" "$workspace" \ + "$repository/crates/trusted-server-adapter-spin" \ + "$repository/crates/trusted-server-adapter-cloudflare" + cp "$REPO_ROOT/scripts/generate-integration-viceroy-configs.sh" "$repository/scripts/" + export PATH="$WORKSPACE/bin:$PATH" + export GENERATOR_TEST_CARGO_LOG="$repository/cargo.log" + export GENERATOR_TEST_CURRENT="$WORKSPACE/current-generator" + export GENERATOR_TEST_DEFAULT="$generator" + unset GENERATOR_BIN + + case "$state" in + cached) + # A stale default must be replaced by Cargo before it can run. + printf '#!/usr/bin/env bash\nexit 89\n' > "$generator" + chmod +x "$generator" + ;; + missing) ;; + override) + export GENERATOR_BIN="$repository/custom-generator" + cp "$WORKSPACE/current-generator" "$GENERATOR_BIN" + ;; + esac + + if [ "$script" = generate ]; then + bash "$repository/scripts/generate-integration-viceroy-configs.sh" --local-manifests + else + source "$REPO_ROOT/scripts/smoke-common.sh" + SMOKE_APP_CONFIG="$workspace/trusted-server.toml" + smoke_generate_bindings "$repository" "$workspace" axum + [ "$SMOKE_HANDLER_ENV" = fixture_handler ] + [ "$SMOKE_PROXY_ENV" = fixture_proxy ] + [ "$SMOKE_EC_ENV" = fixture_ec ] + fi + + if [ "$state" = override ]; then + [ ! -e "$GENERATOR_TEST_CARGO_LOG" ] + else + [ "$(wc -l < "$GENERATOR_TEST_CARGO_LOG")" -eq 1 ] + fi + printf 'PASS: %s %s\n' "$script" "$state" +) + +for script in generate smoke; do + for state in cached missing override; do + run_case "$script" "$state" + done +done diff --git a/tinybird/README.md b/tinybird/README.md index c0a0e976ce..8e9fcdc3b1 100644 --- a/tinybird/README.md +++ b/tinybird/README.md @@ -17,10 +17,18 @@ Authenticate the Tinybird CLI against the intended workspace, change to this directory, run `tb test`, inspect `tb deploy --dry-run`, and deploy with `tb deploy`. Do not commit workspace tokens or generated local credentials. -Create an APPEND token scoped to `auction_events_raw`, store its value in the -platform secret store, and put only its key name in -`tinybird.auction_token_secret`. The runtime sends directly to the regional -Events API host configured by `tinybird.api_host`. +Create an APPEND token restricted to `auction_events_raw` outside Trusted Server. +Enable Tinybird with its regional DNS `api_host`, optionally including an HTTPS +`:port`, and exact `auction_dataset`, then +review a generated plan and provision `tinybird.auction` through secure input. +Do not add a token-name property to app config. The binding includes the Events +endpoint and dataset, so a changed destination needs newly approved provisioning. +`api_host` must not include a scheme, path, userinfo, query, or fragment. Explicit +port `443` normalizes away; other valid ports remain part of the bound endpoint. +See [Provision app credentials](../docs/guide/secrets.md) and +[CI provisioning](../docs/guide/secrets-ci.md). Recreate state after writes and +verify an actual approved event at the recipient. The Fastly fire-and-forget +sink does not observe delivery or the response status. For runtime behavior, limits, privacy properties, and the Fastly-only emission boundary, see [Auction Telemetry](../docs/guide/telemetry.md). For exact diff --git a/trusted-server.example.toml b/trusted-server.example.toml index d65c5d1476..f45e9d6f97 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -9,7 +9,9 @@ # Only three sections are REQUIRED for the server to start and pass validation: # 1. [[handlers]] covering /_ts/admin (admin authentication) # 2. [publisher] (domain + origin) -# 3. [ec] passphrase (Edge Cookie identity secret) +# 3. [ec] (Edge Cookie settings) +# Provision generated handler.password, publisher.proxy and ec.passphrase bindings +# before startup. See docs/guide/secrets.md. No credential properties belong here. # # Everything below those is OPTIONAL. Most optional blocks are commented out — # uncomment and edit one to enable it — but a few integrations are kept as active @@ -32,21 +34,17 @@ # ----------------------------------------------------------------------------- # HTTP Basic-auth handler(s). At least one handler whose `path` regex covers # the /_ts/admin endpoints is mandatory; startup fails without it. Each handler -# needs a non-placeholder username/password (deploy validation rejects the -# sample password below). +# needs a non-placeholder username and its generated handler.password binding. [[handlers]] # Regex matched against the request path. This one guards the admin surface. path = "^/_ts/admin" username = "admin" -password = "handler_password" # You can add more handlers to basic-auth-protect other path prefixes. The -# sample password below is a known placeholder that deploy validation rejects, -# so it forces a real secret before push: +# generated binding includes the exact path regex and username: # [[handlers]] # path = "^/secure" # username = "user" -# password = "replace-with-admin-password" # ----------------------------------------------------------------------------- @@ -59,8 +57,7 @@ domain = "example.com" cookie_domain = ".example.com" # Upstream origin to proxy publisher content from. No trailing slash. origin_url = "https://origin.example.com" -# Secret Store key for the HMAC secret used to sign first-party proxy URLs. -proxy_secret = "publisher_proxy_secret" +# The publisher.proxy credential is implicit and provisioned outside this file. # Optional: override the outbound Host header sent to origin_url. # origin_host_header_override = "www.example.com" # Optional: max bytes buffered when a response is post-processed in full (HTML @@ -78,8 +75,7 @@ proxy_secret = "publisher_proxy_secret" # REQUIRED — Edge Cookie (EC) identity # ----------------------------------------------------------------------------- [ec] -# Secret Store key for the secret used to derive EC identifiers. -passphrase = "ec_passphrase" +# The ec.passphrase credential is implicit. Keep its value stable for EC continuity. # KV store that persists EC identity state. This is the physical store name # bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's # logical KV id is `trusted_server_kv`. @@ -90,7 +86,7 @@ pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds -# Example partner configuration. Provision referenced keys in +# Example partner configuration. Provision generated bindings in # trusted_server_secrets before validating/pushing. # [[ec.partners]] # name = "Example Partner" @@ -101,9 +97,10 @@ pull_sync_concurrency = 3 # include this partner's UIDs in auction user.eids # bidstream_enabled = true # Only for inbound identify or batch-sync API access: -# api_token = "partner_api_token" -# Required when pull sync is enabled: -# ts_pull_token = "partner_ts_pull_token" +# api_token_enabled = true +# Enabling pull sync requires a separate ec.pull binding for the exact endpoint. +# pull_sync_url = "https://sync.example.com/pull" +# pull_sync_allowed_domains = ["sync.example.com"] # batch_rate_limit = 60 # max batch-sync requests/min (default 60) # pull_sync_enabled = false # default false @@ -136,12 +133,11 @@ pull_sync_concurrency = 3 # Trust a fronting CDN's reader IP only when the same request also supplies # the matching shared secret. The front door must set both headers on requests # it sends here and remove client-supplied copies on its other routes. -# `shared_secret` is a key in trusted_server_secrets, not the credential itself. +# Presence requires the generated clientip.shared binding in trusted_server_secrets. # Fastly-only; see docs/guide/fastly.md for the required front-door setup. # [trusted_client_ip] # ip_header = "x-ts-client-ip" # auth_header = "x-ts-client-ip-auth" -# shared_secret = "trusted_client_ip_shared_secret" # Tester-cookie endpoints: GET /_ts/set-tester sets ts-tester=true and # GET /_ts/clear-tester clears it on publisher.cookie_domain. @@ -211,9 +207,8 @@ proxy = "enabled" # type = "s3_sigv4" # region = "us-east-1" # origin_query = "strip" -# secret_store = "s3-auth" -# access_key_id = "access_key_id" -# secret_access_key = "secret_access_key" +# Required s3.access-id and s3.secret bindings are generated for this route. +# session_token_enabled = false # true also requires s3.session # # Optional Fastly Image Optimizer for the route (references a profile_set below): # [proxy.asset_routes.image_optimizer] @@ -440,12 +435,13 @@ auction_timeout_ms = 500 # "/reviews/y" -> /123456789/example/reviews # Direct Tinybird auction telemetry (off by default). When enabled, `api_host` -# is required and must be a bare host (no scheme or path). +# is a DNS host with optional HTTPS :port, no scheme/path/userinfo/query/fragment. +# Explicit :443 normalizes away; other valid ports remain in the bound endpoint. # [tinybird] # enabled = true -# api_host = "api.us-east.tinybird.example" # required when enabled; host only +# api_host = "api.tinybird.example.com" # optionally append :8443 # auction_dataset = "auction_events" # Events API datasource name -# auction_token_secret = "tinybird_auction_append_token" # Key in trusted_server_secrets +# The generated tinybird.auction binding binds the Events API host and dataset. # Debug endpoints (all default false — never enable in production). # [debug] @@ -602,9 +598,8 @@ enabled = false # rewrite_sdk = true # Server-side Protection API validation (fails open on timeout/error): # enable_protection = false -# Required when enable_protection = true. The value is a key in -# trusted_server_secrets, not the DataDome credential itself. -# server_side_key_secret_name = "datadome_server_side_key" +# Enabled Protection requires the generated datadome.server binding in +# trusted_server_secrets, provisioned separately from app config. # protection_api_origin = "https://api.example.com" # timeout_ms = 1500 # protection_excluded_methods = ["OPTIONS"] @@ -619,8 +614,7 @@ enabled = false # Never enable in production. # [integrations.datadome.protection_test_bypass] # enabled = true -# credential_secret_store = "ts_secrets" -# credential_secret_name = "datadome_protection_test_bypass" +# The enabled bypass requires a separate datadome.bypass binding. # Google Publisher Tag (GPT) first-party proxy. Kept active but disabled so # `ts audit` can flip `enabled` to true when GPT is detected.