feat(secrets): seed secrets from TEMPER_SECRET_ environment variables when temper serve starts - #521
Conversation
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_<NAME> with a non-empty value as the platform secret <name> in lower case, so TEMPER_SECRET_BUILD_TOKEN supplies build_token to every tenant. <NAME> 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 <noreply@anthropic.com>
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_<NAME> 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
| // 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()); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The guide said authorization is unchanged, next to an example of a
{secret:<name>} 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 <noreply@anthropic.com>
chore: merge nerdsane/temper main (nerdsane#513 to nerdsane#521)
What and why
temper servefills its secrets cache in two ways: from secrets stored through the secrets API once it is running, and at start from two fixed environment variables (ANTHROPIC_API_KEYbecomes the secretanthropic_api_key,EXA_API_KEYbecomesexa_api_key). An operator who wants a module to have any other secret from the moment the server is up cannot supply it with the server's configuration. They have to wait for the server to answer, authenticate, and call the secrets API, and do it again after every restart of a server that keeps its cache in memory only.This adds the general form of what the two fixed variables already do. Every variable named
TEMPER_SECRET_<NAME>with a non-empty value is seeded at start as the secret<name>in lower case:TEMPER_SECRET_BUILD_TOKEN=abcsuppliesbuild_token. With no such variable set, start-up does and logs nothing new.It adds a source of secrets and changes nothing in how secrets are stored, encrypted, authorized or read. A module's
get_secretcall still needs a policy in its tenant that permitsaccess_secretonSecret::"<name>". A{secret:<name>}template in an integration config is resolved without that check, as it is for every secret today; see the first point under "Behaviour worth a reviewer's attention".The rule
<NAME>is one or more ofAtoZ,0to9and_, starting with a letter. Lower-casing it is then one-to-one, so no two variables can supply the same secret.TEMPER_SECRET_alone, a lower-case letter, a dash). The server still starts.anthropic_api_keyfromANTHROPIC_API_KEY,exa_api_keyfromEXA_API_KEY, and the addresses it gives its own modules). 3. The prefixed variable.Behaviour worth a reviewer's attention
get_secretfrom a module goes throughaccess_secret. A{secret:<name>}template in an integration config is resolved from the vault when the integration runs, with no such check (resolve_secret_templates); that is how{secret:anthropic_api_key}resolves today. This change does not touch that. What it does change is how much can be in the platform layer: whatever the operator supplies reaches every tenant, so whoever may deploy a spec in any tenant can use it in a template. The guide says so and tells the operator to supply this way only what every tenant's specs may use. Gating templates byaccess_secretwould change existing deployments and is left for a change of its own.TEMPER_SECRET_ANTHROPIC_API_KEYsuppliesanthropic_api_keyonly whenANTHROPIC_API_KEYis not set, and an existing deployment sees no change. Seeding last also means the prefixed variables cannot use up the platform budget (100 secrets) before the server's own secrets are in.ANTHROPIC_API_KEYset to an empty string is cached as an empty secret today. That is unchanged, and it still wins over the prefixed form.GET /api/tenants/{tenant}/secretslists its name, and the existing fallback that starts the Discord transport from adiscord_bot_tokensecret will find one supplied this way when neither the flag norDISCORD_BOT_TOKENis given (read from the code, not exercised).temper_api_keystays unavailable to modules: the resolver refuses that name before it looks anything up.tracing, after the subscriber is installed, like the rest of the server's logs. It follows the log filter, and the readability ratchet'sprintlncount does not grow.std::env::vars_os().std::env::vars()panics on a variable that is not Unicode anywhere in the environment. A prefixed variable whose name or value is not UTF-8 is skipped and logged by name.temper_server::secrets::seed_platform_secrets_from_environment, which takes the variables as an argument and reads no environment itself, so it stays deterministic.temper servepasses it the process environment in one call.Not done
access_secret. Existing behaviour for every secret, described above, and not changed here.How it is tested
crates/temper-server/src/secrets/environment/tests/, 19 unit tests in four files (authorization, naming, precedence, reporting). The two authorization tests seed a vault, attach it to aServerState, load Cedar policies for the tenant, and read throughauthorized_wasm_secret_resolverwithCedarWasmAuthzGate: the lookup a module'sget_secretcall goes through. The logging tests capture every log line and span of the seeding atTRACElevel.crates/temper-cli/tests/serve_environment_secrets.rs, 2 tests. They start the builttemper serveas a process with a clean environment, an empty home and an empty working directory, wait until it listens, and assert on what it wrote.main(see check 7).Acceptance checks
All commands were run locally on the content of the final commit of this branch.
TEMPER_SECRET_BUILD_TOKEN=abcset, the secretbuild_tokenresolves toabcfor a module that is permitted to read it; the test fails first because the secret does not existpermitted_module_reads_a_secret_seeded_from_a_prefixed_variable. Before the seeding existed (an empty function of the same signature):left: Err("secret not found: build_token"),right: Ok("abc"). After it: passes.build_tokenis still refusedmodule_without_permission_is_still_refused_a_seeded_secret(a module the policies do not name:authorization denied for secret 'build_token') andpermitted_module_is_refused_a_seeded_secret_its_policy_does_not_name.several_prefixed_variables_are_all_seeded,seeded_secret_reaches_every_tenant.empty_value_seeds_nothing_and_is_not_reported.badly_named_variables_are_skipped_and_each_reported_once_by_name(seven names, among themTEMPER_SECRET_alone, a lower-case letter and a dash; the well-named variable beside them is still seeded). Against the real process:prefixed_variables_are_reported_by_count_and_bad_names_once_and_the_server_starts.ANTHROPIC_API_KEYset,anthropic_api_keyresolves as it does today, with or withoutTEMPER_SECRET_ANTHROPIC_API_KEYname_the_server_already_set_keeps_its_value. The process test shows start-up applies it in that order: with both set,TEMPER_SECRET_ANTHROPIC_API_KEYis the variable reported as skipped.prefixed_form_of_a_fixed_name_is_seeded_when_the_server_has_not_set_itcovers the other case.TEMPER_SECRET_variable, start-up output is identical to before the changeno_prefixed_variable_seeds_nothing_and_logs_nothing,variables_without_the_prefix_are_ignored, and the process teststart_up_without_a_prefixed_variable_reports_nothing_about_them. By hand:temper serve --port 0 --no-observebuilt frommainat0d7ae427and from this branch, same clean environment; 457 lines of stdout and 26 of stderr each, identical once timestamps, the port and the temporary directory are normalised and the lines sorted.no_report_log_line_or_span_contains_a_valueruns every outcome (seeded, badly named, already set, too large, over the budget) and searches the report and the captured log, with span events on. The process test searches the server's whole stdout and stderr for the five values it set.stored_tenant_secret_wins_over_a_seeded_one_for_that_tenant_only: stored before or after the seeding, the tenant's own secret wins for that tenant, other tenants read the seeded one, and removing the stored one uncovers it. Stated under "Precedence" in the guide.cargo fmt --check,cargo check --workspace,cargo clippy --workspace --all-targets -- -D warnings, the readability ratchet, the storage dispatch boundary check, the TODO andunwrap()scans, the dependency isolation check,check_instrumentationandcargo test --doc --workspaceall pass.cargo nextest run --workspace -E 'not test(dst_)', the command CI uses: 3526 tests run, 3526 passed, 65 skipped. The three observe-gatedcargo testcommands,cargo test --locked -p temper-cli verify::and the four simulation suites CI runs on a pull request pass. CI passed on the first three commits; it has not yet run on the three that follow.docs/AGENT_GUIDE.md, and the module documentation oftemper_server::secrets::environment.Also covered: a value or a name that is not UTF-8 (
value_that_is_not_utf8_is_skipped_and_reported_by_name,name_that_is_not_utf8_is_skipped_and_reported), a value over the size limit (value_over_the_size_limit_is_skipped_and_reported_by_name), the template route (integration_config_template_resolves_a_seeded_secret_like_any_other), the platform budget (variables_over_the_platform_budget_are_skipped_in_name_order, which lists the variables in reverse to show the outcome follows the names), and the log line itself (log_gives_the_count_of_seeded_secrets_and_no_names).Lint baseline against the result
mainat0d7ae427cargo fmt --checkcargo clippy --workspace --all-targets -- -D warningsPROD_PRINTLN_COUNT245,PROD_FILES_GT50078,PROD_FILES_GT100024,PROD_UNWRAP_CI_OK_COUNT126)PROD_FILES_GT300unwrap()scans, dependency isolation, storage dispatch boundary,check_instrumentationcrates/temper-cli/src/serve/mod.rsgoes from 992 to 997 lines; the rule and its tests are in new files.Departures from the plan
temper-server, not intemper serve. The plan was to seed throughcache_platform_secret_if_present, the private helper intemper-clithat the two fixed variables use. The new function callsSecretsVault::cache_platform_secretdirectly, which is all that helper does after unwrapping anOption.temper-clihas no logging dependency and none is added, and the authorization tests need the server's own resolver.temper serve, and was then seen to fail with the call removed (left: 0, right: 1on the count line). The size-limit test was seen to fail before the check (left: ["at_limit", "over_limit"]).After the first review
Three commits answer the automated review of the first three:
test(secrets): split the environment seeding tests by topic: the test file was 525 lines, over the 500-line rule. It moves the tests without changing them.fix(secrets): skip a TEMPER_SECRET_ value over the secret size limit.docs(secrets): say what each way of reading a seeded secret checks: the guide said "authorization is unchanged" next to a template example, which read as if the permit covered both routes. The guide and the module documentation now say what each route checks.🤖 Generated with Claude Code
The PR does not appear safe to merge while an integration template can pass a seeded secret to a module whose policy denies access to it.
Fix with agent prompt
Summary
The PR seeds non-empty
TEMPER_SECRET_<NAME>variables into the in-memory platform secrets layer at startup.Reviews (2) · Last reviewed commit: "docs(secrets): say what each way of read..."