1use super::*;
2use coil_cache::DistributedCacheBackend;
3use coil_config::{
4 DatabaseDriver, DistributedCache, Environment, JobBackend, ObjectStoreKind, SecretRef,
5 SessionStore,
6};
7use coil_storage::execution::{ObjectStoreClientConfig, ObjectStoreCredentials};
8use std::collections::BTreeMap;
9use std::fmt;
10
11#[derive(Debug, Error, Clone, PartialEq, Eq)]
12pub enum SecretResolutionError {
13 #[error("secret `{reference}` was not provided to the runtime")]
14 MissingSecret { reference: String },
15 #[error("secret `{reference}` uses a source that is not available in this runtime context")]
16 UnsupportedSecretSource { reference: String },
17 #[error("object-store backend `{kind:?}` requires an object-store secret")]
18 MissingObjectStoreSecret { kind: ObjectStoreKind },
19 #[error("object-store secret `{reference}` is invalid: {message}")]
20 InvalidObjectStoreConfig { reference: String, message: String },
21}
22
23pub trait SecretResolver {
24 fn resolve(&self, secret: &SecretRef) -> Result<String, SecretResolutionError>;
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct StaticSecretResolver {
29 values: BTreeMap<String, String>,
30}
31
32impl StaticSecretResolver {
33 pub fn new() -> Self {
34 Self::default()
35 }
36
37 pub fn with_secret(
38 mut self,
39 secret: SecretRef,
40 value: impl Into<String>,
41 ) -> Result<Self, SecretResolutionError> {
42 self.values.insert(secret.redacted(), value.into());
43 Ok(self)
44 }
45}
46
47impl SecretResolver for StaticSecretResolver {
48 fn resolve(&self, secret: &SecretRef) -> Result<String, SecretResolutionError> {
49 self.values.get(&secret.redacted()).cloned().ok_or_else(|| {
50 SecretResolutionError::MissingSecret {
51 reference: secret.redacted(),
52 }
53 })
54 }
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct EnvironmentSecretResolver;
59
60impl SecretResolver for EnvironmentSecretResolver {
61 fn resolve(&self, secret: &SecretRef) -> Result<String, SecretResolutionError> {
62 match secret {
63 SecretRef::Env { var } => {
64 std::env::var(var).map_err(|_| SecretResolutionError::MissingSecret {
65 reference: secret.redacted(),
66 })
67 }
68 SecretRef::SecretManager { .. } => {
69 Err(SecretResolutionError::UnsupportedSecretSource {
70 reference: secret.redacted(),
71 })
72 }
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct DatabaseClientTarget {
79 pub driver: DatabaseDriver,
80 pub url: Option<String>,
81 pub min_connections: u16,
82 pub max_connections: u16,
83 pub statement_timeout_secs: u64,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct DistributedCacheClientTarget {
88 pub backend: DistributedCacheBackend,
89 pub purpose: &'static str,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct JobsClientTarget {
94 pub backend: JobBackend,
95 pub shared: bool,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct SessionStoreClientTarget {
100 pub kind: SessionStoreBackendKind,
101 pub shared: bool,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ObjectStoreClientTarget {
106 pub kind: ObjectStoreKind,
107 pub endpoint_url: Option<String>,
108 pub bucket: Option<String>,
109 pub region: Option<String>,
110 pub credential_reference: Option<String>,
111 pub signed_url_ttl_secs: Option<u64>,
112 pub local_root: String,
113 config: ObjectStoreClientConfig,
114}
115
116impl ObjectStoreClientTarget {
117 pub fn object_store_client_config(&self) -> Option<ObjectStoreClientConfig> {
118 Some(self.config.clone())
119 }
120
121 fn new(
122 kind: ObjectStoreKind,
123 config: ObjectStoreClientConfig,
124 credential_reference: Option<String>,
125 local_root: String,
126 ) -> Self {
127 let endpoint_url = config.endpoint_url.clone();
128 let bucket = Some(config.bucket.clone());
129 let region = Some(config.region.clone());
130 let signed_url_ttl_secs = Some(config.signed_url_ttl_secs);
131 Self {
132 kind,
133 endpoint_url,
134 bucket,
135 region,
136 credential_reference,
137 signed_url_ttl_secs,
138 local_root,
139 config,
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct SharedBackendClients {
146 pub database: DatabaseClientTarget,
147 pub distributed_cache: Option<DistributedCacheClientTarget>,
148 pub jobs: JobsClientTarget,
149 pub session_store: Option<SessionStoreClientTarget>,
150 pub object_store: Option<ObjectStoreClientTarget>,
151}
152
153impl SharedBackendClients {
154 pub fn object_store_client_config<R: SecretResolver>(
155 config: &PlatformConfig,
156 resolver: &R,
157 ) -> Result<Option<ObjectStoreClientConfig>, SecretResolutionError> {
158 resolve_object_store_client_config(config, resolver)
159 }
160
161 pub fn from_config<R: SecretResolver>(
162 config: &PlatformConfig,
163 resolver: &R,
164 ) -> Result<Self, SecretResolutionError> {
165 let database = DatabaseClientTarget {
166 driver: config.database.driver,
167 url: config
168 .database
169 .url
170 .as_ref()
171 .map(|secret| resolver.resolve(secret))
172 .transpose()?,
173 min_connections: config.database.min_connections,
174 max_connections: config.database.max_connections,
175 statement_timeout_secs: config.database.statement_timeout_secs,
176 };
177 let distributed_cache = config.cache.l2.map(|backend| DistributedCacheClientTarget {
178 backend: distributed_cache_backend(backend),
179 purpose: "cache-and-coordination",
180 });
181 let jobs = JobsClientTarget {
182 backend: config.jobs.backend,
183 shared: true,
184 };
185 let session_store = match config.http.session.store {
186 SessionStore::Memory => None,
187 SessionStore::Database => Some(SessionStoreClientTarget {
188 kind: SessionStoreBackendKind::Database,
189 shared: true,
190 }),
191 SessionStore::Redis => Some(SessionStoreClientTarget {
192 kind: SessionStoreBackendKind::Redis,
193 shared: true,
194 }),
195 SessionStore::Valkey => Some(SessionStoreClientTarget {
196 kind: SessionStoreBackendKind::Valkey,
197 shared: true,
198 }),
199 };
200 let object_store = config
201 .storage
202 .object_store
203 .map(|kind| {
204 let credential_reference = config
205 .storage
206 .object_store_secret
207 .as_ref()
208 .map(SecretRef::redacted);
209 let client_config = Self::object_store_client_config(config, resolver)?
210 .expect("object-store config should be present when backend is enabled");
211 Ok(ObjectStoreClientTarget::new(
212 kind,
213 client_config,
214 credential_reference,
215 config.storage.local_root.clone(),
216 ))
217 })
218 .transpose()?;
219
220 Ok(Self {
221 database,
222 distributed_cache,
223 jobs,
224 session_store,
225 object_store,
226 })
227 }
228}
229
230impl fmt::Display for SharedBackendClients {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 write!(
233 f,
234 "db={:?} cache={:?} jobs={:?} sessions={:?} object_store={:?}",
235 self.database.driver,
236 self.distributed_cache.as_ref().map(|cache| cache.backend),
237 self.jobs.backend,
238 self.session_store.as_ref().map(|store| store.kind),
239 self.object_store.as_ref().map(|store| store.kind)
240 )
241 }
242}
243
244fn resolve_object_store_client_config<R: SecretResolver>(
245 config: &PlatformConfig,
246 resolver: &R,
247) -> Result<Option<ObjectStoreClientConfig>, SecretResolutionError> {
248 let Some(kind) = config.storage.object_store else {
249 return Ok(None);
250 };
251 let secret = config
252 .storage
253 .object_store_secret
254 .as_ref()
255 .ok_or(SecretResolutionError::MissingObjectStoreSecret { kind })?;
256 let value = resolver.resolve(secret)?;
257 let object_store_config = ObjectStoreClientConfig::from_structured_secret_value(&value)
258 .map_err(|error| SecretResolutionError::InvalidObjectStoreConfig {
259 reference: secret.redacted(),
260 message: error.to_string(),
261 })?;
262 validate_runtime_object_store_config(config.app.environment, secret, &object_store_config)?;
263 Ok(Some(object_store_config))
264}
265
266fn distributed_cache_backend(cache: DistributedCache) -> DistributedCacheBackend {
267 match cache {
268 DistributedCache::Redis => DistributedCacheBackend::Redis,
269 DistributedCache::Valkey => DistributedCacheBackend::Valkey,
270 }
271}
272
273fn validate_runtime_object_store_config(
274 environment: Environment,
275 secret: &SecretRef,
276 config: &ObjectStoreClientConfig,
277) -> Result<(), SecretResolutionError> {
278 if matches!(&config.credentials, ObjectStoreCredentials::Environment) {
279 return Err(SecretResolutionError::InvalidObjectStoreConfig {
280 reference: secret.redacted(),
281 message: "structured object-store secrets must include explicit access_key_id and secret_access_key".to_string(),
282 });
283 }
284
285 if let Some(endpoint_url) = config.endpoint_url.as_deref() {
286 if endpoint_url.starts_with("http://") && !matches!(environment, Environment::Development) {
287 return Err(SecretResolutionError::InvalidObjectStoreConfig {
288 reference: secret.redacted(),
289 message: "runtime-backed object-store endpoints must use https outside development"
290 .to_string(),
291 });
292 }
293 }
294
295 Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use coil_storage::execution::S3CompatibleObjectStoreClient;
302
303 const BACKEND_TEST_CONFIG: &str = r#"
304[app]
305name = "showcase-events"
306environment = "production"
307
308[server]
309bind = "0.0.0.0:8080"
310trusted_proxies = ["10.0.0.0/8"]
311
312[http.session]
313store = "redis"
314idle_timeout_secs = 3600
315absolute_timeout_secs = 86400
316
317[http.session_cookie]
318name = "coil_session"
319path = "/"
320same_site = "lax"
321secure = true
322http_only = true
323
324[http.flash_cookie]
325name = "coil_flash"
326path = "/"
327same_site = "lax"
328secure = true
329http_only = true
330
331[http.csrf]
332enabled = true
333field_name = "_csrf"
334header_name = "x-csrf-token"
335
336[tls]
337mode = "acme"
338challenge = "dns-01"
339provider = "cloudflare-dns"
340
341[storage]
342default_class = "public_upload"
343single_node_escape_hatch = "explicit_single_node"
344object_store = "s3"
345object_store_secret = { kind = "env", var = "OBJECT_STORE_URL" }
346local_root = "/tmp/coil-runtime-tests"
347deployment = "single_node"
348
349[cache]
350l1 = "moka"
351l2 = "redis"
352
353[i18n]
354default_locale = "en-GB"
355supported_locales = ["en-GB", "fr-FR"]
356fallback_locale = "en-GB"
357localized_routes = true
358
359[seo]
360canonical_host = "www.example.com"
361emit_json_ld = true
362
363[auth]
364package = "coil-default-auth"
365explain_api = false
366tenant_id = 101
367
368[modules]
369enabled = ["cms-pages", "admin-shell"]
370
371[wasm]
372directory = "extensions"
373default_time_limit_ms = 50
374allow_network = false
375
376[jobs]
377backend = "redis"
378
379[observability]
380metrics = true
381tracing = true
382
383[assets]
384publish_manifest = true
385cdn_base_url = "https://cdn.example.com"
386"#;
387
388 fn backend_test_config(environment: Environment) -> PlatformConfig {
389 let environment_value = match environment {
390 Environment::Development => "development",
391 Environment::Staging => "staging",
392 Environment::Production => "production",
393 };
394 PlatformConfig::from_toml_str(&BACKEND_TEST_CONFIG.replace(
395 "environment = \"production\"",
396 &format!("environment = \"{environment_value}\""),
397 ))
398 .unwrap()
399 }
400
401 #[test]
402 fn object_store_backend_requires_explicit_structured_credentials() {
403 let config = backend_test_config(Environment::Production);
404 let resolver = StaticSecretResolver::new()
405 .with_secret(
406 SecretRef::Env {
407 var: "OBJECT_STORE_URL".to_string(),
408 },
409 r#"
410endpoint_url = "https://s3.internal"
411bucket = "runtime"
412region = "eu-west-2"
413signed_url_ttl_secs = 900
414"#,
415 )
416 .unwrap();
417
418 assert_eq!(
419 SharedBackendClients::object_store_client_config(&config, &resolver).unwrap_err(),
420 SecretResolutionError::InvalidObjectStoreConfig {
421 reference: "env:OBJECT_STORE_URL".to_string(),
422 message:
423 "structured object-store secrets must include explicit access_key_id and secret_access_key"
424 .to_string(),
425 }
426 );
427 }
428
429 #[test]
430 fn object_store_backend_rejects_legacy_url_secrets() {
431 let config = backend_test_config(Environment::Production);
432 let resolver = StaticSecretResolver::new()
433 .with_secret(
434 SecretRef::Env {
435 var: "OBJECT_STORE_URL".to_string(),
436 },
437 "https://s3.internal/runtime",
438 )
439 .unwrap();
440
441 assert_eq!(
442 SharedBackendClients::object_store_client_config(&config, &resolver).unwrap_err(),
443 SecretResolutionError::InvalidObjectStoreConfig {
444 reference: "env:OBJECT_STORE_URL".to_string(),
445 message: "object-store secret must be a supported TOML or JSON document"
446 .to_string(),
447 }
448 );
449 }
450
451 #[test]
452 fn object_store_backend_rejects_http_endpoints_outside_development() {
453 let config = backend_test_config(Environment::Production);
454 let resolver = StaticSecretResolver::new()
455 .with_secret(
456 SecretRef::Env {
457 var: "OBJECT_STORE_URL".to_string(),
458 },
459 r#"
460endpoint_url = "http://s3.internal"
461bucket = "runtime"
462region = "eu-west-2"
463access_key_id = "runtime-access"
464secret_access_key = "runtime-secret"
465signed_url_ttl_secs = 900
466"#,
467 )
468 .unwrap();
469
470 assert_eq!(
471 SharedBackendClients::object_store_client_config(&config, &resolver).unwrap_err(),
472 SecretResolutionError::InvalidObjectStoreConfig {
473 reference: "env:OBJECT_STORE_URL".to_string(),
474 message: "runtime-backed object-store endpoints must use https outside development"
475 .to_string(),
476 }
477 );
478 }
479
480 #[test]
481 fn object_store_backend_allows_http_endpoints_in_development() {
482 let config = backend_test_config(Environment::Development);
483 let resolver = StaticSecretResolver::new()
484 .with_secret(
485 SecretRef::Env {
486 var: "OBJECT_STORE_URL".to_string(),
487 },
488 r#"
489endpoint_url = "http://127.0.0.1:9000"
490bucket = "runtime"
491region = "eu-west-2"
492access_key_id = "runtime-access"
493secret_access_key = "runtime-secret"
494signed_url_ttl_secs = 900
495"#,
496 )
497 .unwrap();
498
499 let object_store =
500 SharedBackendClients::object_store_client_config(&config, &resolver).unwrap();
501 assert_eq!(
502 object_store.and_then(|config| config.endpoint_url),
503 Some("http://127.0.0.1:9000".to_string())
504 );
505 }
506
507 #[test]
508 fn object_store_backend_materializes_signed_requests_from_structured_config() {
509 let config = backend_test_config(Environment::Production);
510 let resolver = StaticSecretResolver::new()
511 .with_secret(
512 SecretRef::Env {
513 var: "OBJECT_STORE_URL".to_string(),
514 },
515 r#"
516endpoint_url = "https://s3.internal"
517bucket = "runtime"
518region = "eu-west-2"
519access_key_id = "runtime-access"
520secret_access_key = "runtime-secret"
521signed_url_ttl_secs = 900
522"#,
523 )
524 .unwrap();
525
526 let object_store_config =
527 SharedBackendClients::object_store_client_config(&config, &resolver)
528 .unwrap()
529 .unwrap();
530 let signed = S3CompatibleObjectStoreClient::new(object_store_config)
531 .signed_get_url("secure/reports/march.csv")
532 .unwrap();
533
534 assert_eq!(signed.object_key, "secure/reports/march.csv");
535 assert!(
536 signed
537 .signed_url
538 .starts_with("https://s3.internal/runtime/")
539 );
540 assert!(
541 signed
542 .signed_url
543 .contains("X-Amz-Algorithm=AWS4-HMAC-SHA256")
544 );
545 assert!(
546 signed
547 .signed_url
548 .contains("X-Amz-Credential=runtime-access")
549 );
550 assert!(signed.expires_at_unix_seconds > 0);
551 }
552}