From 38c3215fd5b372114358799e181cb673c4eeb3b5 Mon Sep 17 00:00:00 2001 From: "arun.parthiban" Date: Sun, 4 Oct 2026 19:54:11 +0000 Subject: [PATCH 1/6] feat(secrets): seed platform secrets from TEMPER_SECRET_ variables Add seed_platform_secrets_from_environment to the server's secrets module. Given the environment as name and value pairs, it caches every variable named TEMPER_SECRET_ with a non-empty value as the platform secret in lower case, so TEMPER_SECRET_BUILD_TOKEN supplies build_token to every tenant. is A-Z, 0-9 and _, starting with a letter, so no two variables can supply the same secret. A variable that does not fit, a value that is not UTF-8, a name the server already holds a platform secret for, and a variable beyond the platform budget are skipped and logged once by variable name. Seeded secrets are logged as a count. Values are never logged or returned. Nothing is logged when no prefixed variable is set. The function reads no environment and writes nothing to storage. Reading a seeded secret is authorized as before: the tests go through the resolver a module's get_secret call uses, with Cedar policies that permit and refuse it. Co-Authored-By: Claude Code --- .../temper-server/src/secrets/environment.rs | 161 ++++++ .../src/secrets/environment_test.rs | 525 ++++++++++++++++++ crates/temper-server/src/secrets/mod.rs | 5 +- 3 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 crates/temper-server/src/secrets/environment.rs create mode 100644 crates/temper-server/src/secrets/environment_test.rs diff --git a/crates/temper-server/src/secrets/environment.rs b/crates/temper-server/src/secrets/environment.rs new file mode 100644 index 000000000..18b995f36 --- /dev/null +++ b/crates/temper-server/src/secrets/environment.rs @@ -0,0 +1,161 @@ +//! Platform secrets supplied through the server's environment. +//! +//! An operator can hand the server any number of named secrets at start by +//! setting `TEMPER_SECRET_` variables. Each one with a non-empty value +//! becomes the platform secret `` in lower case: `TEMPER_SECRET_BUILD_TOKEN` +//! supplies `build_token`. A platform secret is the baseline for every tenant; +//! a tenant's own stored secret of the same name overrides it for that tenant. +//! +//! `` is one or more of `A` to `Z`, `0` to `9` and `_`, starting with a +//! letter, so that no two variables can supply the same secret. A variable +//! that does not fit is skipped and reported by name. Values are never +//! reported. A name the server already holds a platform secret for is left +//! as it is, so what the server sets for itself at start wins. +//! +//! Nothing here reads the process environment or writes to storage: the +//! caller passes the variables in, and the secrets live in the vault's +//! in-memory platform layer only. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; + +use super::vault::SecretsVault; + +/// Prefix of an environment variable that supplies a platform secret. +pub const ENVIRONMENT_SECRET_PREFIX: &str = "TEMPER_SECRET_"; + +/// Why a prefixed environment variable did not become a secret. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnvironmentSecretSkip { + /// The part after the prefix does not fit the naming rule. + InvalidName, + /// The value is not valid UTF-8. + ValueNotUnicode, + /// The server already holds a platform secret of that name. + AlreadySet, + /// The platform layer has no room for another secret. + BudgetExhausted, +} + +impl EnvironmentSecretSkip { + /// A short description for the start-up log. + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidName => { + "the name after the prefix must be A-Z, 0-9 or _, starting with a letter" + } + Self::ValueNotUnicode => "the value is not valid UTF-8", + Self::AlreadySet => "the server already sets a secret of that name", + Self::BudgetExhausted => "the maximum number of platform secrets is reached", + } + } +} + +/// What seeding from the environment did. Holds names only, never values. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvironmentSecretsReport { + /// Names of the secrets that were seeded, in variable-name order. + pub seeded: Vec, + /// Variables that were skipped, by variable name, with the reason. + pub skipped: Vec<(String, EnvironmentSecretSkip)>, +} + +impl EnvironmentSecretsReport { + /// Log each skipped variable once by name, and how many secrets were + /// seeded. Logs nothing when no prefixed variable was set. + fn log(&self) { + for (variable, reason) in &self.skipped { + tracing::warn!( + variable = %variable, + reason = reason.as_str(), + "environment variable was not seeded as a secret" + ); + } + if !self.seeded.is_empty() { + tracing::info!( + count = self.seeded.len(), + "seeded platform secrets from TEMPER_SECRET_ environment variables" + ); + } + } +} + +/// Seed the vault's platform layer from `TEMPER_SECRET_` variables. +/// +/// `variables` is the environment as `(name, value)` pairs; anything without +/// the prefix, and any prefixed variable with an empty value, is ignored. +/// Variables are taken in name order, so the outcome does not depend on the +/// order the environment lists them in. The outcome is logged (names and a +/// count, never values) and returned. +pub fn seed_platform_secrets_from_environment( + vault: &SecretsVault, + variables: I, +) -> EnvironmentSecretsReport +where + I: IntoIterator, +{ + let prefixed: BTreeMap = variables + .into_iter() + .filter(|(variable, value)| has_secret_prefix(variable) && !value.is_empty()) + .collect(); + + let mut report = EnvironmentSecretsReport::default(); + for (variable, value) in prefixed { + match seed_one(vault, &variable, value) { + Ok(secret) => report.seeded.push(secret), + Err(reason) => report + .skipped + .push((variable.to_string_lossy().into_owned(), reason)), + } + } + + debug_assert!( + report + .seeded + .iter() + .all(|secret| vault.get_platform_secret(secret).is_some()), + "every secret reported as seeded must be in the platform layer" + ); + report.log(); + report +} + +fn has_secret_prefix(variable: &OsStr) -> bool { + variable + .as_encoded_bytes() + .starts_with(ENVIRONMENT_SECRET_PREFIX.as_bytes()) +} + +/// The secret a prefixed variable names, or `None` when the part after the +/// prefix does not fit the naming rule. +fn secret_name(variable: &OsStr) -> Option { + let name = variable.to_str()?.strip_prefix(ENVIRONMENT_SECRET_PREFIX)?; + let mut chars = name.chars(); + let starts_with_letter = chars.next().is_some_and(|c| c.is_ascii_uppercase()); + let rest_fits = chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'); + (starts_with_letter && rest_fits).then(|| name.to_ascii_lowercase()) +} + +fn seed_one( + vault: &SecretsVault, + variable: &OsStr, + value: OsString, +) -> Result { + let secret = secret_name(variable).ok_or(EnvironmentSecretSkip::InvalidName)?; + // The rejected value is dropped here, not carried in the error. + let value = value + .into_string() + .map_err(|_| EnvironmentSecretSkip::ValueNotUnicode)?; + if vault.get_platform_secret(&secret).is_some() { + return Err(EnvironmentSecretSkip::AlreadySet); + } + // The only failure of the platform cache is its budget. + vault + .cache_platform_secret(&secret, value) + .map_err(|_| EnvironmentSecretSkip::BudgetExhausted)?; + Ok(secret) +} + +#[cfg(test)] +#[path = "environment_test.rs"] +mod tests; diff --git a/crates/temper-server/src/secrets/environment_test.rs b/crates/temper-server/src/secrets/environment_test.rs new file mode 100644 index 000000000..ba65068ad --- /dev/null +++ b/crates/temper-server/src/secrets/environment_test.rs @@ -0,0 +1,525 @@ +use std::ffi::OsString; +use std::io; +use std::sync::{Arc, Mutex}; + +use temper_runtime::ActorSystem; +use temper_runtime::tenant::TenantId; +use temper_wasm::WasmAuthzContext; + +use super::*; +use crate::authz::CedarWasmAuthzGate; +use crate::registry::SpecRegistry; +use crate::state::ServerState; + +const TENANT: &str = "tenant-a"; +const MODULE: &str = "token-reader"; +/// Lets the module read `build_token` and nothing else. +const POLICIES: &str = r#" +permit(principal == Agent::"token-reader", action == Action::"access_secret", resource == Secret::"build_token"); +"#; +/// A value no name, reason or message contains, for the checks that nothing +/// reports a value. +const DISTINCT_VALUE: &str = "value-kept-out-of-reports"; + +fn test_vault() -> SecretsVault { + SecretsVault::new(&[0x42; 32]) +} + +fn vars(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + pairs + .iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect() +} + +/// The lookup a module's `get_secret` call goes through: the tenant's Cedar +/// policies decide, then the vault answers. +fn module_reads(vault: SecretsVault, module: &str, secret: &str) -> Result { + let state = + ServerState::from_registry(ActorSystem::new("env-secret-tests"), SpecRegistry::new()) + .with_secrets_vault(vault); + state + .authz + .reload_tenant_policies(TENANT, POLICIES) + .expect("policies load"); + let gate = Arc::new(CedarWasmAuthzGate::new(state.authz.clone())); + let authz_ctx = WasmAuthzContext { + tenant: TENANT.to_string(), + module_name: module.to_string(), + agent_id: None, + session_id: None, + entity_type: "Build".to_string(), + trigger_action: "Start".to_string(), + }; + let resolver = state + .authorized_wasm_secret_resolver(&TenantId::new(TENANT), gate, authz_ctx) + .expect("resolver exists when a vault is configured"); + resolver(secret) +} + +#[derive(Clone, Default)] +struct LogBuffer(Arc>>); + +impl io::Write for LogBuffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 + .lock() + .expect("log buffer lock") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Seed `variables` and return the report with every log line and span the +/// seeding produced, at the most verbose level. +fn seed_with_log( + vault: &SecretsVault, + variables: Vec<(OsString, OsString)>, +) -> (EnvironmentSecretsReport, String) { + let buffer = LogBuffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_ansi(false) + .with_writer(move || writer.clone()) + .finish(); + let report = tracing::subscriber::with_default(subscriber, || { + seed_platform_secrets_from_environment(vault, variables) + }); + let log = + String::from_utf8(buffer.0.lock().expect("log buffer lock").clone()).expect("log is UTF-8"); + (report, log) +} + +#[test] +fn permitted_module_reads_a_secret_seeded_from_a_prefixed_variable() { + let vault = test_vault(); + + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + + assert_eq!( + module_reads(vault, MODULE, "build_token"), + Ok("abc".to_string()) + ); +} + +#[test] +fn module_without_permission_is_still_refused_a_seeded_secret() { + let vault = test_vault(); + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + + let refused = module_reads(vault, "another-module", "build_token") + .expect_err("a module the policies do not name must be refused"); + + assert!( + refused.contains("authorization denied for secret 'build_token'"), + "{refused}" + ); + assert!(!refused.contains("abc"), "{refused}"); +} + +#[test] +fn permitted_module_is_refused_a_seeded_secret_its_policy_does_not_name() { + let vault = test_vault(); + seed_platform_secrets_from_environment( + &vault, + vars(&[ + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ("TEMPER_SECRET_DEPLOY_TOKEN", "def"), + ]), + ); + + let refused = + module_reads(vault, MODULE, "deploy_token").expect_err("the policy names build_token only"); + + assert!( + refused.contains("authorization denied for secret 'deploy_token'"), + "{refused}" + ); +} + +#[test] +fn seeded_secret_reaches_every_tenant() { + let vault = test_vault(); + + let report = seed_platform_secrets_from_environment( + &vault, + vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")]), + ); + + assert_eq!(report.seeded, vec!["build_token".to_string()]); + assert!(report.skipped.is_empty()); + assert_eq!(vault.get_platform_secret("build_token"), Some("abc".into())); + assert_eq!( + vault.get_secret("tenant-a", "build_token"), + Some("abc".into()) + ); + assert_eq!( + vault.get_secret("tenant-b", "build_token"), + Some("abc".into()) + ); + assert!( + vault + .list_keys("tenant-b") + .contains(&"build_token".to_string()) + ); +} + +#[test] +fn several_prefixed_variables_are_all_seeded() { + let vault = test_vault(); + + let report = seed_platform_secrets_from_environment( + &vault, + vars(&[ + ("TEMPER_SECRET_REGION", "north"), + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ("TEMPER_SECRET_KEY_2", "def"), + ]), + ); + + assert_eq!( + report.seeded, + vec![ + "build_token".to_string(), + "key_2".to_string(), + "region".to_string() + ] + ); + assert_eq!(vault.get_platform_secret("build_token"), Some("abc".into())); + assert_eq!(vault.get_platform_secret("key_2"), Some("def".into())); + assert_eq!(vault.get_platform_secret("region"), Some("north".into())); +} + +#[test] +fn empty_value_seeds_nothing_and_is_not_reported() { + let vault = test_vault(); + + let (report, log) = seed_with_log( + &vault, + vars(&[("TEMPER_SECRET_BUILD_TOKEN", ""), ("TEMPER_SECRET_", "")]), + ); + + assert_eq!(report, EnvironmentSecretsReport::default()); + assert_eq!(vault.get_platform_secret("build_token"), None); + assert!(vault.get_platform_secrets().is_empty()); + assert_eq!(log, ""); +} + +#[test] +fn variables_without_the_prefix_are_ignored() { + let vault = test_vault(); + + let (report, log) = seed_with_log( + &vault, + vars(&[ + ("BUILD_TOKEN", "abc"), + ("TEMPER_SECRET", "abc"), + ("TEMPER_SECRETS_BUILD_TOKEN", "abc"), + ("temper_secret_BUILD_TOKEN", "abc"), + ("MY_TEMPER_SECRET_BUILD_TOKEN", "abc"), + ]), + ); + + assert_eq!(report, EnvironmentSecretsReport::default()); + assert!(vault.get_platform_secrets().is_empty()); + assert_eq!(log, ""); +} + +#[test] +fn no_prefixed_variable_seeds_nothing_and_logs_nothing() { + let vault = test_vault(); + + let (report, log) = seed_with_log(&vault, vars(&[("HOME", "/home/user"), ("PATH", "/bin")])); + + assert_eq!(report, EnvironmentSecretsReport::default()); + assert!(vault.get_platform_secrets().is_empty()); + assert_eq!(log, ""); +} + +#[test] +fn badly_named_variables_are_skipped_and_each_reported_once_by_name() { + let vault = test_vault(); + let bad_names = [ + "TEMPER_SECRET_", + "TEMPER_SECRET_1TOKEN", + "TEMPER_SECRET_BUILD-TOKEN", + "TEMPER_SECRET_BUILD.TOKEN", + "TEMPER_SECRET_Build_Token", + "TEMPER_SECRET__TOKEN", + "TEMPER_SECRET_build_token", + ]; + let mut variables = vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")]); + variables.extend( + bad_names + .iter() + .flat_map(|name| vars(&[(name, DISTINCT_VALUE)])), + ); + + let (report, log) = seed_with_log(&vault, variables); + + // The well-named variable beside them is still seeded. + assert_eq!(report.seeded, vec!["build_token".to_string()]); + assert_eq!( + vault.get_platform_secrets().keys().collect::>(), + vec!["build_token"] + ); + assert_eq!( + report.skipped, + bad_names + .iter() + .map(|name| (name.to_string(), EnvironmentSecretSkip::InvalidName)) + .collect::>() + ); + for name in bad_names { + let lines: Vec<&str> = log + .lines() + .filter(|line| line.contains(&format!("variable={name} "))) + .collect(); + assert_eq!(lines.len(), 1, "{name} must be reported once: {log}"); + assert!(lines[0].contains("WARN"), "{log}"); + } + assert_eq!(log.lines().count(), bad_names.len() + 1, "{log}"); +} + +#[test] +fn log_gives_the_count_of_seeded_secrets_and_no_names() { + let vault = test_vault(); + + let (_, log) = seed_with_log( + &vault, + vars(&[ + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ("TEMPER_SECRET_REGION", "north"), + ]), + ); + + assert_eq!(log.lines().count(), 1, "{log}"); + assert!(log.contains("INFO"), "{log}"); + assert!( + log.contains("seeded platform secrets from TEMPER_SECRET_ environment variables"), + "{log}" + ); + assert!(log.contains("count=2"), "{log}"); + assert!(!log.to_lowercase().contains("build_token"), "{log}"); + assert!(!log.to_lowercase().contains("region"), "{log}"); +} + +#[test] +fn no_report_log_line_or_span_contains_a_value() { + let vault = test_vault(); + // One variable for every outcome: seeded, badly named, already set, and + // (below) over the budget. + vault + .cache_platform_secret("region", "north".to_string()) + .expect("platform secret cached"); + let mut variables = vars(&[ + ("TEMPER_SECRET_BUILD_TOKEN", DISTINCT_VALUE), + ("TEMPER_SECRET_build_token", DISTINCT_VALUE), + ("TEMPER_SECRET_REGION", DISTINCT_VALUE), + ]); + variables.extend( + (0..crate::secrets::vault::MAX_SECRETS_PER_TENANT).flat_map(|i| { + vars(&[( + format!("TEMPER_SECRET_FILL_{i:03}").as_str(), + DISTINCT_VALUE, + )]) + }), + ); + + let (report, log) = seed_with_log(&vault, variables); + + let reasons: Vec = + report.skipped.iter().map(|(_, reason)| *reason).collect(); + for reason in [ + EnvironmentSecretSkip::InvalidName, + EnvironmentSecretSkip::AlreadySet, + EnvironmentSecretSkip::BudgetExhausted, + ] { + assert!(reasons.contains(&reason), "{reason:?} not exercised"); + } + assert!(!report.seeded.is_empty()); + assert!(!log.is_empty()); + assert!(!log.contains(DISTINCT_VALUE), "{log}"); + assert!( + !format!("{report:?}").contains(DISTINCT_VALUE), + "{report:?}" + ); +} + +#[cfg(unix)] +#[test] +fn value_that_is_not_utf8_is_skipped_and_reported_by_name() { + use std::os::unix::ffi::OsStringExt; + + let vault = test_vault(); + let variables = vec![( + OsString::from("TEMPER_SECRET_BUILD_TOKEN"), + OsString::from_vec(vec![0x61, 0xff, 0x62]), + )]; + + let (report, log) = seed_with_log(&vault, variables); + + assert!(report.seeded.is_empty()); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_BUILD_TOKEN".to_string(), + EnvironmentSecretSkip::ValueNotUnicode + )] + ); + assert_eq!(vault.get_platform_secret("build_token"), None); + assert_eq!(log.lines().count(), 1, "{log}"); + assert!(log.contains("variable=TEMPER_SECRET_BUILD_TOKEN "), "{log}"); +} + +#[cfg(unix)] +#[test] +fn name_that_is_not_utf8_is_skipped_and_reported() { + use std::os::unix::ffi::OsStringExt; + + let vault = test_vault(); + let mut name = b"TEMPER_SECRET_BUILD".to_vec(); + name.push(0xff); + let variables = vec![(OsString::from_vec(name), OsString::from("abc"))]; + + let report = seed_platform_secrets_from_environment(&vault, variables); + + assert!(report.seeded.is_empty()); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_BUILD\u{fffd}".to_string(), + EnvironmentSecretSkip::InvalidName + )] + ); + assert!(vault.get_platform_secrets().is_empty()); +} + +#[test] +fn name_the_server_already_set_keeps_its_value() { + let vault = test_vault(); + // What start-up does for ANTHROPIC_API_KEY before the prefixed variables. + vault + .cache_platform_secret("anthropic_api_key", "from-fixed-variable".to_string()) + .expect("platform secret cached"); + + let (report, log) = seed_with_log( + &vault, + vars(&[ + ("TEMPER_SECRET_ANTHROPIC_API_KEY", "from-prefixed-variable"), + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ]), + ); + + assert_eq!( + vault.get_secret(TENANT, "anthropic_api_key"), + Some("from-fixed-variable".into()) + ); + assert_eq!(report.seeded, vec!["build_token".to_string()]); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_ANTHROPIC_API_KEY".to_string(), + EnvironmentSecretSkip::AlreadySet + )] + ); + assert!( + log.contains("variable=TEMPER_SECRET_ANTHROPIC_API_KEY "), + "{log}" + ); + assert!(!log.contains("from-prefixed-variable"), "{log}"); + assert!(!log.contains("from-fixed-variable"), "{log}"); +} + +#[test] +fn prefixed_form_of_a_fixed_name_is_seeded_when_the_server_has_not_set_it() { + let vault = test_vault(); + + let report = seed_platform_secrets_from_environment( + &vault, + vars(&[("TEMPER_SECRET_ANTHROPIC_API_KEY", "from-prefixed-variable")]), + ); + + assert_eq!(report.seeded, vec!["anthropic_api_key".to_string()]); + assert_eq!( + vault.get_secret(TENANT, "anthropic_api_key"), + Some("from-prefixed-variable".into()) + ); +} + +#[test] +fn stored_tenant_secret_wins_over_a_seeded_one_for_that_tenant_only() { + let vault = test_vault(); + // tenant-a stored its own build_token before the server started; + // tenant-b stores one after. + vault + .cache_secret("tenant-a", "build_token", "stored-by-a".to_string()) + .expect("tenant secret cached"); + + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + vault + .cache_secret("tenant-b", "build_token", "stored-by-b".to_string()) + .expect("tenant secret cached"); + + assert_eq!( + vault.get_secret("tenant-a", "build_token"), + Some("stored-by-a".into()) + ); + assert_eq!( + vault.get_secret("tenant-b", "build_token"), + Some("stored-by-b".into()) + ); + assert_eq!( + vault.get_tenant_secrets("tenant-a").get("build_token"), + Some(&"stored-by-a".to_string()) + ); + // A tenant with no stored secret of that name reads the seeded one. + assert_eq!( + vault.get_secret("tenant-c", "build_token"), + Some("abc".into()) + ); + + // Removing the stored secret uncovers the seeded one again. + assert!(vault.remove_secret("tenant-a", "build_token")); + assert_eq!( + vault.get_secret("tenant-a", "build_token"), + Some("abc".into()) + ); +} + +#[test] +fn variables_over_the_platform_budget_are_skipped_in_name_order() { + let vault = test_vault(); + let budget = crate::secrets::vault::MAX_SECRETS_PER_TENANT; + // Listed in reverse, to show the outcome follows the names and not the + // order the environment lists them in. + let variables: Vec<(OsString, OsString)> = (0..budget + 2) + .rev() + .flat_map(|i| vars(&[(format!("TEMPER_SECRET_KEY_{i:03}").as_str(), "abc")])) + .collect(); + + let report = seed_platform_secrets_from_environment(&vault, variables); + + assert_eq!(report.seeded.len(), budget); + assert_eq!(report.seeded.first().map(String::as_str), Some("key_000")); + assert_eq!( + report.skipped, + vec![ + ( + format!("TEMPER_SECRET_KEY_{budget:03}"), + EnvironmentSecretSkip::BudgetExhausted + ), + ( + format!("TEMPER_SECRET_KEY_{:03}", budget + 1), + EnvironmentSecretSkip::BudgetExhausted + ), + ] + ); + assert_eq!(vault.get_platform_secrets().len(), budget); +} diff --git a/crates/temper-server/src/secrets/mod.rs b/crates/temper-server/src/secrets/mod.rs index 895090049..24285c4ca 100644 --- a/crates/temper-server/src/secrets/mod.rs +++ b/crates/temper-server/src/secrets/mod.rs @@ -1,7 +1,10 @@ -//! Tenant secret management: encrypted storage and template resolution. +//! Tenant secret management: encrypted storage, template resolution and +//! platform secrets supplied through the environment. +pub mod environment; pub mod template; pub mod vault; +pub use environment::seed_platform_secrets_from_environment; pub use template::resolve_secret_templates; pub use vault::SecretsVault; From 9edd6f384873136bcb65ddc3720d1e2a6a13485d Mon Sep 17 00:00:00 2001 From: "arun.parthiban" Date: Sun, 4 Oct 2026 19:54:16 +0000 Subject: [PATCH 2/6] feat(cli): seed TEMPER_SECRET_ variables when temper serve starts temper serve seeded two fixed variables into the secrets cache at start, ANTHROPIC_API_KEY and EXA_API_KEY, and had no way to supply any other secret with the server's configuration: it had to be stored through the secrets API once the server answered, and again after every restart of a server that keeps its cache in memory only. Seed every TEMPER_SECRET_ variable after the secrets the server sets itself, so those keep their values and an existing deployment sees no change. With no such variable set, start-up does and logs nothing new. The test starts the built server as a process, with and without prefixed variables, and checks the count, one warning per skipped variable, that the fixed variable wins over its prefixed form, that no value appears in the output, and that the server listens. Co-Authored-By: Claude Code --- crates/temper-cli/src/serve/mod.rs | 5 + .../tests/serve_environment_secrets.rs | 166 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 crates/temper-cli/tests/serve_environment_secrets.rs diff --git a/crates/temper-cli/src/serve/mod.rs b/crates/temper-cli/src/serve/mod.rs index 8c7616e43..e1cb598f2 100644 --- a/crates/temper-cli/src/serve/mod.rs +++ b/crates/temper-cli/src/serve/mod.rs @@ -223,6 +223,11 @@ pub async fn run( }; let _ = vault.cache_platform_secret("sandbox_url", sandbox_url); } + + // TEMPER_SECRET_ — any other secret the operator supplies. Runs + // last, so every name seeded above keeps its value. + // determinism-ok: environment read once at startup + temper_server::secrets::seed_platform_secrets_from_environment(vault, std::env::vars_os()); } // Startup banner diff --git a/crates/temper-cli/tests/serve_environment_secrets.rs b/crates/temper-cli/tests/serve_environment_secrets.rs new file mode 100644 index 000000000..6be617405 --- /dev/null +++ b/crates/temper-cli/tests/serve_environment_secrets.rs @@ -0,0 +1,166 @@ +//! `temper serve` started as a real process, with and without +//! `TEMPER_SECRET_` variables: what start-up reports, and that it starts. +use std::fs::File; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const SEEDED_MESSAGE: &str = "seeded platform secrets from TEMPER_SECRET_ environment variables"; +const SKIPPED_MESSAGE: &str = "environment variable was not seeded as a secret"; + +struct Process(Child); + +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Everything the server wrote until it was listening. +struct StartupOutput { + stdout: String, + stderr: String, +} + +impl StartupOutput { + /// The structured log events with the given message. + fn events(&self, message: &str) -> Vec { + self.stdout + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["fields"]["message"] == message) + .collect() + } + + fn contains(&self, text: &str) -> bool { + self.stdout.contains(text) || self.stderr.contains(text) + } +} + +/// Start `temper serve` with only the given variables, in an empty home and +/// working directory, wait until it listens, and stop it. +fn start_and_stop(variables: &[(&str, &str)]) -> StartupOutput { + let dir = tempfile::tempdir().expect("temporary directory"); + let stdout_path = dir.path().join("stdout.txt"); + let stderr_path = dir.path().join("stderr.txt"); + let home = dir.path().join("home"); + let cwd = dir.path().join("cwd"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::create_dir_all(&cwd).expect("working directory"); + + let mut command = Command::new(env!("CARGO_BIN_EXE_temper")); + command + .args(["serve", "--port", "0", "--no-observe"]) + .current_dir(&cwd) + .env_clear() + .env("HOME", &home) + .envs(variables.iter().copied()) + .stdin(Stdio::null()) + .stdout(Stdio::from( + File::create(&stdout_path).expect("stdout file"), + )) + .stderr(Stdio::from( + File::create(&stderr_path).expect("stderr file"), + )); + let mut process = Process(command.spawn().expect("start temper serve")); + + // A loaded workspace test run starts the server slowly; a server that + // fails to start exits, and one that hangs runs into the deadline. + let deadline = Instant::now() + Duration::from_secs(180); + while !read(&stdout_path) + .lines() + .any(|line| line.starts_with("Listening on ")) + { + if let Some(status) = process.0.try_wait().expect("read child status") { + panic!( + "temper serve exited with {status} before listening:\n{}", + read(&stderr_path) + ); + } + assert!( + Instant::now() < deadline, + "temper serve was not listening in time:\n{}", + read(&stderr_path) + ); + thread::sleep(Duration::from_millis(50)); + } + drop(process); + + StartupOutput { + stdout: read(&stdout_path), + stderr: read(&stderr_path), + } +} + +fn read(path: &Path) -> String { + String::from_utf8_lossy(&std::fs::read(path).expect("read output file")).into_owned() +} + +#[test] +fn prefixed_variables_are_reported_by_count_and_bad_names_once_and_the_server_starts() { + let values = [ + "seeded-value-one", + "seeded-value-two", + "bad-name-value", + "fixed-variable-value", + "prefixed-form-value", + ]; + let bad_names = [ + "TEMPER_SECRET_", + "TEMPER_SECRET_BUILD-TOKEN", + "TEMPER_SECRET_build_token", + ]; + + let output = start_and_stop(&[ + ("TEMPER_SECRET_BUILD_TOKEN", values[0]), + ("TEMPER_SECRET_REGION", values[1]), + ("TEMPER_SECRET_EMPTY", ""), + (bad_names[0], values[2]), + (bad_names[1], values[2]), + (bad_names[2], values[2]), + ("ANTHROPIC_API_KEY", values[3]), + ("TEMPER_SECRET_ANTHROPIC_API_KEY", values[4]), + ]); + + let seeded = output.events(SEEDED_MESSAGE); + assert_eq!(seeded.len(), 1, "{seeded:?}"); + assert_eq!(seeded[0]["level"], "INFO"); + assert_eq!(seeded[0]["fields"]["count"], 2); + + let skipped = output.events(SKIPPED_MESSAGE); + let reported: Vec<&str> = skipped + .iter() + .map(|event| event["fields"]["variable"].as_str().expect("variable name")) + .collect(); + // The fixed variable was seeded first, so its prefixed form is the one skipped. + assert_eq!( + reported, + [ + bad_names[0], + "TEMPER_SECRET_ANTHROPIC_API_KEY", + bad_names[1], + bad_names[2], + ] + ); + assert!(skipped.iter().all(|event| event["level"] == "WARN")); + assert_eq!( + skipped[1]["fields"]["reason"], + "the server already sets a secret of that name" + ); + + for value in values { + assert!(!output.contains(value), "start-up output contains {value}"); + } +} + +#[test] +fn start_up_without_a_prefixed_variable_reports_nothing_about_them() { + let output = start_and_stop(&[("ANTHROPIC_API_KEY", "fixed-variable-value")]); + + assert!(output.events(SEEDED_MESSAGE).is_empty()); + assert!(output.events(SKIPPED_MESSAGE).is_empty()); + assert!(!output.contains("TEMPER_SECRET_")); + assert!(!output.contains("fixed-variable-value")); +} From c296eccf4181b5fc0ccbc6b4cee4a6a39e73c46a Mon Sep 17 00:00:00 2001 From: "arun.parthiban" Date: Sun, 4 Oct 2026 19:54:17 +0000 Subject: [PATCH 3/6] docs(secrets): list TEMPER_SECRET_ variables, the naming rule and the precedence Add the prefix to the environment variable appendix of the guide, with the naming rule, the reach across tenants, the unchanged authorization, the precedence against a tenant's stored secret and the secrets the server sets itself, what start-up logs, and the budget. Co-Authored-By: Claude Code --- docs/AGENT_GUIDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md index f17238a56..ef54fdd8d 100644 --- a/docs/AGENT_GUIDE.md +++ b/docs/AGENT_GUIDE.md @@ -1286,8 +1286,23 @@ temper serve [--port PORT] [--specs-dir DIR] [--tenant NAME] | `OTLP_ENDPOINT` | For telemetry export | OTLP collector base URL (e.g., `http://localhost:4318`) | | `CLICKHOUSE_URL` | For analysis queries | ClickHouse HTTP endpoint (read path for trajectory analysis) | | `ANTHROPIC_API_KEY` | For agent mode | Claude API key | +| `TEMPER_SECRET_` | No | Supplies the secret `` to every tenant from the moment the server is up. See [Secrets from the environment](#secrets-from-the-environment). | | `RUST_LOG` | No | Log level (default: `info,temper=debug`) | +### Secrets from the environment + +`temper serve` seeds its secrets cache at start from every variable named `TEMPER_SECRET_` that has a non-empty value. The secret is `` in lower case: `TEMPER_SECRET_BUILD_TOKEN=abc` supplies the secret `build_token`, which a module reads with `get_secret("build_token")` and an integration config references as `{secret:build_token}`. With no such variable set, nothing changes. + +- **Naming rule.** `` is one or more of `A` to `Z`, `0` to `9` and `_`, and starts with a letter. A variable that does not fit (`TEMPER_SECRET_` alone, a lower-case letter, a dash) is skipped and logged once at startup as a warning that gives the variable's name. The server still starts. An empty value is the same as an unset variable. +- **Reach.** A seeded secret is a platform secret: every tenant reads it, including tenants created later. It is held in memory only and is not written to storage, so it has to be in the environment at every start. `GET /api/tenants/{tenant}/secrets` lists it by name. +- **Authorization is unchanged.** A module still needs a policy in its tenant that permits `access_secret` on `Secret::""`. Without one the read is refused. +- **Precedence**, highest first: + 1. A secret a tenant stored through the secrets API, for that tenant only, whether it was stored before or after the server started. Deleting it uncovers the seeded value again. + 2. What the server sets itself at start: `anthropic_api_key` when `ANTHROPIC_API_KEY` is set, `exa_api_key` when `EXA_API_KEY` is set, and the addresses the server gives its own modules (such as `temper_api_url`). The prefixed variable of the same name is skipped and logged by name. + 3. The prefixed variable. +- **What is logged.** One line with the number of secrets seeded, and one warning for each skipped variable with its name. Values are never logged. With no `TEMPER_SECRET_` variable set, startup logs nothing about them. +- **Budget.** The platform layer holds at most 100 secrets, the server's own included. Variables are taken in name order, and any beyond the budget are skipped and logged by name. + ### Telemetry export settings When an OTLP endpoint is configured, these standard OpenTelemetry variables adjust what `temper serve` exports. All are optional. With none of them set, the export is unchanged. From 310fcc27d696b92120cc2187318a2557ba9c45b9 Mon Sep 17 00:00:00 2001 From: "arun.parthiban" Date: Sun, 4 Oct 2026 20:47:41 +0000 Subject: [PATCH 4/6] test(secrets): split the environment seeding tests by topic The test file was 525 lines, over the 500-line rule. Move it to a tests directory under the module: shared helpers, and one file each for authorization, naming, precedence and reporting. No test is changed. Co-Authored-By: Claude Code --- .../temper-server/src/secrets/environment.rs | 1 - .../environment/tests/authorization.rs | 48 ++ .../src/secrets/environment/tests/mod.rs | 102 ++++ .../src/secrets/environment/tests/naming.rs | 194 +++++++ .../secrets/environment/tests/precedence.rs | 124 +++++ .../secrets/environment/tests/reporting.rs | 66 +++ .../src/secrets/environment_test.rs | 525 ------------------ 7 files changed, 534 insertions(+), 526 deletions(-) create mode 100644 crates/temper-server/src/secrets/environment/tests/authorization.rs create mode 100644 crates/temper-server/src/secrets/environment/tests/mod.rs create mode 100644 crates/temper-server/src/secrets/environment/tests/naming.rs create mode 100644 crates/temper-server/src/secrets/environment/tests/precedence.rs create mode 100644 crates/temper-server/src/secrets/environment/tests/reporting.rs delete mode 100644 crates/temper-server/src/secrets/environment_test.rs diff --git a/crates/temper-server/src/secrets/environment.rs b/crates/temper-server/src/secrets/environment.rs index 18b995f36..d49f65799 100644 --- a/crates/temper-server/src/secrets/environment.rs +++ b/crates/temper-server/src/secrets/environment.rs @@ -157,5 +157,4 @@ fn seed_one( } #[cfg(test)] -#[path = "environment_test.rs"] mod tests; diff --git a/crates/temper-server/src/secrets/environment/tests/authorization.rs b/crates/temper-server/src/secrets/environment/tests/authorization.rs new file mode 100644 index 000000000..5ee1e5bb7 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/authorization.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn permitted_module_reads_a_secret_seeded_from_a_prefixed_variable() { + let vault = test_vault(); + + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + + assert_eq!( + module_reads(vault, MODULE, "build_token"), + Ok("abc".to_string()) + ); +} + +#[test] +fn module_without_permission_is_still_refused_a_seeded_secret() { + let vault = test_vault(); + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + + let refused = module_reads(vault, "another-module", "build_token") + .expect_err("a module the policies do not name must be refused"); + + assert!( + refused.contains("authorization denied for secret 'build_token'"), + "{refused}" + ); + assert!(!refused.contains("abc"), "{refused}"); +} + +#[test] +fn permitted_module_is_refused_a_seeded_secret_its_policy_does_not_name() { + let vault = test_vault(); + seed_platform_secrets_from_environment( + &vault, + vars(&[ + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ("TEMPER_SECRET_DEPLOY_TOKEN", "def"), + ]), + ); + + let refused = + module_reads(vault, MODULE, "deploy_token").expect_err("the policy names build_token only"); + + assert!( + refused.contains("authorization denied for secret 'deploy_token'"), + "{refused}" + ); +} diff --git a/crates/temper-server/src/secrets/environment/tests/mod.rs b/crates/temper-server/src/secrets/environment/tests/mod.rs new file mode 100644 index 000000000..1cd809b05 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/mod.rs @@ -0,0 +1,102 @@ +use std::ffi::OsString; +use std::io; +use std::sync::{Arc, Mutex}; + +use temper_runtime::ActorSystem; +use temper_runtime::tenant::TenantId; +use temper_wasm::WasmAuthzContext; + +use super::*; +use crate::authz::CedarWasmAuthzGate; +use crate::registry::SpecRegistry; +use crate::state::ServerState; + +const TENANT: &str = "tenant-a"; +const MODULE: &str = "token-reader"; +/// Lets the module read `build_token` and nothing else. +const POLICIES: &str = r#" +permit(principal == Agent::"token-reader", action == Action::"access_secret", resource == Secret::"build_token"); +"#; +/// A value no name, reason or message contains, for the checks that nothing +/// reports a value. +const DISTINCT_VALUE: &str = "value-kept-out-of-reports"; + +fn test_vault() -> SecretsVault { + SecretsVault::new(&[0x42; 32]) +} + +fn vars(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + pairs + .iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect() +} + +/// The lookup a module's `get_secret` call goes through: the tenant's Cedar +/// policies decide, then the vault answers. +fn module_reads(vault: SecretsVault, module: &str, secret: &str) -> Result { + let state = + ServerState::from_registry(ActorSystem::new("env-secret-tests"), SpecRegistry::new()) + .with_secrets_vault(vault); + state + .authz + .reload_tenant_policies(TENANT, POLICIES) + .expect("policies load"); + let gate = Arc::new(CedarWasmAuthzGate::new(state.authz.clone())); + let authz_ctx = WasmAuthzContext { + tenant: TENANT.to_string(), + module_name: module.to_string(), + agent_id: None, + session_id: None, + entity_type: "Build".to_string(), + trigger_action: "Start".to_string(), + }; + let resolver = state + .authorized_wasm_secret_resolver(&TenantId::new(TENANT), gate, authz_ctx) + .expect("resolver exists when a vault is configured"); + resolver(secret) +} + +#[derive(Clone, Default)] +struct LogBuffer(Arc>>); + +impl io::Write for LogBuffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 + .lock() + .expect("log buffer lock") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Seed `variables` and return the report with every log line and span the +/// seeding produced, at the most verbose level. +fn seed_with_log( + vault: &SecretsVault, + variables: Vec<(OsString, OsString)>, +) -> (EnvironmentSecretsReport, String) { + let buffer = LogBuffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_ansi(false) + .with_writer(move || writer.clone()) + .finish(); + let report = tracing::subscriber::with_default(subscriber, || { + seed_platform_secrets_from_environment(vault, variables) + }); + let log = + String::from_utf8(buffer.0.lock().expect("log buffer lock").clone()).expect("log is UTF-8"); + (report, log) +} + +mod authorization; +mod naming; +mod precedence; +mod reporting; diff --git a/crates/temper-server/src/secrets/environment/tests/naming.rs b/crates/temper-server/src/secrets/environment/tests/naming.rs new file mode 100644 index 000000000..1eab2ff97 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/naming.rs @@ -0,0 +1,194 @@ +use super::*; + +#[test] +fn seeded_secret_reaches_every_tenant() { + let vault = test_vault(); + + let report = seed_platform_secrets_from_environment( + &vault, + vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")]), + ); + + assert_eq!(report.seeded, vec!["build_token".to_string()]); + assert!(report.skipped.is_empty()); + assert_eq!(vault.get_platform_secret("build_token"), Some("abc".into())); + assert_eq!( + vault.get_secret("tenant-a", "build_token"), + Some("abc".into()) + ); + assert_eq!( + vault.get_secret("tenant-b", "build_token"), + Some("abc".into()) + ); + assert!( + vault + .list_keys("tenant-b") + .contains(&"build_token".to_string()) + ); +} + +#[test] +fn several_prefixed_variables_are_all_seeded() { + let vault = test_vault(); + + let report = seed_platform_secrets_from_environment( + &vault, + vars(&[ + ("TEMPER_SECRET_REGION", "north"), + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ("TEMPER_SECRET_KEY_2", "def"), + ]), + ); + + assert_eq!( + report.seeded, + vec![ + "build_token".to_string(), + "key_2".to_string(), + "region".to_string() + ] + ); + assert_eq!(vault.get_platform_secret("build_token"), Some("abc".into())); + assert_eq!(vault.get_platform_secret("key_2"), Some("def".into())); + assert_eq!(vault.get_platform_secret("region"), Some("north".into())); +} + +#[test] +fn empty_value_seeds_nothing_and_is_not_reported() { + let vault = test_vault(); + + let (report, log) = seed_with_log( + &vault, + vars(&[("TEMPER_SECRET_BUILD_TOKEN", ""), ("TEMPER_SECRET_", "")]), + ); + + assert_eq!(report, EnvironmentSecretsReport::default()); + assert_eq!(vault.get_platform_secret("build_token"), None); + assert!(vault.get_platform_secrets().is_empty()); + assert_eq!(log, ""); +} + +#[test] +fn variables_without_the_prefix_are_ignored() { + let vault = test_vault(); + + let (report, log) = seed_with_log( + &vault, + vars(&[ + ("BUILD_TOKEN", "abc"), + ("TEMPER_SECRET", "abc"), + ("TEMPER_SECRETS_BUILD_TOKEN", "abc"), + ("temper_secret_BUILD_TOKEN", "abc"), + ("MY_TEMPER_SECRET_BUILD_TOKEN", "abc"), + ]), + ); + + assert_eq!(report, EnvironmentSecretsReport::default()); + assert!(vault.get_platform_secrets().is_empty()); + assert_eq!(log, ""); +} + +#[test] +fn no_prefixed_variable_seeds_nothing_and_logs_nothing() { + let vault = test_vault(); + + let (report, log) = seed_with_log(&vault, vars(&[("HOME", "/home/user"), ("PATH", "/bin")])); + + assert_eq!(report, EnvironmentSecretsReport::default()); + assert!(vault.get_platform_secrets().is_empty()); + assert_eq!(log, ""); +} + +#[test] +fn badly_named_variables_are_skipped_and_each_reported_once_by_name() { + let vault = test_vault(); + let bad_names = [ + "TEMPER_SECRET_", + "TEMPER_SECRET_1TOKEN", + "TEMPER_SECRET_BUILD-TOKEN", + "TEMPER_SECRET_BUILD.TOKEN", + "TEMPER_SECRET_Build_Token", + "TEMPER_SECRET__TOKEN", + "TEMPER_SECRET_build_token", + ]; + let mut variables = vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")]); + variables.extend( + bad_names + .iter() + .flat_map(|name| vars(&[(name, DISTINCT_VALUE)])), + ); + + let (report, log) = seed_with_log(&vault, variables); + + // The well-named variable beside them is still seeded. + assert_eq!(report.seeded, vec!["build_token".to_string()]); + assert_eq!( + vault.get_platform_secrets().keys().collect::>(), + vec!["build_token"] + ); + assert_eq!( + report.skipped, + bad_names + .iter() + .map(|name| (name.to_string(), EnvironmentSecretSkip::InvalidName)) + .collect::>() + ); + for name in bad_names { + let lines: Vec<&str> = log + .lines() + .filter(|line| line.contains(&format!("variable={name} "))) + .collect(); + assert_eq!(lines.len(), 1, "{name} must be reported once: {log}"); + assert!(lines[0].contains("WARN"), "{log}"); + } + assert_eq!(log.lines().count(), bad_names.len() + 1, "{log}"); +} + +#[cfg(unix)] +#[test] +fn value_that_is_not_utf8_is_skipped_and_reported_by_name() { + use std::os::unix::ffi::OsStringExt; + + let vault = test_vault(); + let variables = vec![( + OsString::from("TEMPER_SECRET_BUILD_TOKEN"), + OsString::from_vec(vec![0x61, 0xff, 0x62]), + )]; + + let (report, log) = seed_with_log(&vault, variables); + + assert!(report.seeded.is_empty()); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_BUILD_TOKEN".to_string(), + EnvironmentSecretSkip::ValueNotUnicode + )] + ); + assert_eq!(vault.get_platform_secret("build_token"), None); + assert_eq!(log.lines().count(), 1, "{log}"); + assert!(log.contains("variable=TEMPER_SECRET_BUILD_TOKEN "), "{log}"); +} + +#[cfg(unix)] +#[test] +fn name_that_is_not_utf8_is_skipped_and_reported() { + use std::os::unix::ffi::OsStringExt; + + let vault = test_vault(); + let mut name = b"TEMPER_SECRET_BUILD".to_vec(); + name.push(0xff); + let variables = vec![(OsString::from_vec(name), OsString::from("abc"))]; + + let report = seed_platform_secrets_from_environment(&vault, variables); + + assert!(report.seeded.is_empty()); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_BUILD\u{fffd}".to_string(), + EnvironmentSecretSkip::InvalidName + )] + ); + assert!(vault.get_platform_secrets().is_empty()); +} diff --git a/crates/temper-server/src/secrets/environment/tests/precedence.rs b/crates/temper-server/src/secrets/environment/tests/precedence.rs new file mode 100644 index 000000000..eaceccac9 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/precedence.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn name_the_server_already_set_keeps_its_value() { + let vault = test_vault(); + // What start-up does for ANTHROPIC_API_KEY before the prefixed variables. + vault + .cache_platform_secret("anthropic_api_key", "from-fixed-variable".to_string()) + .expect("platform secret cached"); + + let (report, log) = seed_with_log( + &vault, + vars(&[ + ("TEMPER_SECRET_ANTHROPIC_API_KEY", "from-prefixed-variable"), + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ]), + ); + + assert_eq!( + vault.get_secret(TENANT, "anthropic_api_key"), + Some("from-fixed-variable".into()) + ); + assert_eq!(report.seeded, vec!["build_token".to_string()]); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_ANTHROPIC_API_KEY".to_string(), + EnvironmentSecretSkip::AlreadySet + )] + ); + assert!( + log.contains("variable=TEMPER_SECRET_ANTHROPIC_API_KEY "), + "{log}" + ); + assert!(!log.contains("from-prefixed-variable"), "{log}"); + assert!(!log.contains("from-fixed-variable"), "{log}"); +} + +#[test] +fn prefixed_form_of_a_fixed_name_is_seeded_when_the_server_has_not_set_it() { + let vault = test_vault(); + + let report = seed_platform_secrets_from_environment( + &vault, + vars(&[("TEMPER_SECRET_ANTHROPIC_API_KEY", "from-prefixed-variable")]), + ); + + assert_eq!(report.seeded, vec!["anthropic_api_key".to_string()]); + assert_eq!( + vault.get_secret(TENANT, "anthropic_api_key"), + Some("from-prefixed-variable".into()) + ); +} + +#[test] +fn stored_tenant_secret_wins_over_a_seeded_one_for_that_tenant_only() { + let vault = test_vault(); + // tenant-a stored its own build_token before the server started; + // tenant-b stores one after. + vault + .cache_secret("tenant-a", "build_token", "stored-by-a".to_string()) + .expect("tenant secret cached"); + + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + vault + .cache_secret("tenant-b", "build_token", "stored-by-b".to_string()) + .expect("tenant secret cached"); + + assert_eq!( + vault.get_secret("tenant-a", "build_token"), + Some("stored-by-a".into()) + ); + assert_eq!( + vault.get_secret("tenant-b", "build_token"), + Some("stored-by-b".into()) + ); + assert_eq!( + vault.get_tenant_secrets("tenant-a").get("build_token"), + Some(&"stored-by-a".to_string()) + ); + // A tenant with no stored secret of that name reads the seeded one. + assert_eq!( + vault.get_secret("tenant-c", "build_token"), + Some("abc".into()) + ); + + // Removing the stored secret uncovers the seeded one again. + assert!(vault.remove_secret("tenant-a", "build_token")); + assert_eq!( + vault.get_secret("tenant-a", "build_token"), + Some("abc".into()) + ); +} + +#[test] +fn variables_over_the_platform_budget_are_skipped_in_name_order() { + let vault = test_vault(); + let budget = crate::secrets::vault::MAX_SECRETS_PER_TENANT; + // Listed in reverse, to show the outcome follows the names and not the + // order the environment lists them in. + let variables: Vec<(OsString, OsString)> = (0..budget + 2) + .rev() + .flat_map(|i| vars(&[(format!("TEMPER_SECRET_KEY_{i:03}").as_str(), "abc")])) + .collect(); + + let report = seed_platform_secrets_from_environment(&vault, variables); + + assert_eq!(report.seeded.len(), budget); + assert_eq!(report.seeded.first().map(String::as_str), Some("key_000")); + assert_eq!( + report.skipped, + vec![ + ( + format!("TEMPER_SECRET_KEY_{budget:03}"), + EnvironmentSecretSkip::BudgetExhausted + ), + ( + format!("TEMPER_SECRET_KEY_{:03}", budget + 1), + EnvironmentSecretSkip::BudgetExhausted + ), + ] + ); + assert_eq!(vault.get_platform_secrets().len(), budget); +} diff --git a/crates/temper-server/src/secrets/environment/tests/reporting.rs b/crates/temper-server/src/secrets/environment/tests/reporting.rs new file mode 100644 index 000000000..fcd1508d0 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/reporting.rs @@ -0,0 +1,66 @@ +use super::*; + +#[test] +fn log_gives_the_count_of_seeded_secrets_and_no_names() { + let vault = test_vault(); + + let (_, log) = seed_with_log( + &vault, + vars(&[ + ("TEMPER_SECRET_BUILD_TOKEN", "abc"), + ("TEMPER_SECRET_REGION", "north"), + ]), + ); + + assert_eq!(log.lines().count(), 1, "{log}"); + assert!(log.contains("INFO"), "{log}"); + assert!( + log.contains("seeded platform secrets from TEMPER_SECRET_ environment variables"), + "{log}" + ); + assert!(log.contains("count=2"), "{log}"); + assert!(!log.to_lowercase().contains("build_token"), "{log}"); + assert!(!log.to_lowercase().contains("region"), "{log}"); +} + +#[test] +fn no_report_log_line_or_span_contains_a_value() { + let vault = test_vault(); + // One variable for every outcome: seeded, badly named, already set, and + // (below) over the budget. + vault + .cache_platform_secret("region", "north".to_string()) + .expect("platform secret cached"); + let mut variables = vars(&[ + ("TEMPER_SECRET_BUILD_TOKEN", DISTINCT_VALUE), + ("TEMPER_SECRET_build_token", DISTINCT_VALUE), + ("TEMPER_SECRET_REGION", DISTINCT_VALUE), + ]); + variables.extend( + (0..crate::secrets::vault::MAX_SECRETS_PER_TENANT).flat_map(|i| { + vars(&[( + format!("TEMPER_SECRET_FILL_{i:03}").as_str(), + DISTINCT_VALUE, + )]) + }), + ); + + let (report, log) = seed_with_log(&vault, variables); + + let reasons: Vec = + report.skipped.iter().map(|(_, reason)| *reason).collect(); + for reason in [ + EnvironmentSecretSkip::InvalidName, + EnvironmentSecretSkip::AlreadySet, + EnvironmentSecretSkip::BudgetExhausted, + ] { + assert!(reasons.contains(&reason), "{reason:?} not exercised"); + } + assert!(!report.seeded.is_empty()); + assert!(!log.is_empty()); + assert!(!log.contains(DISTINCT_VALUE), "{log}"); + assert!( + !format!("{report:?}").contains(DISTINCT_VALUE), + "{report:?}" + ); +} diff --git a/crates/temper-server/src/secrets/environment_test.rs b/crates/temper-server/src/secrets/environment_test.rs deleted file mode 100644 index ba65068ad..000000000 --- a/crates/temper-server/src/secrets/environment_test.rs +++ /dev/null @@ -1,525 +0,0 @@ -use std::ffi::OsString; -use std::io; -use std::sync::{Arc, Mutex}; - -use temper_runtime::ActorSystem; -use temper_runtime::tenant::TenantId; -use temper_wasm::WasmAuthzContext; - -use super::*; -use crate::authz::CedarWasmAuthzGate; -use crate::registry::SpecRegistry; -use crate::state::ServerState; - -const TENANT: &str = "tenant-a"; -const MODULE: &str = "token-reader"; -/// Lets the module read `build_token` and nothing else. -const POLICIES: &str = r#" -permit(principal == Agent::"token-reader", action == Action::"access_secret", resource == Secret::"build_token"); -"#; -/// A value no name, reason or message contains, for the checks that nothing -/// reports a value. -const DISTINCT_VALUE: &str = "value-kept-out-of-reports"; - -fn test_vault() -> SecretsVault { - SecretsVault::new(&[0x42; 32]) -} - -fn vars(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> { - pairs - .iter() - .map(|(name, value)| (OsString::from(name), OsString::from(value))) - .collect() -} - -/// The lookup a module's `get_secret` call goes through: the tenant's Cedar -/// policies decide, then the vault answers. -fn module_reads(vault: SecretsVault, module: &str, secret: &str) -> Result { - let state = - ServerState::from_registry(ActorSystem::new("env-secret-tests"), SpecRegistry::new()) - .with_secrets_vault(vault); - state - .authz - .reload_tenant_policies(TENANT, POLICIES) - .expect("policies load"); - let gate = Arc::new(CedarWasmAuthzGate::new(state.authz.clone())); - let authz_ctx = WasmAuthzContext { - tenant: TENANT.to_string(), - module_name: module.to_string(), - agent_id: None, - session_id: None, - entity_type: "Build".to_string(), - trigger_action: "Start".to_string(), - }; - let resolver = state - .authorized_wasm_secret_resolver(&TenantId::new(TENANT), gate, authz_ctx) - .expect("resolver exists when a vault is configured"); - resolver(secret) -} - -#[derive(Clone, Default)] -struct LogBuffer(Arc>>); - -impl io::Write for LogBuffer { - fn write(&mut self, bytes: &[u8]) -> io::Result { - self.0 - .lock() - .expect("log buffer lock") - .extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -/// Seed `variables` and return the report with every log line and span the -/// seeding produced, at the most verbose level. -fn seed_with_log( - vault: &SecretsVault, - variables: Vec<(OsString, OsString)>, -) -> (EnvironmentSecretsReport, String) { - let buffer = LogBuffer::default(); - let writer = buffer.clone(); - let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::TRACE) - .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) - .with_ansi(false) - .with_writer(move || writer.clone()) - .finish(); - let report = tracing::subscriber::with_default(subscriber, || { - seed_platform_secrets_from_environment(vault, variables) - }); - let log = - String::from_utf8(buffer.0.lock().expect("log buffer lock").clone()).expect("log is UTF-8"); - (report, log) -} - -#[test] -fn permitted_module_reads_a_secret_seeded_from_a_prefixed_variable() { - let vault = test_vault(); - - seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); - - assert_eq!( - module_reads(vault, MODULE, "build_token"), - Ok("abc".to_string()) - ); -} - -#[test] -fn module_without_permission_is_still_refused_a_seeded_secret() { - let vault = test_vault(); - seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); - - let refused = module_reads(vault, "another-module", "build_token") - .expect_err("a module the policies do not name must be refused"); - - assert!( - refused.contains("authorization denied for secret 'build_token'"), - "{refused}" - ); - assert!(!refused.contains("abc"), "{refused}"); -} - -#[test] -fn permitted_module_is_refused_a_seeded_secret_its_policy_does_not_name() { - let vault = test_vault(); - seed_platform_secrets_from_environment( - &vault, - vars(&[ - ("TEMPER_SECRET_BUILD_TOKEN", "abc"), - ("TEMPER_SECRET_DEPLOY_TOKEN", "def"), - ]), - ); - - let refused = - module_reads(vault, MODULE, "deploy_token").expect_err("the policy names build_token only"); - - assert!( - refused.contains("authorization denied for secret 'deploy_token'"), - "{refused}" - ); -} - -#[test] -fn seeded_secret_reaches_every_tenant() { - let vault = test_vault(); - - let report = seed_platform_secrets_from_environment( - &vault, - vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")]), - ); - - assert_eq!(report.seeded, vec!["build_token".to_string()]); - assert!(report.skipped.is_empty()); - assert_eq!(vault.get_platform_secret("build_token"), Some("abc".into())); - assert_eq!( - vault.get_secret("tenant-a", "build_token"), - Some("abc".into()) - ); - assert_eq!( - vault.get_secret("tenant-b", "build_token"), - Some("abc".into()) - ); - assert!( - vault - .list_keys("tenant-b") - .contains(&"build_token".to_string()) - ); -} - -#[test] -fn several_prefixed_variables_are_all_seeded() { - let vault = test_vault(); - - let report = seed_platform_secrets_from_environment( - &vault, - vars(&[ - ("TEMPER_SECRET_REGION", "north"), - ("TEMPER_SECRET_BUILD_TOKEN", "abc"), - ("TEMPER_SECRET_KEY_2", "def"), - ]), - ); - - assert_eq!( - report.seeded, - vec![ - "build_token".to_string(), - "key_2".to_string(), - "region".to_string() - ] - ); - assert_eq!(vault.get_platform_secret("build_token"), Some("abc".into())); - assert_eq!(vault.get_platform_secret("key_2"), Some("def".into())); - assert_eq!(vault.get_platform_secret("region"), Some("north".into())); -} - -#[test] -fn empty_value_seeds_nothing_and_is_not_reported() { - let vault = test_vault(); - - let (report, log) = seed_with_log( - &vault, - vars(&[("TEMPER_SECRET_BUILD_TOKEN", ""), ("TEMPER_SECRET_", "")]), - ); - - assert_eq!(report, EnvironmentSecretsReport::default()); - assert_eq!(vault.get_platform_secret("build_token"), None); - assert!(vault.get_platform_secrets().is_empty()); - assert_eq!(log, ""); -} - -#[test] -fn variables_without_the_prefix_are_ignored() { - let vault = test_vault(); - - let (report, log) = seed_with_log( - &vault, - vars(&[ - ("BUILD_TOKEN", "abc"), - ("TEMPER_SECRET", "abc"), - ("TEMPER_SECRETS_BUILD_TOKEN", "abc"), - ("temper_secret_BUILD_TOKEN", "abc"), - ("MY_TEMPER_SECRET_BUILD_TOKEN", "abc"), - ]), - ); - - assert_eq!(report, EnvironmentSecretsReport::default()); - assert!(vault.get_platform_secrets().is_empty()); - assert_eq!(log, ""); -} - -#[test] -fn no_prefixed_variable_seeds_nothing_and_logs_nothing() { - let vault = test_vault(); - - let (report, log) = seed_with_log(&vault, vars(&[("HOME", "/home/user"), ("PATH", "/bin")])); - - assert_eq!(report, EnvironmentSecretsReport::default()); - assert!(vault.get_platform_secrets().is_empty()); - assert_eq!(log, ""); -} - -#[test] -fn badly_named_variables_are_skipped_and_each_reported_once_by_name() { - let vault = test_vault(); - let bad_names = [ - "TEMPER_SECRET_", - "TEMPER_SECRET_1TOKEN", - "TEMPER_SECRET_BUILD-TOKEN", - "TEMPER_SECRET_BUILD.TOKEN", - "TEMPER_SECRET_Build_Token", - "TEMPER_SECRET__TOKEN", - "TEMPER_SECRET_build_token", - ]; - let mut variables = vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")]); - variables.extend( - bad_names - .iter() - .flat_map(|name| vars(&[(name, DISTINCT_VALUE)])), - ); - - let (report, log) = seed_with_log(&vault, variables); - - // The well-named variable beside them is still seeded. - assert_eq!(report.seeded, vec!["build_token".to_string()]); - assert_eq!( - vault.get_platform_secrets().keys().collect::>(), - vec!["build_token"] - ); - assert_eq!( - report.skipped, - bad_names - .iter() - .map(|name| (name.to_string(), EnvironmentSecretSkip::InvalidName)) - .collect::>() - ); - for name in bad_names { - let lines: Vec<&str> = log - .lines() - .filter(|line| line.contains(&format!("variable={name} "))) - .collect(); - assert_eq!(lines.len(), 1, "{name} must be reported once: {log}"); - assert!(lines[0].contains("WARN"), "{log}"); - } - assert_eq!(log.lines().count(), bad_names.len() + 1, "{log}"); -} - -#[test] -fn log_gives_the_count_of_seeded_secrets_and_no_names() { - let vault = test_vault(); - - let (_, log) = seed_with_log( - &vault, - vars(&[ - ("TEMPER_SECRET_BUILD_TOKEN", "abc"), - ("TEMPER_SECRET_REGION", "north"), - ]), - ); - - assert_eq!(log.lines().count(), 1, "{log}"); - assert!(log.contains("INFO"), "{log}"); - assert!( - log.contains("seeded platform secrets from TEMPER_SECRET_ environment variables"), - "{log}" - ); - assert!(log.contains("count=2"), "{log}"); - assert!(!log.to_lowercase().contains("build_token"), "{log}"); - assert!(!log.to_lowercase().contains("region"), "{log}"); -} - -#[test] -fn no_report_log_line_or_span_contains_a_value() { - let vault = test_vault(); - // One variable for every outcome: seeded, badly named, already set, and - // (below) over the budget. - vault - .cache_platform_secret("region", "north".to_string()) - .expect("platform secret cached"); - let mut variables = vars(&[ - ("TEMPER_SECRET_BUILD_TOKEN", DISTINCT_VALUE), - ("TEMPER_SECRET_build_token", DISTINCT_VALUE), - ("TEMPER_SECRET_REGION", DISTINCT_VALUE), - ]); - variables.extend( - (0..crate::secrets::vault::MAX_SECRETS_PER_TENANT).flat_map(|i| { - vars(&[( - format!("TEMPER_SECRET_FILL_{i:03}").as_str(), - DISTINCT_VALUE, - )]) - }), - ); - - let (report, log) = seed_with_log(&vault, variables); - - let reasons: Vec = - report.skipped.iter().map(|(_, reason)| *reason).collect(); - for reason in [ - EnvironmentSecretSkip::InvalidName, - EnvironmentSecretSkip::AlreadySet, - EnvironmentSecretSkip::BudgetExhausted, - ] { - assert!(reasons.contains(&reason), "{reason:?} not exercised"); - } - assert!(!report.seeded.is_empty()); - assert!(!log.is_empty()); - assert!(!log.contains(DISTINCT_VALUE), "{log}"); - assert!( - !format!("{report:?}").contains(DISTINCT_VALUE), - "{report:?}" - ); -} - -#[cfg(unix)] -#[test] -fn value_that_is_not_utf8_is_skipped_and_reported_by_name() { - use std::os::unix::ffi::OsStringExt; - - let vault = test_vault(); - let variables = vec![( - OsString::from("TEMPER_SECRET_BUILD_TOKEN"), - OsString::from_vec(vec![0x61, 0xff, 0x62]), - )]; - - let (report, log) = seed_with_log(&vault, variables); - - assert!(report.seeded.is_empty()); - assert_eq!( - report.skipped, - vec![( - "TEMPER_SECRET_BUILD_TOKEN".to_string(), - EnvironmentSecretSkip::ValueNotUnicode - )] - ); - assert_eq!(vault.get_platform_secret("build_token"), None); - assert_eq!(log.lines().count(), 1, "{log}"); - assert!(log.contains("variable=TEMPER_SECRET_BUILD_TOKEN "), "{log}"); -} - -#[cfg(unix)] -#[test] -fn name_that_is_not_utf8_is_skipped_and_reported() { - use std::os::unix::ffi::OsStringExt; - - let vault = test_vault(); - let mut name = b"TEMPER_SECRET_BUILD".to_vec(); - name.push(0xff); - let variables = vec![(OsString::from_vec(name), OsString::from("abc"))]; - - let report = seed_platform_secrets_from_environment(&vault, variables); - - assert!(report.seeded.is_empty()); - assert_eq!( - report.skipped, - vec![( - "TEMPER_SECRET_BUILD\u{fffd}".to_string(), - EnvironmentSecretSkip::InvalidName - )] - ); - assert!(vault.get_platform_secrets().is_empty()); -} - -#[test] -fn name_the_server_already_set_keeps_its_value() { - let vault = test_vault(); - // What start-up does for ANTHROPIC_API_KEY before the prefixed variables. - vault - .cache_platform_secret("anthropic_api_key", "from-fixed-variable".to_string()) - .expect("platform secret cached"); - - let (report, log) = seed_with_log( - &vault, - vars(&[ - ("TEMPER_SECRET_ANTHROPIC_API_KEY", "from-prefixed-variable"), - ("TEMPER_SECRET_BUILD_TOKEN", "abc"), - ]), - ); - - assert_eq!( - vault.get_secret(TENANT, "anthropic_api_key"), - Some("from-fixed-variable".into()) - ); - assert_eq!(report.seeded, vec!["build_token".to_string()]); - assert_eq!( - report.skipped, - vec![( - "TEMPER_SECRET_ANTHROPIC_API_KEY".to_string(), - EnvironmentSecretSkip::AlreadySet - )] - ); - assert!( - log.contains("variable=TEMPER_SECRET_ANTHROPIC_API_KEY "), - "{log}" - ); - assert!(!log.contains("from-prefixed-variable"), "{log}"); - assert!(!log.contains("from-fixed-variable"), "{log}"); -} - -#[test] -fn prefixed_form_of_a_fixed_name_is_seeded_when_the_server_has_not_set_it() { - let vault = test_vault(); - - let report = seed_platform_secrets_from_environment( - &vault, - vars(&[("TEMPER_SECRET_ANTHROPIC_API_KEY", "from-prefixed-variable")]), - ); - - assert_eq!(report.seeded, vec!["anthropic_api_key".to_string()]); - assert_eq!( - vault.get_secret(TENANT, "anthropic_api_key"), - Some("from-prefixed-variable".into()) - ); -} - -#[test] -fn stored_tenant_secret_wins_over_a_seeded_one_for_that_tenant_only() { - let vault = test_vault(); - // tenant-a stored its own build_token before the server started; - // tenant-b stores one after. - vault - .cache_secret("tenant-a", "build_token", "stored-by-a".to_string()) - .expect("tenant secret cached"); - - seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); - vault - .cache_secret("tenant-b", "build_token", "stored-by-b".to_string()) - .expect("tenant secret cached"); - - assert_eq!( - vault.get_secret("tenant-a", "build_token"), - Some("stored-by-a".into()) - ); - assert_eq!( - vault.get_secret("tenant-b", "build_token"), - Some("stored-by-b".into()) - ); - assert_eq!( - vault.get_tenant_secrets("tenant-a").get("build_token"), - Some(&"stored-by-a".to_string()) - ); - // A tenant with no stored secret of that name reads the seeded one. - assert_eq!( - vault.get_secret("tenant-c", "build_token"), - Some("abc".into()) - ); - - // Removing the stored secret uncovers the seeded one again. - assert!(vault.remove_secret("tenant-a", "build_token")); - assert_eq!( - vault.get_secret("tenant-a", "build_token"), - Some("abc".into()) - ); -} - -#[test] -fn variables_over_the_platform_budget_are_skipped_in_name_order() { - let vault = test_vault(); - let budget = crate::secrets::vault::MAX_SECRETS_PER_TENANT; - // Listed in reverse, to show the outcome follows the names and not the - // order the environment lists them in. - let variables: Vec<(OsString, OsString)> = (0..budget + 2) - .rev() - .flat_map(|i| vars(&[(format!("TEMPER_SECRET_KEY_{i:03}").as_str(), "abc")])) - .collect(); - - let report = seed_platform_secrets_from_environment(&vault, variables); - - assert_eq!(report.seeded.len(), budget); - assert_eq!(report.seeded.first().map(String::as_str), Some("key_000")); - assert_eq!( - report.skipped, - vec![ - ( - format!("TEMPER_SECRET_KEY_{budget:03}"), - EnvironmentSecretSkip::BudgetExhausted - ), - ( - format!("TEMPER_SECRET_KEY_{:03}", budget + 1), - EnvironmentSecretSkip::BudgetExhausted - ), - ] - ); - assert_eq!(vault.get_platform_secrets().len(), budget); -} From aaecf619e31965b857d2a5c8aca2b000fac83175 Mon Sep 17 00:00:00 2001 From: "arun.parthiban" Date: Sun, 4 Oct 2026 20:52:17 +0000 Subject: [PATCH 5/6] fix(secrets): skip a TEMPER_SECRET_ value over the secret size limit The secrets API refuses a value over 8192 bytes. A prefixed variable was cached whatever its size, so the limit depended on how a secret arrived. Skip a larger value and log the variable by name, like the other skipped variables. A value of exactly the limit is seeded. Co-Authored-By: Claude Code --- .../temper-server/src/secrets/environment.rs | 15 +++++++--- .../src/secrets/environment/tests/naming.rs | 29 +++++++++++++++++++ .../secrets/environment/tests/reporting.rs | 7 +++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/temper-server/src/secrets/environment.rs b/crates/temper-server/src/secrets/environment.rs index d49f65799..dbfbb49cd 100644 --- a/crates/temper-server/src/secrets/environment.rs +++ b/crates/temper-server/src/secrets/environment.rs @@ -8,9 +8,10 @@ //! //! `` is one or more of `A` to `Z`, `0` to `9` and `_`, starting with a //! letter, so that no two variables can supply the same secret. A variable -//! that does not fit is skipped and reported by name. Values are never -//! reported. A name the server already holds a platform secret for is left -//! as it is, so what the server sets for itself at start wins. +//! that does not fit, or whose value is larger than the secrets API accepts, +//! is skipped and reported by name. Values are never reported. A name the +//! server already holds a platform secret for is left as it is, so what the +//! server sets for itself at start wins. //! //! Nothing here reads the process environment or writes to storage: the //! caller passes the variables in, and the secrets live in the vault's @@ -19,7 +20,7 @@ use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; -use super::vault::SecretsVault; +use super::vault::{MAX_SECRET_VALUE_BYTES, SecretsVault}; /// Prefix of an environment variable that supplies a platform secret. pub const ENVIRONMENT_SECRET_PREFIX: &str = "TEMPER_SECRET_"; @@ -31,6 +32,8 @@ pub enum EnvironmentSecretSkip { InvalidName, /// The value is not valid UTF-8. ValueNotUnicode, + /// The value is larger than the secrets API accepts. + ValueTooLarge, /// The server already holds a platform secret of that name. AlreadySet, /// The platform layer has no room for another secret. @@ -45,6 +48,7 @@ impl EnvironmentSecretSkip { "the name after the prefix must be A-Z, 0-9 or _, starting with a letter" } Self::ValueNotUnicode => "the value is not valid UTF-8", + Self::ValueTooLarge => "the value is larger than the maximum size of a secret", Self::AlreadySet => "the server already sets a secret of that name", Self::BudgetExhausted => "the maximum number of platform secrets is reached", } @@ -146,6 +150,9 @@ fn seed_one( let value = value .into_string() .map_err(|_| EnvironmentSecretSkip::ValueNotUnicode)?; + if value.len() > MAX_SECRET_VALUE_BYTES { + return Err(EnvironmentSecretSkip::ValueTooLarge); + } if vault.get_platform_secret(&secret).is_some() { return Err(EnvironmentSecretSkip::AlreadySet); } diff --git a/crates/temper-server/src/secrets/environment/tests/naming.rs b/crates/temper-server/src/secrets/environment/tests/naming.rs index 1eab2ff97..deeb875e5 100644 --- a/crates/temper-server/src/secrets/environment/tests/naming.rs +++ b/crates/temper-server/src/secrets/environment/tests/naming.rs @@ -192,3 +192,32 @@ fn name_that_is_not_utf8_is_skipped_and_reported() { ); assert!(vault.get_platform_secrets().is_empty()); } + +#[test] +fn value_over_the_size_limit_is_skipped_and_reported_by_name() { + let vault = test_vault(); + let limit = crate::secrets::vault::MAX_SECRET_VALUE_BYTES; + let at_limit = "a".repeat(limit); + let over_limit = "b".repeat(limit + 1); + + let (report, log) = seed_with_log( + &vault, + vars(&[ + ("TEMPER_SECRET_AT_LIMIT", &at_limit), + ("TEMPER_SECRET_OVER_LIMIT", &over_limit), + ]), + ); + + assert_eq!(report.seeded, vec!["at_limit".to_string()]); + assert_eq!( + report.skipped, + vec![( + "TEMPER_SECRET_OVER_LIMIT".to_string(), + EnvironmentSecretSkip::ValueTooLarge + )] + ); + assert_eq!(vault.get_platform_secret("at_limit"), Some(at_limit)); + assert_eq!(vault.get_platform_secret("over_limit"), None); + assert!(log.contains("variable=TEMPER_SECRET_OVER_LIMIT "), "{log}"); + assert!(!log.contains("bbbb"), "the log must not contain the value"); +} diff --git a/crates/temper-server/src/secrets/environment/tests/reporting.rs b/crates/temper-server/src/secrets/environment/tests/reporting.rs index fcd1508d0..29160de38 100644 --- a/crates/temper-server/src/secrets/environment/tests/reporting.rs +++ b/crates/temper-server/src/secrets/environment/tests/reporting.rs @@ -26,15 +26,17 @@ fn log_gives_the_count_of_seeded_secrets_and_no_names() { #[test] fn no_report_log_line_or_span_contains_a_value() { let vault = test_vault(); - // One variable for every outcome: seeded, badly named, already set, and - // (below) over the budget. + // One variable for every outcome: seeded, badly named, already set, too + // large, and (below) over the budget. vault .cache_platform_secret("region", "north".to_string()) .expect("platform secret cached"); + let too_large = DISTINCT_VALUE.repeat(crate::secrets::vault::MAX_SECRET_VALUE_BYTES); let mut variables = vars(&[ ("TEMPER_SECRET_BUILD_TOKEN", DISTINCT_VALUE), ("TEMPER_SECRET_build_token", DISTINCT_VALUE), ("TEMPER_SECRET_REGION", DISTINCT_VALUE), + ("TEMPER_SECRET_LARGE", &too_large), ]); variables.extend( (0..crate::secrets::vault::MAX_SECRETS_PER_TENANT).flat_map(|i| { @@ -52,6 +54,7 @@ fn no_report_log_line_or_span_contains_a_value() { for reason in [ EnvironmentSecretSkip::InvalidName, EnvironmentSecretSkip::AlreadySet, + EnvironmentSecretSkip::ValueTooLarge, EnvironmentSecretSkip::BudgetExhausted, ] { assert!(reasons.contains(&reason), "{reason:?} not exercised"); From e4329d734bab39e1237251a2ea3f963d5df10aa1 Mon Sep 17 00:00:00 2001 From: "arun.parthiban" Date: Sun, 4 Oct 2026 20:56:45 +0000 Subject: [PATCH 6/6] docs(secrets): say what each way of reading a seeded secret checks The guide said authorization is unchanged, next to an example of a {secret:} template. That holds for a module's get_secret call, which needs a policy that permits access_secret. A template in an integration config is resolved when the integration runs without that check, as it is for every secret. Since a seeded secret reaches every tenant, say so, and say to supply this way only what every tenant's specs may use. Add the size limit to the guide, and a test that states the template behaviour for a seeded secret. Co-Authored-By: Claude Code --- crates/temper-server/src/secrets/environment.rs | 5 +++++ .../secrets/environment/tests/authorization.rs | 17 +++++++++++++++++ docs/AGENT_GUIDE.md | 4 ++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/temper-server/src/secrets/environment.rs b/crates/temper-server/src/secrets/environment.rs index dbfbb49cd..adaf35d89 100644 --- a/crates/temper-server/src/secrets/environment.rs +++ b/crates/temper-server/src/secrets/environment.rs @@ -13,6 +13,11 @@ //! server already holds a platform secret for is left as it is, so what the //! server sets for itself at start wins. //! +//! A seeded secret is read like any other secret a tenant can read: a +//! module's `get_secret` call needs a policy that permits `access_secret` on +//! it, and a `{secret:}` template in an integration config is resolved +//! without that check. Supply this way only what every tenant's specs may use. +//! //! Nothing here reads the process environment or writes to storage: the //! caller passes the variables in, and the secrets live in the vault's //! in-memory platform layer only. diff --git a/crates/temper-server/src/secrets/environment/tests/authorization.rs b/crates/temper-server/src/secrets/environment/tests/authorization.rs index 5ee1e5bb7..e6a13efd7 100644 --- a/crates/temper-server/src/secrets/environment/tests/authorization.rs +++ b/crates/temper-server/src/secrets/environment/tests/authorization.rs @@ -46,3 +46,20 @@ fn permitted_module_is_refused_a_seeded_secret_its_policy_does_not_name() { "{refused}" ); } + +#[test] +fn integration_config_template_resolves_a_seeded_secret_like_any_other() { + let vault = test_vault(); + seed_platform_secrets_from_environment(&vault, vars(&[("TEMPER_SECRET_BUILD_TOKEN", "abc")])); + let config = std::collections::BTreeMap::from([( + "authorization".to_string(), + "Bearer {secret:build_token}".to_string(), + )]); + + // Templates are resolved from the vault when an integration runs, for + // every secret the tenant can read, without the `access_secret` check + // that `get_secret` goes through. + let resolved = crate::secrets::resolve_secret_templates(&config, &vault, "tenant-b"); + + assert_eq!(resolved["authorization"], "Bearer abc"); +} diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md index ef54fdd8d..a7ba7ba2e 100644 --- a/docs/AGENT_GUIDE.md +++ b/docs/AGENT_GUIDE.md @@ -1295,13 +1295,13 @@ temper serve [--port PORT] [--specs-dir DIR] [--tenant NAME] - **Naming rule.** `` is one or more of `A` to `Z`, `0` to `9` and `_`, and starts with a letter. A variable that does not fit (`TEMPER_SECRET_` alone, a lower-case letter, a dash) is skipped and logged once at startup as a warning that gives the variable's name. The server still starts. An empty value is the same as an unset variable. - **Reach.** A seeded secret is a platform secret: every tenant reads it, including tenants created later. It is held in memory only and is not written to storage, so it has to be in the environment at every start. `GET /api/tenants/{tenant}/secrets` lists it by name. -- **Authorization is unchanged.** A module still needs a policy in its tenant that permits `access_secret` on `Secret::""`. Without one the read is refused. +- **Who can read it.** Whoever can read any other secret of the tenant, by the same two routes. A module's `get_secret("")` call needs a policy in its tenant that permits `access_secret` on `Secret::""`, and is refused without one. A `{secret:}` template in an integration config is resolved when the integration runs, without that check, as it is for every secret. Because a seeded secret reaches every tenant, supply this way only what every tenant's specs may use. - **Precedence**, highest first: 1. A secret a tenant stored through the secrets API, for that tenant only, whether it was stored before or after the server started. Deleting it uncovers the seeded value again. 2. What the server sets itself at start: `anthropic_api_key` when `ANTHROPIC_API_KEY` is set, `exa_api_key` when `EXA_API_KEY` is set, and the addresses the server gives its own modules (such as `temper_api_url`). The prefixed variable of the same name is skipped and logged by name. 3. The prefixed variable. - **What is logged.** One line with the number of secrets seeded, and one warning for each skipped variable with its name. Values are never logged. With no `TEMPER_SECRET_` variable set, startup logs nothing about them. -- **Budget.** The platform layer holds at most 100 secrets, the server's own included. Variables are taken in name order, and any beyond the budget are skipped and logged by name. +- **Budgets.** A value larger than 8192 bytes, the limit of the secrets API, is skipped and logged by name. The platform layer holds at most 100 secrets, the server's own included. Variables are taken in name order, and any beyond that are skipped and logged by name. ### Telemetry export settings