Skip to main content

coil_runtime/server/
backend.rs

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}