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, Serialize};
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                allow_guest_egress_extra_ca: false,
111                max_handler_blob_bytes: MT_MAX_BLOB,
112                max_component_bytes: MT_MAX_COMPONENT,
113                oidc_require_audience: true,
114                domain_verify_allow_private: false,
115                domain_verify_self_serve: true,
116                allow_shared_kernel_compute: false,
117                allow_compute_exec: false,
118                ratelimit_fail_open: false,
119                allow_implicit_routing: false,
120                require_pop: false,
121                require_domain_verification: true,
122                allow_env_secret_refs: false,
123                allow_guest_email: false,
124                allow_guest_mint_capability: false,
125                max_guest_capability_ttl_secs: 900,
126                allow_guest_admin_domains: false,
127                allow_guest_admin_email: false,
128                allow_guest_admin_site: false,
129                allow_guest_admin_secrets: false,
130                require_tenancy_declaration: true,
131                allow_cross_tenant_db: false,
132            },
133            Self::SingleTenant => SecurityPosture {
134                allow_unauthenticated_public_bind: false,
135                max_upload_bytes: ST_MAX_UPLOAD,
136                allow_site_unix_upstreams: true,
137                allow_site_private_upstreams: true,
138                allow_guest_private_egress: true,
139                allow_guest_self_egress: true,
140                allow_guest_egress_extra_ca: true,
141                max_handler_blob_bytes: ST_MAX_BLOB,
142                max_component_bytes: ST_MAX_COMPONENT,
143                oidc_require_audience: true,
144                domain_verify_allow_private: true,
145                domain_verify_self_serve: true,
146                allow_shared_kernel_compute: true,
147                allow_compute_exec: false,
148                ratelimit_fail_open: false,
149                allow_implicit_routing: true,
150                require_pop: false,
151                require_domain_verification: true,
152                allow_env_secret_refs: true,
153                allow_guest_email: true,
154                allow_guest_mint_capability: true,
155                max_guest_capability_ttl_secs: 3600,
156                allow_guest_admin_domains: true,
157                allow_guest_admin_email: true,
158                allow_guest_admin_site: true,
159                allow_guest_admin_secrets: true,
160                require_tenancy_declaration: false,
161                allow_cross_tenant_db: true,
162            },
163            Self::Dev => SecurityPosture {
164                allow_unauthenticated_public_bind: true,
165                max_upload_bytes: 0,
166                allow_site_unix_upstreams: true,
167                allow_site_private_upstreams: true,
168                allow_guest_private_egress: true,
169                allow_guest_self_egress: true,
170                allow_guest_egress_extra_ca: true,
171                max_handler_blob_bytes: 0,
172                max_component_bytes: 0,
173                oidc_require_audience: false,
174                domain_verify_allow_private: true,
175                domain_verify_self_serve: true,
176                allow_shared_kernel_compute: true,
177                allow_compute_exec: true,
178                ratelimit_fail_open: true,
179                allow_implicit_routing: true,
180                require_pop: false,
181                // Dev serves arbitrary test hosts locally; the gate is off.
182                require_domain_verification: false,
183                allow_env_secret_refs: true,
184                allow_guest_email: true,
185                allow_guest_mint_capability: true,
186                max_guest_capability_ttl_secs: 3600,
187                allow_guest_admin_domains: true,
188                allow_guest_admin_email: true,
189                allow_guest_admin_site: true,
190                allow_guest_admin_secrets: true,
191                require_tenancy_declaration: false,
192                allow_cross_tenant_db: true,
193            },
194        }
195    }
196}
197
198/// Individual posture-knob overrides — every field optional, `Some` wins over the
199/// profile preset (knobs are the source of truth). Used both as the top-level
200/// [`SecurityConfig::overrides`] and as each custom [`SecurityConfig::profiles`]
201/// entry (applied over the strict `multi-tenant` baseline). Byte caps: `0` =
202/// unlimited.
203// `Serialize` (alongside `Deserialize`) lets a test enumerate every field name to assert each has a
204// `BOATRAMP_SECURITY_*` env mapping — the guard against silently forgetting an env knob (the exact
205// gap v0.4.18 closed). Serializing is otherwise unused; `None` fields render as null.
206#[derive(Debug, Clone, Default, Deserialize, Serialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct PostureOverrides {
209    /// Permit binding a non-loopback address with control-plane auth disabled.
210    pub allow_unauthenticated_public_bind: Option<bool>,
211    /// Default blob-upload cap in bytes (`0` = unlimited).
212    pub max_upload_bytes: Option<u64>,
213    /// Permit site-declared `unix:` gateway upstreams (operator-declared always ok).
214    pub allow_site_unix_upstreams: Option<bool>,
215    /// Permit site-declared gateway upstreams resolving to private/loopback IPs.
216    pub allow_site_private_upstreams: Option<bool>,
217    /// Permit a guest handler's outbound `wasi:http` to reach private/loopback IPs.
218    pub allow_guest_private_egress: Option<bool>,
219    /// Permit a guest handler's outbound `wasi:http` to reach this instance's own serve socket.
220    pub allow_guest_self_egress: Option<bool>,
221    /// Permit a guest handler's outbound `wasi:http` TLS client to trust an operator-supplied EXTRA
222    /// CA (`guest_egress_extra_ca_file`), on top of the webpki roots — a dev/single-tenant lever for
223    /// a hermetic HTTPS test double. Verification is still fully performed; this only widens the CA
224    /// set. Off (and refused) under `multi-tenant`. No effect unless a CA file is also configured.
225    pub allow_guest_egress_extra_ca: Option<bool>,
226    /// Cap on handler blobstore host reads/ranges/copies in bytes (`0` = unlimited).
227    pub max_handler_blob_bytes: Option<u64>,
228    /// Cap on a Wasm component blob in bytes (`0` = unlimited).
229    pub max_component_bytes: Option<u64>,
230    /// Require an OIDC audience when OIDC is enabled.
231    pub oidc_require_audience: Option<bool>,
232    /// Permit HTTP domain-verification probes to private/loopback/metadata hosts.
233    pub domain_verify_allow_private: Option<bool>,
234    /// Serve pending HTTP ownership challenges at
235    /// `/.well-known/boatramp-domain-verification/<token>` directly from the edge
236    /// (before host routing), so a host pointed at this server verifies itself
237    /// without a prior deploy — the fix for the domain-attach chicken-and-egg. On
238    /// by default in every profile (it only ever returns a random token to a host
239    /// with a matching pending challenge); an operator can disable it to require
240    /// out-of-band token placement instead.
241    pub domain_verify_self_serve: Option<bool>,
242    /// Permit scheduling untrusted workloads onto shared-kernel compute backends.
243    pub allow_shared_kernel_compute: Option<bool>,
244    /// Permit `boatramp compute exec` — running a command inside a running workload
245    /// (docker-exec style). Arbitrary code execution in the workload, so **off** in
246    /// every profile but `dev`; an operator opts in for migrations/backups/debug.
247    pub allow_compute_exec: Option<bool>,
248    /// Fail **open** (allow) instead of closed when the rate-limit KV is unreadable.
249    pub ratelimit_fail_open: Option<bool>,
250    /// Serve a site at root for an unmatched `Host` **without** an explicit domain
251    /// registration — either by first host label (`<site>.localhost`) or, when
252    /// exactly one site is served, as the sole site. A dev/single-operator
253    /// convenience; off under `multi-tenant` so a public host can never
254    /// implicitly resolve to a site. A loopback bind enables it regardless.
255    pub allow_implicit_routing: Option<bool>,
256    /// Require **every** control-plane token to carry a holder key (`cnf`) and to
257    /// present a valid per-request proof-of-possession (DPoP-style). Off by default
258    /// (a `cnf` token *always* requires a proof regardless — this knob additionally
259    /// bans plain bearer tokens fleet-wide, so a leaked bearer alone is inert).
260    pub require_pop: Option<bool>,
261    /// Refuse to serve a non-local `Host` that isn't a verified, attached
262    /// virtualhost (serve the pending page instead). On under multi-/single-tenant.
263    /// Setting `false` here (file + restart) disables the gate fleet-wide; a single
264    /// host is excluded instead with an admin `domain add <host> --unverified`.
265    pub require_domain_verification: Option<bool>,
266    /// Permit a site handler's `[handlers].secrets` / a function's `secrets` map to
267    /// name a **bare** / `env:`-scheme reference into the serve process's own
268    /// environment. Such a reference reads the *operator's* namespace, so it is only
269    /// safe when the config author IS the operator. On under `single-tenant`/`dev`;
270    /// **off** under `multi-tenant`, where an untrusted tenant authors the map and a
271    /// permitted bare ref would let them exfiltrate any host env var (another
272    /// tenant's DB password, a cloud key) into their guest.
273    pub allow_env_secret_refs: Option<bool>,
274    /// Permit a guest handler/function's `email` capability to send. Off under
275    /// `multi-tenant`; on under `single-tenant`/`dev`.
276    pub allow_guest_email: Option<bool>,
277    /// Permit a guest's `capability` capability to MINT fleet-signed target-capability tokens
278    /// (PLAN-delegable-capabilities). Off under `multi-tenant`; on under `single-tenant`/`dev`.
279    pub allow_guest_mint_capability: Option<bool>,
280    /// The operator's ceiling on the TTL (seconds) a guest-minted capability may request (R5). A
281    /// mint requesting more is clamped to this value.
282    pub max_guest_capability_ttl_secs: Option<u64>,
283    /// Permit a guest's `admin` capability to manage the project's domains.
284    pub allow_guest_admin_domains: Option<bool>,
285    /// Permit a guest's `admin` capability to manage the project's SMTP email profiles.
286    pub allow_guest_admin_email: Option<bool>,
287    /// Permit a guest's `admin` capability to write the project's site config + aliases.
288    pub allow_guest_admin_site: Option<bool>,
289    /// Permit a guest's `admin` capability to write the project's sealed secrets.
290    pub allow_guest_admin_secrets: Option<bool>,
291    /// Require an explicit in-site tenancy decision from sql/orm importers.
292    pub require_tenancy_declaration: Option<bool>,
293    /// Permit an in-site tenancy grant to reach across tenants (`all`).
294    pub allow_cross_tenant_db: Option<bool>,
295}
296
297/// A **per-project** override (Gap 4a) of the tenancy/capability posture sub-knobs, from
298/// `[security.projects.<project>]` in `boatramp.cfg`. ONLY these four knobs are per-project; the
299/// rest of the posture (egress, upload caps, domain verification, …) stays fleet-wide node policy.
300///
301/// A per-project override tunes only that project's OWN in-project tenancy strictness + its guests'
302/// capability-mint ceiling. It can never widen reach into **another** project — cross-project
303/// isolation is structural (project = database), not a posture knob. So one serve process can host a
304/// strict-isolation project alongside a looser one on a shared, multi-project machine.
305#[derive(Debug, Clone, Default, Deserialize)]
306#[serde(default, deny_unknown_fields)]
307pub struct ProjectPostureOverride {
308    /// Override [`SecurityPosture::require_tenancy_declaration`] for this project.
309    pub require_tenancy_declaration: Option<bool>,
310    /// Override [`SecurityPosture::allow_cross_tenant_db`] for this project.
311    pub allow_cross_tenant_db: Option<bool>,
312    /// Override [`SecurityPosture::allow_guest_mint_capability`] for this project.
313    pub allow_guest_mint_capability: Option<bool>,
314    /// Override [`SecurityPosture::max_guest_capability_ttl_secs`] for this project.
315    pub max_guest_capability_ttl_secs: Option<u64>,
316}
317
318/// The resolved per-project tenancy/capability knobs (base posture ⊕ an optional project override),
319/// consulted at each in-project enforcement point (tenancy declaration, cross-tenant `all`, guest
320/// capability minting). Cheap `Copy` so it can be looked up per request.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub struct ResolvedProjectTenancy {
323    /// Whether a sql/orm importer in this project must declare an explicit tenancy decision.
324    pub require_tenancy_declaration: bool,
325    /// Whether an `all` in-site grant may reach across sub-tenants within this project's database.
326    pub allow_cross_tenant_db: bool,
327    /// `Some(ttl)` ⇒ guest capability minting is enabled for this project, clamped to `ttl` seconds;
328    /// `None` ⇒ minting disabled (a guest `mint` is `access-denied`).
329    pub capability_max_ttl_secs: Option<u64>,
330}
331
332/// The raw `[security]` config section as written in `boatramp.cfg` (RON).
333#[derive(Debug, Clone, Default, Deserialize)]
334#[serde(default, deny_unknown_fields)]
335pub struct SecurityConfig {
336    /// Selected profile: a built-in (`multi-tenant` / `single-tenant` / `dev`) or
337    /// a name defined under [`profiles`](Self::profiles). Default `multi-tenant`.
338    pub profile: Option<String>,
339    /// Operator-defined custom profiles: name → overrides over the strict baseline.
340    pub profiles: BTreeMap<String, PostureOverrides>,
341    /// Individual knob overrides applied over the selected profile (these win).
342    pub overrides: PostureOverrides,
343    /// **Per-project** overrides (Gap 4a) of the tenancy/capability sub-knobs — project name →
344    /// override. Layered over the resolved fleet posture for that project only; every other project
345    /// (and every non-tenancy knob) uses the fleet posture. Lets a shared, multi-project serve
346    /// process run e.g. a strict-isolation production project alongside a looser preview project.
347    pub projects: BTreeMap<String, ProjectPostureOverride>,
348}
349
350impl SecurityConfig {
351    /// The base posture for a profile name: a built-in preset, or a custom profile
352    /// (its overrides applied over the strict `multi-tenant` baseline).
353    fn base_for(&self, name: &str) -> Result<SecurityPosture, SecurityError> {
354        if let Some(builtin) = SecurityProfile::from_name(name) {
355            Ok(builtin.preset())
356        } else if let Some(custom) = self.profiles.get(name) {
357            Ok(apply(SecurityProfile::MultiTenant.preset(), custom))
358        } else {
359            Err(SecurityError::UnknownProfile(name.to_string()))
360        }
361    }
362
363    /// Resolve the configured profile + overrides into a concrete posture.
364    pub fn resolve(&self) -> Result<SecurityPosture, SecurityError> {
365        let name = self.profile.as_deref().unwrap_or("multi-tenant");
366        Ok(apply(self.base_for(name)?, &self.overrides))
367    }
368
369    /// Render the resolved posture with each knob's value and source (the profile
370    /// preset vs an explicit override), for `boatramp security explain`.
371    pub fn explain(&self) -> Result<String, SecurityError> {
372        let name = self.profile.as_deref().unwrap_or("multi-tenant");
373        let p = self.resolve()?;
374        let o = &self.overrides;
375        let mut out = String::new();
376        let _ = writeln!(out, "security profile: {name}");
377        let mut row = |label: &str, value: String, overridden: bool| {
378            let src = if overridden { "override" } else { "profile" };
379            let _ = writeln!(out, "  {label:<34} {value:<12} ({src})");
380        };
381        row(
382            "allow_unauthenticated_public_bind",
383            p.allow_unauthenticated_public_bind.to_string(),
384            o.allow_unauthenticated_public_bind.is_some(),
385        );
386        row(
387            "max_upload_bytes",
388            fmt_cap(p.max_upload_bytes),
389            o.max_upload_bytes.is_some(),
390        );
391        row(
392            "allow_site_unix_upstreams",
393            p.allow_site_unix_upstreams.to_string(),
394            o.allow_site_unix_upstreams.is_some(),
395        );
396        row(
397            "allow_site_private_upstreams",
398            p.allow_site_private_upstreams.to_string(),
399            o.allow_site_private_upstreams.is_some(),
400        );
401        row(
402            "allow_guest_private_egress",
403            p.allow_guest_private_egress.to_string(),
404            o.allow_guest_private_egress.is_some(),
405        );
406        row(
407            "allow_guest_self_egress",
408            p.allow_guest_self_egress.to_string(),
409            o.allow_guest_self_egress.is_some(),
410        );
411        row(
412            "allow_guest_egress_extra_ca",
413            p.allow_guest_egress_extra_ca.to_string(),
414            o.allow_guest_egress_extra_ca.is_some(),
415        );
416        row(
417            "max_handler_blob_bytes",
418            fmt_cap(p.max_handler_blob_bytes),
419            o.max_handler_blob_bytes.is_some(),
420        );
421        row(
422            "max_component_bytes",
423            fmt_cap(p.max_component_bytes),
424            o.max_component_bytes.is_some(),
425        );
426        row(
427            "oidc_require_audience",
428            p.oidc_require_audience.to_string(),
429            o.oidc_require_audience.is_some(),
430        );
431        row(
432            "domain_verify_allow_private",
433            p.domain_verify_allow_private.to_string(),
434            o.domain_verify_allow_private.is_some(),
435        );
436        row(
437            "domain_verify_self_serve",
438            p.domain_verify_self_serve.to_string(),
439            o.domain_verify_self_serve.is_some(),
440        );
441        row(
442            "allow_shared_kernel_compute",
443            p.allow_shared_kernel_compute.to_string(),
444            o.allow_shared_kernel_compute.is_some(),
445        );
446        row(
447            "allow_compute_exec",
448            p.allow_compute_exec.to_string(),
449            o.allow_compute_exec.is_some(),
450        );
451        row(
452            "ratelimit_fail_open",
453            p.ratelimit_fail_open.to_string(),
454            o.ratelimit_fail_open.is_some(),
455        );
456        row(
457            "allow_implicit_routing",
458            p.allow_implicit_routing.to_string(),
459            o.allow_implicit_routing.is_some(),
460        );
461        row(
462            "require_pop",
463            p.require_pop.to_string(),
464            o.require_pop.is_some(),
465        );
466        row(
467            "allow_env_secret_refs",
468            p.allow_env_secret_refs.to_string(),
469            o.allow_env_secret_refs.is_some(),
470        );
471        row(
472            "allow_guest_email",
473            p.allow_guest_email.to_string(),
474            o.allow_guest_email.is_some(),
475        );
476        row(
477            "allow_guest_mint_capability",
478            p.allow_guest_mint_capability.to_string(),
479            o.allow_guest_mint_capability.is_some(),
480        );
481        row(
482            "max_guest_capability_ttl_secs",
483            p.max_guest_capability_ttl_secs.to_string(),
484            o.max_guest_capability_ttl_secs.is_some(),
485        );
486        row(
487            "allow_guest_admin_domains",
488            p.allow_guest_admin_domains.to_string(),
489            o.allow_guest_admin_domains.is_some(),
490        );
491        row(
492            "allow_guest_admin_email",
493            p.allow_guest_admin_email.to_string(),
494            o.allow_guest_admin_email.is_some(),
495        );
496        row(
497            "allow_guest_admin_site",
498            p.allow_guest_admin_site.to_string(),
499            o.allow_guest_admin_site.is_some(),
500        );
501        row(
502            "allow_guest_admin_secrets",
503            p.allow_guest_admin_secrets.to_string(),
504            o.allow_guest_admin_secrets.is_some(),
505        );
506        row(
507            "require_tenancy_declaration",
508            p.require_tenancy_declaration.to_string(),
509            o.require_tenancy_declaration.is_some(),
510        );
511        row(
512            "allow_cross_tenant_db",
513            p.allow_cross_tenant_db.to_string(),
514            o.allow_cross_tenant_db.is_some(),
515        );
516        Ok(out)
517    }
518}
519
520/// The **resolved** security posture: every knob a concrete value. [`Default`] is
521/// the strict `multi-tenant` preset, so a server with no `[security]` section —
522/// and any code path that defaults this — is locked down. Byte caps: `0` =
523/// unlimited.
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub struct SecurityPosture {
526    /// Permit binding a non-loopback address with control-plane auth disabled.
527    pub allow_unauthenticated_public_bind: bool,
528    /// Default blob-upload cap in bytes, `0` = unlimited.
529    pub max_upload_bytes: u64,
530    /// Permit site-declared `unix:` gateway upstreams.
531    pub allow_site_unix_upstreams: bool,
532    /// Permit site-declared gateway upstreams to private/loopback IPs.
533    pub allow_site_private_upstreams: bool,
534    /// Permit a **guest** handler's outbound `wasi:http` to reach a private/loopback/
535    /// link-local address. Off under `multi-tenant` (the SSRF default — a guest can only
536    /// reach globally-routable hosts); on under `single-tenant`/`dev`. This is the guest
537    /// egress analog of [`allow_site_private_upstreams`](Self::allow_site_private_upstreams)
538    /// (which gates *operator-declared* gateway upstreams, a different path). It does **not**
539    /// cover a guest calling its own site — that is served in-process, host-asserted, and is
540    /// never treated as private egress.
541    pub allow_guest_private_egress: bool,
542    /// Permit a **guest** handler's outbound `wasi:http` to reach **this instance's own HTTP
543    /// serve socket** (loopback / the bind address on the serve port) even when
544    /// [`allow_guest_private_egress`](Self::allow_guest_private_egress) is off. A much tighter
545    /// grant than opening the whole private range: the only reachable internal target is
546    /// boatramp's own front door, which re-enters the full pipeline (host routing, visitor
547    /// auth, rate-limit, DV) — so a guest reaches only what any anonymous client could. A
548    /// self-recursion is bounded by a process-stamped depth cap. **On** by default in every
549    /// posture. (For depth-capped, allowlisted function-to-function calls, prefer the `invoke`
550    /// binding, which is unaffected by any egress knob.)
551    pub allow_guest_self_egress: bool,
552    /// Whether a guest's outbound `wasi:http` TLS client trusts an operator-supplied EXTRA CA
553    /// (`guest_egress_extra_ca_file`) on top of the webpki roots. Off under `multi-tenant`; a
554    /// trusted single-tenant/dev fleet may opt in (e.g. to reach a hermetic HTTPS test double under
555    /// a test CA). Verification is still fully performed — this only widens the accepted CA set.
556    pub allow_guest_egress_extra_ca: bool,
557    /// Cap on handler blobstore host reads/ranges/copies, `0` = unlimited.
558    pub max_handler_blob_bytes: u64,
559    /// Cap on a Wasm component blob, `0` = unlimited.
560    pub max_component_bytes: u64,
561    /// Require an OIDC audience when OIDC is enabled.
562    pub oidc_require_audience: bool,
563    /// Permit HTTP domain-verification probes to private hosts.
564    pub domain_verify_allow_private: bool,
565    /// Serve pending HTTP ownership challenges from the edge before host routing
566    /// (the domain-attach chicken-and-egg fix).
567    pub domain_verify_self_serve: bool,
568    /// Permit untrusted workloads on shared-kernel compute backends.
569    pub allow_shared_kernel_compute: bool,
570    /// Permit `boatramp compute exec` (run a command inside a running workload).
571    pub allow_compute_exec: bool,
572    /// Fail open instead of closed on rate-limit KV errors.
573    pub ratelimit_fail_open: bool,
574    /// Resolve an unmatched `Host` to a site without an explicit domain
575    /// registration (first-label `<site>.host` or the sole served site). Off
576    /// under `multi-tenant`; a loopback bind enables it regardless.
577    pub allow_implicit_routing: bool,
578    /// Require every control-plane token to be `cnf`-bound and PoP-proven
579    /// (fleet-wide holder-key enforcement). Off by default.
580    pub require_pop: bool,
581    /// Refuse to serve a **non-local** `Host` that is not a verified, attached
582    /// virtualhost — the request gets the "verification pending" holding page
583    /// instead of any `default_site`/implicit fallback. On under multi-/single-
584    /// tenant; off under `dev`. Local hosts (`localhost`/`*.localhost`/`*.local`/
585    /// IP literals) always serve. An operator disables it globally in
586    /// `[security]`, or excludes one host with an admin `domain add --unverified`.
587    pub require_domain_verification: bool,
588    /// Permit a site handler's / function's `secrets` map to resolve a **bare** or
589    /// `env:`-scheme reference against the serve process's own environment. That
590    /// namespace is the *operator's*, so a bare ref is only safe when the config
591    /// author IS the operator: on under `single-tenant`/`dev`, **off** under
592    /// `multi-tenant`. When off, `resolve_secret_env` refuses such a ref (fail-closed)
593    /// instead of injecting the host value, so an untrusted tenant can't name an
594    /// arbitrary host env var to exfiltrate it across the tenant boundary.
595    pub allow_env_secret_refs: bool,
596    /// Permit a **guest** handler/function's `email` capability to actually send
597    /// (bind the `send` verb). Off under `multi-tenant` — an untrusted tenant can't
598    /// use the shared node's SMTP egress until the operator opts in — and on under
599    /// `single-tenant`/`dev`. Independent of the guest-HTTP egress knobs: email is a
600    /// host-mediated SMTP connection whose credentials the guest never sees (a
601    /// separate path), so it is gated separately. When off, the binding is absent
602    /// and `send` returns `access-denied`. The SMTP relay host is additionally held
603    /// to the SSRF rule (a private/loopback relay is refused unless
604    /// [`allow_guest_private_egress`](Self::allow_guest_private_egress) is on).
605    pub allow_guest_email: bool,
606    /// Permit a **guest**'s `capability` capability to MINT fleet-signed target-capability tokens
607    /// (`boatramp:handlers/capability`, PLAN-delegable-capabilities). A minted token is bounded:
608    /// its audience is host-forced to the guest's OWN project (never redeemable elsewhere), its TTL
609    /// is clamped to [`max_guest_capability_ttl_secs`](Self::max_guest_capability_ttl_secs), and its
610    /// power is fully gated at redeem by the operator's target-eligible route config (a token is inert
611    /// anywhere no matching `via:[capability]` route is opened). Off under `multi-tenant` (an untrusted
612    /// tenant can't mint); on under `single-tenant`/`dev`. When off, the binding is absent and `mint`
613    /// returns `access-denied`.
614    pub allow_guest_mint_capability: bool,
615    /// The operator's ceiling (seconds) on a guest-minted capability's TTL (R5). A `mint` requesting a
616    /// larger TTL is clamped to this; `0` disables minting (any request is refused). Defaults to 1h.
617    pub max_guest_capability_ttl_secs: u64,
618    /// Permit a **guest**'s `admin` capability to manage the project's **domains** (add /
619    /// verify / attach-verified / remove) via `boatramp:handlers/admin`. Off under
620    /// `multi-tenant`, on under `single-tenant`/`dev`. Per-surface + operator-set (a tenant
621    /// can't turn it on via site config); domain attach still runs the real ownership probe,
622    /// and there is no guest path to the unverified-attach admin route.
623    pub allow_guest_admin_domains: bool,
624    /// Permit a guest's `admin` capability to manage the project's **SMTP email profiles**
625    /// (set / delete). Passwords stay sealed and are never returned to the guest.
626    pub allow_guest_admin_email: bool,
627    /// Permit a guest's `admin` capability to write **site config + aliases** (routing,
628    /// headers, cache). A config write can't attach an unverified domain (the verified-domain
629    /// guard is shared with the HTTP path).
630    pub allow_guest_admin_site: bool,
631    /// Permit a guest's `admin` capability to write the project's **sealed secrets** (set /
632    /// rotate / delete — write-only, redacted). The most sensitive surface: an operator can
633    /// withhold it while still allowing domains/email/site self-service.
634    pub allow_guest_admin_secrets: bool,
635    /// Require an **explicit in-site tenancy decision** (Dimension 0) from any site/function that
636    /// imports `sql`/`orm`: it must declare either `tenancy: disabled` (deliberately plain) or a
637    /// `scoped` config. On under `multi-tenant` — so running a query unscoped on an
638    /// untrusted-tenant fleet is a reviewed choice, never an accidental omission — and **off**
639    /// under `single-tenant`/`dev` (one operator; *undeclared* silently means plain). When on, an
640    /// undeclared sql/orm importer is refused at activation.
641    pub require_tenancy_declaration: bool,
642    /// Permit an in-site tenancy grant to reach **across tenants** (`read`/`write: all`) — the
643    /// operator ceiling on the cross-tenant mode. **Off** under `multi-tenant` (an `all` grant is
644    /// refused until the operator opts in) and on under `single-tenant`/`dev`. Independent of the
645    /// per-function grant: even a function that declares `all` is capped to `own` (its resolved
646    /// tenant) while this is off, so a compromised/misconfigured tenant can't read the fleet.
647    pub allow_cross_tenant_db: bool,
648}
649
650impl Default for SecurityPosture {
651    fn default() -> Self {
652        SecurityProfile::MultiTenant.preset()
653    }
654}
655
656impl SecurityPosture {
657    /// The base (no per-project override) resolved tenancy/capability knobs for this posture.
658    pub fn base_project_tenancy(&self) -> ResolvedProjectTenancy {
659        ResolvedProjectTenancy {
660            require_tenancy_declaration: self.require_tenancy_declaration,
661            allow_cross_tenant_db: self.allow_cross_tenant_db,
662            capability_max_ttl_secs: (self.allow_guest_mint_capability
663                && self.max_guest_capability_ttl_secs > 0)
664                .then_some(self.max_guest_capability_ttl_secs),
665        }
666    }
667
668    /// Apply a [`ProjectPostureOverride`] over this posture's base tenancy knobs (Gap 4a). Each
669    /// `Some` field of the override wins; the rest fall through to the fleet posture. Only affects
670    /// this project's own in-project tenancy + capability-mint ceiling — never cross-project reach.
671    pub fn project_tenancy(&self, ovr: &ProjectPostureOverride) -> ResolvedProjectTenancy {
672        let mint = ovr
673            .allow_guest_mint_capability
674            .unwrap_or(self.allow_guest_mint_capability);
675        let ttl = ovr
676            .max_guest_capability_ttl_secs
677            .unwrap_or(self.max_guest_capability_ttl_secs);
678        ResolvedProjectTenancy {
679            require_tenancy_declaration: ovr
680                .require_tenancy_declaration
681                .unwrap_or(self.require_tenancy_declaration),
682            allow_cross_tenant_db: ovr
683                .allow_cross_tenant_db
684                .unwrap_or(self.allow_cross_tenant_db),
685            capability_max_ttl_secs: (mint && ttl > 0).then_some(ttl),
686        }
687    }
688}
689
690/// Apply a set of overrides over a base posture (each `Some` field wins).
691fn apply(mut base: SecurityPosture, o: &PostureOverrides) -> SecurityPosture {
692    if let Some(v) = o.allow_unauthenticated_public_bind {
693        base.allow_unauthenticated_public_bind = v;
694    }
695    if let Some(v) = o.max_upload_bytes {
696        base.max_upload_bytes = v;
697    }
698    if let Some(v) = o.allow_site_unix_upstreams {
699        base.allow_site_unix_upstreams = v;
700    }
701    if let Some(v) = o.allow_site_private_upstreams {
702        base.allow_site_private_upstreams = v;
703    }
704    if let Some(v) = o.allow_guest_private_egress {
705        base.allow_guest_private_egress = v;
706    }
707    if let Some(v) = o.allow_guest_self_egress {
708        base.allow_guest_self_egress = v;
709    }
710    if let Some(v) = o.allow_guest_egress_extra_ca {
711        base.allow_guest_egress_extra_ca = v;
712    }
713    if let Some(v) = o.max_handler_blob_bytes {
714        base.max_handler_blob_bytes = v;
715    }
716    if let Some(v) = o.max_component_bytes {
717        base.max_component_bytes = v;
718    }
719    if let Some(v) = o.oidc_require_audience {
720        base.oidc_require_audience = v;
721    }
722    if let Some(v) = o.domain_verify_allow_private {
723        base.domain_verify_allow_private = v;
724    }
725    if let Some(v) = o.domain_verify_self_serve {
726        base.domain_verify_self_serve = v;
727    }
728    if let Some(v) = o.allow_shared_kernel_compute {
729        base.allow_shared_kernel_compute = v;
730    }
731    if let Some(v) = o.allow_compute_exec {
732        base.allow_compute_exec = v;
733    }
734    if let Some(v) = o.ratelimit_fail_open {
735        base.ratelimit_fail_open = v;
736    }
737    if let Some(v) = o.allow_implicit_routing {
738        base.allow_implicit_routing = v;
739    }
740    if let Some(v) = o.require_pop {
741        base.require_pop = v;
742    }
743    if let Some(v) = o.require_domain_verification {
744        base.require_domain_verification = v;
745    }
746    if let Some(v) = o.allow_env_secret_refs {
747        base.allow_env_secret_refs = v;
748    }
749    if let Some(v) = o.allow_guest_email {
750        base.allow_guest_email = v;
751    }
752    if let Some(v) = o.allow_guest_mint_capability {
753        base.allow_guest_mint_capability = v;
754    }
755    if let Some(v) = o.max_guest_capability_ttl_secs {
756        base.max_guest_capability_ttl_secs = v;
757    }
758    if let Some(v) = o.allow_guest_admin_domains {
759        base.allow_guest_admin_domains = v;
760    }
761    if let Some(v) = o.allow_guest_admin_email {
762        base.allow_guest_admin_email = v;
763    }
764    if let Some(v) = o.allow_guest_admin_site {
765        base.allow_guest_admin_site = v;
766    }
767    if let Some(v) = o.allow_guest_admin_secrets {
768        base.allow_guest_admin_secrets = v;
769    }
770    if let Some(v) = o.require_tenancy_declaration {
771        base.require_tenancy_declaration = v;
772    }
773    if let Some(v) = o.allow_cross_tenant_db {
774        base.allow_cross_tenant_db = v;
775    }
776    base
777}
778
779/// Render a byte cap for `explain` (`0` shows as `unlimited`).
780fn fmt_cap(bytes: u64) -> String {
781    if bytes == 0 {
782        "unlimited".to_string()
783    } else {
784        bytes.to_string()
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn per_project_override_tunes_only_the_named_project() {
794        // Base: strict multi-tenant (declaration required, no cross-tenant, no guest mint).
795        let base = SecurityProfile::MultiTenant.preset();
796        let b = base.base_project_tenancy();
797        assert!(b.require_tenancy_declaration);
798        assert!(!b.allow_cross_tenant_db);
799        assert_eq!(b.capability_max_ttl_secs, None);
800
801        // A project override permits the `all` twins + guest capability minting WHILE keeping
802        // strict declaration — the three knobs compose (Gap 4.3).
803        let ovr = ProjectPostureOverride {
804            require_tenancy_declaration: None, // inherit (stays true)
805            allow_cross_tenant_db: Some(true),
806            allow_guest_mint_capability: Some(true),
807            max_guest_capability_ttl_secs: Some(1800),
808        };
809        let r = base.project_tenancy(&ovr);
810        assert!(r.require_tenancy_declaration); // inherited strict
811        assert!(r.allow_cross_tenant_db); // opted in
812        assert_eq!(r.capability_max_ttl_secs, Some(1800)); // minting enabled + clamped
813
814        // Minting stays OFF when only a TTL is given without enabling the knob.
815        let ttl_only = ProjectPostureOverride {
816            max_guest_capability_ttl_secs: Some(3600),
817            ..Default::default()
818        };
819        assert_eq!(
820            base.project_tenancy(&ttl_only).capability_max_ttl_secs,
821            None
822        );
823
824        // A tighter project override (disable minting the base had enabled) also holds.
825        let looser = SecurityProfile::SingleTenant.preset();
826        assert!(looser
827            .base_project_tenancy()
828            .capability_max_ttl_secs
829            .is_some());
830        let tighten = ProjectPostureOverride {
831            allow_guest_mint_capability: Some(false),
832            ..Default::default()
833        };
834        assert_eq!(
835            looser.project_tenancy(&tighten).capability_max_ttl_secs,
836            None
837        );
838    }
839
840    #[test]
841    fn default_posture_is_multi_tenant_strict() {
842        let p = SecurityPosture::default();
843        assert_eq!(p, SecurityProfile::MultiTenant.preset());
844        assert!(!p.allow_unauthenticated_public_bind);
845        assert!(!p.allow_site_unix_upstreams);
846        assert!(!p.allow_site_private_upstreams);
847        assert!(p.oidc_require_audience);
848        assert!(!p.domain_verify_allow_private);
849        assert!(p.domain_verify_self_serve);
850        assert!(!p.allow_shared_kernel_compute);
851        assert!(!p.ratelimit_fail_open);
852        assert!(!p.allow_implicit_routing);
853        assert!(!p.require_pop);
854        // Bare/`env:` secret refs read the operator namespace → off under multi-tenant.
855        assert!(!p.allow_env_secret_refs);
856        assert_eq!(p.max_upload_bytes, MT_MAX_UPLOAD);
857    }
858
859    #[test]
860    fn allow_env_secret_refs_follows_the_trust_model() {
861        // Multi-tenant: untrusted config authors, so a bare host-env secret ref is off.
862        assert!(!SecurityProfile::MultiTenant.preset().allow_env_secret_refs);
863        // Single-tenant / dev: the operator owns every config, so it is on.
864        assert!(SecurityProfile::SingleTenant.preset().allow_env_secret_refs);
865        assert!(SecurityProfile::Dev.preset().allow_env_secret_refs);
866        // An explicit override wins over the profile (e.g. re-enable under multi-tenant).
867        let cfg = SecurityConfig {
868            overrides: PostureOverrides {
869                allow_env_secret_refs: Some(true),
870                ..Default::default()
871            },
872            ..Default::default()
873        };
874        let p = cfg.resolve().unwrap();
875        assert!(p.allow_env_secret_refs);
876        assert!(cfg
877            .explain()
878            .unwrap()
879            .lines()
880            .any(|l| l.contains("allow_env_secret_refs")
881                && l.contains("true")
882                && l.contains("override")));
883    }
884
885    #[test]
886    fn allow_guest_email_follows_the_trust_model() {
887        // Multi-tenant: untrusted tenants can't use the shared SMTP egress by default.
888        assert!(!SecurityProfile::MultiTenant.preset().allow_guest_email);
889        // Single-tenant / dev: the operator owns everything, so it is on.
890        assert!(SecurityProfile::SingleTenant.preset().allow_guest_email);
891        assert!(SecurityProfile::Dev.preset().allow_guest_email);
892        // An explicit override wins (opt a multi-tenant fleet in).
893        let cfg = SecurityConfig {
894            overrides: PostureOverrides {
895                allow_guest_email: Some(true),
896                ..Default::default()
897            },
898            ..Default::default()
899        };
900        assert!(cfg.resolve().unwrap().allow_guest_email);
901        assert!(cfg
902            .explain()
903            .unwrap()
904            .lines()
905            .any(|l| l.contains("allow_guest_email")
906                && l.contains("true")
907                && l.contains("override")));
908    }
909
910    #[test]
911    fn allow_guest_admin_is_per_surface_and_follows_the_trust_model() {
912        // All four surfaces off under multi-tenant, on under single-tenant/dev.
913        let mt = SecurityProfile::MultiTenant.preset();
914        assert!(
915            !mt.allow_guest_admin_domains
916                && !mt.allow_guest_admin_email
917                && !mt.allow_guest_admin_site
918                && !mt.allow_guest_admin_secrets
919        );
920        let st = SecurityProfile::SingleTenant.preset();
921        assert!(
922            st.allow_guest_admin_domains
923                && st.allow_guest_admin_email
924                && st.allow_guest_admin_site
925                && st.allow_guest_admin_secrets
926        );
927        // Per-surface override: an operator enables domains fleet-wide but keeps the sensitive
928        // secrets surface OFF — the whole point of per-surface knobs.
929        let cfg = SecurityConfig {
930            overrides: PostureOverrides {
931                allow_guest_admin_domains: Some(true),
932                ..Default::default()
933            },
934            ..Default::default()
935        };
936        let p = cfg.resolve().unwrap();
937        assert!(p.allow_guest_admin_domains);
938        assert!(!p.allow_guest_admin_secrets, "other surfaces stay off");
939        assert!(cfg
940            .explain()
941            .unwrap()
942            .lines()
943            .any(|l| l.contains("allow_guest_admin_domains")
944                && l.contains("true")
945                && l.contains("override")));
946    }
947
948    #[test]
949    fn tenancy_knobs_follow_the_trust_model() {
950        // Multi-tenant demands an explicit tenancy decision and forbids cross-tenant `all`.
951        let mt = SecurityProfile::MultiTenant.preset();
952        assert!(mt.require_tenancy_declaration);
953        assert!(!mt.allow_cross_tenant_db);
954        // Single-tenant / dev: one operator — undeclared is fine, cross-tenant is allowed.
955        for p in [
956            SecurityProfile::SingleTenant.preset(),
957            SecurityProfile::Dev.preset(),
958        ] {
959            assert!(!p.require_tenancy_declaration);
960            assert!(p.allow_cross_tenant_db);
961        }
962        // An operator can open cross-tenant on a multi-tenant fleet explicitly.
963        let cfg = SecurityConfig {
964            overrides: PostureOverrides {
965                allow_cross_tenant_db: Some(true),
966                ..Default::default()
967            },
968            ..Default::default()
969        };
970        let p = cfg.resolve().unwrap();
971        assert!(p.allow_cross_tenant_db);
972        assert!(
973            p.require_tenancy_declaration,
974            "the declaration gate stays on"
975        );
976        assert!(cfg
977            .explain()
978            .unwrap()
979            .lines()
980            .any(|l| l.contains("allow_cross_tenant_db")
981                && l.contains("true")
982                && l.contains("override")));
983    }
984
985    #[test]
986    fn require_pop_defaults_off_everywhere_and_overrides() {
987        // Off in every built-in preset (per-token opt-in is issuing a `cnf` token).
988        for profile in [
989            SecurityProfile::MultiTenant,
990            SecurityProfile::SingleTenant,
991            SecurityProfile::Dev,
992        ] {
993            assert!(!profile.preset().require_pop, "{}", profile.as_str());
994        }
995        // An explicit override turns fleet-wide enforcement on.
996        let cfg = SecurityConfig {
997            overrides: PostureOverrides {
998                require_pop: Some(true),
999                ..Default::default()
1000            },
1001            ..Default::default()
1002        };
1003        assert!(cfg.resolve().unwrap().require_pop);
1004        assert!(cfg
1005            .explain()
1006            .unwrap()
1007            .lines()
1008            .any(|l| l.contains("require_pop") && l.contains("true") && l.contains("override")));
1009    }
1010
1011    #[test]
1012    fn empty_config_resolves_to_multi_tenant() {
1013        let resolved = SecurityConfig::default().resolve().unwrap();
1014        assert_eq!(resolved, SecurityProfile::MultiTenant.preset());
1015    }
1016
1017    #[test]
1018    fn dev_profile_is_loose() {
1019        let cfg = SecurityConfig {
1020            profile: Some("dev".into()),
1021            ..Default::default()
1022        };
1023        let p = cfg.resolve().unwrap();
1024        assert!(p.allow_unauthenticated_public_bind);
1025        assert!(!p.oidc_require_audience);
1026        assert_eq!(p.max_upload_bytes, 0); // unlimited
1027        assert!(p.ratelimit_fail_open);
1028        assert!(p.allow_implicit_routing);
1029    }
1030
1031    #[test]
1032    fn override_beats_profile() {
1033        // `dev` disables OIDC audience; an explicit override re-requires it.
1034        let cfg = SecurityConfig {
1035            profile: Some("dev".into()),
1036            overrides: PostureOverrides {
1037                oidc_require_audience: Some(true),
1038                max_upload_bytes: Some(123),
1039                ..Default::default()
1040            },
1041            ..Default::default()
1042        };
1043        let p = cfg.resolve().unwrap();
1044        assert!(
1045            p.oidc_require_audience,
1046            "override must win over the profile"
1047        );
1048        assert_eq!(p.max_upload_bytes, 123);
1049        // A non-overridden knob still follows the dev preset.
1050        assert!(p.allow_unauthenticated_public_bind);
1051    }
1052
1053    #[test]
1054    fn custom_profile_layers_over_multi_tenant_baseline() {
1055        let mut profiles = BTreeMap::new();
1056        profiles.insert(
1057            "ci".to_string(),
1058            PostureOverrides {
1059                allow_unauthenticated_public_bind: Some(true),
1060                ..Default::default()
1061            },
1062        );
1063        let cfg = SecurityConfig {
1064            profile: Some("ci".into()),
1065            profiles,
1066            ..Default::default()
1067        };
1068        let p = cfg.resolve().unwrap();
1069        // The custom knob is set...
1070        assert!(p.allow_unauthenticated_public_bind);
1071        // ...but everything else stays at the strict multi-tenant baseline.
1072        assert!(!p.allow_site_private_upstreams);
1073        assert!(p.oidc_require_audience);
1074    }
1075
1076    #[test]
1077    fn unknown_profile_errors() {
1078        let cfg = SecurityConfig {
1079            profile: Some("nope".into()),
1080            ..Default::default()
1081        };
1082        assert!(matches!(
1083            cfg.resolve(),
1084            Err(SecurityError::UnknownProfile(name)) if name == "nope"
1085        ));
1086    }
1087
1088    #[test]
1089    fn explain_marks_value_source() {
1090        let cfg = SecurityConfig {
1091            profile: Some("multi-tenant".into()),
1092            overrides: PostureOverrides {
1093                max_upload_bytes: Some(0),
1094                ..Default::default()
1095            },
1096            ..Default::default()
1097        };
1098        let text = cfg.explain().unwrap();
1099        assert!(text.contains("security profile: multi-tenant"));
1100        // The overridden knob is marked (override) and 0 renders as unlimited.
1101        assert!(text.lines().any(|l| l.contains("max_upload_bytes")
1102            && l.contains("unlimited")
1103            && l.contains("override")));
1104        // A non-overridden knob is marked (profile).
1105        assert!(text
1106            .lines()
1107            .any(|l| l.contains("oidc_require_audience") && l.contains("profile")));
1108    }
1109}