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")); +} diff --git a/crates/temper-server/src/secrets/environment.rs b/crates/temper-server/src/secrets/environment.rs new file mode 100644 index 000000000..adaf35d89 --- /dev/null +++ b/crates/temper-server/src/secrets/environment.rs @@ -0,0 +1,172 @@ +//! 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, 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. +//! +//! 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. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; + +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_"; + +/// 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 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. + 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::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", + } + } +} + +/// 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 value.len() > MAX_SECRET_VALUE_BYTES { + return Err(EnvironmentSecretSkip::ValueTooLarge); + } + 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)] +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..e6a13efd7 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/authorization.rs @@ -0,0 +1,65 @@ +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}" + ); +} + +#[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/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..deeb875e5 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/naming.rs @@ -0,0 +1,223 @@ +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()); +} + +#[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/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..29160de38 --- /dev/null +++ b/crates/temper-server/src/secrets/environment/tests/reporting.rs @@ -0,0 +1,69 @@ +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, 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| { + 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::ValueTooLarge, + 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/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; diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md index f17238a56..a7ba7ba2e 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. +- **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. +- **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 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.