Skip to content

Commit 99a3e74

Browse files
authored
Add provider-neutral secret store support (#230)
Add a provider-neutral runtime secret store so handlers can read secrets consistently across Fastly, Cloudflare, and Axum.
1 parent 4664bee commit 99a3e74

File tree

31 files changed

+2072
-48
lines changed

31 files changed

+2072
-48
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ target/
1414
# OS
1515
.DS_Store
1616

17+
# Worktrees
18+
.worktrees/
19+
20+
# Superpowers plans
21+
docs/superpowers/
22+
1723
# Editors
1824
.claude/*
1925
!.claude/settings.json

crates/edgezero-adapter-axum/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ walkdir = { workspace = true, optional = true }
5050
[dev-dependencies]
5151
async-trait = { workspace = true }
5252
axum = { workspace = true, features = ["macros"] }
53+
edgezero-core = { path = "../edgezero-core", features = ["test-utils"] }
5354
serde = { workspace = true }
5455
tempfile = { workspace = true }
5556
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }

crates/edgezero-adapter-axum/src/dev_server.rs

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,12 @@ async fn serve_with_listener(
180180
listener: tokio::net::TcpListener,
181181
enable_ctrl_c: bool,
182182
) -> anyhow::Result<()> {
183-
// No KV store is attached here — this path is used by `AxumDevServer::run()`
184-
// which is the manifest-unaware embedding API. Callers that need KV should
185-
// use `run_app()` (manifest-driven) or attach a `KvHandle` directly via
186-
// `EdgeZeroAxumService::with_kv_handle`.
183+
// No KV store or secret store is attached here — this path is used by
184+
// `AxumDevServer::run()`, which is the manifest-unaware embedding API.
185+
// Callers that need KV should use `run_app()` (manifest-driven) or attach
186+
// a `KvHandle` directly via `EdgeZeroAxumService::with_kv_handle`.
187+
// Callers that need secrets should use `run_app()` or attach a
188+
// `SecretHandle` directly via `EdgeZeroAxumService::with_secret_handle`.
187189
serve_with_listener_and_kv_path(router, listener, enable_ctrl_c, None).await
188190
}
189191

@@ -196,19 +198,23 @@ async fn serve_with_listener_and_kv_path(
196198
let kv_handle = kv_path
197199
.map(|kv_path| kv_handle_from_path(Path::new(kv_path)))
198200
.transpose()?;
199-
serve_with_listener_and_kv_handle(router, listener, enable_ctrl_c, kv_handle).await
201+
serve_with_listener_and_stores(router, listener, enable_ctrl_c, kv_handle, None).await
200202
}
201203

202-
async fn serve_with_listener_and_kv_handle(
204+
async fn serve_with_listener_and_stores(
203205
router: RouterService,
204206
listener: tokio::net::TcpListener,
205207
enable_ctrl_c: bool,
206208
kv_handle: Option<edgezero_core::key_value_store::KvHandle>,
209+
secret_handle: Option<edgezero_core::secret_store::SecretHandle>,
207210
) -> anyhow::Result<()> {
208211
let mut service = EdgeZeroAxumService::new(router);
209212
if let Some(kv_handle) = kv_handle {
210213
service = service.with_kv_handle(kv_handle);
211214
}
215+
if let Some(secret_handle) = secret_handle {
216+
service = service.with_secret_handle(secret_handle);
217+
}
212218

213219
let service = service;
214220
let router = Router::new().fallback_service(service_fn(move |req| {
@@ -243,6 +249,7 @@ pub fn run_app<A: Hooks>(manifest_src: &str) -> anyhow::Result<()> {
243249
let kv_init_requirement = kv_init_requirement(manifest);
244250
let kv_store_name = manifest.kv_store_name("axum").to_string();
245251
let kv_path = kv_store_path(&kv_store_name);
252+
let has_secret_store = manifest.secret_store_enabled("axum");
246253

247254
let level: LevelFilter = logging.level.into();
248255
let level = if logging.echo_stdout.unwrap_or(true) {
@@ -294,7 +301,22 @@ pub fn run_app<A: Hooks>(manifest_src: &str) -> anyhow::Result<()> {
294301
}
295302
}
296303
};
297-
serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await
304+
let secret_handle = if has_secret_store {
305+
log::info!("Secret store: reading from environment variables");
306+
Some(edgezero_core::secret_store::SecretHandle::new(
307+
std::sync::Arc::new(crate::secret_store::EnvSecretStore::new()),
308+
))
309+
} else {
310+
None
311+
};
312+
serve_with_listener_and_stores(
313+
router,
314+
listener,
315+
config.enable_ctrl_c,
316+
kv_handle,
317+
secret_handle,
318+
)
319+
.await
298320
})
299321
}
300322

@@ -427,8 +449,10 @@ name = "EDGEZERO_KV"
427449
#[cfg(test)]
428450
mod integration_tests {
429451
use super::*;
452+
use edgezero_core::action;
430453
use edgezero_core::context::RequestContext;
431454
use edgezero_core::error::EdgeError;
455+
use edgezero_core::extractor::Secrets;
432456
use edgezero_core::router::RouterService;
433457
use std::time::{Duration, Instant};
434458

@@ -781,4 +805,117 @@ mod integration_tests {
781805

782806
server.handle.abort();
783807
}
808+
809+
// -----------------------------------------------------------------------
810+
// Secret store helpers
811+
// -----------------------------------------------------------------------
812+
813+
struct TestServerSecrets {
814+
base_url: String,
815+
handle: tokio::task::JoinHandle<()>,
816+
}
817+
818+
async fn start_test_server_with_secret_handle(
819+
router: RouterService,
820+
secret_handle: Option<edgezero_core::secret_store::SecretHandle>,
821+
) -> TestServerSecrets {
822+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
823+
.await
824+
.expect("bind secrets test server");
825+
let addr = listener.local_addr().expect("local addr");
826+
let handle = tokio::spawn(async move {
827+
let _ =
828+
super::serve_with_listener_and_stores(router, listener, false, None, secret_handle)
829+
.await;
830+
});
831+
TestServerSecrets {
832+
base_url: format!("http://{}", addr),
833+
handle,
834+
}
835+
}
836+
837+
#[action]
838+
async fn secret_value_handler(Secrets(store): Secrets) -> Result<String, EdgeError> {
839+
store
840+
.require_str("test-store", "API_KEY")
841+
.await
842+
.map_err(EdgeError::from)
843+
}
844+
845+
// -----------------------------------------------------------------------
846+
// Secret store integration tests
847+
// -----------------------------------------------------------------------
848+
849+
#[tokio::test(flavor = "multi_thread")]
850+
async fn secret_present_returns_value() {
851+
use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle};
852+
use std::sync::Arc;
853+
854+
let router = RouterService::builder()
855+
.get("/secret", secret_value_handler)
856+
.build();
857+
let store =
858+
InMemorySecretStore::new([("test-store/API_KEY", bytes::Bytes::from("s3cr3t"))]);
859+
let handle = SecretHandle::new(Arc::new(store));
860+
let server = start_test_server_with_secret_handle(router, Some(handle)).await;
861+
862+
let client = reqwest::Client::new();
863+
let url = format!("{}/secret", server.base_url);
864+
let response = send_with_retry(&client, |c| c.get(url.as_str())).await;
865+
866+
assert_eq!(response.status(), reqwest::StatusCode::OK);
867+
assert_eq!(response.text().await.unwrap(), "s3cr3t");
868+
869+
server.handle.abort();
870+
}
871+
872+
#[tokio::test(flavor = "multi_thread")]
873+
async fn secret_missing_returns_500() {
874+
use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle};
875+
use std::sync::Arc;
876+
877+
let router = RouterService::builder()
878+
.get("/secret", secret_value_handler)
879+
.build();
880+
let store = InMemorySecretStore::new(std::iter::empty::<(&str, bytes::Bytes)>());
881+
let handle = SecretHandle::new(Arc::new(store));
882+
let server = start_test_server_with_secret_handle(router, Some(handle)).await;
883+
884+
let client = reqwest::Client::new();
885+
let url = format!("{}/secret", server.base_url);
886+
let response = send_with_retry(&client, |c| c.get(url.as_str())).await;
887+
888+
assert_eq!(
889+
response.status(),
890+
reqwest::StatusCode::INTERNAL_SERVER_ERROR
891+
);
892+
let body = response.text().await.unwrap();
893+
assert!(!body.contains("API_KEY"));
894+
assert!(body.contains("required secret is not configured"));
895+
896+
server.handle.abort();
897+
}
898+
899+
#[tokio::test(flavor = "multi_thread")]
900+
async fn no_secret_store_configured_returns_500() {
901+
let router = RouterService::builder()
902+
.get("/secret", secret_value_handler)
903+
.build();
904+
let server = start_test_server_with_secret_handle(router, None).await;
905+
906+
let client = reqwest::Client::new();
907+
let url = format!("{}/secret", server.base_url);
908+
let response = send_with_retry(&client, |c| c.get(url.as_str())).await;
909+
910+
assert_eq!(
911+
response.status(),
912+
reqwest::StatusCode::INTERNAL_SERVER_ERROR
913+
);
914+
let body = response.text().await.unwrap();
915+
assert!(body.contains(
916+
"no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings"
917+
));
918+
919+
server.handle.abort();
920+
}
784921
}

crates/edgezero-adapter-axum/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@ mod request;
1313
#[cfg(feature = "axum")]
1414
mod response;
1515
#[cfg(feature = "axum")]
16+
pub mod secret_store;
17+
#[cfg(feature = "axum")]
1618
mod service;
1719

1820
#[cfg(feature = "cli")]
1921
pub mod cli;
2022

23+
#[cfg(test)]
24+
pub mod test_utils;
25+
2126
#[cfg(feature = "axum")]
2227
pub use context::AxumRequestContext;
2328
#[cfg(feature = "axum")]
@@ -31,4 +36,6 @@ pub use request::into_core_request;
3136
#[cfg(feature = "axum")]
3237
pub use response::into_axum_response;
3338
#[cfg(feature = "axum")]
39+
pub use secret_store::EnvSecretStore;
40+
#[cfg(feature = "axum")]
3441
pub use service::EdgeZeroAxumService;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//! Environment variable secret store for local development.
2+
//!
3+
//! Reads secrets from the process environment. Set secrets as environment
4+
//! variables before starting the dev server:
5+
//!
6+
//! ```bash
7+
//! API_KEY=mysecret cargo edgezero dev
8+
//! ```
9+
10+
use async_trait::async_trait;
11+
use bytes::Bytes;
12+
use edgezero_core::secret_store::{SecretError, SecretStore};
13+
14+
/// Secret store for local development that reads secrets from environment variables.
15+
///
16+
/// When `[stores.secrets]` is declared in `edgezero.toml`, the dev server
17+
/// creates an `EnvSecretStore` that reads secrets from the process environment.
18+
pub struct EnvSecretStore;
19+
20+
impl EnvSecretStore {
21+
pub fn new() -> Self {
22+
Self
23+
}
24+
}
25+
26+
impl Default for EnvSecretStore {
27+
fn default() -> Self {
28+
Self::new()
29+
}
30+
}
31+
32+
#[async_trait(?Send)]
33+
impl SecretStore for EnvSecretStore {
34+
async fn get_bytes(&self, _store_name: &str, key: &str) -> Result<Option<Bytes>, SecretError> {
35+
#[cfg(unix)]
36+
{
37+
use std::os::unix::ffi::OsStringExt;
38+
39+
match std::env::var_os(key) {
40+
Some(value) => Ok(Some(Bytes::from(value.into_vec()))),
41+
None => Ok(None),
42+
}
43+
}
44+
45+
#[cfg(not(unix))]
46+
{
47+
match std::env::var(key) {
48+
Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))),
49+
Err(std::env::VarError::NotPresent) => Ok(None),
50+
Err(std::env::VarError::NotUnicode(_)) => Err(SecretError::Internal(
51+
anyhow::anyhow!("secret store returned an invalid Unicode value"),
52+
)),
53+
}
54+
}
55+
}
56+
}
57+
58+
#[cfg(test)]
59+
mod tests {
60+
use super::*;
61+
use crate::test_utils::{env_guard, EnvOverride};
62+
use bytes::Bytes;
63+
#[cfg(unix)]
64+
use std::ffi::OsString;
65+
66+
#[tokio::test(flavor = "current_thread")]
67+
async fn get_bytes_returns_none_when_var_not_set() {
68+
let _guard = env_guard().lock().await;
69+
let _env = EnvOverride::clear("__EDGEZERO_TEST_MISSING_VAR_XYZ__");
70+
let store = EnvSecretStore::new();
71+
let result = store
72+
.get_bytes("env", "__EDGEZERO_TEST_MISSING_VAR_XYZ__")
73+
.await
74+
.unwrap();
75+
assert!(result.is_none());
76+
}
77+
78+
#[tokio::test(flavor = "current_thread")]
79+
async fn get_bytes_returns_value_when_var_set() {
80+
let _guard = env_guard().lock().await;
81+
let _env = EnvOverride::set("__EDGEZERO_TEST_SECRET__", "test_value_123");
82+
let store = EnvSecretStore::new();
83+
let result = store
84+
.get_bytes("env", "__EDGEZERO_TEST_SECRET__")
85+
.await
86+
.unwrap();
87+
assert_eq!(result, Some(Bytes::from("test_value_123")));
88+
}
89+
90+
#[cfg(unix)]
91+
#[tokio::test(flavor = "current_thread")]
92+
async fn get_bytes_preserves_non_utf8_secret_values() {
93+
use std::os::unix::ffi::OsStringExt;
94+
95+
let _guard = env_guard().lock().await;
96+
let _env = EnvOverride::set(
97+
"__EDGEZERO_TEST_BINARY_SECRET__",
98+
OsString::from_vec(vec![0xff, 0x61]),
99+
);
100+
let store = EnvSecretStore::new();
101+
let result = store
102+
.get_bytes("env", "__EDGEZERO_TEST_BINARY_SECRET__")
103+
.await
104+
.unwrap();
105+
assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61])));
106+
}
107+
108+
// Contract tests: use InMemorySecretStoreProvider since EnvSecretStore needs
109+
// real env vars, which are unsafe in parallel tests.
110+
// The EnvSecretStore is tested individually above.
111+
use edgezero_core::secret_store_contract_tests;
112+
113+
secret_store_contract_tests!(env_secret_contract, {
114+
edgezero_core::InMemorySecretStore::new([
115+
("mystore/contract_key", Bytes::from("contract_value")),
116+
("mystore/contract_key_2", Bytes::from("another_value")),
117+
])
118+
});
119+
}

0 commit comments

Comments
 (0)