Skip to content
Merged
5 changes: 5 additions & 0 deletions crates/temper-cli/src/serve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ pub async fn run(
};
let _ = vault.cache_platform_secret("sandbox_url", sandbox_url);
}

// TEMPER_SECRET_<NAME> — 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Seeded secrets bypass module permissions
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 lacks access_secret permission for build_token. Template resolution reads the vault without the Cedar check used by the module’s get_secret call, 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
This is a comment left during a code review.
Path: crates/temper-cli/src/serve/mod.rs
Line: 230

Comment:
**Seeded secrets bypass module permissions**
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 lacks `access_secret` permission for `build_token`. Template resolution reads the vault without the Cedar check used by the module’s `get_secret` call, 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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Copy link
Copy Markdown
Collaborator Author

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 the access_secret check that a module's get_secret call 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 by access_secret would 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".

Copy link
Copy Markdown

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_secret route 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.md and .greptile/config.json.

}

// Startup banner
Expand Down
166 changes: 166 additions & 0 deletions crates/temper-cli/tests/serve_environment_secrets.rs
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"));
}
172 changes: 172 additions & 0 deletions crates/temper-server/src/secrets/environment.rs
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)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

#[cfg(test)]
mod tests;
Loading
Loading