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 allow_guest_private_egress: false,
109 allow_guest_self_egress: true,
110 max_handler_blob_bytes: MT_MAX_BLOB,
111 max_component_bytes: MT_MAX_COMPONENT,
112 oidc_require_audience: true,
113 domain_verify_allow_private: false,
114 domain_verify_self_serve: true,
115 allow_shared_kernel_compute: false,
116 ratelimit_fail_open: false,
117 allow_implicit_routing: false,
118 require_pop: false,
119 require_domain_verification: true,
120 },
121 Self::SingleTenant => SecurityPosture {
122 allow_unauthenticated_public_bind: false,
123 max_upload_bytes: ST_MAX_UPLOAD,
124 allow_site_unix_upstreams: true,
125 allow_site_private_upstreams: true,
126 allow_guest_private_egress: true,
127 allow_guest_self_egress: true,
128 max_handler_blob_bytes: ST_MAX_BLOB,
129 max_component_bytes: ST_MAX_COMPONENT,
130 oidc_require_audience: true,
131 domain_verify_allow_private: true,
132 domain_verify_self_serve: true,
133 allow_shared_kernel_compute: true,
134 ratelimit_fail_open: false,
135 allow_implicit_routing: true,
136 require_pop: false,
137 require_domain_verification: true,
138 },
139 Self::Dev => SecurityPosture {
140 allow_unauthenticated_public_bind: true,
141 max_upload_bytes: 0,
142 allow_site_unix_upstreams: true,
143 allow_site_private_upstreams: true,
144 allow_guest_private_egress: true,
145 allow_guest_self_egress: true,
146 max_handler_blob_bytes: 0,
147 max_component_bytes: 0,
148 oidc_require_audience: false,
149 domain_verify_allow_private: true,
150 domain_verify_self_serve: true,
151 allow_shared_kernel_compute: true,
152 ratelimit_fail_open: true,
153 allow_implicit_routing: true,
154 require_pop: false,
155 require_domain_verification: false,
157 },
158 }
159 }
160}
161
162#[derive(Debug, Clone, Default, Deserialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct PostureOverrides {
170 pub allow_unauthenticated_public_bind: Option<bool>,
172 pub max_upload_bytes: Option<u64>,
174 pub allow_site_unix_upstreams: Option<bool>,
176 pub allow_site_private_upstreams: Option<bool>,
178 pub allow_guest_private_egress: Option<bool>,
180 pub allow_guest_self_egress: Option<bool>,
182 pub max_handler_blob_bytes: Option<u64>,
184 pub max_component_bytes: Option<u64>,
186 pub oidc_require_audience: Option<bool>,
188 pub domain_verify_allow_private: Option<bool>,
190 pub domain_verify_self_serve: Option<bool>,
198 pub allow_shared_kernel_compute: Option<bool>,
200 pub ratelimit_fail_open: Option<bool>,
202 pub allow_implicit_routing: Option<bool>,
208 pub require_pop: Option<bool>,
213 pub require_domain_verification: Option<bool>,
218}
219
220#[derive(Debug, Clone, Default, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub struct SecurityConfig {
224 pub profile: Option<String>,
227 pub profiles: BTreeMap<String, PostureOverrides>,
229 pub overrides: PostureOverrides,
231}
232
233impl SecurityConfig {
234 fn base_for(&self, name: &str) -> Result<SecurityPosture, SecurityError> {
237 if let Some(builtin) = SecurityProfile::from_name(name) {
238 Ok(builtin.preset())
239 } else if let Some(custom) = self.profiles.get(name) {
240 Ok(apply(SecurityProfile::MultiTenant.preset(), custom))
241 } else {
242 Err(SecurityError::UnknownProfile(name.to_string()))
243 }
244 }
245
246 pub fn resolve(&self) -> Result<SecurityPosture, SecurityError> {
248 let name = self.profile.as_deref().unwrap_or("multi-tenant");
249 Ok(apply(self.base_for(name)?, &self.overrides))
250 }
251
252 pub fn explain(&self) -> Result<String, SecurityError> {
255 let name = self.profile.as_deref().unwrap_or("multi-tenant");
256 let p = self.resolve()?;
257 let o = &self.overrides;
258 let mut out = String::new();
259 let _ = writeln!(out, "security profile: {name}");
260 let mut row = |label: &str, value: String, overridden: bool| {
261 let src = if overridden { "override" } else { "profile" };
262 let _ = writeln!(out, " {label:<34} {value:<12} ({src})");
263 };
264 row(
265 "allow_unauthenticated_public_bind",
266 p.allow_unauthenticated_public_bind.to_string(),
267 o.allow_unauthenticated_public_bind.is_some(),
268 );
269 row(
270 "max_upload_bytes",
271 fmt_cap(p.max_upload_bytes),
272 o.max_upload_bytes.is_some(),
273 );
274 row(
275 "allow_site_unix_upstreams",
276 p.allow_site_unix_upstreams.to_string(),
277 o.allow_site_unix_upstreams.is_some(),
278 );
279 row(
280 "allow_site_private_upstreams",
281 p.allow_site_private_upstreams.to_string(),
282 o.allow_site_private_upstreams.is_some(),
283 );
284 row(
285 "allow_guest_private_egress",
286 p.allow_guest_private_egress.to_string(),
287 o.allow_guest_private_egress.is_some(),
288 );
289 row(
290 "allow_guest_self_egress",
291 p.allow_guest_self_egress.to_string(),
292 o.allow_guest_self_egress.is_some(),
293 );
294 row(
295 "max_handler_blob_bytes",
296 fmt_cap(p.max_handler_blob_bytes),
297 o.max_handler_blob_bytes.is_some(),
298 );
299 row(
300 "max_component_bytes",
301 fmt_cap(p.max_component_bytes),
302 o.max_component_bytes.is_some(),
303 );
304 row(
305 "oidc_require_audience",
306 p.oidc_require_audience.to_string(),
307 o.oidc_require_audience.is_some(),
308 );
309 row(
310 "domain_verify_allow_private",
311 p.domain_verify_allow_private.to_string(),
312 o.domain_verify_allow_private.is_some(),
313 );
314 row(
315 "domain_verify_self_serve",
316 p.domain_verify_self_serve.to_string(),
317 o.domain_verify_self_serve.is_some(),
318 );
319 row(
320 "allow_shared_kernel_compute",
321 p.allow_shared_kernel_compute.to_string(),
322 o.allow_shared_kernel_compute.is_some(),
323 );
324 row(
325 "ratelimit_fail_open",
326 p.ratelimit_fail_open.to_string(),
327 o.ratelimit_fail_open.is_some(),
328 );
329 row(
330 "allow_implicit_routing",
331 p.allow_implicit_routing.to_string(),
332 o.allow_implicit_routing.is_some(),
333 );
334 row(
335 "require_pop",
336 p.require_pop.to_string(),
337 o.require_pop.is_some(),
338 );
339 Ok(out)
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub struct SecurityPosture {
349 pub allow_unauthenticated_public_bind: bool,
351 pub max_upload_bytes: u64,
353 pub allow_site_unix_upstreams: bool,
355 pub allow_site_private_upstreams: bool,
357 pub allow_guest_private_egress: bool,
365 pub allow_guest_self_egress: bool,
375 pub max_handler_blob_bytes: u64,
377 pub max_component_bytes: u64,
379 pub oidc_require_audience: bool,
381 pub domain_verify_allow_private: bool,
383 pub domain_verify_self_serve: bool,
386 pub allow_shared_kernel_compute: bool,
388 pub ratelimit_fail_open: bool,
390 pub allow_implicit_routing: bool,
394 pub require_pop: bool,
397 pub require_domain_verification: bool,
404}
405
406impl Default for SecurityPosture {
407 fn default() -> Self {
408 SecurityProfile::MultiTenant.preset()
409 }
410}
411
412fn apply(mut base: SecurityPosture, o: &PostureOverrides) -> SecurityPosture {
414 if let Some(v) = o.allow_unauthenticated_public_bind {
415 base.allow_unauthenticated_public_bind = v;
416 }
417 if let Some(v) = o.max_upload_bytes {
418 base.max_upload_bytes = v;
419 }
420 if let Some(v) = o.allow_site_unix_upstreams {
421 base.allow_site_unix_upstreams = v;
422 }
423 if let Some(v) = o.allow_site_private_upstreams {
424 base.allow_site_private_upstreams = v;
425 }
426 if let Some(v) = o.allow_guest_private_egress {
427 base.allow_guest_private_egress = v;
428 }
429 if let Some(v) = o.allow_guest_self_egress {
430 base.allow_guest_self_egress = v;
431 }
432 if let Some(v) = o.max_handler_blob_bytes {
433 base.max_handler_blob_bytes = v;
434 }
435 if let Some(v) = o.max_component_bytes {
436 base.max_component_bytes = v;
437 }
438 if let Some(v) = o.oidc_require_audience {
439 base.oidc_require_audience = v;
440 }
441 if let Some(v) = o.domain_verify_allow_private {
442 base.domain_verify_allow_private = v;
443 }
444 if let Some(v) = o.domain_verify_self_serve {
445 base.domain_verify_self_serve = v;
446 }
447 if let Some(v) = o.allow_shared_kernel_compute {
448 base.allow_shared_kernel_compute = v;
449 }
450 if let Some(v) = o.ratelimit_fail_open {
451 base.ratelimit_fail_open = v;
452 }
453 if let Some(v) = o.allow_implicit_routing {
454 base.allow_implicit_routing = v;
455 }
456 if let Some(v) = o.require_pop {
457 base.require_pop = v;
458 }
459 if let Some(v) = o.require_domain_verification {
460 base.require_domain_verification = v;
461 }
462 base
463}
464
465fn fmt_cap(bytes: u64) -> String {
467 if bytes == 0 {
468 "unlimited".to_string()
469 } else {
470 bytes.to_string()
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn default_posture_is_multi_tenant_strict() {
480 let p = SecurityPosture::default();
481 assert_eq!(p, SecurityProfile::MultiTenant.preset());
482 assert!(!p.allow_unauthenticated_public_bind);
483 assert!(!p.allow_site_unix_upstreams);
484 assert!(!p.allow_site_private_upstreams);
485 assert!(p.oidc_require_audience);
486 assert!(!p.domain_verify_allow_private);
487 assert!(p.domain_verify_self_serve);
488 assert!(!p.allow_shared_kernel_compute);
489 assert!(!p.ratelimit_fail_open);
490 assert!(!p.allow_implicit_routing);
491 assert!(!p.require_pop);
492 assert_eq!(p.max_upload_bytes, MT_MAX_UPLOAD);
493 }
494
495 #[test]
496 fn require_pop_defaults_off_everywhere_and_overrides() {
497 for profile in [
499 SecurityProfile::MultiTenant,
500 SecurityProfile::SingleTenant,
501 SecurityProfile::Dev,
502 ] {
503 assert!(!profile.preset().require_pop, "{}", profile.as_str());
504 }
505 let cfg = SecurityConfig {
507 overrides: PostureOverrides {
508 require_pop: Some(true),
509 ..Default::default()
510 },
511 ..Default::default()
512 };
513 assert!(cfg.resolve().unwrap().require_pop);
514 assert!(cfg
515 .explain()
516 .unwrap()
517 .lines()
518 .any(|l| l.contains("require_pop") && l.contains("true") && l.contains("override")));
519 }
520
521 #[test]
522 fn empty_config_resolves_to_multi_tenant() {
523 let resolved = SecurityConfig::default().resolve().unwrap();
524 assert_eq!(resolved, SecurityProfile::MultiTenant.preset());
525 }
526
527 #[test]
528 fn dev_profile_is_loose() {
529 let cfg = SecurityConfig {
530 profile: Some("dev".into()),
531 ..Default::default()
532 };
533 let p = cfg.resolve().unwrap();
534 assert!(p.allow_unauthenticated_public_bind);
535 assert!(!p.oidc_require_audience);
536 assert_eq!(p.max_upload_bytes, 0); assert!(p.ratelimit_fail_open);
538 assert!(p.allow_implicit_routing);
539 }
540
541 #[test]
542 fn override_beats_profile() {
543 let cfg = SecurityConfig {
545 profile: Some("dev".into()),
546 overrides: PostureOverrides {
547 oidc_require_audience: Some(true),
548 max_upload_bytes: Some(123),
549 ..Default::default()
550 },
551 ..Default::default()
552 };
553 let p = cfg.resolve().unwrap();
554 assert!(
555 p.oidc_require_audience,
556 "override must win over the profile"
557 );
558 assert_eq!(p.max_upload_bytes, 123);
559 assert!(p.allow_unauthenticated_public_bind);
561 }
562
563 #[test]
564 fn custom_profile_layers_over_multi_tenant_baseline() {
565 let mut profiles = BTreeMap::new();
566 profiles.insert(
567 "ci".to_string(),
568 PostureOverrides {
569 allow_unauthenticated_public_bind: Some(true),
570 ..Default::default()
571 },
572 );
573 let cfg = SecurityConfig {
574 profile: Some("ci".into()),
575 profiles,
576 ..Default::default()
577 };
578 let p = cfg.resolve().unwrap();
579 assert!(p.allow_unauthenticated_public_bind);
581 assert!(!p.allow_site_private_upstreams);
583 assert!(p.oidc_require_audience);
584 }
585
586 #[test]
587 fn unknown_profile_errors() {
588 let cfg = SecurityConfig {
589 profile: Some("nope".into()),
590 ..Default::default()
591 };
592 assert!(matches!(
593 cfg.resolve(),
594 Err(SecurityError::UnknownProfile(name)) if name == "nope"
595 ));
596 }
597
598 #[test]
599 fn explain_marks_value_source() {
600 let cfg = SecurityConfig {
601 profile: Some("multi-tenant".into()),
602 overrides: PostureOverrides {
603 max_upload_bytes: Some(0),
604 ..Default::default()
605 },
606 ..Default::default()
607 };
608 let text = cfg.explain().unwrap();
609 assert!(text.contains("security profile: multi-tenant"));
610 assert!(text.lines().any(|l| l.contains("max_upload_bytes")
612 && l.contains("unlimited")
613 && l.contains("override")));
614 assert!(text
616 .lines()
617 .any(|l| l.contains("oidc_require_audience") && l.contains("profile")));
618 }
619}