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                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                // Dev serves arbitrary test hosts locally; the gate is off.
156                require_domain_verification: false,
157            },
158        }
159    }
160}
161
162/// Individual posture-knob overrides — every field optional, `Some` wins over the
163/// profile preset (knobs are the source of truth). Used both as the top-level
164/// [`SecurityConfig::overrides`] and as each custom [`SecurityConfig::profiles`]
165/// entry (applied over the strict `multi-tenant` baseline). Byte caps: `0` =
166/// unlimited.
167#[derive(Debug, Clone, Default, Deserialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct PostureOverrides {
170    /// Permit binding a non-loopback address with control-plane auth disabled.
171    pub allow_unauthenticated_public_bind: Option<bool>,
172    /// Default blob-upload cap in bytes (`0` = unlimited).
173    pub max_upload_bytes: Option<u64>,
174    /// Permit site-declared `unix:` gateway upstreams (operator-declared always ok).
175    pub allow_site_unix_upstreams: Option<bool>,
176    /// Permit site-declared gateway upstreams resolving to private/loopback IPs.
177    pub allow_site_private_upstreams: Option<bool>,
178    /// Permit a guest handler's outbound `wasi:http` to reach private/loopback IPs.
179    pub allow_guest_private_egress: Option<bool>,
180    /// Permit a guest handler's outbound `wasi:http` to reach this instance's own serve socket.
181    pub allow_guest_self_egress: Option<bool>,
182    /// Cap on handler blobstore host reads/ranges/copies in bytes (`0` = unlimited).
183    pub max_handler_blob_bytes: Option<u64>,
184    /// Cap on a Wasm component blob in bytes (`0` = unlimited).
185    pub max_component_bytes: Option<u64>,
186    /// Require an OIDC audience when OIDC is enabled.
187    pub oidc_require_audience: Option<bool>,
188    /// Permit HTTP domain-verification probes to private/loopback/metadata hosts.
189    pub domain_verify_allow_private: Option<bool>,
190    /// Serve pending HTTP ownership challenges at
191    /// `/.well-known/boatramp-domain-verification/<token>` directly from the edge
192    /// (before host routing), so a host pointed at this server verifies itself
193    /// without a prior deploy — the fix for the domain-attach chicken-and-egg. On
194    /// by default in every profile (it only ever returns a random token to a host
195    /// with a matching pending challenge); an operator can disable it to require
196    /// out-of-band token placement instead.
197    pub domain_verify_self_serve: Option<bool>,
198    /// Permit scheduling untrusted workloads onto shared-kernel compute backends.
199    pub allow_shared_kernel_compute: Option<bool>,
200    /// Fail **open** (allow) instead of closed when the rate-limit KV is unreadable.
201    pub ratelimit_fail_open: Option<bool>,
202    /// Serve a site at root for an unmatched `Host` **without** an explicit domain
203    /// registration — either by first host label (`<site>.localhost`) or, when
204    /// exactly one site is served, as the sole site. A dev/single-operator
205    /// convenience; off under `multi-tenant` so a public host can never
206    /// implicitly resolve to a site. A loopback bind enables it regardless.
207    pub allow_implicit_routing: Option<bool>,
208    /// Require **every** control-plane token to carry a holder key (`cnf`) and to
209    /// present a valid per-request proof-of-possession (DPoP-style). Off by default
210    /// (a `cnf` token *always* requires a proof regardless — this knob additionally
211    /// bans plain bearer tokens fleet-wide, so a leaked bearer alone is inert).
212    pub require_pop: Option<bool>,
213    /// Refuse to serve a non-local `Host` that isn't a verified, attached
214    /// virtualhost (serve the pending page instead). On under multi-/single-tenant.
215    /// Setting `false` here (file + restart) disables the gate fleet-wide; a single
216    /// host is excluded instead with an admin `domain add <host> --unverified`.
217    pub require_domain_verification: Option<bool>,
218}
219
220/// The raw `[security]` config section as written in `boatramp.cfg` (RON).
221#[derive(Debug, Clone, Default, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub struct SecurityConfig {
224    /// Selected profile: a built-in (`multi-tenant` / `single-tenant` / `dev`) or
225    /// a name defined under [`profiles`](Self::profiles). Default `multi-tenant`.
226    pub profile: Option<String>,
227    /// Operator-defined custom profiles: name → overrides over the strict baseline.
228    pub profiles: BTreeMap<String, PostureOverrides>,
229    /// Individual knob overrides applied over the selected profile (these win).
230    pub overrides: PostureOverrides,
231}
232
233impl SecurityConfig {
234    /// The base posture for a profile name: a built-in preset, or a custom profile
235    /// (its overrides applied over the strict `multi-tenant` baseline).
236    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    /// Resolve the configured profile + overrides into a concrete posture.
247    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    /// Render the resolved posture with each knob's value and source (the profile
253    /// preset vs an explicit override), for `boatramp security explain`.
254    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/// The **resolved** security posture: every knob a concrete value. [`Default`] is
344/// the strict `multi-tenant` preset, so a server with no `[security]` section —
345/// and any code path that defaults this — is locked down. Byte caps: `0` =
346/// unlimited.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub struct SecurityPosture {
349    /// Permit binding a non-loopback address with control-plane auth disabled.
350    pub allow_unauthenticated_public_bind: bool,
351    /// Default blob-upload cap in bytes, `0` = unlimited.
352    pub max_upload_bytes: u64,
353    /// Permit site-declared `unix:` gateway upstreams.
354    pub allow_site_unix_upstreams: bool,
355    /// Permit site-declared gateway upstreams to private/loopback IPs.
356    pub allow_site_private_upstreams: bool,
357    /// Permit a **guest** handler's outbound `wasi:http` to reach a private/loopback/
358    /// link-local address. Off under `multi-tenant` (the SSRF default — a guest can only
359    /// reach globally-routable hosts); on under `single-tenant`/`dev`. This is the guest
360    /// egress analog of [`allow_site_private_upstreams`](Self::allow_site_private_upstreams)
361    /// (which gates *operator-declared* gateway upstreams, a different path). It does **not**
362    /// cover a guest calling its own site — that is served in-process, host-asserted, and is
363    /// never treated as private egress.
364    pub allow_guest_private_egress: bool,
365    /// Permit a **guest** handler's outbound `wasi:http` to reach **this instance's own HTTP
366    /// serve socket** (loopback / the bind address on the serve port) even when
367    /// [`allow_guest_private_egress`](Self::allow_guest_private_egress) is off. A much tighter
368    /// grant than opening the whole private range: the only reachable internal target is
369    /// boatramp's own front door, which re-enters the full pipeline (host routing, visitor
370    /// auth, rate-limit, DV) — so a guest reaches only what any anonymous client could. A
371    /// self-recursion is bounded by a process-stamped depth cap. **On** by default in every
372    /// posture. (For depth-capped, allowlisted function-to-function calls, prefer the `invoke`
373    /// binding, which is unaffected by any egress knob.)
374    pub allow_guest_self_egress: bool,
375    /// Cap on handler blobstore host reads/ranges/copies, `0` = unlimited.
376    pub max_handler_blob_bytes: u64,
377    /// Cap on a Wasm component blob, `0` = unlimited.
378    pub max_component_bytes: u64,
379    /// Require an OIDC audience when OIDC is enabled.
380    pub oidc_require_audience: bool,
381    /// Permit HTTP domain-verification probes to private hosts.
382    pub domain_verify_allow_private: bool,
383    /// Serve pending HTTP ownership challenges from the edge before host routing
384    /// (the domain-attach chicken-and-egg fix).
385    pub domain_verify_self_serve: bool,
386    /// Permit untrusted workloads on shared-kernel compute backends.
387    pub allow_shared_kernel_compute: bool,
388    /// Fail open instead of closed on rate-limit KV errors.
389    pub ratelimit_fail_open: bool,
390    /// Resolve an unmatched `Host` to a site without an explicit domain
391    /// registration (first-label `<site>.host` or the sole served site). Off
392    /// under `multi-tenant`; a loopback bind enables it regardless.
393    pub allow_implicit_routing: bool,
394    /// Require every control-plane token to be `cnf`-bound and PoP-proven
395    /// (fleet-wide holder-key enforcement). Off by default.
396    pub require_pop: bool,
397    /// Refuse to serve a **non-local** `Host` that is not a verified, attached
398    /// virtualhost — the request gets the "verification pending" holding page
399    /// instead of any `default_site`/implicit fallback. On under multi-/single-
400    /// tenant; off under `dev`. Local hosts (`localhost`/`*.localhost`/`*.local`/
401    /// IP literals) always serve. An operator disables it globally in
402    /// `[security]`, or excludes one host with an admin `domain add --unverified`.
403    pub require_domain_verification: bool,
404}
405
406impl Default for SecurityPosture {
407    fn default() -> Self {
408        SecurityProfile::MultiTenant.preset()
409    }
410}
411
412/// Apply a set of overrides over a base posture (each `Some` field wins).
413fn 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
465/// Render a byte cap for `explain` (`0` shows as `unlimited`).
466fn 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        // Off in every built-in preset (per-token opt-in is issuing a `cnf` token).
498        for profile in [
499            SecurityProfile::MultiTenant,
500            SecurityProfile::SingleTenant,
501            SecurityProfile::Dev,
502        ] {
503            assert!(!profile.preset().require_pop, "{}", profile.as_str());
504        }
505        // An explicit override turns fleet-wide enforcement on.
506        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); // unlimited
537        assert!(p.ratelimit_fail_open);
538        assert!(p.allow_implicit_routing);
539    }
540
541    #[test]
542    fn override_beats_profile() {
543        // `dev` disables OIDC audience; an explicit override re-requires it.
544        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        // A non-overridden knob still follows the dev preset.
560        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        // The custom knob is set...
580        assert!(p.allow_unauthenticated_public_bind);
581        // ...but everything else stays at the strict multi-tenant baseline.
582        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        // The overridden knob is marked (override) and 0 renders as unlimited.
611        assert!(text.lines().any(|l| l.contains("max_upload_bytes")
612            && l.contains("unlimited")
613            && l.contains("override")));
614        // A non-overridden knob is marked (profile).
615        assert!(text
616            .lines()
617            .any(|l| l.contains("oidc_require_audience") && l.contains("profile")));
618    }
619}