From 5ecd97fb08dbff76e76fd87943961de146e12434 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 17 Aug 2026 16:19:34 -0500 Subject: [PATCH 1/5] feat: add native secret-store config resolution --- .env.dev | 4 + .env.example | 14 +- Cargo.lock | 37 +- Cargo.toml | 12 +- crates/trusted-server-adapter-axum/src/app.rs | 11 +- .../src/app.rs | 38 ++- .../src/lib.rs | 1 + .../src/platform.rs | 4 +- .../wrangler.ci.toml | 9 + .../wrangler.toml | 4 + .../trusted-server-adapter-fastly/src/app.rs | 8 +- crates/trusted-server-adapter-spin/spin.toml | 14 +- crates/trusted-server-adapter-spin/src/app.rs | 50 ++- .../src/platform.rs | 63 +++- crates/trusted-server-core/src/config.rs | 319 +++++++++++++++--- .../trusted-server-core/src/config_payload.rs | 141 +++++++- crates/trusted-server-core/src/ec/registry.rs | 120 ++++++- crates/trusted-server-core/src/lib.rs | 1 + .../src/secret_resolution.rs | 301 +++++++++++++++++ crates/trusted-server-core/src/settings.rs | 63 +++- .../trusted-server-core/src/settings_data.rs | 92 +++-- .../Cargo.toml | 2 +- .../configs/trusted-server.integration.toml | 10 +- .../fixtures/configs/viceroy-template.toml | 16 + .../src/bin/generate-viceroy-config.rs | 91 ++++- .../tests/common/config.rs | 12 +- .../tests/environments/axum.rs | 25 ++ docs/guide/configuration.md | 183 +++++----- docs/guide/getting-started.md | 50 ++- fastly.toml | 6 + trusted-server.example.toml | 16 +- 31 files changed, 1435 insertions(+), 282 deletions(-) create mode 100644 crates/trusted-server-core/src/secret_resolution.rs diff --git a/.env.dev b/.env.dev index cdd6af510..fd7aa3ba4 100644 --- a/.env.dev +++ b/.env.dev @@ -1,3 +1,7 @@ +# Non-secret development overlays used while generating the Axum config blob. +# Sourcing this file alone does not configure the Axum server: also export the +# blob and referenced secret-store values as shown in docs/guide/getting-started.md. + # [publisher] TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://localhost:9090 diff --git a/.env.example b/.env.example index c2ac88e3a..87a3502d2 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ -# Trusted Server Environment Variables -# Copy this file to .env.dev, .env.staging, or .env.production and fill in values -# See docs/guide/configuration.md for details +# 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. +# 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_= +# The commented examples below are CLI overlays for ordinary fields only. # ============================================================================= # Publisher Settings @@ -8,14 +14,12 @@ 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__PROXY_SECRET= # ============================================================================= # Synthetic ID Settings # ============================================================================= TRUSTED_SERVER__SYNTHETIC__COUNTER_STORE=counter_store TRUSTED_SERVER__SYNTHETIC__OPID_STORE=opid_store -TRUSTED_SERVER__SYNTHETIC__SECRET_KEY= # Template variables: client_ip, user_agent, first_party_id, auth_user_id, publisher_domain, accept_language TRUSTED_SERVER__SYNTHETIC__TEMPLATE={{ client_ip }}:{{ user_agent }}:{{ first_party_id }} diff --git a/Cargo.lock b/Cargo.lock index cb8f40c68..b65cacdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "toml", ] @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-trait", @@ -1449,7 +1449,7 @@ dependencies = [ "log", "serde_json", "tempfile", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "walkdir", "worker", ] @@ -1457,7 +1457,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-stream", @@ -1479,14 +1479,14 @@ dependencies = [ "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "walkdir", ] [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-trait", @@ -1506,14 +1506,14 @@ dependencies = [ "subtle", "thiserror 2.0.18", "toml", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "walkdir", ] [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "chrono", "clap", @@ -1538,7 +1538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "anyhow", "async-compression", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?tag=v0.0.4#9e661ae520a8130660f18fd10f42703d7f3e050b" +source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" dependencies = [ "log", "proc-macro2", @@ -5074,6 +5074,19 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -5340,7 +5353,7 @@ dependencies = [ "tokio", "tokio-rustls", "toml", - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", "trusted-server-core", "url", "webpki-roots", diff --git a/Cargo.toml b/Cargo.toml index 7ca87e687..16a2109e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,12 +54,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc 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/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4" } -edgezero-core = { git = "https://github.com/stackpop/edgezero", tag = "v0.0.4", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } env_logger = "0.11" error-stack = "0.6" fastly = "0.12" diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1bed830ac..e7cf40ded 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -33,7 +33,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; -use crate::platform::{AxumPlatformConfigStore, build_runtime_services}; +use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -55,8 +55,13 @@ pub struct AppState { fn build_state() -> Result, Report> { let store_name = default_config_store_name(); let config_key = default_config_key(); - let settings = - get_settings_from_config_store(&AxumPlatformConfigStore, &store_name, &config_key)?; + let settings = get_settings_from_config_store( + &AxumPlatformConfigStore, + &AxumPlatformSecretStore, + &store_name, + &config_key, + &trusted_server_core::settings_data::default_secret_store_name(), + )?; build_state_with_settings(settings) } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 644676fc5..6f2a59950 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -29,6 +29,8 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +#[cfg(target_arch = "wasm32")] +use trusted_server_core::settings_data::default_secret_store_name; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; @@ -38,11 +40,23 @@ use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- #[cfg(target_arch = "wasm32")] -static CLOUDFLARE_CONFIG_JSON: std::sync::OnceLock = std::sync::OnceLock::new(); +thread_local! { + static CLOUDFLARE_CONFIG_JSON: std::cell::OnceCell = const { std::cell::OnceCell::new() }; + static CLOUDFLARE_ENV: std::cell::OnceCell = const { std::cell::OnceCell::new() }; +} #[cfg(target_arch = "wasm32")] pub fn set_cloudflare_config_json(value: String) { - let _ = CLOUDFLARE_CONFIG_JSON.set(value); + CLOUDFLARE_CONFIG_JSON.with(|slot| { + let _ = slot.set(value); + }); +} + +#[cfg(target_arch = "wasm32")] +pub fn set_cloudflare_env(env: worker::Env) { + CLOUDFLARE_ENV.with(|slot| { + let _ = slot.set(env); + }); } /// Application state built once at startup and shared across all requests. @@ -70,18 +84,22 @@ fn load_startup_settings() -> Result> { #[cfg(not(target_arch = "wasm32"))] fn load_startup_settings() -> Result> { - Settings::from_toml(include_str!("../../../trusted-server.example.toml")) + Err(Report::new(TrustedServerError::Configuration { + message: "Cloudflare startup settings require a Worker config binding".to_string(), + }) + .attach("use TrustedServerApp::routes_with_settings for host tests")) } #[cfg(target_arch = "wasm32")] fn settings_from_cloudflare_config_json() -> Result> { - let raw_config = CLOUDFLARE_CONFIG_JSON.get().ok_or_else(|| { + let raw_config = CLOUDFLARE_CONFIG_JSON.with(|slot| slot.get().cloned()); + let raw_config = raw_config.ok_or_else(|| { Report::new(TrustedServerError::Configuration { message: "Cloudflare TRUSTED_SERVER_CONFIG is required".to_string(), }) .attach("set TRUSTED_SERVER_CONFIG to JSON containing the app_config blob envelope") })?; - let value: serde_json::Value = serde_json::from_str(raw_config).map_err(|error| { + let value: serde_json::Value = serde_json::from_str(&raw_config).map_err(|error| { Report::new(TrustedServerError::Configuration { message: "invalid Cloudflare TRUSTED_SERVER_CONFIG JSON".to_string(), }) @@ -95,7 +113,15 @@ fn settings_from_cloudflare_config_json() -> Result Result { if let Ok(config) = env.var("TRUSTED_SERVER_CONFIG") { app::set_cloudflare_config_json(config.to_string()); } + app::set_cloudflare_env(env.clone()); match edgezero_adapter_cloudflare::run_app::(req, env, ctx).await { Ok(resp) => Ok(resp), diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..d9ef8583a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -547,8 +547,8 @@ impl PlatformHttpClient for CloudflareHttpClient { /// Bridges [`worker::Env`] secrets to [`PlatformSecretStore`] by calling /// `env.secret(key)` synchronously. Writes and deletes return errors. #[cfg(target_arch = "wasm32")] -struct CloudflareSecretStoreAdapter { - env: worker::Env, +pub(crate) struct CloudflareSecretStoreAdapter { + pub(crate) env: worker::Env, } #[cfg(target_arch = "wasm32")] diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index e6891eb79..9992db712 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -14,3 +14,12 @@ id = "ci-local-kv" # Placeholder replaced by the integration test harness with a JSON object that # 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" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 7c91173fc..48eb2db8d 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -26,3 +26,7 @@ id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" # invalid placeholder with JSON containing an `app_config` blob envelope before # deploying or running `wrangler dev` against real traffic. TRUSTED_SERVER_CONFIG = '{"app_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. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d6090c983..96c06637b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -166,7 +166,13 @@ pub(crate) fn build_state() -> Result, Report> pub(crate) fn load_settings_from_config_store() -> Result> { let store_name = default_config_store_name(); let config_key = default_config_key(); - get_settings_from_config_store(&FastlyPlatformConfigStore, &store_name, &config_key) + get_settings_from_config_store( + &FastlyPlatformConfigStore, + &FastlyPlatformSecretStore, + &store_name, + &config_key, + &trusted_server_core::settings_data::default_secret_store_name(), + ) } pub(crate) fn build_state_from_settings( diff --git a/crates/trusted-server-adapter-spin/spin.toml b/crates/trusted-server-adapter-spin/spin.toml index 9bc3634d8..a8ed99253 100644 --- a/crates/trusted-server-adapter-spin/spin.toml +++ b/crates/trusted-server-adapter-spin/spin.toml @@ -25,6 +25,13 @@ version = "0.1.0" [variables] v_current_x2dkid = { default = "" } v_active_x2dkids = { default = "" } +# Trusted Server app-config secret references. 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 = "" } +v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = { default = "" } +v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = { default = "" } +v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = { default = "" } +v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = { default = "" } [[trigger.http]] route = "/..." @@ -38,11 +45,16 @@ source = "../../target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm" # origins are still served over plaintext http. Follow-up: scope this to the # configured origins once they can be enumerated from settings. allowed_outbound_hosts = ["https://*:*", "http://*:*"] -key_value_stores = ["default"] +key_value_stores = ["default", "trusted_server_config"] [component.trusted-server.variables] v_current_x2dkid = "{{ v_current_x2dkid }}" v_active_x2dkids = "{{ v_active_x2dkids }}" +v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = "{{ v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_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 }}" [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 960bafc41..7d0e24750 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1,8 +1,12 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_adapter_spin::config_store::SpinConfigStore; use edgezero_adapter_spin::context::SpinRequestContext; use edgezero_core::app::Hooks; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; @@ -10,10 +14,14 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use trusted_server_core::platform::PlatformConfigStore; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, @@ -28,9 +36,15 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use trusted_server_core::settings_data::{ + default_config_key, default_config_store_name, default_secret_store_name, +}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; use crate::platform::build_runtime_services; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use crate::platform::{ConfigStoreHandleAdapter, SpinSecretStoreAdapter}; // --------------------------------------------------------------------------- // AppState @@ -50,10 +64,44 @@ pub struct AppState { /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. fn build_state() -> Result, Report> { - let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; + let settings = load_startup_settings()?; build_state_with_settings(settings) } +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +fn load_startup_settings() -> Result> { + let config_store_name = default_config_store_name(); + let config_key = default_config_key(); + let config_store = + futures::executor::block_on(SpinConfigStore::open(config_store_name.as_ref().to_owned())) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to open Spin Trusted Server config store".to_string(), + }) + .attach(error.to_string()) + })?; + let config_handle = ConfigStoreHandle::new(Arc::new(config_store)); + let config_adapter = ConfigStoreHandleAdapter(config_handle); + let raw_envelope = config_adapter + .get(&config_store_name, &config_key) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read Spin Trusted Server app-config blob".to_string(), + }) + .attach(error.to_string()) + })?; + let secret_store = SpinSecretStoreAdapter; + settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) +} + +#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] +fn load_startup_settings() -> Result> { + Err(Report::new(TrustedServerError::Configuration { + message: "Spin startup settings require the production config store".to_string(), + }) + .attach("use TrustedServerApp::routes_with_settings for host tests")) +} + /// Build the application state from explicit settings. /// /// # Errors diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..0f05ef17d 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -39,6 +39,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")))] const SPIN_VARIABLE_HEX: &[u8; 16] = b"0123456789abcdef"; // --------------------------------------------------------------------------- @@ -116,25 +117,22 @@ impl PlatformBackend for NoopBackend { /// Bridges edgezero's [`ConfigStoreHandle`] to [`PlatformConfigStore`]. /// -/// Reads delegate through the handle after mapping Trusted Server keys to Spin -/// variable names. Writes are unsupported on current Spin runtime config and -/// return typed errors. -struct ConfigStoreHandleAdapter(ConfigStoreHandle); +/// Spin config stores are KV-backed, so reads preserve the requested key +/// verbatim. Writes are unsupported on current Spin runtime config and return +/// typed errors. +pub(crate) struct ConfigStoreHandleAdapter(pub(crate) ConfigStoreHandle); impl PlatformConfigStore for ConfigStoreHandleAdapter { fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - let variable_name = spin_variable_name(key, PlatformError::ConfigStore)?; - futures::executor::block_on(self.0.get(&variable_name)) - .map_err(|e| { - Report::new(PlatformError::ConfigStore) - .attach(format!( - "config store lookup failed for key `{key}` as Spin variable `{variable_name}`: {e}" - )) - })? - .ok_or_else(|| { + futures::executor::block_on(self.0.get(key)) + .map_err(|error| { Report::new(PlatformError::ConfigStore).attach(format!( - "key `{key}` not found as Spin variable `{variable_name}`" + "config store lookup failed for key `{key}`: {error}" )) + })? + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore) + .attach(format!("key `{key}` not found in Spin config store")) }) } @@ -149,6 +147,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] fn spin_variable_name( key: &str, error_context: PlatformError, @@ -187,6 +186,7 @@ fn spin_variable_name( Ok(out) } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] fn push_spin_variable_escape(out: &mut String, byte: u8) { out.push('_'); out.push('x'); @@ -676,7 +676,7 @@ fn into_spin_method(method: &edgezero_core::http::Method) -> spin_sdk::http::Met /// with a real secret-provider source (e.g. Vault, Azure Key Vault) to avoid /// storing signing keys in plaintext on disk. #[cfg(all(feature = "spin", target_arch = "wasm32"))] -struct SpinSecretStoreAdapter; +pub(crate) struct SpinSecretStoreAdapter; #[cfg(all(feature = "spin", target_arch = "wasm32"))] impl PlatformSecretStore for SpinSecretStoreAdapter { @@ -794,6 +794,7 @@ mod tests { use super::*; use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; use edgezero_core::context::RequestContext; use edgezero_core::http::request_builder; use edgezero_core::params::PathParams; @@ -801,6 +802,15 @@ mod tests { use flate2::write::GzEncoder; use std::io::Write as _; + struct InMemoryConfigStore(std::collections::BTreeMap); + + #[async_trait::async_trait(?Send)] + impl ConfigStore for InMemoryConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.0.get(key).cloned()) + } + } + fn make_ctx_without_spin_context() -> RequestContext { let req = request_builder() .method("GET") @@ -894,6 +904,29 @@ mod tests { ); } + #[test] + fn config_store_handle_adapter_reads_verbatim_kv_key() { + let handle = ConfigStoreHandle::new(Arc::new(InMemoryConfigStore( + std::collections::BTreeMap::from([( + "trusted_server_config".to_owned(), + "blob-envelope".to_owned(), + )]), + ))); + let adapter = ConfigStoreHandleAdapter(handle); + + let value = adapter + .get( + &StoreName::from("trusted_server_config"), + "trusted_server_config", + ) + .expect("should read the verbatim config-store key"); + + assert_eq!( + value, "blob-envelope", + "should not translate a KV-backed config key into a Spin variable name" + ); + } + #[test] fn spin_variable_name_encodes_trusted_server_keys() { assert_eq!( diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index e74ef4150..19bd7d070 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -9,6 +9,7 @@ use std::borrow::Cow; use std::collections::HashSet; +use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator::{Validate, ValidationError, ValidationErrors}; @@ -25,6 +26,7 @@ use crate::integrations::{ use crate::settings::{IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; +const MIN_PROXY_SECRET_LENGTH: usize = 32; #[cfg(test)] const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ "prebid", @@ -54,15 +56,20 @@ pub struct TrustedServerAppConfig { } impl TrustedServerAppConfig { - /// Creates a validated app-config wrapper from [`Settings`]. + /// Creates a push-valid app-config wrapper from [`Settings`]. /// /// # Errors /// - /// Returns [`TrustedServerError::Configuration`] when deploy validation + /// Returns [`TrustedServerError::Configuration`] when push-safe validation /// fails. pub fn new(settings: Settings) -> Result> { - validate_settings_for_deploy(&settings)?; - Ok(Self { settings }) + let app_config = Self { settings }; + edgezero_core::app_config::validate_excluding_secrets(&app_config).map_err(|errors| { + Report::new(TrustedServerError::Configuration { + message: format!("Configuration validation failed: {errors}"), + }) + })?; + Ok(app_config) } /// Consumes the wrapper and returns the inner [`Settings`]. @@ -92,41 +99,107 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { where D: Deserializer<'de>, { - let settings = Settings::deserialize(deserializer)?; - let settings = Settings::finalize_deserialized(settings, "Configuration") - .map_err(serde::de::Error::custom)?; + let mut settings = Settings::deserialize(deserializer)?; + settings.normalize_deserialized(); Ok(Self { settings }) } } impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { - validate_settings_for_deploy(&self.settings) - .map_err(|report| report_to_validation_errors(&report)) + let mut errors = self.settings.validate().err().unwrap_or_default(); + if let Err(report) = validate_settings_for_deploy(&self.settings) { + errors.add( + DEPLOY_VALIDATION_FIELD, + report_to_validation_error(&report, "trusted_server_deploy_validation"), + ); + } + if errors.errors().is_empty() { + Ok(()) + } else { + Err(errors) + } } } impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { - // Phase 1 intentionally preserves the existing inline-settings model: - // `ts config push` publishes the validated Trusted Server config as one - // app-config blob. Migrating app-level secrets to `EdgeZero` secret-store - // references needs nested/array extraction support and operator migration - // work tracked separately. - const SECRET_FIELDS: &'static [edgezero_core::app_config::SecretField] = &[]; + 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)); + + vec![ + field(vec![object("publisher"), object("proxy_secret")], false), + field(vec![object("ec"), object("passphrase")], false), + field( + vec![ + object("ec"), + object("partners"), + SecretPathSegment::ArrayEach, + object("api_token"), + ], + false, + ), + field( + vec![ + object("ec"), + object("partners"), + SecretPathSegment::ArrayEach, + object("ts_pull_token"), + ], + true, + ), + field( + vec![ + object("handlers"), + SecretPathSegment::ArrayEach, + object("password"), + ], + false, + ), + ] + } } -/// Runs Trusted Server deploy-time validation for pushed app config. +/// Runs Trusted Server push-time validation for app config. /// -/// This supplements [`Settings`] structural validation with checks that should -/// fail before an operator publishes a config blob: placeholder secrets, -/// enabled integration startup checks, auction provider references, and EC -/// partner registry construction. +/// 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. /// /// # Errors /// -/// Returns [`TrustedServerError`] when the config should not be deployed. +/// Returns [`TrustedServerError`] when non-secret configuration or a secret key +/// reference is invalid. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { + validate_secret_key_references(settings)?; + + let mut structural_settings = settings.clone(); + structural_settings.prepare_runtime()?; + structural_settings.validate_admin_coverage()?; + + let enabled_auction_providers = validate_enabled_integrations(settings)?; + validate_auction_provider_names(settings, &enabled_auction_providers)?; + PartnerRegistry::validate_config_for_deploy(&settings.ec.partners)?; + Ok(()) +} + +/// Runs Trusted Server runtime validation after secret references are resolved. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when resolved secrets or runtime-only +/// configuration checks are invalid. +pub fn validate_settings_for_runtime( + settings: &Settings, +) -> Result<(), Report> { settings.reject_placeholder_secrets()?; + validate_proxy_secret_strength(settings)?; + settings.validate_admin_handler_passwords()?; let enabled_auction_providers = validate_enabled_integrations(settings)?; validate_auction_provider_names(settings, &enabled_auction_providers)?; PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; @@ -180,6 +253,59 @@ where .map(|config| config.is_some()) } +fn validate_secret_key_references(settings: &Settings) -> 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() { + validate_secret_key_reference( + &format!("ec.partners[{index}].api_token"), + partner.api_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(), + )?; + } + + Ok(()) +} + +fn validate_secret_key_reference( + path: &str, + key_name: &str, +) -> Result<(), Report> { + if key_name.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("secret key reference at `{path}` must not be empty"), + })); + } + Ok(()) +} + +fn validate_proxy_secret_strength(settings: &Settings) -> Result<(), Report> { + if settings.publisher.proxy_secret.expose().len() < MIN_PROXY_SECRET_LENGTH { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "publisher.proxy_secret must be at least {MIN_PROXY_SECRET_LENGTH} bytes after secret resolution" + ), + })); + } + Ok(()) +} + fn validate_auction_provider_names( settings: &Settings, enabled_auction_providers: &HashSet<&'static str>, @@ -206,19 +332,21 @@ fn validate_auction_provider_names( Ok(()) } -fn report_to_validation_errors(report: &Report) -> ValidationErrors { - let mut error = ValidationError::new("trusted_server_deploy_validation"); +fn report_to_validation_error( + report: &Report, + code: &'static str, +) -> ValidationError { + let mut error = ValidationError::new(code); error.message = Some(Cow::Owned(report.to_string())); - - let mut errors = ValidationErrors::new(); - errors.add(DEPLOY_VALIDATION_FIELD, error); - errors + error } #[cfg(test)] mod tests { use super::*; + use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; + use edgezero_core::app_config::AppConfigMeta; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -233,7 +361,9 @@ mod tests { slot: Vec, } - fn serialized_creative_opportunities(gam_unit_path: Option<&str>) -> serde_json::Value { + fn app_config_with_creative_opportunities( + gam_unit_path: Option<&str>, + ) -> TrustedServerAppConfig { let mut toml = crate_test_settings_str(); toml.push_str( r#" @@ -251,9 +381,15 @@ formats = [{ width = 300, height = 250 }] toml.push_str(&format!("gam_unit_path = {gam_unit_path:?}\n")); } - let app_config: TrustedServerAppConfig = + let mut app_config: TrustedServerAppConfig = toml::from_str(&toml).expect("should deserialize app config wrapper"); - serde_json::to_value(app_config) + app_config.settings.proxy.allowed_domains = + vec!["*.example".to_owned(), "*.example.com".to_owned()]; + app_config + } + + fn serialized_creative_opportunities(gam_unit_path: Option<&str>) -> serde_json::Value { + serde_json::to_value(app_config_with_creative_opportunities(gam_unit_path)) .expect("should serialize app config wrapper") .get("creative_opportunities") .cloned() @@ -297,18 +433,65 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn dynamic_gam_unit_templates_are_rejected_by_legacy_schema() { - for gam_unit_path in ["/{network_id}/example", "/example/{slot_id}"] { - let creative_opportunities = serialized_creative_opportunities(Some(gam_unit_path)); - let err = - serde_json::from_value::(creative_opportunities) - .expect_err("should reject dynamic GAM unit template"); + fn push_validation_accepts_secret_key_names() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new("publisher_proxy".to_owned()); + settings.ec.passphrase = Redacted::new("ec_key".to_owned()); + settings.handlers[0].password = Redacted::new("handler_password".to_owned()); + settings.handlers[1].password = Redacted::new("admin_password".to_owned()); + let app_config = TrustedServerAppConfig::new(settings) + .expect("should validate key names without values"); + + let serialized = + serde_json::to_string(&app_config).expect("should serialize key-name-only app config"); + assert!(serialized.contains("publisher_proxy")); + assert!(!serialized.contains("unit-test-proxy-secret")); + } - assert!( - err.to_string().contains("section_segment"), - "legacy error should name section_segment: {err}" - ); - } + #[test] + fn secret_metadata_lists_all_secret_paths_and_optionality() { + let fields = TrustedServerAppConfig::secret_fields(); + let paths = fields + .iter() + .map(|field| (field.dotted_path(), field.optional)) + .collect::>(); + + assert_eq!( + paths, + vec![ + ("publisher.proxy_secret".to_owned(), false), + ("ec.passphrase".to_owned(), false), + ("ec.partners[*].api_token".to_owned(), false), + ("ec.partners[*].ts_pull_token".to_owned(), true), + ("handlers[*].password".to_owned(), false), + ], + "should expose the native EdgeZero secret metadata contract" + ); + assert!( + fields.iter().all(|field| matches!( + field.kind, + edgezero_core::app_config::SecretKind::KeyInDefault + )), + "all Trusted Server app secrets should use the default secret store" + ); + } + + #[test] + fn app_config_deserialization_does_not_finalize_runtime_templates() { + let creative_opportunities = + serialized_creative_opportunities(Some("/{network_id}/example")); + let slot = creative_opportunities["slot"][0] + .as_object() + .expect("should serialize creative opportunity slot"); + + assert!( + slot.contains_key("gam_unit_path"), + "push deserialization should preserve the operator config field" + ); + assert!( + !slot.contains_key("section_segment"), + "push deserialization should not add runtime-only compiled fields" + ); } #[test] @@ -328,7 +511,53 @@ formats = [{ width = 300, height = 250 }] } #[test] - fn deploy_validation_rejects_placeholders() { + fn app_config_new_rejects_empty_secret_key_reference() { + 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:?}" + ); + } + + #[test] + fn app_config_new_rejects_invalid_non_secret_settings() { + let mut settings = valid_settings(); + settings.publisher.domain = "invalid/domain".to_owned(); + + let err = TrustedServerAppConfig::new(settings) + .expect_err("should reject invalid publisher domain before creating an app config"); + + assert!( + err.to_string().contains("invalid_publisher_domain"), + "error should identify the structural validation failure: {err:?}" + ); + } + + #[test] + fn runtime_validation_rejects_short_proxy_secret() { + let mut settings = valid_settings(); + settings.publisher.proxy_secret = Redacted::new("short".to_owned()); + + let err = validate_settings_for_runtime(&settings) + .expect_err("should reject a short resolved proxy secret"); + + assert!( + err.to_string().contains("at least 32 bytes"), + "error should identify the required proxy-secret strength: {err:?}" + ); + assert!( + !err.to_string().contains("short"), + "error should not expose the resolved secret" + ); + } + + #[test] + fn runtime_validation_rejects_placeholders() { let settings = Settings::from_toml( r#" [publisher] @@ -346,10 +575,10 @@ username = "admin" password = "production-admin-password-32-bytes" "#, ) - .expect("should parse placeholder settings before deploy validation"); + .expect("should parse placeholder settings before runtime validation"); - let err = - validate_settings_for_deploy(&settings).expect_err("should reject placeholder secrets"); + let err = validate_settings_for_runtime(&settings) + .expect_err("should reject placeholder secrets at runtime"); assert!( err.to_string().contains("Insecure default"), diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..fa56ca59e 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -8,20 +8,32 @@ use edgezero_core::blob_envelope::BlobEnvelope; 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; +/// Canonical logical secret store used by Trusted Server app-config secrets. +pub const DEFAULT_SECRET_STORE_ID: &str = "trusted_server_secrets"; + /// Default config-store key containing the Trusted Server app-config blob. pub const CONFIG_BLOB_KEY: &str = "trusted_server_config"; -/// Reconstruct validated [`Settings`] from a serialized config blob envelope. +/// Reconstruct runtime [`Settings`] from a serialized config blob envelope. +/// +/// Secret references are resolved after envelope verification and before +/// deserialization. The envelope data itself is never mutated or rewritten. /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the envelope cannot be -/// parsed, fails integrity verification, or contains invalid settings data. +/// parsed, fails integrity verification, secret resolution fails, or resolved +/// settings are invalid. 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 { @@ -36,14 +48,21 @@ pub fn settings_from_config_blob( .attach(error.to_string()) })?; - let settings = Settings::from_json_value(envelope.into_data())?; - settings.reject_placeholder_secrets()?; + let mut data = envelope.into_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) } #[cfg(test)] mod tests { use super::*; + use crate::platform::{PlatformError, StoreId}; use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; use serde::Deserialize; @@ -69,7 +88,40 @@ mod tests { } fn test_settings() -> Settings { - Settings::from_toml(&crate_test_settings_str()).expect("should parse test 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()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } } fn envelope_json(settings: &Settings) -> String { @@ -78,11 +130,19 @@ mod tests { 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"), + ) + } + #[test] fn payload_round_trips_through_blob_envelope() { let original = test_settings(); - let reconstructed = settings_from_config_blob(&envelope_json(&original)) - .expect("should reconstruct settings"); + let reconstructed = + load_settings(&envelope_json(&original)).expect("should reconstruct settings"); assert_eq!( reconstructed.publisher.domain, original.publisher.domain, @@ -115,7 +175,7 @@ mod tests { let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); let reconstructed = - settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); + load_settings(&envelope_json).expect("should reconstruct legacy settings"); assert!( reconstructed.auction.rewrite_creatives, @@ -141,7 +201,7 @@ mod tests { let mut original = test_settings(); original.auction.rewrite_creatives = false; - let reconstructed = settings_from_config_blob(&envelope_json(&original)) + let reconstructed = load_settings(&envelope_json(&original)) .expect("should reconstruct disabled rewriting"); assert!( @@ -153,12 +213,13 @@ mod tests { #[test] fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); - original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); + original.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 = settings_from_config_blob(&envelope_json(&original)) - .expect("should reconstruct settings"); + let reconstructed = + load_settings(&envelope_json(&original)).expect("should reconstruct settings"); assert_eq!( reconstructed.publisher.proxy_secret.expose(), @@ -177,6 +238,60 @@ mod tests { ); } + #[test] + fn runtime_validation_rejects_short_resolved_proxy_secret() { + let mut settings = test_settings(); + settings.publisher.proxy_secret = Redacted::new("short_proxy".to_owned()); + + let err = load_settings(&envelope_json(&settings)) + .expect_err("should reject a short resolved proxy secret"); + + assert!( + err.to_string().contains("at least 32 bytes"), + "error should indicate runtime validation: {err:?}" + ); + assert!( + !err.to_string().contains("short_proxy"), + "error should not expose the secret value" + ); + } + + #[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:?}" + ); + assert!( + !err.to_string().contains("change-me-proxy-secret"), + "error should not expose the resolved secret value" + ); + } + #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = @@ -185,7 +300,7 @@ mod tests { let tampered = serde_json::to_string(&envelope).expect("should serialize tampered envelope"); - let err = settings_from_config_blob(&tampered).expect_err("should reject hash mismatch"); + let err = load_settings(&tampered).expect_err("should reject hash mismatch"); assert!( err.to_string().contains("integrity verification"), diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index 8532de03b..847fe70c1 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -61,6 +61,68 @@ pub struct PartnerRegistry { } 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. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when non-secret partner + /// structure is invalid. + pub fn validate_config_for_deploy( + partners: &[EcPartner], + ) -> Result<(), Report> { + let mut source_domains = HashMap::with_capacity(partners.len()); + + for partner in partners { + let normalized_source = normalize_partner_source_domain(&partner.source_domain) + .map_err(|msg| { + Report::new(TrustedServerError::Configuration { + message: format!("ec.partners: {msg}"), + }) + })?; + + if source_domains + .insert(normalized_source.clone(), ()) + .is_some() + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("ec.partners: duplicate source_domain '{normalized_source}'"), + })); + } + + validate_rate_limits_values(partner.batch_rate_limit, partner.pull_sync_rate_limit) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "ec.partners: invalid rate limits for '{normalized_source}': {error}" + ), + }) + })?; + + 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}'" + ), + })?; + } + } + + Ok(()) + } + /// Builds a registry from the config-defined partner list. /// /// # Errors @@ -231,34 +293,56 @@ fn build_partner_config( } fn validate_rate_limits(config: &PartnerConfig) -> Result<(), Report> { - if config.batch_rate_limit == 0 { - return Err(Report::new(TrustedServerError::Configuration { - message: "batch_rate_limit must be greater than 0".to_owned(), - })); + validate_rate_limits_values(config.batch_rate_limit, config.pull_sync_rate_limit).map_err( + |message| { + Report::new(TrustedServerError::Configuration { + message: message.to_owned(), + }) + }, + ) +} + +fn validate_rate_limits_values( + batch_rate_limit: u32, + pull_sync_rate_limit: u32, +) -> Result<(), &'static str> { + if batch_rate_limit == 0 { + return Err("batch_rate_limit must be greater than 0"); } - if config.pull_sync_rate_limit == 0 { - return Err(Report::new(TrustedServerError::Configuration { - message: "pull_sync_rate_limit must be greater than 0".to_owned(), - })); + if pull_sync_rate_limit == 0 { + return Err("pull_sync_rate_limit must be greater than 0"); } Ok(()) } fn validate_pull_sync(config: &PartnerConfig) -> Result<(), Report> { - let url_str = config.pull_sync_url.as_deref().unwrap_or(""); + 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 config - .ts_pull_token - .as_ref() - .is_none_or(|token| token.expose().trim().is_empty()) - { + 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(), })); @@ -289,7 +373,7 @@ fn validate_pull_sync(config: &PartnerConfig) -> Result<(), Report Result<(), Report( + data: &mut Value, + secret_store: &dyn PlatformSecretStore, + default_store_name: &StoreName, +) -> Result<(), Report> { + for field in C::secret_fields() { + if matches!(field.kind, SecretKind::StoreRef) { + continue; + } + resolve_field( + data, + &field, + &field.path, + "", + secret_store, + default_store_name, + )?; + } + 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::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::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(()) + } + 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::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}`" + )) + })?; + 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("missing test secret") + }) + } + + 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()), + ], + }, + ] + } + } + + 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()), + ]), + } + } + + #[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 rejects_missing_required_path_without_secret_values() { + let mut data = serde_json::json!({"outer": [{}]}); + let err = + resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) + .expect_err("should reject missing required secret path"); + + assert!(err.to_string().contains("outer[0].token")); + assert!(!err.to_string().contains("resolved-a")); + } + + #[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 598c00056..91aa7ff62 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2015,19 +2015,25 @@ impl Settings { Self::finalize_deserialized(settings, "Build-time configuration") } + pub(crate) fn normalize_deserialized(&mut self) { + self.proxy.normalize(); + self.image_optimizer.normalize(); + self.consent.validate(); + } + pub(crate) fn finalize_deserialized( mut settings: Self, validation_label: &str, ) -> Result> { - settings.proxy.normalize(); - settings.image_optimizer.normalize(); - settings.consent.validate(); - + settings.normalize_deserialized(); settings.prepare_runtime()?; settings.validate().map_err(|err| { Report::new(TrustedServerError::Configuration { - message: format!("{validation_label} validation failed: {err}"), + message: format!( + "{validation_label} validation failed: {}", + validation_error_summary(&err) + ), }) })?; @@ -2235,7 +2241,7 @@ impl Settings { /// /// Returns [`TrustedServerError::Configuration`] listing any uncovered /// admin endpoints. - fn validate_admin_coverage(&self) -> Result<(), Report> { + pub(crate) fn validate_admin_coverage(&self) -> Result<(), Report> { let uncovered = self.uncovered_admin_endpoints()?; if uncovered.is_empty() { return Ok(()); @@ -2250,7 +2256,9 @@ impl Settings { })) } - fn validate_admin_handler_passwords(&self) -> Result<(), Report> { + pub(crate) fn validate_admin_handler_passwords( + &self, + ) -> Result<(), Report> { for handler in &self.handlers { let covers_admin = Self::ADMIN_ENDPOINTS .iter() @@ -2351,6 +2359,47 @@ fn validate_host_header_override(value: &str) -> Result<(), ValidationError> { Ok(()) } +fn validation_error_summary(errors: &validator::ValidationErrors) -> String { + fn walk(errors: &validator::ValidationErrors, prefix: &str, messages: &mut Vec) { + let mut fields = errors + .errors() + .keys() + .map(AsRef::as_ref) + .collect::>(); + fields.sort_unstable(); + + for field in fields { + let path = if prefix.is_empty() { + field.to_owned() + } else { + format!("{prefix}.{field}") + }; + let Some(kind) = errors.errors().get(field) else { + continue; + }; + match kind { + validator::ValidationErrorsKind::Field(validations) => { + for validation in validations { + messages.push(format!("{path}: {}", validation.code)); + } + } + validator::ValidationErrorsKind::Struct(inner) => { + walk(inner, &path, messages); + } + validator::ValidationErrorsKind::List(items) => { + for (index, inner) in items { + walk(inner, &format!("{path}[{index}]"), messages); + } + } + } + } + } + + let mut messages = Vec::new(); + walk(errors, "", &mut messages); + messages.join(", ") +} + fn validate_redacted_not_empty(value: &Redacted) -> Result<(), ValidationError> { if value.expose().is_empty() { return Err(ValidationError::new("empty_value")); diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 06ea548fc..bec1e4ad3 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -3,9 +3,10 @@ use error_stack::{Report, ResultExt}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; +use crate::config_payload::DEFAULT_SECRET_STORE_ID; use crate::config_payload::settings_from_config_blob; use crate::error::TrustedServerError; -use crate::platform::{PlatformConfigStore, StoreName}; +use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; use crate::settings::Settings; const DEFAULT_CONFIG_STORE_ID: &str = "trusted_server_config"; @@ -40,21 +41,29 @@ pub fn default_config_key() -> String { EnvConfig::from_env().store_key("config", DEFAULT_CONFIG_STORE_ID) } +/// Returns the default `EdgeZero` secret-store name for Trusted Server secrets. +#[must_use] +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. /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the config blob is -/// missing, cannot be read, fails envelope verification, or fails Trusted -/// Server settings validation. +/// missing, cannot be read, fails envelope verification, secret resolution, +/// or Trusted Server settings validation. pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, + secret_store: &dyn PlatformSecretStore, store_name: &StoreName, key: &str, + default_secret_store_name: &StoreName, ) -> 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) + settings_from_config_blob(&envelope_json, secret_store, default_secret_store_name) } fn read_config_entry( @@ -177,7 +186,7 @@ fn configuration_error(message: String) -> Result Result<(), Report> { Ok(()) } - fn delete( + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Ok(()) + } + } + + struct EchoSecretStore; + + impl PlatformSecretStore for EchoSecretStore { + fn get_bytes( &self, - _store_id: &crate::platform::StoreId, - _key: &str, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let value = match key { + "unit-test-proxy-secret" => "unit-test-proxy-secret-32-bytes-ok", + _ => key, + }; + Ok(value.as_bytes().to_vec()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, ) -> Result<(), Report> { Ok(()) } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } } fn envelope_json(settings: &Settings) -> String { @@ -219,18 +253,32 @@ mod tests { serde_json::to_string(&envelope).expect("should serialize envelope") } + fn load_settings( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, + key: &str, + ) -> Result> { + get_settings_from_config_store( + config_store, + &EchoSecretStore, + store_name, + key, + &StoreName::from("trusted_server_secrets"), + ) + } + #[test] fn loads_settings_from_config_blob_entry() { - let 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()]; let envelope_json = envelope_json(&settings); let store = MemoryConfigStore { entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), envelope_json)]), }; - let loaded = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + let loaded = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, @@ -240,8 +288,9 @@ mod tests { #[test] fn loads_settings_from_fastly_chunk_pointer() { - let 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()]; let envelope_json = envelope_json(&settings); let midpoint = envelope_json.len() / 2; let first_chunk = envelope_json[..midpoint].to_string(); @@ -275,9 +324,8 @@ mod tests { ]), }; - let loaded = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + let loaded = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, @@ -306,9 +354,8 @@ mod tests { entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), pointer)]), }; - let err = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect_err("should reject malformed chunk length metadata"); + let err = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect_err("should reject malformed chunk length metadata"); assert!( err.to_string().contains("chunk lengths total mismatch"), @@ -322,9 +369,8 @@ mod tests { entries: BTreeMap::new(), }; - let err = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect_err("should fail when blob is missing"); + let err = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) + .expect_err("should fail when blob is missing"); assert!( err.to_string().contains(CONFIG_BLOB_KEY), diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index f2319fec8..7477fdbd1 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -23,6 +23,7 @@ workspace = true [dependencies] edgezero-core = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } trusted-server-core = { workspace = true } [dev-dependencies] @@ -40,7 +41,6 @@ reqwest = { workspace = true, features = ["blocking", "cookies"] } scraper = { workspace = true } testcontainers = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } -toml = { workspace = true } tower = { workspace = true, features = ["util"] } trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } trusted-server-adapter-cloudflare = { path = "../trusted-server-adapter-cloudflare" } 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 17d7c2713..2a3dec32d 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,16 @@ [[handlers]] path = "^/_ts/admin" username = "admin" -password = "integration-admin-password-32-bytes-ok" +password = "integration_admin_password" [publisher] domain = "localhost" cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" -proxy_secret = "integration-test-proxy-secret" +proxy_secret = "integration_proxy_secret" [ec] -passphrase = "integration-test-ec-secret-padded-32" +passphrase = "integration_ec_passphrase" ec_store = "ec_identity_store" pull_sync_concurrency = 3 @@ -18,13 +18,13 @@ pull_sync_concurrency = 3 name = "Integration Test Partner" source_domain = "inttest.example.com" bidstream_enabled = true -api_token = "integration-test-token-alpha-32-bytes-ok" +api_token = "integration_partner_token_alpha" [[ec.partners]] name = "Integration Test Partner 2" source_domain = "inttest2.example.com" bidstream_enabled = true -api_token = "integration-test-token-bravo-32-bytes-ok" +api_token = "integration_partner_token_bravo" [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 9f1443d20..aa025b6c7 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -66,6 +66,22 @@ 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" + [local_server.config_stores] # Generated integration configs inject the trusted_server_config blob # into the store required by the Fastly entry point. 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 85b1bcf0f..58c26736e 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 @@ -4,7 +4,7 @@ use std::fs; use std::path::PathBuf; use edgezero_core::blob_envelope::BlobEnvelope; -use trusted_server_core::{config::validate_settings_for_deploy, settings::Settings}; +use trusted_server_core::config::TrustedServerAppConfig; const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const GENERATED_STORES_MARKER: &str = " # GENERATED_TRUSTED_SERVER_CONFIG_STORES"; @@ -114,15 +114,16 @@ fn build_app_config_envelope( app_config_toml: &str, origin_url: Option<&str>, ) -> Result { - let mut settings = Settings::from_toml(app_config_toml) - .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; + 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(); } - validate_settings_for_deploy(&settings) + let app_config = TrustedServerAppConfig::new(settings) .map_err(|report| error_box(format!("invalid Trusted Server app config: {report:?}")))?; - let data = serde_json::to_value(&settings).map_err(|error| { + let data = serde_json::to_value(&app_config).map_err(|error| { error_box(format!( "failed to serialize Trusted Server app config to JSON: {error}" )) @@ -161,11 +162,71 @@ fn error_box(message: impl Into) -> DynError { #[cfg(test)] mod tests { use super::*; + use error_stack::Report; + use std::collections::HashMap; use trusted_server_core::config_payload::settings_from_config_blob; + use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; const TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml"); const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); + struct IntegrationSecretStore { + values: HashMap>, + } + + impl PlatformSecretStore for IntegrationSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + self.values + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::SecretStore)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } + } + + fn integration_secret_store() -> IntegrationSecretStore { + 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(), + ), + ]), + } + } + #[test] fn parse_args_does_not_require_removed_rollout_switch() { let result = parse_args([ @@ -253,7 +314,12 @@ mod tests { fn generated_blob_verifies_and_applies_origin_override() { let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999")) .expect("should build envelope"); - let settings = settings_from_config_blob(&envelope).expect("should verify blob"); + let settings = settings_from_config_blob( + &envelope, + &integration_secret_store(), + &StoreName::from("trusted_server_secrets"), + ) + .expect("should verify blob"); assert_eq!( settings.publisher.origin_url, "http://127.0.0.1:9999", @@ -268,6 +334,19 @@ mod tests { assert!(result.is_err(), "should reject invalid app config"); } + #[test] + fn invalid_non_secret_app_config_fails_before_envelope_generation() { + let invalid = APP_CONFIG.replace("domain = \"localhost\"", "domain = \"invalid/domain\""); + + let err = build_app_config_envelope(&invalid, None) + .expect_err("should reject invalid non-secret config before creating an envelope"); + + assert!( + err.to_string().contains("invalid_publisher_domain"), + "error should identify the structural validation failure: {err}" + ); + } + #[test] fn missing_marker_fails() { let result = inject_generated_config_stores("[local_server]", "{}"); diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 4dc971d0e..037fa4658 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -1,7 +1,6 @@ use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; -use trusted_server_core::config::validate_settings_for_deploy; -use trusted_server_core::settings::Settings; +use trusted_server_core::config::TrustedServerAppConfig; use crate::common::runtime::{TestError, TestResult}; @@ -10,18 +9,19 @@ const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.int pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { let origin_url = format!("http://127.0.0.1:{origin_port}"); - let mut settings = Settings::from_toml(APP_CONFIG).map_err(|report| { + let app_config: TrustedServerAppConfig = toml::from_str(APP_CONFIG).map_err(|error| { Report::new(TestError::ConfigGeneration).attach(format!( - "invalid Trusted Server integration config: {report:?}" + "invalid Trusted Server integration config: {error}" )) })?; + let mut settings = app_config.into_settings(); settings.publisher.origin_url = origin_url; - validate_settings_for_deploy(&settings).map_err(|report| { + let app_config = TrustedServerAppConfig::new(settings).map_err(|report| { Report::new(TestError::ConfigGeneration) .attach(format!("invalid generated integration config: {report:?}")) })?; - let data = serde_json::to_value(&settings).map_err(|error| { + let data = serde_json::to_value(&app_config).map_err(|error| { Report::new(TestError::ConfigGeneration) .attach(format!("failed to serialize integration settings: {error}")) })?; diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 235af413f..3623d8491 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -10,6 +10,30 @@ use std::process::{Child, Command, Stdio}; /// 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 @@ -40,6 +64,7 @@ impl RuntimeEnvironment for AxumDevServer { "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", app_config, ) + .envs(INTEGRATION_SECRET_ENV.iter().copied()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ddb6544ce..7bc737d5b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -6,9 +6,9 @@ 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 base configuration +1. **TOML Files** - `trusted-server.toml` for ordinary configuration and secret key names 2. **Environment Variables** - Typed CLI overrides with the `TRUSTED_SERVER__` prefix -3. **Fastly Stores** - KV/Config/Secret stores for runtime data +3. **EdgeZero Stores** - Config and secret stores for the pushed blob and runtime secret values ## Quick Start @@ -21,10 +21,10 @@ Create `trusted-server.toml` in your project root: domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "your-secure-secret-here" +proxy_secret = "publisher_proxy_secret" [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "ec_passphrase" ``` ### Environment Variable Overrides @@ -37,16 +37,43 @@ read by the deployed application at request time. # Format: TRUSTED_SERVER__SECTION__FIELD export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -export TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +# 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 ts config validate ts config push --adapter fastly ``` +### Secret-store migration + +The five app-config secret fields contain stable key names only: +`publisher.proxy_secret`, `ec.passphrase`, `ec.partners[*].api_token`, +`ec.partners[*].ts_pull_token` (when used), and `handlers[*].password`. +Their values belong in the logical `trusted_server_secrets` store and are +resolved only while an instance builds runtime settings. + +Migrate an existing deployment in this order: + +1. Create/populate `trusted_server_secrets` with the existing credential values + without printing them in shell history, logs, or CI output. +2. Replace the five config values with stable key names. +3. Run `ts config validate`, then `ts config push --adapter fastly`. +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, declare a component variable +for each chosen 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. + ### Generate Secure Secrets +Generate values locally and write them directly to the platform secret store; +do not put the generated output in `trusted-server.toml` or the app-config blob. + ```bash -# Generate cryptographically random secrets openssl rand -base64 32 ``` @@ -84,10 +111,10 @@ fail and the service will return its startup-error response. domain = "publisher.com" cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" -proxy_secret = "change-me-to-secure-value" +proxy_secret = "publisher_proxy_secret" [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "ec_passphrase" [request_signing] enabled = true @@ -114,9 +141,10 @@ base TOML configuration by `ts config validate`, `ts config diff`, and stored in the app-config blob. Changing an environment variable requires rerunning validation and pushing the resolved config, not rebuilding the binary. -EdgeZero v0.0.4 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. Pass `--no-env` +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. ### Format @@ -177,7 +205,7 @@ 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 key for encrypting/signing proxy URLs | +| `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) | > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and @@ -192,7 +220,7 @@ cookie_domain = ".publisher.com" origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" -proxy_secret = "change-me-to-secure-random-value" +proxy_secret = "publisher_proxy_secret" ``` **Environment Override**: @@ -202,7 +230,7 @@ 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=your-secret-here +TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=publisher_proxy_secret TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` @@ -281,21 +309,12 @@ connecting to the host in `origin_url`. #### `proxy_secret` -**Purpose**: Secret key for HMAC-SHA256 signing of proxy URLs. - -**Security**: - -- Keep confidential and secure -- Rotate periodically (90 days recommended) -- Use cryptographically random values (32+ bytes) -- Never commit to version control +**Purpose**: Secret-store key name for the HMAC-SHA256 value used to sign proxy URLs. -**Generation**: - -```bash -# Generate secure random secret -openssl rand -base64 32 -``` +The referenced value is resolved from `trusted_server_secrets` at startup. It +must be at least 32 bytes, so generate it with a cryptographically secure random +source. Keep that value confidential, rotate it only intentionally, and never +put it in the TOML file or pushed app-config blob. **Usage**: @@ -404,6 +423,9 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[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. + | Field | Type | Required | Description | | ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- | | `passphrase` | String | Yes | Publisher passphrase used as HMAC key | @@ -421,20 +443,21 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +passphrase = "ec_passphrase" ec_store = "ec_identity_store" [[ec.partners]] name = "Mocktioneer SSP" source_domain = "mocktioneer.example" -api_token = "partner-api-token-32-bytes-minimum" +api_token = "partner_api_token" bidstream_enabled = true +# ts_pull_token = "partner_ts_pull_token" # only when pull sync is enabled ``` **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=your-secret +TRUSTED_SERVER__EC__PASSPHRASE=ec_passphrase TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` @@ -442,20 +465,13 @@ TRUSTED_SERVER__EC__EC_STORE=ec_identity_store #### `passphrase` -**Purpose**: Publisher passphrase used as HMAC key for EC ID generation. +**Purpose**: Secret-store key name whose resolved value is the HMAC key for EC ID generation. **Security**: -- Must be non-empty -- Rotate periodically for security -- Store securely (environment variable recommended) - -**Generation**: - -```bash -# Generate secure random key -openssl rand -hex 32 -``` +- 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 **Validation**: Application startup fails if: @@ -592,18 +608,18 @@ Path-based HTTP Basic Authentication. [[handlers]] path = "^/_ts/admin" username = "admin" -password = "secure-password" +password = "admin_password" # Multiple handlers [[handlers]] path = "^/secure" username = "user1" -password = "pass1" +password = "secure_handler_password" [[handlers]] path = "^/api/private" username = "api-user" -password = "api-pass" +password = "api_handler_password" ``` **Environment Override**: @@ -612,12 +628,12 @@ password = "api-pass" # Handler 0 TRUSTED_SERVER__HANDLERS__0__PATH="^/_ts/admin" TRUSTED_SERVER__HANDLERS__0__USERNAME="admin" -TRUSTED_SERVER__HANDLERS__0__PASSWORD="secure-password" +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-pass" +TRUSTED_SERVER__HANDLERS__1__PASSWORD="api_handler_password" ``` ### Path Patterns @@ -674,10 +690,9 @@ scheduled for removal **Password Storage**: -- Stored in plain text in config -- Use environment variables in production -- Rotate passwords regularly -- Consider using Fastly Secret Store +- `handlers[*].password` is a key name in `trusted_server_secrets` +- Store the resolved password only in the platform secret store +- Rotate passwords through the store and restart/redeploy instances **Limitations**: @@ -687,12 +702,9 @@ scheduled for removal - No rate limiting (add at edge) ::: warning Production Use -For production, store credentials in environment variables: - -```bash -TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) -``` - +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. ::: ## URL Rewrite Configuration @@ -1292,7 +1304,7 @@ remove that field's non-default value (and any environment override), run `ts config validate`, push the resulting default-compatible blob, and only then roll back the binary. -**Environment overlays:** EdgeZero v0.0.4 overlays cannot create missing TOML +**Environment overlays:** The pinned EdgeZero loader cannot create missing TOML leaves. Existing configs must add **both** leaves under `[auction]` (`rewrite_creatives` and `sanitize_creatives`) before `TRUSTED_SERVER__AUCTION__REWRITE_CREATIVES` / @@ -1506,14 +1518,15 @@ Configuration is validated at startup: **EC Validation**: -- `passphrase` ≥ 1 character -- `passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"` — case-insensitive) +- The `passphrase` key name is non-empty at push time +- The resolved passphrase is at least 32 bytes at runtime +- Known placeholder values are rejected after resolution **Handler Validation**: - `path` is valid regex -- `username` non-empty -- `password` non-empty +- `username` is ordinary configuration and non-empty +- The resolved `password` is non-empty and is checked for placeholders at runtime **Integration Validation**: @@ -1548,37 +1561,29 @@ server_url: must not be empty [publisher] domain = "localhost" origin_url = "http://localhost:3000" -proxy_secret = "dev-secret" +proxy_secret = "publisher_proxy_secret" ``` -**Staging**: - -```bash -# .env.staging -TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://staging.publisher.com -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret_staging) -``` +**Staging and production**: -**Production**: - -```bash -# All secrets from environment -TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret) -TRUSTED_SERVER__EC__PASSPHRASE=$(cat /run/secrets/ec_secret) -TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) -``` +- Provision the same key names in the target `trusted_server_secrets` store. +- Keep only the key names in `trusted-server.toml` and environment overlays. +- Push the config after provisioning and restart/redeploy after rotation. ### Secret Management **Do**: -✅ Use environment variables for secrets -✅ Rotate secrets periodically -✅ Generate cryptographically random values -✅ Store in secure secret management (Fastly Secret Store, Vault) -✅ Use different secrets per environment +✅ Store values in the platform secret store +✅ 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 **Don't**: -❌ Commit secrets to version control +❌ Commit secret values to version control +❌ Put secret values in environment overlays +❌ Put secret values in config diff output or app-config blobs +❌ Treat missing secret-store keys as inline values ❌ Use default/placeholder values ❌ Share secrets across environments ❌ Log secret values @@ -1618,10 +1623,10 @@ trusted-server.dev.toml # Development overrides **"Configuration field '...' is set to a known placeholder value"**: -- `ec.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) -- `publisher.proxy_secret` cannot be `"change-me-proxy-secret"` (case-insensitive) -- Must be non-empty -- Change to a secure random value (see generation commands above) +- Confirm the referenced key exists in `trusted_server_secrets` +- 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 +- Rotate the value in the platform secret store, then restart/redeploy **"Invalid regex"**: @@ -1638,7 +1643,7 @@ trusted-server.dev.toml # Development overrides **Environment Variables Not Applied**: - Run the override through `ts config validate`, `ts config diff`, or `ts config push` -- Verify the target leaf already exists in `trusted-server.toml`; EdgeZero v0.0.4 does not create missing fields +- Verify the target leaf already exists in `trusted-server.toml`; the pinned EdgeZero loader does not create missing fields - Verify prefix: `TRUSTED_SERVER__` - Check separator: `__` (double underscore) - Confirm the variable is exported: `echo $VARIABLE_NAME` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9314f983b..760a747cc 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -65,18 +65,29 @@ The server will be available at `http://localhost:7676`. No Fastly account, CLI, or Viceroy needed. Runs natively on your machine. -The Axum adapter reads configuration from environment variables — it does **not** -auto-load `.env` files. You must export the variables into your shell before starting -the server. +The Axum adapter reads the EdgeZero config blob and secret store from +environment variables — it does **not** auto-load `.env` files. You must export +the variables into your shell before starting the server. ```bash -# Copy and edit the environment file +# Create the local app config and apply the non-secret development overlay. +cp trusted-server.example.toml trusted-server.toml cp .env.dev .env - -# Export the variables into your current shell session set -a && source .env && set +a -# Build and start the dev server +# Create the local blob-backed config-store entry. +ts config push --adapter axum --local --yes +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)" + +# Build and start the dev server in the same shell. cargo run -p trusted-server-adapter-axum ``` @@ -85,12 +96,16 @@ The server will be available at `http://localhost:8787`. Set `PORT=` befor **Environment variable conventions used by the Axum adapter:** -| Purpose | Pattern | Example | -| ------------------ | ------------------------------------- | -------------------------------------------------------- | -| Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_SETTINGS_AD_SERVER_URL=https://…` | -| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_KEYS_SIGNING_KEY=abc123` | +| 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=…` | -Store names and key names are uppercased with hyphens and dots replaced by underscores. +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 and dots replaced by underscores. 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. > **Dev server limitations:** The Axum adapter does not support KV store, > geo lookup, config/secret-store writes, or admin key-management routes. @@ -131,7 +146,8 @@ ts audit https://publisher.example ``` The audit command writes `js-assets.toml` plus a draft `trusted-server.toml`. -Review the draft, replace placeholders/secrets, then validate it. +Review the draft, replace placeholders with stable secret key names, then +validate it. Edit `trusted-server.toml` to configure: @@ -139,14 +155,18 @@ Edit `trusted-server.toml` to configure: - KV store mappings - EC configuration - Consent settings (`[gdpr]`) +- Stable key names for `trusted_server_secrets` -Validate the config before pushing it to platform storage: +Provision `trusted_server_secrets` with the existing credential values before +pushing a migrated config. Then validate and push: ```bash ts config validate +ts config push --adapter fastly ``` -See [Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. +Restart or redeploy instances after secret rotation. See +[Configuration](/guide/configuration) and [Trusted Server CLI](/guide/cli) for details. ## Deploy to Fastly diff --git a/fastly.toml b/fastly.toml index 56002bc5a..9d44a3e10 100644 --- a/fastly.toml +++ b/fastly.toml @@ -61,6 +61,12 @@ build = """ key = "tinybird_access_append_token" data = "test-tinybird-access-append-token" + # App-config secret references resolve from this canonical logical store. + # Populate production values through the EdgeZero secret-store workflow. + [[local_server.secret_stores.trusted_server_secrets]] + key = "placeholder" + data = "placeholder" + [local_server.config_stores] [local_server.config_stores.trusted_server_config] format = "inline-toml" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 19ecda4a5..6169559a7 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,7 +1,7 @@ [[handlers]] path = "^/_ts/admin" username = "admin" -password = "replace-with-admin-password-32-bytes" +password = "handler_password" [publisher] domain = "example.com" @@ -9,23 +9,29 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" # Optional: override outbound Host header while connecting to origin_url. # origin_host_header_override = "www.example.com" -proxy_secret = "change-me-proxy-secret" +proxy_secret = "publisher_proxy_secret" [ec] -passphrase = "trusted-server-placeholder-secret" +passphrase = "ec_passphrase" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +# Keep this empty when no partners are configured. Replace this line with +# `[[ec.partners]]` entries when adding partners. +partners = [] # cluster_trust_threshold = 10 # cluster_recheck_secs = 3600 -# Example partner configuration. Replace the token before validating/pushing. +# Example partner configuration. Provision referenced keys in +# trusted_server_secrets before validating/pushing. # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" # OpenRTB agent type; vendor-specific values are supported (PAIR uses 571187). # openrtb_atype = 3 # bidstream_enabled = true -# api_token = "replace-with-partner-api-token-32-bytes-minimum" +# api_token = "partner_api_token" +# Optional when pull sync is enabled: +# ts_pull_token = "partner_ts_pull_token" # batch_rate_limit = 60 # pull_sync_enabled = false From 6a2a5fac0e9d4423f3f798fcdc6e4e89c7a7d7c6 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 18 Aug 2026 13:28:22 -0500 Subject: [PATCH 2/5] Fix platform secret-store startup configuration --- .../src/app.rs | 9 ++++---- crates/trusted-server-adapter-spin/spin.toml | 12 +++++----- crates/trusted-server-adapter-spin/src/app.rs | 14 ++++++----- docs/guide/configuration.md | 23 +++++++++++++++---- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 6f2a59950..b71afe830 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -11,11 +11,13 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; #[cfg(target_arch = "wasm32")] -use trusted_server_core::config_payload::settings_from_config_blob; +use trusted_server_core::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; +#[cfg(target_arch = "wasm32")] +use trusted_server_core::platform::StoreName; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -29,8 +31,6 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; -#[cfg(target_arch = "wasm32")] -use trusted_server_core::settings_data::default_secret_store_name; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::build_runtime_services; @@ -121,7 +121,8 @@ fn settings_from_cloudflare_config_json() -> Result, @@ -70,7 +72,7 @@ fn build_state() -> Result, Report> { #[cfg(all(feature = "spin", target_arch = "wasm32"))] fn load_startup_settings() -> Result> { - let config_store_name = default_config_store_name(); + let config_store_name = StoreName::from(SPIN_DEFAULT_CONFIG_STORE); let config_key = default_config_key(); let config_store = futures::executor::block_on(SpinConfigStore::open(config_store_name.as_ref().to_owned())) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 7bc737d5b..434d4878d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -58,15 +58,28 @@ Migrate an existing deployment in this order: 1. Create/populate `trusted_server_secrets` with the existing credential values without printing them in shell history, logs, or CI output. 2. Replace the five config values with stable key names. -3. Run `ts config validate`, then `ts config push --adapter fastly`. +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. +`--no-diff` prevents `config push` from rendering the previous plaintext +configuration during this migration. + Keep `publisher.proxy_secret` and `ec.passphrase` stable unless intentionally -rotating signed URLs or EC identifiers. On Spin, declare a component variable -for each chosen 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. +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: + +```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. ### Generate Secure Secrets From 070397f1bcc240b9b5bc84bcb02dd6841eb7c2a7 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 24 Aug 2026 16:02:47 -0500 Subject: [PATCH 3/5] Resolve static credentials through typed config Unify Tinybird, DataDome, and S3 static credentials under the logical default secret store, resolve them during typed config loading, and remove request-time static secret reads. Honor Fastly logical-to-physical store mappings, preserve deserialize-only selector compatibility, redact runtime values, and document provisioning and migration behavior. --- .env.example | 2 + Cargo.lock | 22 +- Cargo.toml | 12 +- .../trusted-server-adapter-fastly/src/app.rs | 128 ++++++++-- .../trusted-server-adapter-fastly/src/main.rs | 48 ++-- .../src/tinybird.rs | 75 +----- crates/trusted-server-core/src/config.rs | 241 ++++++++++++++++-- .../trusted-server-core/src/config_payload.rs | 224 ++++++++++++++++ .../src/integrations/datadome.rs | 164 ++++++------ .../src/integrations/datadome/protection.rs | 238 +++++++---------- crates/trusted-server-core/src/proxy.rs | 176 +++---------- crates/trusted-server-core/src/publisher.rs | 1 + .../src/secret_resolution.rs | 64 ++++- crates/trusted-server-core/src/settings.rs | 182 +++++++------ .../trusted-server-core/src/settings_data.rs | 3 +- .../fixtures/configs/viceroy-template.toml | 15 +- docs/guide/asset-routes.md | 16 +- docs/guide/configuration.md | 73 ++++-- docs/guide/fastly.md | 32 ++- docs/guide/getting-started.md | 5 +- docs/guide/integrations/datadome.md | 12 +- fastly.toml | 13 +- trusted-server.example.toml | 10 + 23 files changed, 1118 insertions(+), 638 deletions(-) diff --git a/.env.example b/.env.example index 87a3502d2..518f49406 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ # and export one secret per key name as: # TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_= # 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 # ============================================================================= # Publisher Settings diff --git a/Cargo.lock b/Cargo.lock index b65cacdbe..57e8a3c61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "toml", ] @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "anyhow", "async-trait", @@ -1457,7 +1457,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "anyhow", "async-stream", @@ -1486,7 +1486,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "anyhow", "async-trait", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "chrono", "clap", @@ -1538,7 +1538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "anyhow", "async-compression", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=bb4411625856472b1279a3db49aeeac5e8b1507e#bb4411625856472b1279a3db49aeeac5e8b1507e" +source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" dependencies = [ "log", "proc-macro2", @@ -3604,7 +3604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -3624,7 +3624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.118", @@ -3637,7 +3637,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.118", diff --git a/Cargo.toml b/Cargo.toml index 16a2109e8..2466eba8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,12 +54,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc 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/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e" } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "bb4411625856472b1279a3db49aeeac5e8b1507e", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } env_logger = "0.11" error-stack = "0.6" fastly = "0.12" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 96c06637b..7bee0202b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -86,8 +86,9 @@ use std::sync::Arc; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use edgezero_adapter_fastly::context::FastlyRequestContext; -use edgezero_core::app::{App, Hooks}; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; +use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{ HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, @@ -97,6 +98,7 @@ use error_stack::Report; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::config_payload::DEFAULT_SECRET_STORE_ID; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::batch_sync::handle_batch_sync; @@ -111,7 +113,9 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; -use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, +}; use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -126,9 +130,7 @@ use trusted_server_core::request_signing::{ handle_verify_signature, }; use trusted_server_core::settings::{ProxyAssetRoute, Settings}; -use trusted_server_core::settings_data::{ - default_config_key, default_config_store_name, get_settings_from_config_store, -}; +use trusted_server_core::settings_data::{DEFAULT_CONFIG_STORE_ID, get_settings_from_config_store}; use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; @@ -141,6 +143,23 @@ use crate::platform::{ // AppState // --------------------------------------------------------------------------- +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeStoreConfig { + pub(crate) config_store_name: StoreName, + pub(crate) config_key: String, + pub(crate) secret_store_name: StoreName, +} + +impl RuntimeStoreConfig { + pub(crate) fn from_env(env: &EnvConfig) -> Self { + Self { + config_store_name: StoreName::from(env.store_name("config", DEFAULT_CONFIG_STORE_ID)), + config_key: env.store_key("config", DEFAULT_CONFIG_STORE_ID), + secret_store_name: StoreName::from(env.store_name("secrets", DEFAULT_SECRET_STORE_ID)), + } + } +} + /// Application state built once per Wasm instance and shared for its lifetime. /// /// In Fastly Compute each request spawns a new Wasm instance, so this struct is @@ -159,19 +178,21 @@ pub(crate) struct AppState { /// /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. -pub(crate) fn build_state() -> Result, Report> { - build_state_from_settings(load_settings_from_config_store()?) +pub(crate) fn build_state( + stores: &RuntimeStoreConfig, +) -> Result, Report> { + build_state_from_settings(load_settings_from_config_store(stores)?) } -pub(crate) fn load_settings_from_config_store() -> Result> { - let store_name = default_config_store_name(); - let config_key = default_config_key(); +pub(crate) fn load_settings_from_config_store( + stores: &RuntimeStoreConfig, +) -> Result> { get_settings_from_config_store( &FastlyPlatformConfigStore, &FastlyPlatformSecretStore, - &store_name, - &config_key, - &trusted_server_core::settings_data::default_secret_store_name(), + &stores.config_store_name, + &stores.config_key, + &stores.secret_store_name, ) } @@ -1169,15 +1190,17 @@ fn fallback_route_handler( pub struct TrustedServerApp; impl TrustedServerApp { - pub(crate) fn build_app_with_state() -> (App, Option>) { - let (router, state) = Self::router_with_state(); + pub(crate) fn build_app_with_state( + stores: &RuntimeStoreConfig, + ) -> (App, Option>) { + let (router, state) = Self::router_with_state(stores); let mut app = App::with_name(router, Self::name()); Self::configure(&mut app); (app, state) } - fn router_with_state() -> (RouterService, Option>) { - let state = match build_state() { + fn router_with_state(stores: &RuntimeStoreConfig) -> (RouterService, Option>) { + let state = match build_state(stores) { Ok(state) => state, Err(ref e) => { log::error!("failed to build application state: {:?}", e); @@ -1235,7 +1258,25 @@ impl Hooks for TrustedServerApp { } fn routes() -> RouterService { - Self::router_with_state().0 + let stores = RuntimeStoreConfig::from_env(&EnvConfig::from_env()); + Self::router_with_state(&stores).0 + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: DEFAULT_CONFIG_STORE_ID, + ids: &[DEFAULT_CONFIG_STORE_ID], + }), + kv: Some(StoreMetadata { + default: "trusted_server_kv", + ids: &["trusted_server_kv"], + }), + secrets: Some(StoreMetadata { + default: DEFAULT_SECRET_STORE_ID, + ids: &[DEFAULT_SECRET_STORE_ID], + }), + } } } @@ -1245,10 +1286,12 @@ mod tests { use super::{ AppState, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, - TrustedServerApp, build_state_from_settings, startup_error_router, + RuntimeStoreConfig, TrustedServerApp, build_state_from_settings, startup_error_router, }; use bytes::Bytes; + use edgezero_core::app::Hooks as _; use edgezero_core::body::Body; + use edgezero_core::env_config::EnvConfig; use edgezero_core::http::{Method, Response, StatusCode, header, request_builder}; use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::router::RouterService; @@ -1272,6 +1315,53 @@ mod tests { }; use trusted_server_core::settings::Settings; + #[test] + fn hooks_expose_the_manifest_store_metadata_used_by_fastly_runtime_mapping() { + let metadata = TrustedServerApp::stores(); + + assert_eq!( + metadata.config.map(|store| store.default), + Some("trusted_server_config") + ); + assert_eq!( + metadata.secrets.map(|store| store.default), + Some("trusted_server_secrets") + ); + } + + #[test] + fn runtime_store_config_maps_logical_store_names_and_config_key() { + let env = EnvConfig::from_vars([ + ( + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME", + "physical_config", + ), + ( + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY", + "active_config", + ), + ( + "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME", + "ts_secrets", + ), + ]); + + let stores = RuntimeStoreConfig::from_env(&env); + + assert_eq!(stores.config_store_name.as_ref(), "physical_config"); + assert_eq!(stores.config_key, "active_config"); + assert_eq!(stores.secret_store_name.as_ref(), "ts_secrets"); + } + + #[test] + fn runtime_store_config_uses_logical_defaults_without_overrides() { + let stores = RuntimeStoreConfig::from_env(&EnvConfig::default()); + + assert_eq!(stores.config_store_name.as_ref(), "trusted_server_config"); + assert_eq!(stores.config_key, "trusted_server_config"); + assert_eq!(stores.secret_store_name.as_ref(), "trusted_server_secrets"); + } + fn settings_with_missing_consent_store() -> Settings { Settings::from_toml( r#" diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 39d35b198..86c75f4cb 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use edgezero_adapter_fastly::config_store::FastlyConfigStore as EdgeZeroFastlyConfigStore; +use edgezero_adapter_fastly::env_config_from_runtime_dictionary; use edgezero_adapter_fastly::request::into_core_request; +use edgezero_core::app::Hooks as _; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; @@ -36,24 +38,22 @@ mod platform; mod rate_limiter; mod tinybird; -use crate::app::{EcFinalizeState, TrustedServerApp, load_settings_from_config_store}; +use crate::app::{ + EcFinalizeState, RuntimeStoreConfig, TrustedServerApp, load_settings_from_config_store, +}; use crate::ec_kv::FastlyEcKvStore; use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers, resolve_geo_for_response}; use crate::platform::{FastlyPlatformGeo, client_info_from_request}; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; -const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; - /// Opens the Fastly Config Store used by the `EdgeZero` dispatcher. /// /// # Errors /// /// Returns [`fastly::Error`] if the config store cannot be opened. -fn open_trusted_server_config_store() -> Result { - let store = EdgeZeroFastlyConfigStore::try_open(TRUSTED_SERVER_CONFIG_STORE).map_err(|e| { - fastly::Error::msg(format!( - "failed to open config store `{TRUSTED_SERVER_CONFIG_STORE}`: {e}" - )) +fn open_trusted_server_config_store(store_name: &str) -> Result { + let store = EdgeZeroFastlyConfigStore::try_open(store_name).map_err(|e| { + fastly::Error::msg(format!("failed to open config store `{store_name}`: {e}")) })?; Ok(ConfigStoreHandle::new(Arc::new(store))) } @@ -86,11 +86,14 @@ fn main() { /// Handles a request through the `EdgeZero` router path. fn edgezero_main(mut req: FastlyRequest) { + let runtime_env = env_config_from_runtime_dictionary(TrustedServerApp::stores()); + let runtime_stores = RuntimeStoreConfig::from_env(&runtime_env); + // Short-circuit the JA4 debug probe before app construction. Must run here // because TLS/JA4 accessors are only available on FastlyRequest before // conversion to edgezero types. if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { - match load_settings_from_config_store() { + match load_settings_from_config_store(&runtime_stores) { Ok(settings) if settings.debug.ja4_endpoint_enabled => { build_ja4_debug_response(&req).send_to_client(); } @@ -107,18 +110,19 @@ fn edgezero_main(mut req: FastlyRequest) { return; } - let config_store = match open_trusted_server_config_store() { - Ok(cs) => cs, - Err(e) => { - log::error!("failed to open config store: {e}"); - FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Internal Server Error") - .send_to_client(); - return; - } - }; + let config_store = + match open_trusted_server_config_store(runtime_stores.config_store_name.as_ref()) { + Ok(cs) => cs, + Err(e) => { + log::error!("failed to open config store: {e}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + } + }; - let (app, app_state) = TrustedServerApp::build_app_with_state(); + let (app, app_state) = TrustedServerApp::build_app_with_state(&runtime_stores); let settings_snapshot = app_state.as_ref().map(|state| Arc::clone(&state.settings)); // Strip client-spoofable forwarded headers before dispatch. @@ -190,7 +194,7 @@ fn edgezero_main(mut req: FastlyRequest) { if let Some(settings) = settings_snapshot.as_deref() { apply_entry_point_finalize_headers(settings, &mut response, client_ip); } else { - match load_settings_from_config_store() { + match load_settings_from_config_store(&runtime_stores) { Ok(settings) => { apply_entry_point_finalize_headers(&settings, &mut response, client_ip); } @@ -220,7 +224,7 @@ fn edgezero_main(mut req: FastlyRequest) { } } } else { - match load_settings_from_config_store() { + match load_settings_from_config_store(&runtime_stores) { Ok(settings) => { match apply_edgezero_ec_finalize(&settings, &ec_state, &mut response) { Ok(partner_registry) => { diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index f2df61744..44bda88aa 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -10,9 +10,8 @@ use trusted_server_core::auction::telemetry::{ AuctionEventBatch, AuctionTelemetrySink, NoopAuctionTelemetrySink, }; use trusted_server_core::error::TrustedServerError; -use trusted_server_core::platform::{ - PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, StoreName, -}; +use trusted_server_core::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; +use trusted_server_core::redacted::Redacted; use trusted_server_core::settings::{Settings, TinybirdSettings}; const TINYBIRD_EVENTS_PATH: &str = "/v0/events"; @@ -43,8 +42,7 @@ struct FastlyTinybirdAuctionTelemetrySink { struct TinybirdEventsTarget { api_host: String, dataset: String, - secret_store: StoreName, - token_secret: String, + append_token: Redacted, uri: String, backend_spec: PlatformBackendSpec, max_body_bytes: usize, @@ -57,8 +55,9 @@ impl TinybirdEventsTarget { Self { api_host: config.api_host, dataset: config.auction_dataset, - secret_store: StoreName::from(config.secret_store), - token_secret: config.auction_token_secret, + append_token: config + .auction_token_secret + .expect("should contain a resolved Tinybird auction token when enabled"), uri, backend_spec, max_body_bytes: config.max_body_bytes, @@ -95,25 +94,6 @@ impl FastlyTinybirdAuctionTelemetrySink { batch.to_ndjson(self.target.max_body_bytes) } - fn load_append_token( - &self, - services: &RuntimeServices, - ) -> Result> { - let token = services - .secret_store() - .get_string(&self.target.secret_store, &self.target.token_secret) - .change_context(TrustedServerError::Proxy { - message: "Tinybird auction append token unavailable".to_owned(), - })?; - let token = token.trim().to_owned(); - if token.is_empty() { - return Err(Report::new(TrustedServerError::Proxy { - message: "Tinybird auction append token is empty".to_owned(), - })); - } - Ok(token) - } - fn ensure_backend( &self, services: &RuntimeServices, @@ -185,8 +165,7 @@ impl AuctionTelemetrySink for FastlyTinybirdAuctionTelemetrySink { Self::validate_batch(&batch)?; let body = self.serialize_batch(&batch)?; let body_len = body.len(); - let token = self.load_append_token(services)?; - let auth_header = Self::authorization_header(&token)?; + let auth_header = Self::authorization_header(self.target.append_token.expose())?; let backend_name = self.ensure_backend(services)?; let request = self.build_events_request(body, auth_header)?; @@ -233,7 +212,7 @@ mod tests { use trusted_server_core::platform::{ ClientInfo, PlatformBackend, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, - PlatformSelectResult, RuntimeServices, StoreId, + PlatformSelectResult, RuntimeServices, StoreId, StoreName, }; use super::*; @@ -444,12 +423,12 @@ mod tests { TinybirdSettings { enabled: true, api_host: "api.us-east.aws.tinybird.co".to_owned(), - secret_store: "ts_secrets".to_owned(), + secret_store: None, auction_dataset: "auction_events_raw".to_owned(), - auction_token_secret: "tinybird_auction_append_token".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: "tinybird_access_append_token".to_owned(), + access_token_secret: None, access_sample_rate: 0.0, max_body_bytes: 1024 * 1024, } @@ -481,16 +460,13 @@ mod tests { } #[test] - fn sink_posts_ndjson_with_secret_token_and_does_not_wait() { + fn sink_posts_ndjson_with_resolved_token_and_does_not_wait() { let backend = Arc::new(RecordingBackend::default()); let http_client = Arc::new(RecordingHttpClient::default()); let services = services( Arc::clone(&backend), Arc::clone(&http_client), - HashMap::from([( - "tinybird_auction_append_token".to_owned(), - b" append-token\n".to_vec(), - )]), + HashMap::new(), ); let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); @@ -601,31 +577,6 @@ mod tests { ); } - #[test] - fn sink_drops_missing_secret_as_setup_error() { - let backend = Arc::new(RecordingBackend::default()); - let http_client = Arc::new(RecordingHttpClient::default()); - let services = services(backend, Arc::clone(&http_client), HashMap::new()); - let sink = FastlyTinybirdAuctionTelemetrySink::new(enabled_config()); - - let result = futures::executor::block_on( - sink.emit_auction_events(&services, AuctionEventBatch::new(vec![test_row()])), - ); - - assert!( - result.is_err(), - "best-effort caller will suppress this error" - ); - assert!( - http_client - .requests - .lock() - .expect("should lock recorded requests") - .is_empty(), - "should not send without a token" - ); - } - #[test] fn sink_drops_row_count_oversize_before_sending() { let backend = Arc::new(RecordingBackend::default()); diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 19bd7d070..a5eb5cfb4 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -23,7 +23,7 @@ use crate::integrations::{ osano::OsanoConfig, permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{IntegrationConfig, Settings}; +use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; const MIN_PROXY_SECRET_LENGTH: usize = 32; @@ -130,6 +130,8 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { 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), @@ -160,6 +162,57 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { ], 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"), + ], + false, + ), + field( + vec![ + optional_object("proxy"), + optional_object("asset_routes"), + SecretPathSegment::ArrayEach, + optional_object("auth"), + object("secret_access_key"), + ], + false, + ), + field( + vec![ + optional_object("proxy"), + optional_object("asset_routes"), + SecretPathSegment::ArrayEach, + optional_object("auth"), + object("session_token"), + ], + true, + ), ] } } @@ -182,7 +235,7 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report Result, Report> { let mut enabled_auction_providers = HashSet::new(); @@ -229,7 +283,13 @@ fn validate_enabled_integrations( validate_integration::(settings, "osano")?; validate_integration::(settings, "google_tag_manager")?; if let Some(config) = settings.integration_config::("datadome")? { - crate::integrations::datadome::DataDomeIntegration::validate_config_for_startup(config)?; + if resolved_secrets { + crate::integrations::datadome::DataDomeIntegration::validate_config_for_startup( + config, + )?; + } else { + crate::integrations::datadome::DataDomeIntegration::validate_config_for_deploy(config)?; + } } validate_integration::(settings, "gpt")?; validate_integration::(settings, "gpt_diagnostics")?; @@ -280,6 +340,67 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report("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(), + )?; + } + if let Some(bypass) = datadome + .protection_test_bypass + .as_ref() + .filter(|bypass| bypass.enabled) + { + 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(), + )?; + } + } + Ok(()) } @@ -288,13 +409,17 @@ fn validate_secret_key_reference( key_name: &str, ) -> Result<(), Report> { if key_name.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("secret key reference at `{path}` must not be 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"), + }) +} + fn validate_proxy_secret_strength(settings: &Settings) -> Result<(), Report> { if settings.publisher.proxy_secret.expose().len() < MIN_PROXY_SECRET_LENGTH { return Err(Report::new(TrustedServerError::Configuration { @@ -345,6 +470,7 @@ fn report_to_validation_error( mod tests { use super::*; use crate::redacted::Redacted; + use crate::settings::{ProxyAssetRoute, S3SigV4AuthConfig}; use crate::test_support::tests::crate_test_settings_str; use edgezero_core::app_config::AppConfigMeta; @@ -464,6 +590,22 @@ formats = [{ width = 300, height = 250 }] ("ec.partners[*].api_token".to_owned(), false), ("ec.partners[*].ts_pull_token".to_owned(), true), ("handlers[*].password".to_owned(), false), + ("tinybird.auction_token_secret".to_owned(), true), + ( + "integrations.datadome.server_side_key_secret_name".to_owned(), + true, + ), + ( + "integrations.datadome.protection_test_bypass.credential_secret_name" + .to_owned(), + true, + ), + ("proxy.asset_routes[*].auth.access_key_id".to_owned(), false), + ( + "proxy.asset_routes[*].auth.secret_access_key".to_owned(), + false, + ), + ("proxy.asset_routes[*].auth.session_token".to_owned(), true), ], "should expose the native EdgeZero secret metadata contract" ); @@ -476,6 +618,77 @@ formats = [{ width = 300, height = 250 }] ); } + #[test] + fn legacy_static_secret_store_selectors_are_accepted_but_not_serialized() { + let mut settings = valid_settings(); + settings.tinybird.secret_store = Some("legacy-tinybird-store".to_string()); + settings + .integrations + .insert_config( + "datadome", + &serde_json::json!({ + "enabled": true, + "server_side_key_secret_store": "legacy-datadome-store", + "protection_test_bypass": { + "enabled": false, + "credential_secret_store": "legacy-bypass-store", + }, + }), + ) + .expect("should insert legacy DataDome selectors"); + 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: None, + origin_query: None, + })); + settings.proxy.asset_routes.push(route); + + settings.normalize_deserialized(); + let serialized = serde_json::to_string(&settings).expect("should serialize settings"); + + for legacy_store in [ + "legacy-tinybird-store", + "legacy-datadome-store", + "legacy-bypass-store", + "legacy-s3-store", + ] { + assert!( + !serialized.contains(legacy_store), + "serialized config should omit deprecated selector {legacy_store}" + ); + } + } + + #[test] + fn settings_debug_redacts_resolved_static_credentials() { + let mut settings = valid_settings(); + settings.tinybird.auction_token_secret = + Some(Redacted::new("resolved-tinybird-secret".to_string())); + settings + .integrations + .insert_config( + "datadome", + &serde_json::json!({ + "enabled": true, + "server_side_key_secret_name": "resolved-datadome-secret", + }), + ) + .expect("should insert resolved DataDome config"); + + let debug = format!("{settings:?}"); + + assert!(!debug.contains("resolved-tinybird-secret")); + assert!(!debug.contains("resolved-datadome-secret")); + assert!(debug.contains("datadome")); + } + #[test] fn app_config_deserialization_does_not_finalize_runtime_templates() { let creative_opportunities = @@ -637,15 +850,9 @@ password = "production-admin-password-32-bytes" #[test] fn deploy_validation_rejects_invalid_datadome_test_bypass() { - for (enable_protection, store, name, expected_message) in [ - ( - false, - "ts_secrets", - "datadome_test_bypass", - "requires enable_protection", - ), - (true, "", "datadome_test_bypass", "credential_secret_store"), - (true, "ts_secrets", "", "credential_secret_name"), + for (enable_protection, name, expected_message) in [ + (false, "datadome_test_bypass", "requires enable_protection"), + (true, "", "credential_secret_name"), ] { let mut settings = valid_settings(); settings @@ -655,9 +862,9 @@ password = "production-admin-password-32-bytes" &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_store": store, "credential_secret_name": name, }, }), diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index fa56ca59e..169ecd59f 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -49,6 +49,7 @@ pub fn settings_from_config_blob( })?; let mut data = envelope.into_data(); + remove_inactive_secret_references(&mut data); resolve_secret_references::( &mut data, secret_store, @@ -59,11 +60,58 @@ pub fn settings_from_config_blob( Ok(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"); + } + + 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(false); + 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"); + } +} + #[cfg(test)] mod tests { use super::*; use crate::platform::{PlatformError, StoreId}; use crate::redacted::Redacted; + use crate::settings::{AssetOriginAuth, ProxyAssetRoute, S3SigV4AuthConfig}; use crate::test_support::tests::crate_test_settings_str; use serde::Deserialize; @@ -124,6 +172,44 @@ mod tests { } } + 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", + "s3-access-key" => "AKIAIOSFODNN7EXAMPLE", + "s3-secret-key" => "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "s3-session-key" => "resolved-session-token", + _ => key, + }; + Ok(value.as_bytes().to_vec()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } + } + 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()); @@ -159,6 +245,144 @@ mod tests { ); } + #[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); + + 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()); + } + + #[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"); + + 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()); + 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()); + assert!( + datadome + .protection_test_bypass + .as_ref() + .is_some_and(|bypass| bypass.credential_secret_name.is_none()) + ); + } + #[test] fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { let data = diff --git a/crates/trusted-server-core/src/integrations/datadome.rs b/crates/trusted-server-core/src/integrations/datadome.rs index d95ee35ee..0d1f3cfe9 100644 --- a/crates/trusted-server-core/src/integrations/datadome.rs +++ b/crates/trusted-server-core/src/integrations/datadome.rs @@ -78,6 +78,7 @@ use crate::integrations::{ collect_body_bounded, collect_response_bounded, ensure_integration_backend, }; use crate::platform::{PlatformHttpRequest, RuntimeServices}; +use crate::redacted::Redacted; use crate::settings::{IntegrationConfig, Settings}; mod protection; @@ -90,6 +91,7 @@ pub use protection_scope::{ use protection_scope::ProtectionScope; pub(crate) const DATADOME_INTEGRATION_ID: &str = "datadome"; +pub(super) const MIN_TEST_BYPASS_CREDENTIAL_BYTES: usize = 32; /// Fixed request header used by the staging-only protection test bypass. pub(crate) const HEADER_DATADOME_TEST_BYPASS: &str = "x-ts-datadome-bypass"; @@ -133,13 +135,13 @@ pub struct ProtectionTestBypassConfig { #[serde(default)] pub enabled: bool, - /// Secret Store containing the temporary bypass credential. - #[serde(default = "default_protection_test_bypass_secret_store")] - pub credential_secret_store: String, + /// Deprecated feature-specific store selector accepted for migration only. + #[serde(default)] + pub credential_secret_store: Option, - /// Secret name containing at least 32 bytes of high-entropy bypass material. - #[serde(default = "default_protection_test_bypass_secret_name")] - pub credential_secret_name: String, + /// Secret reference containing at least 32 bytes of high-entropy bypass material. + #[serde(default)] + pub credential_secret_name: Option>, } /// Configuration for `DataDome` integration. @@ -175,13 +177,13 @@ pub struct DataDomeConfig { #[serde(default)] pub enable_protection: bool, - /// Runtime secret store containing the `DataDome` server-side key. - #[serde(default = "default_server_side_key_secret_store")] - pub server_side_key_secret_store: String, + /// Deprecated feature-specific store selector accepted for migration only. + #[serde(default)] + pub server_side_key_secret_store: Option, - /// Secret name containing the `DataDome` server-side key. - #[serde(default = "default_server_side_key_secret_name")] - pub server_side_key_secret_name: String, + /// Secret reference containing the `DataDome` server-side key. + #[serde(default)] + pub server_side_key_secret_name: Option>, /// Base URL for the `DataDome` Protection API. #[serde(default = "default_protection_api_origin")] @@ -273,22 +275,6 @@ fn default_protection_api_origin() -> String { "https://api-fastly.datadome.co".to_string() } -fn default_server_side_key_secret_store() -> String { - "ts_secrets".to_string() -} - -fn default_server_side_key_secret_name() -> String { - "datadome_server_side_key".to_string() -} - -fn default_protection_test_bypass_secret_store() -> String { - "ts_secrets".to_string() -} - -fn default_protection_test_bypass_secret_name() -> String { - "datadome_test_bypass".to_string() -} - fn default_timeout_ms() -> u32 { 1500 } @@ -356,8 +342,8 @@ impl Default for DataDomeConfig { cache_ttl_seconds: default_cache_ttl(), rewrite_sdk: default_rewrite_sdk(), enable_protection: false, - server_side_key_secret_store: default_server_side_key_secret_store(), - server_side_key_secret_name: default_server_side_key_secret_name(), + server_side_key_secret_store: None, + server_side_key_secret_name: None, protection_api_origin: default_protection_api_origin(), timeout_ms: default_timeout_ms(), protection_excluded_methods: default_protection_excluded_methods(), @@ -394,28 +380,48 @@ impl DataDomeIntegration { Self::try_new(config).expect("should create DataDome integration") } - fn try_new(mut config: DataDomeConfig) -> Result, Report> { - config.server_side_key_secret_store = - config.server_side_key_secret_store.trim().to_string(); - config.server_side_key_secret_name = config.server_side_key_secret_name.trim().to_string(); + fn try_new(config: DataDomeConfig) -> Result, Report> { + Self::try_new_with_secret_validation(config, true) + } + + fn try_new_with_secret_validation( + mut config: DataDomeConfig, + validate_resolved_secrets: bool, + ) -> Result, Report> { + if config.server_side_key_secret_store.take().is_some() { + log::warn!( + "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 = bypass.credential_secret_store.trim().to_string(); - bypass.credential_secret_name = bypass.credential_secret_name.trim().to_string(); + if bypass.credential_secret_store.take().is_some() { + log::warn!( + "DataDome credential_secret_store is deprecated and ignored; static credentials resolve through the default app-config secret store" + ); + } + bypass.credential_secret_name = + bypass.credential_secret_name.take().and_then(|value| { + let value = value.expose().trim().to_string(); + (!value.is_empty()).then(|| Redacted::new(value)) + }); } if config.enable_protection { - if config.server_side_key_secret_store.is_empty() - || config.server_side_key_secret_name.is_empty() - { + if config.server_side_key_secret_name.is_none() { return Err(Report::new(Self::error( - "server_side_key_secret_store and server_side_key_secret_name are required when enable_protection is true", + "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)?; + Self::validate_protection_test_bypass(&config, validate_resolved_secrets)?; if config.inject_client_side_tag { Self::validate_client_side_tag_url(&config.client_side_tag_url)?; @@ -477,6 +483,12 @@ impl DataDomeIntegration { Self::try_new(config).map(|_| ()) } + pub(crate) fn validate_config_for_deploy( + config: DataDomeConfig, + ) -> Result<(), Report> { + Self::try_new_with_secret_validation(config, false).map(|_| ()) + } + fn active_protection_test_bypass(&self) -> Option<&ProtectionTestBypassConfig> { if std::env::var(ENV_FASTLY_IS_STAGING).as_deref() != Ok("1") { return None; @@ -490,6 +502,7 @@ impl DataDomeIntegration { fn validate_protection_test_bypass( config: &DataDomeConfig, + validate_resolved_secret: bool, ) -> Result<(), Report> { let Some(bypass) = config .protection_test_bypass @@ -504,10 +517,16 @@ impl DataDomeIntegration { "protection_test_bypass requires enable_protection to be true", ))); } - if bypass.credential_secret_store.is_empty() || bypass.credential_secret_name.is_empty() { + let Some(credential) = bypass.credential_secret_name.as_ref() else { return Err(Report::new(Self::error( - "protection_test_bypass credential_secret_store and credential_secret_name must not be empty when enabled", + "protection_test_bypass credential_secret_name is required when enabled", ))); + }; + if validate_resolved_secret && credential.expose().len() < MIN_TEST_BYPASS_CREDENTIAL_BYTES + { + return Err(Report::new(Self::error(format!( + "protection_test_bypass credential_secret_name must resolve to at least {MIN_TEST_BYPASS_CREDENTIAL_BYTES} bytes" + )))); } Ok(()) @@ -1013,6 +1032,7 @@ mod tests { api_origin: "https://api-js.datadome.co".to_string(), cache_ttl_seconds: 3600, rewrite_sdk: true, + server_side_key_secret_name: Some(Redacted::new("server-side-key".to_string())), ..DataDomeConfig::default() } } @@ -1200,14 +1220,11 @@ mod tests { } #[test] - fn protection_secret_defaults_match_sample_config() { + fn protection_secrets_are_absent_by_default() { let config = DataDomeConfig::default(); - assert_eq!(config.server_side_key_secret_store, "ts_secrets"); - assert_eq!( - config.server_side_key_secret_name, - "datadome_server_side_key" - ); + assert!(config.server_side_key_secret_store.is_none()); + assert!(config.server_side_key_secret_name.is_none()); assert!( config.protection_test_bypass.is_none(), "the temporary test bypass should be disabled by default" @@ -1234,33 +1251,40 @@ mod tests { assert!(bypass.enabled, "should retain the enabled flag"); assert_eq!( - bypass.credential_secret_store, "ts_secrets", - "should retain the configured credential Secret Store" + bypass.credential_secret_store.as_deref(), + Some("ts_secrets"), + "should accept the deprecated credential Secret Store" ); assert_eq!( - bypass.credential_secret_name, "datadome_test_bypass", - "should retain the configured credential secret name" + bypass + .credential_secret_name + .as_ref() + .map(Redacted::expose) + .map(String::as_str), + Some("datadome_test_bypass"), + "should retain the configured credential secret reference" ); } #[test] - fn protection_test_bypass_requires_protection_and_secret_references() { - for (enable_protection, store, name, expected_message) in [ + fn protection_test_bypass_requires_protection_and_resolved_credential() { + for (enable_protection, credential, expected_message) in [ ( false, - "ts_secrets", - "datadome_test_bypass", + Some("test-bypass-credential-at-least-32-bytes"), "requires enable_protection", ), - (true, "", "datadome_test_bypass", "credential_secret_store"), - (true, "ts_secrets", "", "credential_secret_name"), + (true, None, "credential_secret_name"), + (true, Some("short"), "at least 32 bytes"), ] { let mut config = test_config(); config.enable_protection = enable_protection; + config.server_side_key_secret_name = + Some(Redacted::new("resolved-server-key".to_string())); config.protection_test_bypass = Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: store.to_string(), - credential_secret_name: name.to_string(), + credential_secret_store: None, + credential_secret_name: credential.map(|value| Redacted::new(value.to_string())), }); let err = match DataDomeIntegration::try_new(config) { @@ -1274,27 +1298,11 @@ mod tests { } } - #[test] - fn protection_enabled_requires_server_side_key_secret_store() { - let mut config = test_config(); - config.enable_protection = true; - config.server_side_key_secret_store = " ".to_string(); - - let err = match DataDomeIntegration::try_new(config) { - Ok(_) => panic!("should reject empty store"), - Err(err) => err, - }; - assert!( - format!("{err:?}").contains("server_side_key_secret_store"), - "should mention secret store config" - ); - } - #[test] fn protection_enabled_requires_server_side_key_secret_name() { let mut config = test_config(); config.enable_protection = true; - config.server_side_key_secret_name = " ".to_string(); + config.server_side_key_secret_name = Some(Redacted::new(" ".to_string())); let err = match DataDomeIntegration::try_new(config) { Ok(_) => panic!("should reject empty name"), diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 75de88afb..681c7e81c 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -13,7 +13,7 @@ use crate::http_util::is_navigation_request; use crate::integrations::{ HeaderMutation, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, }; -use crate::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, StoreName}; +use crate::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; use crate::redacted::Redacted; use super::DataDomeIntegration; @@ -21,8 +21,6 @@ use super::protection_scope::{ ProtectionRequestFacts, ProtectionScopeDecision, ProtectionSkipReason, }; -const MIN_TEST_BYPASS_CREDENTIAL_BYTES: usize = 32; - const VALIDATE_REQUEST_PATH: &str = "/validate-request"; const REQUEST_MODULE_NAME: &str = "Trusted-Server-Rust"; const MODULE_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -43,8 +41,7 @@ impl DataDomeIntegration { &self, mut input: RequestFilterInput<'_>, ) -> RequestFilterDecision { - let test_bypass_matched = - self.take_protection_test_bypass_header(input.request, input.services); + let test_bypass_matched = self.take_protection_test_bypass_header(input.request); if test_bypass_matched { input .request @@ -87,9 +84,9 @@ impl DataDomeIntegration { .ensure_protection_backend(input.services, &api_url) .map_err(ProtectionRequestError::Setup)?; let server_side_key = self - .load_server_side_key(input.services) + .server_side_key() .map_err(ProtectionRequestError::Setup)?; - let payload = self.build_protection_payload(&input, &server_side_key); + let payload = self.build_protection_payload(&input, server_side_key); let encoded_body = form_encode(&payload.fields); let mut builder = request_builder() @@ -175,11 +172,7 @@ impl DataDomeIntegration { true } - fn take_protection_test_bypass_header( - &self, - req: &mut Request, - services: &RuntimeServices, - ) -> bool { + fn take_protection_test_bypass_header(&self, req: &mut Request) -> bool { let supplied_values = req .headers() .get_all(super::HEADER_DATADOME_TEST_BYPASS) @@ -200,28 +193,21 @@ impl DataDomeIntegration { return false; } - let store_name = StoreName::from(bypass.credential_secret_store.as_str()); - let credential = match services - .secret_store() - .get_string(&store_name, &bypass.credential_secret_name) - { - Ok(credential) if credential.len() >= MIN_TEST_BYPASS_CREDENTIAL_BYTES => credential, - Ok(_) => { - log::warn!( - "[datadome] DataDome test bypass credential does not meet security requirements; ignoring bypass header" - ); - return false; - } - Err(err) => { - log::warn!( - "[datadome] Failed to load DataDome test bypass credential; ignoring bypass header: {err:?}" - ); - return false; - } + let Some(credential) = bypass.credential_secret_name.as_ref() 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 { + log::warn!( + "[datadome] DataDome test bypass credential does not meet security requirements; ignoring bypass header" + ); + return false; + } let actual = Sha256::digest(supplied_values[0].as_bytes()); - let expected = Sha256::digest(credential.as_bytes()); + let expected = Sha256::digest(credential.expose().as_bytes()); bool::from(actual.ct_eq(&expected)) } @@ -259,25 +245,15 @@ impl DataDomeIntegration { )) } - fn load_server_side_key( - &self, - services: &RuntimeServices, - ) -> Result, Report> { - let store_name = StoreName::from(self.config.server_side_key_secret_store.as_str()); - let key = services - .secret_store() - .get_string(&store_name, &self.config.server_side_key_secret_name) - .change_context(Self::error( - "Failed to read DataDome server-side key from secret store", - ))?; - let key = key.trim().to_string(); - if key.is_empty() { - return Err(Report::new(Self::error( - "DataDome server-side key secret must not be empty", - ))); - } - - Ok(Redacted::new(key)) + fn server_side_key(&self) -> Result<&Redacted, Report> { + self.config + .server_side_key_secret_name + .as_ref() + .ok_or_else(|| { + Report::new(Self::error( + "DataDome server-side key is unavailable after secret resolution", + )) + }) } fn build_protection_payload( @@ -854,13 +830,17 @@ mod tests { static FASTLY_IS_STAGING_ENV_LOCK: Mutex<()> = Mutex::new(()); - fn protection_integration() -> Arc { - let config = DataDomeConfig { + fn protection_config() -> DataDomeConfig { + DataDomeConfig { enabled: true, enable_protection: true, + server_side_key_secret_name: Some(Redacted::new("server-side-key".to_string())), ..DataDomeConfig::default() - }; - DataDomeIntegration::try_new(config).expect("should create integration") + } + } + + fn protection_integration() -> Arc { + DataDomeIntegration::try_new(protection_config()).expect("should create integration") } fn request_for_filter() -> Request { @@ -950,10 +930,12 @@ mod tests { enable_protection: true, protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: Some(Redacted::new( + "temporary-test-credential-32-bytes!".to_string(), + )), }), - ..DataDomeConfig::default() + ..protection_config() }; let integration = DataDomeIntegration::try_new(config).expect("should create integration"); let mut secrets = HashMap::new(); @@ -1002,15 +984,17 @@ mod tests { None, Some(ProtectionTestBypassConfig { enabled: false, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: Some(Redacted::new( + "temporary-test-credential-32-bytes!".to_string(), + )), }), ] { let config = DataDomeConfig { enabled: true, enable_protection: true, protection_test_bypass, - ..DataDomeConfig::default() + ..protection_config() }; let integration = DataDomeIntegration::try_new(config).expect("should create integration"); @@ -1068,10 +1052,12 @@ mod tests { enable_protection: true, protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: Some(Redacted::new( + "temporary-test-credential-32-bytes!".to_string(), + )), }), - ..DataDomeConfig::default() + ..protection_config() }; let integration = DataDomeIntegration::try_new(config).expect("should create integration"); let mut secrets = HashMap::new(); @@ -1156,10 +1142,12 @@ mod tests { }], protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: Some(Redacted::new( + "temporary-test-credential-32-bytes!".to_string(), + )), }), - ..DataDomeConfig::default() + ..protection_config() }; let integration = DataDomeIntegration::try_new(config).expect("should create integration"); let mut secrets = HashMap::new(); @@ -1202,10 +1190,12 @@ mod tests { enable_protection: true, protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: Some(Redacted::new( + "temporary-test-credential-32-bytes!".to_string(), + )), }), - ..DataDomeConfig::default() + ..protection_config() }; let integration = DataDomeIntegration::try_new(config).expect("should create integration"); let mut secrets = HashMap::new(); @@ -1265,10 +1255,12 @@ mod tests { enable_protection: true, protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: Some(Redacted::new( + "temporary-test-credential-32-bytes!".to_string(), + )), }), - ..DataDomeConfig::default() + ..protection_config() }; let integration = DataDomeIntegration::try_new(config).expect("should create integration"); let mut secrets = HashMap::new(); @@ -1318,64 +1310,26 @@ mod tests { #[test] fn test_bypass_credential_requires_at_least_32_bytes() { - for (credential, should_match) in [ + for (credential, should_succeed) in [ (Some("1234567890123456789012345678901"), false), (Some("12345678901234567890123456789012"), true), (Some(""), false), (None, false), ] { let config = DataDomeConfig { - enabled: true, - enable_protection: true, protection_test_bypass: Some(ProtectionTestBypassConfig { enabled: true, - credential_secret_store: "ts_secrets".to_string(), - credential_secret_name: "datadome_test_bypass".to_string(), + credential_secret_store: None, + credential_secret_name: credential + .map(|value| Redacted::new(value.to_string())), }), - ..DataDomeConfig::default() + ..protection_config() }; - let integration = - DataDomeIntegration::try_new(config).expect("should create integration"); - let mut secrets = HashMap::new(); - secrets.insert( - "datadome_server_side_key".to_string(), - b"server-side-key".to_vec(), - ); - if let Some(credential) = credential { - secrets.insert( - "datadome_test_bypass".to_string(), - credential.as_bytes().to_vec(), - ); - } - let http_client = Arc::new(StubHttpClient::new()); - if !should_match { - http_client.push_response_with_headers( - 200, - Vec::new(), - vec![(HEADER_DATADOME_RESPONSE, "200")], - ); - } - let services = build_services_with_secret_and_http_client( - HashMapSecretStore::new(secrets), - http_client.clone(), - ); - let settings = Settings::default(); - let mut request = request_for_filter(); - let supplied = credential.unwrap_or("12345678901234567890123456789012"); - request.headers_mut().insert( - super::super::HEADER_DATADOME_TEST_BYPASS, - edgezero_core::http::HeaderValue::from_str(supplied) - .expect("should build bypass header"), - ); - - let decision = filter_with_staging(&integration, &settings, &services, &mut request); - assert!(matches!(decision, RequestFilterDecision::Continue(_))); - assert_eq!(has_client_tag_suppression_marker(&request), should_match); assert_eq!( - http_client.recorded_backend_names().is_empty(), - should_match, - "only a credential meeting the minimum should skip the API" + DataDomeIntegration::try_new(config).is_ok(), + should_succeed, + "startup validation should enforce the resolved bypass credential length" ); } } @@ -1417,7 +1371,7 @@ mod tests { enabled: true, enable_protection: true, protection_excluded_ip_cidrs: vec!["192.0.2.0/24".to_string()], - ..DataDomeConfig::default() + ..protection_config() }; let inline_request = filter_marks_request(inline.clone(), &noop_services_with_client_ip(ip)); @@ -1456,7 +1410,7 @@ mod tests { cidrs: vec!["192.0.2.0/24".to_string()], }, }], - ..DataDomeConfig::default() + ..protection_config() }; let structured_request = filter_marks_request(structured_ip, &noop_services_with_client_ip(ip)); @@ -1477,7 +1431,7 @@ mod tests { key: "structured-source".to_string(), }, }], - ..DataDomeConfig::default() + ..protection_config() }; let mut structured_values = HashMap::new(); structured_values.insert("structured-source".to_string(), "192.0.2.0/24".to_string()); @@ -1534,7 +1488,7 @@ mod tests { methods: Vec::new(), matcher, }], - ..DataDomeConfig::default() + ..protection_config() }; let request = filter_marks_request_for_uri(config, &noop_services_with_client_ip(ip), None, uri); @@ -1569,7 +1523,7 @@ mod tests { }, }, ], - ..DataDomeConfig::default() + ..protection_config() }; let request = filter_marks_request(config, &noop_services_with_client_ip(ip)); @@ -1586,7 +1540,7 @@ mod tests { enabled: true, enable_protection: true, protection_excluded_asns: vec![64500], - ..DataDomeConfig::default() + ..protection_config() }; let geo_info = GeoInfo { city: String::new(), @@ -1615,7 +1569,7 @@ mod tests { enabled: true, enable_protection: true, protection_excluded_ip_cidrs: vec!["192.0.2.0/24".to_string()], - ..DataDomeConfig::default() + ..protection_config() }; let request = filter_marks_request( config, @@ -1628,39 +1582,27 @@ mod tests { } #[test] - fn load_server_side_key_reads_secret_store() { - let mut secrets = HashMap::new(); - secrets.insert( - "datadome_server_side_key".to_string(), - b"secret-from-store".to_vec(), - ); - let services = build_services_with_config_and_secret( - NoopConfigStore, - HashMapSecretStore::new(secrets), - ); + fn server_side_key_uses_resolved_config_value() { let integration = protection_integration(); let key = integration - .load_server_side_key(&services) - .expect("should load server-side key"); + .server_side_key() + .expect("should contain resolved server-side key"); - assert_eq!(key.expose(), "secret-from-store"); + assert_eq!(key.expose(), "server-side-key"); } #[test] - fn load_server_side_key_errors_when_secret_missing() { - let services = build_services_with_config_and_secret(NoopConfigStore, NoopSecretStore); + fn protection_startup_rejects_missing_resolved_server_side_key() { let config = DataDomeConfig { - enabled: true, - enable_protection: true, - server_side_key_secret_name: "missing_server_side_key".to_string(), - ..DataDomeConfig::default() + server_side_key_secret_name: None, + ..protection_config() }; - let integration = DataDomeIntegration::try_new(config).expect("should create integration"); - - let result = integration.load_server_side_key(&services); - assert!(result.is_err(), "should error when secret is missing"); + assert!( + DataDomeIntegration::try_new(config).is_err(), + "should reject a missing resolved server-side key" + ); } #[test] diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index ea0a0cf8d..fa0fdf44e 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -7,9 +7,7 @@ use error_stack::{Report, ResultExt}; use futures::StreamExt as _; use http::{HeaderValue, Method, Request, Response, StatusCode, header}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::io::{Cursor, Write}; -use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; @@ -22,13 +20,10 @@ use crate::edge_cookie::get_ec_id; use crate::error::TrustedServerError; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, - RuntimeServices, StoreName, + RuntimeServices, }; -use crate::redacted::Redacted; use crate::s3_sigv4::{self, S3Credentials}; -use crate::settings::{ - AssetOriginAuth, OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, Settings, -}; +use crate::settings::{AssetOriginAuth, OriginQueryPolicy, ProxyAssetRoute, Settings}; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; /// Chunk size used for streaming content through the rewrite pipeline. @@ -200,17 +195,6 @@ impl AssetProxyResponse { } } -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -struct S3CredentialsCacheKey { - secret_store: String, - access_key_id: String, - secret_access_key: String, - session_token: Option, -} - -static S3_CREDENTIALS_CACHE: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - /// Convert a platform-neutral response into a buffered [`Response`] for downstream processing. /// /// # Errors @@ -854,76 +838,7 @@ fn asset_origin_host_header( }) } -fn s3_credentials_cache_key(config: &S3SigV4AuthConfig) -> S3CredentialsCacheKey { - S3CredentialsCacheKey { - secret_store: config.secret_store.clone(), - access_key_id: config.access_key_id.clone(), - secret_access_key: config.secret_access_key.clone(), - session_token: config.session_token.clone(), - } -} - -fn load_s3_credentials( - services: &RuntimeServices, - config: &S3SigV4AuthConfig, -) -> Result, Report> { - let cache_key = s3_credentials_cache_key(config); - if let Some(credentials) = S3_CREDENTIALS_CACHE - .lock() - .expect("should lock S3 credentials cache") - .get(&cache_key) - .cloned() - { - return Ok(credentials); - } - - let store_name = StoreName::from(config.secret_store.as_str()); - let access_key_id = services - .secret_store() - .get_string(&store_name, &config.access_key_id) - .change_context(TrustedServerError::Proxy { - message: "failed to read S3 access key ID from secret store".to_string(), - })?; - let secret_access_key = services - .secret_store() - .get_string(&store_name, &config.secret_access_key) - .change_context(TrustedServerError::Proxy { - message: "failed to read S3 secret access key from secret store".to_string(), - })?; - let session_token = config - .session_token - .as_deref() - .map(|key| { - services - .secret_store() - .get_string(&store_name, key) - .change_context(TrustedServerError::Proxy { - message: "failed to read S3 session token from secret store".to_string(), - }) - }) - .transpose()?; - let credentials = Arc::new(S3Credentials { - access_key_id, - secret_access_key: Redacted::new(secret_access_key), - session_token: session_token.map(Redacted::new), - }); - - let mut cache = S3_CREDENTIALS_CACHE - .lock() - .expect("should lock S3 credentials cache"); - Ok(Arc::clone(cache.entry(cache_key).or_insert(credentials))) -} - -#[cfg(test)] -fn clear_s3_credentials_cache_for_tests() { - S3_CREDENTIALS_CACHE - .lock() - .expect("should lock S3 credentials cache") - .clear(); -} - fn apply_asset_origin_auth( - services: &RuntimeServices, method: &Method, target_url: &url::Url, headers: &mut http::HeaderMap, @@ -931,13 +846,17 @@ fn apply_asset_origin_auth( ) -> Result<(), Report> { match auth { AssetOriginAuth::S3SigV4(config) => { - let credentials = load_s3_credentials(services, 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.as_ref(), + &credentials, // s3_sigv4 converts this via chrono's `DateTime::::from`, which // only accepts `std::time::SystemTime`. `std::time::SystemTime::now()` // panics on `wasm32-unknown-unknown` (Cloudflare Workers), so derive an @@ -1052,7 +971,7 @@ 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(services, &Method::HEAD, target_url, &mut head_headers, auth)?; + apply_asset_origin_auth(&Method::HEAD, target_url, &mut head_headers, auth)?; let head_response = send_asset_origin_request( services, backend_name, @@ -1075,7 +994,7 @@ async fn preflight_s3_origin_for_image_optimizer( } let mut get_headers = unsigned_headers.clone(); - apply_asset_origin_auth(services, &Method::GET, target_url, &mut get_headers, auth)?; + apply_asset_origin_auth(&Method::GET, target_url, &mut get_headers, auth)?; let mut response = send_asset_origin_request( services, backend_name, @@ -1179,13 +1098,7 @@ pub async fn handle_asset_proxy_request( } if let Some(auth) = &route.auth { - apply_asset_origin_auth( - services, - req.method(), - &target_url, - &mut outbound_headers, - auth, - )?; + apply_asset_origin_auth(req.method(), &target_url, &mut outbound_headers, auth)?; } let mut platform_req = @@ -2171,11 +2084,10 @@ mod tests { use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, SUPPORTED_ENCODINGS, asset_origin_host_header, asset_path_skips_image_optimizer, - build_asset_proxy_target_url, clear_s3_credentials_cache_for_tests, - handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, - handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, - redirect_is_permitted, stream_asset_body, + build_asset_proxy_target_url, handle_asset_proxy_request, handle_first_party_click, + handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, + is_host_allowed, proxy_request, rebuild_response_with_body, + reconstruct_and_validate_signed_target, redirect_is_permitted, stream_asset_body, }; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; @@ -2188,6 +2100,7 @@ mod tests { PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; + use crate::redacted::Redacted; use crate::settings::{ AssetImageOptimizerConfig, AssetOriginAuth, ImageOptimizerAspectRatioConfig, ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, ImageOptimizerSettings, @@ -4351,9 +4264,11 @@ mod tests { ); route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { region: "us-east-1".to_string(), - secret_store: "s3-auth".to_string(), - access_key_id: "access_key_id".to_string(), - secret_access_key: "secret_access_key".to_string(), + secret_store: None, + access_key_id: Redacted::new("AKIAIOSFODNN7EXAMPLE".to_string()), + secret_access_key: Redacted::new( + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + ), session_token: None, origin_query: None, })); @@ -4395,9 +4310,11 @@ mod tests { ); route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { region: "us-east-1".to_string(), - secret_store: "s3-auth".to_string(), - access_key_id: "access_key_id".to_string(), - secret_access_key: "secret_access_key".to_string(), + secret_store: None, + access_key_id: Redacted::new("AKIAIOSFODNN7EXAMPLE".to_string()), + secret_access_key: Redacted::new( + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + ), session_token: None, origin_query: Some(OriginQueryPolicy::Strip), })); @@ -4437,23 +4354,12 @@ mod tests { } #[test] - fn handle_asset_proxy_request_caches_s3_credentials_for_repeated_signing() { + fn handle_asset_proxy_request_uses_resolved_s3_credentials_without_store_reads() { futures::executor::block_on(async { - clear_s3_credentials_cache_for_tests(); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, Vec::new()); stub.push_response(200, b"optimized".to_vec()); - let secret_store = CountingSecretStore::new(HashMap::from([ - ( - "cache_access_key_id".to_string(), - b"AKIAIOSFODNN7EXAMPLE".to_vec(), - ), - ( - "cache_secret_access_key".to_string(), - b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(), - ), - ("cache_session_token".to_string(), b"session-token".to_vec()), - ])); + let secret_store = CountingSecretStore::new(HashMap::new()); let observed_secret_store = secret_store.clone(); let services = build_services_with_secret_and_http_client( secret_store, @@ -4470,10 +4376,12 @@ mod tests { let mut route = test_s3_image_optimizer_route(); route.auth = Some(AssetOriginAuth::S3SigV4(S3SigV4AuthConfig { region: "us-east-1".to_string(), - secret_store: "s3-auth-cache".to_string(), - access_key_id: "cache_access_key_id".to_string(), - secret_access_key: "cache_secret_access_key".to_string(), - session_token: Some("cache_session_token".to_string()), + secret_store: None, + access_key_id: Redacted::new("AKIAIOSFODNN7EXAMPLE".to_string()), + secret_access_key: Redacted::new( + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + ), + session_token: Some(Redacted::new("session-token".to_string())), origin_query: None, })); @@ -4487,19 +4395,9 @@ mod tests { "should sign both the S3 preflight and final request" ); assert_eq!( - observed_secret_store.read_count("cache_access_key_id"), - 1, - "should read S3 access key ID once despite repeated signing" - ); - assert_eq!( - observed_secret_store.read_count("cache_secret_access_key"), - 1, - "should read S3 secret access key once despite repeated signing" - ); - assert_eq!( - observed_secret_store.read_count("cache_session_token"), - 1, - "should read S3 session token once despite repeated signing" + observed_secret_store.read_count("AKIAIOSFODNN7EXAMPLE"), + 0, + "should not read S3 credentials from the runtime secret store" ); let headers = stub.recorded_request_headers(); assert!( diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index d95f82567..fa0a0f7fd 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5640,6 +5640,7 @@ mod tests { &serde_json::json!({ "enabled": true, "enable_protection": true, + "server_side_key_secret_name": "server-side-key", "protection_excluded_ip_cidrs": ["192.0.2.0/24"], "client_side_key": "test-client-key", }), diff --git a/crates/trusted-server-core/src/secret_resolution.rs b/crates/trusted-server-core/src/secret_resolution.rs index 89ec3a3e7..3084f74eb 100644 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ b/crates/trusted-server-core/src/secret_resolution.rs @@ -26,12 +26,13 @@ pub fn resolve_secret_references( 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( - data, + &mut resolved_data, &field, &field.path, "", @@ -39,6 +40,7 @@ pub fn resolve_secret_references( default_store_name, )?; } + *data = resolved_data; Ok(()) } @@ -59,6 +61,19 @@ fn resolve_field( 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 @@ -77,6 +92,26 @@ fn resolve_field( 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}`")) @@ -217,6 +252,14 @@ mod tests { SecretPathSegment::Field("optional".into()), ], }, + SecretField { + kind: SecretKind::KeyInDefault, + optional: false, + path: vec![ + SecretPathSegment::OptionalField("feature".into()), + SecretPathSegment::Field("credential".into()), + ], + }, ] } } @@ -226,6 +269,7 @@ mod tests { 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()), ]), } } @@ -247,6 +291,24 @@ mod tests { 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() { let mut data = serde_json::json!({"outer": [{}]}); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 91aa7ff62..28c6c5018 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -160,12 +160,23 @@ impl Publisher { } } -#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[derive(Default, Clone, Deserialize, Serialize)] pub struct IntegrationSettings { #[serde(flatten)] entries: HashMap, } +impl std::fmt::Debug for IntegrationSettings { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut integration_ids = self.entries.keys().collect::>(); + integration_ids.sort_unstable(); + formatter + .debug_struct("IntegrationSettings") + .field("integration_ids", &integration_ids) + .finish() + } +} + pub trait IntegrationConfig: DeserializeOwned + Validate { fn is_enabled(&self) -> bool; } @@ -200,6 +211,29 @@ impl IntegrationSettings { == Some(false) } + fn remove_legacy_static_secret_store_selectors(&mut self) { + let Some(datadome) = self + .entries + .get_mut("datadome") + .and_then(JsonValue::as_object_mut) + else { + return; + }; + + let mut removed = datadome.remove("server_side_key_secret_store").is_some(); + if let Some(bypass) = datadome + .get_mut("protection_test_bypass") + .and_then(JsonValue::as_object_mut) + { + removed |= bypass.remove("credential_secret_store").is_some(); + } + if removed { + log::warn!( + "DataDome secret-store selectors are deprecated and ignored; static credentials resolve through the default app-config secret store" + ); + } + } + /// Retrieves and validates a typed configuration for an integration. /// /// # Errors @@ -628,16 +662,12 @@ fn default_request_signing_enabled() -> bool { false } -fn default_s3_secret_store() -> String { - "s3-auth".to_string() -} - -fn default_s3_access_key_id() -> String { - "access_key_id".to_string() +fn default_s3_access_key_id() -> Redacted { + Redacted::new("access_key_id".to_string()) } -fn default_s3_secret_access_key() -> String { - "secret_access_key".to_string() +fn default_s3_secret_access_key() -> Redacted { + Redacted::new("secret_access_key".to_string()) } fn default_asset_image_optimizer_enabled() -> bool { @@ -724,25 +754,25 @@ 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. Credentials are read from the named runtime -/// secret store and cached per process by configured secret names. +/// the `SigV4` canonical request. Credential fields hold secret-store key names +/// in app config and resolved values at runtime. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct S3SigV4AuthConfig { /// `AWS` region used in the credential scope. pub region: String, - /// Runtime secret store containing `S3` credentials. - #[serde(default = "default_s3_secret_store")] - pub secret_store: String, - /// Secret name containing the `AWS` access key ID. + /// 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")] - pub access_key_id: String, - /// Secret name containing the `AWS` secret access key. + pub access_key_id: Redacted, + /// Secret reference containing the `AWS` secret access key. #[serde(default = "default_s3_secret_access_key")] - pub secret_access_key: String, - /// Optional secret name containing an `AWS` session token. + pub secret_access_key: Redacted, + /// Optional secret reference containing an `AWS` session token. #[serde(default)] - pub session_token: Option, + pub session_token: Option>, /// Query-string handling policy for the signed `S3` origin request. /// /// Set this to `strip` when request query parameters are transformation @@ -761,14 +791,17 @@ fn s3_region_is_valid(region: &str) -> bool { impl S3SigV4AuthConfig { fn normalize(&mut self) { self.region = self.region.trim().to_string(); - self.secret_store = self.secret_store.trim().to_string(); - self.access_key_id = self.access_key_id.trim().to_string(); - self.secret_access_key = self.secret_access_key.trim().to_string(); - self.session_token = self - .session_token - .take() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); + if self.secret_store.take().is_some() { + log::warn!( + "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)) + }); } fn prepare_runtime(&self) -> Result<(), Report> { @@ -784,12 +817,9 @@ impl S3SigV4AuthConfig { .to_string(), })); } - if self.secret_store.is_empty() - || self.access_key_id.is_empty() - || self.secret_access_key.is_empty() - { + 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 secret names must not be empty" + message: "proxy.asset_routes auth s3_sigv4 credentials must not be empty after secret resolution" .to_string(), })); } @@ -1707,15 +1737,15 @@ pub struct TinybirdSettings { /// Regional Tinybird API host, without scheme or path. #[serde(default)] pub api_host: String, - /// Fastly Secret Store name containing Tinybird append tokens. - #[serde(default = "default_tinybird_secret_store")] - pub secret_store: String, + /// Deprecated feature-specific store selector accepted for migration only. + #[serde(default, skip_serializing)] + pub secret_store: Option, /// Auction Events API datasource name. #[serde(default = "default_tinybird_auction_dataset")] pub auction_dataset: String, - /// Secret key containing the auction datasource APPEND token. - #[serde(default = "default_tinybird_auction_token_secret")] - pub auction_token_secret: String, + /// Secret reference containing the auction datasource APPEND token. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auction_token_secret: Option>, /// Reserved for future access-log telemetry. /// /// `true` is rejected until an access-log emitter is wired, so operators @@ -1725,9 +1755,9 @@ pub struct TinybirdSettings { /// Future access-log Events API datasource name. #[serde(default = "default_tinybird_access_dataset")] pub access_dataset: String, - /// Future Secret Store key containing the access-log datasource APPEND token. - #[serde(default = "default_tinybird_access_token_secret")] - pub access_token_secret: String, + /// Deprecated placeholder for the unwired access-log APPEND token. + #[serde(default, skip_serializing)] + pub access_token_secret: Option>, /// Future fraction of requests to emit for optional access telemetry. #[serde(default)] pub access_sample_rate: f64, @@ -1736,26 +1766,14 @@ pub struct TinybirdSettings { pub max_body_bytes: usize, } -fn default_tinybird_secret_store() -> String { - "ts_secrets".to_owned() -} - fn default_tinybird_auction_dataset() -> String { "auction_events_raw".to_owned() } -fn default_tinybird_auction_token_secret() -> String { - "tinybird_auction_append_token".to_owned() -} - fn default_tinybird_access_dataset() -> String { "access_logs_raw".to_owned() } -fn default_tinybird_access_token_secret() -> String { - "tinybird_access_append_token".to_owned() -} - fn default_tinybird_max_body_bytes() -> usize { 1024 * 1024 } @@ -1765,12 +1783,12 @@ impl Default for TinybirdSettings { Self { enabled: false, api_host: String::new(), - secret_store: default_tinybird_secret_store(), + secret_store: None, auction_dataset: default_tinybird_auction_dataset(), - auction_token_secret: default_tinybird_auction_token_secret(), + auction_token_secret: None, access_enabled: false, access_dataset: default_tinybird_access_dataset(), - access_token_secret: default_tinybird_access_token_secret(), + access_token_secret: None, access_sample_rate: 0.0, max_body_bytes: default_tinybird_max_body_bytes(), } @@ -1780,11 +1798,18 @@ impl Default for TinybirdSettings { impl TinybirdSettings { fn normalize(&mut self) { self.api_host = self.api_host.trim().to_ascii_lowercase(); - self.secret_store = self.secret_store.trim().to_owned(); + if self.secret_store.take().is_some() { + log::warn!( + "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.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)) + }); self.access_dataset = self.access_dataset.trim().to_owned(); - self.access_token_secret = self.access_token_secret.trim().to_owned(); + self.access_token_secret = None; } fn prepare_runtime(&mut self) -> Result<(), Report> { @@ -1808,18 +1833,15 @@ impl TinybirdSettings { return Ok(()); } validate_tinybird_api_host(&self.api_host)?; - if self.secret_store.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { + 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.secret_store must not be empty when Tinybird telemetry is enabled" + "tinybird.auction_token_secret is required when Tinybird telemetry is enabled" .to_owned(), - })); - } - if self.enabled { - validate_tinybird_dataset(&self.auction_dataset, "tinybird.auction_dataset")?; - validate_tinybird_secret(&self.auction_token_secret, "tinybird.auction_token_secret")?; - } - Ok(()) + }) + })?; + validate_tinybird_secret(token.expose(), "tinybird.auction_token_secret") } } @@ -1860,7 +1882,7 @@ fn validate_tinybird_dataset(value: &str, setting: &str) -> Result<(), Report Result<(), Report> { if value.is_empty() || value.chars().any(char::is_control) { return Err(Report::new(TrustedServerError::Configuration { - message: format!("{setting} must be a non-empty Secret Store key"), + message: format!("{setting} must be non-empty after secret resolution"), })); } Ok(()) @@ -2018,6 +2040,9 @@ impl Settings { pub(crate) fn normalize_deserialized(&mut self) { self.proxy.normalize(); self.image_optimizer.normalize(); + self.tinybird.normalize(); + self.integrations + .remove_legacy_static_secret_store_selectors(); self.consent.validate(); } @@ -2653,12 +2678,9 @@ mod tests { !settings.tinybird.enabled, "Tinybird should default disabled" ); - assert_eq!(settings.tinybird.secret_store, "ts_secrets"); + assert_eq!(settings.tinybird.secret_store, None); assert_eq!(settings.tinybird.auction_dataset, "auction_events_raw"); - assert_eq!( - settings.tinybird.auction_token_secret, - "tinybird_auction_append_token" - ); + assert!(settings.tinybird.auction_token_secret.is_none()); } #[test] @@ -2678,7 +2700,7 @@ mod tests { #[test] fn tinybird_accepts_region_host_without_scheme() { let toml = format!( - "{}\n[tinybird]\nenabled = true\napi_host = \"api.us-east.aws.tinybird.co\"\n", + "{}\n[tinybird]\nenabled = true\napi_host = \"api.us-east.aws.tinybird.co\"\nauction_token_secret = \"test-auction-token\"\n", crate_test_settings_str() ); @@ -4259,9 +4281,9 @@ origin_host_header_overide = "www.example.com""#, match route.auth.as_ref().expect("should configure route auth") { AssetOriginAuth::S3SigV4(config) => { assert_eq!(config.region, "us-east-1"); - assert_eq!(config.secret_store, "s3-auth"); - assert_eq!(config.access_key_id, "access_key_id"); - assert_eq!(config.secret_access_key, "secret_access_key"); + 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"); } } } diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index bec1e4ad3..b82ec92d4 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -9,7 +9,8 @@ use crate::error::TrustedServerError; use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; use crate::settings::Settings; -const DEFAULT_CONFIG_STORE_ID: &str = "trusted_server_config"; +/// Canonical logical config store used by Trusted Server app config. +pub const DEFAULT_CONFIG_STORE_ID: &str = "trusted_server_config"; const FASTLY_CHUNK_POINTER_KIND: &str = "fastly_config_chunks"; const FASTLY_CONFIG_ENTRY_LIMIT: usize = 8_000; 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 aa025b6c7..f11a67dff 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -66,23 +66,28 @@ key = "api_key" data = "test-api-key" - [[local_server.secret_stores.trusted_server_secrets]] + [[local_server.secret_stores.ts_secrets]] key = "integration_admin_password" data = "integration-admin-password-32-bytes-ok" - [[local_server.secret_stores.trusted_server_secrets]] + [[local_server.secret_stores.ts_secrets]] key = "integration_proxy_secret" data = "integration-test-proxy-secret-32-bytes-ok" - [[local_server.secret_stores.trusted_server_secrets]] + [[local_server.secret_stores.ts_secrets]] key = "integration_ec_passphrase" data = "integration-test-ec-secret-padded-32" - [[local_server.secret_stores.trusted_server_secrets]] + [[local_server.secret_stores.ts_secrets]] key = "integration_partner_token_alpha" data = "integration-test-token-alpha-32-bytes-ok" - [[local_server.secret_stores.trusted_server_secrets]] + [[local_server.secret_stores.ts_secrets]] key = "integration_partner_token_bravo" data = "integration-test-token-bravo-32-bytes-ok" [local_server.config_stores] + [local_server.config_stores.edgezero_runtime_env] + format = "inline-toml" + [local_server.config_stores.edgezero_runtime_env.contents] + EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME = "ts_secrets" + # Generated integration configs inject the trusted_server_config blob # into the store required by the Fastly entry point. # GENERATED_TRUSTED_SERVER_CONFIG_STORES diff --git a/docs/guide/asset-routes.md b/docs/guide/asset-routes.md index 8ac9b25cd..405bc1d1b 100644 --- a/docs/guide/asset-routes.md +++ b/docs/guide/asset-routes.md @@ -64,10 +64,9 @@ origin_url = "https://bucket.s3.us-east-1.amazonaws.com" 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" -# session_token = "session_token" +access_key_id = "s3_access_key_id" +secret_access_key = "s3_secret_access_key" +# session_token = "s3_session_token" ``` ### S3 requirements @@ -77,22 +76,21 @@ secret_access_key = "secret_access_key" - 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`. -- Credentials are loaded from the configured runtime secret store and cached per process by configured secret names. +- Credential references resolve from the logical `trusted_server_secrets` store while runtime settings are built. Signing performs no request-time secret-store reads. - 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 -The default secret store and key names are: +Credential fields contain secret key references: -| Config field | Default value | Secret value | +| Config field | Default key | Resolved value | | ------------------- | ------------------- | ------------------------------------ | -| `secret_store` | `s3-auth` | Secret store name | | `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 | -Use private deployment configuration for environment-specific store names or profile tables. +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. ## Origin query policy diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 434d4878d..07ffad8c8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -47,17 +47,31 @@ ts config push --adapter fastly ### Secret-store migration -The five app-config secret fields contain stable key names only: -`publisher.proxy_secret`, `ec.passphrase`, `ec.partners[*].api_token`, -`ec.partners[*].ts_pull_token` (when used), and `handlers[*].password`. +Static app-config credentials contain stable key names only. This includes +publisher, EC, handler, Tinybird, DataDome, and S3 credential fields: + +- `publisher.proxy_secret` +- `ec.passphrase` +- `ec.partners[*].api_token` +- `ec.partners[*].ts_pull_token`, when used +- `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. +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`. Migrate an existing deployment in this order: -1. Create/populate `trusted_server_secrets` with the existing credential values - without printing them in shell history, logs, or CI output. -2. Replace the five config values with stable key names. +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. @@ -81,6 +95,25 @@ 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. +### 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" +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. + ### Generate Secure Secrets Generate values locally and write them directly to the platform secret store; @@ -937,15 +970,14 @@ target_path = "/image/upload/$1.$2" The first supported origin auth type is `s3_sigv4`. -| 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 | -| `secret_store` | String | No | `s3-auth` | Runtime secret store containing AWS credentials | -| `access_key_id` | String | No | `access_key_id` | Secret key containing the AWS access key ID | -| `secret_access_key` | String | No | `secret_access_key` | Secret key containing 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 | +| `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` | **Example**: @@ -958,13 +990,12 @@ origin_url = "https://bucket.s3.us-east-1.amazonaws.com" 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" -# session_token = "session_token" +access_key_id = "s3_access_key_id" +secret_access_key = "s3_secret_access_key" +# session_token = "s3_session_token" ``` -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. Credentials are cached per process by configured secret names after the first successful read. +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. Effective `origin_query` precedence is auth-level `origin_query`, then enabled Image Optimizer `origin_query`, then the route default. diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 20faf1995..708bc0a41 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -84,15 +84,41 @@ Used for storing public configuration (e.g., public keys, key metadata): fastly config-store create --name jwks_store ``` -### Secret Store +### Secret Stores -Used for storing sensitive data (e.g., private signing keys): +Trusted Server keeps static app-config credentials under logical store ID +`trusted_server_secrets`. The physical Fastly store can use another name, such +as `ts_secrets`. Request-signing private keys remain in their separate, +runtime-managed store. + +Set the physical mapping before provisioning: + +```bash +export EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets +ts provision --adapter fastly +``` + +Provisioning creates or reuses the physical store and persists this runtime +mapping in Fastly Config Store `edgezero_runtime_env`: + +```text +EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets +``` + +The Fastly service must link both `ts_secrets` and `edgezero_runtime_env` to the +active service version. The custom streaming entry point reads the mapping +before loading app config, so every startup and reload resolves static +credentials from `ts_secrets` while the portable manifest continues to declare +`trusted_server_secrets`. + +Create the separate request-signing store when that feature is enabled: ```bash fastly secret-store create --name signing_keys ``` -Note the store IDs - you'll need them for your `trusted-server.toml` configuration. +Do not copy the same app credential store under a second hardcoded +`trusted_server_secrets` Fastly link. Configure the mapping instead. ## Create EC KV Store diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 760a747cc..5615e1c66 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -157,8 +157,9 @@ Edit `trusted-server.toml` to configure: - Consent settings (`[gdpr]`) - Stable key names for `trusted_server_secrets` -Provision `trusted_server_secrets` with the existing credential values before -pushing a migrated config. Then validate and push: +Provision the physical store mapped from logical `trusted_server_secrets` with +the existing credential values before pushing a migrated config. On Fastly, +`ts_secrets` is the documented example physical name. Then validate and push: ```bash ts config validate diff --git a/docs/guide/integrations/datadome.md b/docs/guide/integrations/datadome.md index 0c2d8ae6c..f289a77b9 100644 --- a/docs/guide/integrations/datadome.md +++ b/docs/guide/integrations/datadome.md @@ -43,7 +43,7 @@ rewrite_sdk = true # Server-side Protection API layer enable_protection = false -server_side_key_secret_store = "ts_secrets" +# Required only when enable_protection = true. server_side_key_secret_name = "datadome_server_side_key" protection_api_origin = "https://api-fastly.datadome.co" timeout_ms = 1500 @@ -76,8 +76,7 @@ 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_store` | string | `ts_secrets` | Runtime secret store containing the DataDome server-side key | -| `server_side_key_secret_name` | string | `datadome_server_side_key` | Secret name containing the DataDome server-side key | +| `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 | @@ -156,7 +155,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. -The configured `server_side_key_secret_store` and `server_side_key_secret_name` must resolve to a non-empty secret when server-side protection is enabled. If the secret cannot be read, DataDome protection fails open for that request. +`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. ### Protected traffic @@ -185,7 +184,6 @@ Protection API: # Runtime activation also requires FASTLY_IS_STAGING=1. [integrations.datadome.protection_test_bypass] enabled = true -credential_secret_store = "ts_secrets" credential_secret_name = "datadome_test_bypass" ``` @@ -197,7 +195,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 in the configured Secret Store, configure this section +high-entropy material under the referenced key 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. @@ -375,7 +373,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_STORE=ts_secrets TRUSTED_SERVER__INTEGRATIONS__DATADOME__SERVER_SIDE_KEY_SECRET_NAME=datadome_server_side_key TRUSTED_SERVER__INTEGRATIONS__DATADOME__CLIENT_SIDE_KEY=your-client-side-key ``` @@ -421,7 +418,6 @@ Check that both fields are configured: [integrations.datadome] enabled = true enable_protection = true -server_side_key_secret_store = "ts_secrets" server_side_key_secret_name = "datadome_server_side_key" ``` diff --git a/fastly.toml b/fastly.toml index 9d44a3e10..ca8bce8d3 100644 --- a/fastly.toml +++ b/fastly.toml @@ -57,17 +57,18 @@ build = """ key = "tinybird_auction_append_token" data = "test-tinybird-auction-append-token" + # App-config references use logical `trusted_server_secrets`; the + # edgezero_runtime_env mapping below resolves it to physical `ts_secrets`. [[local_server.secret_stores.ts_secrets]] - key = "tinybird_access_append_token" - data = "test-tinybird-access-append-token" - - # App-config secret references resolve from this canonical logical store. - # Populate production values through the EdgeZero secret-store workflow. - [[local_server.secret_stores.trusted_server_secrets]] key = "placeholder" data = "placeholder" [local_server.config_stores] + [local_server.config_stores.edgezero_runtime_env] + format = "inline-toml" + [local_server.config_stores.edgezero_runtime_env.contents] + EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME = "ts_secrets" + [local_server.config_stores.trusted_server_config] format = "inline-toml" [local_server.config_stores.trusted_server_config.contents] diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 1b083ee75..3f0887185 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -107,6 +107,16 @@ sdk_origin = "https://sdk.example.com" api_origin = "https://api.example.com" cache_ttl_seconds = 3600 rewrite_sdk = true +# 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" + +[tinybird] +enabled = false +# api_host = "api.example.com" +# auction_dataset = "auction_events_raw" +# Required when enabled. The value is a key in trusted_server_secrets. +# auction_token_secret = "tinybird_auction_append_token" [integrations.gpt] enabled = false From b1e967e3b72d3f820638bbbe1382d843a49e6600 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 24 Aug 2026 16:12:16 -0500 Subject: [PATCH 4/5] Update EdgeZero static-secret support revision --- Cargo.lock | 33 ++++++++++++++++++++++----------- Cargo.toml | 12 ++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57e8a3c61..61d1dcae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,7 +767,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "toml", ] @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "anyhow", "async-trait", @@ -1457,7 +1457,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "anyhow", "async-stream", @@ -1486,7 +1486,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "anyhow", "async-trait", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "chrono", "clap", @@ -1538,7 +1538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "anyhow", "async-compression", @@ -1569,14 +1569,14 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221#a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" +source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" dependencies = [ "log", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.118", + "syn 3.0.4", "toml", "validator", ] @@ -4769,6 +4769,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5909,7 +5920,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2466eba8c..5e7f743d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,12 +54,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc 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/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221" } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "a9bcbf8b29c8e5f7a555fba6ea7c428d5e360221", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } env_logger = "0.11" error-stack = "0.6" fastly = "0.12" From 1315cdb1af34f5bd793e78d577b287c5197f83ff Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 24 Aug 2026 17:24:12 -0500 Subject: [PATCH 5/5] Align EdgeZero deployment mapping revision --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61d1dcae6..83dfab04f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -767,7 +767,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1398,7 +1398,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "toml", ] @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1434,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "anyhow", "async-trait", @@ -1457,7 +1457,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "anyhow", "async-stream", @@ -1486,7 +1486,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "anyhow", "async-trait", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "chrono", "clap", @@ -1538,7 +1538,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "anyhow", "async-compression", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=2b249571af53a45c1539a24895ea975edd2bf4d5#2b249571af53a45c1539a24895ea975edd2bf4d5" +source = "git+https://github.com/stackpop/edgezero?rev=0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34#0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" dependencies = [ "log", "proc-macro2", @@ -5920,7 +5920,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5e7f743d4..851053708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,12 +54,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc 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/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5" } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "2b249571af53a45c1539a24895ea975edd2bf4d5", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "0d6ebf9b0250efa5f7031a93ec7b7f09f2c9bf34", default-features = false } env_logger = "0.11" error-stack = "0.6" fastly = "0.12"