diff --git a/.gitignore b/.gitignore index 515cbf4..48a5ede 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,12 @@ target/ # OS .DS_Store +# Worktrees +.worktrees/ + +# Superpowers plans +docs/superpowers/ + # Editors .claude/* !.claude/settings.json diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 10356ee..9f9b3c9 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -50,6 +50,7 @@ walkdir = { workspace = true, optional = true } [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } serde = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index a984cdb..788398b 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -180,10 +180,12 @@ async fn serve_with_listener( listener: tokio::net::TcpListener, enable_ctrl_c: bool, ) -> anyhow::Result<()> { - // No KV store is attached here — this path is used by `AxumDevServer::run()` - // which is the manifest-unaware embedding API. Callers that need KV should - // use `run_app()` (manifest-driven) or attach a `KvHandle` directly via - // `EdgeZeroAxumService::with_kv_handle`. + // No KV store or secret store is attached here — this path is used by + // `AxumDevServer::run()`, which is the manifest-unaware embedding API. + // Callers that need KV should use `run_app()` (manifest-driven) or attach + // a `KvHandle` directly via `EdgeZeroAxumService::with_kv_handle`. + // Callers that need secrets should use `run_app()` or attach a + // `SecretHandle` directly via `EdgeZeroAxumService::with_secret_handle`. serve_with_listener_and_kv_path(router, listener, enable_ctrl_c, None).await } @@ -196,19 +198,23 @@ async fn serve_with_listener_and_kv_path( let kv_handle = kv_path .map(|kv_path| kv_handle_from_path(Path::new(kv_path))) .transpose()?; - serve_with_listener_and_kv_handle(router, listener, enable_ctrl_c, kv_handle).await + serve_with_listener_and_stores(router, listener, enable_ctrl_c, kv_handle, None).await } -async fn serve_with_listener_and_kv_handle( +async fn serve_with_listener_and_stores( router: RouterService, listener: tokio::net::TcpListener, enable_ctrl_c: bool, kv_handle: Option, + secret_handle: Option, ) -> anyhow::Result<()> { let mut service = EdgeZeroAxumService::new(router); if let Some(kv_handle) = kv_handle { service = service.with_kv_handle(kv_handle); } + if let Some(secret_handle) = secret_handle { + service = service.with_secret_handle(secret_handle); + } let service = service; let router = Router::new().fallback_service(service_fn(move |req| { @@ -243,6 +249,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let kv_init_requirement = kv_init_requirement(manifest); let kv_store_name = manifest.kv_store_name("axum").to_string(); let kv_path = kv_store_path(&kv_store_name); + let has_secret_store = manifest.secret_store_enabled("axum"); let level: LevelFilter = logging.level.into(); let level = if logging.echo_stdout.unwrap_or(true) { @@ -294,7 +301,22 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { } } }; - serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await + let secret_handle = if has_secret_store { + log::info!("Secret store: reading from environment variables"); + Some(edgezero_core::secret_store::SecretHandle::new( + std::sync::Arc::new(crate::secret_store::EnvSecretStore::new()), + )) + } else { + None + }; + serve_with_listener_and_stores( + router, + listener, + config.enable_ctrl_c, + kv_handle, + secret_handle, + ) + .await }) } @@ -427,8 +449,10 @@ name = "EDGEZERO_KV" #[cfg(test)] mod integration_tests { use super::*; + use edgezero_core::action; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; + use edgezero_core::extractor::Secrets; use edgezero_core::router::RouterService; use std::time::{Duration, Instant}; @@ -781,4 +805,117 @@ mod integration_tests { server.handle.abort(); } + + // ----------------------------------------------------------------------- + // Secret store helpers + // ----------------------------------------------------------------------- + + struct TestServerSecrets { + base_url: String, + handle: tokio::task::JoinHandle<()>, + } + + async fn start_test_server_with_secret_handle( + router: RouterService, + secret_handle: Option, + ) -> TestServerSecrets { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind secrets test server"); + let addr = listener.local_addr().expect("local addr"); + let handle = tokio::spawn(async move { + let _ = + super::serve_with_listener_and_stores(router, listener, false, None, secret_handle) + .await; + }); + TestServerSecrets { + base_url: format!("http://{}", addr), + handle, + } + } + + #[action] + async fn secret_value_handler(Secrets(store): Secrets) -> Result { + store + .require_str("test-store", "API_KEY") + .await + .map_err(EdgeError::from) + } + + // ----------------------------------------------------------------------- + // Secret store integration tests + // ----------------------------------------------------------------------- + + #[tokio::test(flavor = "multi_thread")] + async fn secret_present_returns_value() { + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; + use std::sync::Arc; + + let router = RouterService::builder() + .get("/secret", secret_value_handler) + .build(); + let store = + InMemorySecretStore::new([("test-store/API_KEY", bytes::Bytes::from("s3cr3t"))]); + let handle = SecretHandle::new(Arc::new(store)); + let server = start_test_server_with_secret_handle(router, Some(handle)).await; + + let client = reqwest::Client::new(); + let url = format!("{}/secret", server.base_url); + let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "s3cr3t"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn secret_missing_returns_500() { + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; + use std::sync::Arc; + + let router = RouterService::builder() + .get("/secret", secret_value_handler) + .build(); + let store = InMemorySecretStore::new(std::iter::empty::<(&str, bytes::Bytes)>()); + let handle = SecretHandle::new(Arc::new(store)); + let server = start_test_server_with_secret_handle(router, Some(handle)).await; + + let client = reqwest::Client::new(); + let url = format!("{}/secret", server.base_url); + let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + + assert_eq!( + response.status(), + reqwest::StatusCode::INTERNAL_SERVER_ERROR + ); + let body = response.text().await.unwrap(); + assert!(!body.contains("API_KEY")); + assert!(body.contains("required secret is not configured")); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn no_secret_store_configured_returns_500() { + let router = RouterService::builder() + .get("/secret", secret_value_handler) + .build(); + let server = start_test_server_with_secret_handle(router, None).await; + + let client = reqwest::Client::new(); + let url = format!("{}/secret", server.base_url); + let response = send_with_retry(&client, |c| c.get(url.as_str())).await; + + assert_eq!( + response.status(), + reqwest::StatusCode::INTERNAL_SERVER_ERROR + ); + let body = response.text().await.unwrap(); + assert!(body.contains( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )); + + server.handle.abort(); + } } diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index ef78ffe..c94ca65 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -13,11 +13,16 @@ mod request; #[cfg(feature = "axum")] mod response; #[cfg(feature = "axum")] +pub mod secret_store; +#[cfg(feature = "axum")] mod service; #[cfg(feature = "cli")] pub mod cli; +#[cfg(test)] +pub mod test_utils; + #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] @@ -31,4 +36,6 @@ pub use request::into_core_request; #[cfg(feature = "axum")] pub use response::into_axum_response; #[cfg(feature = "axum")] +pub use secret_store::EnvSecretStore; +#[cfg(feature = "axum")] pub use service::EdgeZeroAxumService; diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs new file mode 100644 index 0000000..1d216c8 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -0,0 +1,119 @@ +//! Environment variable secret store for local development. +//! +//! Reads secrets from the process environment. Set secrets as environment +//! variables before starting the dev server: +//! +//! ```bash +//! API_KEY=mysecret cargo edgezero dev +//! ``` + +use async_trait::async_trait; +use bytes::Bytes; +use edgezero_core::secret_store::{SecretError, SecretStore}; + +/// Secret store for local development that reads secrets from environment variables. +/// +/// When `[stores.secrets]` is declared in `edgezero.toml`, the dev server +/// creates an `EnvSecretStore` that reads secrets from the process environment. +pub struct EnvSecretStore; + +impl EnvSecretStore { + pub fn new() -> Self { + Self + } +} + +impl Default for EnvSecretStore { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl SecretStore for EnvSecretStore { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + match std::env::var_os(key) { + Some(value) => Ok(Some(Bytes::from(value.into_vec()))), + None => Ok(None), + } + } + + #[cfg(not(unix))] + { + match std::env::var(key) { + Ok(value) => Ok(Some(Bytes::from(value.into_bytes()))), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(SecretError::Internal( + anyhow::anyhow!("secret store returned an invalid Unicode value"), + )), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{env_guard, EnvOverride}; + use bytes::Bytes; + #[cfg(unix)] + use std::ffi::OsString; + + #[tokio::test(flavor = "current_thread")] + async fn get_bytes_returns_none_when_var_not_set() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::clear("__EDGEZERO_TEST_MISSING_VAR_XYZ__"); + let store = EnvSecretStore::new(); + let result = store + .get_bytes("env", "__EDGEZERO_TEST_MISSING_VAR_XYZ__") + .await + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn get_bytes_returns_value_when_var_set() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_TEST_SECRET__", "test_value_123"); + let store = EnvSecretStore::new(); + let result = store + .get_bytes("env", "__EDGEZERO_TEST_SECRET__") + .await + .unwrap(); + assert_eq!(result, Some(Bytes::from("test_value_123"))); + } + + #[cfg(unix)] + #[tokio::test(flavor = "current_thread")] + async fn get_bytes_preserves_non_utf8_secret_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = env_guard().lock().await; + let _env = EnvOverride::set( + "__EDGEZERO_TEST_BINARY_SECRET__", + OsString::from_vec(vec![0xff, 0x61]), + ); + let store = EnvSecretStore::new(); + let result = store + .get_bytes("env", "__EDGEZERO_TEST_BINARY_SECRET__") + .await + .unwrap(); + assert_eq!(result, Some(Bytes::from_static(&[0xff, 0x61]))); + } + + // Contract tests: use InMemorySecretStoreProvider since EnvSecretStore needs + // real env vars, which are unsafe in parallel tests. + // The EnvSecretStore is tested individually above. + use edgezero_core::secret_store_contract_tests; + + secret_store_contract_tests!(env_secret_contract, { + edgezero_core::InMemorySecretStore::new([ + ("mystore/contract_key", Bytes::from("contract_value")), + ("mystore/contract_key_2", Bytes::from("another_value")), + ]) + }); +} diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index e273aea..a0099a9 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -11,6 +11,7 @@ use tower::Service; use edgezero_core::key_value_store::KvHandle; use edgezero_core::router::RouterService; +use edgezero_core::secret_store::SecretHandle; use crate::request::into_core_request; use crate::response::into_axum_response; @@ -20,6 +21,7 @@ use crate::response::into_axum_response; pub struct EdgeZeroAxumService { router: RouterService, kv_handle: Option, + secret_handle: Option, } impl EdgeZeroAxumService { @@ -27,6 +29,7 @@ impl EdgeZeroAxumService { Self { router, kv_handle: None, + secret_handle: None, } } @@ -39,6 +42,16 @@ impl EdgeZeroAxumService { self.kv_handle = Some(handle); self } + + /// Attach a shared secret store to this service. + /// + /// The handle is cloned into every request's extensions, making + /// the `Secrets` extractor available in handlers. + #[must_use] + pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { + self.secret_handle = Some(handle); + self + } } impl Service> for EdgeZeroAxumService { @@ -53,6 +66,7 @@ impl Service> for EdgeZeroAxumService { fn call(&mut self, request: Request) -> Self::Future { let router = self.router.clone(); let kv_handle = self.kv_handle.clone(); + let secret_handle = self.secret_handle.clone(); Box::pin(async move { let mut core_request = match into_core_request(request).await { Ok(req) => req, @@ -68,6 +82,10 @@ impl Service> for EdgeZeroAxumService { core_request.extensions_mut().insert(handle); } + if let Some(secret_handle) = secret_handle { + core_request.extensions_mut().insert(secret_handle); + } + let core_response = task::block_in_place(move || { Handle::current().block_on(router.oneshot(core_request)) }); @@ -80,6 +98,7 @@ impl Service> for EdgeZeroAxumService { #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{env_guard, EnvOverride}; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; @@ -142,6 +161,48 @@ mod tests { assert_eq!(&body[..], b"injected"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_secret_handle_injects_into_request() { + use crate::secret_store::EnvSecretStore; + use edgezero_core::secret_store::SecretHandle; + use std::sync::Arc; + + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_SERVICE_TEST_SECRET__", "injected_value"); + + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let router = RouterService::builder() + .get("/check", |ctx: RequestContext| async move { + let secrets = ctx + .secret_handle() + .expect("secret handle should be present"); + let val = secrets + .get_bytes("env", "__EDGEZERO_SERVICE_TEST_SECRET__") + .await + .unwrap() + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(val)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_secret_handle(handle); + + let request = Request::builder() + .uri("/check") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"injected_value"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_without_kv_handle_still_works() { let router = RouterService::builder() diff --git a/crates/edgezero-adapter-axum/src/test_utils.rs b/crates/edgezero-adapter-axum/src/test_utils.rs new file mode 100644 index 0000000..ce4e39d --- /dev/null +++ b/crates/edgezero-adapter-axum/src/test_utils.rs @@ -0,0 +1,43 @@ +use std::ffi::OsString; +use std::sync::OnceLock; +use tokio::sync::Mutex; + +/// Returns a process-wide mutex used to serialize tests that mutate environment variables. +/// +/// Both `secret_store` and `service` tests share this lock to avoid data races across +/// test threads when setting or clearing environment variables. +pub fn env_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) +} + +/// RAII guard that sets an environment variable for the duration of a test and +/// restores the original value (or removes the variable) on drop. +pub struct EnvOverride { + key: &'static str, + original: Option, +} + +impl EnvOverride { + pub fn set(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } + + pub fn clear(key: &'static str) -> Self { + let original = std::env::var_os(key); + std::env::remove_var(key); + Self { key, original } + } +} + +impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(ref original) = self.original { + std::env::set_var(self.key, original); + } else { + std::env::remove_var(self.key); + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 43f4f1e..aedf270 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -36,6 +36,7 @@ walkdir = { workspace = true, optional = true } wasm-bindgen-test = "0.3" [dev-dependencies] +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } web-sys = { version = "0.3", features = [ "Window", "Response", diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index ec28382..0752d38 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -13,13 +13,19 @@ mod proxy; mod request; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod response; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub mod secret_store; +mod store_handles; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use context::CloudflareRequestContext; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use proxy::CloudflareProxyClient; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use request::{dispatch, dispatch_with_kv, into_core_request, DEFAULT_KV_BINDING}; +pub use request::{ + dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, + into_core_request, DEFAULT_KV_BINDING, +}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; @@ -59,6 +65,11 @@ impl AppExt for edgezero_core::app::App { } } +/// Entry point for a Cloudflare Workers application. +/// +/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. +/// Callers previously using `run_app_with_manifest` can rename to `run_app` — +/// the signatures are identical. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub async fn run_app( manifest_src: &str, @@ -71,8 +82,11 @@ pub async fn run_app( let manifest = manifest_loader.manifest(); let kv_binding = manifest.kv_store_name("cloudflare"); let kv_required = manifest.stores.kv.is_some(); + let secrets_required = manifest.secret_store_enabled("cloudflare"); let app = A::build_app(); - dispatch_with_kv(&app, req, env, ctx, kv_binding, kv_required).await + let kv_handle = crate::request::resolve_kv_handle(&env, kv_binding, kv_required)?; + let secret_handle = crate::request::resolve_secret_handle(&env, secrets_required); + crate::request::dispatch_with_handles(&app, req, env, ctx, kv_handle, secret_handle).await } /// Deprecated: use [`run_app`] which now takes `manifest_src` directly. diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 86604d7..463fa28 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -3,6 +3,7 @@ use std::sync::{Mutex, OnceLock}; use crate::proxy::CloudflareProxyClient; use crate::response::from_core_response; +use crate::store_handles::insert_store_handles; use crate::CloudflareRequestContext; use edgezero_core::app::App; use edgezero_core::body::Body; @@ -10,6 +11,7 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Method as CoreMethod, Request, Uri}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::secret_store::SecretHandle; use worker::{ Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; @@ -75,10 +77,80 @@ pub async fn dispatch_with_kv( kv_binding: &str, kv_required: bool, ) -> Result { - // Try to open the KV binding from `env` before consuming it in `into_core_request`. - // We borrow `env` here; `into_core_request` takes ownership afterwards. - let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { - Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), + let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; + dispatch_with_handles(app, req, env, ctx, kv_handle, None).await +} + +/// Dispatch a Cloudflare Worker request with a secret store attached. +/// +/// Dispatch a Cloudflare Worker request with a secret store attached (no KV store). +/// +/// Use this when your application accesses secrets but does not need a KV store. +/// For applications that need both, use [`dispatch_with_kv_and_secrets`] instead. +/// +/// The store is only attached when `secrets_required` is `true`. +/// Individual missing secrets surface as `SecretError::NotFound` at access time. +pub async fn dispatch_with_secrets( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + secrets_required: bool, +) -> Result { + let secret_handle = resolve_secret_handle(&env, secrets_required); + dispatch_with_handles(app, req, env, ctx, None, secret_handle).await +} + +/// Dispatch a Cloudflare Worker request with both KV and secret stores attached. +/// +/// Note: Cloudflare secrets have no namespace concept, so no secret binding name is needed. +pub async fn dispatch_with_kv_and_secrets( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + kv_binding: &str, + kv_required: bool, + secrets_required: bool, +) -> Result { + let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; + let secret_handle = resolve_secret_handle(&env, secrets_required); + dispatch_with_handles(app, req, env, ctx, kv_handle, secret_handle).await +} + +pub(crate) async fn dispatch_with_handles( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + kv_handle: Option, + secret_handle: Option, +) -> Result { + let core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + dispatch_core_request(app, core_request, kv_handle, secret_handle).await +} + +async fn dispatch_core_request( + app: &App, + mut core_request: Request, + kv_handle: Option, + secret_handle: Option, +) -> Result { + insert_store_handles(&mut core_request, kv_handle, secret_handle); + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} + +pub(crate) fn resolve_kv_handle( + env: &Env, + kv_binding: &str, + kv_required: bool, +) -> Result, WorkerError> { + match crate::key_value_store::CloudflareKvStore::from_env(env, kv_binding) { + Ok(store) => Ok(Some(KvHandle::new(std::sync::Arc::new(store)))), Err(e) => { if kv_required { return Err(WorkerError::RustError(format!( @@ -87,21 +159,18 @@ pub async fn dispatch_with_kv( ))); } warn_missing_kv_binding_once(kv_binding, &e); - None + Ok(None) } - }; - - let mut core_request = into_core_request(req, env, ctx) - .await - .map_err(edge_error_to_worker)?; + } +} - if let Some(handle) = kv_handle { - core_request.extensions_mut().insert(handle); +pub(crate) fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { + if !secrets_required { + return None; } - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) + let secret_store = crate::secret_store::CloudflareSecretStore::from_env(env.clone()); + Some(SecretHandle::new(std::sync::Arc::new(secret_store))) } fn edge_error_to_worker(err: EdgeError) -> WorkerError { diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs new file mode 100644 index 0000000..e8c3bbe --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -0,0 +1,59 @@ +//! Cloudflare Workers secret adapter. +//! +//! Reads secrets from `worker::Env::secret()`. Each call to `get_bytes(name)` +//! invokes `env.secret(name)` to retrieve the value. The `Env` is cloned at +//! dispatch time to outlive `into_core_request`'s ownership of the original. +//! +//! Note: Cloudflare Workers Secrets have no namespace concept — each secret +//! is an individual `[vars]` / Secrets binding in `wrangler.toml`. The +//! `[stores.secrets] name` in `edgezero.toml` is used only for Fastly; +//! Cloudflare accesses all secrets via this adapter regardless of name. + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::secret_store::{SecretError, SecretStore}; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::Error as WorkerError; + +/// Secret store backed by Cloudflare Workers `Env`. +/// +/// Reads secrets via `env.secret(name)`. Clones the `Env` handle at dispatch +/// time so secrets remain accessible throughout the request lifetime. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub struct CloudflareSecretStore { + env: worker::Env, +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl CloudflareSecretStore { + /// Create a secret store from a cloned `Env`. + pub fn from_env(env: worker::Env) -> Self { + Self { env } + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SecretStore for CloudflareSecretStore { + async fn get_bytes(&self, _store_name: &str, key: &str) -> Result, SecretError> { + match self.env.secret(key) { + Ok(secret) => { + let value = secret.to_string(); + Ok(Some(Bytes::from(value.into_bytes()))) + } + Err(WorkerError::BindingError(_)) => Ok(None), + Err(WorkerError::JsError(message)) + if message.contains("does not contain binding") + || message.contains("is undefined") => + { + Ok(None) + } + Err(err) => Err(SecretError::Internal(anyhow::anyhow!( + "secret lookup failed: {err}" + ))), + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/store_handles.rs b/crates/edgezero-adapter-cloudflare/src/store_handles.rs new file mode 100644 index 0000000..7a98af8 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/store_handles.rs @@ -0,0 +1,63 @@ +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::http::Request; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::key_value_store::KvHandle; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::secret_store::SecretHandle; + +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +pub(crate) fn insert_store_handles( + request: &mut Request, + kv_handle: Option, + secret_handle: Option, +) { + if let Some(handle) = kv_handle { + request.extensions_mut().insert(handle); + } + + if let Some(handle) = secret_handle { + request.extensions_mut().insert(handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::NoopKvStore; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + #[test] + fn insert_store_handles_adds_present_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + let kv_handle = KvHandle::new(Arc::new(NoopKvStore)); + let secret_handle = SecretHandle::new(Arc::new(NoopSecretStore)); + + insert_store_handles( + &mut request, + Some(kv_handle.clone()), + Some(secret_handle.clone()), + ); + + assert!(request.extensions().get::().is_some()); + assert!(request.extensions().get::().is_some()); + } + + #[test] + fn insert_store_handles_skips_absent_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + + insert_store_handles(&mut request, None, None); + + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + } +} diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 192885d..279436e 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -165,3 +165,14 @@ async fn dispatch_passes_request_body_to_handlers() { let bytes = response.bytes().await.expect("bytes"); assert_eq!(bytes.as_slice(), b"echo"); } + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod secret_store_compile_check { + use edgezero_adapter_cloudflare::secret_store::CloudflareSecretStore; + use edgezero_core::secret_store::SecretStore; + + fn _assert_provider_impl() {} + fn _check() { + _assert_provider_impl::(); + } +} diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index fe112df..f052e57 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -37,4 +37,5 @@ chrono = { workspace = true } walkdir = { workspace = true, optional = true } [dev-dependencies] +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } tempfile = { workspace = true } diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 20ba5cd..167937f 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -14,14 +14,22 @@ mod proxy; mod request; #[cfg(feature = "fastly")] mod response; +#[cfg(feature = "fastly")] +pub mod secret_store; +mod store_handles; pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] pub use proxy::FastlyProxyClient; #[cfg(feature = "fastly")] -pub use request::{dispatch, dispatch_with_kv, into_core_request, DEFAULT_KV_STORE_NAME}; +pub use request::{ + dispatch, dispatch_with_kv, dispatch_with_kv_and_secrets, dispatch_with_secrets, + into_core_request, DEFAULT_KV_STORE_NAME, +}; #[cfg(feature = "fastly")] pub use response::from_core_response; +#[cfg(feature = "fastly")] +pub use secret_store::FastlySecretStore; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] @@ -74,6 +82,9 @@ impl AppExt for edgezero_core::app::App { } } +/// Entry point for a Fastly Compute application. +/// +/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. #[cfg(feature = "fastly")] pub fn run_app( manifest_src: &str, @@ -84,7 +95,8 @@ pub fn run_app( let logging = manifest.logging_or_default("fastly"); let kv_name = manifest.kv_store_name("fastly").to_string(); let kv_required = manifest.stores.kv.is_some(); - run_app_with_logging::(logging.into(), req, &kv_name, kv_required) + let secrets_required = manifest.secret_store_enabled("fastly"); + run_app_with_logging::(logging.into(), req, &kv_name, kv_required, secrets_required) } #[cfg(feature = "fastly")] @@ -93,6 +105,7 @@ pub(crate) fn run_app_with_logging( req: fastly::Request, kv_store_name: &str, kv_required: bool, + secrets_required: bool, ) -> Result { if logging.use_fastly_logger { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); @@ -100,7 +113,9 @@ pub(crate) fn run_app_with_logging( } let app = A::build_app(); - dispatch_with_kv(&app, req, kv_store_name, kv_required) + let kv_handle = crate::request::resolve_kv_handle(kv_store_name, kv_required)?; + let secret_handle = crate::request::resolve_secret_handle(secrets_required); + crate::request::dispatch_with_handles(&app, req, kv_handle, secret_handle) } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 670b698..82969e4 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -8,12 +8,14 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::secret_store::SecretHandle; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; use crate::key_value_store::FastlyKvStore; use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; +use crate::store_handles::insert_store_handles; use crate::FastlyRequestContext; /// Default Fastly KV Store name. @@ -65,26 +67,8 @@ pub fn dispatch_with_kv( kv_store_name: &str, kv_required: bool, ) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; - - match FastlyKvStore::open(kv_store_name) { - Ok(store) => { - let handle = KvHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { - if kv_required { - return Err(FastlyError::msg(format!( - "KV store '{}' is explicitly configured but could not be opened: {}", - kv_store_name, e - ))); - } - warn_missing_kv_store_once(kv_store_name, &e); - } - } - - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) + let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; + dispatch_with_handles(app, req, kv_handle, None) } fn map_edge_error(err: EdgeError) -> FastlyError { @@ -107,3 +91,75 @@ fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl std::fmt::Displa } } } + +/// Dispatch a Fastly request with a secret store attached. +pub fn dispatch_with_secrets( + app: &App, + req: FastlyRequest, + secrets_required: bool, +) -> Result { + let secret_handle = resolve_secret_handle(secrets_required); + dispatch_with_handles(app, req, None, secret_handle) +} + +/// Dispatch a Fastly request with both KV and secret stores attached. +pub fn dispatch_with_kv_and_secrets( + app: &App, + req: FastlyRequest, + kv_store_name: &str, + kv_required: bool, + secrets_required: bool, +) -> Result { + let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; + let secret_handle = resolve_secret_handle(secrets_required); + dispatch_with_handles(app, req, kv_handle, secret_handle) +} + +pub(crate) fn dispatch_with_handles( + app: &App, + req: FastlyRequest, + kv_handle: Option, + secret_handle: Option, +) -> Result { + let core_request = into_core_request(req).map_err(map_edge_error)?; + dispatch_core_request(app, core_request, kv_handle, secret_handle) +} + +fn dispatch_core_request( + app: &App, + mut core_request: Request, + kv_handle: Option, + secret_handle: Option, +) -> Result { + insert_store_handles(&mut core_request, kv_handle, secret_handle); + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + +pub(crate) fn resolve_kv_handle( + kv_store_name: &str, + kv_required: bool, +) -> Result, FastlyError> { + match FastlyKvStore::open(kv_store_name) { + Ok(store) => Ok(Some(KvHandle::new(std::sync::Arc::new(store)))), + Err(e) => { + if kv_required { + return Err(FastlyError::msg(format!( + "KV store '{}' is explicitly configured but could not be opened: {}", + kv_store_name, e + ))); + } + warn_missing_kv_store_once(kv_store_name, &e); + Ok(None) + } + } +} + +pub(crate) fn resolve_secret_handle(secrets_required: bool) -> Option { + if !secrets_required { + return None; + } + Some(SecretHandle::new(std::sync::Arc::new( + crate::secret_store::FastlySecretStore, + ))) +} diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs new file mode 100644 index 0000000..6458aa0 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -0,0 +1,75 @@ +//! Fastly secret store adapter. +//! +//! Implements `edgezero_core::secret_store::SecretStore` via +//! `FastlySecretStore`, which opens a named Fastly SecretStore on +//! each lookup. + +#[cfg(feature = "fastly")] +use async_trait::async_trait; +#[cfg(feature = "fastly")] +use bytes::Bytes; +#[cfg(feature = "fastly")] +use edgezero_core::secret_store::SecretError; + +/// Internal helper that opens a single named Fastly SecretStore. +#[cfg(feature = "fastly")] +pub struct FastlyNamedStore { + store: fastly::secret_store::SecretStore, +} + +#[cfg(feature = "fastly")] +impl FastlyNamedStore { + /// Open a Fastly SecretStore by name. + /// + /// Returns `SecretError::Internal` if the store does not exist or cannot + /// be opened. Unlike `KVStore::open`, the Fastly SecretStore API returns + /// `Result` (not `Result, _>`), so there + /// is no `ok_or` unwrap here. + pub fn open(name: &str) -> Result { + let store = fastly::secret_store::SecretStore::open(name).map_err(|e| { + SecretError::Internal(anyhow::anyhow!( + "failed to open secret store '{}': {e}", + name + )) + })?; + Ok(Self { store }) + } + + pub(crate) fn get_bytes_sync(&self, key: &str) -> Result, SecretError> { + let secret = self + .store + .try_get(key) + .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret lookup failed: {e}")))?; + + match secret { + Some(secret) => secret.try_plaintext().map(Some).map_err(|e| { + SecretError::Internal(anyhow::anyhow!("secret decryption failed: {e}")) + }), + None => Ok(None), + } + } +} + +/// Multi-store provider backed by Fastly's SecretStore API. +/// +/// Opens the named store per call — `FastlyNamedStore::open` is cheap +/// (no network; just a handle) so there is no caching. +#[cfg(feature = "fastly")] +pub struct FastlySecretStore; + +#[cfg(feature = "fastly")] +#[async_trait(?Send)] +impl edgezero_core::secret_store::SecretStore for FastlySecretStore { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, edgezero_core::secret_store::SecretError> { + let store = FastlyNamedStore::open(store_name)?; + store.get_bytes_sync(key) + } +} + +// TODO: integration tests require the Fastly compute environment. +// Test `FastlyNamedStore` and `FastlySecretStore` as part of the +// Fastly adapter E2E test suite. diff --git a/crates/edgezero-adapter-fastly/src/store_handles.rs b/crates/edgezero-adapter-fastly/src/store_handles.rs new file mode 100644 index 0000000..3e93c5b --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/store_handles.rs @@ -0,0 +1,63 @@ +#[cfg(any(test, feature = "fastly"))] +use edgezero_core::http::Request; +#[cfg(any(test, feature = "fastly"))] +use edgezero_core::key_value_store::KvHandle; +#[cfg(any(test, feature = "fastly"))] +use edgezero_core::secret_store::SecretHandle; + +#[cfg(any(test, feature = "fastly"))] +pub(crate) fn insert_store_handles( + request: &mut Request, + kv_handle: Option, + secret_handle: Option, +) { + if let Some(handle) = kv_handle { + request.extensions_mut().insert(handle); + } + + if let Some(handle) = secret_handle { + request.extensions_mut().insert(handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::NoopKvStore; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + #[test] + fn insert_store_handles_adds_present_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + let kv_handle = KvHandle::new(Arc::new(NoopKvStore)); + let secret_handle = SecretHandle::new(Arc::new(NoopSecretStore)); + + insert_store_handles( + &mut request, + Some(kv_handle.clone()), + Some(secret_handle.clone()), + ); + + assert!(request.extensions().get::().is_some()); + assert!(request.extensions().get::().is_some()); + } + + #[test] + fn insert_store_handles_skips_absent_handles() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + + insert_store_handles(&mut request, None, None); + + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + } +} diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index f3c25b3..c8e66d8 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -141,3 +141,14 @@ fn dispatch_passes_request_body_to_handlers() { assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"echo"); } + +#[cfg(all(feature = "fastly", target_arch = "wasm32"))] +mod secret_store_compile_check { + use edgezero_adapter_fastly::FastlySecretStore; + use edgezero_core::secret_store::SecretStore; + + fn _assert_provider_impl() {} + fn _check() { + _assert_provider_impl::(); + } +} diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index d562934..e5c7ae4 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -77,10 +77,43 @@ fn main() { eprintln!("edgezero-cli built without `cli` feature. Rebuild with `--features cli`."); } +#[cfg(feature = "cli")] +fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { + let m = manifest.manifest(); + if !m.secret_store_enabled(adapter_name) { + return None; + } + + let binding_name = m.secret_store_name(adapter_name); + let message = match adapter_name { + "axum" => format!( + "[edgezero] secrets enabled for axum -- ensure the required environment variables are set for local runs (configured store name: '{binding_name}')" + ), + "cloudflare" => format!( + "[edgezero] secrets enabled for cloudflare -- ensure the required secret bindings exist in wrangler (configured store name: '{binding_name}' is metadata only)" + ), + _ => format!( + "[edgezero] secret store '{binding_name}' enabled for {adapter_name} -- ensure it is provisioned on the target platform" + ), + }; + + Some(message) +} + +#[cfg(feature = "cli")] +fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { + if let Some(message) = store_bindings_message(adapter_name, manifest) { + println!("{message}"); + } +} + #[cfg(feature = "cli")] fn handle_build(adapter_name: &str, adapter_args: &[String]) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(adapter_name, manifest.as_ref())?; + if let Some(ref m) = manifest { + log_store_bindings(adapter_name, m); + } adapter::execute( adapter_name, adapter::Action::Build, @@ -289,4 +322,54 @@ serve = "echo serve" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); handle_serve("fastly").expect("serve command runs"); } + + #[test] + fn secret_store_name_is_readable_from_manifest() { + let manifest_with_secrets = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[stores.secrets] +name = "MY_SECRETS" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + let loader = ManifestLoader::load_from_str(manifest_with_secrets); + assert_eq!(loader.manifest().secret_store_name("fastly"), "MY_SECRETS"); + assert!(loader.manifest().stores.secrets.is_some()); + } + + #[test] + fn store_bindings_message_is_adapter_specific() { + let loader = ManifestLoader::load_from_str( + r#" +[stores.secrets] +name = "MY_SECRETS" +"#, + ); + + let axum = store_bindings_message("axum", &loader).expect("axum message"); + assert!(axum.contains("environment variables")); + + let cloudflare = store_bindings_message("cloudflare", &loader).expect("cloudflare message"); + assert!(cloudflare.contains("wrangler")); + + let fastly = store_bindings_message("fastly", &loader).expect("fastly message"); + assert!(fastly.contains("secret store 'MY_SECRETS'")); + } + + #[test] + fn store_bindings_message_respects_secret_store_enabled() { + let loader = ManifestLoader::load_from_str( + r#" +[stores.secrets] +enabled = false +"#, + ); + assert!(store_bindings_message("fastly", &loader).is_none()); + } } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 67efdef..8103dff 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -4,6 +4,7 @@ use crate::http::Request; use crate::key_value_store::KvHandle; use crate::params::PathParams; use crate::proxy::ProxyHandle; +use crate::secret_store::SecretHandle; use serde::de::DeserializeOwned; /// Request context exposed to handlers and middleware. @@ -85,9 +86,15 @@ impl RequestContext { self.request.extensions().get::().cloned() } + /// Returns the KV store handle if one was configured for this request. pub fn kv_handle(&self) -> Option { self.request.extensions().get::().cloned() } + + /// Returns the secret store handle if one was configured for this request. + pub fn secret_handle(&self) -> Option { + self.request.extensions().get::().cloned() + } } #[cfg(test)] @@ -350,4 +357,28 @@ mod tests { let ctx = ctx("/test", Body::empty(), PathParams::default()); assert!(ctx.kv_handle().is_none()); } + + #[test] + fn secret_handle_is_retrieved_when_present() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.secret_handle().is_some()); + } + + #[test] + fn secret_handle_returns_none_when_absent() { + let ctx = ctx("/test", Body::empty(), PathParams::default()); + assert!(ctx.secret_handle().is_none()); + } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 2c58d9b..0d9e156 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -448,6 +448,55 @@ impl Kv { } } +/// Extracts the [`SecretHandle`] from the request context. +/// +/// Returns `EdgeError::Internal` if no secret store was configured for this request. +/// +/// # Example +/// ```ignore +/// #[action] +/// pub async fn handler(Secrets(secrets): Secrets) -> Result { +/// let key = secrets.require_str("api-keys", "API_KEY").await.map_err(EdgeError::from)?; +/// // use key ... +/// } +/// ``` +#[derive(Debug)] +pub struct Secrets(pub crate::secret_store::SecretHandle); + +#[async_trait(?Send)] +impl FromRequest for Secrets { + async fn from_request(ctx: &RequestContext) -> Result { + // ctx.secret_handle() returns a handle object, not secret bytes. + // The error message below contains only store configuration info — no secret values + // are included, so this is safe from a cleartext-logging perspective. + ctx.secret_handle().map(Secrets).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )) + }) + } +} + +impl std::ops::Deref for Secrets { + type Target = crate::secret_store::SecretHandle; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Secrets { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Secrets { + pub fn into_inner(self) -> crate::secret_store::SecretHandle { + self.0 + } +} + #[cfg(test)] mod tests { use super::*; @@ -1009,4 +1058,36 @@ mod tests { // into_inner works let _inner: KvHandle = kv.into_inner(); } + + // -- Secrets extractor -------------------------------------------------- + + #[test] + fn secrets_extractor_returns_handle_when_present() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + let ctx = RequestContext::new(request, PathParams::default()); + let result = block_on(Secrets::from_request(&ctx)); + assert!(result.is_ok()); + } + + #[test] + fn secrets_extractor_errors_when_absent() { + let request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } } diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index bc6fe81..bb01dca 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -16,6 +16,12 @@ pub mod proxy; pub mod responder; pub mod response; pub mod router; +pub mod secret_store; pub use edgezero_macros::{action, app}; +#[cfg(any(test, feature = "test-utils"))] +pub use key_value_store::NoopKvStore; pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; +#[cfg(any(test, feature = "test-utils"))] +pub use secret_store::{InMemorySecretStore, NoopSecretStore}; +pub use secret_store::{SecretError, SecretHandle, SecretStore}; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 0efb690..a417e8f 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -142,6 +142,49 @@ impl Manifest { } } + /// Returns the secret store name for a given adapter. + /// + /// Resolution order: + /// 1. Per-adapter override (`[stores.secrets.adapters.]`) + /// 2. Global name (`[stores.secrets] name = "..."`) + /// 3. Default: `"EDGEZERO_SECRETS"` + pub fn secret_store_name(&self, adapter: &str) -> &str { + match &self.stores.secrets { + Some(secrets) => { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(adapter_cfg) = secrets + .adapters + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) + { + if let Some(name) = adapter_cfg.1.name.as_deref() { + return name; + } + } + &secrets.name + } + None => DEFAULT_SECRET_STORE_NAME, + } + } + + /// Returns whether the secret store should be attached for a given adapter. + pub fn secret_store_enabled(&self, adapter: &str) -> bool { + match &self.stores.secrets { + Some(secrets) => { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(adapter_cfg) = secrets + .adapters + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) + { + return adapter_cfg.1.enabled; + } + secrets.enabled + } + None => false, + } + } + fn finalize(&mut self) { let mut resolved = BTreeMap::new(); @@ -397,6 +440,17 @@ fn default_kv_name() -> String { DEFAULT_KV_STORE_NAME.to_string() } +/// Default secret store / binding name used when `[stores.secrets]` is omitted. +pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; + +fn default_secret_name() -> String { + DEFAULT_SECRET_STORE_NAME.to_string() +} + +fn default_enabled() -> bool { + true +} + /// Configuration for external stores (e.g., KV, object storage). /// /// ```toml @@ -413,6 +467,12 @@ pub struct ManifestStores { #[serde(default)] #[validate(nested)] pub kv: Option, + + /// Secret store configuration. When absent, the default + /// name `EDGEZERO_SECRETS` is used. + #[serde(default)] + #[validate(nested)] + pub secrets: Option, } /// Global KV store configuration. @@ -436,6 +496,37 @@ pub struct ManifestKvAdapterConfig { pub name: String, } +/// Global secret store configuration. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestSecretsConfig { + /// Whether the secret store is enabled for adapters without overrides. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Store / binding name (default: `"EDGEZERO_SECRETS"`). + #[serde(default = "default_secret_name")] + #[validate(length(min = 1))] + pub name: String, + + /// Per-adapter name overrides. + #[serde(default)] + #[validate(nested)] + pub adapters: BTreeMap, +} + +/// Per-adapter secret store name override. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestSecretsAdapterConfig { + /// Whether the secret store is enabled for this adapter. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Optional per-adapter secret store name override. + #[serde(default)] + #[validate(length(min = 1))] + pub name: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum HttpMethod { Get, @@ -1239,4 +1330,98 @@ name = "FASTLY_STORE" assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); } + + // -- Secret store config ----------------------------------------------- + + #[test] + fn secret_store_name_defaults_to_constant_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + DEFAULT_SECRET_STORE_NAME + ); + } + + #[test] + fn secret_store_name_uses_global_name_when_declared() { + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "MY_SECRETS" + ); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + "MY_SECRETS" + ); + } + + #[test] + fn secret_store_name_uses_per_adapter_override() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n\ + [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", + ); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "FASTLY_STORE" + ); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + "MY_SECRETS" + ); + } + + #[test] + fn secrets_required_is_false_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert!(manifest.manifest().stores.secrets.is_none()); + } + + #[test] + fn secrets_required_is_true_when_declared() { + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + assert!(manifest.manifest().stores.secrets.is_some()); + } + + #[test] + fn secret_store_enabled_is_false_when_absent() { + let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); + assert!(!manifest.manifest().secret_store_enabled("fastly")); + assert!(!manifest.manifest().secret_store_enabled("cloudflare")); + } + + #[test] + fn secret_store_enabled_is_true_when_declared() { + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + assert!(manifest.manifest().secret_store_enabled("fastly")); + assert!(manifest.manifest().secret_store_enabled("cloudflare")); + } + + #[test] + fn secret_store_enabled_can_be_disabled_per_adapter() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nname = \"MY_SECRETS\"\n\ + [stores.secrets.adapters.cloudflare]\nenabled = false\n", + ); + assert!(manifest.manifest().secret_store_enabled("fastly")); + assert!(!manifest.manifest().secret_store_enabled("cloudflare")); + } + + #[test] + fn secret_store_enabled_can_be_enabled_only_for_specific_adapter() { + let manifest = ManifestLoader::load_from_str( + "[stores.secrets]\nenabled = false\n\ + [stores.secrets.adapters.fastly]\nenabled = true\nname = \"FASTLY_STORE\"\n", + ); + assert!(manifest.manifest().secret_store_enabled("fastly")); + assert!(!manifest.manifest().secret_store_enabled("cloudflare")); + assert_eq!( + manifest.manifest().secret_store_name("fastly"), + "FASTLY_STORE" + ); + assert_eq!( + manifest.manifest().secret_store_name("cloudflare"), + DEFAULT_SECRET_STORE_NAME + ); + } } diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs new file mode 100644 index 0000000..5ecd699 --- /dev/null +++ b/crates/edgezero-core/src/secret_store.rs @@ -0,0 +1,460 @@ +//! Provider-neutral secret store abstraction. +//! +//! # Architecture +//! +//! ```text +//! Handler code SecretHandle (get_bytes / require_bytes / require_str) +//! │ │ +//! └── Secrets extractor ─►│ UTF-8 / bytes layer +//! │ +//! Arc +//! │ +//! ┌──────────────┼──────────────┐ +//! ▼ ▼ ▼ +//! EnvSecretStore FastlySecretStore CloudflareSecretStore +//! ``` +//! +//! Secrets are read-only — this API only retrieves values, +//! it never writes or deletes them. Provisioning secrets is the +//! responsibility of each platform's deployment toolchain. + +use std::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; + +use crate::error::EdgeError; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// Errors returned by secret store operations. +#[derive(Debug, thiserror::Error)] +pub enum SecretError { + /// The requested secret was not found. + #[error("secret not found: {name}")] + NotFound { name: String }, + + /// The secret store backend is temporarily unavailable. + #[error("secret store unavailable")] + Unavailable, + + /// A validation error (e.g., invalid secret name). + #[error("validation error: {0}")] + Validation(String), + + /// A general internal error. + #[error("secret store error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl From for EdgeError { + fn from(err: SecretError) -> Self { + match err { + SecretError::NotFound { .. } => { + EdgeError::internal(anyhow::anyhow!("required secret is not configured")) + } + SecretError::Unavailable => EdgeError::service_unavailable("secret store unavailable"), + SecretError::Validation(..) => { + EdgeError::internal(anyhow::anyhow!("secret lookup failed")) + } + SecretError::Internal(..) => { + EdgeError::internal(anyhow::anyhow!("secret store operation failed")) + } + } + } +} + +// --------------------------------------------------------------------------- +// Maximum name length +// --------------------------------------------------------------------------- + +/// Maximum length in bytes for any secret name or store name. +pub const MAX_NAME_LEN: usize = 512; + +// --------------------------------------------------------------------------- +// Multi-store provider trait +// --------------------------------------------------------------------------- + +/// Access secrets across multiple named stores. +/// +/// Platforms with a single flat namespace (env vars, in-memory test stores) +/// implement this by keying on `"{store_name}/{key}"`. +/// Platforms with named stores (Fastly, Spin) open a store-specific handle +/// per `store_name`. +#[async_trait(?Send)] +pub trait SecretStore: Send + Sync { + /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError>; +} + +// --------------------------------------------------------------------------- +// No-op provider (test-utils) +// --------------------------------------------------------------------------- + +/// A no-op [`SecretStore`] for tests that don't need secrets. +/// +/// All reads return `None`. +#[cfg(any(test, feature = "test-utils"))] +pub struct NoopSecretStore; + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for NoopSecretStore { + async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// In-memory provider (test-utils) +// --------------------------------------------------------------------------- + +/// An in-memory [`SecretStore`] keyed by `"{store_name}/{key}"`. +/// +/// Useful for contract tests and unit tests that need deterministic values +/// across multiple named stores. +#[cfg(any(test, feature = "test-utils"))] +pub struct InMemorySecretStore { + secrets: std::collections::HashMap, +} + +#[cfg(any(test, feature = "test-utils"))] +impl InMemorySecretStore { + /// Build with entries of the form `("{store_name}/{key}", value)`. + pub fn new(entries: impl IntoIterator, impl Into)>) -> Self { + Self { + secrets: entries + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl SecretStore for InMemorySecretStore { + async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError> { + let compound = format!("{store_name}/{key}"); + Ok(self.secrets.get(&compound).cloned()) + } +} + +// --------------------------------------------------------------------------- +// Provider handle +// --------------------------------------------------------------------------- + +/// A cloneable, ergonomic handle to a multi-store [`SecretStore`]. +/// +/// Validates both `store_name` and `key` before delegating to the provider. +#[derive(Clone)] +pub struct SecretHandle { + provider: Arc, +} + +impl fmt::Debug for SecretHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SecretHandle").finish_non_exhaustive() + } +} + +impl SecretHandle { + /// Create a new handle wrapping a multi-store provider. + pub fn new(provider: Arc) -> Self { + Self { provider } + } + + /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. + pub async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + validate_name(store_name)?; + validate_name(key)?; + self.provider.get_bytes(store_name, key).await + } + + /// Retrieve a secret as raw bytes. Returns `SecretError::NotFound` if absent. + pub async fn require_bytes(&self, store_name: &str, key: &str) -> Result { + self.get_bytes(store_name, key) + .await? + .ok_or_else(|| SecretError::NotFound { + name: format!("{store_name}/{key}"), + }) + } + + /// Retrieve a secret as a UTF-8 string. Returns `SecretError::NotFound` if absent. + pub async fn require_str(&self, store_name: &str, key: &str) -> Result { + let bytes = self.require_bytes(store_name, key).await?; + String::from_utf8(bytes.into()) + .map_err(|e| SecretError::Internal(anyhow::anyhow!("secret is not valid UTF-8: {e}"))) + } +} + +// --------------------------------------------------------------------------- +// Shared validation +// --------------------------------------------------------------------------- + +pub(crate) fn validate_name(name: &str) -> Result<(), SecretError> { + if name.is_empty() { + return Err(SecretError::Validation( + "secret name cannot be empty".to_string(), + )); + } + if name.len() > MAX_NAME_LEN { + return Err(SecretError::Validation(format!( + "secret name length {} exceeds limit of {} bytes", + name.len(), + MAX_NAME_LEN + ))); + } + if name.chars().any(|c| c.is_control()) { + return Err(SecretError::Validation( + "secret name contains invalid control characters".to_string(), + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`SecretStore`] implementation. +/// +/// The factory expression must produce a provider pre-populated with these +/// entries in the `"mystore"` store: +/// - `"contract_key"` → `Bytes::from("contract_value")` +/// - `"contract_key_2"` → `Bytes::from("another_value")` +/// - `"missing_key"` must NOT be present. +#[macro_export] +macro_rules! secret_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use bytes::Bytes; + use $crate::secret_store::SecretStore; + + fn run(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn contract_get_existing_returns_bytes() { + let provider = $factory; + run(async { + let result = provider.get_bytes("mystore", "contract_key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("contract_value"))); + }); + } + + #[test] + fn contract_get_second_key_returns_bytes() { + let provider = $factory; + run(async { + let result = provider + .get_bytes("mystore", "contract_key_2") + .await + .unwrap(); + assert_eq!(result, Some(Bytes::from("another_value"))); + }); + } + + #[test] + fn contract_get_missing_returns_none() { + let provider = $factory; + run(async { + let result = provider.get_bytes("mystore", "missing_key").await.unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn contract_wrong_store_returns_none() { + let provider = $factory; + run(async { + let result = provider + .get_bytes("other_store", "contract_key") + .await + .unwrap(); + assert!(result.is_none()); + }); + } + } + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::StatusCode; + use bytes::Bytes; + use futures::executor::block_on; + + // ----------------------------------------------------------------------- + // SecretStoreProvider tests + // ----------------------------------------------------------------------- + + #[test] + fn provider_in_memory_returns_value_for_existing_key() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + block_on(async { + let result = provider.get_bytes("store", "key").await.unwrap(); + assert_eq!(result, Some(Bytes::from("hello"))); + }); + } + + #[test] + fn provider_in_memory_returns_none_for_missing_key() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + block_on(async { + let result = provider.get_bytes("store", "missing").await.unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn provider_in_memory_returns_none_for_wrong_store() { + let provider = InMemorySecretStore::new([("store/key", Bytes::from("hello"))]); + block_on(async { + let result = provider.get_bytes("other", "key").await.unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn noop_provider_always_returns_none() { + let provider = NoopSecretStore; + block_on(async { + let result = provider.get_bytes("any_store", "any_key").await.unwrap(); + assert!(result.is_none()); + }); + } + + // ----------------------------------------------------------------------- + // SecretProviderHandle tests + // ----------------------------------------------------------------------- + + fn provider_handle_with(entries: &[(&str, &str)]) -> SecretHandle { + let provider = InMemorySecretStore::new( + entries + .iter() + .map(|(k, v)| (k.to_string(), Bytes::from(v.to_string()))), + ); + SecretHandle::new(std::sync::Arc::new(provider)) + } + + #[test] + fn provider_handle_get_bytes_returns_value() { + let h = provider_handle_with(&[("signing-keys/current", "abc123")]); + block_on(async { + let result = h.get_bytes("signing-keys", "current").await.unwrap(); + assert_eq!(result, Some(Bytes::from("abc123"))); + }); + } + + #[test] + fn provider_handle_get_bytes_returns_none_for_missing() { + let h = provider_handle_with(&[]); + block_on(async { + let result = h.get_bytes("store", "missing").await.unwrap(); + assert!(result.is_none()); + }); + } + + #[test] + fn provider_handle_require_bytes_errors_for_missing() { + let h = provider_handle_with(&[]); + block_on(async { + let err = h.require_bytes("store", "missing").await.unwrap_err(); + assert!(matches!(err, SecretError::NotFound { .. })); + }); + } + + #[test] + fn provider_handle_require_str_returns_value() { + let h = provider_handle_with(&[("api-keys/prod", "secret_val")]); + block_on(async { + let val = h.require_str("api-keys", "prod").await.unwrap(); + assert_eq!(val, "secret_val"); + }); + } + + #[test] + fn provider_handle_validates_empty_store_name() { + let h = provider_handle_with(&[]); + block_on(async { + let err = h.get_bytes("", "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn provider_handle_validates_empty_key() { + let h = provider_handle_with(&[]); + block_on(async { + let err = h.get_bytes("store", "").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn provider_handle_validates_control_chars_in_store_name() { + let h = provider_handle_with(&[]); + block_on(async { + let err = h.get_bytes("bad\x00store", "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn provider_handle_validates_control_chars_in_key() { + let h = provider_handle_with(&[]); + block_on(async { + let err = h.get_bytes("store", "bad\x00key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn provider_handle_validates_oversized_name() { + let h = provider_handle_with(&[]); + block_on(async { + let name = "x".repeat(MAX_NAME_LEN + 1); + let err = h.get_bytes(&name, "key").await.unwrap_err(); + assert!(matches!(err, SecretError::Validation(_))); + }); + } + + #[test] + fn secret_error_not_found_does_not_leak_secret_name() { + let err: EdgeError = SecretError::NotFound { + name: "API_KEY".to_string(), + } + .into(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!err.message().contains("API_KEY")); + } + + #[test] + fn secret_error_validation_does_not_leak_details() { + let err: EdgeError = SecretError::Validation("bad\x00name".to_string()).into(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(!err.message().contains("bad")); + } + + secret_store_contract_tests!(in_memory_provider_contract, { + InMemorySecretStore::new([ + ("mystore/contract_key", Bytes::from("contract_value")), + ("mystore/contract_key_2", Bytes::from("another_value")), + ]) + }); +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a7a34cb..55770ba 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -137,6 +137,48 @@ Variables with a default `value` are injected when running CLI commands. Secrets must be present in the environment; missing secrets abort CLI commands with an error. +These declarations are for CLI and deployment workflows. To expose a runtime +secret store to request handlers, configure `[stores.secrets]`. + +## Runtime Secret Stores + +Use `[stores.secrets]` when your application reads secrets at request time via +the `Secrets` extractor. This is separate from `[[environment.secrets]]`: + +- `[[environment.secrets]]` declares required environment variables for CLI commands +- `[stores.secrets]` enables runtime secret lookup during request handling + +```toml +[stores.secrets] +name = "EDGEZERO_SECRETS" + +[stores.secrets.adapters.fastly] +name = "MY_FASTLY_SECRETS" +``` + +### Global Fields + +| Field | Required | Description | +| --------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `enabled` | No | Whether secrets are enabled for adapters without overrides (defaults to `true` when the section is present) | +| `name` | No | Store or binding name (defaults to `EDGEZERO_SECRETS`) | + +### Per-Adapter Overrides + +| Field | Required | Description | +| ---------------------------- | -------- | --------------------------------------------- | +| `adapters..enabled` | No | Override whether that adapter exposes secrets | +| `adapters..name` | No | Override the adapter-specific store name | + +### Adapter Behavior + +- Axum reads secrets from process environment variables of the same name. +- Fastly opens the configured secret store name from `fastly.toml`. +- Cloudflare reads Worker Secrets individually; the configured `name` is metadata only. + +If `[stores.secrets]` is omitted, the `Secrets` extractor is not attached for +that adapter. + ## Adapters Section Each adapter has its own configuration block: @@ -238,6 +280,9 @@ value = "https://api.example.com" [[environment.secrets]] name = "API_KEY" +[stores.secrets] +name = "EDGEZERO_SECRETS" + [adapters.fastly.adapter] crate = "crates/my-app-adapter-fastly" manifest = "crates/my-app-adapter-fastly/fastly.toml" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml index 8d5c4ac..c95fd2f 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -16,11 +16,21 @@ service_id = "" key = "__init__" data = "" +[local_server.secret_stores] + +[[local_server.secret_stores.EDGEZERO_SECRETS]] +key = "SMOKE_SECRET" +env = "SMOKE_SECRET" + [setup] [setup.kv_stores] [setup.kv_stores.EDGEZERO_KV] description = "KV store for EdgeZero demo" +[setup.secret_stores] +[setup.secret_stores.EDGEZERO_SECRETS] +description = "Secret store for EdgeZero demo" + [scripts] build = "cargo build --profile release --target wasm32-wasip1" diff --git a/examples/app-demo/crates/app-demo-core/Cargo.toml b/examples/app-demo/crates/app-demo-core/Cargo.toml index b356b4e..91c2281 100644 --- a/examples/app-demo/crates/app-demo-core/Cargo.toml +++ b/examples/app-demo/crates/app-demo-core/Cargo.toml @@ -15,3 +15,4 @@ validator = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +edgezero-core = { path = "../../../../crates/edgezero-core", features = ["test-utils"] } diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index 1eb44dc..b7bb433 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -3,13 +3,16 @@ use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::extractor::{Headers, Json, Kv, Path, ValidatedPath}; +use edgezero_core::extractor::{Headers, Json, Kv, Path, Query, Secrets, ValidatedPath}; use edgezero_core::http::{self, Response, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use edgezero_core::response::Text; use futures::{stream, StreamExt}; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const SMOKE_SECRET_NAME: &str = "SMOKE_SECRET"; +const SMOKE_SECRET_MISSING_NAME: &str = "SMOKE_SECRET_MISSING"; +const SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; #[derive(serde::Deserialize)] pub(crate) struct EchoParams { @@ -191,6 +194,37 @@ pub(crate) async fn kv_note_delete( .map_err(EdgeError::internal) } +// --------------------------------------------------------------------------- +// Secrets demo handler — illustrates platform-neutral secret access. +// WARNING: This handler returns the raw secret value in the response body. +// It exists solely for smoke-testing. Never do this in production. +// Only fixed smoke-test key names are accepted. +// --------------------------------------------------------------------------- + +/// Echo the value of an allowlisted smoke-test secret from the configured store. +/// +/// Usage: GET /secrets/echo?name=SMOKE_SECRET +#[action] +pub(crate) async fn secrets_echo( + Secrets(store): Secrets, + Query(params): Query, +) -> Result, EdgeError> { + match params.name.as_str() { + SMOKE_SECRET_NAME | SMOKE_SECRET_MISSING_NAME => {} + _ => { + return Err(EdgeError::bad_request( + "only smoke-test secret names are allowed", + )) + } + } + + let value = store + .require_str(SECRET_STORE_NAME, ¶ms.name) + .await + .map_err(EdgeError::from)?; + Ok(Text::new(value)) +} + #[cfg(test)] mod tests { use super::*; @@ -516,4 +550,70 @@ mod tests { let resp = block_on(kv_note_delete(ctx2)).expect("response"); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } + + // -- Secrets handler tests ---------------------------------------------- + + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; + + fn context_with_secrets(path: &str, query: &str, entries: &[(&str, &str)]) -> RequestContext { + let provider = InMemorySecretStore::new(entries.iter().map(|(k, v)| { + ( + format!("{SECRET_STORE_NAME}/{k}"), + bytes::Bytes::from(v.to_string()), + ) + })); + let handle = SecretHandle::new(std::sync::Arc::new(provider)); + let uri = format!("{}?{}", path, query); + let mut request = request_builder() + .method(Method::GET) + .uri(uri.as_str()) + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(handle); + RequestContext::new(request, PathParams::default()) + } + + #[test] + fn secrets_echo_returns_secret_value() { + let ctx = context_with_secrets( + "/secrets/echo", + "name=SMOKE_SECRET", + &[("SMOKE_SECRET", "my-secret-value")], + ); + let response = block_on(secrets_echo(ctx)) + .expect("handler ok") + .into_response(); + let bytes = response.into_body().into_bytes(); + assert_eq!(bytes.as_ref(), b"my-secret-value"); + } + + #[test] + fn secrets_echo_returns_sanitized_500_for_missing_allowed_secret() { + use edgezero_core::http::StatusCode; + + let ctx = context_with_secrets("/secrets/echo", "name=SMOKE_SECRET_MISSING", &[]); + let response = block_on(secrets_echo(ctx)) + .expect_err("should fail") + .into_response(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()).expect("utf8"); + assert!(body.contains("required secret is not configured")); + assert!(!body.contains("SMOKE_SECRET_MISSING")); + } + + #[test] + fn secrets_echo_rejects_non_smoke_secret_names() { + use edgezero_core::http::StatusCode; + + let ctx = context_with_secrets("/secrets/echo", "name=API_KEY", &[("API_KEY", "secret")]); + let response = block_on(secrets_echo(ctx)) + .expect_err("should reject arbitrary secret names") + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()).expect("utf8"); + assert!(body.contains("only smoke-test secret names are allowed")); + assert!(!body.contains("API_KEY")); + } } diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index a187197..34bdccf 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -86,6 +86,16 @@ handler = "app_demo_core::handlers::kv_note_delete" adapters = ["axum", "cloudflare", "fastly"] description = "Delete a note by id" +# -- Secrets demo route -------------------------------------------------------- + +[[triggers.http]] +id = "secrets_echo" +path = "/secrets/echo" +methods = ["GET"] +handler = "app_demo_core::handlers::secrets_echo" +adapters = ["axum", "cloudflare", "fastly"] +description = "Echo an allowlisted smoke-test secret value (smoke-test only — do not use in production)" + # -- Stores ---------------------------------------------------------------- [stores.kv] @@ -96,6 +106,12 @@ description = "Delete a note by id" # [stores.kv.adapters.cloudflare] # name = "CF_KV_BINDING" +[stores.secrets] +# Uses the default name "EDGEZERO_SECRETS". +# Axum reads secrets from environment variables of the same name. +# Cloudflare reads from Worker secret bindings (local: .dev.vars). +# Fastly reads from the declared secret store (local: fastly.toml [local_server.secret_stores]). + # [environment] # # [[environment.variables]] diff --git a/scripts/smoke_test_secrets.sh b/scripts/smoke_test_secrets.sh new file mode 100755 index 0000000..764c1a3 --- /dev/null +++ b/scripts/smoke_test_secrets.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke-test the secret-store demo handlers by starting an adapter, running +# checks, and tearing it down automatically. +# +# Usage: +# ./scripts/smoke_test_secrets.sh # defaults to axum +# ./scripts/smoke_test_secrets.sh axum +# ./scripts/smoke_test_secrets.sh fastly +# ./scripts/smoke_test_secrets.sh cloudflare + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEMO_DIR="$ROOT_DIR/examples/app-demo" +ADAPTER="${1:-axum}" +SERVER_PID="" +DEV_VARS_FILE="" +SMOKE_SECRET_NAME="SMOKE_SECRET" +MISSING_SECRET_NAME="SMOKE_SECRET_MISSING" +DISALLOWED_SECRET_NAME="API_KEY" +SMOKE_SECRET_VALUE="smoke-secret-$(date +%s)-$$" +PASS=0 +FAIL=0 + +export SMOKE_SECRET="$SMOKE_SECRET_VALUE" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + echo "" + echo "==> Stopping server (PID $SERVER_PID)..." + pkill -P "$SERVER_PID" 2>/dev/null || true + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + + if [ -n "$DEV_VARS_FILE" ] && [ -f "$DEV_VARS_FILE" ]; then + rm -f "$DEV_VARS_FILE" + fi +} +trap cleanup EXIT + +section() { + printf '\n--- %s ---\n' "$1" +} + +check() { + local label="$1" expect="$2" actual="$3" + if [ "$actual" = "$expect" ]; then + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %s, got %s)\n' "$label" "$expect" "$actual" + FAIL=$((FAIL + 1)) + fi +} + +check_contains() { + local label="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected body to contain %s)\n' "$label" "$needle" + FAIL=$((FAIL + 1)) + fi +} + +check_not_contains() { + local label="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + printf ' FAIL %s (body unexpectedly contained %s)\n' "$label" "$needle" + FAIL=$((FAIL + 1)) + else + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + fi +} + +start_server() { + case "$ADAPTER" in + axum) + PORT=8787 + echo "==> Building app-demo (axum)..." + (cd "$DEMO_DIR" && cargo build -p app-demo-adapter-axum 2>&1) + echo "==> Starting Axum adapter on port $PORT..." + (cd "$DEMO_DIR" && cargo run -p app-demo-adapter-axum 2>&1) & + SERVER_PID=$! + ;; + fastly) + PORT=7676 + command -v fastly >/dev/null 2>&1 || { + echo "Fastly CLI is required. Install from https://developer.fastly.com/reference/cli/" >&2 + exit 1 + } + echo "==> Starting Fastly Viceroy on port $PORT..." + (cd "$DEMO_DIR" && fastly compute serve -C crates/app-demo-adapter-fastly 2>&1) & + SERVER_PID=$! + ;; + cloudflare) + PORT=8787 + command -v wrangler >/dev/null 2>&1 || { + echo "wrangler is required. Install with 'npm i -g wrangler'" >&2 + exit 1 + } + DEV_VARS_FILE="$DEMO_DIR/crates/app-demo-adapter-cloudflare/.dev.vars" + printf '%s=%s\n' "$SMOKE_SECRET_NAME" "$SMOKE_SECRET_VALUE" > "$DEV_VARS_FILE" + echo "==> Starting Cloudflare wrangler dev on port $PORT..." + (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & + SERVER_PID=$! + ;; + *) + echo "Unknown adapter: $ADAPTER" >&2 + echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + exit 1 + ;; + esac +} + +wait_for_server() { + BASE="http://127.0.0.1:${PORT}" + + echo "==> Waiting for server at $BASE ..." + MAX_WAIT=60 + WAITED=0 + until curl -fsS -o /dev/null "$BASE/" 2>/dev/null; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Server process exited early" >&2 + return 1 + fi + sleep 1 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge "$MAX_WAIT" ]; then + echo "Server did not start within ${MAX_WAIT}s" >&2 + return 1 + fi + done + echo "==> Server ready (${WAITED}s)" +} + +run_checks() { + section "Health check" + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/") + check "GET / returns 200" "200" "$STATUS" + + section "Secret echo" + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/secrets/echo?name=$SMOKE_SECRET_NAME") + check "GET /secrets/echo?name=$SMOKE_SECRET_NAME returns 200" "200" "$STATUS" + + BODY=$(curl -s "$BASE/secrets/echo?name=$SMOKE_SECRET_NAME") + check "GET /secrets/echo?name=$SMOKE_SECRET_NAME returns secret value" "$SMOKE_SECRET_VALUE" "$BODY" + + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/secrets/echo?name=$MISSING_SECRET_NAME") + check "GET /secrets/echo?name=$MISSING_SECRET_NAME returns 500" "500" "$STATUS" + + BODY=$(curl -s "$BASE/secrets/echo?name=$MISSING_SECRET_NAME") + check_contains \ + "Missing allowed secret response is sanitized" \ + "required secret is not configured" \ + "$BODY" + check_not_contains \ + "Missing allowed secret response does not leak the key name" \ + "$MISSING_SECRET_NAME" \ + "$BODY" + + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/secrets/echo?name=$DISALLOWED_SECRET_NAME") + check "GET /secrets/echo?name=$DISALLOWED_SECRET_NAME returns 400" "400" "$STATUS" + + BODY=$(curl -s "$BASE/secrets/echo?name=$DISALLOWED_SECRET_NAME") + check_contains \ + "Disallowed secret name returns a policy error" \ + "only smoke-test secret names are allowed" \ + "$BODY" + check_not_contains \ + "Disallowed secret name response does not echo user input" \ + "$DISALLOWED_SECRET_NAME" \ + "$BODY" +} + +start_server + +if wait_for_server; then + run_checks +else + FAIL=$((FAIL + 1)) + echo "==> Skipping checks because the server did not become ready" +fi + +printf '\n==============================\n' +printf 'Adapter: %s\n' "$ADAPTER" +printf 'Secret: %s\n' "$SMOKE_SECRET_NAME" +printf 'Missing: %s\n' "$MISSING_SECRET_NAME" +printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL" +printf '==============================\n' + +[ "$FAIL" -eq 0 ] || exit 1