Skip to main content

basil_core/
agent_cli.rs

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