Skip to main content

basil_core/
agent_cli.rs

1//! `basil-agent`: the standalone Basil daemon.
2//!
3//! It loads a **`0600` sealed bundle** of the broker's own bootstrap credentials
4//! (vault-vh1, `designs/unlock-and-bundle.html`), unlocks it via an enabled +
5//! available slot, hands each backend its credential, zeroizes the plaintext,
6//! and hands off to [`crate::run_grpc`].
7//!
8//! The old plaintext bootstrap paths (`--vault-token` / `$VAULT_TOKEN` /
9//! `~/.vault-token` and the implicit-SPIFFE flags) are **gone**: the sealed
10//! bundle is the only supported source of bootstrap creds, with no fallback.
11//!
12//! The top-level `basil bundle` subcommand creates and manages sealed bundles.
13//! Core broker logic lives in this crate's library.
14
15// `indexing_slicing` gate has no test-allow config option, unlike unwrap/expect.
16// Tests index `serde_json::Value` (e.g. `doc["decision"]`) by construction.
17#![cfg_attr(test, allow(clippy::indexing_slicing))]
18
19use std::collections::BTreeMap;
20use std::path::PathBuf;
21use std::sync::Arc;
22use std::time::Duration;
23
24use crate::catalog::{Class, KeyAlgorithm};
25use crate::seal::{BackendCred, CredBundle};
26use crate::service::broker::{BrokerIdentityRuntimeConfig, InvocationRuntimeConfig};
27use crate::{
28    AuditLog, Backend, BackendKind, BackendManager, BrokerLimits, BrokerState, CapabilityPolicy,
29    DEFAULT_MAX_ENCRYPT_SIZE, DEFAULT_MAX_PAYLOAD_SIZE, DEFAULT_ROTATION_GRACE_VERSIONS,
30    DEFAULT_SOCKET_MODE, DEFAULT_SVID_TTL_SECS, JwtRevocationStore, MissingPolicy, ReloadActor,
31    ReloadInputs, ServerConfig, SpiffeConfig, SpiffeVaultBackend, VaultBackend,
32    enforce_capabilities, load, reload_generation, run_grpc,
33};
34use crate::{bundle_cli, doctor, init, unlock};
35use anyhow::{Context, Result, bail};
36#[cfg(feature = "keystore-backend")]
37use basil_keystore_backend::StoreConfig;
38#[cfg(feature = "otlp")]
39use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
40#[cfg(feature = "otlp")]
41use opentelemetry_otlp::{Protocol, WithExportConfig};
42#[cfg(feature = "otlp")]
43use opentelemetry_sdk::logs::SdkLoggerProvider;
44use serde::Deserialize;
45use serde::de::{self, Visitor};
46use tokio::signal::unix::{SignalKind, signal};
47use tracing::{info, warn};
48use tracing_subscriber::{EnvFilter, fmt, prelude::*};
49
50const MAX_INVOCATION_TTL_SECS: u32 = 300;
51const MAX_INVOCATION_CLOCK_SKEW_SECS: u32 = 300;
52const MAX_INVOCATION_REPLAY_CACHE_CAPACITY: usize = 1_000_000;
53const BROKER_KEY_USE_LABEL: &str = "broker_key_use";
54const BROKER_RESPONSE_SIGNING_USE: &str = "response-signing";
55const BROKER_REQUEST_ENCRYPTION_USE: &str = "request-encryption";
56
57/// basil agent: a signing proxy over a Vault transit engine (Vault or `OpenBao`).
58///
59/// `run` launches the broker daemon.
60/// Arguments for the daemon (`run`) path.
61#[derive(Debug, clap::Args)]
62pub struct RunArgs {
63    #[command(flatten)]
64    overrides: ConfigOverrides,
65}
66
67/// Arguments for the read-only catalog **`check`** (lint) path (`vault-roe`).
68///
69/// Loads the catalog/policy + unlocks the bundle (the same setup `run` uses),
70/// then probes each catalog key and reports present/missing per key with its
71/// `missing` policy. With `--require`, exits non-zero iff a required
72/// (`missing=error`) key is absent. It NEVER generates or mutates anything, and
73/// never binds a socket: a CI / pre-deploy gate.
74#[derive(Debug, clap::Args)]
75pub struct CheckArgs {
76    /// Exit non-zero if any `missing=error` (required) key is absent from its
77    /// backend. Without this flag the check is report-only and always exits 0.
78    /// `warn`/`generate`-policy absent keys never fail the gate.
79    #[arg(long)]
80    require: bool,
81
82    #[command(flatten)]
83    overrides: ConfigOverrides,
84}
85
86/// Arguments for the offline policy **`explain`** (dry-run) path (`basil-4vf`).
87///
88/// Loads ONLY the catalog + policy JSON (no sealed bundle, no backend, no socket),
89/// builds the real [`Pdp`](crate::catalog::Pdp), and evaluates a proposed
90/// tuple through the SAME matcher enforcement uses. It NEVER performs the op,
91/// reads a secret, or talks to a backend: safe to run anywhere, before rollout.
92///
93/// What the PDP matches on: authorization binds to a registered **subject**.
94/// The offline tool evaluates that subject name directly; Unix uid/gid
95/// resolution is covered by the loader and runtime actor-resolution tests.
96#[derive(Debug, clap::Args)]
97pub struct ExplainArgs {
98    /// The policy subject to evaluate.
99    #[arg(long)]
100    subject: String,
101
102    /// The op to evaluate (`get`, `list`, `sign`, `set`, `mint`, ...). Required
103    /// unless `--effective` is given.
104    #[arg(long, value_parser = parse_op, required_unless_present = "effective")]
105    op: Option<crate::catalog::Op>,
106
107    /// The catalog key/target to evaluate. Required unless `--effective`.
108    #[arg(long, required_unless_present = "effective")]
109    key: Option<String>,
110
111    /// Preview EVERY `(key, op)` the subject is granted across the whole catalog,
112    /// instead of a single tuple. Ignores `--op`/`--key`.
113    #[arg(long)]
114    effective: bool,
115
116    /// Emit a stable machine-readable JSON object instead of human-readable text.
117    #[arg(long)]
118    json: bool,
119
120    #[command(flatten)]
121    overrides: ConfigOverrides,
122}
123
124/// clap value parser for a policy [`Op`](crate::catalog::Op).
125fn parse_op(s: &str) -> Result<crate::catalog::Op, String> {
126    crate::catalog::Op::parse(s).map_err(|e| e.to_string())
127}
128
129/// Arguments for the preflight **`doctor`** path (`basil-f0j`).
130///
131/// Resolves the same catalog/policy/bundle/socket config `run` uses, then runs a
132/// set of independent read-only diagnostics. By default it NEVER unlocks the
133/// bundle, binds the socket, or mutates anything. `--check-keys` explicitly adds
134/// an authenticated read-only key-material probe that unlocks the bundle but
135/// still never reconciles/generates/mutates. Exits non-zero iff any check FAILs.
136#[derive(Debug, clap::Args)]
137pub struct DoctorArgs {
138    /// Emit a stable machine-readable JSON document instead of human-readable text.
139    #[arg(long)]
140    json: bool,
141
142    /// Unlock the sealed bundle and run the authenticated read-only key-material probe.
143    #[arg(long)]
144    check_keys: bool,
145
146    #[command(flatten)]
147    overrides: ConfigOverrides,
148}
149
150/// Backend-connection defaults applied to every cred that pins none of its own:
151/// the fallback Vault address and transit mount, plus the JWT-auth parameters a
152/// [`BackendCred::SpiffeSigner`] needs to exchange its self-minted SVID for a
153/// short-lived backend token. Threaded from [`SetupArgs`] to each constructor.
154struct BackendDefaults<'a> {
155    /// Fallback Vault address used when a cred pins no `addr`.
156    vault_addr: &'a str,
157    /// Transit secrets-engine mount path.
158    transit_mount: &'a str,
159    /// JWT auth-method mount path (`auth/<mount>/login`).
160    jwt_auth_mount: &'a str,
161    /// Vault jwt role bound to the broker's SPIFFE id (empty ⇒ unset).
162    jwt_role: &'a str,
163    /// Audience the jwt role's `bound_audiences` expects.
164    jwt_audience: &'a str,
165    /// Lifetime of each self-minted JWT-SVID.
166    svid_ttl: Duration,
167    /// db-keystore encryption cipher.
168    #[cfg(feature = "db-keystore")]
169    db_keystore_cipher: &'a str,
170    /// Default `1Password` provider URI when the sealed cred leaves it empty.
171    #[cfg(feature = "keystore-backend")]
172    onepassword_provider_uri: &'a str,
173    /// Default `1Password` project when the sealed cred leaves it empty.
174    #[cfg(feature = "keystore-backend")]
175    onepassword_project: &'a str,
176    /// Default `1Password` profile when the sealed cred leaves it empty.
177    #[cfg(feature = "keystore-backend")]
178    onepassword_profile: &'a str,
179}
180
181/// Build a [`Backend`] from a single decrypted [`BackendCred`].
182///
183/// `VaultToken` is used directly; `VaultAppRole` is exchanged at the bao `AppRole`
184/// login endpoint for a short-lived token first; `SpiffeSigner` boots a
185/// [`SpiffeVaultBackend`] that self-mints a JWT-SVID and exchanges it at the
186/// jwt-auth login endpoint. Other variants are not yet wired to a backend
187/// constructor (logged OFIs) and fail closed.
188async fn backend_from_cred(
189    name: &str,
190    backend_ref: &crate::catalog::BackendRef,
191    cred: &BackendCred,
192    defaults: &BackendDefaults<'_>,
193) -> Result<Box<dyn Backend>> {
194    match (backend_ref.kind, cred) {
195        (BackendKind::Keystore, _) => backend_from_keystore_cred(name, backend_ref, cred, defaults),
196        (BackendKind::AwsKms, cred) => backend_from_aws_kms_cred(name, cred).await,
197        (BackendKind::GcpKms, cred) => backend_from_gcp_kms_cred(name, cred).await,
198        (
199            BackendKind::Vault,
200            BackendCred::DbKeystoreDek { .. }
201            | BackendCred::OnePassword { .. }
202            | BackendCred::AwsKms { .. }
203            | BackendCred::GcpKms { .. },
204        ) => {
205            bail!("backend `{name}`: non-vault credential cannot construct a vault backend")
206        }
207        (BackendKind::Vault, cred) => backend_from_vault_cred(name, cred, defaults).await,
208    }
209}
210
211/// Build an AWS KMS transit backend from an [`BackendCred::AwsKms`] cred.
212#[cfg(feature = "aws-kms")]
213async fn backend_from_aws_kms_cred(name: &str, cred: &BackendCred) -> Result<Box<dyn Backend>> {
214    match cred {
215        BackendCred::AwsKms { region, profile } => {
216            let backend = crate::core::backend::aws_kms::AwsKmsBackend::new(region, profile)
217                .await
218                .with_context(|| format!("building aws-kms backend for `{name}`"))?;
219            Ok(Box::new(backend))
220        }
221        _ => bail!("backend `{name}`: kind `aws-kms` requires an `AwsKms` credential"),
222    }
223}
224
225#[cfg(not(feature = "aws-kms"))]
226#[allow(clippy::unused_async)]
227async fn backend_from_aws_kms_cred(name: &str, _cred: &BackendCred) -> Result<Box<dyn Backend>> {
228    bail!("backend `{name}`: kind `aws-kms` requires the aws-kms feature")
229}
230
231/// Build a GCP Cloud KMS transit backend from a [`BackendCred::GcpKms`] cred.
232#[cfg(feature = "gcp-kms")]
233async fn backend_from_gcp_kms_cred(name: &str, cred: &BackendCred) -> Result<Box<dyn Backend>> {
234    match cred {
235        BackendCred::GcpKms {
236            project,
237            location,
238            key_ring,
239            service_account_json,
240        } => {
241            let backend = crate::core::backend::gcp_kms::GcpKmsBackend::new(
242                project,
243                location,
244                key_ring,
245                service_account_json
246                    .as_ref()
247                    .map(zero_secrets::SecretString::expose_secret),
248            )
249            .await
250            .with_context(|| format!("building gcp-kms backend for `{name}`"))?;
251            Ok(Box::new(backend))
252        }
253        _ => bail!("backend `{name}`: kind `gcp-kms` requires a `GcpKms` credential"),
254    }
255}
256
257#[cfg(not(feature = "gcp-kms"))]
258#[allow(clippy::unused_async)]
259async fn backend_from_gcp_kms_cred(name: &str, _cred: &BackendCred) -> Result<Box<dyn Backend>> {
260    bail!("backend `{name}`: kind `gcp-kms` requires the gcp-kms feature")
261}
262
263async fn backend_from_vault_cred(
264    name: &str,
265    cred: &BackendCred,
266    defaults: &BackendDefaults<'_>,
267) -> Result<Box<dyn Backend>> {
268    match cred {
269        BackendCred::VaultToken { token, addr } => {
270            let addr = addr.as_deref().unwrap_or(defaults.vault_addr);
271            let backend = VaultBackend::new(addr, token.expose_secret(), defaults.transit_mount)
272                .with_context(|| format!("building token backend for `{name}`"))?;
273            Ok(Box::new(backend))
274        }
275        BackendCred::VaultAppRole {
276            role_id,
277            secret_id,
278            addr,
279        } => {
280            let addr = addr.as_deref().unwrap_or(defaults.vault_addr);
281            let token = unlock::approle_login(addr, role_id, secret_id.expose_secret())
282                .await
283                .with_context(|| format!("AppRole login for backend `{name}`"))?;
284            let backend = VaultBackend::new(addr, token.as_str(), defaults.transit_mount)
285                .with_context(|| format!("building approle backend for `{name}`"))?;
286            Ok(Box::new(backend))
287        }
288        BackendCred::SpiffeSigner { key_pem, spiffe_id } => {
289            // The jwt role is a deployment artifact with no safe default; an empty
290            // role would make every login fail at the backend. Fail closed at
291            // startup with an actionable error instead.
292            anyhow::ensure!(
293                !defaults.jwt_role.trim().is_empty(),
294                "backend `{name}`: SpiffeSigner cred requires a jwt role \
295                 (config key `jwt-role`); none set, failing closed"
296            );
297            let cfg = SpiffeConfig {
298                vault_addr: defaults.vault_addr.to_string(),
299                transit_mount: defaults.transit_mount.to_string(),
300                jwt_auth_mount: defaults.jwt_auth_mount.to_string(),
301                role: defaults.jwt_role.to_string(),
302                spiffe_id: spiffe_id.clone(),
303                audience: defaults.jwt_audience.to_string(),
304                svid_ttl: defaults.svid_ttl,
305            };
306            let backend = SpiffeVaultBackend::from_signer(key_pem.expose_secret(), cfg)
307                .with_context(|| format!("building spiffe-signer backend for `{name}`"))?;
308            Ok(Box::new(backend))
309        }
310        BackendCred::DbKeystoreDek { .. }
311        | BackendCred::OnePassword { .. }
312        | BackendCred::AwsKms { .. }
313        | BackendCred::GcpKms { .. } => {
314            bail!("backend `{name}`: non-vault credential cannot construct a vault backend")
315        }
316        BackendCred::Opaque { kind, .. } => bail!(
317            "backend `{name}`: opaque cred of kind `{kind}` has no backend constructor; fail closed"
318        ),
319    }
320}
321
322#[cfg(feature = "keystore-backend")]
323fn backend_from_keystore_cred(
324    name: &str,
325    backend_ref: &crate::catalog::BackendRef,
326    cred: &BackendCred,
327    defaults: &BackendDefaults<'_>,
328) -> Result<Box<dyn Backend>> {
329    #[cfg(not(feature = "db-keystore"))]
330    let _ = backend_ref;
331
332    match cred {
333        #[cfg(feature = "db-keystore")]
334        BackendCred::DbKeystoreDek { dek } => {
335            let backend = crate::KeystoreBackend::open(StoreConfig::DbKeystore {
336                path: PathBuf::from(&backend_ref.addr),
337                cipher: defaults.db_keystore_cipher.to_string(),
338                dek: dek.clone(),
339            })
340            .with_context(|| format!("building db-keystore backend for `{name}`"))?;
341            Ok(Box::new(backend))
342        }
343        #[cfg(not(feature = "db-keystore"))]
344        BackendCred::DbKeystoreDek { .. } => {
345            bail!("backend `{name}`: DbKeystoreDek requires the db-keystore feature")
346        }
347        #[cfg(feature = "onepassword")]
348        BackendCred::OnePassword {
349            provider_uri,
350            project,
351            profile,
352        } => {
353            let provider_uri = default_if_empty(provider_uri, defaults.onepassword_provider_uri);
354            let project = default_if_empty(project, defaults.onepassword_project);
355            let profile = default_if_empty(profile, defaults.onepassword_profile);
356            anyhow::ensure!(
357                !provider_uri.trim().is_empty(),
358                "backend `{name}`: `OnePassword` cred requires provider_uri or config key \
359                 `onepassword-provider-uri`"
360            );
361            let backend = crate::KeystoreBackend::open(StoreConfig::OnePassword {
362                provider_uri: provider_uri.to_string(),
363                project: project.to_string(),
364                profile: profile.to_string(),
365            })
366            .with_context(|| format!("building onepassword backend for `{name}`"))?;
367            Ok(Box::new(backend))
368        }
369        #[cfg(not(feature = "onepassword"))]
370        BackendCred::OnePassword { .. } => {
371            bail!("backend `{name}`: `OnePassword` requires the onepassword feature")
372        }
373        BackendCred::VaultToken { .. }
374        | BackendCred::VaultAppRole { .. }
375        | BackendCred::SpiffeSigner { .. } => {
376            bail!("backend `{name}`: vault credential cannot construct a keystore backend")
377        }
378        BackendCred::AwsKms { .. } | BackendCred::GcpKms { .. } => {
379            bail!("backend `{name}`: KMS credential cannot construct a keystore backend")
380        }
381        BackendCred::Opaque { kind, .. } => bail!(
382            "backend `{name}`: opaque cred of kind `{kind}` has no keystore constructor; fail closed"
383        ),
384    }
385}
386
387#[cfg(not(feature = "keystore-backend"))]
388fn backend_from_keystore_cred(
389    name: &str,
390    _backend_ref: &crate::catalog::BackendRef,
391    _cred: &BackendCred,
392    _defaults: &BackendDefaults<'_>,
393) -> Result<Box<dyn Backend>> {
394    bail!("backend `{name}`: kind `keystore` requires the keystore-backend feature")
395}
396
397#[cfg(feature = "keystore-backend")]
398fn default_if_empty<'a>(value: &'a str, default: &'a str) -> &'a str {
399    if value.trim().is_empty() {
400        default
401    } else {
402        value
403    }
404}
405
406/// Build the manager from the catalog + the decrypted creds. Every catalog
407/// backend name must have a matching cred in the bundle (fail closed otherwise).
408async fn build_manager(
409    defaults: &BackendDefaults<'_>,
410    catalog: crate::Catalog,
411    creds: &CredBundle,
412) -> Result<(BackendManager, String)> {
413    anyhow::ensure!(
414        !catalog.backends.is_empty(),
415        "catalog declares no backends; nothing to route to"
416    );
417
418    let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
419    let mut label: Option<String> = None;
420    for (name, backend_ref) in &catalog.backends {
421        let cred = creds.backends.get(name).with_context(|| {
422            format!("sealed bundle has no credential for catalog backend `{name}`")
423        })?;
424        let backend = backend_from_cred(name, backend_ref, cred, defaults).await?;
425        if label.is_none() {
426            label = Some(backend.kind().to_string());
427        }
428        backends.insert(name.clone(), backend);
429    }
430    let backend_label = label.unwrap_or_else(|| "unknown".to_string());
431
432    let manager = BackendManager::new(catalog, backends)
433        .context("constructing backend manager from catalog")?;
434    Ok((manager, backend_label))
435}
436
437/// The only startup settings that may be supplied outside the config file.
438#[derive(Debug, Clone, clap::Args)]
439pub struct ConfigOverrides {
440    /// Path to the TOML daemon config file.
441    #[arg(short = 'c', long, env = "BASIL_CONFIG")]
442    pub(crate) config: Option<PathBuf>,
443
444    /// Path to the exported **catalog** JSON (the key inventory + routing table).
445    #[arg(long, env = "BASIL_CATALOG")]
446    pub(crate) catalog: Option<PathBuf>,
447
448    /// Path to the exported **policy** JSON (the authorization allow-list).
449    #[arg(long, env = "BASIL_POLICY")]
450    pub(crate) policy: Option<PathBuf>,
451
452    /// Path to the `0600` sealed credential bundle (vault-vh1).
453    #[arg(long, env = "BASIL_BUNDLE")]
454    pub(crate) bundle: Option<PathBuf>,
455
456    /// Unix socket to listen on.
457    #[arg(long, env = "BASIL_SOCKET")]
458    pub(crate) socket: Option<String>,
459
460    /// Default Vault address (used when a cred pins no `addr`).
461    #[arg(long, env = "VAULT_ADDR")]
462    pub(crate) vault_addr: Option<String>,
463}
464
465/// TOML startup config for `run` and `check`.
466///
467/// Top-level keys intentionally mirror the former long flag names, e.g.
468/// `vault-addr`, `max-encrypt-size`, and `capability-policy`.
469#[derive(Debug, Clone, Default, Deserialize)]
470#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
471pub(crate) struct AgentConfigFile {
472    pub(crate) catalog: Option<PathBuf>,
473    pub(crate) policy: Option<PathBuf>,
474    pub(crate) bundle: Option<PathBuf>,
475    pub(crate) socket: Option<String>,
476    pub(crate) socket_mode: Option<SocketMode>,
477    pub(crate) socket_group: Option<String>,
478    pub(crate) vault_addr: Option<String>,
479    pub(crate) transit_mount: Option<String>,
480    pub(crate) jwt_auth_mount: Option<String>,
481    pub(crate) jwt_role: Option<String>,
482    pub(crate) jwt_audience: Option<String>,
483    pub(crate) svid_ttl_secs: Option<u64>,
484    pub(crate) capability_policy: Option<String>,
485    #[cfg(feature = "keystore-backend")]
486    pub(crate) db_keystore_cipher: Option<String>,
487    #[cfg(feature = "keystore-backend")]
488    pub(crate) onepassword_provider_uri: Option<String>,
489    #[cfg(feature = "keystore-backend")]
490    pub(crate) onepassword_project: Option<String>,
491    #[cfg(feature = "keystore-backend")]
492    pub(crate) onepassword_profile: Option<String>,
493    pub(crate) max_encrypt_size: Option<usize>,
494    pub(crate) max_payload_size: Option<usize>,
495    pub(crate) grace_versions: Option<u32>,
496    pub(crate) retain_versions: Option<u32>,
497    pub(crate) retention_sweep_secs: Option<u64>,
498    pub(crate) audit_log: Option<PathBuf>,
499    pub(crate) no_reconcile: Option<bool>,
500    pub(crate) logging: LoggingConfigFile,
501    pub(crate) unlock: UnlockConfigFile,
502    pub(crate) broker_identity: BrokerIdentityConfigFile,
503    pub(crate) invocation: InvocationConfigFile,
504    pub(crate) jwks: JwksConfigFile,
505}
506
507#[derive(Debug, Clone, Deserialize)]
508#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
509pub(crate) struct LoggingConfigFile {
510    stdout: LoggingSinkConfigFile,
511    journald: LoggingSinkConfigFile,
512    opentelemetry: OpenTelemetryLoggingConfigFile,
513}
514
515impl Default for LoggingConfigFile {
516    fn default() -> Self {
517        Self {
518            stdout: LoggingSinkConfigFile { enable: true },
519            journald: LoggingSinkConfigFile { enable: true },
520            opentelemetry: OpenTelemetryLoggingConfigFile::default(),
521        }
522    }
523}
524
525#[derive(Debug, Clone, Deserialize)]
526#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
527struct LoggingSinkConfigFile {
528    enable: bool,
529}
530
531impl Default for LoggingSinkConfigFile {
532    fn default() -> Self {
533        Self { enable: true }
534    }
535}
536
537#[derive(Debug, Clone, Deserialize)]
538#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
539struct OpenTelemetryLoggingConfigFile {
540    enable: bool,
541    endpoint: Option<String>,
542    protocol: OpenTelemetryProtocol,
543}
544
545impl Default for OpenTelemetryLoggingConfigFile {
546    fn default() -> Self {
547        Self {
548            enable: false,
549            endpoint: None,
550            protocol: OpenTelemetryProtocol::Grpc,
551        }
552    }
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
556#[serde(rename_all = "kebab-case")]
557enum OpenTelemetryProtocol {
558    Grpc,
559    HttpBinary,
560    HttpJson,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub(crate) struct SocketMode(pub(crate) u32);
565
566impl SocketMode {
567    const MAX: u32 = 0o7777;
568}
569
570impl<'de> Deserialize<'de> for SocketMode {
571    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
572    where
573        D: serde::Deserializer<'de>,
574    {
575        deserializer.deserialize_any(SocketModeVisitor)
576    }
577}
578
579struct SocketModeVisitor;
580
581impl Visitor<'_> for SocketModeVisitor {
582    type Value = SocketMode;
583
584    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        formatter.write_str("an octal socket mode string like \"0660\" or integer mode")
586    }
587
588    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
589    where
590        E: de::Error,
591    {
592        let mode = u32::try_from(value).map_err(|_| E::custom("socket mode is too large"))?;
593        validate_socket_mode(mode)
594            .map(SocketMode)
595            .map_err(E::custom)
596    }
597
598    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
599    where
600        E: de::Error,
601    {
602        let mode = u32::try_from(value).map_err(|_| E::custom("socket mode must be positive"))?;
603        validate_socket_mode(mode)
604            .map(SocketMode)
605            .map_err(E::custom)
606    }
607
608    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
609    where
610        E: de::Error,
611    {
612        parse_socket_mode(value).map(SocketMode).map_err(E::custom)
613    }
614}
615
616fn parse_socket_mode(value: &str) -> Result<u32, String> {
617    let trimmed = value.trim();
618    let octal = trimmed.strip_prefix("0o").unwrap_or(trimmed);
619    if octal.is_empty() {
620        return Err("socket mode must not be empty".to_string());
621    }
622    if !octal.chars().all(|c| matches!(c, '0'..='7')) {
623        return Err(format!("socket mode `{value}` must be octal"));
624    }
625    let mode = u32::from_str_radix(octal, 8)
626        .map_err(|err| format!("socket mode `{value}` is invalid: {err}"))?;
627    validate_socket_mode(mode)
628}
629
630fn validate_socket_mode(mode: u32) -> Result<u32, String> {
631    if mode > SocketMode::MAX {
632        return Err(format!("socket mode {mode:o} exceeds 07777"));
633    }
634    Ok(mode)
635}
636
637#[derive(Debug, Clone, Default, Deserialize)]
638#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
639pub(crate) struct UnlockConfigFile {
640    age_yubikey: Option<bool>,
641    bip39_phrase_file: Option<PathBuf>,
642    unlock_tpm: Option<bool>,
643    unlock_passphrase_file: Option<PathBuf>,
644    unlock_passphrase_no_wipe: Option<bool>,
645    strict_bundle_perms: Option<bool>,
646}
647
648/// The `[broker-identity]` config section for response protection.
649#[derive(Debug, Clone, Default, Deserialize)]
650#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
651pub(crate) struct BrokerIdentityConfigFile {
652    /// Stable broker identity / audience URI.
653    id: Option<String>,
654    /// Catalog key id used to sign invocation responses.
655    response_signing_key_id: Option<String>,
656}
657
658/// The `[invocation]` config section for the sealed invocation gRPC service.
659///
660/// `enable` defaults to `false`: the service is registered so clients get a
661/// stable method, but it rejects all requests unless an operator explicitly
662/// enables bridged sealed invocations.
663#[derive(Debug, Clone, Deserialize)]
664#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
665pub(crate) struct InvocationConfigFile {
666    /// Accept sealed invocation requests. Default `false`.
667    enable: bool,
668    /// Accepted broker audiences for sealed invocations.
669    audience: Vec<String>,
670    /// Catalog key id whose public half receives sealed invocation requests.
671    request_encryption_key_id: Option<String>,
672    /// Maximum accepted signed request TTL in seconds.
673    max_ttl_secs: u32,
674    /// Allowed clock skew in seconds.
675    clock_skew_secs: u32,
676    /// Maximum in-memory replay-cache entries.
677    replay_cache_capacity: usize,
678}
679
680impl Default for InvocationConfigFile {
681    fn default() -> Self {
682        Self {
683            enable: false,
684            audience: Vec::new(),
685            request_encryption_key_id: None,
686            max_ttl_secs: basil_proto::invocation::DEFAULT_EXPIRES_AFTER_SECS,
687            clock_skew_secs: 30,
688            replay_cache_capacity: 4096,
689        }
690    }
691}
692
693/// The `[jwks]` config section: the **opt-in** JWKS HTTP surface (`basil-uce.1`).
694///
695/// `enable` **defaults to `false`**: the HTTP port is NOT opened unless an
696/// operator explicitly turns it on. This is the broker's first HTTP endpoint in
697/// an otherwise gRPC-over-unix-socket system, so it is strictly opt-in. When
698/// enabled, a standard verifier can fetch the issuer JWK set (public keys only)
699/// and validate Basil-minted JWT-SVID signatures without SPIFFE plumbing.
700#[derive(Debug, Clone, Deserialize)]
701#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
702pub(crate) struct JwksConfigFile {
703    /// Open the JWKS HTTP listener. Default `false` (no port opened).
704    enable: bool,
705    /// Address to bind when `enable` is true. Default `127.0.0.1:8201`.
706    listen: String,
707    /// Public base URL the surface is reachable at (no trailing slash), used as
708    /// the OIDC discovery `issuer` and the base for `jwks_uri`. When unset, the
709    /// `/.well-known/openid-configuration` discovery document is **not** served
710    /// (the bare JWKS endpoints still are). Example: `https://basil.example.com`.
711    issuer: Option<String>,
712    /// Optional native TLS listener settings.
713    tls: JwksTlsConfigFile,
714}
715
716#[derive(Debug, Clone, Default, Deserialize)]
717#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
718pub(crate) struct JwksTlsConfigFile {
719    /// Serve the JWKS surface over TLS. Requires the `http-tls` cargo feature.
720    enable: bool,
721    /// PEM certificate chain file for the TLS listener.
722    cert_file: Option<PathBuf>,
723    /// PEM private-key file for the TLS listener.
724    key_file: Option<PathBuf>,
725}
726
727impl Default for JwksConfigFile {
728    fn default() -> Self {
729        Self {
730            enable: false,
731            listen: "127.0.0.1:8201".to_string(),
732            issuer: None,
733            tls: JwksTlsConfigFile::default(),
734        }
735    }
736}
737
738/// Resolved JWKS HTTP-surface settings threaded into [`RunConfig`].
739#[cfg(feature = "http")]
740#[derive(Debug, Clone)]
741struct JwksConfig {
742    enable: bool,
743    listen: std::net::SocketAddr,
744    issuer: Option<String>,
745    tls: Option<crate::jwks::JwksTlsConfig>,
746}
747
748/// The setup inputs shared by every subcommand that needs a live
749/// [`BackendManager`].
750#[derive(Debug, Clone)]
751struct SetupArgs {
752    catalog: PathBuf,
753    policy: PathBuf,
754    bundle: PathBuf,
755    vault_addr: String,
756    transit_mount: String,
757    jwt_auth_mount: String,
758    jwt_role: String,
759    jwt_audience: String,
760    svid_ttl_secs: u64,
761    capability_policy: CapabilityPolicy,
762    #[cfg(feature = "db-keystore")]
763    db_keystore_cipher: String,
764    #[cfg(feature = "keystore-backend")]
765    onepassword_provider_uri: String,
766    #[cfg(feature = "keystore-backend")]
767    onepassword_project: String,
768    #[cfg(feature = "keystore-backend")]
769    onepassword_profile: String,
770    unlock: unlock::UnlockArgs,
771}
772
773#[derive(Debug, Clone)]
774struct RunConfig {
775    socket: Option<String>,
776    socket_mode: u32,
777    socket_group: Option<String>,
778    max_encrypt_size: usize,
779    max_payload_size: usize,
780    grace_versions: u32,
781    retain_versions: Option<u32>,
782    retention_sweep_secs: u64,
783    audit_log: Option<PathBuf>,
784    no_reconcile: bool,
785    invocation: InvocationRuntimeConfig,
786    #[cfg(feature = "http")]
787    jwks: JwksConfig,
788    setup: SetupArgs,
789}
790
791#[derive(Debug, Clone)]
792struct CheckConfig {
793    invocation: InvocationRuntimeConfig,
794    setup: SetupArgs,
795}
796
797/// clap value parser for [`CapabilityPolicy`] (`strict` | `degraded` | `off`).
798fn parse_capability_policy(s: &str) -> Result<CapabilityPolicy, String> {
799    s.parse()
800}
801
802fn load_run_config(overrides: &ConfigOverrides) -> Result<RunConfig> {
803    let file = load_config_file(overrides)?;
804    let setup = build_setup(&file, overrides)?;
805    #[cfg(feature = "http")]
806    let jwks = resolve_jwks_config(&file.jwks)?;
807    #[cfg(not(feature = "http"))]
808    reject_jwks_config(&file.jwks)?;
809    Ok(RunConfig {
810        socket: overrides.socket.clone().or(file.socket),
811        socket_mode: file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0),
812        socket_group: file.socket_group,
813        max_encrypt_size: file.max_encrypt_size.unwrap_or(DEFAULT_MAX_ENCRYPT_SIZE),
814        max_payload_size: file.max_payload_size.unwrap_or(DEFAULT_MAX_PAYLOAD_SIZE),
815        grace_versions: file
816            .grace_versions
817            .unwrap_or(DEFAULT_ROTATION_GRACE_VERSIONS),
818        retain_versions: file.retain_versions,
819        retention_sweep_secs: file.retention_sweep_secs.unwrap_or(3600),
820        audit_log: file.audit_log,
821        no_reconcile: file.no_reconcile.unwrap_or(false),
822        invocation: resolve_invocation_config(&file.broker_identity, &file.invocation)?,
823        #[cfg(feature = "http")]
824        jwks,
825        setup,
826    })
827}
828
829fn resolve_invocation_config(
830    identity_file: &BrokerIdentityConfigFile,
831    file: &InvocationConfigFile,
832) -> Result<InvocationRuntimeConfig> {
833    if file.max_ttl_secs == 0 {
834        bail!("invocation.max-ttl-secs must be greater than zero");
835    }
836    if file.max_ttl_secs > MAX_INVOCATION_TTL_SECS {
837        bail!("invocation.max-ttl-secs must be at most {MAX_INVOCATION_TTL_SECS} seconds");
838    }
839    if file.clock_skew_secs > MAX_INVOCATION_CLOCK_SKEW_SECS {
840        bail!(
841            "invocation.clock-skew-secs must be at most {MAX_INVOCATION_CLOCK_SKEW_SECS} seconds"
842        );
843    }
844    if file.replay_cache_capacity == 0 {
845        bail!("invocation.replay-cache-capacity must be greater than zero");
846    }
847    if file.replay_cache_capacity > MAX_INVOCATION_REPLAY_CACHE_CAPACITY {
848        bail!(
849            "invocation.replay-cache-capacity must be at most {MAX_INVOCATION_REPLAY_CACHE_CAPACITY}"
850        );
851    }
852    let broker_identity = resolve_broker_identity_config(identity_file)?;
853    let request_encryption_key_id = optional_nonempty_config(
854        "invocation.request-encryption-key-id",
855        file.request_encryption_key_id.as_ref(),
856    )?;
857    let mut audiences = Vec::with_capacity(file.audience.len());
858    for audience in &file.audience {
859        let trimmed = audience.trim();
860        if trimmed.is_empty() {
861            bail!("invocation.audience must not contain blank values");
862        }
863        validate_basil_identity_uri("invocation.audience", trimmed)?;
864        audiences.push(trimmed.to_string());
865    }
866    audiences.sort();
867    audiences.dedup();
868    if file.enable && audiences.is_empty() {
869        bail!("invocation.audience must be set when invocation.enable is true");
870    }
871    if file.enable && broker_identity.is_none() {
872        bail!(
873            "broker-identity.id and broker-identity.response-signing-key-id are required when invocation.enable is true"
874        );
875    }
876    if file.enable && request_encryption_key_id.is_none() {
877        bail!("invocation.request-encryption-key-id is required when invocation.enable is true");
878    }
879    Ok(InvocationRuntimeConfig {
880        enabled: file.enable,
881        broker_identity,
882        audiences,
883        request_encryption_key_id,
884        max_ttl_secs: file.max_ttl_secs,
885        clock_skew_secs: file.clock_skew_secs,
886        replay_cache_capacity: file.replay_cache_capacity,
887        now_unix_override: None,
888    })
889}
890
891fn resolve_broker_identity_config(
892    file: &BrokerIdentityConfigFile,
893) -> Result<Option<BrokerIdentityRuntimeConfig>> {
894    let id = optional_nonempty_config("broker-identity.id", file.id.as_ref())?;
895    let response_signing_key_id = optional_nonempty_config(
896        "broker-identity.response-signing-key-id",
897        file.response_signing_key_id.as_ref(),
898    )?;
899    match (id, response_signing_key_id) {
900        (None, None) => Ok(None),
901        (Some(id), Some(response_signing_key_id)) => {
902            validate_basil_identity_uri("broker-identity.id", &id)?;
903            Ok(Some(BrokerIdentityRuntimeConfig {
904                id,
905                response_signing_key_id,
906            }))
907        }
908        (None, Some(_)) | (Some(_), None) => {
909            bail!(
910                "broker-identity.id and broker-identity.response-signing-key-id must be set together"
911            )
912        }
913    }
914}
915
916fn optional_nonempty_config(field: &str, value: Option<&String>) -> Result<Option<String>> {
917    let Some(raw) = value else {
918        return Ok(None);
919    };
920    let trimmed = raw.trim();
921    if trimmed.is_empty() {
922        bail!("{field} must not be empty");
923    }
924    Ok(Some(trimmed.to_string()))
925}
926
927fn validate_basil_identity_uri(field: &str, value: &str) -> Result<()> {
928    let parsed =
929        reqwest::Url::parse(value).with_context(|| format!("parsing {field} `{value}`"))?;
930    if parsed.scheme() != "basil" {
931        bail!("{field} must use the basil:// scheme");
932    }
933    if parsed.host_str().is_none_or(str::is_empty) {
934        bail!("{field} must include a host component");
935    }
936    if !parsed.username().is_empty() || parsed.password().is_some() {
937        bail!("{field} must not include userinfo");
938    }
939    if parsed.query().is_some() || parsed.fragment().is_some() {
940        bail!("{field} must not include query or fragment components");
941    }
942    if parsed.path() == "/" || parsed.path().trim_matches('/').is_empty() {
943        bail!("{field} must include a non-empty path component");
944    }
945    Ok(())
946}
947
948fn validate_invocation_catalog_bindings(
949    invocation: &InvocationRuntimeConfig,
950    catalog: &crate::Catalog,
951) -> Result<()> {
952    if !invocation.enabled {
953        return Ok(());
954    }
955    let identity = invocation
956        .broker_identity
957        .as_ref()
958        .context("broker identity is required when invocation is enabled")?;
959    validate_response_signing_key(catalog, &identity.response_signing_key_id)?;
960    let request_encryption_key_id = invocation
961        .request_encryption_key_id
962        .as_deref()
963        .context("request encryption key is required when invocation is enabled")?;
964    validate_request_encryption_key(catalog, request_encryption_key_id)
965}
966
967fn catalog_key<'a>(
968    catalog: &'a crate::Catalog,
969    key_id: &str,
970    field: &'static str,
971) -> Result<&'a crate::catalog::KeyEntry> {
972    catalog
973        .keys
974        .get(key_id)
975        .with_context(|| format!("{field} references unknown catalog key `{key_id}`"))
976}
977
978fn validate_response_signing_key(catalog: &crate::Catalog, key_id: &str) -> Result<()> {
979    let key = catalog_key(catalog, key_id, "broker-identity.response-signing-key-id")?;
980    if key.class != Class::Asymmetric {
981        bail!("broker response-signing key `{key_id}` must be class `asymmetric`");
982    }
983    let Some(algorithm) = key.key_type else {
984        bail!("broker response-signing key `{key_id}` must declare keyType");
985    };
986    if !is_broker_response_signing_algorithm(algorithm) {
987        bail!(
988            "broker response-signing key `{key_id}` has unsupported keyType `{}`",
989            algorithm.token()
990        );
991    }
992    validate_broker_key_use(
993        key_id,
994        key,
995        BROKER_RESPONSE_SIGNING_USE,
996        "broker response-signing key",
997    )
998}
999
1000fn validate_request_encryption_key(catalog: &crate::Catalog, key_id: &str) -> Result<()> {
1001    let key = catalog_key(catalog, key_id, "invocation.request-encryption-key-id")?;
1002    if key.class != Class::Sealing {
1003        bail!("broker request-encryption key `{key_id}` must be class `sealing`");
1004    }
1005    validate_broker_key_use(
1006        key_id,
1007        key,
1008        BROKER_REQUEST_ENCRYPTION_USE,
1009        "broker request-encryption key",
1010    )
1011}
1012
1013fn validate_broker_key_use(
1014    key_id: &str,
1015    key: &crate::catalog::KeyEntry,
1016    expected: &str,
1017    role: &str,
1018) -> Result<()> {
1019    match key.labels.get(BROKER_KEY_USE_LABEL) {
1020        Some(actual) if actual == expected => Ok(()),
1021        Some(actual) => bail!(
1022            "{role} `{key_id}` must carry label `{BROKER_KEY_USE_LABEL}={expected}`, got `{actual}`"
1023        ),
1024        None => bail!("{role} `{key_id}` must carry label `{BROKER_KEY_USE_LABEL}={expected}`"),
1025    }
1026}
1027
1028const fn is_broker_response_signing_algorithm(algorithm: KeyAlgorithm) -> bool {
1029    matches!(
1030        algorithm,
1031        KeyAlgorithm::Ed25519
1032            | KeyAlgorithm::Rsa2048
1033            | KeyAlgorithm::EcdsaP256
1034            | KeyAlgorithm::MlDsa44
1035            | KeyAlgorithm::MlDsa65
1036            | KeyAlgorithm::MlDsa87
1037    )
1038}
1039
1040/// Resolve the `[jwks]` section, parsing `listen` to a [`SocketAddr`] so a
1041/// malformed address fails closed at startup rather than at bind time. The
1042/// `listen` value is parsed even when `enable` is false (config validity is
1043/// independent of whether the listener will be opened).
1044#[cfg(feature = "http")]
1045fn resolve_jwks_config(file: &JwksConfigFile) -> Result<JwksConfig> {
1046    let listen = file
1047        .listen
1048        .parse::<std::net::SocketAddr>()
1049        .with_context(|| format!("parsing jwks.listen `{}`", file.listen))?;
1050    // Normalize the issuer base URL: must be absolute http(s), trailing slash
1051    // stripped so `issuer + path` is well-formed. A malformed value fails closed
1052    // at startup rather than serving an inconsistent discovery document.
1053    let issuer = match file.issuer.as_deref().map(str::trim) {
1054        Some(s) if !s.is_empty() => Some(
1055            parse_public_http_url("jwks.issuer", s)?
1056                .to_string()
1057                .trim_end_matches('/')
1058                .to_string(),
1059        ),
1060        _ => None,
1061    };
1062    Ok(JwksConfig {
1063        enable: file.enable,
1064        listen,
1065        issuer,
1066        tls: resolve_jwks_tls_config(&file.tls)?,
1067    })
1068}
1069
1070#[cfg(not(feature = "http"))]
1071fn reject_jwks_config(file: &JwksConfigFile) -> Result<()> {
1072    if file.enable {
1073        bail!("jwks.enable requires the http cargo feature");
1074    }
1075    if file.tls.enable {
1076        bail!("jwks.tls.enable requires the http cargo feature");
1077    }
1078    Ok(())
1079}
1080
1081#[cfg(feature = "http")]
1082fn resolve_jwks_tls_config(file: &JwksTlsConfigFile) -> Result<Option<crate::jwks::JwksTlsConfig>> {
1083    if !file.enable {
1084        return Ok(None);
1085    }
1086    #[cfg(not(feature = "http-tls"))]
1087    {
1088        anyhow::bail!("jwks.tls.enable requires the http-tls cargo feature");
1089    }
1090    #[cfg(feature = "http-tls")]
1091    {
1092        let cert_file = file
1093            .cert_file
1094            .clone()
1095            .context("jwks.tls.cert-file is required when jwks.tls.enable = true")?;
1096        let key_file = file
1097            .key_file
1098            .clone()
1099            .context("jwks.tls.key-file is required when jwks.tls.enable = true")?;
1100        Ok(Some(crate::jwks::JwksTlsConfig {
1101            cert_file,
1102            key_file,
1103        }))
1104    }
1105}
1106
1107#[cfg(any(feature = "http", feature = "otlp"))]
1108fn parse_public_http_url(field: &str, value: &str) -> Result<reqwest::Url> {
1109    let parsed =
1110        reqwest::Url::parse(value).with_context(|| format!("parsing {field} `{value}`"))?;
1111    match parsed.scheme() {
1112        "http" | "https" => {}
1113        scheme => bail!("{field} must use http or https, got `{scheme}`"),
1114    }
1115    if !parsed.username().is_empty() || parsed.password().is_some() {
1116        bail!("{field} must not include userinfo");
1117    }
1118    if parsed.fragment().is_some() {
1119        bail!("{field} must not include a fragment");
1120    }
1121    reject_metadata_host(field, &parsed)?;
1122    Ok(parsed)
1123}
1124
1125#[cfg(any(feature = "http", feature = "otlp"))]
1126fn reject_metadata_host(field: &str, parsed: &reqwest::Url) -> Result<()> {
1127    let Some(host) = parsed.host_str() else {
1128        return Ok(());
1129    };
1130    if host.eq_ignore_ascii_case("metadata.google.internal") {
1131        bail!("{field} must not target cloud instance metadata hosts");
1132    }
1133    if let Ok(ip) = host.parse::<std::net::IpAddr>()
1134        && is_metadata_ip(ip)
1135    {
1136        bail!("{field} must not target link-local or cloud instance metadata addresses");
1137    }
1138    Ok(())
1139}
1140
1141#[cfg(any(feature = "http", feature = "otlp"))]
1142fn is_metadata_ip(ip: std::net::IpAddr) -> bool {
1143    match ip {
1144        std::net::IpAddr::V4(addr) => addr.is_link_local() || addr.octets() == [100, 100, 100, 200],
1145        std::net::IpAddr::V6(addr) => addr.is_unicast_link_local(),
1146    }
1147}
1148
1149fn load_check_config(overrides: &ConfigOverrides) -> Result<CheckConfig> {
1150    let file = load_config_file(overrides)?;
1151    let invocation = resolve_invocation_config(&file.broker_identity, &file.invocation)?;
1152    let mut setup = build_setup(&file, overrides)?;
1153    // `config check --require` and doctor key probing are preflight reads. They
1154    // must not consume a passphrase file that the subsequent agent start needs.
1155    setup.unlock.passphrase_no_wipe = true;
1156    Ok(CheckConfig { invocation, setup })
1157}
1158
1159pub(crate) fn load_config_file(overrides: &ConfigOverrides) -> Result<AgentConfigFile> {
1160    let Some(path) = &overrides.config else {
1161        return Ok(AgentConfigFile::default());
1162    };
1163    let raw = std::fs::read_to_string(path)
1164        .with_context(|| format!("reading config from {}", path.display()))?;
1165    toml::from_str(&raw).with_context(|| format!("parsing config from {}", path.display()))
1166}
1167
1168struct LoggingGuards {
1169    #[cfg(feature = "otlp")]
1170    otel_provider: Option<SdkLoggerProvider>,
1171}
1172
1173#[cfg(feature = "otlp")]
1174type OtelTracingLayer =
1175    OpenTelemetryTracingBridge<SdkLoggerProvider, opentelemetry_sdk::logs::SdkLogger>;
1176#[cfg(feature = "otlp")]
1177type OptionalOtelLayer = (Option<OtelTracingLayer>, Option<SdkLoggerProvider>);
1178
1179#[cfg(feature = "otlp")]
1180impl LoggingGuards {
1181    fn shutdown(self) {
1182        if let Some(provider) = self.otel_provider
1183            && let Err(err) = provider.shutdown()
1184        {
1185            warn!(error = %err, "failed to shut down OpenTelemetry logger provider");
1186        }
1187    }
1188}
1189
1190#[cfg(not(feature = "otlp"))]
1191impl LoggingGuards {
1192    const fn shutdown(self) {
1193        let Self {} = self;
1194    }
1195}
1196
1197/// Resolution of the journald log sink.
1198///
1199/// Journald is the right default for the systemd-managed daemon, but a journal
1200/// socket is absent on minimal hosts (containers, initramfs, CI) where the
1201/// offline `basil config ...` commands run. An unavailable socket must be a
1202/// non-fatal fall back to stderr, never an abort on logging init.
1203enum JournaldSink {
1204    /// Journald disabled in config; no journald sink.
1205    Disabled,
1206    /// Journald socket opened; emit through this layer.
1207    Active(tracing_journald::Layer),
1208    /// Journald requested but the socket is unavailable. The caller must log to
1209    /// stderr instead; the message carries the underlying error for one warning.
1210    FellBackToStderr(String),
1211}
1212
1213fn journald_sink(enabled: bool) -> JournaldSink {
1214    if !enabled {
1215        return JournaldSink::Disabled;
1216    }
1217    match tracing_journald::layer() {
1218        Ok(layer) => JournaldSink::Active(layer),
1219        Err(err) => JournaldSink::FellBackToStderr(err.to_string()),
1220    }
1221}
1222
1223fn init_logging(config: &LoggingConfigFile) -> Result<LoggingGuards> {
1224    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
1225    let stdout_layer = config.stdout.enable.then(fmt::layer);
1226
1227    let (journald_layer, stderr_fallback) = match journald_sink(config.journald.enable) {
1228        JournaldSink::Disabled => (None, None),
1229        JournaldSink::Active(layer) => (Some(layer), None),
1230        JournaldSink::FellBackToStderr(err) => (None, Some(err)),
1231    };
1232    let stderr_layer = stderr_fallback
1233        .is_some()
1234        .then(|| fmt::layer().with_writer(std::io::stderr));
1235
1236    #[cfg(feature = "otlp")]
1237    let (otel_layer, otel_provider) = build_otel_layer(&config.opentelemetry)?;
1238    #[cfg(not(feature = "otlp"))]
1239    ensure_otel_disabled(&config.opentelemetry)?;
1240
1241    let subscriber = tracing_subscriber::registry()
1242        .with(env_filter)
1243        .with(stdout_layer)
1244        .with(journald_layer)
1245        .with(stderr_layer);
1246    #[cfg(feature = "otlp")]
1247    let subscriber = subscriber.with(otel_layer);
1248    subscriber
1249        .try_init()
1250        .context("initializing tracing subscriber")?;
1251
1252    if let Some(err) = stderr_fallback {
1253        warn!(error = %err, "journald log sink unavailable; falling back to stderr");
1254    }
1255
1256    Ok(LoggingGuards {
1257        #[cfg(feature = "otlp")]
1258        otel_provider,
1259    })
1260}
1261
1262#[cfg(not(feature = "otlp"))]
1263fn ensure_otel_disabled(config: &OpenTelemetryLoggingConfigFile) -> Result<()> {
1264    if config.enable {
1265        bail!(
1266            "logging.opentelemetry.enable=true but this basil binary was built without the `otlp` feature"
1267        );
1268    }
1269    Ok(())
1270}
1271
1272#[cfg(feature = "otlp")]
1273fn build_otel_layer(config: &OpenTelemetryLoggingConfigFile) -> Result<OptionalOtelLayer> {
1274    if !config.enable {
1275        return Ok((None, None));
1276    }
1277    let endpoint = required_otel_endpoint(config)?;
1278    let exporter = match config.protocol {
1279        OpenTelemetryProtocol::Grpc => opentelemetry_otlp::LogExporter::builder()
1280            .with_tonic()
1281            .with_endpoint(endpoint)
1282            .build(),
1283        OpenTelemetryProtocol::HttpBinary => opentelemetry_otlp::LogExporter::builder()
1284            .with_http()
1285            .with_protocol(Protocol::HttpBinary)
1286            .with_endpoint(endpoint)
1287            .build(),
1288        OpenTelemetryProtocol::HttpJson => opentelemetry_otlp::LogExporter::builder()
1289            .with_http()
1290            .with_protocol(Protocol::HttpJson)
1291            .with_endpoint(endpoint)
1292            .build(),
1293    }
1294    .context("building OpenTelemetry log exporter")?;
1295    let provider = SdkLoggerProvider::builder()
1296        .with_batch_exporter(exporter)
1297        .build();
1298    let layer = OpenTelemetryTracingBridge::new(&provider);
1299    Ok((Some(layer), Some(provider)))
1300}
1301
1302#[cfg(feature = "otlp")]
1303fn required_otel_endpoint(config: &OpenTelemetryLoggingConfigFile) -> Result<String> {
1304    let endpoint = config
1305        .endpoint
1306        .as_deref()
1307        .map(str::trim)
1308        .filter(|value| !value.is_empty())
1309        .context(
1310            "logging.opentelemetry.endpoint is required when OpenTelemetry logging is enabled",
1311        )?;
1312    Ok(parse_public_http_url("logging.opentelemetry.endpoint", endpoint)?.to_string())
1313}
1314
1315fn build_setup(file: &AgentConfigFile, overrides: &ConfigOverrides) -> Result<SetupArgs> {
1316    let catalog = required_path(
1317        overrides.catalog.clone().or_else(|| file.catalog.clone()),
1318        "catalog",
1319    )?;
1320    let policy = required_path(
1321        overrides.policy.clone().or_else(|| file.policy.clone()),
1322        "policy",
1323    )?;
1324    let bundle = required_path(
1325        overrides.bundle.clone().or_else(|| file.bundle.clone()),
1326        "bundle",
1327    )?;
1328    let vault_addr = overrides
1329        .vault_addr
1330        .clone()
1331        .or_else(|| file.vault_addr.clone())
1332        .unwrap_or_else(|| "http://127.0.0.1:8200".to_string());
1333    let capability_policy = file
1334        .capability_policy
1335        .as_deref()
1336        .map_or(Ok(CapabilityPolicy::Strict), parse_capability_policy)
1337        .map_err(|err| anyhow::anyhow!("parsing config key `capability-policy`: {err}"))?;
1338
1339    Ok(SetupArgs {
1340        catalog,
1341        policy,
1342        bundle,
1343        vault_addr,
1344        transit_mount: file
1345            .transit_mount
1346            .clone()
1347            .unwrap_or_else(|| "transit".to_string()),
1348        jwt_auth_mount: file
1349            .jwt_auth_mount
1350            .clone()
1351            .unwrap_or_else(|| "jwt".to_string()),
1352        jwt_role: file.jwt_role.clone().unwrap_or_default(),
1353        jwt_audience: file
1354            .jwt_audience
1355            .clone()
1356            .unwrap_or_else(|| "openbao".to_string()),
1357        svid_ttl_secs: file.svid_ttl_secs.unwrap_or(DEFAULT_SVID_TTL_SECS),
1358        capability_policy,
1359        #[cfg(feature = "db-keystore")]
1360        db_keystore_cipher: file
1361            .db_keystore_cipher
1362            .clone()
1363            .unwrap_or_else(|| "aegis256".to_string()),
1364        #[cfg(feature = "keystore-backend")]
1365        onepassword_provider_uri: file.onepassword_provider_uri.clone().unwrap_or_default(),
1366        #[cfg(feature = "keystore-backend")]
1367        onepassword_project: file.onepassword_project.clone().unwrap_or_default(),
1368        #[cfg(feature = "keystore-backend")]
1369        onepassword_profile: file.onepassword_profile.clone().unwrap_or_default(),
1370        unlock: unlock::UnlockArgs {
1371            age_yubikey: file.unlock.age_yubikey.unwrap_or(false),
1372            bip39_phrase_file: file.unlock.bip39_phrase_file.clone(),
1373            tpm: file.unlock.unlock_tpm.unwrap_or(false),
1374            passphrase_file: file.unlock.unlock_passphrase_file.clone(),
1375            passphrase_no_wipe: file.unlock.unlock_passphrase_no_wipe.unwrap_or(false),
1376            strict_bundle_perms: file.unlock.strict_bundle_perms.unwrap_or(false),
1377        },
1378    })
1379}
1380
1381fn required_path(path: Option<PathBuf>, name: &str) -> Result<PathBuf> {
1382    path.with_context(|| {
1383        format!(
1384            "`{name}` is required; set it in the config file or pass --{name} / the BASIL_{} env var",
1385            name.to_ascii_uppercase()
1386        )
1387    })
1388}
1389
1390/// The loaded, validated catalog/policy plus a live [`BackendManager`] over the
1391/// unlocked creds: the common ground both `run` (serve) and `check` (lint)
1392/// stand on.
1393struct Prepared {
1394    catalog: Arc<crate::Catalog>,
1395    policy: crate::ResolvedPolicy,
1396    config: crate::Config,
1397    manager: BackendManager,
1398    backend_label: String,
1399}
1400
1401/// Load + validate the exported catalog/policy, unlock the sealed bundle, and
1402/// build the routed [`BackendManager`] over the decrypted creds. The shared
1403/// startup pipeline for `run` and `check`; fails closed (clean non-zero, no
1404/// panic) at every step. The plaintext [`CredBundle`] is zeroized before return;
1405/// each backend holds only its own cred.
1406async fn prepare_manager(setup: &SetupArgs) -> Result<Prepared> {
1407    if setup.vault_addr.starts_with("http://") && !setup.vault_addr.contains("127.0.0.1") {
1408        warn!(addr = %setup.vault_addr, "talking to vault over plaintext HTTP");
1409    }
1410
1411    // Load + validate the exported catalog & policy.
1412    let catalog_json = std::fs::read_to_string(&setup.catalog)
1413        .with_context(|| format!("reading catalog from {}", setup.catalog.display()))?;
1414    let policy_json = std::fs::read_to_string(&setup.policy)
1415        .with_context(|| format!("reading policy from {}", setup.policy.display()))?;
1416    let (catalog, policy, config, warnings) =
1417        load(&catalog_json, &policy_json).context("loading catalog/policy")?;
1418    for w in &warnings {
1419        warn!(warning = %w, "catalog/policy load warning");
1420    }
1421    info!(
1422        keys = catalog.keys.len(),
1423        backends = catalog.backends.len(),
1424        "loaded catalog + policy",
1425    );
1426
1427    // Unlock the sealed bundle -> CredBundle (KEK zeroized inside `open`).
1428    let creds = unlock::open_bundle_at_startup(&setup.bundle, &setup.unlock)
1429        .context("unlocking sealed credential bundle")?;
1430    info!(
1431        backend_creds = creds.backends.len(),
1432        "sealed bundle unlocked",
1433    );
1434
1435    // Build the manager from catalog + creds, then drop (zeroize) the CredBundle.
1436    let defaults = BackendDefaults {
1437        vault_addr: &setup.vault_addr,
1438        transit_mount: &setup.transit_mount,
1439        jwt_auth_mount: &setup.jwt_auth_mount,
1440        jwt_role: &setup.jwt_role,
1441        jwt_audience: &setup.jwt_audience,
1442        svid_ttl: Duration::from_secs(setup.svid_ttl_secs),
1443        #[cfg(feature = "db-keystore")]
1444        db_keystore_cipher: &setup.db_keystore_cipher,
1445        #[cfg(feature = "keystore-backend")]
1446        onepassword_provider_uri: &setup.onepassword_provider_uri,
1447        #[cfg(feature = "keystore-backend")]
1448        onepassword_project: &setup.onepassword_project,
1449        #[cfg(feature = "keystore-backend")]
1450        onepassword_profile: &setup.onepassword_profile,
1451    };
1452    let (manager, backend_label) = build_manager(&defaults, catalog, &creds).await?;
1453    let catalog = manager.catalog();
1454    drop(creds); // ZEROIZE the CredBundle: every backend now holds its own cred.
1455
1456    Ok(Prepared {
1457        catalog,
1458        policy,
1459        config,
1460        manager,
1461        backend_label,
1462    })
1463}
1464
1465fn enforce_startup_capabilities(catalog: &crate::Catalog, policy: CapabilityPolicy) -> Result<()> {
1466    let cap = enforce_capabilities(catalog, policy).context("backend capability check failed")?;
1467    info!(
1468        policy = %policy,
1469        enforced = cap.enforced,
1470        skipped_undeclared = cap.skipped_undeclared,
1471        warnings = cap.warnings,
1472        "capability check complete",
1473    );
1474    Ok(())
1475}
1476
1477/// Run the broker daemon: load catalog/policy + the sealed bundle, unlock,
1478/// construct backends from the decrypted creds, then serve.
1479async fn run_daemon(args: RunArgs) -> Result<()> {
1480    let run_config = load_run_config(&args.overrides)?;
1481    // Shared setup: load catalog/policy, unlock the bundle, build the manager
1482    // (the CredBundle is zeroized inside `prepare_manager`).
1483    let Prepared {
1484        catalog,
1485        policy,
1486        config,
1487        manager,
1488        backend_label,
1489    } = prepare_manager(&run_config.setup).await?;
1490
1491    validate_invocation_catalog_bindings(&run_config.invocation, &catalog)
1492        .context("validating invocation broker identity and keys")?;
1493
1494    // Capability enforcement: does each backend PROVIDE what the catalog's keys
1495    // (+ explicit `requires`) need? A pure, offline catalog check (no backend
1496    // I/O, no extra Vault privilege), gated by `capability-policy` (default
1497    // `strict`, fail closed). Runs independently of `no-reconcile`.
1498    enforce_startup_capabilities(&catalog, run_config.setup.capability_policy)?;
1499
1500    // Startup reconcile (vault-zrg): apply each key's `missing` policy against its
1501    // backend BEFORE binding the socket. A required key absent (or a backend
1502    // unreachable during the probe) is a clean fail-closed startup error (the `?`
1503    // propagates a non-zero exit, no panic). `no-reconcile` is a recovery hatch.
1504    if run_config.no_reconcile {
1505        warn!("startup reconcile skipped (no-reconcile); missing keys will fail at request time");
1506    } else {
1507        let summary = manager
1508            .reconcile()
1509            .await
1510            .context("reconciling catalog against backends")?;
1511        info!(
1512            present = summary.present,
1513            generated = summary.generated,
1514            warned = summary.warned,
1515            "catalog reconcile complete",
1516        );
1517    }
1518
1519    let limits = BrokerLimits {
1520        max_encrypt_size: run_config.max_encrypt_size,
1521        max_payload_size: run_config.max_payload_size,
1522        grace_versions: run_config.grace_versions,
1523        svid_ttl_secs: run_config.setup.svid_ttl_secs,
1524        retain_versions: run_config.retain_versions,
1525    };
1526    let jwt_revocations = JwtRevocationStore::load_from_manager(&manager)
1527        .await
1528        .context("loading JWT-SVID revocation deny-list")?;
1529    let mut state =
1530        BrokerState::with_limits(catalog, policy, config, manager, backend_label, limits)
1531            .with_jwt_revocations(jwt_revocations)
1532            // The SIGHUP reload engine (basil-y3e.2) re-reads from the SAME
1533            // configured catalog/policy paths startup used, never the wire.
1534            .with_reload_inputs(ReloadInputs {
1535                catalog_path: run_config.setup.catalog.clone(),
1536                policy_path: run_config.setup.policy.clone(),
1537            });
1538
1539    // Optional JSONL audit sink (`vault-vq5`): open the append-only file ONCE at
1540    // startup so a permissions/path error fails closed here rather than per-op. A
1541    // per-op append is best-effort thereafter (the sink logs-and-continues).
1542    let audit_reopen = if let Some(audit_path) = &run_config.audit_log {
1543        let audit = Arc::new(
1544            AuditLog::open(audit_path)
1545                .with_context(|| format!("opening audit log at {}", audit_path.display()))?,
1546        );
1547        info!(path = %audit_path.display(), "JSONL audit log enabled");
1548        state = state.with_audit_log(Arc::clone(&audit));
1549        Some(audit)
1550    } else {
1551        None
1552    };
1553
1554    let state = Arc::new(state);
1555    spawn_sighup_handler(Arc::clone(&state), audit_reopen);
1556    spawn_retention_sweep(Arc::clone(&state), run_config.retention_sweep_secs);
1557
1558    let socket_path = run_config
1559        .socket
1560        .unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
1561    let server_config = ServerConfig {
1562        socket_path,
1563        socket_mode: run_config.socket_mode,
1564        socket_group: run_config.socket_group,
1565        invocation: run_config.invocation,
1566    };
1567
1568    // Opt-in JWKS HTTP surface (basil-uce.1). When `[jwks] enable` is false (the
1569    // default) NO listener is bound: the broker stays gRPC-over-unix-socket only.
1570    // When enabled, bind here so a bind failure is a clean fail-closed startup
1571    // error (before the gRPC server starts serving), never a mid-run panic. The
1572    // server is tied to the SAME lifecycle as the gRPC server: when the gRPC
1573    // server returns (on its shutdown signal), we trigger the JWKS shutdown and
1574    // await it, so the HTTP surface never outlives the broker.
1575    #[cfg(feature = "http")]
1576    let jwks_shutdown = run_config.jwks.enable.then(|| {
1577        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1578        let http_state = Arc::clone(&state);
1579        let listen = run_config.jwks.listen;
1580        let http_config = crate::jwks::JwksHttpConfig {
1581            issuer: run_config.jwks.issuer.clone(),
1582            tls: run_config.jwks.tls.clone(),
1583        };
1584        let handle = tokio::spawn(async move {
1585            let shutdown = async {
1586                let _ = rx.await;
1587            };
1588            if let Err(err) = crate::jwks::serve(http_state, listen, http_config, shutdown).await {
1589                warn!(error = %err, "JWKS HTTP surface terminated with error");
1590            }
1591        });
1592        (tx, handle)
1593    });
1594
1595    let grpc_result = run_grpc(server_config, state).await;
1596
1597    #[cfg(feature = "http")]
1598    if let Some((tx, handle)) = jwks_shutdown {
1599        // Signal the HTTP surface to drain and wait for it before returning, so it
1600        // never keeps serving after the broker stops.
1601        let _ = tx.send(());
1602        if let Err(err) = handle.await {
1603            warn!(error = %err, "JWKS HTTP surface task join failed");
1604        }
1605    }
1606
1607    grpc_result?;
1608    Ok(())
1609}
1610
1611/// Install the SIGHUP handler. SIGHUP is the operational "reload" signal: it
1612/// (1) hot-reloads the catalog/policy generation (`basil-y3e`) and then
1613/// (2) reopens the JSONL audit log (rotation). It is installed
1614/// **unconditionally** (even when no audit log is configured) so that SIGHUP
1615/// can never fall through to its default disposition (terminate the process).
1616/// This matters because the Nix module wires `systemctl reload`
1617/// (`nixos-rebuild switch` on a catalog/policy edit) to `kill -HUP $MAINPID`; the
1618/// broker must absorb that and reload in place, not die or re-unlock.
1619///
1620/// The reload is the shared [`reload_generation`] engine: it re-reads the
1621/// configured catalog/policy paths, runs the full startup/`check` validation, and
1622/// on success atomically swaps in a new generation. On **any** failure it fails
1623/// closed (the previous generation keeps serving) and the rejection is audited;
1624/// the broker never panics. Reload runs **before** the audit-log reopen so the
1625/// reload outcome lands in the current log segment. With no audit log configured,
1626/// the reload still runs (it is signal-driven, not audit-driven).
1627fn spawn_sighup_handler(state: Arc<BrokerState>, audit: Option<Arc<AuditLog>>) {
1628    let handle = tokio::spawn(async move {
1629        let mut hangup = match signal(SignalKind::hangup()) {
1630            Ok(signal) => signal,
1631            Err(err) => {
1632                warn!(error = %err, "SIGHUP handler disabled");
1633                return;
1634            }
1635        };
1636        while hangup.recv().await.is_some() {
1637            // 1. Reload the catalog/policy generation (fail-closed: a rejection
1638            //    keeps the previous generation serving; both outcomes audited).
1639            handle_sighup_reload(&state).await;
1640            // 2. Reopen the audit log (rotation), if one is configured.
1641            if let Some(audit) = &audit {
1642                audit.request_reopen();
1643                info!("SIGHUP: requested audit log reopen");
1644            }
1645        }
1646    });
1647    std::mem::drop(handle);
1648}
1649
1650/// Run one SIGHUP-driven reload via the shared [`reload_generation`] engine and
1651/// audit the outcome. Never panics; on rejection the previous generation keeps
1652/// serving and the reason is recorded.
1653async fn handle_sighup_reload(state: &BrokerState) {
1654    match reload_generation(state) {
1655        Ok(outcome) => {
1656            info!(
1657                previous_generation = outcome.previous_generation,
1658                generation = outcome.new_generation,
1659                keys = outcome.key_count,
1660                grants = outcome.grant_count,
1661                "SIGHUP: catalog/policy reload applied",
1662            );
1663            state.record_reload(
1664                outcome.previous_generation,
1665                outcome.new_generation,
1666                "applied",
1667                "signal",
1668                ReloadActor::Sighup,
1669            );
1670            if let Err(err) = state.refresh_jwt_revocations().await {
1671                warn!(
1672                    error = %err,
1673                    generation = outcome.new_generation,
1674                    "SIGHUP: JWT-SVID revocation deny-list refresh failed; previous in-memory set still serving",
1675                );
1676                state.record_reload(
1677                    outcome.new_generation,
1678                    outcome.new_generation,
1679                    "revocation_refresh_failed",
1680                    "signal",
1681                    ReloadActor::Sighup,
1682                );
1683            }
1684        }
1685        Err(err) => {
1686            let active = state.active_generation_id();
1687            warn!(
1688                error = %err,
1689                generation = active,
1690                "SIGHUP: catalog/policy reload rejected; previous generation still serving",
1691            );
1692            state.record_reload(
1693                active,
1694                active,
1695                "rejected",
1696                err.audit_reason(),
1697                ReloadActor::Sighup,
1698            );
1699        }
1700    }
1701}
1702
1703fn spawn_retention_sweep(state: Arc<BrokerState>, interval_secs: u64) {
1704    let limits = state.limits();
1705    if limits.retain_versions.is_none() || interval_secs == 0 {
1706        return;
1707    }
1708    let handle = tokio::spawn(async move {
1709        let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
1710        loop {
1711            interval.tick().await;
1712            if let Err(err) = state.manager().sweep_all_retention(limits).await {
1713                warn!(error = %err, "retention sweep failed");
1714            }
1715        }
1716    });
1717    std::mem::drop(handle);
1718}
1719
1720/// Run the read-only catalog **check** (`vault-roe`): load catalog/policy, unlock
1721/// the bundle, build the manager, then probe each key and report present/missing
1722/// (with its `missing` policy). NEVER generates/mutates; never binds a socket.
1723///
1724/// With `--require`, returns an error (non-zero exit) iff a required
1725/// (`missing=error`) key is absent. Without it, the function reports and returns
1726/// `Ok` regardless of what is missing. A backend unreachable during the probe is
1727/// a fatal error either way (fail closed).
1728async fn run_check(args: CheckArgs) -> Result<()> {
1729    let check_config = load_check_config(&args.overrides)?;
1730    let Prepared {
1731        manager, catalog, ..
1732    } = prepare_manager(&check_config.setup).await?;
1733
1734    validate_invocation_catalog_bindings(&check_config.invocation, &catalog)
1735        .context("validating invocation broker identity and keys")?;
1736
1737    // Offline capability check (no backend I/O): does each backend provide what
1738    // the catalog requires? Honors `capability-policy`; under `strict` a gap
1739    // fails the lint (the offline CI gate), independent of --require.
1740    match enforce_capabilities(&catalog, check_config.setup.capability_policy) {
1741        Ok(s) => println!(
1742            "capability check: {} backend(s) enforced, {} undeclared (skipped), {} warning(s)",
1743            s.enforced, s.skipped_undeclared, s.warnings
1744        ),
1745        Err(e) => bail!("{e}"),
1746    }
1747
1748    // Read-only probe of every catalog key. An unreachable backend is a fatal
1749    // error here (never reported as a clean "missing").
1750    let report = manager
1751        .check()
1752        .await
1753        .context("probing catalog keys against their backends")?;
1754
1755    let present = report.present_count();
1756    let total = report.keys.len();
1757    println!("catalog check: {present}/{total} key(s) present");
1758
1759    // Emit each missing key with the policy reconcile would apply to it, grouped
1760    // so the error-class (required) keys are unmistakable.
1761    let mut required_absent = 0usize;
1762    for (name, policy) in report.missing() {
1763        let label = match policy {
1764            MissingPolicy::Error => {
1765                required_absent += 1;
1766                "required (missing=error)"
1767            }
1768            MissingPolicy::Warn => "warn (missing=warn)",
1769            MissingPolicy::Generate => "would-generate (missing=generate)",
1770        };
1771        println!("  MISSING {name}  [{label}]");
1772    }
1773
1774    if report.missing().count() == 0 {
1775        println!("all catalog keys present");
1776    }
1777
1778    // --require gates ONLY on error-class absent keys; warn/generate-absent never
1779    // fail the check. Without --require the check is report-only (always Ok).
1780    if args.require && report.should_fail_required() {
1781        bail!(
1782            "{required_absent} required key(s) absent (missing=error); failing check (--require)"
1783        );
1784    }
1785    Ok(())
1786}
1787
1788/// Build the resolved [`doctor::DoctorInputs`] from the config file + overrides,
1789/// using the same path/socket resolution `run` uses. A missing required path
1790/// (catalog/policy/bundle) is a clean config error (non-zero exit), exactly as in
1791/// `check`: that is a usage error, distinct from a diagnostic failure.
1792fn load_doctor_inputs(overrides: &ConfigOverrides) -> Result<doctor::DoctorInputs> {
1793    let file = load_config_file(overrides)?;
1794    let _invocation = resolve_invocation_config(&file.broker_identity, &file.invocation)?;
1795    let setup = build_setup(&file, overrides)?;
1796    let socket = overrides
1797        .socket
1798        .clone()
1799        .or(file.socket)
1800        .unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
1801    let socket_mode = file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0);
1802
1803    Ok(doctor::DoctorInputs {
1804        catalog: setup.catalog,
1805        policy: setup.policy,
1806        bundle: setup.bundle,
1807        socket,
1808        socket_mode,
1809        socket_group: file.socket_group,
1810        unlock_passphrase_selected: setup.unlock.passphrase_file.is_some(),
1811        unlock_bip39_selected: setup.unlock.bip39_phrase_file.is_some(),
1812        unlock_age_yubikey_selected: setup.unlock.age_yubikey,
1813    })
1814}
1815
1816/// Run the preflight **doctor** (`basil-f0j`): resolve config, run every
1817/// independent read-only check, print the report (human or `--json`), and exit
1818/// non-zero iff any check failed. The default path never unlocks the bundle,
1819/// binds the socket, or mutates anything; `--check-keys` explicitly unlocks only
1820/// to run the same read-only existence probe as `config check --require`.
1821async fn run_doctor(args: DoctorArgs) -> Result<()> {
1822    let inputs = load_doctor_inputs(&args.overrides)?;
1823    let mut report = doctor::run_doctor(&inputs, doctor::EnabledFeatures::current()).await;
1824    if args.check_keys {
1825        let key_row = doctor_key_material_check(&args.overrides).await;
1826        report.checks.push(key_row);
1827        report = doctor::DoctorReport::from_checks(report.checks);
1828    }
1829
1830    if args.json {
1831        println!("{}", doctor::render_json(&report)?);
1832    } else {
1833        print!("{}", doctor::render_human(&report));
1834    }
1835
1836    if report.has_blocking_failure() {
1837        // A blocking misconfiguration exits non-zero, but cleanly: the report has
1838        // already been printed, so suppress the redundant anyhow error chain.
1839        std::process::exit(1);
1840    }
1841    Ok(())
1842}
1843
1844async fn doctor_key_material_check(overrides: &ConfigOverrides) -> doctor::CheckResult {
1845    let check_config = match load_check_config(overrides) {
1846        Ok(config) => config,
1847        Err(err) => {
1848            return doctor::key_material_check(Err(format!(
1849                "could not resolve check configuration: {err:#}"
1850            )));
1851        }
1852    };
1853    let Prepared { manager, .. } = match prepare_manager(&check_config.setup).await {
1854        Ok(prepared) => prepared,
1855        Err(err) => {
1856            return doctor::key_material_check(Err(format!(
1857                "could not build authenticated backend manager: {err:#}"
1858            )));
1859        }
1860    };
1861    match manager.check().await {
1862        Ok(report) => doctor::key_material_check(Ok(&report)),
1863        Err(err) => doctor::key_material_check(Err(err.to_string())),
1864    }
1865}
1866
1867/// Run the offline policy dry-run/explainer (`basil-4vf`): load catalog + policy
1868/// from files, build the REAL PDP, evaluate the proposed tuple (or the effective
1869/// preview), and print the result. Entirely offline: no bundle, no backend, no
1870/// socket, no secret material. Default-deny holds exactly as in enforcement.
1871fn run_explain(args: &ExplainArgs) -> Result<()> {
1872    use crate::catalog::Pdp;
1873
1874    let file = load_config_file(&args.overrides)?;
1875    let catalog_path = required_path(
1876        args.overrides
1877            .catalog
1878            .clone()
1879            .or_else(|| file.catalog.clone()),
1880        "catalog",
1881    )?;
1882    let policy_path = required_path(
1883        args.overrides
1884            .policy
1885            .clone()
1886            .or_else(|| file.policy.clone()),
1887        "policy",
1888    )?;
1889
1890    let catalog_json = std::fs::read_to_string(&catalog_path)
1891        .with_context(|| format!("reading catalog from {}", catalog_path.display()))?;
1892    let policy_json = std::fs::read_to_string(&policy_path)
1893        .with_context(|| format!("reading policy from {}", policy_path.display()))?;
1894    let (catalog, policy, config, warnings) =
1895        load(&catalog_json, &policy_json).context("loading catalog/policy")?;
1896    for w in &warnings {
1897        warn!(warning = %w, "catalog/policy load warning");
1898    }
1899
1900    let pdp = Pdp::new(&catalog, &policy, &config);
1901
1902    if args.effective {
1903        return print_effective(&pdp, &args.subject, args.json);
1904    }
1905
1906    // Single-tuple explain. clap guarantees op/key are present here (required
1907    // unless --effective), but handle their absence fail-safe rather than unwrap.
1908    let (Some(op), Some(key)) = (args.op, args.key.as_deref()) else {
1909        bail!("--op and --key are required unless --effective is given");
1910    };
1911    print_explanation(&pdp, &args.subject, op, key, args.json)
1912}
1913
1914/// Print the "preview effective permissions" view for an identity to stdout.
1915fn print_effective(pdp: &crate::catalog::Pdp, subject: &str, json: bool) -> Result<()> {
1916    let mut out = std::io::stdout().lock();
1917    render_effective(&mut out, pdp, subject, json)
1918}
1919
1920/// Render the "preview effective permissions" view into `out`.
1921///
1922/// The render seam (separate from [`print_effective`]) so the stable `--json`
1923/// shape and the human text can be asserted without capturing the process's real
1924/// stdout. Production simply renders into a locked `stdout`.
1925fn render_effective(
1926    out: &mut impl std::io::Write,
1927    pdp: &crate::catalog::Pdp,
1928    subject: &str,
1929    json: bool,
1930) -> Result<()> {
1931    let grants = pdp.effective(subject);
1932    if json {
1933        let rows: Vec<serde_json::Value> = grants
1934            .iter()
1935            .map(|g| {
1936                serde_json::json!({
1937                    "key": g.key,
1938                    "op": g.op.token(),
1939                    "via": allow_via_json(&g.via),
1940                    "rule": g.rule_id,
1941                })
1942            })
1943            .collect();
1944        let doc = serde_json::json!({ "subject": subject, "effective": rows });
1945        writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
1946        return Ok(());
1947    }
1948    writeln!(
1949        out,
1950        "effective permissions for subject {subject}: {} grant(s)",
1951        grants.len()
1952    )?;
1953    for g in &grants {
1954        let rule = g.rule_id.as_deref().unwrap_or("<public-class>");
1955        writeln!(
1956            out,
1957            "  ALLOW  {}  {}  via {} [{rule}]",
1958            g.op.token(),
1959            g.key,
1960            allow_via_json(&g.via),
1961        )?;
1962    }
1963    if grants.is_empty() {
1964        writeln!(out, "  (none: default-deny)")?;
1965    }
1966    Ok(())
1967}
1968
1969/// Print a single-tuple explanation (decision + matched rule / denial reason) to
1970/// stdout.
1971fn print_explanation(
1972    pdp: &crate::catalog::Pdp,
1973    subject: &str,
1974    op: crate::catalog::Op,
1975    key: &str,
1976    json: bool,
1977) -> Result<()> {
1978    let mut out = std::io::stdout().lock();
1979    render_explanation(&mut out, pdp, subject, op, key, json)
1980}
1981
1982/// Render a single-tuple explanation into `out`.
1983///
1984/// The render seam (separate from [`print_explanation`]) so the stable `--json`
1985/// allow/deny shape can be asserted without the process's real stdout.
1986fn render_explanation(
1987    out: &mut impl std::io::Write,
1988    pdp: &crate::catalog::Pdp,
1989    subject: &str,
1990    op: crate::catalog::Op,
1991    key: &str,
1992    json: bool,
1993) -> Result<()> {
1994    use crate::catalog::Decision;
1995    let ex = pdp.explain_subject(subject, op, key);
1996
1997    if json {
1998        let mut obj = serde_json::Map::new();
1999        obj.insert("subject".into(), subject.into());
2000        obj.insert("op".into(), op.token().into());
2001        obj.insert("key".into(), key.into());
2002        match &ex.decision {
2003            Decision::Allow { via } => {
2004                obj.insert("decision".into(), "allow".into());
2005                obj.insert("via".into(), allow_via_json(via).into());
2006                let matched = ex.matched.as_ref().map_or(serde_json::Value::Null, |m| {
2007                    serde_json::json!({
2008                        "rule": m.rule_id,
2009                        "via": allow_via_json(&m.via),
2010                        "subject": m.subject,
2011                        "action": m.action,
2012                        "target": m.target,
2013                    })
2014                });
2015                obj.insert("matched_rule".into(), matched);
2016            }
2017            Decision::Deny { reason } => {
2018                obj.insert("decision".into(), "deny".into());
2019                obj.insert("reason".into(), deny_reason_json(*reason).into());
2020            }
2021        }
2022        writeln!(
2023            out,
2024            "{}",
2025            serde_json::to_string_pretty(&serde_json::Value::Object(obj))?
2026        )?;
2027        return Ok(());
2028    }
2029
2030    match &ex.decision {
2031        Decision::Allow { via } => {
2032            writeln!(
2033                out,
2034                "ALLOW  subject {subject}  {}  {key}  (via {})",
2035                op.token(),
2036                allow_via_json(via)
2037            )?;
2038            match &ex.matched {
2039                Some(m) => writeln!(
2040                    out,
2041                    "  matched subject `{}` (rule `{}`): action `{}` over target `{}`",
2042                    m.subject, m.rule_id, m.action, m.target
2043                )?,
2044                None => {
2045                    writeln!(
2046                        out,
2047                        "  matched the world-readable public-class rule (no policy rule needed)"
2048                    )?;
2049                }
2050            }
2051        }
2052        Decision::Deny { reason } => {
2053            writeln!(
2054                out,
2055                "DENY   subject {subject}  {}  {key}  ({})",
2056                op.token(),
2057                deny_reason_json(*reason)
2058            )?;
2059            writeln!(out, "  {}", deny_explanation(*reason))?;
2060        }
2061    }
2062    Ok(())
2063}
2064
2065/// Stable JSON/string token for a [`AllowVia`](crate::catalog::AllowVia).
2066fn allow_via_json(via: &crate::catalog::AllowVia) -> String {
2067    use crate::catalog::AllowVia;
2068    match via {
2069        AllowVia::Subject(subject) => format!("subject:{subject}"),
2070        AllowVia::PublicClass => "public_class".to_string(),
2071    }
2072}
2073
2074/// Stable JSON/string token for a [`DenyReason`](crate::catalog::DenyReason).
2075const fn deny_reason_json(reason: crate::catalog::DenyReason) -> &'static str {
2076    use crate::catalog::DenyReason;
2077    match reason {
2078        DenyReason::UnknownKey => "unknown_key",
2079        DenyReason::NotWritable => "not_writable",
2080        DenyReason::NotPermitted => "not_permitted",
2081    }
2082}
2083
2084/// A human sentence for each deny reason.
2085const fn deny_explanation(reason: crate::catalog::DenyReason) -> &'static str {
2086    use crate::catalog::DenyReason;
2087    match reason {
2088        DenyReason::UnknownKey => "key is not in the catalog",
2089        DenyReason::NotWritable => {
2090            "the key is not writable (write hard-cap), denied regardless of policy"
2091        }
2092        DenyReason::NotPermitted => "no policy grant matches this (subject, op, key): default-deny",
2093    }
2094}
2095
2096/// Run the broker daemon behind `basil agent`.
2097pub async fn run_agent(args: RunArgs) -> Result<()> {
2098    let overrides = args.overrides.clone();
2099    // `Box::pin`: with the cloud-KMS features the daemon future is large; this is
2100    // a once-per-process cold path, so the heap indirection is free.
2101    Box::pin(run_with_config_logging(&overrides, run_daemon(args))).await
2102}
2103
2104/// Run the offline catalog/backend check behind `basil config check`.
2105pub async fn run_config_check(args: CheckArgs) -> Result<()> {
2106    let overrides = args.overrides.clone();
2107    Box::pin(run_with_config_logging(&overrides, run_check(args))).await
2108}
2109
2110/// Run the offline policy explainer behind `basil config explain`.
2111pub fn run_config_explain(args: &ExplainArgs) -> Result<()> {
2112    let logging = logging_config_for_overrides(&args.overrides)?;
2113    let logging_guards = init_logging(&logging)?;
2114    let result = run_explain(args);
2115    logging_guards.shutdown();
2116    result
2117}
2118
2119/// Run the preflight doctor behind `basil doctor`.
2120pub async fn run_doctor_command(args: DoctorArgs) -> Result<()> {
2121    let overrides = args.overrides.clone();
2122    if args.json {
2123        let mut logging = logging_config_for_overrides(&overrides)?;
2124        logging.stdout.enable = false;
2125        let logging_guards = init_logging(&logging)?;
2126        let result = run_doctor(args).await;
2127        logging_guards.shutdown();
2128        result
2129    } else {
2130        Box::pin(run_with_config_logging(&overrides, run_doctor(args))).await
2131    }
2132}
2133
2134/// Run first-run config scaffolding behind `basil config init`.
2135pub fn run_config_init(args: &init::InitArgs) -> Result<()> {
2136    let logging_guards = init_logging(&LoggingConfigFile::default())?;
2137    let result = init::run(args);
2138    logging_guards.shutdown();
2139    result
2140}
2141
2142/// Run sealed-bundle operations behind top-level `basil bundle`.
2143pub fn run_bundle(command: bundle_cli::BundleCommand) -> Result<()> {
2144    let logging_guards = init_logging(&LoggingConfigFile::default())?;
2145    let result = bundle_cli::run(command);
2146    logging_guards.shutdown();
2147    result
2148}
2149
2150async fn run_with_config_logging(
2151    overrides: &ConfigOverrides,
2152    fut: impl std::future::Future<Output = Result<()>>,
2153) -> Result<()> {
2154    let logging = logging_config_for_overrides(overrides)?;
2155    let logging_guards = init_logging(&logging)?;
2156    let result = fut.await;
2157    logging_guards.shutdown();
2158    result
2159}
2160
2161fn logging_config_for_overrides(overrides: &ConfigOverrides) -> Result<LoggingConfigFile> {
2162    Ok(load_config_file(overrides)?.logging)
2163}
2164
2165#[cfg(test)]
2166mod tests {
2167    use std::path::Path;
2168
2169    use super::*;
2170    use clap::Parser as _;
2171
2172    fn temp_config(contents: &str) -> PathBuf {
2173        let path = std::env::temp_dir().join(format!(
2174            "basil-agent-config-test-{}-{}.toml",
2175            std::process::id(),
2176            uuid::Uuid::new_v4()
2177        ));
2178        std::fs::write(&path, contents).expect("write temp config");
2179        path
2180    }
2181
2182    fn overrides_for(config: PathBuf) -> ConfigOverrides {
2183        ConfigOverrides {
2184            config: Some(config),
2185            catalog: None,
2186            policy: None,
2187            bundle: None,
2188            socket: None,
2189            vault_addr: None,
2190        }
2191    }
2192
2193    fn catalog_with_invocation_keys(
2194        response_signing: crate::catalog::KeyEntry,
2195        request_encryption: crate::catalog::KeyEntry,
2196    ) -> crate::Catalog {
2197        let mut backends = BTreeMap::new();
2198        backends.insert(
2199            "bao".to_string(),
2200            crate::catalog::BackendRef {
2201                kind: crate::catalog::BackendKind::Vault,
2202                addr: "http://127.0.0.1:8200".to_string(),
2203                engines: Vec::new(),
2204                capabilities: Vec::new(),
2205                mint_key_types: Vec::new(),
2206                requires: Vec::new(),
2207            },
2208        );
2209        let mut keys = BTreeMap::new();
2210        keys.insert("broker.response".to_string(), response_signing);
2211        keys.insert("broker.request".to_string(), request_encryption);
2212        crate::Catalog {
2213            schema_version: 1,
2214            backends,
2215            keys,
2216        }
2217    }
2218
2219    fn catalog_key(
2220        class: Class,
2221        key_type: Option<KeyAlgorithm>,
2222        labels: &[&str],
2223    ) -> crate::catalog::KeyEntry {
2224        crate::catalog::KeyEntry {
2225            class,
2226            key_type,
2227            backend: "bao".to_string(),
2228            engine: None,
2229            path: "path".to_string(),
2230            public_path: matches!(class, Class::Sealing).then(|| "public/path".to_string()),
2231            writable: false,
2232            missing: MissingPolicy::Error,
2233            generate: None,
2234            sealing_pin: None,
2235            labels: crate::catalog::Labels(labels.iter().map(ToString::to_string).collect()),
2236            description: "test key".to_string(),
2237        }
2238    }
2239
2240    #[cfg(feature = "keystore-backend")]
2241    #[test]
2242    fn run_config_file_supplies_all_startup_settings() {
2243        let config = temp_config(
2244            r#"
2245catalog = "/cfg/catalog.json"
2246policy = "/cfg/policy.json"
2247bundle = "/cfg/bundle.sealed"
2248socket = "/run/basil.sock"
2249vault-addr = "http://vault.internal:8200"
2250transit-mount = "basil-transit"
2251jwt-auth-mount = "jwt-basil"
2252jwt-role = "basil-agent"
2253jwt-audience = "openbao-prod"
2254svid-ttl-secs = 120
2255capability-policy = "degraded"
2256db-keystore-cipher = "aegis256"
2257onepassword-provider-uri = "onepassword://basil"
2258onepassword-project = "prod"
2259onepassword-profile = "agent"
2260max-encrypt-size = 4096
2261max-payload-size = 8192
2262grace-versions = 2
2263retain-versions = 5
2264retention-sweep-secs = 60
2265audit-log = "/var/log/basil/audit.jsonl"
2266no-reconcile = true
2267
2268[logging.stdout]
2269enable = false
2270
2271[logging.journald]
2272enable = true
2273
2274[logging.opentelemetry]
2275enable = true
2276endpoint = "http://localhost:4317"
2277protocol = "grpc"
2278
2279[unlock]
2280age-yubikey = true
2281bip39-phrase-file = "/secure/recovery.txt"
2282unlock-tpm = true
2283unlock-passphrase-file = "/secure/passphrase.txt"
2284unlock-passphrase-no-wipe = true
2285strict-bundle-perms = true
2286"#,
2287        );
2288        let args = ConfigOverrides {
2289            config: Some(config.clone()),
2290            catalog: None,
2291            policy: None,
2292            bundle: None,
2293            socket: None,
2294            vault_addr: None,
2295        };
2296
2297        let loaded_file = load_config_file(&args).expect("load config file");
2298        let loaded = load_run_config(&args).expect("load run config");
2299
2300        assert_eq!(loaded.setup.catalog, PathBuf::from("/cfg/catalog.json"));
2301        assert_eq!(loaded.setup.policy, PathBuf::from("/cfg/policy.json"));
2302        assert_eq!(loaded.setup.bundle, PathBuf::from("/cfg/bundle.sealed"));
2303        assert_eq!(loaded.socket.as_deref(), Some("/run/basil.sock"));
2304        assert_eq!(loaded.setup.vault_addr, "http://vault.internal:8200");
2305        assert_eq!(loaded.setup.transit_mount, "basil-transit");
2306        assert_eq!(loaded.setup.jwt_auth_mount, "jwt-basil");
2307        assert_eq!(loaded.setup.jwt_role, "basil-agent");
2308        assert_eq!(loaded.setup.jwt_audience, "openbao-prod");
2309        assert_eq!(loaded.setup.svid_ttl_secs, 120);
2310        assert_eq!(loaded.setup.capability_policy, CapabilityPolicy::Degraded);
2311        #[cfg(feature = "db-keystore")]
2312        assert_eq!(loaded.setup.db_keystore_cipher, "aegis256");
2313        assert_eq!(loaded.setup.onepassword_provider_uri, "onepassword://basil");
2314        assert_eq!(loaded.setup.onepassword_project, "prod");
2315        assert_eq!(loaded.setup.onepassword_profile, "agent");
2316        assert_eq!(loaded.max_encrypt_size, 4096);
2317        assert_eq!(loaded.max_payload_size, 8192);
2318        assert_eq!(loaded.grace_versions, 2);
2319        assert_eq!(loaded.retain_versions, Some(5));
2320        assert_eq!(loaded.retention_sweep_secs, 60);
2321        assert_eq!(
2322            loaded.audit_log.as_deref(),
2323            Some(Path::new("/var/log/basil/audit.jsonl"))
2324        );
2325        assert!(loaded.no_reconcile);
2326        assert!(!loaded_file.logging.stdout.enable);
2327        assert!(loaded_file.logging.journald.enable);
2328        assert!(loaded_file.logging.opentelemetry.enable);
2329        assert_eq!(
2330            (
2331                loaded_file.logging.opentelemetry.endpoint.as_deref(),
2332                loaded_file.logging.opentelemetry.protocol
2333            ),
2334            (Some("http://localhost:4317"), OpenTelemetryProtocol::Grpc)
2335        );
2336        assert!(loaded.setup.unlock.age_yubikey);
2337        assert!(loaded.setup.unlock.tpm);
2338        assert_eq!(
2339            loaded.setup.unlock.bip39_phrase_file.as_deref(),
2340            Some(Path::new("/secure/recovery.txt"))
2341        );
2342        assert_eq!(
2343            loaded.setup.unlock.passphrase_file.as_deref(),
2344            Some(Path::new("/secure/passphrase.txt"))
2345        );
2346        assert!(loaded.setup.unlock.passphrase_no_wipe);
2347        assert!(loaded.setup.unlock.strict_bundle_perms);
2348
2349        std::fs::remove_file(config).expect("remove temp config");
2350    }
2351
2352    #[test]
2353    fn socket_mode_config_accepts_octal_string_and_rejects_bad_values() {
2354        assert_eq!(parse_socket_mode("0600").expect("0600 parses"), 0o600);
2355        assert_eq!(parse_socket_mode("0o660").expect("0o660 parses"), 0o660);
2356        assert!(parse_socket_mode("").is_err());
2357        assert!(parse_socket_mode("0888").is_err());
2358        assert!(parse_socket_mode("10000").is_err());
2359    }
2360
2361    #[test]
2362    fn run_config_file_supplies_socket_ownership_settings() {
2363        let config = temp_config(
2364            r#"
2365catalog = "/cfg/catalog.json"
2366policy = "/cfg/policy.json"
2367bundle = "/cfg/bundle.sealed"
2368socket-mode = "0660"
2369socket-group = "basil-edge"
2370"#,
2371        );
2372        let args = ConfigOverrides {
2373            config: Some(config.clone()),
2374            catalog: None,
2375            policy: None,
2376            bundle: None,
2377            socket: None,
2378            vault_addr: None,
2379        };
2380
2381        let loaded = load_run_config(&args).expect("load run config");
2382
2383        assert_eq!(loaded.socket_mode, 0o660);
2384        assert_eq!(loaded.socket_group.as_deref(), Some("basil-edge"));
2385
2386        std::fs::remove_file(config).expect("remove temp config");
2387    }
2388
2389    #[test]
2390    fn socket_mode_defaults_to_owner_only() {
2391        let config = temp_config(
2392            r#"
2393catalog = "/cfg/catalog.json"
2394policy = "/cfg/policy.json"
2395bundle = "/cfg/bundle.sealed"
2396"#,
2397        );
2398        let args = ConfigOverrides {
2399            config: Some(config.clone()),
2400            catalog: None,
2401            policy: None,
2402            bundle: None,
2403            socket: None,
2404            vault_addr: None,
2405        };
2406
2407        let loaded = load_run_config(&args).expect("load run config");
2408
2409        assert_eq!(loaded.socket_mode, DEFAULT_SOCKET_MODE);
2410        assert!(loaded.socket_group.is_none());
2411
2412        std::fs::remove_file(config).expect("remove temp config");
2413    }
2414
2415    #[test]
2416    fn logging_defaults_stdout_and_journald_on_otel_off() {
2417        let config = AgentConfigFile::default();
2418        assert!(config.logging.stdout.enable);
2419        assert!(config.logging.journald.enable);
2420        assert!(!config.logging.opentelemetry.enable);
2421        assert_eq!(
2422            config.logging.opentelemetry.protocol,
2423            OpenTelemetryProtocol::Grpc
2424        );
2425        assert!(config.logging.opentelemetry.endpoint.is_none());
2426    }
2427
2428    #[test]
2429    fn journald_disabled_produces_no_sink() {
2430        assert!(matches!(journald_sink(false), JournaldSink::Disabled));
2431    }
2432
2433    #[test]
2434    fn journald_without_socket_falls_back_instead_of_aborting() {
2435        // Portability guarantee (basil-ftfj): requesting journald on a host with
2436        // no journal socket (containers, minimal VMs, CI) must NOT abort the
2437        // offline `basil config ...` commands. It resolves to either an active
2438        // journald layer (socket present) or a non-fatal stderr fall back (socket
2439        // absent): never a hard error.
2440        match journald_sink(true) {
2441            JournaldSink::Active(_) | JournaldSink::FellBackToStderr(_) => {}
2442            JournaldSink::Disabled => panic!("enabled journald must not resolve to Disabled"),
2443        }
2444    }
2445
2446    #[test]
2447    fn stderr_fallback_subscriber_constructs_without_journal_socket() {
2448        // The offline logging setup must yield a working stderr subscriber and
2449        // return normally even with no journal socket present. Install it
2450        // thread-locally (not globally) so the test does not contend for the
2451        // process-wide dispatcher.
2452        let subscriber = tracing_subscriber::registry()
2453            .with(EnvFilter::new("info"))
2454            .with(fmt::layer().with_writer(std::io::stderr));
2455        tracing::subscriber::with_default(subscriber, || {
2456            tracing::info!("offline logging falls back to stderr");
2457        });
2458    }
2459
2460    #[cfg(feature = "http")]
2461    #[test]
2462    fn jwks_http_surface_is_disabled_by_default_no_port_opened() {
2463        // The acceptance bar (basil-uce.1): with no `[jwks]` section the surface
2464        // is OFF: `enable` is false, so `run_daemon` never spawns the listener
2465        // task (the `then(...)` guard is not taken). The documented default
2466        // listen address still parses (config validity is independent of enable).
2467        let defaults = JwksConfigFile::default();
2468        assert!(!defaults.enable, "JWKS surface must default to disabled");
2469        assert_eq!(defaults.listen, "127.0.0.1:8201");
2470
2471        let config = temp_config(
2472            r#"
2473catalog = "/cfg/catalog.json"
2474policy = "/cfg/policy.json"
2475bundle = "/cfg/bundle.sealed"
2476"#,
2477        );
2478        let args = ConfigOverrides {
2479            config: Some(config.clone()),
2480            catalog: None,
2481            policy: None,
2482            bundle: None,
2483            socket: None,
2484            vault_addr: None,
2485        };
2486        let loaded = load_run_config(&args).expect("load run config");
2487        assert!(
2488            !loaded.jwks.enable,
2489            "default config leaves the JWKS port closed"
2490        );
2491        assert_eq!(
2492            loaded.jwks.listen,
2493            "127.0.0.1:8201".parse().expect("default listen")
2494        );
2495
2496        std::fs::remove_file(config).expect("remove temp config");
2497    }
2498
2499    #[test]
2500    fn invocation_service_requires_explicit_config_enable() {
2501        let defaults = InvocationConfigFile::default();
2502        assert!(
2503            !defaults.enable,
2504            "InvocationService must default to rejecting requests"
2505        );
2506        assert_eq!(defaults.max_ttl_secs, 60);
2507        assert_eq!(defaults.clock_skew_secs, 30);
2508        assert_eq!(defaults.replay_cache_capacity, 4096);
2509
2510        let config = temp_config(
2511            r#"
2512catalog = "/cfg/catalog.json"
2513policy = "/cfg/policy.json"
2514bundle = "/cfg/bundle.sealed"
2515"#,
2516        );
2517        let args = overrides_for(config.clone());
2518        let loaded = load_run_config(&args).expect("load run config");
2519        assert!(!loaded.invocation.enabled);
2520        assert!(loaded.invocation.broker_identity.is_none());
2521        assert!(loaded.invocation.request_encryption_key_id.is_none());
2522        std::fs::remove_file(config).expect("remove temp config");
2523
2524        let config = temp_config(
2525            r#"
2526catalog = "/cfg/catalog.json"
2527policy = "/cfg/policy.json"
2528bundle = "/cfg/bundle.sealed"
2529
2530[broker-identity]
2531id = "basil://prod/us-east-1/agent-a"
2532response-signing-key-id = "broker.response"
2533
2534[invocation]
2535enable = true
2536audience = ["basil://prod/us-east-1/agent-a"]
2537request-encryption-key-id = "broker.request"
2538max-ttl-secs = 45
2539clock-skew-secs = 7
2540replay-cache-capacity = 128
2541"#,
2542        );
2543        let args = overrides_for(config.clone());
2544        let loaded = load_run_config(&args).expect("load run config");
2545        assert!(loaded.invocation.enabled);
2546        let identity = loaded
2547            .invocation
2548            .broker_identity
2549            .as_ref()
2550            .expect("broker identity");
2551        assert_eq!(identity.id, "basil://prod/us-east-1/agent-a");
2552        assert_eq!(identity.response_signing_key_id, "broker.response");
2553        assert_eq!(
2554            loaded.invocation.request_encryption_key_id.as_deref(),
2555            Some("broker.request")
2556        );
2557        assert_eq!(
2558            loaded.invocation.audiences,
2559            vec!["basil://prod/us-east-1/agent-a".to_string()]
2560        );
2561        assert_eq!(loaded.invocation.max_ttl_secs, 45);
2562        assert_eq!(loaded.invocation.clock_skew_secs, 7);
2563        assert_eq!(loaded.invocation.replay_cache_capacity, 128);
2564
2565        std::fs::remove_file(config).expect("remove temp config");
2566
2567        let config = temp_config(
2568            r#"
2569catalog = "/cfg/catalog.json"
2570policy = "/cfg/policy.json"
2571bundle = "/cfg/bundle.sealed"
2572
2573[invocation]
2574enable = true
2575"#,
2576        );
2577        let args = overrides_for(config.clone());
2578        let err = load_run_config(&args).expect_err("enabled invocation requires audience");
2579        assert!(
2580            err.to_string()
2581                .contains("invocation.audience must be set when invocation.enable is true")
2582        );
2583
2584        std::fs::remove_file(config).expect("remove temp config");
2585    }
2586
2587    #[test]
2588    fn invocation_config_rejects_missing_keys_invalid_identity_and_bounds() {
2589        let config = temp_config(
2590            r#"
2591catalog = "/cfg/catalog.json"
2592policy = "/cfg/policy.json"
2593bundle = "/cfg/bundle.sealed"
2594
2595[broker-identity]
2596id = "https://not-basil.example/agent-a"
2597response-signing-key-id = "broker.response"
2598
2599[invocation]
2600enable = true
2601audience = ["basil://prod/us-east-1/agent-a"]
2602request-encryption-key-id = "broker.request"
2603"#,
2604        );
2605        let err = load_run_config(&overrides_for(config.clone()))
2606            .expect_err("non-basil broker id rejects");
2607        assert!(
2608            err.to_string()
2609                .contains("broker-identity.id must use the basil:// scheme")
2610        );
2611        std::fs::remove_file(config).expect("remove temp config");
2612
2613        let config = temp_config(
2614            r#"
2615catalog = "/cfg/catalog.json"
2616policy = "/cfg/policy.json"
2617bundle = "/cfg/bundle.sealed"
2618
2619[broker-identity]
2620id = "basil://prod/us-east-1/agent-a"
2621response-signing-key-id = "broker.response"
2622
2623[invocation]
2624enable = true
2625audience = ["basil://prod/us-east-1/agent-a"]
2626max-ttl-secs = 301
2627"#,
2628        );
2629        let err =
2630            load_run_config(&overrides_for(config.clone())).expect_err("excessive ttl rejects");
2631        assert!(
2632            err.to_string()
2633                .contains("invocation.max-ttl-secs must be at most 300 seconds")
2634        );
2635        std::fs::remove_file(config).expect("remove temp config");
2636
2637        let config = temp_config(
2638            r#"
2639catalog = "/cfg/catalog.json"
2640policy = "/cfg/policy.json"
2641bundle = "/cfg/bundle.sealed"
2642
2643[broker-identity]
2644id = "basil://prod/us-east-1/agent-a"
2645response-signing-key-id = "broker.response"
2646
2647[invocation]
2648enable = true
2649audience = ["basil://prod/us-east-1/agent-a"]
2650"#,
2651        );
2652        let err = load_run_config(&overrides_for(config.clone()))
2653            .expect_err("enabled invocation requires request encryption key");
2654        assert!(
2655            err.to_string()
2656                .contains("invocation.request-encryption-key-id is required")
2657        );
2658        std::fs::remove_file(config).expect("remove temp config");
2659    }
2660
2661    #[test]
2662    fn invocation_catalog_bindings_require_expected_class_and_use() {
2663        let invocation = InvocationRuntimeConfig {
2664            enabled: true,
2665            broker_identity: Some(BrokerIdentityRuntimeConfig {
2666                id: "basil://prod/us-east-1/agent-a".to_string(),
2667                response_signing_key_id: "broker.response".to_string(),
2668            }),
2669            audiences: vec!["basil://prod/us-east-1/agent-a".to_string()],
2670            request_encryption_key_id: Some("broker.request".to_string()),
2671            max_ttl_secs: 60,
2672            clock_skew_secs: 30,
2673            replay_cache_capacity: 4096,
2674            now_unix_override: None,
2675        };
2676        let valid = catalog_with_invocation_keys(
2677            catalog_key(
2678                Class::Asymmetric,
2679                Some(KeyAlgorithm::Ed25519),
2680                &["broker_key_use=response-signing"],
2681            ),
2682            catalog_key(
2683                Class::Sealing,
2684                Some(KeyAlgorithm::X25519),
2685                &["broker_key_use=request-encryption"],
2686            ),
2687        );
2688        validate_invocation_catalog_bindings(&invocation, &valid).expect("valid keys pass");
2689
2690        let wrong_response_class = catalog_with_invocation_keys(
2691            catalog_key(
2692                Class::Sealing,
2693                Some(KeyAlgorithm::X25519),
2694                &["broker_key_use=response-signing"],
2695            ),
2696            catalog_key(
2697                Class::Sealing,
2698                Some(KeyAlgorithm::X25519),
2699                &["broker_key_use=request-encryption"],
2700            ),
2701        );
2702        let err = validate_invocation_catalog_bindings(&invocation, &wrong_response_class)
2703            .expect_err("wrong response signing class rejects");
2704        assert!(
2705            err.to_string().contains(
2706                "broker response-signing key `broker.response` must be class `asymmetric`"
2707            )
2708        );
2709
2710        let wrong_request_use = catalog_with_invocation_keys(
2711            catalog_key(
2712                Class::Asymmetric,
2713                Some(KeyAlgorithm::Ed25519),
2714                &["broker_key_use=response-signing"],
2715            ),
2716            catalog_key(
2717                Class::Sealing,
2718                Some(KeyAlgorithm::X25519),
2719                &["broker_key_use=response-signing"],
2720            ),
2721        );
2722        let err = validate_invocation_catalog_bindings(&invocation, &wrong_request_use)
2723            .expect_err("wrong request encryption use rejects");
2724        assert!(err.to_string().contains(
2725            "broker request-encryption key `broker.request` must carry label `broker_key_use=request-encryption`"
2726        ));
2727    }
2728
2729    #[cfg(feature = "http")]
2730    #[test]
2731    fn jwks_http_surface_enables_and_parses_listen_from_config() {
2732        let config = temp_config(
2733            r#"
2734catalog = "/cfg/catalog.json"
2735policy = "/cfg/policy.json"
2736bundle = "/cfg/bundle.sealed"
2737
2738[jwks]
2739enable = true
2740listen = "0.0.0.0:9443"
2741"#,
2742        );
2743        let args = ConfigOverrides {
2744            config: Some(config.clone()),
2745            catalog: None,
2746            policy: None,
2747            bundle: None,
2748            socket: None,
2749            vault_addr: None,
2750        };
2751        let loaded = load_run_config(&args).expect("load run config");
2752        assert!(loaded.jwks.enable);
2753        assert_eq!(
2754            loaded.jwks.listen,
2755            "0.0.0.0:9443".parse().expect("custom listen")
2756        );
2757
2758        std::fs::remove_file(config).expect("remove temp config");
2759    }
2760
2761    #[cfg(feature = "http")]
2762    #[test]
2763    fn jwks_listen_must_be_a_valid_socket_addr() {
2764        let bad = JwksConfigFile {
2765            enable: true,
2766            listen: "not-an-addr".to_string(),
2767            issuer: None,
2768            tls: JwksTlsConfigFile::default(),
2769        };
2770        assert!(
2771            resolve_jwks_config(&bad).is_err(),
2772            "a malformed jwks.listen fails closed at startup"
2773        );
2774    }
2775
2776    #[cfg(feature = "http")]
2777    #[test]
2778    fn jwks_issuer_must_be_absolute_http_url_or_unset() {
2779        // Unset issuer: discovery doc simply isn't served (ok).
2780        let none = JwksConfigFile {
2781            enable: true,
2782            listen: "127.0.0.1:8201".to_string(),
2783            issuer: None,
2784            tls: JwksTlsConfigFile::default(),
2785        };
2786        assert!(resolve_jwks_config(&none).expect("ok").issuer.is_none());
2787
2788        // A relative / schemeless issuer fails closed.
2789        let bad = JwksConfigFile {
2790            enable: true,
2791            listen: "127.0.0.1:8201".to_string(),
2792            issuer: Some("basil.example.com".to_string()),
2793            tls: JwksTlsConfigFile::default(),
2794        };
2795        assert!(
2796            resolve_jwks_config(&bad).is_err(),
2797            "a non-http(s) issuer fails closed at startup"
2798        );
2799
2800        // A valid https issuer with a trailing slash is normalized.
2801        let good = JwksConfigFile {
2802            enable: true,
2803            listen: "127.0.0.1:8201".to_string(),
2804            issuer: Some("https://basil.example.com/".to_string()),
2805            tls: JwksTlsConfigFile::default(),
2806        };
2807        assert_eq!(
2808            resolve_jwks_config(&good).expect("ok").issuer.as_deref(),
2809            Some("https://basil.example.com")
2810        );
2811    }
2812
2813    #[cfg(feature = "http")]
2814    #[test]
2815    fn jwks_issuer_rejects_ssrf_prone_url_shapes() {
2816        for issuer in [
2817            "//basil.example.com",
2818            "ftp://basil.example.com",
2819            "file:///etc/passwd",
2820            "unix:///run/basil-jwks.sock",
2821            "http://169.254.169.254/latest/meta-data/",
2822            "http://metadata.google.internal/computeMetadata/v1/",
2823            "https://user:pass@basil.example.com",
2824            "https://basil.example.com/#fragment",
2825        ] {
2826            let bad = JwksConfigFile {
2827                enable: true,
2828                listen: "127.0.0.1:8201".to_string(),
2829                issuer: Some(issuer.to_string()),
2830                tls: JwksTlsConfigFile::default(),
2831            };
2832            assert!(
2833                resolve_jwks_config(&bad).is_err(),
2834                "jwks.issuer `{issuer}` should fail closed"
2835            );
2836        }
2837    }
2838
2839    #[cfg(feature = "http")]
2840    #[test]
2841    fn jwks_tls_defaults_to_disabled() {
2842        let defaults = JwksConfigFile::default();
2843        assert!(resolve_jwks_config(&defaults).expect("ok").tls.is_none());
2844    }
2845
2846    #[cfg(all(feature = "http", not(feature = "http-tls")))]
2847    #[test]
2848    fn jwks_tls_requires_cargo_feature() {
2849        let config = JwksConfigFile {
2850            enable: true,
2851            listen: "127.0.0.1:8201".to_string(),
2852            issuer: Some("https://basil.example.com".to_string()),
2853            tls: JwksTlsConfigFile {
2854                enable: true,
2855                cert_file: Some("/etc/basil/jwks-cert.pem".into()),
2856                key_file: Some("/etc/basil/jwks-key.pem".into()),
2857            },
2858        };
2859        let err = resolve_jwks_config(&config).expect_err("feature missing");
2860        assert!(err.to_string().contains("http-tls"));
2861    }
2862
2863    #[cfg(not(feature = "http"))]
2864    #[test]
2865    fn jwks_enable_requires_http_feature() {
2866        let config = temp_config(
2867            r#"
2868catalog = "/cfg/catalog.json"
2869policy = "/cfg/policy.json"
2870bundle = "/cfg/bundle.sealed"
2871
2872[jwks]
2873enable = true
2874"#,
2875        );
2876        let err = load_run_config(&overrides_for(config.clone())).expect_err("feature missing");
2877        assert!(err.to_string().contains("http cargo feature"));
2878        std::fs::remove_file(config).expect("remove temp config");
2879    }
2880
2881    #[cfg(feature = "http-tls")]
2882    #[test]
2883    fn jwks_tls_requires_cert_and_key_when_enabled() {
2884        let missing_key = JwksConfigFile {
2885            enable: true,
2886            listen: "127.0.0.1:8201".to_string(),
2887            issuer: Some("https://basil.example.com".to_string()),
2888            tls: JwksTlsConfigFile {
2889                enable: true,
2890                cert_file: Some("/etc/basil/jwks-cert.pem".into()),
2891                key_file: None,
2892            },
2893        };
2894        let err = resolve_jwks_config(&missing_key).expect_err("key required");
2895        assert!(err.to_string().contains("key-file"));
2896    }
2897
2898    #[cfg(feature = "otlp")]
2899    #[test]
2900    fn otel_logging_requires_non_empty_http_endpoint_when_enabled() {
2901        let missing = OpenTelemetryLoggingConfigFile {
2902            enable: true,
2903            endpoint: None,
2904            protocol: OpenTelemetryProtocol::Grpc,
2905        };
2906        assert!(required_otel_endpoint(&missing).is_err());
2907
2908        let empty = OpenTelemetryLoggingConfigFile {
2909            enable: true,
2910            endpoint: Some("   ".to_string()),
2911            protocol: OpenTelemetryProtocol::Grpc,
2912        };
2913        assert!(required_otel_endpoint(&empty).is_err());
2914
2915        let unix = OpenTelemetryLoggingConfigFile {
2916            enable: true,
2917            endpoint: Some("unix:///run/otel.sock".to_string()),
2918            protocol: OpenTelemetryProtocol::Grpc,
2919        };
2920        assert!(required_otel_endpoint(&unix).is_err());
2921
2922        for endpoint in [
2923            "//otel.example.com:4317",
2924            "ftp://otel.example.com:4317",
2925            "file:///tmp/otel.sock",
2926            "http://169.254.169.254/latest/meta-data/",
2927            "http://metadata.google.internal/computeMetadata/v1/",
2928            "https://user:pass@otel.example.com:4317",
2929            "https://otel.example.com:4317/#fragment",
2930        ] {
2931            let bad = OpenTelemetryLoggingConfigFile {
2932                enable: true,
2933                endpoint: Some(endpoint.to_string()),
2934                protocol: OpenTelemetryProtocol::Grpc,
2935            };
2936            assert!(
2937                required_otel_endpoint(&bad).is_err(),
2938                "OpenTelemetry endpoint `{endpoint}` should fail closed"
2939            );
2940        }
2941
2942        let http = OpenTelemetryLoggingConfigFile {
2943            enable: true,
2944            endpoint: Some("http://localhost:4317".to_string()),
2945            protocol: OpenTelemetryProtocol::Grpc,
2946        };
2947        assert_eq!(
2948            required_otel_endpoint(&http).expect("valid endpoint"),
2949            "http://localhost:4317/"
2950        );
2951    }
2952
2953    #[test]
2954    fn cli_overrides_win_over_config_file() {
2955        let config = temp_config(
2956            r#"
2957catalog = "/cfg/catalog.json"
2958policy = "/cfg/policy.json"
2959bundle = "/cfg/bundle.sealed"
2960socket = "/cfg/basil.sock"
2961vault-addr = "http://cfg-vault:8200"
2962"#,
2963        );
2964        let args = ConfigOverrides {
2965            config: Some(config.clone()),
2966            catalog: Some(PathBuf::from("/cli/catalog.json")),
2967            policy: Some(PathBuf::from("/cli/policy.json")),
2968            bundle: Some(PathBuf::from("/cli/bundle.sealed")),
2969            socket: Some("/cli/basil.sock".to_string()),
2970            vault_addr: Some("http://cli-vault:8200".to_string()),
2971        };
2972
2973        let loaded = load_run_config(&args).expect("load run config");
2974
2975        assert_eq!(loaded.setup.catalog, PathBuf::from("/cli/catalog.json"));
2976        assert_eq!(loaded.setup.policy, PathBuf::from("/cli/policy.json"));
2977        assert_eq!(loaded.setup.bundle, PathBuf::from("/cli/bundle.sealed"));
2978        assert_eq!(loaded.socket.as_deref(), Some("/cli/basil.sock"));
2979        assert_eq!(loaded.setup.vault_addr, "http://cli-vault:8200");
2980
2981        std::fs::remove_file(config).expect("remove temp config");
2982    }
2983
2984    #[test]
2985    fn run_cli_rejects_removed_startup_overrides() {
2986        #[derive(Debug, clap::Parser)]
2987        struct TestCli {
2988            #[command(flatten)]
2989            args: RunArgs,
2990        }
2991
2992        let err = TestCli::try_parse_from([
2993            "basil-agent-run",
2994            "--catalog",
2995            "/tmp/catalog.json",
2996            "--policy",
2997            "/tmp/policy.json",
2998            "--bundle",
2999            "/tmp/bundle.sealed",
3000            "--capability-policy",
3001            "off",
3002        ])
3003        .expect_err("removed flag rejected");
3004        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
3005    }
3006
3007    // ---- explain / effective CLI rendering (basil-4vf) ----------------------
3008
3009    /// A small catalog + least-privilege policy reused by the explain tests: uid
3010    /// 9002 is a `reader` of `grafana.admin_password` (user rule); gid 10 is an
3011    /// `operator` of `grafana.**` (group rule). The loader builds the same real
3012    /// types enforcement uses.
3013    mod explain {
3014        use super::*;
3015        use crate::catalog::{Op, Pdp, load};
3016
3017        const CATALOG: &str = r#"{
3018          "schemaVersion": 1,
3019          "backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
3020          "keys": {
3021            "grafana.admin_password": {
3022              "class": "value", "backend": "bao", "engine": "kv2",
3023              "path": "secret/data/grafana/admin", "writable": true,
3024              "missing": "error", "description": "grafana admin value"
3025            }
3026          }
3027        }"#;
3028
3029        const POLICY: &str = r#"{
3030          "schemaVersion": 2,
3031          "subjects": {
3032            "svc.grafana": { "allOf": [ { "kind": "unix", "uid": 9002 } ] },
3033            "ops.wheel": { "allOf": [ { "kind": "unix", "gid": 10 } ] }
3034          },
3035          "roles": { "reader": ["get", "list"], "operator": ["set", "rotate"] },
3036          "rules": [
3037            { "id": "grafana-reader",  "subjects": ["svc.grafana"], "action": ["role:reader"],   "target": ["grafana.admin_password"] },
3038            { "id": "wheel-operator",  "subjects": ["ops.wheel"],   "action": ["role:operator"], "target": ["grafana.**"] }
3039          ],
3040          "config": {
3041            "names": { "users": { "9002": "svc-grafana" }, "groups": { "10": "wheel" } },
3042            "memberships": { "9002": [9002] }
3043          }
3044        }"#;
3045
3046        /// Owned `(Catalog, ResolvedPolicy, Config)` via the loader, so a `Pdp` can
3047        /// borrow them.
3048        fn loaded() -> (
3049            crate::catalog::Catalog,
3050            crate::catalog::ResolvedPolicy,
3051            crate::catalog::Config,
3052        ) {
3053            let (catalog, policy, config, _w) = load(CATALOG, POLICY).expect("fixture loads");
3054            (catalog, policy, config)
3055        }
3056
3057        fn render_explanation_to_string(
3058            pdp: &Pdp,
3059            subject: &str,
3060            op: Op,
3061            key: &str,
3062            json: bool,
3063        ) -> String {
3064            let mut buf = Vec::new();
3065            render_explanation(&mut buf, pdp, subject, op, key, json).expect("render");
3066            String::from_utf8(buf).expect("utf8")
3067        }
3068
3069        #[test]
3070        fn json_allow_shape_carries_matched_rule_fields() {
3071            let (c, p, cfg) = loaded();
3072            let pdp = Pdp::new(&c, &p, &cfg);
3073            let out = render_explanation_to_string(
3074                &pdp,
3075                "svc.grafana",
3076                Op::Get,
3077                "grafana.admin_password",
3078                true,
3079            );
3080            let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3081            assert_eq!(doc["decision"], "allow");
3082            assert_eq!(doc["subject"], "svc.grafana");
3083            assert_eq!(doc["op"], "get");
3084            assert_eq!(doc["key"], "grafana.admin_password");
3085            assert_eq!(doc["via"], "subject:svc.grafana");
3086            assert_eq!(doc["matched_rule"]["rule"], "grafana-reader");
3087            assert_eq!(doc["matched_rule"]["via"], "subject:svc.grafana");
3088            assert!(doc["matched_rule"]["action"].is_string());
3089            assert!(doc["matched_rule"]["target"].is_string());
3090        }
3091
3092        #[test]
3093        fn json_deny_shape_carries_default_deny_reason() {
3094            let (c, p, cfg) = loaded();
3095            let pdp = Pdp::new(&c, &p, &cfg);
3096            // `svc.grafana` is a reader only: `set` is not granted (default-deny).
3097            let out = render_explanation_to_string(
3098                &pdp,
3099                "svc.grafana",
3100                Op::Set,
3101                "grafana.admin_password",
3102                true,
3103            );
3104            let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3105            assert_eq!(doc["decision"], "deny");
3106            assert_eq!(doc["reason"], "not_permitted");
3107            // A deny must NOT leak an allow `via`/`matched_rule`.
3108            assert!(doc.get("via").is_none());
3109            assert!(doc.get("matched_rule").is_none());
3110
3111            // An unknown key denies with the unknown-key reason.
3112            let unknown =
3113                render_explanation_to_string(&pdp, "svc.grafana", Op::Get, "no.such.key", true);
3114            let doc: serde_json::Value = serde_json::from_str(&unknown).expect("json");
3115            assert_eq!(doc["decision"], "deny");
3116            assert_eq!(doc["reason"], "unknown_key");
3117        }
3118
3119        #[test]
3120        fn subject_name_selects_the_rule_to_explain() {
3121            let (c, p, cfg) = loaded();
3122            let pdp = Pdp::new(&c, &p, &cfg);
3123            let denied = render_explanation_to_string(
3124                &pdp,
3125                "svc.grafana",
3126                Op::Set,
3127                "grafana.admin_password",
3128                true,
3129            );
3130            let doc: serde_json::Value = serde_json::from_str(&denied).expect("json");
3131            assert_eq!(doc["decision"], "deny", "reader subject cannot set");
3132
3133            let allowed = render_explanation_to_string(
3134                &pdp,
3135                "ops.wheel",
3136                Op::Set,
3137                "grafana.admin_password",
3138                true,
3139            );
3140            let doc: serde_json::Value = serde_json::from_str(&allowed).expect("json");
3141            assert_eq!(doc["decision"], "allow", "operator subject can set");
3142            assert_eq!(doc["via"], "subject:ops.wheel");
3143            assert_eq!(doc["matched_rule"]["rule"], "wheel-operator");
3144        }
3145
3146        #[test]
3147        fn effective_mode_renders_granted_key_op_set() {
3148            let (c, p, cfg) = loaded();
3149            let pdp = Pdp::new(&c, &p, &cfg);
3150            let mut buf = Vec::new();
3151            render_effective(&mut buf, &pdp, "ops.wheel", true).expect("render effective");
3152            let doc: serde_json::Value = serde_json::from_slice(&buf).expect("effective json");
3153            assert_eq!(doc["subject"], "ops.wheel");
3154            let rows = doc["effective"].as_array().expect("effective rows");
3155            let pairs: Vec<(&str, &str)> = rows
3156                .iter()
3157                .filter_map(|r| Some((r["key"].as_str()?, r["op"].as_str()?)))
3158                .collect();
3159            assert!(
3160                pairs.contains(&("grafana.admin_password", "set")),
3161                "operator set granted: {pairs:?}"
3162            );
3163            assert!(
3164                pairs.contains(&("grafana.admin_password", "rotate")),
3165                "operator rotate granted: {pairs:?}"
3166            );
3167            assert!(
3168                rows.iter().all(|r| r["via"] == "subject:ops.wheel"),
3169                "every grant is via the wheel subject"
3170            );
3171
3172            // An unknown subject renders an empty effective set (default-deny),
3173            // not an error and not a spurious allow.
3174            let mut buf = Vec::new();
3175            render_effective(&mut buf, &pdp, "missing.subject", true).expect("render effective");
3176            let doc: serde_json::Value = serde_json::from_slice(&buf).expect("json");
3177            assert!(
3178                doc["effective"].as_array().expect("rows").is_empty(),
3179                "no grants -> empty effective set"
3180            );
3181        }
3182
3183        /// The fail-safe bail: `run_explain` WITHOUT `--op`/`--key` (and not in
3184        /// `--effective` mode) must error cleanly, never panic, never emit a
3185        /// misleading allow. Driven through the real handler with temp catalog/policy
3186        /// files so the bail at the end of `run_explain` is reached.
3187        #[test]
3188        fn missing_op_key_without_effective_bails_cleanly() {
3189            let dir = std::env::temp_dir();
3190            let stamp = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4());
3191            let catalog_path = dir.join(format!("basil-explain-cat-{stamp}.json"));
3192            let policy_path = dir.join(format!("basil-explain-pol-{stamp}.json"));
3193            std::fs::write(&catalog_path, CATALOG).expect("write catalog");
3194            std::fs::write(&policy_path, POLICY).expect("write policy");
3195
3196            let args = ExplainArgs {
3197                subject: "svc.grafana".to_string(),
3198                op: None,
3199                key: None,
3200                effective: false,
3201                json: true,
3202                overrides: ConfigOverrides {
3203                    config: None,
3204                    catalog: Some(catalog_path.clone()),
3205                    policy: Some(policy_path.clone()),
3206                    bundle: None,
3207                    socket: None,
3208                    vault_addr: None,
3209                },
3210            };
3211
3212            let err = run_explain(&args).expect_err("missing op/key must bail");
3213            assert!(
3214                err.to_string().contains("--op and --key are required"),
3215                "clean bail, not a panic: {err}"
3216            );
3217
3218            std::fs::remove_file(&catalog_path).ok();
3219            std::fs::remove_file(&policy_path).ok();
3220        }
3221    }
3222}