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}