1use std::collections::BTreeMap;
27use std::fmt::Write as _;
28
29use serde::Deserialize;
30
31const MT_MAX_UPLOAD: u64 = 100 * 1024 * 1024;
33const MT_MAX_BLOB: u64 = 64 * 1024 * 1024;
35const MT_MAX_COMPONENT: u64 = 64 * 1024 * 1024;
37const ST_MAX_UPLOAD: u64 = 1024 * 1024 * 1024;
39const ST_MAX_BLOB: u64 = 256 * 1024 * 1024;
41const ST_MAX_COMPONENT: u64 = 128 * 1024 * 1024;
43
44#[derive(Debug, thiserror::Error)]
46pub enum SecurityError {
47 #[error(
49 "unknown security profile {0:?} (built-ins: multi-tenant, single-tenant, dev; \
50 or define it under `security.profiles`)"
51 )]
52 UnknownProfile(String),
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SecurityProfile {
58 MultiTenant,
60 SingleTenant,
62 Dev,
64}
65
66impl std::fmt::Display for SecurityProfile {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.write_str(self.as_str())
69 }
70}
71
72impl std::str::FromStr for SecurityProfile {
73 type Err = SecurityError;
74
75 fn from_str(s: &str) -> Result<Self, Self::Err> {
76 Self::from_name(s).ok_or_else(|| SecurityError::UnknownProfile(s.to_string()))
77 }
78}
79
80impl SecurityProfile {
81 pub fn from_name(name: &str) -> Option<Self> {
83 match name {
84 "multi-tenant" => Some(Self::MultiTenant),
85 "single-tenant" => Some(Self::SingleTenant),
86 "dev" => Some(Self::Dev),
87 _ => None,
88 }
89 }
90
91 pub fn as_str(self) -> &'static str {
93 match self {
94 Self::MultiTenant => "multi-tenant",
95 Self::SingleTenant => "single-tenant",
96 Self::Dev => "dev",
97 }
98 }
99
100 pub fn preset(self) -> SecurityPosture {
102 match self {
103 Self::MultiTenant => SecurityPosture {
104 allow_unauthenticated_public_bind: false,
105 max_upload_bytes: MT_MAX_UPLOAD,
106 allow_site_unix_upstreams: false,
107 allow_site_private_upstreams: false,
108 max_handler_blob_bytes: MT_MAX_BLOB,
109 max_component_bytes: MT_MAX_COMPONENT,
110 oidc_require_audience: true,
111 domain_verify_allow_private: false,
112 domain_verify_self_serve: true,
113 allow_shared_kernel_compute: false,
114 ratelimit_fail_open: false,
115 allow_implicit_routing: false,
116 require_pop: false,
117 require_domain_verification: true,
118 },
119 Self::SingleTenant => SecurityPosture {
120 allow_unauthenticated_public_bind: false,
121 max_upload_bytes: ST_MAX_UPLOAD,
122 allow_site_unix_upstreams: true,
123 allow_site_private_upstreams: true,
124 max_handler_blob_bytes: ST_MAX_BLOB,
125 max_component_bytes: ST_MAX_COMPONENT,
126 oidc_require_audience: true,
127 domain_verify_allow_private: true,
128 domain_verify_self_serve: true,
129 allow_shared_kernel_compute: true,
130 ratelimit_fail_open: false,
131 allow_implicit_routing: true,
132 require_pop: false,
133 require_domain_verification: true,
134 },
135 Self::Dev => SecurityPosture {
136 allow_unauthenticated_public_bind: true,
137 max_upload_bytes: 0,
138 allow_site_unix_upstreams: true,
139 allow_site_private_upstreams: true,
140 max_handler_blob_bytes: 0,
141 max_component_bytes: 0,
142 oidc_require_audience: false,
143 domain_verify_allow_private: true,
144 domain_verify_self_serve: true,
145 allow_shared_kernel_compute: true,
146 ratelimit_fail_open: true,
147 allow_implicit_routing: true,
148 require_pop: false,
149 require_domain_verification: false,
151 },
152 }
153 }
154}
155
156#[derive(Debug, Clone, Default, Deserialize)]
162#[serde(default, deny_unknown_fields)]
163pub struct PostureOverrides {
164 pub allow_unauthenticated_public_bind: Option<bool>,
166 pub max_upload_bytes: Option<u64>,
168 pub allow_site_unix_upstreams: Option<bool>,
170 pub allow_site_private_upstreams: Option<bool>,
172 pub max_handler_blob_bytes: Option<u64>,
174 pub max_component_bytes: Option<u64>,
176 pub oidc_require_audience: Option<bool>,
178 pub domain_verify_allow_private: Option<bool>,
180 pub domain_verify_self_serve: Option<bool>,
188 pub allow_shared_kernel_compute: Option<bool>,
190 pub ratelimit_fail_open: Option<bool>,
192 pub allow_implicit_routing: Option<bool>,
198 pub require_pop: Option<bool>,
203 pub require_domain_verification: Option<bool>,
208}
209
210#[derive(Debug, Clone, Default, Deserialize)]
212#[serde(default, deny_unknown_fields)]
213pub struct SecurityConfig {
214 pub profile: Option<String>,
217 pub profiles: BTreeMap<String, PostureOverrides>,
219 pub overrides: PostureOverrides,
221}
222
223impl SecurityConfig {
224 fn base_for(&self, name: &str) -> Result<SecurityPosture, SecurityError> {
227 if let Some(builtin) = SecurityProfile::from_name(name) {
228 Ok(builtin.preset())
229 } else if let Some(custom) = self.profiles.get(name) {
230 Ok(apply(SecurityProfile::MultiTenant.preset(), custom))
231 } else {
232 Err(SecurityError::UnknownProfile(name.to_string()))
233 }
234 }
235
236 pub fn resolve(&self) -> Result<SecurityPosture, SecurityError> {
238 let name = self.profile.as_deref().unwrap_or("multi-tenant");
239 Ok(apply(self.base_for(name)?, &self.overrides))
240 }
241
242 pub fn explain(&self) -> Result<String, SecurityError> {
245 let name = self.profile.as_deref().unwrap_or("multi-tenant");
246 let p = self.resolve()?;
247 let o = &self.overrides;
248 let mut out = String::new();
249 let _ = writeln!(out, "security profile: {name}");
250 let mut row = |label: &str, value: String, overridden: bool| {
251 let src = if overridden { "override" } else { "profile" };
252 let _ = writeln!(out, " {label:<34} {value:<12} ({src})");
253 };
254 row(
255 "allow_unauthenticated_public_bind",
256 p.allow_unauthenticated_public_bind.to_string(),
257 o.allow_unauthenticated_public_bind.is_some(),
258 );
259 row(
260 "max_upload_bytes",
261 fmt_cap(p.max_upload_bytes),
262 o.max_upload_bytes.is_some(),
263 );
264 row(
265 "allow_site_unix_upstreams",
266 p.allow_site_unix_upstreams.to_string(),
267 o.allow_site_unix_upstreams.is_some(),
268 );
269 row(
270 "allow_site_private_upstreams",
271 p.allow_site_private_upstreams.to_string(),
272 o.allow_site_private_upstreams.is_some(),
273 );
274 row(
275 "max_handler_blob_bytes",
276 fmt_cap(p.max_handler_blob_bytes),
277 o.max_handler_blob_bytes.is_some(),
278 );
279 row(
280 "max_component_bytes",
281 fmt_cap(p.max_component_bytes),
282 o.max_component_bytes.is_some(),
283 );
284 row(
285 "oidc_require_audience",
286 p.oidc_require_audience.to_string(),
287 o.oidc_require_audience.is_some(),
288 );
289 row(
290 "domain_verify_allow_private",
291 p.domain_verify_allow_private.to_string(),
292 o.domain_verify_allow_private.is_some(),
293 );
294 row(
295 "domain_verify_self_serve",
296 p.domain_verify_self_serve.to_string(),
297 o.domain_verify_self_serve.is_some(),
298 );
299 row(
300 "allow_shared_kernel_compute",
301 p.allow_shared_kernel_compute.to_string(),
302 o.allow_shared_kernel_compute.is_some(),
303 );
304 row(
305 "ratelimit_fail_open",
306 p.ratelimit_fail_open.to_string(),
307 o.ratelimit_fail_open.is_some(),
308 );
309 row(
310 "allow_implicit_routing",
311 p.allow_implicit_routing.to_string(),
312 o.allow_implicit_routing.is_some(),
313 );
314 row(
315 "require_pop",
316 p.require_pop.to_string(),
317 o.require_pop.is_some(),
318 );
319 Ok(out)
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct SecurityPosture {
329 pub allow_unauthenticated_public_bind: bool,
331 pub max_upload_bytes: u64,
333 pub allow_site_unix_upstreams: bool,
335 pub allow_site_private_upstreams: bool,
337 pub max_handler_blob_bytes: u64,
339 pub max_component_bytes: u64,
341 pub oidc_require_audience: bool,
343 pub domain_verify_allow_private: bool,
345 pub domain_verify_self_serve: bool,
348 pub allow_shared_kernel_compute: bool,
350 pub ratelimit_fail_open: bool,
352 pub allow_implicit_routing: bool,
356 pub require_pop: bool,
359 pub require_domain_verification: bool,
366}
367
368impl Default for SecurityPosture {
369 fn default() -> Self {
370 SecurityProfile::MultiTenant.preset()
371 }
372}
373
374fn apply(mut base: SecurityPosture, o: &PostureOverrides) -> SecurityPosture {
376 if let Some(v) = o.allow_unauthenticated_public_bind {
377 base.allow_unauthenticated_public_bind = v;
378 }
379 if let Some(v) = o.max_upload_bytes {
380 base.max_upload_bytes = v;
381 }
382 if let Some(v) = o.allow_site_unix_upstreams {
383 base.allow_site_unix_upstreams = v;
384 }
385 if let Some(v) = o.allow_site_private_upstreams {
386 base.allow_site_private_upstreams = v;
387 }
388 if let Some(v) = o.max_handler_blob_bytes {
389 base.max_handler_blob_bytes = v;
390 }
391 if let Some(v) = o.max_component_bytes {
392 base.max_component_bytes = v;
393 }
394 if let Some(v) = o.oidc_require_audience {
395 base.oidc_require_audience = v;
396 }
397 if let Some(v) = o.domain_verify_allow_private {
398 base.domain_verify_allow_private = v;
399 }
400 if let Some(v) = o.domain_verify_self_serve {
401 base.domain_verify_self_serve = v;
402 }
403 if let Some(v) = o.allow_shared_kernel_compute {
404 base.allow_shared_kernel_compute = v;
405 }
406 if let Some(v) = o.ratelimit_fail_open {
407 base.ratelimit_fail_open = v;
408 }
409 if let Some(v) = o.allow_implicit_routing {
410 base.allow_implicit_routing = v;
411 }
412 if let Some(v) = o.require_pop {
413 base.require_pop = v;
414 }
415 if let Some(v) = o.require_domain_verification {
416 base.require_domain_verification = v;
417 }
418 base
419}
420
421fn fmt_cap(bytes: u64) -> String {
423 if bytes == 0 {
424 "unlimited".to_string()
425 } else {
426 bytes.to_string()
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn default_posture_is_multi_tenant_strict() {
436 let p = SecurityPosture::default();
437 assert_eq!(p, SecurityProfile::MultiTenant.preset());
438 assert!(!p.allow_unauthenticated_public_bind);
439 assert!(!p.allow_site_unix_upstreams);
440 assert!(!p.allow_site_private_upstreams);
441 assert!(p.oidc_require_audience);
442 assert!(!p.domain_verify_allow_private);
443 assert!(p.domain_verify_self_serve);
444 assert!(!p.allow_shared_kernel_compute);
445 assert!(!p.ratelimit_fail_open);
446 assert!(!p.allow_implicit_routing);
447 assert!(!p.require_pop);
448 assert_eq!(p.max_upload_bytes, MT_MAX_UPLOAD);
449 }
450
451 #[test]
452 fn require_pop_defaults_off_everywhere_and_overrides() {
453 for profile in [
455 SecurityProfile::MultiTenant,
456 SecurityProfile::SingleTenant,
457 SecurityProfile::Dev,
458 ] {
459 assert!(!profile.preset().require_pop, "{}", profile.as_str());
460 }
461 let cfg = SecurityConfig {
463 overrides: PostureOverrides {
464 require_pop: Some(true),
465 ..Default::default()
466 },
467 ..Default::default()
468 };
469 assert!(cfg.resolve().unwrap().require_pop);
470 assert!(cfg
471 .explain()
472 .unwrap()
473 .lines()
474 .any(|l| l.contains("require_pop") && l.contains("true") && l.contains("override")));
475 }
476
477 #[test]
478 fn empty_config_resolves_to_multi_tenant() {
479 let resolved = SecurityConfig::default().resolve().unwrap();
480 assert_eq!(resolved, SecurityProfile::MultiTenant.preset());
481 }
482
483 #[test]
484 fn dev_profile_is_loose() {
485 let cfg = SecurityConfig {
486 profile: Some("dev".into()),
487 ..Default::default()
488 };
489 let p = cfg.resolve().unwrap();
490 assert!(p.allow_unauthenticated_public_bind);
491 assert!(!p.oidc_require_audience);
492 assert_eq!(p.max_upload_bytes, 0); assert!(p.ratelimit_fail_open);
494 assert!(p.allow_implicit_routing);
495 }
496
497 #[test]
498 fn override_beats_profile() {
499 let cfg = SecurityConfig {
501 profile: Some("dev".into()),
502 overrides: PostureOverrides {
503 oidc_require_audience: Some(true),
504 max_upload_bytes: Some(123),
505 ..Default::default()
506 },
507 ..Default::default()
508 };
509 let p = cfg.resolve().unwrap();
510 assert!(
511 p.oidc_require_audience,
512 "override must win over the profile"
513 );
514 assert_eq!(p.max_upload_bytes, 123);
515 assert!(p.allow_unauthenticated_public_bind);
517 }
518
519 #[test]
520 fn custom_profile_layers_over_multi_tenant_baseline() {
521 let mut profiles = BTreeMap::new();
522 profiles.insert(
523 "ci".to_string(),
524 PostureOverrides {
525 allow_unauthenticated_public_bind: Some(true),
526 ..Default::default()
527 },
528 );
529 let cfg = SecurityConfig {
530 profile: Some("ci".into()),
531 profiles,
532 ..Default::default()
533 };
534 let p = cfg.resolve().unwrap();
535 assert!(p.allow_unauthenticated_public_bind);
537 assert!(!p.allow_site_private_upstreams);
539 assert!(p.oidc_require_audience);
540 }
541
542 #[test]
543 fn unknown_profile_errors() {
544 let cfg = SecurityConfig {
545 profile: Some("nope".into()),
546 ..Default::default()
547 };
548 assert!(matches!(
549 cfg.resolve(),
550 Err(SecurityError::UnknownProfile(name)) if name == "nope"
551 ));
552 }
553
554 #[test]
555 fn explain_marks_value_source() {
556 let cfg = SecurityConfig {
557 profile: Some("multi-tenant".into()),
558 overrides: PostureOverrides {
559 max_upload_bytes: Some(0),
560 ..Default::default()
561 },
562 ..Default::default()
563 };
564 let text = cfg.explain().unwrap();
565 assert!(text.contains("security profile: multi-tenant"));
566 assert!(text.lines().any(|l| l.contains("max_upload_bytes")
568 && l.contains("unlimited")
569 && l.contains("override")));
570 assert!(text
572 .lines()
573 .any(|l| l.contains("oidc_require_audience") && l.contains("profile")));
574 }
575}