Repository navigation
feat(secrets): seed secrets from TEMPER_SECRET_ environment variables when temper serve starts #521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
38c3215
feat(secrets): seed platform secrets from TEMPER_SECRET_ variables
arun-pathiban-ddog 9edd6f3
feat(cli): seed TEMPER_SECRET_ variables when temper serve starts
arun-pathiban-ddog c296ecc
docs(secrets): list TEMPER_SECRET_ variables, the naming rule and the…
arun-pathiban-ddog 310fcc2
test(secrets): split the environment seeding tests by topic
arun-pathiban-ddog aaecf61
fix(secrets): skip a TEMPER_SECRET_ value over the secret size limit
arun-pathiban-ddog e4329d7
docs(secrets): say what each way of reading a seeded secret checks
arun-pathiban-ddog File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| //! `temper serve` started as a real process, with and without | ||
| //! `TEMPER_SECRET_<NAME>` 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<serde_json::Value> { | ||
| self.stdout | ||
| .lines() | ||
| .filter_map(|line| serde_json::from_str::<serde_json::Value>(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")); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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_<NAME>` variables. Each one with a non-empty value | ||
| //! becomes the platform secret `<name>` 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. | ||
| //! | ||
| //! `<NAME>` 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:<name>}` 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<String>, | ||
| /// 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_<NAME>` 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<I>( | ||
| vault: &SecretsVault, | ||
| variables: I, | ||
| ) -> EnvironmentSecretsReport | ||
| where | ||
| I: IntoIterator<Item = (OsString, OsString)>, | ||
| { | ||
| let prefixed: BTreeMap<OsString, OsString> = 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<String> { | ||
| 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<String, EnvironmentSecretSkip> { | ||
| 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) | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| #[cfg(test)] | ||
| mod tests; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If an authorized spec submitter puts
{secret:build_token}in a WASM integration config, this new platform secret is resolved and passed to the module even when that module lacksaccess_secretpermission forbuild_token. Template resolution reads the vault without the Cedar check used by the module’sget_secretcall, so the module can receive a secret its policy denies.How this was verified: A submitted integration config reaches the WASM invocation context through template resolution, which reads platform secrets without calling the module secret-access gate.
Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The route is real, and it is not new. A
{secret:KEY}template in an integration config is resolved from the vault when the integration runs, for every secret the tenant can read, without theaccess_secretcheck that a module'sget_secretcall goes through (resolve_secret_templates). That is how{secret:anthropic_api_key}resolves today, for the two fixed variables and for secrets stored through the API. This pull request does not change how secrets are read, so it leaves that as it is. Gating templates byaccess_secretwould change existing deployments and belongs in a change of its own.What was wrong here is the wording. The guide said "authorization is unchanged" next to a template example, which read as if the permit covered both routes. Fixed in "docs(secrets): say what each way of reading a seeded secret checks": the guide and the module documentation now say what each route checks, and that, because a seeded secret reaches every tenant, an operator should supply this way only what every tenant's specs may use. The same commit adds a test that states the template behaviour for a seeded secret, and the pull request description says the same under "Behaviour worth a reviewer's attention".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the clarification. I agree this is existing behavior rather than a regression introduced by this change, so gating template resolution is out of scope for this PR. The updated guide/module documentation and regression test make the distinction between template resolution and the policy-checked
get_secretroute clear, and appropriately call out that platform-seeded secrets are available across tenants. This concern is resolved; no code change is needed here.Tip: You can customize Greptile's behavior for this repo with
.greptile/rules.mdand.greptile/config.json.