diff --git a/CLAUDE.md b/CLAUDE.md index 49835fe8..32957649 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,7 @@ pub struct UserId(Uuid); - Use concrete error types with `Report`. - Use `ensure!()` / `bail!()` macros for early returns. - Import `Error` from `core::error::` instead of `std::error::`. -- Use `change_context()` to map error types, `attach()` / `attach_with()` for debug info. +- Use `change_context()` to map error types, `attach()` for debug info. - Define errors with `derive_more::Display` (not thiserror): ```rust diff --git a/Cargo.lock b/Cargo.lock index de9dd4ea..ce465f1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,11 +788,13 @@ dependencies = [ "bytes", "chrono", "edgezero-core", + "fastly", "fern", "flate2", "futures", "futures-util", "log", + "log-fastly", ] [[package]] @@ -2776,8 +2778,11 @@ dependencies = [ name = "trusted-server-adapter-fastly" version = "0.1.0" dependencies = [ + "async-trait", + "bytes", "chrono", "edgezero-adapter-fastly", + "edgezero-core", "error-stack", "fastly", "fern", diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index edd5ab54..5b0825dc 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -7,8 +7,11 @@ edition = "2021" workspace = true [dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } chrono = { workspace = true } -edgezero-adapter-fastly = { workspace = true } +edgezero-adapter-fastly = { workspace = true, features = ["fastly"] } +edgezero-core = { workspace = true } error-stack = { workspace = true } fastly = { workspace = true } fern = { workspace = true } @@ -19,3 +22,6 @@ serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { path = "../trusted-server-core" } trusted-server-js = { path = "../js" } + +[dev-dependencies] +edgezero-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d97c8402..859ce117 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -14,6 +14,7 @@ use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -27,7 +28,10 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; mod error; +mod platform; + use crate::error::to_error_response; +use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; #[fastly::main] fn main(req: Request) -> Result { @@ -59,10 +63,28 @@ fn main(req: Request) -> Result { } }; + let kv_store = match open_kv_store(&settings.synthetic.opid_store) { + Ok(s) => s, + Err(e) => { + // Degrade gracefully: routes that do not touch synthetic IDs + // (e.g. /.well-known/, /verify-signature, /admin/keys/*) must + // still succeed even when the KV store is unavailable. + // Handlers that call kv_handle() will receive KvError::Unavailable. + log::warn!( + "KV store '{}' unavailable, synthetic ID routes will return errors: {e}", + settings.synthetic.opid_store + ); + std::sync::Arc::new(UnavailableKvStore) + as std::sync::Arc + } + }; + let runtime_services = build_runtime_services(&req, kv_store); + futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, + &runtime_services, req, )) } @@ -71,6 +93,7 @@ async fn route_request( settings: &Settings, orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, + runtime_services: &RuntimeServices, mut req: Request, ) -> Result { // Strip client-spoofable forwarded headers at the edge. @@ -78,8 +101,15 @@ async fn route_request( // clients are untrusted and can hijack URL rewriting (see #409). sanitize_forwarded_headers(&mut req); - // Extract geo info before auth check or routing consumes the request - let geo_info = GeoInfo::from_request(&req); + // Look up geo info via the platform abstraction using the client IP + // already captured in RuntimeServices at the entry point. + let geo_info = runtime_services + .geo() + .lookup(runtime_services.client_info.client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }); if let Some(mut response) = enforce_basic_auth(settings, &req) { finalize_response(settings, geo_info.as_ref(), &mut response); @@ -99,7 +129,7 @@ async fn route_request( // Discovery endpoint for trusted-server capabilities and JWKS (Method::GET, "/.well-known/trusted-server.json") => { - handle_trusted_server_discovery(settings, req) + handle_trusted_server_discovery(settings, runtime_services, req) } // Signature verification endpoint diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs new file mode 100644 index 00000000..eaf5cf8c --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -0,0 +1,552 @@ +//! Fastly-backed implementations of the platform traits defined in +//! `trusted-server-core::platform`. +//! +//! This module also provides [`build_runtime_services`], a free function that +//! constructs a [`RuntimeServices`] instance once at the entry point from the +//! incoming Fastly request. + +use core::fmt::Display; +use std::net::IpAddr; +use std::sync::Arc; + +use edgezero_adapter_fastly::key_value_store::FastlyKvStore; +use edgezero_core::key_value_store::KvError; +use error_stack::{Report, ResultExt}; +use fastly::geo::geo_lookup; +use fastly::{ConfigStore, Request, SecretStore}; + +use trusted_server_core::backend::BackendConfig; +use trusted_server_core::geo::geo_from_fastly; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, + StoreName, +}; + +pub(crate) use trusted_server_core::platform::UnavailableKvStore; + +trait ConfigStoreReader: Sized { + type LookupError: Display; + + fn try_get(&self, key: &str) -> Result, Self::LookupError>; +} + +impl ConfigStoreReader for ConfigStore { + type LookupError = fastly::config_store::LookupError; + + fn try_get(&self, key: &str) -> Result, Self::LookupError> { + ConfigStore::try_get(self, key) + } +} + +fn get_config_value( + store_name: &str, + key: &str, + open_store: Open, +) -> Result> +where + S: ConfigStoreReader, + Open: FnOnce() -> Result, + OpenError: Display, +{ + let store = open_store().map_err(|error| { + Report::new(PlatformError::ConfigStore).attach(format!( + "failed to open config store '{store_name}': {error}" + )) + })?; + + store + .try_get(key) + .map_err(|error| { + Report::new(PlatformError::ConfigStore).attach(format!( + "lookup for key '{key}' in config store '{store_name}' failed: {error}" + )) + })? + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore).attach(format!( + "key '{key}' not found in config store '{store_name}'" + )) + }) +} + +enum SecretReadError { + Lookup(LookupError), + Decrypt(DecryptError), +} + +type SecretBytesResult = + Result>, SecretReadError>; + +trait SecretStoreReader: Sized { + type LookupError: Display; + type DecryptError: Display; + + fn try_get_bytes(&self, key: &str) -> SecretBytesResult; +} + +impl SecretStoreReader for SecretStore { + type LookupError = fastly::secret_store::LookupError; + type DecryptError = fastly::secret_store::DecryptError; + + fn try_get_bytes(&self, key: &str) -> SecretBytesResult { + let secret = self.try_get(key).map_err(SecretReadError::Lookup)?; + let Some(secret) = secret else { + return Ok(None); + }; + + secret + .try_plaintext() + .map(|bytes| Some(bytes.into_iter().collect())) + .map_err(SecretReadError::Decrypt) + } +} + +fn get_secret_bytes( + store_name: &str, + key: &str, + open_store: Open, +) -> Result, Report> +where + S: SecretStoreReader, + Open: FnOnce() -> Result, + OpenError: Display, +{ + let store = open_store().map_err(|error| { + Report::new(PlatformError::SecretStore).attach(format!( + "failed to open secret store '{store_name}': {error}" + )) + })?; + + store + .try_get_bytes(key) + .map_err(|error| match error { + SecretReadError::Lookup(error) => Report::new(PlatformError::SecretStore).attach( + format!("lookup for key '{key}' in secret store '{store_name}' failed: {error}"), + ), + SecretReadError::Decrypt(error) => Report::new(PlatformError::SecretStore) + .attach(format!("failed to decrypt secret '{key}': {error}")), + })? + .ok_or_else(|| { + Report::new(PlatformError::SecretStore).attach(format!( + "key '{key}' not found in secret store '{store_name}'" + )) + }) +} + +// --------------------------------------------------------------------------- +// FastlyPlatformConfigStore +// --------------------------------------------------------------------------- + +/// Fastly [`ConfigStore`]-backed implementation of [`PlatformConfigStore`]. +/// +/// Stateless — the store name is supplied per call, matching the trait +/// signature. This replaces the store-name-at-construction pattern of +/// [`trusted_server_core::storage::FastlyConfigStore`]. +/// +/// Write methods (`put`, `delete`) are not yet implemented and return +/// [`PlatformError::NotImplemented`]. Management writes land in a follow-up PR. +pub struct FastlyPlatformConfigStore; + +impl PlatformConfigStore for FastlyPlatformConfigStore { + fn get(&self, store_name: &StoreName, key: &str) -> Result> { + let name = store_name.as_ref(); + get_config_value::(name, key, || ConfigStore::try_open(name)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::NotImplemented)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::NotImplemented)) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformSecretStore +// --------------------------------------------------------------------------- + +/// Fastly [`SecretStore`]-backed implementation of [`PlatformSecretStore`]. +/// +/// Stateless — the store name is supplied per call. This replaces the +/// store-name-at-construction pattern of +/// [`trusted_server_core::storage::FastlySecretStore`]. +/// +/// Write methods (`create`, `delete`) are not yet implemented and return +/// [`PlatformError::NotImplemented`]. Management writes land in a follow-up PR. +pub struct FastlyPlatformSecretStore; + +impl PlatformSecretStore for FastlyPlatformSecretStore { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let name = store_name.as_ref(); + get_secret_bytes::(name, key, || SecretStore::open(name)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::NotImplemented)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::NotImplemented)) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformBackend +// --------------------------------------------------------------------------- + +/// Fastly dynamic-backend implementation of [`PlatformBackend`]. +/// +/// Delegates name computation and registration to [`BackendConfig`], preserving +/// the existing deterministic naming scheme (scheme + host + port + cert + +/// timeout → unique name). +pub struct FastlyPlatformBackend; + +impl PlatformBackend for FastlyPlatformBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + BackendConfig::new(&spec.scheme, &spec.host) + .port(spec.port) + .certificate_check(spec.certificate_check) + .first_byte_timeout(spec.first_byte_timeout) + .predict_name() + .change_context(PlatformError::Backend) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + BackendConfig::new(&spec.scheme, &spec.host) + .port(spec.port) + .certificate_check(spec.certificate_check) + .first_byte_timeout(spec.first_byte_timeout) + .ensure() + .change_context(PlatformError::Backend) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformHttpClient +// --------------------------------------------------------------------------- + +/// Placeholder Fastly implementation of [`PlatformHttpClient`]. +/// +/// The Fastly-backed `send` / `send_async` / `select` behavior lands in a +/// follow-up PR once the orchestrator migration is complete. Until then all +/// methods return [`PlatformError::NotImplemented`]. +/// +/// Implementation lands in #487 (PR 6: Backend + HTTP client traits). +pub struct FastlyPlatformHttpClient; + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for FastlyPlatformHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::NotImplemented) + .attach("FastlyPlatformHttpClient::send is not yet implemented")) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::NotImplemented) + .attach("FastlyPlatformHttpClient::send_async is not yet implemented")) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::NotImplemented) + .attach("FastlyPlatformHttpClient::select is not yet implemented")) + } +} + +// --------------------------------------------------------------------------- +// FastlyPlatformGeo +// --------------------------------------------------------------------------- + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// Uses [`geo_from_fastly`] from `trusted_server_core::geo` to avoid +/// duplicating the field-mapping logic present in `GeoInfo::from_request`. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} + +// --------------------------------------------------------------------------- +// Entry-point helper +// --------------------------------------------------------------------------- + +/// Construct a [`RuntimeServices`] instance from the incoming Fastly request. +/// +/// Call this once at the entry point before dispatching to handlers. +/// `client_info` is populated from TLS and IP metadata available on the +/// request; geo lookup is deferred to handler time via +/// `services.geo.lookup(services.client_info.client_ip)`. +/// +/// `kv_store` is an [`Arc`] opened by the caller for +/// the primary KV store. Use [`open_kv_store`] to construct it. +#[must_use] +pub fn build_runtime_services( + req: &Request, + kv_store: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(FastlyPlatformConfigStore)) + .secret_store(Arc::new(FastlyPlatformSecretStore)) + .kv_store(kv_store) + .backend(Arc::new(FastlyPlatformBackend)) + .http_client(Arc::new(FastlyPlatformHttpClient)) + .geo(Arc::new(FastlyPlatformGeo)) + .client_info(ClientInfo { + client_ip: req.get_client_ip_addr(), + tls_protocol: req.get_tls_protocol().map(str::to_string), + tls_cipher: req.get_tls_cipher_openssl_name().map(str::to_string), + }) + .build() +} + +/// Open a named KV store as a [`PlatformKvStore`] implementation. +/// +/// # Errors +/// +/// Returns [`KvError::Unavailable`] when the store does not exist, or +/// [`KvError::Internal`] when the Fastly SDK fails to open it. +pub fn open_kv_store(store_name: &str) -> Result, KvError> { + FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::NoopKvStore; + + use super::*; + + struct StubConfigStore { + value: Result, &'static str>, + } + + impl ConfigStoreReader for StubConfigStore { + type LookupError = &'static str; + + fn try_get(&self, _key: &str) -> Result, Self::LookupError> { + self.value.clone() + } + } + + enum StubSecretReadError { + Decrypt(&'static str), + } + + struct StubSecretStore { + value: Result>, StubSecretReadError>, + } + + impl SecretStoreReader for StubSecretStore { + type LookupError = &'static str; + type DecryptError = &'static str; + + fn try_get_bytes( + &self, + _key: &str, + ) -> SecretBytesResult { + match &self.value { + Ok(Some(bytes)) => Ok(Some(bytes.clone())), + Ok(None) => Ok(None), + Err(StubSecretReadError::Decrypt(error)) => Err(SecretReadError::Decrypt(*error)), + } + } + } + + fn noop_kv_store() -> Arc { + Arc::new(NoopKvStore) + } + + // --- FastlyPlatformBackend::predict_name -------------------------------- + + #[test] + fn predict_name_produces_same_name_as_backend_config() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute backend name for valid spec"); + + assert_eq!( + name, "backend_https_origin_example_com_443_t15000", + "should match BackendConfig naming convention" + ); + } + + #[test] + fn predict_name_includes_nocert_suffix_when_cert_check_disabled() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + certificate_check: false, + first_byte_timeout: Duration::from_secs(15), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute name with cert check disabled"); + + assert!( + name.contains("nocert"), + "should include nocert suffix when certificate_check is false" + ); + } + + #[test] + fn predict_name_returns_error_for_empty_host() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: String::new(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + + let result = backend.predict_name(&spec); + + assert!(result.is_err(), "should return an error for empty host"); + } + + #[test] + fn predict_name_encodes_custom_timeout() { + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "origin.example.com".to_string(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(2000), + }; + + let name = backend + .predict_name(&spec) + .expect("should compute name with custom timeout"); + + assert!( + name.ends_with("_t2000"), + "should encode 2000ms timeout in name" + ); + } + + // --- ClientInfo extraction ---------------------------------------------- + + #[test] + fn build_runtime_services_client_info_is_none_without_tls() { + let req = Request::get("https://example.com/"); + let services = build_runtime_services(&req, noop_kv_store()); + + assert!( + services.client_info.tls_protocol.is_none(), + "should have no tls_protocol on plain test request" + ); + assert!( + services.client_info.tls_cipher.is_none(), + "should have no tls_cipher on plain test request" + ); + } + + #[test] + fn build_runtime_services_returns_cloneable_services() { + let req = Request::get("https://example.com/"); + let services = build_runtime_services(&req, noop_kv_store()); + let cloned = services.clone(); + + assert_eq!( + services.client_info.client_ip, cloned.client_info.client_ip, + "should preserve client_ip through clone" + ); + } + + #[test] + fn get_config_value_returns_error_when_lookup_fails() { + let err = get_config_value::("jwks_store", "active-kids", || { + Ok::(StubConfigStore { + value: Err("lookup failed"), + }) + }) + .expect_err("should return an error when config lookup fails"); + + assert!( + matches!(err.current_context(), &PlatformError::ConfigStore), + "should surface as PlatformError::ConfigStore" + ); + } + + #[test] + fn get_secret_bytes_returns_error_when_decrypt_fails() { + let err = get_secret_bytes::("signing_keys", "kid", || { + Ok::(StubSecretStore { + value: Err(StubSecretReadError::Decrypt("decrypt failed")), + }) + }) + .expect_err("should return an error when secret decryption fails"); + + assert!( + matches!(err.current_context(), &PlatformError::SecretStore), + "should surface as PlatformError::SecretStore" + ); + } + + #[test] + fn fastly_platform_http_client_reports_not_implemented() { + let client = FastlyPlatformHttpClient; + let request = request_builder() + .uri("https://example.com/") + .body(Body::empty()) + .expect("should build test request"); + let err = + futures::executor::block_on(client.send(PlatformHttpRequest::new(request, "origin"))) + .expect_err("should fail until the HTTP client is implemented"); + + assert!( + matches!(err.current_context(), &PlatformError::NotImplemented), + "should report NotImplemented while the Fastly HTTP client is still a stub" + ); + } +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 6a321fd2..25f2b43f 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -72,6 +72,7 @@ default = [] [dev-dependencies] criterion = { workspace = true } +edgezero-core = { workspace = true, features = ["test-utils"] } temp-env = { workspace = true } tokio-test = { workspace = true } diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-core/src/backend.rs index 34e6b523..3df84a7a 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-core/src/backend.rs @@ -90,7 +90,8 @@ impl<'a> BackendConfig<'a> { self } - /// Compute the deterministic backend name without registering anything. + /// Compute the deterministic backend name and resolved port without + /// registering anything. /// /// The name encodes scheme, host, port, certificate setting, and /// first-byte timeout so that backends with different configurations @@ -103,6 +104,16 @@ impl<'a> BackendConfig<'a> { message: "missing host".to_string(), })); } + if self.host.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Proxy { + message: "host contains control characters".to_string(), + })); + } + if self.scheme.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Proxy { + message: "scheme contains control characters".to_string(), + })); + } let target_port = self .port @@ -125,6 +136,19 @@ impl<'a> BackendConfig<'a> { Ok((backend_name, target_port)) } + /// Return the deterministic backend name without registering anything. + /// + /// Convenience wrapper over [`Self::compute_name`] that discards the + /// resolved port, used by [`crate::platform::PlatformBackend`] + /// implementations that only need the name for correlation. + /// + /// # Errors + /// + /// Returns an error if the host is empty. + pub fn predict_name(self) -> Result> { + self.compute_name().map(|(name, _)| name) + } + /// Ensure a dynamic backend exists for this configuration and return its name. /// /// The backend name is derived from the scheme, host, port, certificate @@ -376,6 +400,17 @@ mod tests { assert_eq!(name, "backend_http_example_org_80_t15000"); } + #[test] + fn error_on_host_with_control_characters() { + let err = BackendConfig::new("https", "evil.com\nINFO fake log entry") + .predict_name() + .expect_err("should reject host containing newline"); + assert!( + err.to_string().contains("control characters"), + "should report control characters in error message" + ); + } + #[test] fn error_on_missing_host() { let err = BackendConfig::new("https", "") diff --git a/crates/trusted-server-core/src/fastly_storage.rs b/crates/trusted-server-core/src/fastly_storage.rs deleted file mode 100644 index bedd2495..00000000 --- a/crates/trusted-server-core/src/fastly_storage.rs +++ /dev/null @@ -1,428 +0,0 @@ -use std::io::Read; - -use error_stack::{Report, ResultExt}; -use fastly::{ConfigStore, Request, Response, SecretStore}; -use http::StatusCode; - -use crate::backend::BackendConfig; -use crate::error::TrustedServerError; - -const FASTLY_API_HOST: &str = "https://api.fastly.com"; - -pub struct FastlyConfigStore { - store_name: String, -} - -impl FastlyConfigStore { - pub fn new(store_name: impl Into) -> Self { - Self { - store_name: store_name.into(), - } - } - - /// Retrieves a configuration value from the store. - /// - /// # Errors - /// - /// Returns an error if the key is not found in the config store. - pub fn get(&self, key: &str) -> Result> { - // TODO use try_open and return the error - let store = ConfigStore::open(&self.store_name); - store.get(key).ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: format!( - "Key '{}' not found in config store '{}'", - key, self.store_name - ), - }) - }) - } -} - -pub struct FastlySecretStore { - store_name: String, -} - -impl FastlySecretStore { - pub fn new(store_name: impl Into) -> Self { - Self { - store_name: store_name.into(), - } - } - - /// Retrieves a secret value from the store. - /// - /// # Errors - /// - /// Returns an error if the secret store cannot be opened, the key is not found, - /// or the secret plaintext cannot be retrieved. - pub fn get(&self, key: &str) -> Result, Report> { - let store = SecretStore::open(&self.store_name).map_err(|_| { - Report::new(TrustedServerError::Configuration { - message: format!("Failed to open SecretStore '{}'", self.store_name), - }) - })?; - - let secret = store.get(key).ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: format!( - "Secret '{}' not found in secret store '{}'", - key, self.store_name - ), - }) - })?; - - secret - .try_plaintext() - .map_err(|_| { - Report::new(TrustedServerError::Configuration { - message: "Failed to get secret plaintext".into(), - }) - }) - .map(|bytes| bytes.into_iter().collect()) - } - - /// Retrieves a secret value from the store and decodes it as a UTF-8 string. - /// - /// # Errors - /// - /// Returns an error if the secret cannot be retrieved or is not valid UTF-8. - pub fn get_string(&self, key: &str) -> Result> { - let bytes = self.get(key)?; - String::from_utf8(bytes).change_context(TrustedServerError::Configuration { - message: "Failed to decode secret as UTF-8".to_string(), - }) - } -} - -pub struct FastlyApiClient { - api_key: Vec, - base_url: &'static str, - backend_name: String, -} - -impl FastlyApiClient { - /// Creates a new Fastly API client using the default secret store. - /// - /// # Errors - /// - /// Returns an error if the secret store cannot be opened or the API key cannot be retrieved. - pub fn new() -> Result> { - Self::from_secret_store("api-keys", "api_key") - } - - /// Creates a new Fastly API client from a specified secret store. - /// - /// # Errors - /// - /// Returns an error if the API backend cannot be ensured or the API key cannot be retrieved. - pub fn from_secret_store( - store_name: &str, - key_name: &str, - ) -> Result> { - let backend_name = BackendConfig::from_url("https://api.fastly.com", true)?; - - let secret_store = FastlySecretStore::new(store_name); - let api_key = secret_store.get(key_name)?; - - log::debug!("FastlyApiClient initialized with backend: {}", backend_name); - - Ok(Self { - api_key, - base_url: FASTLY_API_HOST, - backend_name, - }) - } - - fn make_request( - &self, - method: &str, - path: &str, - body: Option, - content_type: &str, - ) -> Result> { - let url = format!("{}{}", self.base_url, path); - - let api_key_str = String::from_utf8_lossy(&self.api_key).to_string(); - - let mut request = match method { - "GET" => Request::get(&url), - "POST" => Request::post(&url), - "PUT" => Request::put(&url), - "DELETE" => Request::delete(&url), - _ => { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("Unsupported HTTP method: {}", method), - })) - } - }; - - request = request - .with_header("Fastly-Key", api_key_str) - .with_header("Accept", "application/json"); - - if let Some(body_content) = body { - request = request - .with_header("Content-Type", content_type) - .with_body(body_content); - } - - request.send(&self.backend_name).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("Failed to send API request: {}", e), - }) - }) - } - - /// Updates a configuration item in a Fastly config store. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns a non-OK status. - pub fn update_config_item( - &self, - store_id: &str, - key: &str, - value: &str, - ) -> Result<(), Report> { - let path = format!("/resources/stores/config/{}/item/{}", store_id, key); - let payload = format!("item_value={}", value); - - let mut response = self.make_request( - "PUT", - &path, - Some(payload), - "application/x-www-form-urlencoded", - )?; - - let mut buf = String::new(); - response - .get_body_mut() - .read_to_string(&mut buf) - .map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("Failed to read API response: {}", e), - }) - })?; - - if response.get_status() == StatusCode::OK { - Ok(()) - } else { - Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Failed to update config item: HTTP {} - {}", - response.get_status(), - buf - ), - })) - } - } - - /// Creates a secret in a Fastly secret store. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns a non-OK status. - pub fn create_secret( - &self, - store_id: &str, - secret_name: &str, - secret_value: &str, - ) -> Result<(), Report> { - let path = format!("/resources/stores/secret/{}/secrets", store_id); - - let payload = serde_json::json!({ - "name": secret_name, - "secret": secret_value - }); - - let mut response = - self.make_request("POST", &path, Some(payload.to_string()), "application/json")?; - - let mut buf = String::new(); - response - .get_body_mut() - .read_to_string(&mut buf) - .map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("Failed to read API response: {}", e), - }) - })?; - - if response.get_status() == StatusCode::OK { - Ok(()) - } else { - Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Failed to create secret: HTTP {} - {}", - response.get_status(), - buf - ), - })) - } - } - - /// Deletes a configuration item from a Fastly config store. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns a non-OK/NO_CONTENT status. - pub fn delete_config_item( - &self, - store_id: &str, - key: &str, - ) -> Result<(), Report> { - let path = format!("/resources/stores/config/{}/item/{}", store_id, key); - - let mut response = self.make_request("DELETE", &path, None, "application/json")?; - - let mut buf = String::new(); - response - .get_body_mut() - .read_to_string(&mut buf) - .map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("Failed to read API response: {}", e), - }) - })?; - - if response.get_status() == StatusCode::OK - || response.get_status() == StatusCode::NO_CONTENT - { - Ok(()) - } else { - Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Failed to delete config item: HTTP {} - {}", - response.get_status(), - buf - ), - })) - } - } - - /// Deletes a secret from a Fastly secret store. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns a non-OK/NO_CONTENT status. - pub fn delete_secret( - &self, - store_id: &str, - secret_name: &str, - ) -> Result<(), Report> { - let path = format!( - "/resources/stores/secret/{}/secrets/{}", - store_id, secret_name - ); - - let mut response = self.make_request("DELETE", &path, None, "application/json")?; - - let mut buf = String::new(); - response - .get_body_mut() - .read_to_string(&mut buf) - .map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("Failed to read API response: {}", e), - }) - })?; - - if response.get_status() == StatusCode::OK - || response.get_status() == StatusCode::NO_CONTENT - { - Ok(()) - } else { - Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Failed to delete secret: HTTP {} - {}", - response.get_status(), - buf - ), - })) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_config_store_new() { - let store = FastlyConfigStore::new("test_store"); - assert_eq!(store.store_name, "test_store"); - } - - #[test] - fn test_secret_store_new() { - let store = FastlySecretStore::new("test_secrets"); - assert_eq!(store.store_name, "test_secrets"); - } - - #[test] - fn test_config_store_get() { - let store = FastlyConfigStore::new("jwks_store"); - let result = store.get("current-kid"); - match result { - Ok(kid) => println!("Current KID: {}", kid), - Err(e) => println!("Expected error in test environment: {}", e), - } - } - - #[test] - fn test_secret_store_get() { - let store = FastlySecretStore::new("signing_keys"); - let config_store = FastlyConfigStore::new("jwks_store"); - - match config_store.get("current-kid") { - Ok(kid) => match store.get(&kid) { - Ok(bytes) => { - println!("Successfully loaded secret, {} bytes", bytes.len()); - assert!(!bytes.is_empty()); - } - Err(e) => println!("Error loading secret: {}", e), - }, - Err(e) => println!("Error getting current kid: {}", e), - } - } - - #[test] - fn test_api_client_creation() { - let result = FastlyApiClient::new(); - match result { - Ok(_client) => println!("Successfully created API client"), - Err(e) => println!("Expected error in test environment: {}", e), - } - } - - // Other tests logic is preserved, prints error which is now a Report - #[test] - fn test_update_config_item() { - let result = FastlyApiClient::new(); - if let Ok(client) = result { - let result = - client.update_config_item("5WNlRjznCUAGTU0QeYU8x2", "test-key", "test-value"); - match result { - Ok(()) => println!("Successfully updated config item"), - Err(e) => println!("Failed to update config item: {}", e), - } - } - } - - #[test] - fn test_create_secret() { - let result = FastlyApiClient::new(); - if let Ok(client) = result { - let result = client.create_secret( - "Ltf3CkSGV0Yn2PIC2lDcZx", - "test-secret-new", - "SGVsbG8sIHdvcmxkIQ==", - ); - match result { - Ok(()) => println!("Successfully created secret"), - Err(e) => println!("Failed to create secret: {}", e), - } - } - } -} diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index a554dc3b..eee01f4f 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -1,48 +1,48 @@ //! Geographic location utilities for the trusted server. //! -//! This module provides functions for extracting and handling geographic -//! information from incoming requests, particularly DMA (Designated Market Area) codes. +//! This module provides Fastly-specific helpers for extracting geographic +//! information from incoming requests and writing geo headers to responses. +//! +//! The [`GeoInfo`] data type is defined in [`crate::platform`] as platform- +//! neutral data; this module re-exports it and adds Fastly-coupled `impl` +//! blocks for construction and response header injection. -use fastly::geo::geo_lookup; +use fastly::geo::{geo_lookup, Geo}; use fastly::{Request, Response}; +pub use crate::platform::GeoInfo; + use crate::constants::{ HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, }; -/// Geographic information extracted from a request. +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. /// -/// Contains all available geographic data from Fastly's geolocation service, -/// including city, country, continent, coordinates, and DMA/metro code. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GeoInfo { - /// City name - pub city: String, - /// Two-letter country code (e.g., "US", "GB") - pub country: String, - /// Continent name - pub continent: String, - /// Latitude coordinate - pub latitude: f64, - /// Longitude coordinate - pub longitude: f64, - /// DMA (Designated Market Area) / metro code - pub metro_code: i64, - /// Region code - pub region: Option, +/// Shared by [`GeoInfo::from_request`] and `FastlyPlatformGeo::lookup` in +/// `trusted-server-adapter-fastly` so that field mapping is never duplicated. +pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + } } impl GeoInfo { /// Creates a new `GeoInfo` from a request by performing a geo lookup. /// - /// This constructor performs a geo lookup based on the client's IP address and returns - /// all available geographic data in a structured format. It does not modify the request - /// or set headers. - /// - /// # Arguments + /// # Legacy /// - /// * `req` - The request to extract geographic information from + /// This is a Fastly-coupled convenience method that predates the + /// `platform` abstraction. New code should use + /// `RuntimeServices::geo.lookup(client_info.client_ip)` instead, which + /// goes through [`crate::platform::PlatformGeo`] and does not require + /// direct access to the Fastly request. /// /// # Returns /// @@ -53,33 +53,12 @@ impl GeoInfo { /// ```ignore /// if let Some(geo_info) = GeoInfo::from_request(&req) { /// println!("User is in {} ({})", geo_info.city, geo_info.country); - /// println!("Coordinates: {}", geo_info.coordinates_string()); /// } /// ``` pub fn from_request(req: &Request) -> Option { req.get_client_ip_addr() .and_then(geo_lookup) - .map(|geo| GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - }) - } - - /// Returns coordinates as a formatted string "latitude,longitude" - #[must_use] - pub fn coordinates_string(&self) -> String { - format!("{},{}", self.latitude, self.longitude) - } - - /// Checks if a valid metro code is available (non-zero) - #[must_use] - pub fn has_metro_code(&self) -> bool { - self.metro_code > 0 + .map(|geo| geo_from_fastly(&geo)) } /// Sets geo information headers on the response. @@ -291,7 +270,6 @@ mod tests { response.get_header(HEADER_X_GEO_REGION).is_none(), "should not set region header when region is None" ); - // Other headers should still be present assert!( response.get_header(HEADER_X_GEO_CITY).is_some(), "should still set city header" diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 1fd95369..e09f7ef8 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -42,7 +42,6 @@ pub mod constants; pub mod cookies; pub mod creative; pub mod error; -pub mod fastly_storage; pub mod geo; pub(crate) mod host_rewrite; pub mod html_processor; @@ -50,6 +49,7 @@ pub mod http_util; pub mod integrations; pub mod models; pub mod openrtb; +pub mod platform; pub mod proxy; pub mod publisher; pub mod redacted; @@ -57,6 +57,7 @@ pub mod request_signing; pub mod rsc_flight; pub mod settings; pub mod settings_data; +pub mod storage; pub mod streaming_processor; pub mod streaming_replacer; pub mod synthetic; diff --git a/crates/trusted-server-core/src/platform/error.rs b/crates/trusted-server-core/src/platform/error.rs new file mode 100644 index 00000000..9619a562 --- /dev/null +++ b/crates/trusted-server-core/src/platform/error.rs @@ -0,0 +1,34 @@ +use derive_more::Display; + +/// Root error type for platform service operations. +/// +/// Use with `error-stack`'s `Report` to attach context before propagating. +#[derive(Debug, Display)] +pub enum PlatformError { + /// Input validation failed before delegating to the platform. + #[display("validation error")] + Validation, + /// Config store access failed. + #[display("config store error")] + ConfigStore, + /// Secret store access failed. + #[display("secret store error")] + SecretStore, + /// Backend registration or name computation failed. + #[display("backend error")] + Backend, + /// HTTP client request failed. + #[display("http client error")] + HttpClient, + /// Geo lookup failed. + #[display("geo lookup error")] + Geo, + /// Operation is not supported by this platform adapter. + #[display("unsupported platform operation")] + Unsupported, + /// Operation is defined by the trait but not yet implemented in this adapter. + #[display("not yet implemented")] + NotImplemented, +} + +impl core::error::Error for PlatformError {} diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs new file mode 100644 index 00000000..757d4a85 --- /dev/null +++ b/crates/trusted-server-core/src/platform/http.rs @@ -0,0 +1,191 @@ +use std::any::Any; +use std::fmt; + +use edgezero_core::http::{Request as EdgeRequest, Response as EdgeResponse}; +use error_stack::Report; + +use super::PlatformError; + +/// Outbound HTTP request paired with a pre-resolved backend name. +/// +/// Uses `EdgeZero`'s neutral [`EdgeRequest`] type so adapters share one +/// HTTP/body model while still preserving backend correlation for Fastly-style +/// fan-out. +#[derive(Debug)] +pub struct PlatformHttpRequest { + /// Platform-neutral request to send upstream. + pub request: EdgeRequest, + /// Backend name resolved ahead of time via `PlatformBackend`. + pub backend_name: String, +} + +impl PlatformHttpRequest { + /// Create a new outbound request wrapper. + #[must_use] + pub fn new(request: EdgeRequest, backend_name: impl Into) -> Self { + Self { + request, + backend_name: backend_name.into(), + } + } +} + +/// Outbound HTTP response with optional backend correlation metadata. +#[derive(Debug)] +pub struct PlatformResponse { + /// Platform-neutral HTTP response. + pub response: EdgeResponse, + /// Backend that produced the response, when known. + pub backend_name: Option, +} + +impl PlatformResponse { + /// Create a response wrapper without backend metadata. + #[must_use] + pub fn new(response: EdgeResponse) -> Self { + Self { + response, + backend_name: None, + } + } + + /// Attach backend correlation metadata to the response. + #[must_use] + pub fn with_backend_name(mut self, backend_name: impl Into) -> Self { + self.backend_name = Some(backend_name.into()); + self + } +} + +/// Opaque handle for an in-flight outbound request. +/// +/// The core stores this as an opaque support type. Adapter implementations can +/// recover their concrete runtime handle through [`Self::downcast`]. +/// +/// # `!Send` design +/// +/// `inner` is `Box` (not `Box`) because all async +/// operations in this platform layer use `#[async_trait(?Send)]`. The `?Send` +/// bound exists because [`edgezero_core::body::Body`] wraps a +/// `LocalBoxStream` that is intentionally `!Send` for wasm32 compatibility — +/// wasm32 targets are single-threaded and cannot use `Send` futures. +/// Adapter crates targeting a multi-threaded runtime (e.g. Axum with tokio) +/// would need to wrap state in `Arc` rather than relying on `Send` here. +pub struct PlatformPendingRequest { + inner: Box, + backend_name: Option, +} + +impl PlatformPendingRequest { + /// Wrap an adapter-specific pending request handle. + #[must_use] + pub fn new(inner: T) -> Self + where + T: Any, + { + Self { + inner: Box::new(inner), + backend_name: None, + } + } + + /// Attach backend correlation metadata to the pending request. + #[must_use] + pub fn with_backend_name(mut self, backend_name: impl Into) -> Self { + self.backend_name = Some(backend_name.into()); + self + } + + /// Return the correlated backend name when it is known before completion. + #[must_use] + pub fn backend_name(&self) -> Option<&str> { + self.backend_name.as_deref() + } + + /// Recover the adapter-specific pending request type. + /// + /// # Errors + /// + /// Returns `Err(self)` — the original wrapper with its backend metadata + /// preserved — when `T` does not match the stored type. + pub fn downcast(self) -> Result + where + T: Any, + { + let Self { + inner, + backend_name, + } = self; + + match inner.downcast::() { + Ok(inner) => Ok(*inner), + Err(inner) => Err(Self { + inner, + backend_name, + }), + } + } +} + +impl fmt::Debug for PlatformPendingRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PlatformPendingRequest") + .field("backend_name", &self.backend_name) + .finish() + } +} + +/// Result of waiting for one in-flight request to complete. +#[derive(Debug)] +pub struct PlatformSelectResult { + /// Completed response, or the error returned by the ready request. + pub ready: Result>, + /// Requests still in flight after the ready result is removed. + pub remaining: Vec, +} + +/// Outbound HTTP client abstraction. +/// +/// This extends `EdgeZero`'s current `ProxyClient` shape so the core platform +/// surface can support both single-request sends and Fastly-style async +/// fan-out (`send_async` + `select`) without keeping parallel abstractions +/// alive. Object safety is provided by `async_trait`, which boxes the returned +/// futures behind `dyn PlatformHttpClient`. +/// +/// Uses `?Send` on all targets because [`edgezero_core::body::Body`] contains +/// a `LocalBoxStream` that is `!Send` by design for wasm32 compatibility. +#[async_trait::async_trait(?Send)] +pub trait PlatformHttpClient: Send + Sync { + /// Send a single upstream request and wait for the response. + /// + /// # Errors + /// + /// Returns `PlatformError::HttpClient` when the request cannot be sent or + /// the platform client fails before a response is produced. + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result>; + + /// Start an upstream request without waiting for it to complete. + /// + /// # Errors + /// + /// Returns `PlatformError::HttpClient` when the request cannot be + /// started. + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result>; + + /// Wait for one of the in-flight requests to complete. + /// + /// # Errors + /// + /// Returns `PlatformError::HttpClient` if the platform cannot poll the + /// pending requests at all. + async fn select( + &self, + pending_requests: Vec, + ) -> Result>; +} diff --git a/crates/trusted-server-core/src/platform/kv.rs b/crates/trusted-server-core/src/platform/kv.rs new file mode 100644 index 00000000..81d5a82d --- /dev/null +++ b/crates/trusted-server-core/src/platform/kv.rs @@ -0,0 +1,47 @@ +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore as PlatformKvStore}; + +/// A [`PlatformKvStore`] stand-in used when the primary KV store cannot be +/// opened at startup. +/// +/// Every method returns [`KvError::Unavailable`], ensuring that handlers +/// which call [`crate::platform::RuntimeServices::kv_handle`] receive a typed +/// error rather than a panic. Routes that do not touch the KV store are +/// unaffected. +/// +/// Adapter crates should use this type rather than defining their own stub so +/// the fallback behaviour is consistent across all platform implementations. +pub struct UnavailableKvStore; + +#[async_trait::async_trait(?Send)] +impl PlatformKvStore for UnavailableKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Err(KvError::Unavailable) + } + + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Err(KvError::Unavailable) + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs new file mode 100644 index 00000000..312c9b13 --- /dev/null +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -0,0 +1,144 @@ +//! Platform abstraction layer for `trusted-server-core`. +//! +//! This module defines platform-neutral service contracts and request-scoped +//! runtime state. Concrete implementations live in adapter crates such as +//! `trusted-server-adapter-fastly`. + +mod error; +mod http; +mod kv; +#[cfg(test)] +pub(crate) mod test_support; +mod traits; +mod types; + +pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; +pub use error::PlatformError; +pub use http::{ + PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSelectResult, +}; +pub use kv::UnavailableKvStore; +pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; +pub use types::{ + ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, + StoreName, +}; + +#[cfg(test)] +mod tests { + use super::test_support::noop_services; + use super::*; + + fn _assert_config_store_object_safe(_: &dyn PlatformConfigStore) {} + fn _assert_secret_store_object_safe(_: &dyn PlatformSecretStore) {} + fn _assert_kv_store_object_safe(_: &dyn PlatformKvStore) {} + fn _assert_backend_object_safe(_: &dyn PlatformBackend) {} + fn _assert_http_client_object_safe(_: &dyn PlatformHttpClient) {} + fn _assert_geo_object_safe(_: &dyn PlatformGeo) {} + // Arc requires the trait impl to be Send + Sync. The assertion + // below documents that RuntimeServices itself satisfies those bounds — the + // compiler verifies this at the point where RuntimeServices is constructed. + fn _assert_runtime_services_send_sync() + where + RuntimeServices: Send + Sync, + { + } + + #[test] + fn runtime_services_can_be_constructed_and_cloned() { + let services = noop_services(); + let cloned = services.clone(); + + assert!( + cloned.client_info.client_ip.is_none(), + "should preserve client_ip through clone" + ); + assert!( + cloned.client_info.tls_protocol.is_none(), + "should preserve tls_protocol through clone" + ); + } + + #[test] + fn runtime_services_geo_lookup_returns_none_for_no_ip() { + let services = noop_services(); + let result = services + .geo + .lookup(services.client_info.client_ip) + .expect("should not fail for noop geo with no ip"); + assert!(result.is_none(), "should return None when no IP is present"); + } + + #[test] + fn platform_pending_request_downcasts_and_preserves_backend_name() { + let pending = PlatformPendingRequest::new(7_u8).with_backend_name("origin"); + let pending = pending + .downcast::() + .expect_err("should reject downcast to the wrong pending type"); + assert_eq!( + pending.backend_name(), + Some("origin"), + "should preserve backend metadata when downcast fails" + ); + + let pending = PlatformPendingRequest::new(7_u8).with_backend_name("origin"); + let value = pending + .downcast::() + .expect("should recover the stored pending request type"); + assert_eq!(value, 7, "should preserve the stored pending request"); + } + + #[test] + fn geo_info_coordinates_string_formats_correctly() { + let geo = GeoInfo { + city: "New York".to_string(), + country: "US".to_string(), + continent: "NorthAmerica".to_string(), + latitude: 40.7128, + longitude: -74.0060, + metro_code: 501, + region: Some("NY".to_string()), + }; + + assert_eq!( + geo.coordinates_string(), + "40.7128,-74.006", + "should format coordinates as lat,lon" + ); + } + + #[test] + fn geo_info_has_metro_code_returns_true_for_nonzero() { + let geo = GeoInfo { + city: String::new(), + country: String::new(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 807, + region: None, + }; + assert!( + geo.has_metro_code(), + "should return true for non-zero metro code" + ); + } + + #[test] + fn geo_info_has_metro_code_returns_false_for_zero() { + let geo = GeoInfo { + city: String::new(), + country: String::new(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + }; + assert!( + !geo.has_metro_code(), + "should return false for zero metro code" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs new file mode 100644 index 00000000..3bdb6b2b --- /dev/null +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -0,0 +1,126 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use error_stack::Report; + +use super::{ + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, +}; + +pub(crate) struct NoopConfigStore; + +impl PlatformConfigStore for NoopConfigStore { + fn get(&self, _store_name: &StoreName, _key: &str) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +pub(crate) struct NoopSecretStore; + +impl PlatformSecretStore for NoopSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +pub(crate) struct NoopBackend; + +impl PlatformBackend for NoopBackend { + fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +pub(crate) struct NoopHttpClient; + +// ?Send matches PlatformHttpClient. Body wraps LocalBoxStream which is !Send +// by design; see http.rs for the full rationale. +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for NoopHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +pub(crate) struct NoopGeo; + +impl PlatformGeo for NoopGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(None) + } +} + +pub(crate) fn build_services_with_config( + config_store: impl PlatformConfigStore + 'static, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(config_store)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + +pub(crate) fn noop_services() -> RuntimeServices { + build_services_with_config(NoopConfigStore) +} diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs new file mode 100644 index 00000000..281b4236 --- /dev/null +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -0,0 +1,132 @@ +use std::net::IpAddr; + +use error_stack::Report; + +use super::{GeoInfo, PlatformBackendSpec, PlatformError, StoreId, StoreName}; + +/// Synchronous, object-safe access to a key-value config store. +/// +/// Reads use the edge-visible store name. Writes use the platform management +/// store identifier because Fastly separates the runtime store name from the +/// management API store ID. +pub trait PlatformConfigStore: Send + Sync { + /// Retrieve a string value from `store_name` by `key`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the key does not exist or + /// the store cannot be opened. + fn get(&self, store_name: &StoreName, key: &str) -> Result>; + + /// Store a string value in the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the write fails or the + /// platform management API is unreachable. Returns + /// [`PlatformError::NotImplemented`] when the adapter has not yet + /// implemented write support. + fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report>; + + /// Delete a key from the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the delete fails or the + /// platform management API is unreachable. Returns + /// [`PlatformError::NotImplemented`] when the adapter has not yet + /// implemented write support. + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report>; +} + +/// Synchronous, object-safe access to a secret store. +/// +/// Reads use the edge-visible store name. Writes use the platform management +/// store identifier. +pub trait PlatformSecretStore: Send + Sync { + /// Retrieve a secret value as raw bytes from `store_name` by `key`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the store cannot be opened, + /// the key does not exist, or decryption fails. + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report>; + + /// Retrieve a secret value as a UTF-8 string from `store_name` by `key`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the secret cannot be + /// retrieved or is not valid UTF-8. + fn get_string( + &self, + store_name: &StoreName, + key: &str, + ) -> Result> { + let bytes = self.get_bytes(store_name, key)?; + String::from_utf8(bytes).map_err(|error| { + Report::new(PlatformError::SecretStore) + .attach(format!("secret is not valid UTF-8: {error}")) + }) + } + + /// Create or overwrite a secret in the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the create fails or the + /// platform management API is unreachable. Returns + /// [`PlatformError::NotImplemented`] when the adapter has not yet + /// implemented write support. + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report>; + + /// Delete a secret from the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the delete fails or the + /// platform management API is unreachable. Returns + /// [`PlatformError::NotImplemented`] when the adapter has not yet + /// implemented write support. + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report>; +} + +/// Synchronous, object-safe dynamic backend management. +pub trait PlatformBackend: Send + Sync { + /// Compute the deterministic backend name for the given spec without + /// registering anything. + /// + /// # Errors + /// + /// Returns [`PlatformError::Backend`] when the spec is invalid or the + /// name cannot be computed. + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result>; + + /// Ensure a dynamic backend exists for the given spec and return its name. + /// + /// # Errors + /// + /// Returns [`PlatformError::Backend`] when the backend cannot be + /// registered on the platform. + fn ensure(&self, spec: &PlatformBackendSpec) -> Result>; +} + +/// Synchronous, object-safe geo lookup. +pub trait PlatformGeo: Send + Sync { + /// Look up geographic information for the given client IP address. + /// + /// # Errors + /// + /// Returns [`PlatformError::Geo`] when the platform geo lookup fails + /// unexpectedly. Returns `Ok(None)` when no data is available for the IP. + fn lookup(&self, client_ip: Option) -> Result, Report>; +} diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs new file mode 100644 index 00000000..05b1c1b8 --- /dev/null +++ b/crates/trusted-server-core/src/platform/types.rs @@ -0,0 +1,330 @@ +use std::fmt; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use super::{ + PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, + PlatformSecretStore, +}; + +/// Geographic information extracted from a request. +/// +/// Serde derives are required because `GeoInfo` is embedded in +/// `AuctionRequest`, which is serialised for bid-request payloads. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GeoInfo { + /// City name. + pub city: String, + /// Two-letter country code. + pub country: String, + /// Continent name. + pub continent: String, + /// Latitude coordinate. + pub latitude: f64, + /// Longitude coordinate. + pub longitude: f64, + /// DMA (Designated Market Area) / metro code. + pub metro_code: i64, + /// Region code. + pub region: Option, +} + +impl GeoInfo { + /// Returns coordinates as a formatted string `"latitude,longitude"`. + #[must_use] + pub fn coordinates_string(&self) -> String { + format!("{},{}", self.latitude, self.longitude) + } + + /// Checks if a valid metro code is available. + #[must_use] + pub fn has_metro_code(&self) -> bool { + self.metro_code > 0 + } +} + +/// Per-request client metadata extracted once at the adapter entry point. +#[derive(Debug, Clone)] +pub struct ClientInfo { + /// Client IP address, if available. + pub client_ip: Option, + /// TLS protocol version string, if the connection used TLS. + pub tls_protocol: Option, + /// OpenSSL cipher name, if the connection used TLS. + pub tls_cipher: Option, +} + +/// Edge-visible name used to open a config or secret store at runtime. +/// +/// Passed to read methods on [`super::PlatformConfigStore`] and +/// [`super::PlatformSecretStore`]. Distinct from [`StoreId`] to prevent +/// accidentally passing a management API identifier where a runtime name is +/// expected. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct StoreName(String); + +impl From for StoreName { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for StoreName { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl AsRef for StoreName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Management API identifier used to write to a config or secret store. +/// +/// Passed to write methods on [`super::PlatformConfigStore`] and +/// [`super::PlatformSecretStore`]. Distinct from [`StoreName`] to prevent +/// accidentally passing a runtime store name where a management API +/// identifier is expected. +#[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] +pub struct StoreId(String); + +impl From for StoreId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for StoreId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl AsRef for StoreId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Input specification for a dynamic backend. +/// +/// Passed to [`PlatformBackend::predict_name`] and [`PlatformBackend::ensure`] +/// to deterministically name and register upstream origins. +#[derive(Debug, Clone)] +pub struct PlatformBackendSpec { + /// URL scheme. + pub scheme: String, + /// Hostname of the backend origin. + pub host: String, + /// Explicit port, or `None` to use the scheme default. + pub port: Option, + /// Whether to verify the TLS certificate. + pub certificate_check: bool, + /// Maximum time to wait for the first response byte. + pub first_byte_timeout: Duration, +} + +/// Cloneable container of platform services for a single request. +#[derive(Clone)] +pub struct RuntimeServices { + /// Access to key-value config stores. + pub(crate) config_store: Arc, + /// Access to encrypted secret stores. + pub(crate) secret_store: Arc, + /// KV store for the primary (opid) store. + /// + /// Additional stores (`counter_store`, `creative_store`) are opened on + /// demand in individual handlers until multi-store support lands here. + pub(crate) kv_store: Arc, + /// Dynamic backend registration and name prediction. + pub(crate) backend: Arc, + /// Outbound HTTP client abstraction. + pub(crate) http_client: Arc, + /// Geographic information lookup. + pub(crate) geo: Arc, + /// Per-request client metadata extracted at the entry point. + pub client_info: ClientInfo, +} + +impl RuntimeServices { + /// Create a builder for [`RuntimeServices`]. + /// + /// Adapter crates should use this builder rather than constructing + /// [`RuntimeServices`] directly, so that any future invariants on the + /// struct are enforced in one place. + /// + /// # Examples + /// + /// ```ignore + /// let services = RuntimeServices::builder() + /// .config_store(Arc::new(MyConfigStore)) + /// .secret_store(Arc::new(MySecretStore)) + /// .kv_store(kv_store) + /// .backend(Arc::new(MyBackend)) + /// .http_client(Arc::new(MyHttpClient)) + /// .geo(Arc::new(MyGeo)) + /// .client_info(client_info) + /// .build(); + /// ``` + #[must_use] + pub fn builder() -> RuntimeServicesBuilder { + RuntimeServicesBuilder::new() + } + + /// Returns the config store service. + #[must_use] + pub fn config_store(&self) -> &dyn PlatformConfigStore { + &*self.config_store + } + + /// Returns the secret store service. + #[must_use] + pub fn secret_store(&self) -> &dyn PlatformSecretStore { + &*self.secret_store + } + + /// Returns the dynamic backend service. + #[must_use] + pub fn backend(&self) -> &dyn PlatformBackend { + &*self.backend + } + + /// Returns the outbound HTTP client service. + #[must_use] + pub fn http_client(&self) -> &dyn PlatformHttpClient { + &*self.http_client + } + + /// Returns the platform geo lookup service. + #[must_use] + pub fn geo(&self) -> &dyn PlatformGeo { + &*self.geo + } + + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to + /// JSON helpers, pagination, and validation. + #[must_use] + pub fn kv_handle(&self) -> super::KvHandle { + super::KvHandle::new(self.kv_store.clone()) + } +} + +impl fmt::Debug for RuntimeServices { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RuntimeServices") + .field("client_info", &self.client_info) + .finish_non_exhaustive() + } +} + +/// Builder for [`RuntimeServices`]. +/// +/// Obtain a builder via [`RuntimeServices::builder`] and set each service +/// before calling [`RuntimeServicesBuilder::build`]. +pub struct RuntimeServicesBuilder { + config_store: Option>, + secret_store: Option>, + kv_store: Option>, + backend: Option>, + http_client: Option>, + geo: Option>, + client_info: Option, +} + +impl RuntimeServicesBuilder { + fn new() -> Self { + Self { + config_store: None, + secret_store: None, + kv_store: None, + backend: None, + http_client: None, + geo: None, + client_info: None, + } + } + + /// Set the config store implementation. + #[must_use] + pub fn config_store(mut self, config_store: Arc) -> Self { + self.config_store = Some(config_store); + self + } + + /// Set the secret store implementation. + #[must_use] + pub fn secret_store(mut self, secret_store: Arc) -> Self { + self.secret_store = Some(secret_store); + self + } + + /// Set the KV store implementation. + #[must_use] + pub fn kv_store(mut self, kv_store: Arc) -> Self { + self.kv_store = Some(kv_store); + self + } + + /// Set the backend implementation. + #[must_use] + pub fn backend(mut self, backend: Arc) -> Self { + self.backend = Some(backend); + self + } + + /// Set the HTTP client implementation. + #[must_use] + pub fn http_client(mut self, http_client: Arc) -> Self { + self.http_client = Some(http_client); + self + } + + /// Set the geo lookup implementation. + #[must_use] + pub fn geo(mut self, geo: Arc) -> Self { + self.geo = Some(geo); + self + } + + /// Set the per-request client metadata. + #[must_use] + pub fn client_info(mut self, client_info: ClientInfo) -> Self { + self.client_info = Some(client_info); + self + } + + /// Construct [`RuntimeServices`] from the accumulated configuration. + /// + /// # Panics + /// + /// Panics if any required service has not been set via the builder methods. + #[must_use] + pub fn build(self) -> RuntimeServices { + RuntimeServices { + config_store: self + .config_store + .expect("should set config_store before building RuntimeServices"), + secret_store: self + .secret_store + .expect("should set secret_store before building RuntimeServices"), + kv_store: self + .kv_store + .expect("should set kv_store before building RuntimeServices"), + backend: self + .backend + .expect("should set backend before building RuntimeServices"), + http_client: self + .http_client + .expect("should set http_client before building RuntimeServices"), + geo: self + .geo + .expect("should set geo before building RuntimeServices"), + client_info: self + .client_info + .expect("should set client_info before building RuntimeServices"), + } + } +} diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index 032a6fc5..8bee239a 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -8,6 +8,7 @@ use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; +use crate::platform::RuntimeServices; use crate::request_signing::discovery::TrustedServerDiscovery; use crate::request_signing::rotation::KeyRotationManager; use crate::request_signing::signing; @@ -24,25 +25,25 @@ use crate::settings::Settings; /// Returns an error if JWKS cannot be retrieved, parsed, or serialized. pub fn handle_trusted_server_discovery( _settings: &Settings, + services: &RuntimeServices, _req: Request, ) -> Result> { - // Get JWKS - let jwks_json = crate::request_signing::jwks::get_active_jwks().change_context( + let jwks_json = crate::request_signing::jwks::get_active_jwks(services).change_context( TrustedServerError::Configuration { - message: "Failed to retrieve JWKS".into(), + message: "failed to retrieve JWKS".into(), }, )?; let jwks_value: serde_json::Value = serde_json::from_str(&jwks_json).change_context(TrustedServerError::Configuration { - message: "Failed to parse JWKS JSON".into(), + message: "failed to parse JWKS JSON".into(), })?; let discovery = TrustedServerDiscovery::new(jwks_value); let json = serde_json::to_string_pretty(&discovery).change_context( TrustedServerError::Configuration { - message: "Failed to serialize discovery document".into(), + message: "failed to serialize discovery document".into(), }, )?; @@ -80,7 +81,7 @@ pub fn handle_verify_signature( let body = req.take_body_str(); let verify_req: VerifySignatureRequest = serde_json::from_str(&body).change_context(TrustedServerError::Configuration { - message: "Invalid JSON request body".into(), + message: "invalid JSON request body".into(), })?; let verification_result = signing::verify_signature( @@ -112,7 +113,7 @@ pub fn handle_verify_signature( let response_json = serde_json::to_string(&response).map_err(|e| { Report::new(TrustedServerError::Configuration { - message: format!("Failed to serialize response: {}", e), + message: format!("failed to serialize response: {}", e), }) })?; @@ -152,7 +153,7 @@ pub fn handle_rotate_key( Some(setting) => (&setting.config_store_id, &setting.secret_store_id), None => { return Err(TrustedServerError::Configuration { - message: "Missing signing storage configuration.".to_string(), + message: "missing signing storage configuration".to_string(), } .into()); } @@ -163,13 +164,13 @@ pub fn handle_rotate_key( RotateKeyRequest { kid: None } } else { serde_json::from_str(&body).change_context(TrustedServerError::Configuration { - message: "Invalid JSON request body".into(), + message: "invalid JSON request body".into(), })? }; let manager = KeyRotationManager::new(config_store_id, secret_store_id).change_context( TrustedServerError::Configuration { - message: "Failed to create KeyRotationManager".into(), + message: "failed to create KeyRotationManager".into(), }, )?; @@ -177,7 +178,7 @@ pub fn handle_rotate_key( Ok(result) => { let jwk_value = serde_json::to_value(&result.jwk).map_err(|e| { Report::new(TrustedServerError::Configuration { - message: format!("Failed to serialize JWK: {}", e), + message: format!("failed to serialize JWK: {}", e), }) })?; @@ -193,7 +194,7 @@ pub fn handle_rotate_key( let response_json = serde_json::to_string(&response).map_err(|e| { Report::new(TrustedServerError::Configuration { - message: format!("Failed to serialize response: {}", e), + message: format!("failed to serialize response: {}", e), }) })?; @@ -214,7 +215,7 @@ pub fn handle_rotate_key( let response_json = serde_json::to_string(&response).map_err(|e| { Report::new(TrustedServerError::Configuration { - message: format!("Failed to serialize response: {}", e), + message: format!("failed to serialize response: {}", e), }) })?; @@ -256,7 +257,7 @@ pub fn handle_deactivate_key( Some(setting) => (&setting.config_store_id, &setting.secret_store_id), None => { return Err(TrustedServerError::Configuration { - message: "Missing signing storage configuration.".to_string(), + message: "missing signing storage configuration".to_string(), } .into()); } @@ -265,12 +266,12 @@ pub fn handle_deactivate_key( let body = req.take_body_str(); let deactivate_req: DeactivateKeyRequest = serde_json::from_str(&body).change_context(TrustedServerError::Configuration { - message: "Invalid JSON request body".into(), + message: "invalid JSON request body".into(), })?; let manager = KeyRotationManager::new(config_store_id, secret_store_id).change_context( TrustedServerError::Configuration { - message: "Failed to create KeyRotationManager".into(), + message: "failed to create KeyRotationManager".into(), }, )?; @@ -282,7 +283,10 @@ pub fn handle_deactivate_key( match result { Ok(()) => { - let remaining_keys = manager.list_active_keys().unwrap_or_else(|_| vec![]); + let remaining_keys = manager.list_active_keys().unwrap_or_else(|e| { + log::warn!("failed to list active keys after deactivation: {}", e); + vec![] + }); let response = DeactivateKeyResponse { success: true, @@ -299,7 +303,7 @@ pub fn handle_deactivate_key( let response_json = serde_json::to_string(&response).map_err(|e| { Report::new(TrustedServerError::Configuration { - message: format!("Failed to serialize response: {}", e), + message: format!("failed to serialize response: {}", e), }) })?; @@ -323,7 +327,7 @@ pub fn handle_deactivate_key( let response_json = serde_json::to_string(&response).map_err(|e| { Report::new(TrustedServerError::Configuration { - message: format!("Failed to serialize response: {}", e), + message: format!("failed to serialize response: {}", e), }) })?; @@ -336,9 +340,39 @@ pub fn handle_deactivate_key( #[cfg(test)] mod tests { + use error_stack::Report; + + use crate::platform::{ + test_support::{build_services_with_config, noop_services}, + PlatformConfigStore, PlatformError, StoreId, StoreName, + }; + use super::*; use fastly::http::{Method, StatusCode}; + /// Config store stub that returns a minimal JWKS with one Ed25519 key. + struct StubJwksConfigStore; + + impl PlatformConfigStore for StubJwksConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + match key { + "active-kids" => Ok("test-kid-1".to_string()), + "test-kid-1" => Ok( + r#"{"kty":"OKP","crv":"Ed25519","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","kid":"test-kid-1","alg":"EdDSA"}"# + .to_string(), + ), + _ => Err(Report::new(PlatformError::ConfigStore)), + } + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } #[test] fn test_handle_verify_signature_valid() { let settings = crate::test_support::tests::create_test_settings(); @@ -580,7 +614,8 @@ mod tests { "https://test.com/.well-known/trusted-server.json", ); - let result = handle_trusted_server_discovery(&settings, req); + let services = noop_services(); + let result = handle_trusted_server_discovery(&settings, &services, req); match result { Ok(mut resp) => { assert_eq!(resp.get_status(), StatusCode::OK); @@ -601,4 +636,35 @@ mod tests { Err(e) => log::debug!("Expected error in test environment: {}", e), } } + + #[test] + fn test_handle_trusted_server_discovery_returns_jwks_document() { + let settings = crate::test_support::tests::create_test_settings(); + let req = Request::new( + Method::GET, + "https://test.com/.well-known/trusted-server.json", + ); + + let services = build_services_with_config(StubJwksConfigStore); + let mut resp = handle_trusted_server_discovery(&settings, &services, req) + .expect("should return discovery document when config store is populated"); + + assert_eq!(resp.get_status(), StatusCode::OK, "should return 200 OK"); + + let body = resp.take_body_str(); + let discovery: serde_json::Value = + serde_json::from_str(&body).expect("should parse discovery document as JSON"); + + assert_eq!(discovery["version"], "1.0", "should return version 1.0"); + + let keys = discovery["jwks"]["keys"] + .as_array() + .expect("should have jwks.keys array"); + assert_eq!(keys.len(), 1, "should contain exactly one key"); + assert_eq!( + keys[0]["kid"], "test-kid-1", + "should include the active key ID" + ); + assert_eq!(keys[0]["crv"], "Ed25519", "should be an Ed25519 key"); + } } diff --git a/crates/trusted-server-core/src/request_signing/jwks.rs b/crates/trusted-server-core/src/request_signing/jwks.rs index 24b86fb4..5c4dda94 100644 --- a/crates/trusted-server-core/src/request_signing/jwks.rs +++ b/crates/trusted-server-core/src/request_signing/jwks.rs @@ -3,6 +3,8 @@ //! This module provides functionality for generating, storing, and retrieving //! Ed25519 keypairs in JWK format for request signing. +use std::sync::LazyLock; + use ed25519_dalek::{SigningKey, VerifyingKey}; use error_stack::{Report, ResultExt}; use jose_jwk::{ @@ -12,15 +14,20 @@ use jose_jwk::{ use rand::rngs::OsRng; use crate::error::TrustedServerError; -use crate::fastly_storage::FastlyConfigStore; +use crate::platform::{RuntimeServices, StoreName}; use crate::request_signing::JWKS_CONFIG_STORE_NAME; +static JWKS_STORE_NAME: LazyLock = + LazyLock::new(|| StoreName::from(JWKS_CONFIG_STORE_NAME)); + +/// An Ed25519 keypair used for request signing. pub struct Keypair { pub signing_key: SigningKey, pub verifying_key: VerifyingKey, } impl Keypair { + /// Generate a new random Ed25519 keypair. #[must_use] pub fn generate() -> Self { let mut csprng = OsRng; @@ -34,6 +41,7 @@ impl Keypair { } } + /// Produce a public JWK from the verifying key, tagged with the given `kid`. #[must_use] pub fn get_jwk(&self, kid: String) -> Jwk { let public_key_bytes = self.verifying_key.as_bytes(); @@ -41,7 +49,7 @@ impl Keypair { let okp = Okp { crv: OkpCurves::Ed25519, x: public_key_bytes.to_vec().into(), - d: None, // No private key in JWK (public only) + d: None, }; Jwk { @@ -57,13 +65,22 @@ impl Keypair { /// Retrieves active JSON Web Keys from the config store. /// +/// Reads the `active-kids` entry from the platform config store, then fetches +/// each referenced JWK and assembles a JWKS JSON document. +/// /// # Errors /// -/// Returns an error if the config store cannot be accessed or if active keys cannot be retrieved. -pub fn get_active_jwks() -> Result> { - let store = FastlyConfigStore::new(JWKS_CONFIG_STORE_NAME); - let active_kids_str = store - .get("active-kids") +/// Returns [`TrustedServerError::Configuration`] if the config store is +/// unavailable, the `active-kids` key is missing, or any referenced JWK entry +/// cannot be read. The underlying [`crate::platform::PlatformError`] is +/// preserved as context in the error chain. +pub fn get_active_jwks(services: &RuntimeServices) -> Result> { + let active_kids_str = services + .config_store() + .get(&JWKS_STORE_NAME, "active-kids") + .change_context(TrustedServerError::Configuration { + message: "failed to read active-kids from config store".into(), + }) .attach("while fetching active kids list")?; let active_kids: Vec<&str> = active_kids_str @@ -74,9 +91,12 @@ pub fn get_active_jwks() -> Result> { let mut jwks = Vec::new(); for kid in active_kids { - let jwk = store - .get(kid) - .attach(format!("Failed to get JWK for kid: {}", kid))?; + let jwk = services + .config_store() + .get(&JWKS_STORE_NAME, kid) + .change_context(TrustedServerError::Configuration { + message: format!("failed to get JWK for kid: {}", kid), + })?; jwks.push(jwk); } @@ -86,41 +106,107 @@ pub fn get_active_jwks() -> Result> { #[cfg(test)] mod tests { - use super::*; use ed25519_dalek::{Signer, Verifier}; + use error_stack::Report; use jose_jwk::Key; + use crate::platform::{ + test_support::build_services_with_config, PlatformConfigStore, PlatformError, StoreId, + StoreName, + }; + + use super::*; + + // --------------------------------------------------------------------------- + // Test doubles + // --------------------------------------------------------------------------- + + struct FailingConfigStore; + + impl PlatformConfigStore for FailingConfigStore { + fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { + Err(Report::new(PlatformError::ConfigStore)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + #[test] - fn test_key_pair_generation() { + fn get_active_jwks_fails_with_configuration_error_when_store_unavailable() { + let services = build_services_with_config(FailingConfigStore); + let result = get_active_jwks(&services); + + assert!( + result.is_err(), + "should fail when config store is unavailable" + ); + let err = result.expect_err("should be an error"); + assert!( + err.contains::(), + "should surface as TrustedServerError" + ); + assert!( + err.contains::(), + "should preserve platform error context in the error chain" + ); + } + + #[test] + fn key_pair_generates_valid_signing_key() { let keypair = Keypair::generate(); let message = b"test message"; let signature = keypair.signing_key.sign(message); - assert!(keypair.verifying_key.verify(message, &signature).is_ok()); + assert!( + keypair.verifying_key.verify(message, &signature).is_ok(), + "should verify signature produced by generated key" + ); } #[test] - fn test_create_jwk_from_verifying_key() { + fn get_jwk_produces_correct_structure() { let jwk = Keypair::generate().get_jwk("test-kid".to_string()); - // Verify JWK structure - assert_eq!(jwk.prm.kid, Some("test-kid".to_string())); + assert_eq!( + jwk.prm.kid, + Some("test-kid".to_string()), + "should set kid parameter" + ); assert_eq!( jwk.prm.alg, Some(jose_jwk::jose_jwa::Algorithm::Signing( jose_jwk::jose_jwa::Signing::EdDsa - )) + )), + "should set EdDSA algorithm" ); - // Verify it's an OKP key with Ed25519 curve match jwk.key { Key::Okp(okp) => { - assert_eq!(okp.crv, OkpCurves::Ed25519); - assert_eq!(okp.x.len(), 32); // Ed25519 public keys are 32 bytes - assert!(okp.d.is_none()); // No private key component + assert_eq!(okp.crv, OkpCurves::Ed25519, "should use Ed25519 curve"); + assert_eq!(okp.x.len(), 32, "should be 32-byte Ed25519 public key"); + assert!(okp.d.is_none(), "should have no private key component"); } - _ => panic!("Expected OKP key type"), + _ => panic!("should be OKP key type"), } } } diff --git a/crates/trusted-server-core/src/request_signing/rotation.rs b/crates/trusted-server-core/src/request_signing/rotation.rs index da3abd9e..e2fd8bd5 100644 --- a/crates/trusted-server-core/src/request_signing/rotation.rs +++ b/crates/trusted-server-core/src/request_signing/rotation.rs @@ -9,8 +9,8 @@ use error_stack::{Report, ResultExt}; use jose_jwk::Jwk; use crate::error::TrustedServerError; -use crate::fastly_storage::{FastlyApiClient, FastlyConfigStore}; use crate::request_signing::JWKS_CONFIG_STORE_NAME; +use crate::storage::{FastlyApiClient, FastlyConfigStore}; use super::Keypair; diff --git a/crates/trusted-server-core/src/request_signing/signing.rs b/crates/trusted-server-core/src/request_signing/signing.rs index c256da02..bed25084 100644 --- a/crates/trusted-server-core/src/request_signing/signing.rs +++ b/crates/trusted-server-core/src/request_signing/signing.rs @@ -9,8 +9,8 @@ use error_stack::{Report, ResultExt}; use serde::Serialize; use crate::error::TrustedServerError; -use crate::fastly_storage::{FastlyConfigStore, FastlySecretStore}; use crate::request_signing::{JWKS_CONFIG_STORE_NAME, SIGNING_SECRET_STORE_NAME}; +use crate::storage::{FastlyConfigStore, FastlySecretStore}; /// Retrieves the current active key ID from the config store. /// diff --git a/crates/trusted-server-core/src/storage/api_client.rs b/crates/trusted-server-core/src/storage/api_client.rs new file mode 100644 index 00000000..81a2d57b --- /dev/null +++ b/crates/trusted-server-core/src/storage/api_client.rs @@ -0,0 +1,291 @@ +//! Fastly management API client (legacy). +//! +//! This module holds [`FastlyApiClient`], which wraps the Fastly management +//! REST API for write operations on config and secret stores. +//! New code should use [`crate::platform::PlatformConfigStore`] and +//! [`crate::platform::PlatformSecretStore`] write methods instead. +//! This type will be removed once all call sites have migrated. + +use std::io::Read; + +use error_stack::{Report, ResultExt}; +use fastly::{Request, Response}; +use http::StatusCode; + +use crate::backend::BackendConfig; +use crate::error::TrustedServerError; +use crate::storage::secret_store::FastlySecretStore; + +const FASTLY_API_HOST: &str = "https://api.fastly.com"; + +fn build_config_item_payload(value: &str) -> String { + format!("item_value={}", urlencoding::encode(value)) +} + +/// HTTP client for the Fastly management API. +/// +/// Used to perform write operations on config and secret stores via the +/// Fastly REST API. Reads are performed directly through the edge-side SDK. +/// +/// # Migration note +/// +/// This type predates the `platform` abstraction. New code should use +/// [`crate::platform::PlatformConfigStore`] and +/// [`crate::platform::PlatformSecretStore`] write methods instead. +pub struct FastlyApiClient { + api_key: Vec, + base_url: &'static str, + backend_name: String, +} + +impl FastlyApiClient { + /// Creates a new Fastly API client using the default secret store. + /// + /// # Errors + /// + /// Returns an error if the secret store cannot be opened or the API key + /// cannot be retrieved. + pub fn new() -> Result> { + Self::from_secret_store("api-keys", "api_key") + } + + /// Creates a new Fastly API client reading credentials from a specified + /// secret store entry. + /// + /// # Errors + /// + /// Returns an error if the API backend cannot be ensured or the API key + /// cannot be retrieved. + pub fn from_secret_store( + store_name: &str, + key_name: &str, + ) -> Result> { + let backend_name = BackendConfig::from_url("https://api.fastly.com", true)?; + let api_key = FastlySecretStore::new(store_name).get(key_name)?; + + log::debug!("FastlyApiClient initialized"); + + Ok(Self { + api_key, + base_url: FASTLY_API_HOST, + backend_name, + }) + } + + fn make_request( + &self, + method: &str, + path: &str, + body: Option, + content_type: &str, + ) -> Result> { + let url = format!("{}{}", self.base_url, path); + let api_key_str = String::from_utf8_lossy(&self.api_key).to_string(); + + let mut request = match method { + "GET" => Request::get(&url), + "POST" => Request::post(&url), + "PUT" => Request::put(&url), + "DELETE" => Request::delete(&url), + _ => { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("unsupported HTTP method: {}", method), + })) + } + }; + + request = request + .with_header("Fastly-Key", api_key_str) + .with_header("Accept", "application/json"); + + if let Some(body_content) = body { + request = request + .with_header("Content-Type", content_type) + .with_body(body_content); + } + + request.send(&self.backend_name).map_err(|e| { + Report::new(TrustedServerError::Configuration { + message: format!("failed to send API request: {}", e), + }) + }) + } + + /// Updates a configuration item in a Fastly config store. + /// + /// # Errors + /// + /// Returns an error if the API request fails or returns a non-OK status. + pub fn update_config_item( + &self, + store_id: &str, + key: &str, + value: &str, + ) -> Result<(), Report> { + let path = format!("/resources/stores/config/{}/item/{}", store_id, key); + let payload = build_config_item_payload(value); + + let mut response = self.make_request( + "PUT", + &path, + Some(payload), + "application/x-www-form-urlencoded", + )?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .change_context(TrustedServerError::Configuration { + message: "failed to read config store API response".into(), + })?; + + if response.get_status() == StatusCode::OK { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "failed to update config item: HTTP {} - {}", + response.get_status(), + buf + ), + })) + } + } + + /// Creates a secret in a Fastly secret store. + /// + /// # Errors + /// + /// Returns an error if the API request fails or returns a non-OK status. + pub fn create_secret( + &self, + store_id: &str, + secret_name: &str, + secret_value: &str, + ) -> Result<(), Report> { + let path = format!("/resources/stores/secret/{}/secrets", store_id); + let payload = serde_json::json!({ + "name": secret_name, + "secret": secret_value + }); + + let mut response = + self.make_request("POST", &path, Some(payload.to_string()), "application/json")?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .change_context(TrustedServerError::Configuration { + message: "failed to read secret store API response".into(), + })?; + + if response.get_status() == StatusCode::OK { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "failed to create secret: HTTP {} - {}", + response.get_status(), + buf + ), + })) + } + } + + /// Deletes a configuration item from a Fastly config store. + /// + /// # Errors + /// + /// Returns an error if the API request fails or returns a non-OK or + /// non-NO_CONTENT status. + pub fn delete_config_item( + &self, + store_id: &str, + key: &str, + ) -> Result<(), Report> { + let path = format!("/resources/stores/config/{}/item/{}", store_id, key); + + let mut response = self.make_request("DELETE", &path, None, "application/json")?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .change_context(TrustedServerError::Configuration { + message: "failed to read config store delete API response".into(), + })?; + + if response.get_status() == StatusCode::OK + || response.get_status() == StatusCode::NO_CONTENT + { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "failed to delete config item: HTTP {} - {}", + response.get_status(), + buf + ), + })) + } + } + + /// Deletes a secret from a Fastly secret store. + /// + /// # Errors + /// + /// Returns an error if the API request fails or returns a non-OK or + /// non-NO_CONTENT status. + pub fn delete_secret( + &self, + store_id: &str, + secret_name: &str, + ) -> Result<(), Report> { + let path = format!( + "/resources/stores/secret/{}/secrets/{}", + store_id, secret_name + ); + + let mut response = self.make_request("DELETE", &path, None, "application/json")?; + + let mut buf = String::new(); + response + .get_body_mut() + .read_to_string(&mut buf) + .change_context(TrustedServerError::Configuration { + message: "failed to read secret store delete API response".into(), + })?; + + if response.get_status() == StatusCode::OK + || response.get_status() == StatusCode::NO_CONTENT + { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "failed to delete secret: HTTP {} - {}", + response.get_status(), + buf + ), + })) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_item_payload_url_encodes_reserved_characters() { + let payload = build_config_item_payload(r#"value with spaces + symbols &= {"kid":"a+b"}"#); + + assert_eq!( + payload, + "item_value=value%20with%20spaces%20%2B%20symbols%20%26%3D%20%7B%22kid%22%3A%22a%2Bb%22%7D", + "should URL-encode config item values in form payloads" + ); + } +} diff --git a/crates/trusted-server-core/src/storage/config_store.rs b/crates/trusted-server-core/src/storage/config_store.rs new file mode 100644 index 00000000..cc396f73 --- /dev/null +++ b/crates/trusted-server-core/src/storage/config_store.rs @@ -0,0 +1,159 @@ +//! Fastly-backed config store (legacy). +//! +//! This module holds the pre-platform [`FastlyConfigStore`] type. +//! New code should use [`crate::platform::PlatformConfigStore`] via +//! [`crate::platform::RuntimeServices`] instead. This type will be removed +//! once all call sites have migrated. + +use core::fmt::Display; + +use error_stack::Report; +use fastly::ConfigStore; + +use crate::error::TrustedServerError; + +// TODO: Deduplicate this transitional helper with +// trusted-server-adapter-fastly/src/platform.rs:get_config_value once +// FastlyConfigStore is removed. +trait ConfigStoreReader { + type LookupError: Display; + + fn try_get(&self, key: &str) -> Result, Self::LookupError>; +} + +impl ConfigStoreReader for ConfigStore { + type LookupError = fastly::config_store::LookupError; + + fn try_get(&self, key: &str) -> Result, Self::LookupError> { + ConfigStore::try_get(self, key) + } +} + +fn load_config_value( + store_name: &str, + key: &str, + open_store: Open, +) -> Result> +where + S: ConfigStoreReader, + Open: FnOnce(&str) -> Result, + OpenError: Display, +{ + let store = open_store(store_name).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("failed to open config store '{store_name}': {error}"), + }) + })?; + + store + .try_get(key) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("lookup for key '{key}' failed: {error}"), + }) + })? + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!("key '{key}' not found in config store '{store_name}'"), + }) + }) +} + +/// Fastly-backed config store with the store name baked in at construction. +/// +/// # Migration note +/// +/// This type predates the `platform` abstraction. New code should use +/// [`crate::platform::PlatformConfigStore`] via [`crate::platform::RuntimeServices`] +/// instead. `FastlyConfigStore` will be removed once all call sites have +/// migrated. +pub struct FastlyConfigStore { + store_name: String, +} + +impl FastlyConfigStore { + /// Create a new config store handle for the named store. + pub fn new(store_name: impl Into) -> Self { + Self { + store_name: store_name.into(), + } + } + + /// Retrieves a configuration value from the store. + /// + /// # Errors + /// + /// Returns an error if the key is not found in the config store. + pub fn get(&self, key: &str) -> Result> { + load_config_value::(&self.store_name, key, ConfigStore::try_open) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct StubConfigStore { + value: Result, &'static str>, + } + + impl ConfigStoreReader for StubConfigStore { + type LookupError = &'static str; + + fn try_get(&self, _key: &str) -> Result, Self::LookupError> { + self.value.clone() + } + } + + #[test] + fn config_store_new_stores_name() { + let store = FastlyConfigStore::new("test_store"); + assert_eq!( + store.store_name, "test_store", + "should store the store name" + ); + } + + #[test] + fn load_config_value_returns_error_when_open_fails() { + let err = load_config_value::("jwks_store", "current-kid", |_| { + Err("open failed") + }) + .expect_err("should return an error when the store cannot be opened"); + + assert!( + err.to_string().contains("failed to open config store"), + "should describe the open failure" + ); + } + + #[test] + fn load_config_value_returns_error_when_lookup_fails() { + let err = load_config_value::("jwks_store", "current-kid", |_| { + Ok::(StubConfigStore { + value: Err("lookup failed"), + }) + }) + .expect_err("should return an error when lookup fails"); + + assert!( + err.to_string() + .contains("lookup for key 'current-kid' failed"), + "should describe the lookup failure" + ); + } + + #[test] + fn load_config_value_returns_error_when_key_is_missing() { + let err = load_config_value::("jwks_store", "current-kid", |_| { + Ok::(StubConfigStore { value: Ok(None) }) + }) + .expect_err("should return an error when the key is absent"); + + assert!( + err.to_string() + .contains("key 'current-kid' not found in config store 'jwks_store'"), + "should describe the missing key" + ); + } +} diff --git a/crates/trusted-server-core/src/storage/mod.rs b/crates/trusted-server-core/src/storage/mod.rs new file mode 100644 index 00000000..6010ae2e --- /dev/null +++ b/crates/trusted-server-core/src/storage/mod.rs @@ -0,0 +1,15 @@ +//! Legacy Fastly-backed store types. +//! +//! These types predate the [`crate::platform`] abstraction and will be removed +//! once all call sites have migrated to the platform traits. New code should +//! use [`crate::platform::PlatformConfigStore`], +//! [`crate::platform::PlatformSecretStore`], and the management write methods +//! via [`crate::platform::RuntimeServices`]. + +pub(crate) mod api_client; +pub(crate) mod config_store; +pub(crate) mod secret_store; + +pub use api_client::FastlyApiClient; +pub use config_store::FastlyConfigStore; +pub use secret_store::FastlySecretStore; diff --git a/crates/trusted-server-core/src/storage/secret_store.rs b/crates/trusted-server-core/src/storage/secret_store.rs new file mode 100644 index 00000000..f2dd7b91 --- /dev/null +++ b/crates/trusted-server-core/src/storage/secret_store.rs @@ -0,0 +1,181 @@ +//! Fastly-backed secret store (legacy). +//! +//! This module holds the pre-platform [`FastlySecretStore`] type. +//! New code should use [`crate::platform::PlatformSecretStore`] via +//! [`crate::platform::RuntimeServices`] instead. This type will be removed +//! once all call sites have migrated. + +use core::fmt::Display; + +use error_stack::{Report, ResultExt}; +use fastly::SecretStore; + +use crate::error::TrustedServerError; + +#[derive(Clone)] +enum SecretReadError { + Lookup(LookupError), + Decrypt(DecryptError), +} + +type SecretBytesResult = + Result>, SecretReadError>; + +trait SecretStoreReader: Sized { + type LookupError: Display; + type DecryptError: Display; + + fn try_get_bytes(&self, key: &str) -> SecretBytesResult; +} + +impl SecretStoreReader for SecretStore { + type LookupError = fastly::secret_store::LookupError; + type DecryptError = fastly::secret_store::DecryptError; + + fn try_get_bytes(&self, key: &str) -> SecretBytesResult { + let secret = self.try_get(key).map_err(SecretReadError::Lookup)?; + let Some(secret) = secret else { + return Ok(None); + }; + + secret + .try_plaintext() + .map(|bytes| Some(bytes.into_iter().collect())) + .map_err(SecretReadError::Decrypt) + } +} + +fn get_secret_bytes( + store_name: &str, + key: &str, + open_store: Open, +) -> Result, Report> +where + S: SecretStoreReader, + Open: FnOnce() -> Result, + OpenError: Display, +{ + let store = open_store().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("failed to open secret store '{store_name}': {error}"), + }) + })?; + + store + .try_get_bytes(key) + .map_err(|error| match error { + SecretReadError::Lookup(error) => Report::new(TrustedServerError::Configuration { + message: format!( + "lookup for secret '{key}' in secret store '{store_name}' failed: {error}" + ), + }), + SecretReadError::Decrypt(error) => Report::new(TrustedServerError::Configuration { + message: format!("failed to decrypt secret '{key}': {error}"), + }), + })? + .ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: format!("secret '{key}' not found in secret store '{store_name}'"), + }) + }) +} + +/// Fastly-backed secret store with the store name baked in at construction. +/// +/// # Migration note +/// +/// This type predates the `platform` abstraction. New code should use +/// [`crate::platform::PlatformSecretStore`] via [`crate::platform::RuntimeServices`] +/// instead. `FastlySecretStore` will be removed once all call sites have +/// migrated. +pub struct FastlySecretStore { + store_name: String, +} + +impl FastlySecretStore { + /// Create a new secret store handle for the named store. + pub fn new(store_name: impl Into) -> Self { + Self { + store_name: store_name.into(), + } + } + + /// Retrieves a secret value as raw bytes from the store. + /// + /// # Errors + /// + /// Returns an error if the secret store cannot be opened, the key is not + /// found, or the plaintext cannot be retrieved. + pub fn get(&self, key: &str) -> Result, Report> { + get_secret_bytes::(&self.store_name, key, || { + SecretStore::open(&self.store_name) + }) + } + + /// Retrieves a secret value from the store and decodes it as a UTF-8 string. + /// + /// # Errors + /// + /// Returns an error if the secret cannot be retrieved or is not valid UTF-8. + pub fn get_string(&self, key: &str) -> Result> { + let bytes = self.get(key)?; + String::from_utf8(bytes).change_context(TrustedServerError::Configuration { + message: "failed to decode secret as UTF-8".to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use core::fmt::{self, Display}; + + use super::*; + + struct StubSecretStore { + value: SecretBytesResult<&'static str, &'static str>, + } + + impl SecretStoreReader for StubSecretStore { + type LookupError = &'static str; + type DecryptError = &'static str; + + fn try_get_bytes( + &self, + _key: &str, + ) -> SecretBytesResult { + self.value.clone() + } + } + + #[derive(Clone)] + struct StubOpenError(&'static str); + + impl Display for StubOpenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } + } + + #[test] + fn secret_store_new_stores_name() { + let store = FastlySecretStore::new("test_secrets"); + assert_eq!( + store.store_name, "test_secrets", + "should store the store name" + ); + } + + #[test] + fn get_secret_bytes_includes_open_error_details() { + let err = get_secret_bytes::("signing_keys", "active", || { + Err(StubOpenError("permission denied")) + }) + .expect_err("should return an error when the secret store cannot be opened"); + + assert!( + err.to_string() + .contains("failed to open secret store 'signing_keys': permission denied"), + "should preserve the original open error message" + ); + } +}