Skip to main content

boatramp_types/
security.rs

1//! Operator-scoped **security posture** (the unifying mechanism for the
2//! security hardening). A single resolved set of trust knobs that the server,
3//! gateway, handler runtime, and compute scheduler all consult, so the trust
4//! model is decided once by the operator rather than scattered across defaults.
5//!
6//! The model is **"default untrusted, easily configured via profiles"**:
7//!
8//! - A [`SecurityProfile`] preset picks a coherent default for every knob —
9//!   `multi-tenant` (strict; the default), `single-tenant` (relaxed, but still
10//!   authenticated, for a single operator who owns every site), or `dev`
11//!   (loopback-loose for local development).
12//! - **Individual knobs are the source of truth; profiles are sugar.** Any knob
13//!   set in [`SecurityConfig::overrides`] wins over the selected profile, and an
14//!   operator can define their own named profiles under
15//!   [`SecurityConfig::profiles`].
16//! - The posture lives **only** in the server daemon config (`boatramp.cfg`),
17//!   never in site config — so a `site-write` principal can never define or
18//!   relax it. That invariant is structural, not enforced here.
19//!
20//! [`SecurityConfig::resolve`] folds (profile preset → overrides) into a concrete
21//! [`SecurityPosture`]; [`SecurityConfig::explain`] renders the resolved posture
22//! with each knob's source for `boatramp security explain`.
23//!
24//! Byte-cap knobs use the convention **`0` = unlimited**.
25
26use std::collections::BTreeMap;
27use std::fmt::Write as _;
28
29use serde::Deserialize;
30
31/// Multi-tenant default blob-upload cap (100 MiB).
32const MT_MAX_UPLOAD: u64 = 100 * 1024 * 1024;
33/// Multi-tenant default handler blobstore host read/copy cap (64 MiB).
34const MT_MAX_BLOB: u64 = 64 * 1024 * 1024;
35/// Multi-tenant default Wasm component blob cap (64 MiB).
36const MT_MAX_COMPONENT: u64 = 64 * 1024 * 1024;
37/// Single-tenant upload cap (1 GiB) — looser, single operator owns every site.
38const ST_MAX_UPLOAD: u64 = 1024 * 1024 * 1024;
39/// Single-tenant handler blobstore cap (256 MiB).
40const ST_MAX_BLOB: u64 = 256 * 1024 * 1024;
41/// Single-tenant component cap (128 MiB).
42const ST_MAX_COMPONENT: u64 = 128 * 1024 * 1024;
43
44/// A failure resolving the `[security]` configuration.
45#[derive(Debug, thiserror::Error)]
46pub enum SecurityError {
47    /// The selected `profile` is neither a built-in nor a key under `profiles`.
48    #[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/// Built-in posture presets. The default is [`SecurityProfile::MultiTenant`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SecurityProfile {
58    /// Strict: untrusted site writers + untrusted network. The default.
59    MultiTenant,
60    /// Relaxed for a single operator who owns every site (still authenticated).
61    SingleTenant,
62    /// Loopback-loose local development (auth optional, caps off).
63    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    /// Map a profile name to a built-in, if it is one.
82    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    /// The canonical name of this built-in profile.
92    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    /// The fully-resolved posture this preset implies (before overrides).
101    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                // Dev serves arbitrary test hosts locally; the gate is off.
150                require_domain_verification: false,
151            },
152        }
153    }
154}
155
156/// Individual posture-knob overrides — every field optional, `Some` wins over the
157/// profile preset (knobs are the source of truth). Used both as the top-level
158/// [`SecurityConfig::overrides`] and as each custom [`SecurityConfig::profiles`]
159/// entry (applied over the strict `multi-tenant` baseline). Byte caps: `0` =
160/// unlimited.
161#[derive(Debug, Clone, Default, Deserialize)]
162#[serde(default, deny_unknown_fields)]
163pub struct PostureOverrides {
164    /// Permit binding a non-loopback address with control-plane auth disabled.
165    pub allow_unauthenticated_public_bind: Option<bool>,
166    /// Default blob-upload cap in bytes (`0` = unlimited).
167    pub max_upload_bytes: Option<u64>,
168    /// Permit site-declared `unix:` gateway upstreams (operator-declared always ok).
169    pub allow_site_unix_upstreams: Option<bool>,
170    /// Permit site-declared gateway upstreams resolving to private/loopback IPs.
171    pub allow_site_private_upstreams: Option<bool>,
172    /// Cap on handler blobstore host reads/ranges/copies in bytes (`0` = unlimited).
173    pub max_handler_blob_bytes: Option<u64>,
174    /// Cap on a Wasm component blob in bytes (`0` = unlimited).
175    pub max_component_bytes: Option<u64>,
176    /// Require an OIDC audience when OIDC is enabled.
177    pub oidc_require_audience: Option<bool>,
178    /// Permit HTTP domain-verification probes to private/loopback/metadata hosts.
179    pub domain_verify_allow_private: Option<bool>,
180    /// Serve pending HTTP ownership challenges at
181    /// `/.well-known/boatramp-domain-verification/<token>` directly from the edge
182    /// (before host routing), so a host pointed at this server verifies itself
183    /// without a prior deploy — the fix for the domain-attach chicken-and-egg. On
184    /// by default in every profile (it only ever returns a random token to a host
185    /// with a matching pending challenge); an operator can disable it to require
186    /// out-of-band token placement instead.
187    pub domain_verify_self_serve: Option<bool>,
188    /// Permit scheduling untrusted workloads onto shared-kernel compute backends.
189    pub allow_shared_kernel_compute: Option<bool>,
190    /// Fail **open** (allow) instead of closed when the rate-limit KV is unreadable.
191    pub ratelimit_fail_open: Option<bool>,
192    /// Serve a site at root for an unmatched `Host` **without** an explicit domain
193    /// registration — either by first host label (`<site>.localhost`) or, when
194    /// exactly one site is served, as the sole site. A dev/single-operator
195    /// convenience; off under `multi-tenant` so a public host can never
196    /// implicitly resolve to a site. A loopback bind enables it regardless.
197    pub allow_implicit_routing: Option<bool>,
198    /// Require **every** control-plane token to carry a holder key (`cnf`) and to
199    /// present a valid per-request proof-of-possession (DPoP-style). Off by default
200    /// (a `cnf` token *always* requires a proof regardless — this knob additionally
201    /// bans plain bearer tokens fleet-wide, so a leaked bearer alone is inert).
202    pub require_pop: Option<bool>,
203    /// Refuse to serve a non-local `Host` that isn't a verified, attached
204    /// virtualhost (serve the pending page instead). On under multi-/single-tenant.
205    /// Setting `false` here (file + restart) disables the gate fleet-wide; a single
206    /// host is excluded instead with an admin `domain add <host> --unverified`.
207    pub require_domain_verification: Option<bool>,
208}
209
210/// The raw `[security]` config section as written in `boatramp.cfg` (RON).
211#[derive(Debug, Clone, Default, Deserialize)]
212#[serde(default, deny_unknown_fields)]
213pub struct SecurityConfig {
214    /// Selected profile: a built-in (`multi-tenant` / `single-tenant` / `dev`) or
215    /// a name defined under [`profiles`](Self::profiles). Default `multi-tenant`.
216    pub profile: Option<String>,
217    /// Operator-defined custom profiles: name → overrides over the strict baseline.
218    pub profiles: BTreeMap<String, PostureOverrides>,
219    /// Individual knob overrides applied over the selected profile (these win).
220    pub overrides: PostureOverrides,
221}
222
223impl SecurityConfig {
224    /// The base posture for a profile name: a built-in preset, or a custom profile
225    /// (its overrides applied over the strict `multi-tenant` baseline).
226    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    /// Resolve the configured profile + overrides into a concrete posture.
237    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    /// Render the resolved posture with each knob's value and source (the profile
243    /// preset vs an explicit override), for `boatramp security explain`.
244    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/// The **resolved** security posture: every knob a concrete value. [`Default`] is
324/// the strict `multi-tenant` preset, so a server with no `[security]` section —
325/// and any code path that defaults this — is locked down. Byte caps: `0` =
326/// unlimited.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct SecurityPosture {
329    /// Permit binding a non-loopback address with control-plane auth disabled.
330    pub allow_unauthenticated_public_bind: bool,
331    /// Default blob-upload cap in bytes, `0` = unlimited.
332    pub max_upload_bytes: u64,
333    /// Permit site-declared `unix:` gateway upstreams.
334    pub allow_site_unix_upstreams: bool,
335    /// Permit site-declared gateway upstreams to private/loopback IPs.
336    pub allow_site_private_upstreams: bool,
337    /// Cap on handler blobstore host reads/ranges/copies, `0` = unlimited.
338    pub max_handler_blob_bytes: u64,
339    /// Cap on a Wasm component blob, `0` = unlimited.
340    pub max_component_bytes: u64,
341    /// Require an OIDC audience when OIDC is enabled.
342    pub oidc_require_audience: bool,
343    /// Permit HTTP domain-verification probes to private hosts.
344    pub domain_verify_allow_private: bool,
345    /// Serve pending HTTP ownership challenges from the edge before host routing
346    /// (the domain-attach chicken-and-egg fix).
347    pub domain_verify_self_serve: bool,
348    /// Permit untrusted workloads on shared-kernel compute backends.
349    pub allow_shared_kernel_compute: bool,
350    /// Fail open instead of closed on rate-limit KV errors.
351    pub ratelimit_fail_open: bool,
352    /// Resolve an unmatched `Host` to a site without an explicit domain
353    /// registration (first-label `<site>.host` or the sole served site). Off
354    /// under `multi-tenant`; a loopback bind enables it regardless.
355    pub allow_implicit_routing: bool,
356    /// Require every control-plane token to be `cnf`-bound and PoP-proven
357    /// (fleet-wide holder-key enforcement). Off by default.
358    pub require_pop: bool,
359    /// Refuse to serve a **non-local** `Host` that is not a verified, attached
360    /// virtualhost — the request gets the "verification pending" holding page
361    /// instead of any `default_site`/implicit fallback. On under multi-/single-
362    /// tenant; off under `dev`. Local hosts (`localhost`/`*.localhost`/`*.local`/
363    /// IP literals) always serve. An operator disables it globally in
364    /// `[security]`, or excludes one host with an admin `domain add --unverified`.
365    pub require_domain_verification: bool,
366}
367
368impl Default for SecurityPosture {
369    fn default() -> Self {
370        SecurityProfile::MultiTenant.preset()
371    }
372}
373
374/// Apply a set of overrides over a base posture (each `Some` field wins).
375fn 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
421/// Render a byte cap for `explain` (`0` shows as `unlimited`).
422fn 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        // Off in every built-in preset (per-token opt-in is issuing a `cnf` token).
454        for profile in [
455            SecurityProfile::MultiTenant,
456            SecurityProfile::SingleTenant,
457            SecurityProfile::Dev,
458        ] {
459            assert!(!profile.preset().require_pop, "{}", profile.as_str());
460        }
461        // An explicit override turns fleet-wide enforcement on.
462        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); // unlimited
493        assert!(p.ratelimit_fail_open);
494        assert!(p.allow_implicit_routing);
495    }
496
497    #[test]
498    fn override_beats_profile() {
499        // `dev` disables OIDC audience; an explicit override re-requires it.
500        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        // A non-overridden knob still follows the dev preset.
516        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        // The custom knob is set...
536        assert!(p.allow_unauthenticated_public_bind);
537        // ...but everything else stays at the strict multi-tenant baseline.
538        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        // The overridden knob is marked (override) and 0 renders as unlimited.
567        assert!(text.lines().any(|l| l.contains("max_upload_bytes")
568            && l.contains("unlimited")
569            && l.contains("override")));
570        // A non-overridden knob is marked (profile).
571        assert!(text
572            .lines()
573            .any(|l| l.contains("oidc_require_audience") && l.contains("profile")));
574    }
575}