From 0422608a988ca56b5ed02e6dd9e01fe8a33af4af Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 9 Jul 2026 12:54:32 +0530 Subject: [PATCH 1/4] Add secret-store references for app-config secrets Secret-bearing config fields (ec.passphrase, publisher.proxy_secret, ec.partners[].api_token, ec.partners[].ts_pull_token, handlers[].password) previously rode in the app-config blob as plaintext, so `ts config push` persisted them into the config store. Add an opt-in `[secrets]` mode where those fields instead hold secret-store key names, resolved from the platform secret store at settings load. Validation splits along the EdgeZero 3.3.8 boundary: push/deploy validates key-name shape and skips value-shape checks (a key name is not the secret); runtime resolves the references first, then runs full validation against the real values and fails closed if a referenced secret is missing or invalid. Fastly wires FastlyPlatformSecretStore into the config load path; the other adapters pass None and fail closed on store-mode blobs until wired in follow-ups. Local Viceroy secrets and a store-mode template are documented. Closes #846 --- crates/trusted-server-adapter-axum/src/app.rs | 4 +- .../trusted-server-adapter-fastly/src/app.rs | 7 +- crates/trusted-server-core/src/config.rs | 91 ++- .../trusted-server-core/src/config_payload.rs | 154 +++- crates/trusted-server-core/src/ec/registry.rs | 23 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/secret_refs.rs | 297 +++++++ crates/trusted-server-core/src/settings.rs | 309 +++++++- .../trusted-server-core/src/settings_data.rs | 97 ++- .../2026-07-09-secrets-to-secret-store.md | 724 ++++++++++++++++++ fastly.toml | 13 + trusted-server.example.toml | 17 + 12 files changed, 1693 insertions(+), 44 deletions(-) create mode 100644 crates/trusted-server-core/src/secret_refs.rs create mode 100644 docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..4425d4911 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -55,8 +55,10 @@ pub struct AppState { fn build_state() -> Result, Report> { let store_name = default_config_store_name(); let config_key = default_config_key(); + // Axum secret-store wiring is a follow-up; `None` fails closed on + // store-mode blobs. let settings = - get_settings_from_config_store(&AxumPlatformConfigStore, &store_name, &config_key)?; + get_settings_from_config_store(&AxumPlatformConfigStore, None, &store_name, &config_key)?; build_state_with_settings(settings) } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e56498b10..35796dfe7 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -164,7 +164,12 @@ 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, + Some(&FastlyPlatformSecretStore), + &store_name, + &config_key, + ) } pub(crate) fn build_state_from_settings( diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd747..51ea8bce4 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -21,7 +21,7 @@ use crate::integrations::{ lockr::LockrConfig, nextjs::NextJsIntegrationConfig, osano::OsanoConfig, permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{IntegrationConfig, Settings}; +use crate::settings::{IntegrationConfig, SecretFieldMode, Settings}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -91,7 +91,8 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { D: Deserializer<'de>, { let settings = Settings::deserialize(deserializer)?; - let settings = Settings::finalize_deserialized(settings, "Configuration") + let mode = settings.secret_field_mode(); + let settings = Settings::finalize_deserialized(settings, "Configuration", mode) .map_err(serde::de::Error::custom)?; Ok(Self { settings }) } @@ -105,11 +106,12 @@ impl Validate for TrustedServerAppConfig { } 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. + // Empty on purpose: EdgeZero's `#[secret]` reflection only handles + // top-level fields, while Trusted Server secrets are nested/array + // fields. Secret-store references are instead handled in-repo by + // `crate::secret_refs` (gated by `[secrets]`), which mirrors EdgeZero's + // key-names-at-rest semantics so the nested `#[secret]` derive can + // replace it once available upstream. const SECRET_FIELDS: &'static [edgezero_core::app_config::SecretField] = &[]; } @@ -124,10 +126,17 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { /// /// Returns [`TrustedServerError`] when the config should not be deployed. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { - settings.reject_placeholder_secrets()?; + let mode = settings.secret_field_mode(); + match mode { + // Store mode: secret fields hold key names; value checks + // (placeholders, token length) run at runtime against the resolved + // values instead. + SecretFieldMode::KeyNames => settings.validate_secret_key_names()?, + SecretFieldMode::ResolvedValues => settings.reject_placeholder_secrets()?, + } let enabled_auction_providers = validate_enabled_integrations(settings)?; validate_auction_provider_names(settings, &enabled_auction_providers)?; - PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; + PartnerRegistry::from_config_with_secret_mode(&settings.ec.partners, mode).map(|_| ())?; Ok(()) } @@ -213,6 +222,8 @@ fn report_to_validation_errors(report: &Report) -> Validatio #[cfg(test)] mod tests { use super::*; + use crate::redacted::Redacted; + use crate::settings::EcPartner; use crate::test_support::tests::crate_test_settings_str; fn valid_settings() -> Settings { @@ -251,6 +262,68 @@ mod tests { ); } + fn store_mode_settings_with_key_names() -> Settings { + let mut settings = valid_settings(); + settings.secrets.enabled = true; + settings.publisher.proxy_secret = Redacted::new("proxy_secret".to_owned()); + settings.ec.passphrase = Redacted::new("ec_passphrase".to_owned()); + for handler in &mut settings.handlers { + handler.password = Redacted::new("admin_password".to_owned()); + } + settings + } + + #[test] + fn deploy_validation_accepts_key_name_secrets_in_store_mode() { + let settings = store_mode_settings_with_key_names(); + + validate_settings_for_deploy(&settings) + .expect("should accept key-name secrets in store mode"); + } + + #[test] + fn deploy_validation_accepts_key_name_partner_tokens_in_store_mode() { + let mut settings = store_mode_settings_with_key_names(); + let partner_toml = r#" + name = "Example Partner" + source_domain = "partner.example" + api_token = "partner_api_token" + "#; + settings.ec.partners = + vec![toml::from_str::(partner_toml).expect("should parse partner fixture")]; + + validate_settings_for_deploy(&settings) + .expect("should accept short key-name partner tokens in store mode"); + } + + #[test] + fn deploy_validation_rejects_whitespace_key_names_in_store_mode() { + let mut settings = store_mode_settings_with_key_names(); + settings.ec.passphrase = Redacted::new("has space".to_owned()); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject whitespace in secret key names"); + + assert!( + err.to_string().contains("ec.passphrase"), + "error should mention the offending field: {err}" + ); + } + + #[test] + fn deploy_validation_rejects_empty_key_names_in_store_mode() { + let mut settings = store_mode_settings_with_key_names(); + settings.publisher.proxy_secret = Redacted::new(String::new()); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject empty secret key names"); + + assert!( + err.to_string().contains("publisher.proxy_secret"), + "error should mention the offending field: {err}" + ); + } + #[test] fn deploy_validation_rejects_placeholders() { let settings = Settings::from_toml( diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ee269045..584ae630d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -9,6 +9,8 @@ use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; use crate::error::TrustedServerError; +use crate::platform::PlatformSecretStore; +use crate::secret_refs::resolve_secret_refs; use crate::settings::Settings; /// Default config-store key containing the Trusted Server app-config blob. @@ -16,12 +18,35 @@ pub const CONFIG_BLOB_KEY: &str = "app_config"; /// Reconstruct validated [`Settings`] from a serialized config blob envelope. /// +/// Inline mode only: blobs with `[secrets]` mode enabled fail closed. Use +/// [`settings_from_config_blob_with_secrets`] on adapters with a wired +/// secret store. +/// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when the envelope cannot be -/// parsed, fails integrity verification, or contains invalid settings data. +/// See [`settings_from_config_blob_with_secrets`]. pub fn settings_from_config_blob( envelope_json: &str, +) -> Result> { + settings_from_config_blob_with_secrets(envelope_json, None) +} + +/// Reconstruct validated [`Settings`] from a config blob envelope, resolving +/// secret-store references when the blob enables `[secrets]` mode. +/// +/// Secret refs are resolved on the verified blob JSON before [`Settings`] +/// parsing, so validation (including placeholder rejection) always runs +/// against resolved values. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the envelope cannot be +/// parsed, fails integrity verification, contains invalid settings data, +/// enables `[secrets]` mode while `secret_store` is `None`, or references a +/// secret that cannot be resolved. +pub fn settings_from_config_blob_with_secrets( + envelope_json: &str, + secret_store: Option<&dyn PlatformSecretStore>, ) -> Result> { let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { Report::new(TrustedServerError::Configuration { @@ -36,14 +61,33 @@ pub fn settings_from_config_blob( .attach(error.to_string()) })?; - let settings = Settings::from_json_value(envelope.into_data())?; + let mut data = envelope.into_data(); + let store_mode = data + .pointer("/secrets/enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if store_mode { + let secret_store = secret_store.ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "[secrets] mode is enabled but this adapter has no secret store wired; \ + disable [secrets] or use an adapter with secret-store support" + .to_string(), + }) + })?; + resolve_secret_refs(&mut data, secret_store)?; + } + + let settings = Settings::from_json_value(data)?; settings.reject_placeholder_secrets()?; Ok(settings) } #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; + use crate::platform::test_support::HashMapSecretStore; use crate::redacted::Redacted; use crate::test_support::tests::crate_test_settings_str; @@ -105,6 +149,110 @@ mod tests { ); } + fn store_mode_envelope_json() -> String { + let mut settings = test_settings(); + settings.secrets.enabled = true; + settings.publisher.proxy_secret = Redacted::new("proxy_secret".to_owned()); + settings.ec.passphrase = Redacted::new("ec_passphrase".to_owned()); + settings.handlers[0].password = Redacted::new("secure_password".to_owned()); + settings.handlers[1].password = Redacted::new("admin_password".to_owned()); + envelope_json(&settings) + } + + fn store_mode_secret_store() -> HashMapSecretStore { + HashMapSecretStore::new(HashMap::from([ + ("proxy_secret".to_owned(), b"resolved-proxy-secret".to_vec()), + ( + "ec_passphrase".to_owned(), + b"resolved-passphrase-32-bytes-min!".to_vec(), + ), + ( + "secure_password".to_owned(), + b"resolved-secure-password".to_vec(), + ), + ( + "admin_password".to_owned(), + b"resolved-admin-password".to_vec(), + ), + ])) + } + + #[test] + fn store_mode_blob_resolves_secrets_before_parse() { + let store = store_mode_secret_store(); + + let settings = + settings_from_config_blob_with_secrets(&store_mode_envelope_json(), Some(&store)) + .expect("should resolve store-mode blob"); + + assert_eq!( + settings.publisher.proxy_secret.expose(), + "resolved-proxy-secret", + "should expose resolved proxy secret" + ); + assert_eq!( + settings.ec.passphrase.expose(), + "resolved-passphrase-32-bytes-min!", + "should expose resolved passphrase" + ); + assert_eq!( + settings.handlers[1].password.expose(), + "resolved-admin-password", + "should expose resolved handler password" + ); + } + + #[test] + fn store_mode_blob_without_secret_store_fails_closed() { + let err = settings_from_config_blob_with_secrets(&store_mode_envelope_json(), None) + .expect_err("should fail closed without a secret store"); + + assert!( + err.to_string().contains("[secrets]"), + "error should mention [secrets] mode: {err}" + ); + } + + #[test] + fn store_mode_blob_with_short_resolved_passphrase_fails_validation() { + let store = HashMapSecretStore::new(HashMap::from([ + ("proxy_secret".to_owned(), b"resolved-proxy-secret".to_vec()), + ("ec_passphrase".to_owned(), b"too-short".to_vec()), + ( + "secure_password".to_owned(), + b"resolved-secure-password".to_vec(), + ), + ( + "admin_password".to_owned(), + b"resolved-admin-password".to_vec(), + ), + ])); + + let err = settings_from_config_blob_with_secrets(&store_mode_envelope_json(), Some(&store)) + .expect_err("should reject short resolved passphrase"); + + assert!( + err.to_string().contains("validation failed"), + "error should mention validation: {err}" + ); + } + + #[test] + fn inline_blob_ignores_secret_store() { + let original = test_settings(); + let store = store_mode_secret_store(); + + let reconstructed = + settings_from_config_blob_with_secrets(&envelope_json(&original), Some(&store)) + .expect("should load inline blob unchanged"); + + assert_eq!( + reconstructed.publisher.proxy_secret.expose(), + original.publisher.proxy_secret.expose(), + "inline mode should not resolve secrets" + ); + } + #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index 6b688d30e..c678463c6 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -10,7 +10,7 @@ use error_stack::{Report, ResultExt as _}; use crate::error::TrustedServerError; use crate::redacted::Redacted; -use crate::settings::EcPartner; +use crate::settings::{EcPartner, SecretFieldMode}; use super::partner::{hash_api_key, normalize_partner_source_domain}; @@ -69,6 +69,23 @@ impl PartnerRegistry { /// invalid source domain, duplicate source domain, duplicate API token hash, /// or invalid pull sync configuration. pub fn from_config(partners: &[EcPartner]) -> Result> { + Self::from_config_with_secret_mode(partners, SecretFieldMode::ResolvedValues) + } + + /// Builds a registry like [`Self::from_config`], with API token checks + /// matched to `mode`. + /// + /// In [`SecretFieldMode::KeyNames`] the tokens are secret-store key + /// names, so the minimum token length check is deferred to runtime where + /// the resolved values are available. + /// + /// # Errors + /// + /// See [`Self::from_config`]. + pub fn from_config_with_secret_mode( + partners: &[EcPartner], + mode: SecretFieldMode, + ) -> Result> { let mut by_source_domain = HashMap::with_capacity(partners.len()); let mut by_api_key_hash = HashMap::with_capacity(partners.len()); @@ -86,7 +103,9 @@ impl PartnerRegistry { })); } - validate_api_token(&normalized_source, partner.api_token.expose())?; + if mode == SecretFieldMode::ResolvedValues { + validate_api_token(&normalized_source, partner.api_token.expose())?; + } let api_key_hash = hash_api_key(partner.api_token.expose()); diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cfd..9de60b6c3 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -63,6 +63,7 @@ pub mod request_signing; pub mod response_privacy; pub mod rsc_flight; pub(crate) mod s3_sigv4; +pub mod secret_refs; pub mod settings; pub mod settings_data; pub mod storage; diff --git a/crates/trusted-server-core/src/secret_refs.rs b/crates/trusted-server-core/src/secret_refs.rs new file mode 100644 index 000000000..31bc2020e --- /dev/null +++ b/crates/trusted-server-core/src/secret_refs.rs @@ -0,0 +1,297 @@ +//! Secret-store reference resolution for the app-config blob. +//! +//! When `[secrets] enabled = true`, secret-bearing fields in the pushed +//! app-config blob hold secret-store **key names**. This module swaps each +//! key name for the value fetched from the platform secret store, operating +//! on the verified blob JSON before [`crate::settings::Settings`] parsing. +//! +//! Semantics intentionally mirror `edgezero_core`'s `secret_walk` (key names +//! at rest, resolve at load, fail closed) extended with nested and array +//! paths, so the `EdgeZero` Phase 3 nested `#[secret]` derive can replace +//! this module once it ships upstream. + +use error_stack::Report; +use serde_json::Value; + +use crate::error::TrustedServerError; +use crate::platform::{PlatformSecretStore, StoreName}; + +/// Default secret store name when the blob does not declare `secrets.store`. +const DEFAULT_SECRET_STORE: &str = "ts_secrets"; + +/// Dotted paths of secret-bearing fields. `[]` walks every array element. +/// +/// `handlers[].username` is intentionally excluded (sensitive, but not +/// secret-store material). Keep in sync with +/// [`crate::settings::Settings::reject_placeholder_secrets`]. +const SECRET_REF_PATHS: &[&str] = &[ + "publisher.proxy_secret", + "ec.passphrase", + "ec.partners[].api_token", + "ec.partners[].ts_pull_token", + "handlers[].password", +]; + +/// Replaces secret-ref key names in `data` with values from `secret_store`. +/// +/// Reads the store name from `data.secrets.store` (default `ts_secrets`). +/// Absent sections, absent leaves, and `null` leaves are skipped so optional +/// fields keep their semantics. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when a present leaf is not a +/// string, or when the secret store cannot supply a referenced key. Error +/// messages carry the dotted path and key name, never a secret value. +pub fn resolve_secret_refs( + data: &mut Value, + secret_store: &dyn PlatformSecretStore, +) -> Result<(), Report> { + let store_name = StoreName::from( + data.pointer("/secrets/store") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_SECRET_STORE), + ); + for path in SECRET_REF_PATHS { + resolve_path(data, path, path, secret_store, &store_name)?; + } + Ok(()) +} + +fn resolve_path( + node: &mut Value, + remaining: &str, + full_path: &str, + secret_store: &dyn PlatformSecretStore, + store_name: &StoreName, +) -> Result<(), Report> { + match remaining.split_once('.') { + None => resolve_leaf(node, remaining, full_path, secret_store, store_name), + Some((head, rest)) => { + if let Some(field) = head.strip_suffix("[]") { + let Some(items) = node.get_mut(field).and_then(Value::as_array_mut) else { + return Ok(()); + }; + for item in items { + resolve_path(item, rest, full_path, secret_store, store_name)?; + } + Ok(()) + } else { + match node.get_mut(head) { + Some(child) => resolve_path(child, rest, full_path, secret_store, store_name), + None => Ok(()), + } + } + } + } +} + +fn resolve_leaf( + node: &mut Value, + field: &str, + full_path: &str, + secret_store: &dyn PlatformSecretStore, + store_name: &StoreName, +) -> Result<(), Report> { + let Some(leaf) = node.get(field) else { + return Ok(()); + }; + if leaf.is_null() { + return Ok(()); + } + let Some(key_name) = leaf.as_str() else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("secret ref `{full_path}` must be a string secret key name"), + })); + }; + let key_name = key_name.to_owned(); + let resolved = secret_store + .get_string(store_name, &key_name) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "failed to resolve secret ref `{full_path}` (key `{key_name}`) from secret store `{store_name}`" + ), + }) + .attach(error.to_string()) + })?; + node[field] = Value::String(resolved); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use super::*; + use crate::platform::test_support::HashMapSecretStore; + + fn test_store() -> HashMapSecretStore { + HashMapSecretStore::new(HashMap::from([ + ("proxy_secret".to_owned(), b"resolved-proxy-secret".to_vec()), + ( + "ec_passphrase".to_owned(), + b"resolved-passphrase-32-bytes-min!".to_vec(), + ), + ( + "partner_a_token".to_owned(), + b"resolved-token-a-32-bytes-minimum".to_vec(), + ), + ( + "partner_b_token".to_owned(), + b"resolved-token-b-32-bytes-minimum".to_vec(), + ), + ( + "partner_b_pull".to_owned(), + b"resolved-pull-token-b".to_vec(), + ), + ( + "admin_password".to_owned(), + b"resolved-admin-password".to_vec(), + ), + ])) + } + + #[test] + fn resolves_nested_and_array_secret_refs() { + let mut data = json!({ + "secrets": { "enabled": true, "store": "ts_secrets" }, + "publisher": { "proxy_secret": "proxy_secret" }, + "ec": { + "passphrase": "ec_passphrase", + "partners": [ + { "api_token": "partner_a_token" }, + { "api_token": "partner_b_token", "ts_pull_token": "partner_b_pull" } + ] + }, + "handlers": [ { "password": "admin_password" } ] + }); + + resolve_secret_refs(&mut data, &test_store()).expect("should resolve all refs"); + + assert_eq!( + data["publisher"]["proxy_secret"], "resolved-proxy-secret", + "should resolve nested scalar" + ); + assert_eq!( + data["ec"]["passphrase"], "resolved-passphrase-32-bytes-min!", + "should resolve ec passphrase" + ); + assert_eq!( + data["ec"]["partners"][0]["api_token"], "resolved-token-a-32-bytes-minimum", + "should resolve first partner token" + ); + assert_eq!( + data["ec"]["partners"][1]["ts_pull_token"], "resolved-pull-token-b", + "should resolve optional array leaf" + ); + assert_eq!( + data["handlers"][0]["password"], "resolved-admin-password", + "should resolve handler password" + ); + } + + #[test] + fn skips_absent_sections_and_optional_leaves() { + let mut data = json!({ + "publisher": { "proxy_secret": "proxy_secret" }, + "ec": { + "passphrase": "ec_passphrase", + "partners": [ { "api_token": "partner_a_token" } ] + } + }); + + resolve_secret_refs(&mut data, &test_store()) + .expect("should skip absent handlers and ts_pull_token"); + + assert_eq!( + data["ec"]["partners"][0]["api_token"], "resolved-token-a-32-bytes-minimum", + "should still resolve present leaves" + ); + assert!( + data["ec"]["partners"][0].get("ts_pull_token").is_none(), + "should not insert absent optional leaves" + ); + } + + #[test] + fn skips_null_leaf() { + let mut data = json!({ + "publisher": { "proxy_secret": null } + }); + + resolve_secret_refs(&mut data, &test_store()).expect("should skip null leaf"); + + assert!( + data["publisher"]["proxy_secret"].is_null(), + "should leave null leaf untouched" + ); + } + + #[test] + fn missing_secret_key_fails_with_path_and_key_name() { + let mut data = json!({ + "publisher": { "proxy_secret": "unknown_key" } + }); + + let err = resolve_secret_refs(&mut data, &test_store()) + .expect_err("should fail on missing secret key"); + let message = err.to_string(); + + assert!( + message.contains("publisher.proxy_secret"), + "error should mention dotted path: {message}" + ); + } + + #[test] + fn non_string_leaf_fails() { + let mut data = json!({ + "publisher": { "proxy_secret": 42 } + }); + + let err = resolve_secret_refs(&mut data, &test_store()) + .expect_err("should fail on non-string leaf"); + + assert!( + err.to_string().contains("publisher.proxy_secret"), + "error should mention the offending path" + ); + } + + #[test] + fn error_message_never_contains_resolved_values() { + let mut data = json!({ + "publisher": { "proxy_secret": "proxy_secret" }, + "ec": { "passphrase": "unknown_key" } + }); + + let err = resolve_secret_refs(&mut data, &test_store()) + .expect_err("should fail on missing passphrase key"); + let debug = format!("{err:?}"); + + assert!( + !debug.contains("resolved-proxy-secret"), + "error output should not leak resolved secrets" + ); + } + + #[test] + fn reads_store_name_with_default_fallback() { + // HashMapSecretStore ignores the store name, so assert the default + // path works end to end when `secrets` is absent entirely. + let mut data = json!({ + "ec": { "passphrase": "ec_passphrase" } + }); + + resolve_secret_refs(&mut data, &test_store()) + .expect("should resolve with default store name"); + + assert_eq!( + data["ec"]["passphrase"], "resolved-passphrase-32-bytes-min!", + "should resolve using default ts_secrets store name" + ); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a546..5d38bf44c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -9,7 +9,7 @@ use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; use url::Url; -use validator::{Validate, ValidationError}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; use crate::auction_config_types::AuctionConfig; use crate::consent_config::ConsentConfig; @@ -1925,6 +1925,71 @@ pub struct TesterCookieConfig { pub enabled: bool, } +/// Secret-store reference mode for secret-bearing settings fields. +/// +/// When [`SecretsSettings::enabled`] is `true`, secret fields in the app +/// config hold secret-store **key names** instead of secret values. The +/// runtime resolves them from the platform secret store at settings load. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct SecretsSettings { + /// Enables key-name resolution from the platform secret store. + #[serde(default)] + pub enabled: bool, + /// Secret store name holding the referenced secrets. + #[serde(default = "default_secrets_store")] + #[validate(length(min = 1))] + pub store: String, +} + +impl Default for SecretsSettings { + fn default() -> Self { + Self { + enabled: false, + store: default_secrets_store(), + } + } +} + +fn default_secrets_store() -> String { + "ts_secrets".to_owned() +} + +/// How secret-bearing fields should be validated during settings +/// finalization. +/// +/// Push-side tooling sees secret-store key names and must skip value-shape +/// validators; the runtime sees resolved secret values and must run +/// everything. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum SecretFieldMode { + /// Fields hold resolved secret values — run every validator. + ResolvedValues, + /// Fields hold secret-store key names — skip value-shape validators. + KeyNames, +} + +/// Removes `ec.passphrase` value-shape errors from `errors`, returning +/// `None` when nothing else remains. +/// +/// Key-name mode defers passphrase shape checks to runtime, where the +/// resolved value is validated. The passphrase minimum-length rule is the +/// only serde-level validator a legitimate key name can trip; every other +/// secret-field validator only rejects empty values. +fn strip_key_name_validation_errors(mut errors: ValidationErrors) -> Option { + if let Some(ValidationErrorsKind::Struct(inner)) = errors.errors_mut().get_mut("ec") { + inner.errors_mut().remove("passphrase"); + if inner.is_empty() { + errors.errors_mut().remove("ec"); + } + } + if errors.is_empty() { + None + } else { + Some(errors) + } +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Settings { @@ -1960,6 +2025,9 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + #[serde(default)] + #[validate(nested)] + pub secrets: SecretsSettings, } impl Settings { @@ -1974,13 +2042,17 @@ impl Settings { message: "Failed to deserialize TOML configuration".to_string(), })?; - Self::finalize_deserialized(settings, "Configuration") + let mode = settings.secret_field_mode(); + Self::finalize_deserialized(settings, "Configuration", mode) } /// Creates a new [`Settings`] instance from a JSON value. /// /// Runtime config-store loading uses this after verifying the `app_config` - /// blob envelope and extracting the same typed settings shape. + /// blob envelope and extracting the same typed settings shape. Callers + /// must resolve secret-store references first + /// ([`crate::secret_refs::resolve_secret_refs`]); secret fields are + /// validated as resolved values regardless of `[secrets]` mode. /// /// # Errors /// @@ -1991,7 +2063,7 @@ impl Settings { message: "Failed to deserialize JSON configuration".to_string(), })?; - Self::finalize_deserialized(settings, "Configuration") + Self::finalize_deserialized(settings, "Configuration", SecretFieldMode::ResolvedValues) } /// Creates a new [`Settings`] instance from a TOML string with legacy @@ -2026,12 +2098,29 @@ impl Settings { message: "Failed to deserialize configuration".to_string(), })?; - Self::finalize_deserialized(settings, "Build-time configuration") + Self::finalize_deserialized( + settings, + "Build-time configuration", + SecretFieldMode::ResolvedValues, + ) + } + + /// Returns how secret-bearing fields should be validated for this + /// configuration: key names when `[secrets]` mode is enabled, resolved + /// values otherwise. + #[must_use] + pub fn secret_field_mode(&self) -> SecretFieldMode { + if self.secrets.enabled { + SecretFieldMode::KeyNames + } else { + SecretFieldMode::ResolvedValues + } } pub(crate) fn finalize_deserialized( mut settings: Self, validation_label: &str, + mode: SecretFieldMode, ) -> Result> { settings.integrations.normalize(); settings.proxy.normalize(); @@ -2040,14 +2129,28 @@ impl Settings { settings.prepare_runtime()?; - settings.validate().map_err(|err| { - Report::new(TrustedServerError::Configuration { - message: format!("{validation_label} validation failed: {err}"), - }) - })?; + settings + .validate() + .map_or_else( + |err| match mode { + SecretFieldMode::KeyNames => match strip_key_name_validation_errors(err) { + None => Ok(()), + Some(remaining) => Err(remaining), + }, + SecretFieldMode::ResolvedValues => Err(err), + }, + Ok, + ) + .map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!("{validation_label} validation failed: {err}"), + }) + })?; settings.validate_admin_coverage()?; - settings.validate_admin_handler_passwords()?; + if mode == SecretFieldMode::ResolvedValues { + settings.validate_admin_handler_passwords()?; + } Ok(settings) } @@ -2108,6 +2211,65 @@ impl Settings { .unwrap_or(&[]) } + /// Validates that every secret-bearing field holds a plausible secret + /// key NAME (store mode): non-empty, without whitespace or control + /// characters. + /// + /// Field coverage matches [`Self::reject_placeholder_secrets`] and + /// [`crate::secret_refs`]. Value-shape checks (length, placeholders) run + /// at runtime against the resolved values instead. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] listing every offending + /// field path. Messages never include the field values. + pub fn validate_secret_key_names(&self) -> Result<(), Report> { + fn is_invalid_key_name(key_name: &str) -> bool { + key_name.is_empty() + || key_name + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + } + + let mut invalid_fields: Vec = Vec::new(); + + if is_invalid_key_name(self.publisher.proxy_secret.expose()) { + invalid_fields.push("publisher.proxy_secret".to_owned()); + } + if is_invalid_key_name(self.ec.passphrase.expose()) { + invalid_fields.push("ec.passphrase".to_owned()); + } + for partner in &self.ec.partners { + if is_invalid_key_name(partner.api_token.expose()) { + invalid_fields.push(format!("ec.partners[{}].api_token", partner.source_domain)); + } + if let Some(ts_pull_token) = &partner.ts_pull_token { + if is_invalid_key_name(ts_pull_token.expose()) { + invalid_fields.push(format!( + "ec.partners[{}].ts_pull_token", + partner.source_domain + )); + } + } + } + for handler in &self.handlers { + if is_invalid_key_name(handler.password.expose()) { + invalid_fields.push(format!("handlers[{}].password", handler.path)); + } + } + + if invalid_fields.is_empty() { + return Ok(()); + } + + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "secret key names must be non-empty without whitespace or control characters: {}", + invalid_fields.join(", ") + ), + })) + } + /// Rejects known placeholder secret values. /// /// # Errors @@ -2601,6 +2763,131 @@ mod tests { use crate::redacted::Redacted; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + #[test] + fn secrets_section_defaults_to_disabled_inline_mode() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without [secrets] section"); + + assert!(!settings.secrets.enabled, "should default to inline mode"); + assert_eq!( + settings.secrets.store, "ts_secrets", + "should default to ts_secrets store" + ); + } + + #[test] + fn secrets_section_parses_enabled_and_store() { + let toml = format!( + "{}\n[secrets]\nenabled = true\nstore = \"custom_secrets\"\n", + crate_test_settings_str() + ); + + let settings = Settings::from_toml(&toml).expect("should parse [secrets] section"); + + assert!(settings.secrets.enabled, "should enable store mode"); + assert_eq!( + settings.secrets.store, "custom_secrets", + "should read store name" + ); + } + + #[test] + fn key_names_mode_accepts_short_passphrase_ref() { + let toml = format!( + "{}\n[secrets]\nenabled = true\n", + crate_test_settings_str().replace("test-secret-key-32-bytes-minimum", "ec_passphrase") + ); + + let settings = + Settings::from_toml(&toml).expect("should accept key-name passphrase in store mode"); + + assert_eq!( + settings.ec.passphrase.expose(), + "ec_passphrase", + "should keep the key name until runtime resolution" + ); + } + + #[test] + fn resolved_values_mode_still_rejects_short_passphrase() { + let toml = crate_test_settings_str().replace("test-secret-key-32-bytes-minimum", "short"); + + let err = Settings::from_toml(&toml).expect_err("should reject short passphrase inline"); + + assert!( + err.to_string().contains("validation failed"), + "should fail validation: {err}" + ); + } + + #[test] + fn key_names_mode_still_rejects_non_secret_field_errors() { + // Break a non-secret validated field while in store mode: the + // key-name exemption must not swallow unrelated validation errors. + let toml = format!( + "{}\n[secrets]\nenabled = true\nstore = \"\"\n", + crate_test_settings_str() + ); + + let err = Settings::from_toml(&toml) + .expect_err("should reject empty secrets.store in store mode"); + + assert!( + err.to_string().contains("validation failed"), + "should fail validation: {err}" + ); + } + + #[test] + fn key_names_mode_skips_admin_placeholder_password_check() { + // `password` is an admin placeholder value inline, but a legitimate + // secret key NAME in store mode; the parse-time check must defer to + // runtime, which validates the resolved value. + let toml = format!( + "{}\n[secrets]\nenabled = true\n", + crate_test_settings_str() + .replace("password = \"admin-pass\"", "password = \"password\"") + ); + + let settings = Settings::from_toml(&toml) + .expect("should accept key-name admin password in store mode"); + + assert_eq!( + settings.handlers[1].password.expose(), + "password", + "should keep the key name until runtime resolution" + ); + } + + #[test] + fn resolved_values_mode_still_rejects_admin_placeholder_password() { + let toml = crate_test_settings_str() + .replace("password = \"admin-pass\"", "password = \"password\""); + + let err = Settings::from_toml(&toml) + .expect_err("should reject placeholder admin password inline"); + + assert!( + err.to_string().contains("placeholder password"), + "should mention placeholder password: {err}" + ); + } + + #[test] + fn secrets_section_rejects_unknown_fields() { + let toml = format!( + "{}\n[secrets]\nenabled = true\ntypo = true\n", + crate_test_settings_str() + ); + + let err = Settings::from_toml(&toml).expect_err("should reject unknown [secrets] field"); + + assert!( + err.to_string().contains("deserialize"), + "should fail deserialization: {err}" + ); + } + #[test] fn tinybird_defaults_to_disabled_placeholders() { let settings = Settings::from_toml(&crate_test_settings_str()) diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 0261c7e4d..c64e637d6 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -3,9 +3,9 @@ use error_stack::{Report, ResultExt}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; -use crate::config_payload::settings_from_config_blob; +use crate::config_payload::settings_from_config_blob_with_secrets; 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 = "app_config"; @@ -42,19 +42,24 @@ pub fn default_config_key() -> String { /// Loads [`Settings`] from a platform config store and key. /// +/// `secret_store` resolves `[secrets]`-mode key names into secret values at +/// load; adapters without secret-store support pass `None`, which fails +/// closed on store-mode blobs. +/// /// # 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, references secrets +/// that cannot be resolved, or fails Trusted Server settings validation. pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, + secret_store: Option<&dyn PlatformSecretStore>, store_name: &StoreName, key: &str, ) -> 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_with_secrets(&envelope_json, secret_store) } fn read_config_entry( @@ -228,9 +233,13 @@ mod tests { 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 = get_settings_from_config_store( + &store, + None, + &StoreName::from("app_config"), + CONFIG_BLOB_KEY, + ) + .expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, @@ -238,6 +247,48 @@ mod tests { ); } + #[test] + fn loads_store_mode_settings_resolving_secrets() { + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); + settings.secrets.enabled = true; + settings.ec.passphrase = crate::redacted::Redacted::new("ec_passphrase".to_owned()); + settings.publisher.proxy_secret = crate::redacted::Redacted::new("proxy_secret".to_owned()); + for handler in &mut settings.handlers { + handler.password = crate::redacted::Redacted::new("handler_password".to_owned()); + } + let store = MemoryConfigStore { + entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), envelope_json(&settings))]), + }; + let secret_store = crate::platform::test_support::HashMapSecretStore::new( + std::collections::HashMap::from([ + ( + "ec_passphrase".to_owned(), + b"resolved-passphrase-32-bytes-min!".to_vec(), + ), + ("proxy_secret".to_owned(), b"resolved-proxy-secret".to_vec()), + ( + "handler_password".to_owned(), + b"resolved-handler-password".to_vec(), + ), + ]), + ); + + let loaded = get_settings_from_config_store( + &store, + Some(&secret_store), + &StoreName::from("app_config"), + CONFIG_BLOB_KEY, + ) + .expect("should load store-mode settings"); + + assert_eq!( + loaded.ec.passphrase.expose(), + "resolved-passphrase-32-bytes-min!", + "should resolve passphrase from secret store" + ); + } + #[test] fn loads_settings_from_fastly_chunk_pointer() { let settings = @@ -275,9 +326,13 @@ mod tests { ]), }; - let loaded = - get_settings_from_config_store(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + let loaded = get_settings_from_config_store( + &store, + None, + &StoreName::from("app_config"), + CONFIG_BLOB_KEY, + ) + .expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, @@ -306,9 +361,13 @@ 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 = get_settings_from_config_store( + &store, + None, + &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 +381,13 @@ 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 = get_settings_from_config_store( + &store, + None, + &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/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md b/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md new file mode 100644 index 000000000..355f06fd8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md @@ -0,0 +1,724 @@ +# Secrets to Secret Store (Issue #846) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop `ts config push` from persisting secret values in the config store: secret-bearing `Settings` fields hold secret-store **key names** at rest, resolved from the platform secret store at startup — Fastly first, other adapters later. + +**Architecture:** Opt-in `[secrets]` section gates a "key names at rest" mode. A small resolver in core walks the verified app-config blob JSON (between envelope verification and `Settings` parse) and swaps each secret-ref leaf for the value fetched via `PlatformSecretStore`. Validation splits like EdgeZero spec 3.3.8: push-side validation skips value-shape checks on secret fields (they hold key names); runtime validates fully against resolved values and fails closed. Semantics intentionally mirror `edgezero_core`'s `secret_walk` so EdgeZero Phase 3 (#843) can later delete this code and replace it with the nested `#[secret]` derive. + +**Tech Stack:** Rust 2024, `error-stack`, `validator`, `serde_json`, existing `PlatformSecretStore` trait, Viceroy for Fastly tests. + +**Branch:** `846-secrets-to-secret-store` + +**Commit policy:** every commit requires explicit user approval before running `git commit` (standing user rule). Commit steps below are checkpoints to *request* approval, not to commit unprompted. + +--- + +## Background (read first — zero-context summary) + +- `Redacted` is `#[serde(transparent)]` (`crates/trusted-server-core/src/redacted.rs:29`), so `ts config push` serializes real secret values into the `app_config` config-store blob. Config stores are not secret-grade storage. +- Secret fields (all consumers take sync `&Settings`, so resolution must happen at settings load): + | TOML path | Struct field | Value-shape validation today | + |---|---|---| + | `publisher.proxy_secret` | `Publisher::proxy_secret` (`settings.rs:45`) | non-empty | + | `ec.passphrase` | `Ec::passphrase` (`settings.rs:449`) | non-empty + **min 32 chars** (`Ec::validate_passphrase`) | + | `ec.partners[].api_token` | `EcPartner::api_token` (`settings.rs:313`) | non-empty + min 32 bytes, at registry build (`ec/registry.rs:189-207`) | + | `ec.partners[].ts_pull_token` | `EcPartner::ts_pull_token` (`settings.rs:347`, `Option`) | required when `pull_sync_enabled` | + | `handlers[].password` | `Handler::password` (`settings.rs:584`) | non-empty + admin placeholder check (`settings.rs:2255-2274`) | +- `handlers[].username` is deliberately **not** migrated (sensitive but not secret-store material — issue #684). +- Load pipeline: `get_settings_from_config_store` (`settings_data.rs:50`) → chunk-pointer resolution → `settings_from_config_blob` (`config_payload.rs:23`) → `BlobEnvelope::verify` → `Settings::from_json_value` → `finalize_deserialized` (normalize + `validate()` + admin checks) → `reject_placeholder_secrets`. +- Push pipeline: `ts config push` → `edgezero_cli::run_config_push_typed::` → our `Deserialize` impl (`config.rs:88-98`) → `finalize_deserialized` → `Validate` impl → `validate_settings_for_deploy` (`config.rs:126`). +- Fastly boot: `load_settings_from_config_store` (`adapter-fastly/src/app.rs:164`); on failure Fastly serves `startup_error_router` (fail-closed). +- `Settings` is `#[serde(deny_unknown_fields)]`: an old WASM binary hard-fails on a blob containing the new `[secrets]` section. **Rollout order: deploy new WASM first, then seed secrets, then push store-mode blob.** +- Migration must carry existing secret **values unchanged** into the secret store: regenerating `ec.passphrase` rotates all visitor IDs; regenerating `proxy_secret` invalidates outstanding proxy URL tokens. +- Existing test helpers: `MemoryConfigStore` (`settings_data.rs` tests), `HashMapSecretStore` (`crate::platform::test_support`, `platform/test_support.rs:96`). +- Local dev: `fastly.toml [local_server.secret_stores.ts_secrets]` already exists (Tinybird tokens); Viceroy serves it. + +Out of scope (follow-ups, do NOT implement here): Cloudflare/Spin/Axum wiring beyond passing `None`, `ts secrets` CLI verb, `edgezero.toml` store-id alignment, request-signing keys (#686), EdgeZero Phase 3 derive swap (#843). + +--- + +### Task 1: `[secrets]` settings section + +**Files:** +- Modify: `crates/trusted-server-core/src/settings.rs` (new struct + `Settings` field) +- Test: same file, `#[cfg(test)] mod tests` + +- [x] **Step 1: Write failing tests** + +In the settings tests module: + +```rust +#[test] +fn secrets_section_defaults_to_disabled_inline_mode() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without [secrets] section"); + assert!(!settings.secrets.enabled, "should default to inline mode"); + assert_eq!( + settings.secrets.store, "ts_secrets", + "should default to ts_secrets store" + ); +} + +#[test] +fn secrets_section_parses_enabled_and_store() { + let mut toml = crate_test_settings_str(); + toml.push_str("\n[secrets]\nenabled = true\nstore = \"custom_secrets\"\n"); + // NOTE: with enabled = true the fixture's inline values are treated as + // key names; parse must still succeed (KeyNames mode skips value-shape + // validators — Task 3). If Task 3 is not yet done, use key-name-shaped + // values long enough to pass validators for this parse test. + let settings = Settings::from_toml(&toml).expect("should parse [secrets] section"); + assert!(settings.secrets.enabled, "should enable store mode"); + assert_eq!(settings.secrets.store, "custom_secrets", "should read store name"); +} +``` + +- [x] **Step 2: Run tests, verify failure** + +Run: `cargo test-fastly -p trusted-server-core secrets_section 2>&1 | tail -20` +Expected: FAIL — no field `secrets` on `Settings`. + +- [x] **Step 3: Implement** + +Add near the other section structs in `settings.rs`: + +```rust +/// Secret-store reference mode for secret-bearing settings fields. +/// +/// When [`SecretsSettings::enabled`] is `true`, secret fields in the app +/// config hold secret-store **key names** instead of secret values. The +/// runtime resolves them from the platform secret store at settings load; +/// see [`crate::secret_refs`]. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct SecretsSettings { + /// Enables key-name resolution from the platform secret store. + #[serde(default)] + pub enabled: bool, + /// Secret store name holding the referenced secrets. + #[serde(default = "default_secrets_store")] + #[validate(length(min = 1))] + pub store: String, +} + +impl Default for SecretsSettings { + fn default() -> Self { + Self { + enabled: false, + store: default_secrets_store(), + } + } +} + +fn default_secrets_store() -> String { + "ts_secrets".to_owned() +} +``` + +Add to `Settings` (root struct, `settings.rs:1930`): + +```rust + #[serde(default)] + #[validate(nested)] + pub secrets: SecretsSettings, +``` + +- [x] **Step 4: Run tests, verify pass** + +Run: `cargo test-fastly -p trusted-server-core secrets_section 2>&1 | tail -5` +Expected: PASS (the `enabled = true` test may need Task 3 — if it fails only on the min-32 passphrase validator, mark it `#[ignore]` with a `// until Task 3` note and un-ignore in Task 3). + +- [ ] **Step 5: Checkpoint — request commit approval** + +`Add [secrets] settings section for secret-store reference mode` + +--- + +### Task 2: Secret-ref resolver module + +**Files:** +- Create: `crates/trusted-server-core/src/secret_refs.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` (add `pub mod secret_refs;`) + +- [x] **Step 1: Write failing tests** (in `secret_refs.rs` `#[cfg(test)]`) + +Cover, using `crate::platform::test_support::HashMapSecretStore` and `serde_json::json!`: +1. resolves nested scalar (`publisher.proxy_secret`), +2. resolves array element (`ec.partners[].api_token` for two partners), +3. skips absent optional leaf (`ts_pull_token` missing) and absent sections (no `handlers`), +4. skips `null` leaf, +5. missing key in store → `Err` mentioning dotted path and key name, never any secret value, +6. non-string leaf → `Err`, +7. reads store name from `secrets.store`, defaults to `ts_secrets` when absent. + +```rust +#[test] +fn resolves_nested_and_array_secret_refs() { + let mut data = json!({ + "secrets": { "enabled": true, "store": "ts_secrets" }, + "publisher": { "proxy_secret": "proxy_secret" }, + "ec": { + "passphrase": "ec_passphrase", + "partners": [ + { "api_token": "partner_a_token" }, + { "api_token": "partner_b_token", "ts_pull_token": "partner_b_pull" } + ] + }, + "handlers": [ { "password": "admin_password" } ] + }); + let store = HashMapSecretStore::new(HashMap::from([ + (("ts_secrets".to_owned(), "proxy_secret".to_owned()), b"resolved-proxy".to_vec()), + (("ts_secrets".to_owned(), "ec_passphrase".to_owned()), b"resolved-passphrase-32-bytes-min!".to_vec()), + (("ts_secrets".to_owned(), "partner_a_token".to_owned()), b"resolved-token-a-32-bytes-minimum".to_vec()), + (("ts_secrets".to_owned(), "partner_b_token".to_owned()), b"resolved-token-b-32-bytes-minimum".to_vec()), + (("ts_secrets".to_owned(), "partner_b_pull".to_owned()), b"resolved-pull-b".to_vec()), + (("ts_secrets".to_owned(), "admin_password".to_owned()), b"resolved-admin-password".to_vec()), + ])); + + resolve_secret_refs(&mut data, &store).expect("should resolve all refs"); + + assert_eq!(data["publisher"]["proxy_secret"], "resolved-proxy", "should resolve nested scalar"); + assert_eq!(data["ec"]["partners"][1]["ts_pull_token"], "resolved-pull-b", "should resolve optional array leaf"); +} +``` + +(Adapt the `HashMapSecretStore::new` argument to its real constructor shape in `platform/test_support.rs:96-110` — check before writing.) + +- [x] **Step 2: Run, verify fail** + +Run: `cargo test-fastly -p trusted-server-core secret_refs 2>&1 | tail -10` +Expected: FAIL — module does not exist. + +- [x] **Step 3: Implement resolver** + +```rust +//! Secret-store reference resolution for the app-config blob. +//! +//! When `[secrets] enabled = true`, secret-bearing fields in the pushed +//! app-config blob hold secret-store **key names**. This module swaps each +//! key name for the value fetched from the platform secret store, operating +//! on the verified blob JSON before [`crate::settings::Settings`] parsing. +//! +//! Semantics intentionally mirror `edgezero_core`'s `secret_walk` (key names +//! at rest, resolve at load, fail closed) extended with nested and array +//! paths, so the EdgeZero Phase 3 derive can replace this module. + +use error_stack::Report; +use serde_json::Value; + +use crate::error::TrustedServerError; +use crate::platform::{PlatformSecretStore, StoreName}; + +/// Dotted paths of secret-bearing fields. `[]` walks every array element. +/// `handlers[].username` is intentionally excluded (sensitive, not +/// secret-store material). Keep in sync with +/// `Settings::reject_placeholder_secrets` and `validate_secret_key_names`. +const SECRET_REF_PATHS: &[&str] = &[ + "publisher.proxy_secret", + "ec.passphrase", + "ec.partners[].api_token", + "ec.partners[].ts_pull_token", + "handlers[].password", +]; + +/// Replaces secret-ref key names in `data` with values from `secret_store`. +/// +/// Reads the store name from `data.secrets.store` (default `ts_secrets`). +/// Absent sections, absent leaves, and `null` leaves are skipped so optional +/// fields keep their semantics. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when a present leaf is not a +/// string, or when the secret store cannot supply a referenced key. Error +/// messages carry the dotted path and key name, never a secret value. +pub fn resolve_secret_refs( + data: &mut Value, + secret_store: &dyn PlatformSecretStore, +) -> Result<(), Report> { + let store_name = StoreName::from( + data.pointer("/secrets/store") + .and_then(Value::as_str) + .unwrap_or("ts_secrets"), + ); + for path in SECRET_REF_PATHS { + resolve_path(data, path, secret_store, &store_name)?; + } + Ok(()) +} + +fn resolve_path( + node: &mut Value, + path: &str, + secret_store: &dyn PlatformSecretStore, + store_name: &StoreName, +) -> Result<(), Report> { + match path.split_once('.') { + None => resolve_leaf(node, path, secret_store, store_name), + Some((head, rest)) => { + if let Some(stripped) = head.strip_suffix("[]") { + let Some(items) = node.get_mut(stripped).and_then(Value::as_array_mut) else { + return Ok(()); + }; + for item in items { + resolve_path(item, rest, secret_store, store_name)?; + } + Ok(()) + } else { + match node.get_mut(head) { + Some(child) => resolve_path(child, rest, secret_store, store_name), + None => Ok(()), + } + } + } + } +} + +fn resolve_leaf( + node: &mut Value, + field: &str, + secret_store: &dyn PlatformSecretStore, + store_name: &StoreName, +) -> Result<(), Report> { + let Some(leaf) = node.get(field) else { + return Ok(()); + }; + if leaf.is_null() { + return Ok(()); + } + let Some(key_name) = leaf.as_str() else { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("secret ref `{field}` must be a string secret key name"), + })); + }; + let resolved = secret_store + .get_string(store_name, key_name) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "failed to resolve secret ref `{field}` (key `{key_name}`) from secret store `{store_name}`" + ), + }) + .attach(error.to_string()) + })?; + node[field] = Value::String(resolved); + Ok(()) +} +``` + +Note: top-level `resolve_path` splits on the FULL dotted path — the `[]` +segment handling above covers `ec.partners[].api_token` (`head` iteration +happens when the `[]` segment is the current head). Verify with test 2; if +`split_once` ordering fights you, restructure to segment-vector iteration — +tests are the contract, not this sketch. + +- [x] **Step 4: Run, verify pass** + +Run: `cargo test-fastly -p trusted-server-core secret_refs 2>&1 | tail -5` +Expected: PASS (all resolver tests). + +- [ ] **Step 5: Checkpoint — request commit approval** + +`Add secret-ref resolver for app-config blob secrets` + +--- + +### Task 3: Validation-mode split (push = key names, runtime = resolved values) + +**Files:** +- Modify: `crates/trusted-server-core/src/settings.rs` (`finalize_deserialized`, `from_toml`, `from_json_value`, new mode enum + error-strip helper) +- Modify: `crates/trusted-server-core/src/config.rs` (`Deserialize` impl passes mode) +- Test: `settings.rs` tests + +- [x] **Step 1: Write failing tests** + +```rust +#[test] +fn key_names_mode_accepts_short_passphrase_ref() { + let mut toml = crate_test_settings_str(); + // Replace the fixture passphrase with a short key name; enable store mode. + // (Adjust the replace target to the fixture's actual passphrase value.) + let toml = toml.replace( + "test-secret-key-32-bytes-minimum!!", + "ec_passphrase", + ); + let toml = format!("{toml}\n[secrets]\nenabled = true\n"); + let settings = Settings::from_toml(&toml) + .expect("should accept key-name passphrase in store mode"); + assert_eq!(settings.ec.passphrase.expose(), "ec_passphrase"); +} + +#[test] +fn resolved_values_mode_still_rejects_short_passphrase() { + let toml = crate_test_settings_str() + .replace("test-secret-key-32-bytes-minimum!!", "short"); + let err = Settings::from_toml(&toml).expect_err("should reject short passphrase inline"); + assert!(err.to_string().contains("validation failed"), "should fail validation: {err}"); +} + +#[test] +fn key_names_mode_skips_admin_placeholder_password_check() { + // handlers[].password holds a key name like "admin_password" in store + // mode; the admin placeholder check must not fire at parse time. + // Build a store-mode TOML whose admin handler password is a key name and + // assert from_toml succeeds. +} +``` + +(Look up the fixture passphrase in `test_support.rs::crate_test_settings_str` first and use its real value in `replace`.) + +- [x] **Step 2: Run, verify fail** + +Run: `cargo test-fastly -p trusted-server-core key_names_mode 2>&1 | tail -10` +Expected: `key_names_mode_accepts_short_passphrase_ref` FAILS (min-32 validator fires). + +- [x] **Step 3: Implement** + +Mode enum (in `settings.rs`, near `Settings`): + +```rust +/// How secret-bearing fields should be validated during settings +/// finalization. +/// +/// Mirrors the EdgeZero spec 3.3.8 split: push-side tooling sees secret-store +/// key names and must skip value-shape validators; the runtime sees resolved +/// secret values and must run everything. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum SecretFieldMode { + /// Fields hold resolved secret values — run every validator. + ResolvedValues, + /// Fields hold secret-store key names — skip value-shape validators. + KeyNames, +} +``` + +Change `finalize_deserialized` signature to `pub(crate) fn finalize_deserialized(settings: Self, validation_label: &str, mode: SecretFieldMode)`. Inside: +- after `settings.validate()` fails, when `mode == SecretFieldMode::KeyNames`, strip only the `ec.passphrase` errors (the sole value-shape serde validator that a key name can trip) via a helper, and succeed if nothing else remains: + +```rust +/// Removes `ec.passphrase` value-shape errors from `errors`, returning +/// `None` when nothing else remains. Key-name mode defers passphrase shape +/// checks to runtime, where the resolved value is validated. +fn strip_key_name_validation_errors(mut errors: ValidationErrors) -> Option { + if let Some(ValidationErrorsKind::Struct(inner)) = errors.errors_mut().get_mut("ec") { + inner.errors_mut().remove("passphrase"); + let inner_empty = inner.errors().is_empty(); + if inner_empty { + errors.errors_mut().remove("ec"); + } + } + if errors.errors().is_empty() { + None + } else { + Some(errors) + } +} +``` + +(Check `validator` 0.x API names — `errors_mut()`/`errors()`/`ValidationErrorsKind` — against the version in `Cargo.lock`; adjust accessors accordingly.) + +- gate `validate_admin_handler_passwords` on mode: skip when `KeyNames` (runtime re-runs it on resolved values). +- `from_toml` and the `TrustedServerAppConfig` `Deserialize` impl derive the mode from the parsed value: `if settings.secrets.enabled { KeyNames } else { ResolvedValues }`. +- `from_json_value` always passes `ResolvedValues` — document the invariant: callers resolve secret refs first (`config_payload`). +- `from_toml_and_env` (test-only): `ResolvedValues`. + +- [x] **Step 4: Run, verify pass** + +Run: `cargo test-fastly -p trusted-server-core 2>&1 | tail -10` +Expected: PASS, including un-ignored Task 1 test and all pre-existing settings tests. + +- [ ] **Step 5: Checkpoint — request commit approval** + +`Split secret-field validation between key-name and resolved-value modes` + +--- + +### Task 4: Deploy-validation gating + key-name shape checks + +**Files:** +- Modify: `crates/trusted-server-core/src/config.rs` (`validate_settings_for_deploy`) +- Modify: `crates/trusted-server-core/src/settings.rs` (new `validate_secret_key_names`) +- Modify: `crates/trusted-server-core/src/ec/registry.rs` (token-mode-aware validation) +- Test: `config.rs` tests + +- [x] **Step 1: Write failing tests** (in `config.rs` tests) + +```rust +#[test] +fn deploy_validation_accepts_key_name_secrets_in_store_mode() { + // valid_settings() fixture with secrets.enabled = true and every secret + // field replaced by a key name (e.g. "ec_passphrase", "proxy_secret", + // partner tokens, handler password). validate_settings_for_deploy must + // pass: placeholder/value checks are deferred to runtime. +} + +#[test] +fn deploy_validation_rejects_whitespace_key_names_in_store_mode() { + // secrets.enabled = true, ec.passphrase = "has space" → error mentioning + // `ec.passphrase` and key-name shape. +} + +#[test] +fn deploy_validation_still_rejects_placeholders_inline() { + // existing behavior: secrets.enabled = false + placeholder proxy_secret + // → InsecureDefault (this test already exists as + // deploy_validation_rejects_placeholders — keep it green). +} +``` + +- [x] **Step 2: Run, verify fail** + +Run: `cargo test-fastly -p trusted-server-core deploy_validation 2>&1 | tail -10` + +- [x] **Step 3: Implement** + +In `validate_settings_for_deploy` (`config.rs:126`), branch on `settings.secrets.enabled`: +- store mode: replace `reject_placeholder_secrets()` with `settings.validate_secret_key_names()`; build the partner registry with token value checks relaxed (see below); keep integration + auction-provider validation unchanged. +- inline mode: unchanged behavior. + +`Settings::validate_secret_key_names` (settings.rs): iterate the same five field sets as `reject_placeholder_secrets` (`publisher.proxy_secret`, `ec.passphrase`, `ec.partners[].api_token`, `ec.partners[].ts_pull_token` when present, `handlers[].password`) and reject empty values or values containing whitespace/control characters, collecting offending dotted paths into one `TrustedServerError::Configuration` error. Never include the value in the message beyond the field path. + +Registry: add `PartnerRegistry::from_config_with_secret_mode(partners, mode)` where `KeyNames` keeps the non-empty check in `validate_api_token` but skips `MIN_API_TOKEN_LENGTH`; `from_config` delegates with `ResolvedValues`. Runtime registry construction keeps calling `from_config` (resolved values → full checks). + +- [x] **Step 4: Run, verify pass** + +Run: `cargo test-fastly -p trusted-server-core 2>&1 | tail -5` + +- [ ] **Step 5: Checkpoint — request commit approval** + +`Gate deploy validation on secret-ref mode with key-name shape checks` + +--- + +### Task 5: Runtime blob resolution in `config_payload` + +**Files:** +- Modify: `crates/trusted-server-core/src/config_payload.rs` +- Test: same file + +- [x] **Step 1: Write failing tests** + +```rust +#[test] +fn store_mode_blob_resolves_secrets_before_parse() { + // Build settings JSON with secrets.enabled = true and key-name leaves; + // envelope it; call settings_from_config_blob_with_secrets with a + // HashMapSecretStore holding 32+-byte values. Assert the returned + // Settings expose the RESOLVED values and full validation ran. +} + +#[test] +fn store_mode_blob_without_secret_store_fails_closed() { + // secrets.enabled = true, secret_store = None → Configuration error + // mentioning "[secrets]" and the adapter capability. +} + +#[test] +fn store_mode_blob_with_short_resolved_passphrase_fails_validation() { + // Store returns an 8-byte passphrase → error (min-32 runs on resolved). +} + +#[test] +fn inline_blob_ignores_secret_store() { + // secrets absent → resolves nothing even when a store is provided; + // existing round-trip behavior intact. +} +``` + +- [x] **Step 2: Run, verify fail** + +Run: `cargo test-fastly -p trusted-server-core config_payload 2>&1 | tail -10` + +- [x] **Step 3: Implement** + +```rust +/// Reconstruct validated [`Settings`] from a config blob envelope, resolving +/// secret-store references when the blob enables `[secrets]` mode. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the envelope is +/// invalid, when `[secrets]` mode is enabled but `secret_store` is `None` +/// (adapter does not support secret resolution yet), when a referenced +/// secret cannot be resolved, or when the resolved settings fail validation. +pub fn settings_from_config_blob_with_secrets( + envelope_json: &str, + secret_store: Option<&dyn PlatformSecretStore>, +) -> Result> { + let envelope: BlobEnvelope = /* existing parse */; + envelope.verify()/* existing */; + + let mut data = envelope.into_data(); + let store_mode = data + .pointer("/secrets/enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if store_mode { + let secret_store = secret_store.ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "[secrets] mode is enabled but this adapter has no secret store wired; \ + disable [secrets] or upgrade the adapter" + .to_string(), + }) + })?; + resolve_secret_refs(&mut data, secret_store)?; + } + + let settings = Settings::from_json_value(data)?; + settings.reject_placeholder_secrets()?; + Ok(settings) +} + +/// Back-compat wrapper: inline mode only. +pub fn settings_from_config_blob( + envelope_json: &str, +) -> Result> { + settings_from_config_blob_with_secrets(envelope_json, None) +} +``` + +- [x] **Step 4: Run, verify pass** + +Run: `cargo test-fastly -p trusted-server-core config_payload 2>&1 | tail -5` + +- [ ] **Step 5: Checkpoint — request commit approval** + +`Resolve secret-store references when loading store-mode config blobs` + +--- + +### Task 6: Loader plumbing (`settings_data`) + adapter call sites + +**Files:** +- Modify: `crates/trusted-server-core/src/settings_data.rs` (`get_settings_from_config_store` signature) +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs:164-168` (pass Fastly secret store) +- Modify: `crates/trusted-server-adapter-axum/src/app.rs:59` (pass `None`) +- Test: `settings_data.rs` tests + +- [x] **Step 1: Write failing test** + +```rust +#[test] +fn loads_store_mode_settings_resolving_secrets() { + // MemoryConfigStore with a store-mode envelope + HashMapSecretStore; + // call get_settings_from_config_store(&config_store, Some(&secret_store), + // &store_name, key) and assert resolved values. +} +``` + +- [x] **Step 2: Run, verify fail** + +Run: `cargo test-fastly -p trusted-server-core settings_data 2>&1 | tail -10` +Expected: FAIL — wrong arity. + +- [x] **Step 3: Implement** + +New signature: + +```rust +pub fn get_settings_from_config_store( + config_store: &dyn PlatformConfigStore, + secret_store: Option<&dyn PlatformSecretStore>, + store_name: &StoreName, + key: &str, +) -> Result> +``` + +body delegates to `settings_from_config_blob_with_secrets(&envelope_json, secret_store)`. Update ALL call sites: +- `adapter-fastly/src/app.rs:167`: `get_settings_from_config_store(&FastlyPlatformConfigStore, Some(&FastlyPlatformSecretStore), &store_name, &config_key)` +- `adapter-axum/src/app.rs:59`: pass `None` (Axum wiring is a follow-up). +- every `settings_data.rs` test call: add `None` (or the new store for the new test). + +- [x] **Step 4: Run, verify pass** + +Run: `cargo test-fastly 2>&1 | tail -5` and `cargo test-axum 2>&1 | tail -5` +Expected: PASS both. + +- [ ] **Step 5: Checkpoint — request commit approval** + +`Wire secret store into config-store settings loading (Fastly first)` + +--- + +### Task 7: Local dev (Viceroy) + operator template + +**Files:** +- Modify: `fastly.toml` (`[local_server.secret_stores.ts_secrets]` entries) +- Modify: `trusted-server.example.toml` (commented store-mode example) + +- [x] **Step 1: Add Viceroy secrets** + +Append to the existing `ts_secrets` local store in `fastly.toml`: + +```toml + [[local_server.secret_stores.ts_secrets]] + key = "ec_passphrase" + data = "local-dev-ec-passphrase-32-bytes!!" + + [[local_server.secret_stores.ts_secrets]] + key = "proxy_secret" + data = "local-dev-proxy-secret" + + [[local_server.secret_stores.ts_secrets]] + key = "admin_password" + data = "local-dev-admin-password-32-bytes!" +``` + +- [x] **Step 2: Document store mode in the example TOML** + +Under the existing secret fields add commented guidance: + +```toml +# Store mode: set [secrets] enabled = true and replace each secret value +# below with the NAME of a secret in the platform secret store. Seed the +# store with your EXISTING values first (changing ec.passphrase rotates all +# visitor IDs; changing publisher.proxy_secret invalidates proxy URL tokens): +# fastly secret-store-entry create --store-id --name ec_passphrase --secret +# Rollout order: deploy new WASM -> seed secrets -> push store-mode config. +# +# [secrets] +# enabled = true +# store = "ts_secrets" +``` + +- [x] **Step 3: Smoke-check Viceroy config parses** + +Run: `fastly compute serve --verbose 2>&1 | head -30` (Ctrl-C after boot) or at minimum `cargo check-fastly`. +Expected: no manifest parse errors. + +- [ ] **Step 4: Checkpoint — request commit approval** + +`Add local secret-store entries and store-mode config template` + +--- + +### Task 8: Full verification + +- [x] **Step 1: Format** — `cargo fmt --all -- --check` → clean. +- [x] **Step 2: Clippy (CI toolchain)** — `cargo +1.95.0 clippy-fastly && cargo +1.95.0 clippy-axum && cargo +1.95.0 clippy-cloudflare && cargo +1.95.0 clippy-cloudflare-wasm && cargo +1.95.0 clippy-spin-native && cargo +1.95.0 clippy-spin-wasm` → no NEW warnings (pre-existing debt exists; compare against `main` if unsure). +- [x] **Step 3: Tests** — `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin` → all pass. +- [x] **Step 4: Parity** — `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` → pass (inline-mode fixtures unaffected). +- [ ] **Step 5: Checkpoint — request commit approval for any fixups, then hand off for PR.** + +--- + +## Follow-ups (tracked, not in this branch) + +- Cloudflare / Spin / Axum secret-store wiring (pass their `PlatformSecretStore` impls instead of `None`; Spin blocked on spin-sdk/Spin 4.x toolchain incompat). +- `ts secrets set` CLI verb (Fastly management API create exists in `adapter-fastly/src/management_api.rs`; CLI can shell out to `fastly secret-store-entry` for v1). +- `edgezero.toml [stores.secrets]` id alignment (`trusted_server_secrets` vs actual `ts_secrets`). +- Push-time warning when plaintext secrets detected in inline mode (nudge migration). +- EdgeZero Phase 3 (#843): replace `secret_refs.rs` + mode gating with nested `#[secret]` derive once stackpop/edgezero#304 ships; delete this module. + +## Status log (keep current) + +- 2026-07-09: Plan created on branch `846-secrets-to-secret-store`. +- 2026-07-09: Task 1 done (3 tests green). Commit pending user approval. +- 2026-07-09: Task 2 done — `secret_refs.rs` resolver, 7 tests green. Commit pending approval. +- 2026-07-09: Task 3 done — `SecretFieldMode` split, 5 new tests; settings (130) + config (18) modules green. Commit pending approval. +- 2026-07-09: Task 4 done — deploy validation gated by mode, `validate_secret_key_names`, registry `from_config_with_secret_mode`; 8 deploy tests green. Commit pending approval. +- 2026-07-09: Task 5 done — `settings_from_config_blob_with_secrets`, fail-closed without store; 7 config_payload tests green. Commit pending approval. +- 2026-07-09: Task 6 done — loader takes Option<&dyn PlatformSecretStore>; Fastly passes FastlyPlatformSecretStore, Axum None; settings_data 5 tests + axum suite green, check-fastly clean. Commit pending approval. +- 2026-07-09: Task 7 done — fastly.toml local ts_secrets entries (ec_passphrase/proxy_secret/admin_password), example.toml store-mode guidance; Viceroy manifest parses. Commit pending approval. +- 2026-07-09: Task 8 done — fmt clean; all 6 clippy targets clean on 1.95.0; test-fastly 1648+99+21+2, test-axum 32, test-cloudflare 32, test-spin 72, parity 13, CLI 108 all green. Awaiting commit approvals + PR handoff. diff --git a/fastly.toml b/fastly.toml index 56002bc5a..ac6f1b331 100644 --- a/fastly.toml +++ b/fastly.toml @@ -61,6 +61,19 @@ build = """ key = "tinybird_access_append_token" data = "test-tinybird-access-append-token" + # Local values for [secrets] store-mode app configs (issue #846). + [[local_server.secret_stores.ts_secrets]] + key = "ec_passphrase" + data = "local-dev-ec-passphrase-32-bytes!!" + + [[local_server.secret_stores.ts_secrets]] + key = "proxy_secret" + data = "local-dev-proxy-secret" + + [[local_server.secret_stores.ts_secrets]] + key = "admin_password" + data = "local-dev-admin-password-32-bytes!" + [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 0951b781e..37aa075c5 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -1,3 +1,14 @@ +# Store mode (recommended for production): enable the [secrets] section at +# the bottom of this file and replace each secret VALUE below with the NAME +# of a secret in the platform secret store. Covered fields: +# publisher.proxy_secret, ec.passphrase, ec.partners[].api_token, +# ec.partners[].ts_pull_token, handlers[].password. Seed the store with your +# EXISTING values first — changing ec.passphrase rotates every visitor ID, +# and changing publisher.proxy_secret invalidates outstanding proxy URL +# tokens: +# fastly secret-store-entry create --store-id --name ec_passphrase --secret +# Rollout order: deploy new WASM -> seed secrets -> push store-mode config. + [[handlers]] path = "^/_ts/admin" username = "admin" @@ -137,6 +148,12 @@ example_segments = "segments" [debug] ja4_endpoint_enabled = false +# Secret-store reference mode. When enabled, the secret fields listed at the +# top of this file hold secret key NAMES resolved from `store` at startup. +# [secrets] +# enabled = true +# store = "ts_secrets" + [creative_opportunities] gam_network_id = "123456789" # FCP is not affected by this value — body content above has already From 5c1c4809513ddf17c9b504414a3fd99daf1a7cbc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 9 Jul 2026 12:58:22 +0530 Subject: [PATCH 2/4] Fix format docs fail --- .../plans/2026-07-09-secrets-to-secret-store.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md b/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md index 355f06fd8..fc408ae37 100644 --- a/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md +++ b/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md @@ -10,7 +10,7 @@ **Branch:** `846-secrets-to-secret-store` -**Commit policy:** every commit requires explicit user approval before running `git commit` (standing user rule). Commit steps below are checkpoints to *request* approval, not to commit unprompted. +**Commit policy:** every commit requires explicit user approval before running `git commit` (standing user rule). Commit steps below are checkpoints to _request_ approval, not to commit unprompted. --- @@ -41,6 +41,7 @@ Out of scope (follow-ups, do NOT implement here): Cloudflare/Spin/Axum wiring be ### Task 1: `[secrets]` settings section **Files:** + - Modify: `crates/trusted-server-core/src/settings.rs` (new struct + `Settings` field) - Test: same file, `#[cfg(test)] mod tests` @@ -138,12 +139,14 @@ Expected: PASS (the `enabled = true` test may need Task 3 — if it fails only o ### Task 2: Secret-ref resolver module **Files:** + - Create: `crates/trusted-server-core/src/secret_refs.rs` - Modify: `crates/trusted-server-core/src/lib.rs` (add `pub mod secret_refs;`) - [x] **Step 1: Write failing tests** (in `secret_refs.rs` `#[cfg(test)]`) Cover, using `crate::platform::test_support::HashMapSecretStore` and `serde_json::json!`: + 1. resolves nested scalar (`publisher.proxy_secret`), 2. resolves array element (`ec.partners[].api_token` for two partners), 3. skips absent optional leaf (`ts_pull_token` missing) and absent sections (no `handlers`), @@ -327,6 +330,7 @@ Expected: PASS (all resolver tests). ### Task 3: Validation-mode split (push = key names, runtime = resolved values) **Files:** + - Modify: `crates/trusted-server-core/src/settings.rs` (`finalize_deserialized`, `from_toml`, `from_json_value`, new mode enum + error-strip helper) - Modify: `crates/trusted-server-core/src/config.rs` (`Deserialize` impl passes mode) - Test: `settings.rs` tests @@ -394,6 +398,7 @@ pub enum SecretFieldMode { ``` Change `finalize_deserialized` signature to `pub(crate) fn finalize_deserialized(settings: Self, validation_label: &str, mode: SecretFieldMode)`. Inside: + - after `settings.validate()` fails, when `mode == SecretFieldMode::KeyNames`, strip only the `ec.passphrase` errors (the sole value-shape serde validator that a key name can trip) via a helper, and succeed if nothing else remains: ```rust @@ -437,6 +442,7 @@ Expected: PASS, including un-ignored Task 1 test and all pre-existing settings t ### Task 4: Deploy-validation gating + key-name shape checks **Files:** + - Modify: `crates/trusted-server-core/src/config.rs` (`validate_settings_for_deploy`) - Modify: `crates/trusted-server-core/src/settings.rs` (new `validate_secret_key_names`) - Modify: `crates/trusted-server-core/src/ec/registry.rs` (token-mode-aware validation) @@ -474,6 +480,7 @@ Run: `cargo test-fastly -p trusted-server-core deploy_validation 2>&1 | tail -10 - [x] **Step 3: Implement** In `validate_settings_for_deploy` (`config.rs:126`), branch on `settings.secrets.enabled`: + - store mode: replace `reject_placeholder_secrets()` with `settings.validate_secret_key_names()`; build the partner registry with token value checks relaxed (see below); keep integration + auction-provider validation unchanged. - inline mode: unchanged behavior. @@ -494,6 +501,7 @@ Run: `cargo test-fastly -p trusted-server-core 2>&1 | tail -5` ### Task 5: Runtime blob resolution in `config_payload` **Files:** + - Modify: `crates/trusted-server-core/src/config_payload.rs` - Test: same file @@ -591,6 +599,7 @@ Run: `cargo test-fastly -p trusted-server-core config_payload 2>&1 | tail -5` ### Task 6: Loader plumbing (`settings_data`) + adapter call sites **Files:** + - Modify: `crates/trusted-server-core/src/settings_data.rs` (`get_settings_from_config_store` signature) - Modify: `crates/trusted-server-adapter-fastly/src/app.rs:164-168` (pass Fastly secret store) - Modify: `crates/trusted-server-adapter-axum/src/app.rs:59` (pass `None`) @@ -626,6 +635,7 @@ pub fn get_settings_from_config_store( ``` body delegates to `settings_from_config_blob_with_secrets(&envelope_json, secret_store)`. Update ALL call sites: + - `adapter-fastly/src/app.rs:167`: `get_settings_from_config_store(&FastlyPlatformConfigStore, Some(&FastlyPlatformSecretStore), &store_name, &config_key)` - `adapter-axum/src/app.rs:59`: pass `None` (Axum wiring is a follow-up). - every `settings_data.rs` test call: add `None` (or the new store for the new test). @@ -644,6 +654,7 @@ Expected: PASS both. ### Task 7: Local dev (Viceroy) + operator template **Files:** + - Modify: `fastly.toml` (`[local_server.secret_stores.ts_secrets]` entries) - Modify: `trusted-server.example.toml` (commented store-mode example) From 419b6453b5b139fe20c2a13ee0486dbae3890720 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 9 Jul 2026 14:05:52 +0530 Subject: [PATCH 3/4] Harden store-mode secret handling and fix operator docs Reject TRUSTED_SERVER__ secret env overrides at push/diff/validate time when store mode is enabled, so the push env overlay cannot overwrite a secret-ref key name with a plaintext value in the config blob (independent of --no-env). Omit the [secrets] section from the serialized blob whenever store mode is disabled, not only at the exact default, so inline-mode blobs stay compatible with binaries that predate the field under deny_unknown_fields. Fail closed in the resolver when a covered array section (ec.partners, handlers) is present in a non-array encoding instead of silently skipping it. Keep the partner API-token non-empty check in key-name mode and defer only the minimum-length rule. Correct the docs: the Fastly CLI reads secret values from stdin or a file (there is no --secret flag), and list every covered override including partner ts_pull_token. --- crates/trusted-server-core/src/config.rs | 112 +++++++++++++++++- crates/trusted-server-core/src/ec/registry.rs | 63 +++++++++- crates/trusted-server-core/src/secret_refs.rs | 80 +++++++++++-- crates/trusted-server-core/src/settings.rs | 66 ++++++++++- docs/guide/configuration.md | 53 +++++++++ .../2026-07-09-secrets-to-secret-store.md | 5 +- trusted-server.example.toml | 11 +- 7 files changed, 371 insertions(+), 19 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 51ea8bce4..301e3ff5d 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -131,7 +131,10 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report settings.validate_secret_key_names()?, + SecretFieldMode::KeyNames => { + settings.validate_secret_key_names()?; + reject_conflicting_secret_env_overrides(std::env::vars())?; + } SecretFieldMode::ResolvedValues => settings.reject_placeholder_secrets()?, } let enabled_auction_providers = validate_enabled_integrations(settings)?; @@ -140,6 +143,63 @@ pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report__…__` +/// onto config fields. For the fields covered by store mode, such an override +/// would replace the secret-ref key NAME with the override's value. +fn is_covered_secret_override(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + let Some(rest) = upper.strip_prefix("TRUSTED_SERVER__") else { + return false; + }; + rest == "PUBLISHER__PROXY_SECRET" + || rest == "EC__PASSPHRASE" + || (rest.starts_with("EC__PARTNERS__") + && (rest.ends_with("__API_TOKEN") || rest.ends_with("__TS_PULL_TOKEN"))) + || (rest.starts_with("HANDLERS__") && rest.ends_with("__PASSWORD")) +} + +/// Rejects `TRUSTED_SERVER__…` env overrides for secret fields while store +/// mode is enabled. +/// +/// `ts config push` applies the env overlay by default, which would overwrite +/// a secret-ref key NAME with the override's value and persist it as +/// **plaintext** in the pushed config blob (then fail at runtime because the +/// value is not a valid secret key). In store mode those overrides must not be +/// set — seed the secret store instead. This rejects the dangerous combination +/// at push / diff / validate time, regardless of `--no-env`, so a later push +/// that forgets the flag cannot leak. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] naming each offending +/// variable when one or more covered overrides are set to a non-empty value. +fn reject_conflicting_secret_env_overrides(vars: I) -> Result<(), Report> +where + I: IntoIterator, +{ + let mut offenders: Vec = vars + .into_iter() + .filter(|(name, value)| !value.is_empty() && is_covered_secret_override(name)) + .map(|(name, _)| name) + .collect(); + if offenders.is_empty() { + return Ok(()); + } + offenders.sort_unstable(); + offenders.dedup(); + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[secrets] store mode is enabled, but these environment overrides would overwrite \ + secret key names with plaintext values at push time: {}. Unset them (seed the \ + secret store with the real values instead) or disable store mode.", + offenders.join(", ") + ), + })) +} + fn validate_enabled_integrations( settings: &Settings, ) -> Result, Report> { @@ -324,6 +384,56 @@ mod tests { ); } + #[test] + fn rejects_covered_secret_env_overrides_in_store_mode() { + let vars = vec![ + ("PATH".to_owned(), "/usr/bin".to_owned()), + ( + "TRUSTED_SERVER__EC__PASSPHRASE".to_owned(), + "real-secret".to_owned(), + ), + ( + "TRUSTED_SERVER__EC__PARTNERS__0__TS_PULL_TOKEN".to_owned(), + "real-token".to_owned(), + ), + ( + "TRUSTED_SERVER__HANDLERS__1__PASSWORD".to_owned(), + "real-pass".to_owned(), + ), + ]; + + let err = reject_conflicting_secret_env_overrides(vars) + .expect_err("covered secret overrides must be rejected in store mode"); + let message = err.to_string(); + + assert!( + message.contains("TRUSTED_SERVER__EC__PASSPHRASE") + && message.contains("TS_PULL_TOKEN") + && message.contains("HANDLERS__1__PASSWORD"), + "error should name every offending override: {message}" + ); + } + + #[test] + fn allows_non_secret_and_empty_env_overrides() { + let vars = vec![ + // Non-secret override — legitimate, must not be rejected. + ( + "TRUSTED_SERVER__PUBLISHER__DOMAIN".to_owned(), + "example.com".to_owned(), + ), + // Covered field but empty value — does not overlay, so allowed. + ( + "TRUSTED_SERVER__PUBLISHER__PROXY_SECRET".to_owned(), + String::new(), + ), + ("HOME".to_owned(), "/home/op".to_owned()), + ]; + + reject_conflicting_secret_env_overrides(vars) + .expect("non-secret and empty overrides must be allowed"); + } + #[test] fn deploy_validation_rejects_placeholders() { let settings = Settings::from_toml( diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index c678463c6..61ec10c5e 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -76,8 +76,9 @@ impl PartnerRegistry { /// matched to `mode`. /// /// In [`SecretFieldMode::KeyNames`] the tokens are secret-store key - /// names, so the minimum token length check is deferred to runtime where - /// the resolved values are available. + /// names: the non-empty check still runs (an empty key name can never + /// resolve), but the minimum token length check is deferred to runtime + /// where the resolved value is available. /// /// # Errors /// @@ -103,8 +104,15 @@ impl PartnerRegistry { })); } - if mode == SecretFieldMode::ResolvedValues { - validate_api_token(&normalized_source, partner.api_token.expose())?; + match mode { + SecretFieldMode::ResolvedValues => { + validate_api_token(&normalized_source, partner.api_token.expose())?; + } + // Key names must still be non-empty; the length rule is a + // property of the resolved secret value, checked at runtime. + SecretFieldMode::KeyNames => { + validate_api_token_non_empty(&normalized_source, partner.api_token.expose())?; + } } let api_key_hash = hash_api_key(partner.api_token.expose()); @@ -205,7 +213,7 @@ impl PartnerRegistry { } } -fn validate_api_token( +fn validate_api_token_non_empty( source_domain: &str, api_token: &str, ) -> Result<(), Report> { @@ -216,6 +224,14 @@ fn validate_api_token( ), })); } + Ok(()) +} + +fn validate_api_token( + source_domain: &str, + api_token: &str, +) -> Result<(), Report> { + validate_api_token_non_empty(source_domain, api_token)?; if api_token.len() < MIN_API_TOKEN_LENGTH { return Err(Report::new(TrustedServerError::Configuration { @@ -354,6 +370,43 @@ mod tests { assert!(registry.is_empty(), "should have no partners"); } + #[test] + fn key_name_mode_accepts_short_non_empty_token() { + // A secret-store key name is typically shorter than MIN_API_TOKEN_LENGTH; + // key-name mode must not apply the length rule. + let partners = vec![make_partner("ssp.example.com", "partner_a_token")]; + PartnerRegistry::from_config_with_secret_mode(&partners, SecretFieldMode::KeyNames) + .expect("short non-empty key name should be accepted in key-name mode"); + } + + #[test] + fn key_name_mode_rejects_empty_token() { + // Non-empty is still enforced in key-name mode — an empty key name can + // never resolve to a secret. + let partners = vec![make_partner("ssp.example.com", "")]; + let err = + PartnerRegistry::from_config_with_secret_mode(&partners, SecretFieldMode::KeyNames) + .expect_err("empty key name should be rejected in key-name mode"); + assert!( + err.to_string().contains("must not be empty"), + "error should name the empty-token rule: {err}" + ); + } + + #[test] + fn resolved_values_mode_still_rejects_short_token() { + let partners = vec![make_partner("ssp.example.com", "too-short")]; + let err = PartnerRegistry::from_config_with_secret_mode( + &partners, + SecretFieldMode::ResolvedValues, + ) + .expect_err("short resolved token should be rejected"); + assert!( + err.to_string().contains("at least"), + "error should name the length rule: {err}" + ); + } + #[test] fn lookup_by_source_domain_returns_configured_partner() { let partners = vec![make_partner("ssp.example.com", &valid_api_token("token-a"))]; diff --git a/crates/trusted-server-core/src/secret_refs.rs b/crates/trusted-server-core/src/secret_refs.rs index 31bc2020e..d9769112a 100644 --- a/crates/trusted-server-core/src/secret_refs.rs +++ b/crates/trusted-server-core/src/secret_refs.rs @@ -41,8 +41,10 @@ const SECRET_REF_PATHS: &[&str] = &[ /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when a present leaf is not a -/// string, or when the secret store cannot supply a referenced key. Error -/// messages carry the dotted path and key name, never a secret value. +/// string, when a covered array section (e.g. `ec.partners`, `handlers`) is +/// present in a non-array encoding that cannot be resolved, or when the secret +/// store cannot supply a referenced key. Error messages carry the dotted path +/// and key name, never a secret value. pub fn resolve_secret_refs( data: &mut Value, secret_store: &dyn PlatformSecretStore, @@ -69,13 +71,26 @@ fn resolve_path( None => resolve_leaf(node, remaining, full_path, secret_store, store_name), Some((head, rest)) => { if let Some(field) = head.strip_suffix("[]") { - let Some(items) = node.get_mut(field).and_then(Value::as_array_mut) else { - return Ok(()); - }; - for item in items { - resolve_path(item, rest, full_path, secret_store, store_name)?; + match node.get_mut(field) { + // Absent section (or explicit null) — nothing to resolve. + None | Some(Value::Null) => Ok(()), + Some(Value::Array(items)) => { + for item in items { + resolve_path(item, rest, full_path, secret_store, store_name)?; + } + Ok(()) + } + // Present but not an array. `Settings` also accepts + // map/string encodings of these fields, which would + // deserialize the unresolved key names as real secret + // values — fail closed instead of silently skipping. + Some(_) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "secret ref `{full_path}` requires `{field}` to be an array; \ + found a non-array encoding that cannot be resolved" + ), + })), } - Ok(()) } else { match node.get_mut(head) { Some(child) => resolve_path(child, rest, full_path, secret_store, store_name), @@ -216,6 +231,55 @@ mod tests { ); } + #[test] + fn present_non_array_section_fails_closed() { + // `Settings` also accepts a numeric-map encoding of `handlers`; if a + // blob carried that shape, the array walk must NOT silently skip it + // (that would leave key names as real passwords). Fail closed. + let mut data = json!({ + "handlers": { "0": { "password": "admin_password" } } + }); + + let err = resolve_secret_refs(&mut data, &test_store()) + .expect_err("non-array handlers must fail closed"); + + assert!( + err.to_string().contains("handlers"), + "error should name the offending section: {err}" + ); + } + + #[test] + fn present_string_encoded_array_fails_closed() { + let mut data = json!({ + "ec": { "partners": "[{\"api_token\":\"partner_a_token\"}]" } + }); + + let err = resolve_secret_refs(&mut data, &test_store()) + .expect_err("string-encoded partners array must fail closed"); + + assert!( + err.to_string().contains("partners"), + "error should name the offending section: {err}" + ); + } + + #[test] + fn null_section_is_skipped() { + let mut data = json!({ + "publisher": { "proxy_secret": "proxy_secret" }, + "ec": { "passphrase": "ec_passphrase", "partners": null } + }); + + resolve_secret_refs(&mut data, &test_store()) + .expect("null array section should be skipped, not rejected"); + + assert_eq!( + data["ec"]["passphrase"], + "resolved-passphrase-32-bytes-min!" + ); + } + #[test] fn skips_null_leaf() { let mut data = json!({ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 5d38bf44c..7db4e9212 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1951,6 +1951,22 @@ impl Default for SecretsSettings { } } +impl SecretsSettings { + /// Returns `true` when store mode is disabled. + /// + /// Used to omit the `[secrets]` section from the serialized app-config + /// blob whenever store mode is off, so that a binary predating this field + /// (which uses `#[serde(deny_unknown_fields)]`) still accepts a blob + /// pushed by a newer CLI. `store` is only meaningful when `enabled`, so a + /// disabled section carries nothing worth serializing (a custom `store` + /// with `enabled = false` is inert). Store mode (`enabled = true`) always + /// serializes and therefore requires the newer binary — as intended. + #[must_use] + pub fn is_disabled(&self) -> bool { + !self.enabled + } +} + fn default_secrets_store() -> String { "ts_secrets".to_owned() } @@ -2025,7 +2041,7 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, - #[serde(default)] + #[serde(default, skip_serializing_if = "SecretsSettings::is_disabled")] #[validate(nested)] pub secrets: SecretsSettings, } @@ -2873,6 +2889,54 @@ mod tests { ); } + #[test] + fn default_secrets_omitted_from_serialization() { + // Wire compatibility: a blob pushed by a newer CLI for an ordinary + // inline config must not carry a `secrets` field, or a binary + // predating this field (with `deny_unknown_fields`) rejects it. + let settings = + Settings::from_toml(&crate_test_settings_str()).expect("should parse inline settings"); + + let json = serde_json::to_value(&settings).expect("should serialize"); + + assert!( + json.get("secrets").is_none(), + "default (inline) secrets section must be omitted from the blob" + ); + } + + #[test] + fn store_mode_secrets_are_serialized() { + let toml = format!("{}\n[secrets]\nenabled = true\n", crate_test_settings_str()); + let settings = Settings::from_toml(&toml).expect("should parse store-mode settings"); + + let json = serde_json::to_value(&settings).expect("should serialize"); + + assert_eq!( + json.pointer("/secrets/enabled"), + Some(&serde_json::Value::Bool(true)), + "store-mode secrets section must be serialized" + ); + } + + #[test] + fn disabled_secrets_with_custom_store_are_omitted() { + // Wire compatibility: a disabled section is inert, so even a + // non-default `store` must be omitted or old binaries reject the blob. + let toml = format!( + "{}\n[secrets]\nenabled = false\nstore = \"custom_secrets\"\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml).expect("should parse disabled custom-store"); + + let json = serde_json::to_value(&settings).expect("should serialize"); + + assert!( + json.get("secrets").is_none(), + "disabled secrets section must be omitted even with a custom store" + ); + } + #[test] fn secrets_section_rejects_unknown_fields() { let toml = format!( diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b975590c6..a6ac4c7ed 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1344,6 +1344,59 @@ TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) ✅ Store in secure secret management (Fastly Secret Store, Vault) ✅ Use different secrets per environment +#### Secret store references (store mode) + +Instead of authoring secret values inline, secret-bearing fields can hold the +**name** of an entry in the platform secret store, resolved at startup. This +keeps plaintext secrets out of the pushed config blob. Covered fields: +`publisher.proxy_secret`, `ec.passphrase`, `ec.partners[].api_token`, +`ec.partners[].ts_pull_token`, and `handlers[].password`. + +Enable it and replace each value with a key name: + +```toml +[publisher] +proxy_secret = "proxy_secret" # name of the secret-store entry + +[ec] +passphrase = "ec_passphrase" + +[secrets] +enabled = true +store = "ts_secrets" # secret store to resolve against +``` + +Seed the store with your **existing** values first — regenerating +`ec.passphrase` rotates every visitor ID, and regenerating +`publisher.proxy_secret` invalidates outstanding proxy URL tokens. The Fastly +CLI reads the value from stdin or a file; there is **no** `--secret` flag: + +```bash +printf %s "$EC_PASSPHRASE" | \ + fastly secret-store-entry create --store-id --name ec_passphrase --stdin +``` + +**Do not combine store mode with secret env overrides.** The `ts config push` +env overlay applies `TRUSTED_SERVER__*` overrides at push time. A leftover +secret override such as `TRUSTED_SERVER__EC__PASSPHRASE` would overwrite the +key **name** with the real value and persist it as **plaintext** in the config +blob. Deploy validation **rejects this**: with store mode enabled, `ts config +push` / `diff` / `validate` fail if any covered secret override is set — + +- `TRUSTED_SERVER__PUBLISHER__PROXY_SECRET` +- `TRUSTED_SERVER__EC__PASSPHRASE` +- `TRUSTED_SERVER__EC__PARTNERS____API_TOKEN` +- `TRUSTED_SERVER__EC__PARTNERS____TS_PULL_TOKEN` +- `TRUSTED_SERVER__HANDLERS____PASSWORD` + +Unset those variables (seed the secret store with the real values instead). +Non-secret overrides are unaffected, so you do not need `--no-env`. + +Rollout order: deploy the new WASM first (an older binary rejects the +`[secrets]` blob), then seed the secret store, then push the store-mode config. +Resolution is fail-closed — a missing or invalid secret makes the service +return a startup error rather than serving with an unresolved value. + **Don't**: ❌ Commit secrets to version control ❌ Use default/placeholder values diff --git a/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md b/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md index fc408ae37..8aa914c34 100644 --- a/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md +++ b/docs/superpowers/plans/2026-07-09-secrets-to-secret-store.md @@ -685,7 +685,7 @@ Under the existing secret fields add commented guidance: # below with the NAME of a secret in the platform secret store. Seed the # store with your EXISTING values first (changing ec.passphrase rotates all # visitor IDs; changing publisher.proxy_secret invalidates proxy URL tokens): -# fastly secret-store-entry create --store-id --name ec_passphrase --secret +# printf %s "$EC_PASSPHRASE" | fastly secret-store-entry create --store-id --name ec_passphrase --stdin # Rollout order: deploy new WASM -> seed secrets -> push store-mode config. # # [secrets] @@ -732,4 +732,5 @@ Expected: no manifest parse errors. - 2026-07-09: Task 5 done — `settings_from_config_blob_with_secrets`, fail-closed without store; 7 config_payload tests green. Commit pending approval. - 2026-07-09: Task 6 done — loader takes Option<&dyn PlatformSecretStore>; Fastly passes FastlyPlatformSecretStore, Axum None; settings_data 5 tests + axum suite green, check-fastly clean. Commit pending approval. - 2026-07-09: Task 7 done — fastly.toml local ts_secrets entries (ec_passphrase/proxy_secret/admin_password), example.toml store-mode guidance; Viceroy manifest parses. Commit pending approval. -- 2026-07-09: Task 8 done — fmt clean; all 6 clippy targets clean on 1.95.0; test-fastly 1648+99+21+2, test-axum 32, test-cloudflare 32, test-spin 72, parity 13, CLI 108 all green. Awaiting commit approvals + PR handoff. +- 2026-07-09: Task 8 done — fmt clean; all 6 clippy targets clean on 1.95.0; full Rust + parity + CLI suites green. Committed and pushed; draft PR #873 opened. +- 2026-07-09: Applied two rounds of external review. Round 1: `[secrets]` omitted from serialization when disabled (old-binary wire compat); resolver fails closed on non-array covered sections; registry key-name mode keeps the non-empty check; docs corrected (`fastly … --stdin`, no `--secret` flag). Round 2: added a hard push/deploy guard rejecting `TRUSTED_SERVER__` secret env overrides in store mode; serialization now omits `[secrets]` whenever disabled (not just the exact default); resolver `# Errors` and this plan's `--stdin` command corrected. Regression tests added for each. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 37aa075c5..5e7e756f2 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -5,8 +5,15 @@ # ec.partners[].ts_pull_token, handlers[].password. Seed the store with your # EXISTING values first — changing ec.passphrase rotates every visitor ID, # and changing publisher.proxy_secret invalidates outstanding proxy URL -# tokens: -# fastly secret-store-entry create --store-id --name ec_passphrase --secret +# tokens. The Fastly CLI reads the value from stdin or a file (there is no +# --secret flag): +# printf %s "$EC_PASSPHRASE" | fastly secret-store-entry create --store-id --name ec_passphrase --stdin +# IMPORTANT: in store mode, do NOT set TRUSTED_SERVER__ secret overrides +# (EC__PASSPHRASE, PUBLISHER__PROXY_SECRET, EC__PARTNERS____API_TOKEN, +# EC__PARTNERS____TS_PULL_TOKEN, HANDLERS____PASSWORD). The push env +# overlay would overwrite the key NAME with the real value and persist it as +# plaintext. `ts config push` rejects this combination — unset those vars and +# seed the secret store with the real values instead. # Rollout order: deploy new WASM -> seed secrets -> push store-mode config. [[handlers]] From f7154e81f8c142a65f354c515f267d47f3c043e9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 9 Jul 2026 15:08:57 +0530 Subject: [PATCH 4/4] Validate resolved partner tokens at settings load Store mode defers ec.partners[].api_token value checks (length, token-hash uniqueness, pull-sync consistency) from deploy time, but the runtime load path only deserialized settings and rejected placeholders without building the partner registry. A mis-seeded partner secret could let the service start and serve unrelated routes, failing only on partner-specific routes instead of failing closed at startup. Build the partner registry in settings_from_config_blob_with_secrets so resolved-value validators run at load in both inline and store mode. --- .../trusted-server-core/src/config_payload.rs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index d6fc0ef39..811e3692d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -8,6 +8,7 @@ use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; +use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::platform::PlatformSecretStore; use crate::secret_refs::resolve_secret_refs; @@ -79,6 +80,15 @@ pub fn settings_from_config_blob_with_secrets( let settings = Settings::from_json_value(data)?; settings.reject_placeholder_secrets()?; + + // Store mode defers resolved-value checks on `ec.partners[].api_token` + // (length, token-hash uniqueness, pull-sync consistency) from deploy time to + // load time. Build the partner registry here so those checks run against the + // resolved secrets and mis-seeded partner tokens fail closed at startup + // rather than only on partner-specific routes. Inline mode already validated + // identical values at deploy, so this is a cheap, consistent re-check. + PartnerRegistry::from_config(&settings.ec.partners)?; + Ok(settings) } @@ -89,6 +99,7 @@ mod tests { use super::*; use crate::platform::test_support::HashMapSecretStore; use crate::redacted::Redacted; + use crate::settings::EcPartner; use crate::test_support::tests::crate_test_settings_str; fn test_settings() -> Settings { @@ -177,6 +188,83 @@ mod tests { ])) } + fn store_mode_envelope_json_with_partner() -> String { + let mut settings = test_settings(); + settings.secrets.enabled = true; + settings.publisher.proxy_secret = Redacted::new("proxy_secret".to_owned()); + settings.ec.passphrase = Redacted::new("ec_passphrase".to_owned()); + settings.handlers[0].password = Redacted::new("secure_password".to_owned()); + settings.handlers[1].password = Redacted::new("admin_password".to_owned()); + settings.ec.partners = vec![EcPartner { + name: "Partner A".to_owned(), + source_domain: "partner-a.example".to_owned(), + openrtb_atype: EcPartner::default_openrtb_atype(), + bidstream_enabled: false, + api_token: Redacted::new("partner_a_token".to_owned()), + batch_rate_limit: EcPartner::default_batch_rate_limit(), + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: Vec::new(), + pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: None, + }]; + envelope_json(&settings) + } + + fn store_mode_secret_store_with_partner_token(partner_token: &[u8]) -> HashMapSecretStore { + HashMapSecretStore::new(HashMap::from([ + ("proxy_secret".to_owned(), b"resolved-proxy-secret".to_vec()), + ( + "ec_passphrase".to_owned(), + b"resolved-passphrase-32-bytes-min!".to_vec(), + ), + ( + "secure_password".to_owned(), + b"resolved-secure-password".to_vec(), + ), + ( + "admin_password".to_owned(), + b"resolved-admin-password".to_vec(), + ), + ("partner_a_token".to_owned(), partner_token.to_vec()), + ])) + } + + #[test] + fn store_mode_blob_with_short_resolved_partner_token_fails_validation() { + let store = store_mode_secret_store_with_partner_token(b"too-short"); + + let err = settings_from_config_blob_with_secrets( + &store_mode_envelope_json_with_partner(), + Some(&store), + ) + .expect_err("should reject short resolved partner api_token at load"); + + assert!( + err.to_string().contains("api_token"), + "error should mention partner api_token: {err}" + ); + } + + #[test] + fn store_mode_blob_with_valid_resolved_partner_token_loads() { + let store = + store_mode_secret_store_with_partner_token(b"resolved-partner-a-token-32-bytes-min"); + + let settings = settings_from_config_blob_with_secrets( + &store_mode_envelope_json_with_partner(), + Some(&store), + ) + .expect("should load store-mode blob with a valid resolved partner token"); + + assert_eq!( + settings.ec.partners[0].api_token.expose(), + "resolved-partner-a-token-32-bytes-min", + "should expose resolved partner api_token" + ); + } + #[test] fn store_mode_blob_resolves_secrets_before_parse() { let store = store_mode_secret_store();