1#![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#[derive(Debug, clap::Args)]
67pub struct RunArgs {
68 #[command(flatten)]
69 overrides: ConfigOverrides,
70}
71
72#[derive(Debug, clap::Args)]
91pub struct ExplainArgs {
92 #[arg(long)]
94 subject: String,
95
96 #[arg(long, value_parser = parse_op, required_unless_present = "effective")]
99 op: Option<crate::catalog::Op>,
100
101 #[arg(long, required_unless_present = "effective")]
103 key: Option<String>,
104
105 #[arg(long)]
108 effective: bool,
109
110 #[arg(long, conflicts_with = "effective")]
115 live: bool,
116
117 #[arg(long)]
119 json: bool,
120
121 #[command(flatten)]
122 overrides: ConfigOverrides,
123}
124
125impl ExplainArgs {
126 #[must_use]
128 pub const fn is_live(&self) -> bool {
129 self.live
130 }
131
132 #[must_use]
134 pub fn subject(&self) -> &str {
135 &self.subject
136 }
137
138 #[must_use]
142 pub fn op_token(&self) -> Option<&'static str> {
143 self.op.map(crate::catalog::Op::token)
144 }
145
146 #[must_use]
148 pub fn key(&self) -> Option<&str> {
149 self.key.as_deref()
150 }
151
152 #[must_use]
154 pub const fn json(&self) -> bool {
155 self.json
156 }
157}
158
159fn parse_op(s: &str) -> Result<crate::catalog::Op, String> {
161 crate::catalog::Op::parse(s).map_err(|e| e.to_string())
162}
163
164#[derive(Debug, clap::Args)]
179pub struct DoctorArgs {
180 #[arg(long)]
182 json: bool,
183
184 #[arg(long)]
187 keys: bool,
188
189 #[arg(long)]
192 strict: bool,
193
194 #[command(flatten)]
195 overrides: ConfigOverrides,
196}
197
198struct BackendDefaults<'a> {
203 vault_addr: &'a str,
205 transit_mount: &'a str,
207 jwt_auth_mount: &'a str,
209 jwt_role: &'a str,
211 jwt_audience: &'a str,
213 svid_ttl: Duration,
215 #[cfg(feature = "db-keystore")]
217 db_keystore_cipher: &'a str,
218 #[cfg(feature = "keystore-backend")]
220 onepassword_provider_uri: &'a str,
221 #[cfg(feature = "keystore-backend")]
223 onepassword_project: &'a str,
224 #[cfg(feature = "keystore-backend")]
226 onepassword_profile: &'a str,
227}
228
229async 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#[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#[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 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
454async 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#[derive(Debug, Clone, clap::Args)]
487pub struct ConfigOverrides {
488 #[arg(short = 'c', long, env = "BASIL_CONFIG")]
490 pub(crate) config: Option<PathBuf>,
491
492 #[arg(long, env = "BASIL_CATALOG")]
494 pub(crate) catalog: Option<PathBuf>,
495
496 #[arg(long, env = "BASIL_POLICY")]
498 pub(crate) policy: Option<PathBuf>,
499
500 #[arg(long, env = "BASIL_BUNDLE")]
502 pub(crate) bundle: Option<PathBuf>,
503
504 #[arg(long, env = "BASIL_SOCKET")]
506 pub(crate) socket: Option<String>,
507
508 #[arg(long, env = "VAULT_ADDR")]
510 pub(crate) vault_addr: Option<String>,
511}
512
513#[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#[derive(Debug, Clone, Default, Deserialize)]
753#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
754pub(crate) struct BrokerIdentityConfigFile {
755 id: Option<String>,
757 response_signing_key_id: Option<String>,
759}
760
761#[derive(Debug, Clone, Deserialize)]
767#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
768pub(crate) struct InvocationConfigFile {
769 enable: bool,
771 audience: Vec<String>,
773 request_encryption_key_id: Option<String>,
775 max_ttl_secs: u32,
777 clock_skew_secs: u32,
779 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#[derive(Debug, Clone, Deserialize)]
804#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
805pub(crate) struct JwksConfigFile {
806 enable: bool,
808 listen: String,
810 issuer: Option<String>,
815 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 enable: bool,
824 cert_file: Option<PathBuf>,
826 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#[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#[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
894fn 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#[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 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
1246fn load_key_probe_config(overrides: &ConfigOverrides) -> Result<SetupArgs> {
1250 let file = load_config_file(overrides)?;
1251 let mut setup = build_setup(&file, overrides)?;
1252 setup.unlock.passphrase_no_wipe = true;
1253 Ok(setup)
1254}
1255
1256pub(crate) fn load_config_file(overrides: &ConfigOverrides) -> Result<AgentConfigFile> {
1257 let Some(path) = &overrides.config else {
1258 return Ok(AgentConfigFile::default());
1259 };
1260 let raw = std::fs::read_to_string(path)
1261 .with_context(|| format!("reading config from {}", path.display()))?;
1262 toml::from_str(&raw).with_context(|| format!("parsing config from {}", path.display()))
1263}
1264
1265struct LoggingGuards {
1266 file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
1267 #[cfg(feature = "otlp")]
1268 otel_provider: Option<SdkLoggerProvider>,
1269}
1270
1271#[cfg(feature = "otlp")]
1272type OtelTracingLayer =
1273 OpenTelemetryTracingBridge<SdkLoggerProvider, opentelemetry_sdk::logs::SdkLogger>;
1274#[cfg(feature = "otlp")]
1275type OptionalOtelLayer = (Option<OtelTracingLayer>, Option<SdkLoggerProvider>);
1276
1277#[cfg(feature = "otlp")]
1278impl LoggingGuards {
1279 fn shutdown(self) {
1280 let Self {
1281 file_guard,
1282 otel_provider,
1283 } = self;
1284 drop(file_guard);
1285 if let Some(provider) = otel_provider
1286 && let Err(err) = provider.shutdown()
1287 {
1288 warn!(error = %err, "failed to shut down OpenTelemetry logger provider");
1289 }
1290 }
1291}
1292
1293#[cfg(not(feature = "otlp"))]
1294impl LoggingGuards {
1295 fn shutdown(self) {
1296 let Self { file_guard } = self;
1297 drop(file_guard);
1298 }
1299}
1300
1301enum JournaldSink {
1308 Disabled,
1310 Active(tracing_journald::Layer),
1312 Unavailable(String),
1314}
1315
1316fn journald_sink(enabled: bool) -> JournaldSink {
1317 if !enabled {
1318 return JournaldSink::Disabled;
1319 }
1320 match tracing_journald::layer() {
1321 Ok(layer) => JournaldSink::Active(layer),
1322 Err(err) => JournaldSink::Unavailable(err.to_string()),
1323 }
1324}
1325
1326fn init_logging(config: &LoggingConfigFile) -> Result<LoggingGuards> {
1327 let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
1328 let stdout_layer = config.stdout_enabled().then(fmt::layer);
1329
1330 let (journald_layer, journald_error) = match journald_sink(config.journald.enable) {
1331 JournaldSink::Disabled => (None, None),
1332 JournaldSink::Active(layer) => (Some(layer), None),
1333 JournaldSink::Unavailable(err) => (None, Some(err)),
1334 };
1335
1336 let (file_writer, file_guard) = build_file_writer(&config.file)?;
1337 let file_layer = file_writer.map(|writer| fmt::layer().with_writer(writer));
1338
1339 #[cfg(feature = "otlp")]
1340 let (otel_layer, otel_provider) = build_otel_layer(&config.opentelemetry)?;
1341 #[cfg(not(feature = "otlp"))]
1342 ensure_otel_disabled(&config.opentelemetry)?;
1343
1344 let subscriber = tracing_subscriber::registry()
1345 .with(env_filter)
1346 .with(stdout_layer)
1347 .with(journald_layer)
1348 .with(file_layer);
1349 #[cfg(feature = "otlp")]
1350 let subscriber = subscriber.with(otel_layer);
1351 subscriber
1352 .try_init()
1353 .context("initializing tracing subscriber")?;
1354
1355 if let Some(err) = journald_error {
1356 eprintln!("journald log sink unavailable: {err}");
1357 }
1358
1359 Ok(LoggingGuards {
1360 file_guard,
1361 #[cfg(feature = "otlp")]
1362 otel_provider,
1363 })
1364}
1365
1366type FileMakeWriter = tracing_appender::non_blocking::NonBlocking;
1367
1368fn build_file_writer(
1369 config: &FileLoggingConfigFile,
1370) -> Result<(
1371 Option<FileMakeWriter>,
1372 Option<tracing_appender::non_blocking::WorkerGuard>,
1373)> {
1374 if !config.enable {
1375 return Ok((None, None));
1376 }
1377
1378 let Some(dir) = config.dir.as_deref() else {
1379 bail!("logging.file.dir is required when logging.file.enable=true");
1380 };
1381
1382 let appender = match tracing_appender::rolling::RollingFileAppender::builder()
1383 .rotation(config.rotation.into())
1384 .filename_prefix(config.prefix.clone())
1385 .build(dir)
1386 {
1387 Ok(appender) => appender,
1388 Err(err) => {
1389 eprintln!("file log sink unavailable at {}: {err}", dir.display());
1390 return Ok((None, None));
1391 }
1392 };
1393
1394 let writer = ReportingWriter::new(appender);
1395 let (non_blocking, guard) = tracing_appender::non_blocking(writer);
1396 Ok((Some(non_blocking), Some(guard)))
1397}
1398
1399struct ReportingWriter<W> {
1400 inner: W,
1401}
1402
1403impl<W> ReportingWriter<W> {
1404 const fn new(inner: W) -> Self {
1405 Self { inner }
1406 }
1407}
1408
1409impl<W: Write> Write for ReportingWriter<W> {
1410 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1411 match self.inner.write(buf) {
1412 Ok(size) => Ok(size),
1413 Err(err) => {
1414 eprintln!("failed to write to file log sink: {err}");
1415 Err(err)
1416 }
1417 }
1418 }
1419
1420 fn flush(&mut self) -> io::Result<()> {
1421 match self.inner.flush() {
1422 Ok(()) => Ok(()),
1423 Err(err) => {
1424 eprintln!("failed to flush file log sink: {err}");
1425 Err(err)
1426 }
1427 }
1428 }
1429}
1430
1431#[cfg(not(feature = "otlp"))]
1432fn ensure_otel_disabled(config: &OpenTelemetryLoggingConfigFile) -> Result<()> {
1433 if config.enable {
1434 bail!(
1435 "logging.opentelemetry.enable=true but this basil binary was built without the `otlp` feature"
1436 );
1437 }
1438 Ok(())
1439}
1440
1441#[cfg(feature = "otlp")]
1442fn build_otel_layer(config: &OpenTelemetryLoggingConfigFile) -> Result<OptionalOtelLayer> {
1443 if !config.enable {
1444 return Ok((None, None));
1445 }
1446 let endpoint = required_otel_endpoint(config)?;
1447 let exporter = match config.protocol {
1448 OpenTelemetryProtocol::Grpc => opentelemetry_otlp::LogExporter::builder()
1449 .with_tonic()
1450 .with_endpoint(endpoint)
1451 .build(),
1452 OpenTelemetryProtocol::HttpBinary => opentelemetry_otlp::LogExporter::builder()
1453 .with_http()
1454 .with_protocol(Protocol::HttpBinary)
1455 .with_endpoint(endpoint)
1456 .build(),
1457 OpenTelemetryProtocol::HttpJson => opentelemetry_otlp::LogExporter::builder()
1458 .with_http()
1459 .with_protocol(Protocol::HttpJson)
1460 .with_endpoint(endpoint)
1461 .build(),
1462 }
1463 .context("building OpenTelemetry log exporter")?;
1464 let provider = SdkLoggerProvider::builder()
1465 .with_batch_exporter(exporter)
1466 .build();
1467 let layer = OpenTelemetryTracingBridge::new(&provider);
1468 Ok((Some(layer), Some(provider)))
1469}
1470
1471#[cfg(feature = "otlp")]
1472fn required_otel_endpoint(config: &OpenTelemetryLoggingConfigFile) -> Result<String> {
1473 let endpoint = config
1474 .endpoint
1475 .as_deref()
1476 .map(str::trim)
1477 .filter(|value| !value.is_empty())
1478 .context(
1479 "logging.opentelemetry.endpoint is required when OpenTelemetry logging is enabled",
1480 )?;
1481 Ok(parse_public_http_url("logging.opentelemetry.endpoint", endpoint)?.to_string())
1482}
1483
1484fn build_setup(file: &AgentConfigFile, overrides: &ConfigOverrides) -> Result<SetupArgs> {
1485 let catalog = required_path(
1486 overrides.catalog.clone().or_else(|| file.catalog.clone()),
1487 "catalog",
1488 )?;
1489 let policy = required_path(
1490 overrides.policy.clone().or_else(|| file.policy.clone()),
1491 "policy",
1492 )?;
1493 let bundle = required_path(
1494 overrides.bundle.clone().or_else(|| file.bundle.clone()),
1495 "bundle",
1496 )?;
1497 let vault_addr = overrides
1498 .vault_addr
1499 .clone()
1500 .or_else(|| file.vault_addr.clone())
1501 .unwrap_or_else(|| "http://127.0.0.1:8200".to_string());
1502 let capability_policy = file
1503 .capability_policy
1504 .as_deref()
1505 .map_or(Ok(CapabilityPolicy::Strict), parse_capability_policy)
1506 .map_err(|err| anyhow::anyhow!("parsing config key `capability-policy`: {err}"))?;
1507
1508 Ok(SetupArgs {
1509 catalog,
1510 policy,
1511 bundle,
1512 vault_addr,
1513 transit_mount: file
1514 .transit_mount
1515 .clone()
1516 .unwrap_or_else(|| "transit".to_string()),
1517 jwt_auth_mount: file
1518 .jwt_auth_mount
1519 .clone()
1520 .unwrap_or_else(|| "jwt".to_string()),
1521 jwt_role: file.jwt_role.clone().unwrap_or_default(),
1522 jwt_audience: file
1523 .jwt_audience
1524 .clone()
1525 .unwrap_or_else(|| "openbao".to_string()),
1526 svid_ttl_secs: file.svid_ttl_secs.unwrap_or(DEFAULT_SVID_TTL_SECS),
1527 capability_policy,
1528 #[cfg(feature = "db-keystore")]
1529 db_keystore_cipher: file
1530 .db_keystore_cipher
1531 .clone()
1532 .unwrap_or_else(|| "aegis256".to_string()),
1533 #[cfg(feature = "keystore-backend")]
1534 onepassword_provider_uri: file.onepassword_provider_uri.clone().unwrap_or_default(),
1535 #[cfg(feature = "keystore-backend")]
1536 onepassword_project: file.onepassword_project.clone().unwrap_or_default(),
1537 #[cfg(feature = "keystore-backend")]
1538 onepassword_profile: file.onepassword_profile.clone().unwrap_or_default(),
1539 unlock: unlock::UnlockArgs {
1540 age_yubikey: file.unlock.age_yubikey.unwrap_or(false),
1541 bip39_phrase_file: file.unlock.bip39_phrase_file.clone(),
1542 tpm: file.unlock.unlock_tpm.unwrap_or(false),
1543 passphrase_file: file.unlock.unlock_passphrase_file.clone(),
1544 passphrase_no_wipe: file.unlock.unlock_passphrase_no_wipe.unwrap_or(false),
1545 strict_bundle_perms: file.unlock.strict_bundle_perms.unwrap_or(false),
1546 },
1547 })
1548}
1549
1550fn required_path(path: Option<PathBuf>, name: &str) -> Result<PathBuf> {
1551 path.with_context(|| {
1552 format!(
1553 "`{name}` is required; set it in the config file or pass --{name} / the BASIL_{} env var",
1554 name.to_ascii_uppercase()
1555 )
1556 })
1557}
1558
1559struct Prepared {
1563 catalog: Arc<crate::Catalog>,
1564 policy: crate::ResolvedPolicy,
1565 config: crate::Config,
1566 manager: BackendManager,
1567 backend_label: String,
1568}
1569
1570async fn prepare_manager(setup: &SetupArgs) -> Result<Prepared> {
1576 if setup.vault_addr.starts_with("http://") && !setup.vault_addr.contains("127.0.0.1") {
1577 warn!(addr = %setup.vault_addr, "talking to vault over plaintext HTTP");
1578 }
1579
1580 let catalog_json = std::fs::read_to_string(&setup.catalog)
1582 .with_context(|| format!("reading catalog from {}", setup.catalog.display()))?;
1583 let policy_json = std::fs::read_to_string(&setup.policy)
1584 .with_context(|| format!("reading policy from {}", setup.policy.display()))?;
1585 let (catalog, policy, config, warnings) =
1586 load(&catalog_json, &policy_json).context("loading catalog/policy")?;
1587 for w in &warnings {
1588 warn!(warning = %w, "catalog/policy load warning");
1589 }
1590 info!(
1591 keys = catalog.keys.len(),
1592 backends = catalog.backends.len(),
1593 "loaded catalog + policy",
1594 );
1595
1596 let creds = unlock::open_bundle_at_startup(&setup.bundle, &setup.unlock)
1598 .context("unlocking sealed credential bundle")?;
1599 info!(
1600 backend_creds = creds.backends.len(),
1601 "sealed bundle unlocked",
1602 );
1603
1604 let defaults = BackendDefaults {
1606 vault_addr: &setup.vault_addr,
1607 transit_mount: &setup.transit_mount,
1608 jwt_auth_mount: &setup.jwt_auth_mount,
1609 jwt_role: &setup.jwt_role,
1610 jwt_audience: &setup.jwt_audience,
1611 svid_ttl: Duration::from_secs(setup.svid_ttl_secs),
1612 #[cfg(feature = "db-keystore")]
1613 db_keystore_cipher: &setup.db_keystore_cipher,
1614 #[cfg(feature = "keystore-backend")]
1615 onepassword_provider_uri: &setup.onepassword_provider_uri,
1616 #[cfg(feature = "keystore-backend")]
1617 onepassword_project: &setup.onepassword_project,
1618 #[cfg(feature = "keystore-backend")]
1619 onepassword_profile: &setup.onepassword_profile,
1620 };
1621 let (manager, backend_label) = build_manager(&defaults, catalog, &creds).await?;
1622 let catalog = manager.catalog();
1623 drop(creds); Ok(Prepared {
1626 catalog,
1627 policy,
1628 config,
1629 manager,
1630 backend_label,
1631 })
1632}
1633
1634fn enforce_startup_capabilities(catalog: &crate::Catalog, policy: CapabilityPolicy) -> Result<()> {
1635 let cap = enforce_capabilities(catalog, policy).context("backend capability check failed")?;
1636 info!(
1637 policy = %policy,
1638 enforced = cap.enforced,
1639 skipped_undeclared = cap.skipped_undeclared,
1640 warnings = cap.warnings,
1641 "capability check complete",
1642 );
1643 Ok(())
1644}
1645
1646async fn run_daemon(args: RunArgs) -> Result<()> {
1649 let run_config = load_run_config(&args.overrides)?;
1650 let Prepared {
1653 catalog,
1654 policy,
1655 config,
1656 manager,
1657 backend_label,
1658 } = prepare_manager(&run_config.setup).await?;
1659
1660 validate_invocation_catalog_bindings(&run_config.invocation, &catalog)
1661 .context("validating invocation broker identity and keys")?;
1662
1663 enforce_startup_capabilities(&catalog, run_config.setup.capability_policy)?;
1668
1669 if run_config.no_reconcile {
1674 warn!("startup reconcile skipped (no-reconcile); missing keys will fail at request time");
1675 } else {
1676 let summary = manager
1677 .reconcile()
1678 .await
1679 .context("reconciling catalog against backends")?;
1680 info!(
1681 present = summary.present,
1682 generated = summary.generated,
1683 warned = summary.warned,
1684 "catalog reconcile complete",
1685 );
1686 }
1687
1688 let limits = BrokerLimits {
1689 max_encrypt_size: run_config.max_encrypt_size,
1690 max_payload_size: run_config.max_payload_size,
1691 grace_versions: run_config.grace_versions,
1692 svid_ttl_secs: run_config.setup.svid_ttl_secs,
1693 retain_versions: run_config.retain_versions,
1694 };
1695 let jwt_revocations = JwtRevocationStore::load_from_manager(&manager)
1696 .await
1697 .context("loading JWT-SVID revocation deny-list")?;
1698 let mut state =
1699 BrokerState::with_limits(catalog, policy, config, manager, backend_label, limits)
1700 .with_jwt_revocations(jwt_revocations)
1701 .with_reload_inputs(ReloadInputs {
1704 catalog_path: run_config.setup.catalog.clone(),
1705 policy_path: run_config.setup.policy.clone(),
1706 });
1707
1708 let audit_reopen = if let Some(audit_path) = &run_config.audit_log {
1712 let audit = Arc::new(
1713 AuditLog::open(audit_path)
1714 .with_context(|| format!("opening audit log at {}", audit_path.display()))?,
1715 );
1716 info!(path = %audit_path.display(), "JSONL audit log enabled");
1717 state = state.with_audit_log(Arc::clone(&audit));
1718 Some(audit)
1719 } else {
1720 None
1721 };
1722
1723 let state = Arc::new(state);
1724 spawn_sighup_handler(Arc::clone(&state), audit_reopen);
1725 spawn_retention_sweep(Arc::clone(&state), run_config.retention_sweep_secs);
1726
1727 let socket_path = run_config
1728 .socket
1729 .unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
1730 let server_config = ServerConfig {
1731 socket_path,
1732 socket_mode: run_config.socket_mode,
1733 socket_group: run_config.socket_group,
1734 invocation: run_config.invocation,
1735 };
1736
1737 #[cfg(feature = "http")]
1745 let jwks_shutdown = run_config.jwks.enable.then(|| {
1746 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1747 let http_state = Arc::clone(&state);
1748 let listen = run_config.jwks.listen;
1749 let http_config = crate::jwks::JwksHttpConfig {
1750 issuer: run_config.jwks.issuer.clone(),
1751 tls: run_config.jwks.tls.clone(),
1752 };
1753 let handle = tokio::spawn(async move {
1754 let shutdown = async {
1755 let _ = rx.await;
1756 };
1757 if let Err(err) = crate::jwks::serve(http_state, listen, http_config, shutdown).await {
1758 warn!(error = %err, "JWKS HTTP surface terminated with error");
1759 }
1760 });
1761 (tx, handle)
1762 });
1763
1764 let grpc_result = run_grpc(server_config, state).await;
1765
1766 #[cfg(feature = "http")]
1767 if let Some((tx, handle)) = jwks_shutdown {
1768 let _ = tx.send(());
1771 if let Err(err) = handle.await {
1772 warn!(error = %err, "JWKS HTTP surface task join failed");
1773 }
1774 }
1775
1776 grpc_result?;
1777 Ok(())
1778}
1779
1780fn spawn_sighup_handler(state: Arc<BrokerState>, audit: Option<Arc<AuditLog>>) {
1797 let handle = tokio::spawn(async move {
1798 let mut hangup = match signal(SignalKind::hangup()) {
1799 Ok(signal) => signal,
1800 Err(err) => {
1801 warn!(error = %err, "SIGHUP handler disabled");
1802 return;
1803 }
1804 };
1805 while hangup.recv().await.is_some() {
1806 handle_sighup_reload(&state).await;
1809 if let Some(audit) = &audit {
1811 audit.request_reopen();
1812 info!("SIGHUP: requested audit log reopen");
1813 }
1814 }
1815 });
1816 std::mem::drop(handle);
1817}
1818
1819async fn handle_sighup_reload(state: &BrokerState) {
1823 match reload_generation(state) {
1824 Ok(outcome) => {
1825 info!(
1826 previous_generation = outcome.previous_generation,
1827 generation = outcome.new_generation,
1828 keys = outcome.key_count,
1829 grants = outcome.grant_count,
1830 "SIGHUP: catalog/policy reload applied",
1831 );
1832 state.record_reload(
1833 outcome.previous_generation,
1834 outcome.new_generation,
1835 "applied",
1836 "signal",
1837 ReloadActor::Sighup,
1838 );
1839 if let Err(err) = state.refresh_jwt_revocations().await {
1840 warn!(
1841 error = %err,
1842 generation = outcome.new_generation,
1843 "SIGHUP: JWT-SVID revocation deny-list refresh failed; previous in-memory set still serving",
1844 );
1845 state.record_reload(
1846 outcome.new_generation,
1847 outcome.new_generation,
1848 "revocation_refresh_failed",
1849 "signal",
1850 ReloadActor::Sighup,
1851 );
1852 }
1853 }
1854 Err(err) => {
1855 let active = state.active_generation_id();
1856 warn!(
1857 error = %err,
1858 generation = active,
1859 "SIGHUP: catalog/policy reload rejected; previous generation still serving",
1860 );
1861 state.record_reload(
1862 active,
1863 active,
1864 "rejected",
1865 err.audit_reason(),
1866 ReloadActor::Sighup,
1867 );
1868 }
1869 }
1870}
1871
1872fn spawn_retention_sweep(state: Arc<BrokerState>, interval_secs: u64) {
1873 let limits = state.limits();
1874 if limits.retain_versions.is_none() || interval_secs == 0 {
1875 return;
1876 }
1877 let handle = tokio::spawn(async move {
1878 let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
1879 loop {
1880 interval.tick().await;
1881 if let Err(err) = state.manager().sweep_all_retention(limits).await {
1882 warn!(error = %err, "retention sweep failed");
1883 }
1884 }
1885 });
1886 std::mem::drop(handle);
1887}
1888
1889fn load_doctor_inputs(overrides: &ConfigOverrides) -> Result<doctor::DoctorInputs> {
1894 let file = load_config_file(overrides)?;
1895 let invocation = resolve_invocation_config(&file.broker_identity, &file.invocation)?;
1896 let setup = build_setup(&file, overrides)?;
1897 let socket = overrides
1898 .socket
1899 .clone()
1900 .or(file.socket)
1901 .unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
1902 let socket_mode = file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0);
1903 let capability_policy = setup.capability_policy;
1904 let unlock_passphrase_selected = setup.unlock.passphrase_file.is_some();
1905 let unlock_bip39_selected = setup.unlock.bip39_phrase_file.is_some();
1906 let unlock_age_yubikey_selected = setup.unlock.age_yubikey;
1907
1908 Ok(doctor::DoctorInputs {
1909 catalog: setup.catalog,
1910 policy: setup.policy,
1911 bundle: setup.bundle,
1912 socket,
1913 socket_mode,
1914 socket_group: file.socket_group,
1915 capability_policy,
1916 invocation,
1917 unlock_passphrase_selected,
1918 unlock_bip39_selected,
1919 unlock_age_yubikey_selected,
1920 })
1921}
1922
1923async fn run_doctor(args: DoctorArgs) -> Result<()> {
1929 let inputs = load_doctor_inputs(&args.overrides)?;
1930 let mut report = doctor::run_doctor(&inputs, doctor::EnabledFeatures::current()).await;
1931 if args.keys {
1932 let mut key_rows = doctor_key_material_rows(&args.overrides).await;
1933 report.checks.append(&mut key_rows);
1934 report = doctor::DoctorReport::from_checks(report.checks);
1935 }
1936
1937 if args.json {
1938 println!("{}", doctor::render_json(&report)?);
1939 } else {
1940 print!("{}", doctor::render_human(&report));
1941 }
1942
1943 if report.should_exit_nonzero(args.strict) {
1944 std::process::exit(1);
1947 }
1948 Ok(())
1949}
1950
1951async fn doctor_key_material_rows(overrides: &ConfigOverrides) -> Vec<doctor::CheckResult> {
1956 let setup = match load_key_probe_config(overrides) {
1957 Ok(setup) => setup,
1958 Err(err) => {
1959 return doctor::key_material_rows(Err(format!(
1960 "could not resolve key-probe configuration: {err:#}"
1961 )));
1962 }
1963 };
1964 let Prepared { manager, .. } = match prepare_manager(&setup).await {
1965 Ok(prepared) => prepared,
1966 Err(err) => {
1967 return doctor::key_material_rows(Err(format!(
1968 "could not build authenticated backend manager: {err:#}"
1969 )));
1970 }
1971 };
1972 match manager.check().await {
1973 Ok(report) => doctor::key_material_rows(Ok(&report)),
1974 Err(err) => doctor::key_material_rows(Err(err.to_string())),
1975 }
1976}
1977
1978pub fn run_explain(args: &ExplainArgs) -> Result<()> {
1988 use crate::catalog::Pdp;
1989
1990 let file = load_config_file(&args.overrides)?;
1991 let catalog_path = required_path(
1992 args.overrides
1993 .catalog
1994 .clone()
1995 .or_else(|| file.catalog.clone()),
1996 "catalog",
1997 )?;
1998 let policy_path = required_path(
1999 args.overrides
2000 .policy
2001 .clone()
2002 .or_else(|| file.policy.clone()),
2003 "policy",
2004 )?;
2005
2006 let catalog_json = std::fs::read_to_string(&catalog_path)
2007 .with_context(|| format!("reading catalog from {}", catalog_path.display()))?;
2008 let policy_json = std::fs::read_to_string(&policy_path)
2009 .with_context(|| format!("reading policy from {}", policy_path.display()))?;
2010 let (catalog, policy, config, warnings) =
2011 load(&catalog_json, &policy_json).context("loading catalog/policy")?;
2012 for w in &warnings {
2013 warn!(warning = %w, "catalog/policy load warning");
2014 }
2015
2016 let pdp = Pdp::new(&catalog, &policy, &config);
2017
2018 if args.effective {
2019 return print_effective(&pdp, &args.subject, args.json);
2020 }
2021
2022 let (Some(op), Some(key)) = (args.op, args.key.as_deref()) else {
2025 bail!("--op and --key are required unless --effective is given");
2026 };
2027 print_explanation(&pdp, &args.subject, op, key, args.json)
2028}
2029
2030fn print_effective(pdp: &crate::catalog::Pdp, subject: &str, json: bool) -> Result<()> {
2032 let mut out = std::io::stdout().lock();
2033 render_effective(&mut out, pdp, subject, json)
2034}
2035
2036fn render_effective(
2042 out: &mut impl std::io::Write,
2043 pdp: &crate::catalog::Pdp,
2044 subject: &str,
2045 json: bool,
2046) -> Result<()> {
2047 let grants = pdp.effective(subject);
2048 if json {
2049 let rows: Vec<serde_json::Value> = grants
2050 .iter()
2051 .map(|g| {
2052 serde_json::json!({
2053 "key": g.key,
2054 "op": g.op.token(),
2055 "via": allow_via_json(&g.via),
2056 "rule": g.rule_id,
2057 })
2058 })
2059 .collect();
2060 let doc = serde_json::json!({ "subject": subject, "effective": rows });
2061 writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
2062 return Ok(());
2063 }
2064 writeln!(
2065 out,
2066 "effective permissions for subject {subject}: {} grant(s)",
2067 grants.len()
2068 )?;
2069 for g in &grants {
2070 let rule = g.rule_id.as_deref().unwrap_or("<public-class>");
2071 writeln!(
2072 out,
2073 " ALLOW {} {} via {} [{rule}]",
2074 g.op.token(),
2075 g.key,
2076 allow_via_json(&g.via),
2077 )?;
2078 }
2079 if grants.is_empty() {
2080 writeln!(out, " (none: default-deny)")?;
2081 }
2082 Ok(())
2083}
2084
2085fn print_explanation(
2088 pdp: &crate::catalog::Pdp,
2089 subject: &str,
2090 op: crate::catalog::Op,
2091 key: &str,
2092 json: bool,
2093) -> Result<()> {
2094 let mut out = std::io::stdout().lock();
2095 render_explanation(&mut out, pdp, subject, op, key, json)
2096}
2097
2098fn render_explanation(
2103 out: &mut impl std::io::Write,
2104 pdp: &crate::catalog::Pdp,
2105 subject: &str,
2106 op: crate::catalog::Op,
2107 key: &str,
2108 json: bool,
2109) -> Result<()> {
2110 use crate::catalog::Decision;
2111 let ex = pdp.explain_subject(subject, op, key);
2112
2113 if json {
2114 let mut obj = serde_json::Map::new();
2115 obj.insert("subject".into(), subject.into());
2116 obj.insert("op".into(), op.token().into());
2117 obj.insert("key".into(), key.into());
2118 match &ex.decision {
2119 Decision::Allow { via } => {
2120 obj.insert("decision".into(), "allow".into());
2121 obj.insert("via".into(), allow_via_json(via).into());
2122 let matched = ex.matched.as_ref().map_or(serde_json::Value::Null, |m| {
2123 serde_json::json!({
2124 "rule": m.rule_id,
2125 "via": allow_via_json(&m.via),
2126 "subject": m.subject,
2127 "action": m.action,
2128 "target": m.target,
2129 })
2130 });
2131 obj.insert("matched_rule".into(), matched);
2132 }
2133 Decision::Deny { reason } => {
2134 obj.insert("decision".into(), "deny".into());
2135 obj.insert("reason".into(), deny_reason_json(*reason).into());
2136 }
2137 }
2138 writeln!(
2139 out,
2140 "{}",
2141 serde_json::to_string_pretty(&serde_json::Value::Object(obj))?
2142 )?;
2143 return Ok(());
2144 }
2145
2146 match &ex.decision {
2147 Decision::Allow { via } => {
2148 writeln!(
2149 out,
2150 "ALLOW subject {subject} {} {key} (via {})",
2151 op.token(),
2152 allow_via_json(via)
2153 )?;
2154 match &ex.matched {
2155 Some(m) => writeln!(
2156 out,
2157 " matched subject `{}` (rule `{}`): action `{}` over target `{}`",
2158 m.subject, m.rule_id, m.action, m.target
2159 )?,
2160 None => {
2161 writeln!(
2162 out,
2163 " matched the world-readable public-class rule (no policy rule needed)"
2164 )?;
2165 }
2166 }
2167 }
2168 Decision::Deny { reason } => {
2169 writeln!(
2170 out,
2171 "DENY subject {subject} {} {key} ({})",
2172 op.token(),
2173 deny_reason_json(*reason)
2174 )?;
2175 writeln!(out, " {}", deny_explanation(*reason))?;
2176 }
2177 }
2178 Ok(())
2179}
2180
2181fn allow_via_json(via: &crate::catalog::AllowVia) -> String {
2183 use crate::catalog::AllowVia;
2184 match via {
2185 AllowVia::Subject(subject) => format!("subject:{subject}"),
2186 AllowVia::PublicClass => "public_class".to_string(),
2187 }
2188}
2189
2190const fn deny_reason_json(reason: crate::catalog::DenyReason) -> &'static str {
2192 use crate::catalog::DenyReason;
2193 match reason {
2194 DenyReason::UnknownKey => "unknown_key",
2195 DenyReason::NotWritable => "not_writable",
2196 DenyReason::NotPermitted => "not_permitted",
2197 }
2198}
2199
2200const fn deny_explanation(reason: crate::catalog::DenyReason) -> &'static str {
2202 use crate::catalog::DenyReason;
2203 match reason {
2204 DenyReason::UnknownKey => "key is not in the catalog",
2205 DenyReason::NotWritable => {
2206 "the key is not writable (write hard-cap), denied regardless of policy"
2207 }
2208 DenyReason::NotPermitted => "no policy grant matches this (subject, op, key): default-deny",
2209 }
2210}
2211
2212pub async fn run_agent(args: RunArgs) -> Result<()> {
2214 let overrides = args.overrides.clone();
2215 Box::pin(run_with_config_logging(&overrides, run_daemon(args))).await
2218}
2219
2220pub async fn run_doctor_command(args: DoctorArgs) -> Result<()> {
2222 let overrides = args.overrides.clone();
2223 if args.json {
2224 let mut logging = logging_config_for_overrides(&overrides)?;
2225 logging.stdout.enable = Some(false);
2226 let logging_guards = init_logging(&logging)?;
2227 let result = run_doctor(args).await;
2228 logging_guards.shutdown();
2229 result
2230 } else {
2231 Box::pin(run_with_config_logging(&overrides, run_doctor(args))).await
2232 }
2233}
2234
2235pub fn run_init(socket: Option<&str>, args: &init::InitArgs) -> Result<()> {
2241 let logging_guards = init_logging(&LoggingConfigFile::default())?;
2242 let result = init::run(args, socket);
2243 logging_guards.shutdown();
2244 result
2245}
2246
2247pub fn run_bundle(command: bundle_cli::BundleCommand) -> Result<()> {
2249 let logging_guards = init_logging(&LoggingConfigFile::default())?;
2250 let result = bundle_cli::run(command);
2251 logging_guards.shutdown();
2252 result
2253}
2254
2255async fn run_with_config_logging(
2256 overrides: &ConfigOverrides,
2257 fut: impl std::future::Future<Output = Result<()>>,
2258) -> Result<()> {
2259 let logging = logging_config_for_overrides(overrides)?;
2260 let logging_guards = init_logging(&logging)?;
2261 let result = fut.await;
2262 logging_guards.shutdown();
2263 result
2264}
2265
2266fn logging_config_for_overrides(overrides: &ConfigOverrides) -> Result<LoggingConfigFile> {
2267 Ok(load_config_file(overrides)?.logging)
2268}
2269
2270#[cfg(test)]
2271mod tests {
2272 use std::path::Path;
2273
2274 use super::*;
2275 use crate::catalog::MissingPolicy;
2276 use clap::Parser as _;
2277
2278 fn temp_config(contents: &str) -> PathBuf {
2279 let path = std::env::temp_dir().join(format!(
2280 "basil-agent-config-test-{}-{}.toml",
2281 std::process::id(),
2282 uuid::Uuid::new_v4()
2283 ));
2284 std::fs::write(&path, contents).expect("write temp config");
2285 path
2286 }
2287
2288 fn overrides_for(config: PathBuf) -> ConfigOverrides {
2289 ConfigOverrides {
2290 config: Some(config),
2291 catalog: None,
2292 policy: None,
2293 bundle: None,
2294 socket: None,
2295 vault_addr: None,
2296 }
2297 }
2298
2299 fn catalog_with_invocation_keys(
2300 response_signing: crate::catalog::KeyEntry,
2301 request_encryption: crate::catalog::KeyEntry,
2302 ) -> crate::Catalog {
2303 let mut backends = BTreeMap::new();
2304 backends.insert(
2305 "bao".to_string(),
2306 crate::catalog::BackendRef {
2307 kind: crate::catalog::BackendKind::Vault,
2308 addr: "http://127.0.0.1:8200".to_string(),
2309 engines: Vec::new(),
2310 capabilities: Vec::new(),
2311 mint_key_types: Vec::new(),
2312 requires: Vec::new(),
2313 },
2314 );
2315 let mut keys = BTreeMap::new();
2316 keys.insert("broker.response".to_string(), response_signing);
2317 keys.insert("broker.request".to_string(), request_encryption);
2318 crate::Catalog {
2319 schema_version: 1,
2320 backends,
2321 keys,
2322 }
2323 }
2324
2325 fn catalog_key(
2326 class: Class,
2327 key_type: Option<KeyAlgorithm>,
2328 labels: &[&str],
2329 ) -> crate::catalog::KeyEntry {
2330 crate::catalog::KeyEntry {
2331 class,
2332 key_type,
2333 backend: "bao".to_string(),
2334 engine: None,
2335 path: "path".to_string(),
2336 public_path: matches!(class, Class::Sealing).then(|| "public/path".to_string()),
2337 writable: false,
2338 missing: MissingPolicy::Error,
2339 generate: None,
2340 sealing_pin: None,
2341 labels: crate::catalog::Labels(labels.iter().map(ToString::to_string).collect()),
2342 description: "test key".to_string(),
2343 }
2344 }
2345
2346 #[cfg(feature = "keystore-backend")]
2347 #[test]
2348 fn run_config_file_supplies_all_startup_settings() {
2349 let config = temp_config(
2350 r#"
2351catalog = "/cfg/catalog.json"
2352policy = "/cfg/policy.json"
2353bundle = "/cfg/bundle.sealed"
2354socket = "/run/basil.sock"
2355vault-addr = "http://vault.internal:8200"
2356transit-mount = "basil-transit"
2357jwt-auth-mount = "jwt-basil"
2358jwt-role = "basil-agent"
2359jwt-audience = "openbao-prod"
2360svid-ttl-secs = 120
2361capability-policy = "degraded"
2362db-keystore-cipher = "aegis256"
2363onepassword-provider-uri = "onepassword://basil"
2364onepassword-project = "prod"
2365onepassword-profile = "agent"
2366max-encrypt-size = 4096
2367max-payload-size = 8192
2368grace-versions = 2
2369retain-versions = 5
2370retention-sweep-secs = 60
2371audit-log = "/var/log/basil/audit.jsonl"
2372no-reconcile = true
2373
2374[logging.stdout]
2375enable = false
2376
2377[logging.journald]
2378enable = true
2379
2380[logging.file]
2381enable = true
2382dir = "/var/log/basil"
2383prefix = "agent-"
2384rotation = "weekly"
2385
2386[logging.opentelemetry]
2387enable = true
2388endpoint = "http://localhost:4317"
2389protocol = "grpc"
2390
2391[unlock]
2392age-yubikey = true
2393bip39-phrase-file = "/secure/recovery.txt"
2394unlock-tpm = true
2395unlock-passphrase-file = "/secure/passphrase.txt"
2396unlock-passphrase-no-wipe = true
2397strict-bundle-perms = true
2398"#,
2399 );
2400 let args = ConfigOverrides {
2401 config: Some(config.clone()),
2402 catalog: None,
2403 policy: None,
2404 bundle: None,
2405 socket: None,
2406 vault_addr: None,
2407 };
2408
2409 let loaded_file = load_config_file(&args).expect("load config file");
2410 let loaded = load_run_config(&args).expect("load run config");
2411
2412 assert_eq!(loaded.setup.catalog, PathBuf::from("/cfg/catalog.json"));
2413 assert_eq!(loaded.setup.policy, PathBuf::from("/cfg/policy.json"));
2414 assert_eq!(loaded.setup.bundle, PathBuf::from("/cfg/bundle.sealed"));
2415 assert_eq!(loaded.socket.as_deref(), Some("/run/basil.sock"));
2416 assert_eq!(loaded.setup.vault_addr, "http://vault.internal:8200");
2417 assert_eq!(loaded.setup.transit_mount, "basil-transit");
2418 assert_eq!(loaded.setup.jwt_auth_mount, "jwt-basil");
2419 assert_eq!(loaded.setup.jwt_role, "basil-agent");
2420 assert_eq!(loaded.setup.jwt_audience, "openbao-prod");
2421 assert_eq!(loaded.setup.svid_ttl_secs, 120);
2422 assert_eq!(loaded.setup.capability_policy, CapabilityPolicy::Degraded);
2423 #[cfg(feature = "db-keystore")]
2424 assert_eq!(loaded.setup.db_keystore_cipher, "aegis256");
2425 assert_eq!(loaded.setup.onepassword_provider_uri, "onepassword://basil");
2426 assert_eq!(loaded.setup.onepassword_project, "prod");
2427 assert_eq!(loaded.setup.onepassword_profile, "agent");
2428 assert_eq!(loaded.max_encrypt_size, 4096);
2429 assert_eq!(loaded.max_payload_size, 8192);
2430 assert_eq!(loaded.grace_versions, 2);
2431 assert_eq!(loaded.retain_versions, Some(5));
2432 assert_eq!(loaded.retention_sweep_secs, 60);
2433 assert_eq!(
2434 loaded.audit_log.as_deref(),
2435 Some(Path::new("/var/log/basil/audit.jsonl"))
2436 );
2437 assert!(loaded.no_reconcile);
2438 assert_eq!(loaded_file.logging.stdout.enable, Some(false));
2439 assert!(!loaded_file.logging.stdout_enabled());
2440 assert!(loaded_file.logging.journald.enable);
2441 assert!(loaded_file.logging.file.enable);
2442 assert_eq!(
2443 loaded_file.logging.file.dir.as_deref(),
2444 Some(Path::new("/var/log/basil"))
2445 );
2446 assert_eq!(loaded_file.logging.file.prefix, "agent-");
2447 assert_eq!(
2448 loaded_file.logging.file.rotation,
2449 FileLoggingRotation::Weekly
2450 );
2451 assert!(loaded_file.logging.opentelemetry.enable);
2452 assert_eq!(
2453 (
2454 loaded_file.logging.opentelemetry.endpoint.as_deref(),
2455 loaded_file.logging.opentelemetry.protocol
2456 ),
2457 (Some("http://localhost:4317"), OpenTelemetryProtocol::Grpc)
2458 );
2459 assert!(loaded.setup.unlock.age_yubikey);
2460 assert!(loaded.setup.unlock.tpm);
2461 assert_eq!(
2462 loaded.setup.unlock.bip39_phrase_file.as_deref(),
2463 Some(Path::new("/secure/recovery.txt"))
2464 );
2465 assert_eq!(
2466 loaded.setup.unlock.passphrase_file.as_deref(),
2467 Some(Path::new("/secure/passphrase.txt"))
2468 );
2469 assert!(loaded.setup.unlock.passphrase_no_wipe);
2470 assert!(loaded.setup.unlock.strict_bundle_perms);
2471
2472 std::fs::remove_file(config).expect("remove temp config");
2473 }
2474
2475 #[test]
2476 fn socket_mode_config_accepts_octal_string_and_rejects_bad_values() {
2477 assert_eq!(parse_socket_mode("0600").expect("0600 parses"), 0o600);
2478 assert_eq!(parse_socket_mode("0o660").expect("0o660 parses"), 0o660);
2479 assert!(parse_socket_mode("").is_err());
2480 assert!(parse_socket_mode("0888").is_err());
2481 assert!(parse_socket_mode("10000").is_err());
2482 }
2483
2484 #[test]
2485 fn run_config_file_supplies_socket_ownership_settings() {
2486 let config = temp_config(
2487 r#"
2488catalog = "/cfg/catalog.json"
2489policy = "/cfg/policy.json"
2490bundle = "/cfg/bundle.sealed"
2491socket-mode = "0660"
2492socket-group = "basil-edge"
2493"#,
2494 );
2495 let args = ConfigOverrides {
2496 config: Some(config.clone()),
2497 catalog: None,
2498 policy: None,
2499 bundle: None,
2500 socket: None,
2501 vault_addr: None,
2502 };
2503
2504 let loaded = load_run_config(&args).expect("load run config");
2505
2506 assert_eq!(loaded.socket_mode, 0o660);
2507 assert_eq!(loaded.socket_group.as_deref(), Some("basil-edge"));
2508
2509 std::fs::remove_file(config).expect("remove temp config");
2510 }
2511
2512 #[test]
2513 fn socket_mode_defaults_to_owner_only() {
2514 let config = temp_config(
2515 r#"
2516catalog = "/cfg/catalog.json"
2517policy = "/cfg/policy.json"
2518bundle = "/cfg/bundle.sealed"
2519"#,
2520 );
2521 let args = ConfigOverrides {
2522 config: Some(config.clone()),
2523 catalog: None,
2524 policy: None,
2525 bundle: None,
2526 socket: None,
2527 vault_addr: None,
2528 };
2529
2530 let loaded = load_run_config(&args).expect("load run config");
2531
2532 assert_eq!(loaded.socket_mode, DEFAULT_SOCKET_MODE);
2533 assert!(loaded.socket_group.is_none());
2534
2535 std::fs::remove_file(config).expect("remove temp config");
2536 }
2537
2538 #[test]
2539 fn logging_defaults_stdout_and_journald_on_otel_off() {
2540 let config = AgentConfigFile::default();
2541 assert_eq!(config.logging.stdout.enable, None);
2542 assert!(config.logging.stdout_enabled());
2543 assert!(config.logging.journald.enable);
2544 assert!(!config.logging.file.enable);
2545 assert!(config.logging.file.dir.is_none());
2546 assert_eq!(config.logging.file.prefix, "basil-agent-");
2547 assert_eq!(config.logging.file.rotation, FileLoggingRotation::Daily);
2548 assert!(!config.logging.opentelemetry.enable);
2549 assert_eq!(
2550 config.logging.opentelemetry.protocol,
2551 OpenTelemetryProtocol::Grpc
2552 );
2553 assert!(config.logging.opentelemetry.endpoint.is_none());
2554 }
2555
2556 #[test]
2557 fn stdout_defaults_off_when_file_logging_enabled() {
2558 let config: AgentConfigFile = toml::from_str(
2559 r#"
2560[logging.file]
2561enable = true
2562dir = "/var/log/basil"
2563"#,
2564 )
2565 .expect("parse config");
2566 assert_eq!(config.logging.stdout.enable, None);
2567 assert!(!config.logging.stdout_enabled());
2568 }
2569
2570 #[test]
2571 fn stdout_explicit_config_overrides_file_logging_default() {
2572 let stdout_on: AgentConfigFile = toml::from_str(
2573 r#"
2574[logging.stdout]
2575enable = true
2576
2577[logging.file]
2578enable = true
2579dir = "/var/log/basil"
2580"#,
2581 )
2582 .expect("parse stdout-on config");
2583 assert_eq!(stdout_on.logging.stdout.enable, Some(true));
2584 assert!(stdout_on.logging.stdout_enabled());
2585
2586 let stdout_off: AgentConfigFile = toml::from_str(
2587 r#"
2588[logging.stdout]
2589enable = false
2590"#,
2591 )
2592 .expect("parse stdout-off config");
2593 assert_eq!(stdout_off.logging.stdout.enable, Some(false));
2594 assert!(!stdout_off.logging.stdout_enabled());
2595 }
2596
2597 #[test]
2598 fn file_logging_path_alias_populates_dir() {
2599 let config: AgentConfigFile = toml::from_str(
2600 r#"
2601[logging.file]
2602enable = true
2603path = "/var/log/basil"
2604"#,
2605 )
2606 .expect("parse file config");
2607 assert_eq!(
2608 config.logging.file.dir.as_deref(),
2609 Some(Path::new("/var/log/basil"))
2610 );
2611 }
2612
2613 #[test]
2614 fn file_logging_requires_dir_when_enabled() {
2615 let config: AgentConfigFile = toml::from_str(
2616 r#"
2617[logging.file]
2618enable = true
2619"#,
2620 )
2621 .expect("parse file config");
2622 let err = build_file_writer(&config.logging.file).expect_err("missing dir rejects");
2623 assert!(
2624 err.to_string()
2625 .contains("logging.file.dir is required when logging.file.enable=true")
2626 );
2627 }
2628
2629 #[test]
2630 fn file_logging_constructs_non_blocking_writer() {
2631 let dir = std::env::temp_dir().join(format!(
2632 "basil-agent-log-test-{}-{}",
2633 std::process::id(),
2634 uuid::Uuid::new_v4()
2635 ));
2636 std::fs::create_dir(&dir).expect("create log dir");
2637 let config = FileLoggingConfigFile {
2638 enable: true,
2639 dir: Some(dir.clone()),
2640 prefix: "agent-test".to_owned(),
2641 rotation: FileLoggingRotation::None,
2642 };
2643 let (writer, guard) = build_file_writer(&config).expect("build file writer");
2644 assert!(writer.is_some());
2645 drop(writer);
2646 drop(guard);
2647 std::fs::remove_dir_all(dir).expect("remove log dir");
2648 }
2649
2650 #[test]
2651 fn journald_disabled_produces_no_sink() {
2652 assert!(matches!(journald_sink(false), JournaldSink::Disabled));
2653 }
2654
2655 #[test]
2656 fn journald_without_socket_is_non_fatal_without_stderr_sink() {
2657 match journald_sink(true) {
2663 JournaldSink::Active(_) | JournaldSink::Unavailable(_) => {}
2664 JournaldSink::Disabled => panic!("enabled journald must not resolve to Disabled"),
2665 }
2666 }
2667
2668 #[cfg(feature = "http")]
2669 #[test]
2670 fn jwks_http_surface_is_disabled_by_default_no_port_opened() {
2671 let defaults = JwksConfigFile::default();
2676 assert!(!defaults.enable, "JWKS surface must default to disabled");
2677 assert_eq!(defaults.listen, "127.0.0.1:8201");
2678
2679 let config = temp_config(
2680 r#"
2681catalog = "/cfg/catalog.json"
2682policy = "/cfg/policy.json"
2683bundle = "/cfg/bundle.sealed"
2684"#,
2685 );
2686 let args = ConfigOverrides {
2687 config: Some(config.clone()),
2688 catalog: None,
2689 policy: None,
2690 bundle: None,
2691 socket: None,
2692 vault_addr: None,
2693 };
2694 let loaded = load_run_config(&args).expect("load run config");
2695 assert!(
2696 !loaded.jwks.enable,
2697 "default config leaves the JWKS port closed"
2698 );
2699 assert_eq!(
2700 loaded.jwks.listen,
2701 "127.0.0.1:8201".parse().expect("default listen")
2702 );
2703
2704 std::fs::remove_file(config).expect("remove temp config");
2705 }
2706
2707 #[test]
2708 fn invocation_service_requires_explicit_config_enable() {
2709 let defaults = InvocationConfigFile::default();
2710 assert!(
2711 !defaults.enable,
2712 "InvocationService must default to rejecting requests"
2713 );
2714 assert_eq!(defaults.max_ttl_secs, 60);
2715 assert_eq!(defaults.clock_skew_secs, 30);
2716 assert_eq!(defaults.replay_cache_capacity, 4096);
2717
2718 let config = temp_config(
2719 r#"
2720catalog = "/cfg/catalog.json"
2721policy = "/cfg/policy.json"
2722bundle = "/cfg/bundle.sealed"
2723"#,
2724 );
2725 let args = overrides_for(config.clone());
2726 let loaded = load_run_config(&args).expect("load run config");
2727 assert!(!loaded.invocation.enabled);
2728 assert!(loaded.invocation.broker_identity.is_none());
2729 assert!(loaded.invocation.request_encryption_key_id.is_none());
2730 std::fs::remove_file(config).expect("remove temp config");
2731
2732 let config = temp_config(
2733 r#"
2734catalog = "/cfg/catalog.json"
2735policy = "/cfg/policy.json"
2736bundle = "/cfg/bundle.sealed"
2737
2738[broker-identity]
2739id = "basil://prod/us-east-1/agent-a"
2740response-signing-key-id = "broker.response"
2741
2742[invocation]
2743enable = true
2744audience = ["basil://prod/us-east-1/agent-a"]
2745request-encryption-key-id = "broker.request"
2746max-ttl-secs = 45
2747clock-skew-secs = 7
2748replay-cache-capacity = 128
2749"#,
2750 );
2751 let args = overrides_for(config.clone());
2752 let loaded = load_run_config(&args).expect("load run config");
2753 assert!(loaded.invocation.enabled);
2754 let identity = loaded
2755 .invocation
2756 .broker_identity
2757 .as_ref()
2758 .expect("broker identity");
2759 assert_eq!(identity.id, "basil://prod/us-east-1/agent-a");
2760 assert_eq!(identity.response_signing_key_id, "broker.response");
2761 assert_eq!(
2762 loaded.invocation.request_encryption_key_id.as_deref(),
2763 Some("broker.request")
2764 );
2765 assert_eq!(
2766 loaded.invocation.audiences,
2767 vec!["basil://prod/us-east-1/agent-a".to_string()]
2768 );
2769 assert_eq!(loaded.invocation.max_ttl_secs, 45);
2770 assert_eq!(loaded.invocation.clock_skew_secs, 7);
2771 assert_eq!(loaded.invocation.replay_cache_capacity, 128);
2772
2773 std::fs::remove_file(config).expect("remove temp config");
2774
2775 let config = temp_config(
2776 r#"
2777catalog = "/cfg/catalog.json"
2778policy = "/cfg/policy.json"
2779bundle = "/cfg/bundle.sealed"
2780
2781[invocation]
2782enable = true
2783"#,
2784 );
2785 let args = overrides_for(config.clone());
2786 let err = load_run_config(&args).expect_err("enabled invocation requires audience");
2787 assert!(
2788 err.to_string()
2789 .contains("invocation.audience must be set when invocation.enable is true")
2790 );
2791
2792 std::fs::remove_file(config).expect("remove temp config");
2793 }
2794
2795 #[test]
2796 fn invocation_config_rejects_missing_keys_invalid_identity_and_bounds() {
2797 let config = temp_config(
2798 r#"
2799catalog = "/cfg/catalog.json"
2800policy = "/cfg/policy.json"
2801bundle = "/cfg/bundle.sealed"
2802
2803[broker-identity]
2804id = "https://not-basil.example/agent-a"
2805response-signing-key-id = "broker.response"
2806
2807[invocation]
2808enable = true
2809audience = ["basil://prod/us-east-1/agent-a"]
2810request-encryption-key-id = "broker.request"
2811"#,
2812 );
2813 let err = load_run_config(&overrides_for(config.clone()))
2814 .expect_err("non-basil broker id rejects");
2815 assert!(
2816 err.to_string()
2817 .contains("broker-identity.id must use the basil:// scheme")
2818 );
2819 std::fs::remove_file(config).expect("remove temp config");
2820
2821 let config = temp_config(
2822 r#"
2823catalog = "/cfg/catalog.json"
2824policy = "/cfg/policy.json"
2825bundle = "/cfg/bundle.sealed"
2826
2827[broker-identity]
2828id = "basil://prod/us-east-1/agent-a"
2829response-signing-key-id = "broker.response"
2830
2831[invocation]
2832enable = true
2833audience = ["basil://prod/us-east-1/agent-a"]
2834max-ttl-secs = 301
2835"#,
2836 );
2837 let err =
2838 load_run_config(&overrides_for(config.clone())).expect_err("excessive ttl rejects");
2839 assert!(
2840 err.to_string()
2841 .contains("invocation.max-ttl-secs must be at most 300 seconds")
2842 );
2843 std::fs::remove_file(config).expect("remove temp config");
2844
2845 let config = temp_config(
2846 r#"
2847catalog = "/cfg/catalog.json"
2848policy = "/cfg/policy.json"
2849bundle = "/cfg/bundle.sealed"
2850
2851[broker-identity]
2852id = "basil://prod/us-east-1/agent-a"
2853response-signing-key-id = "broker.response"
2854
2855[invocation]
2856enable = true
2857audience = ["basil://prod/us-east-1/agent-a"]
2858"#,
2859 );
2860 let err = load_run_config(&overrides_for(config.clone()))
2861 .expect_err("enabled invocation requires request encryption key");
2862 assert!(
2863 err.to_string()
2864 .contains("invocation.request-encryption-key-id is required")
2865 );
2866 std::fs::remove_file(config).expect("remove temp config");
2867 }
2868
2869 #[test]
2870 fn invocation_catalog_bindings_require_expected_class_and_use() {
2871 let invocation = InvocationRuntimeConfig {
2872 enabled: true,
2873 broker_identity: Some(BrokerIdentityRuntimeConfig {
2874 id: "basil://prod/us-east-1/agent-a".to_string(),
2875 response_signing_key_id: "broker.response".to_string(),
2876 }),
2877 audiences: vec!["basil://prod/us-east-1/agent-a".to_string()],
2878 request_encryption_key_id: Some("broker.request".to_string()),
2879 max_ttl_secs: 60,
2880 clock_skew_secs: 30,
2881 replay_cache_capacity: 4096,
2882 now_unix_override: None,
2883 };
2884 let valid = catalog_with_invocation_keys(
2885 catalog_key(
2886 Class::Asymmetric,
2887 Some(KeyAlgorithm::Ed25519),
2888 &["broker_key_use=response-signing"],
2889 ),
2890 catalog_key(
2891 Class::Sealing,
2892 Some(KeyAlgorithm::X25519),
2893 &["broker_key_use=request-encryption"],
2894 ),
2895 );
2896 validate_invocation_catalog_bindings(&invocation, &valid).expect("valid keys pass");
2897
2898 let wrong_response_class = catalog_with_invocation_keys(
2899 catalog_key(
2900 Class::Sealing,
2901 Some(KeyAlgorithm::X25519),
2902 &["broker_key_use=response-signing"],
2903 ),
2904 catalog_key(
2905 Class::Sealing,
2906 Some(KeyAlgorithm::X25519),
2907 &["broker_key_use=request-encryption"],
2908 ),
2909 );
2910 let err = validate_invocation_catalog_bindings(&invocation, &wrong_response_class)
2911 .expect_err("wrong response signing class rejects");
2912 assert!(
2913 err.to_string().contains(
2914 "broker response-signing key `broker.response` must be class `asymmetric`"
2915 )
2916 );
2917
2918 let wrong_request_use = catalog_with_invocation_keys(
2919 catalog_key(
2920 Class::Asymmetric,
2921 Some(KeyAlgorithm::Ed25519),
2922 &["broker_key_use=response-signing"],
2923 ),
2924 catalog_key(
2925 Class::Sealing,
2926 Some(KeyAlgorithm::X25519),
2927 &["broker_key_use=response-signing"],
2928 ),
2929 );
2930 let err = validate_invocation_catalog_bindings(&invocation, &wrong_request_use)
2931 .expect_err("wrong request encryption use rejects");
2932 assert!(err.to_string().contains(
2933 "broker request-encryption key `broker.request` must carry label `broker_key_use=request-encryption`"
2934 ));
2935 }
2936
2937 #[cfg(feature = "http")]
2938 #[test]
2939 fn jwks_http_surface_enables_and_parses_listen_from_config() {
2940 let config = temp_config(
2941 r#"
2942catalog = "/cfg/catalog.json"
2943policy = "/cfg/policy.json"
2944bundle = "/cfg/bundle.sealed"
2945
2946[jwks]
2947enable = true
2948listen = "0.0.0.0:9443"
2949"#,
2950 );
2951 let args = ConfigOverrides {
2952 config: Some(config.clone()),
2953 catalog: None,
2954 policy: None,
2955 bundle: None,
2956 socket: None,
2957 vault_addr: None,
2958 };
2959 let loaded = load_run_config(&args).expect("load run config");
2960 assert!(loaded.jwks.enable);
2961 assert_eq!(
2962 loaded.jwks.listen,
2963 "0.0.0.0:9443".parse().expect("custom listen")
2964 );
2965
2966 std::fs::remove_file(config).expect("remove temp config");
2967 }
2968
2969 #[cfg(feature = "http")]
2970 #[test]
2971 fn jwks_listen_must_be_a_valid_socket_addr() {
2972 let bad = JwksConfigFile {
2973 enable: true,
2974 listen: "not-an-addr".to_string(),
2975 issuer: None,
2976 tls: JwksTlsConfigFile::default(),
2977 };
2978 assert!(
2979 resolve_jwks_config(&bad).is_err(),
2980 "a malformed jwks.listen fails closed at startup"
2981 );
2982 }
2983
2984 #[cfg(feature = "http")]
2985 #[test]
2986 fn jwks_issuer_must_be_absolute_http_url_or_unset() {
2987 let none = JwksConfigFile {
2989 enable: true,
2990 listen: "127.0.0.1:8201".to_string(),
2991 issuer: None,
2992 tls: JwksTlsConfigFile::default(),
2993 };
2994 assert!(resolve_jwks_config(&none).expect("ok").issuer.is_none());
2995
2996 let bad = JwksConfigFile {
2998 enable: true,
2999 listen: "127.0.0.1:8201".to_string(),
3000 issuer: Some("basil.example.com".to_string()),
3001 tls: JwksTlsConfigFile::default(),
3002 };
3003 assert!(
3004 resolve_jwks_config(&bad).is_err(),
3005 "a non-http(s) issuer fails closed at startup"
3006 );
3007
3008 let good = JwksConfigFile {
3010 enable: true,
3011 listen: "127.0.0.1:8201".to_string(),
3012 issuer: Some("https://basil.example.com/".to_string()),
3013 tls: JwksTlsConfigFile::default(),
3014 };
3015 assert_eq!(
3016 resolve_jwks_config(&good).expect("ok").issuer.as_deref(),
3017 Some("https://basil.example.com")
3018 );
3019 }
3020
3021 #[cfg(feature = "http")]
3022 #[test]
3023 fn jwks_issuer_rejects_ssrf_prone_url_shapes() {
3024 for issuer in [
3025 "//basil.example.com",
3026 "ftp://basil.example.com",
3027 "file:///etc/passwd",
3028 "unix:///run/basil-jwks.sock",
3029 "http://169.254.169.254/latest/meta-data/",
3030 "http://metadata.google.internal/computeMetadata/v1/",
3031 "https://user:pass@basil.example.com",
3032 "https://basil.example.com/#fragment",
3033 ] {
3034 let bad = JwksConfigFile {
3035 enable: true,
3036 listen: "127.0.0.1:8201".to_string(),
3037 issuer: Some(issuer.to_string()),
3038 tls: JwksTlsConfigFile::default(),
3039 };
3040 assert!(
3041 resolve_jwks_config(&bad).is_err(),
3042 "jwks.issuer `{issuer}` should fail closed"
3043 );
3044 }
3045 }
3046
3047 #[cfg(feature = "http")]
3048 #[test]
3049 fn jwks_tls_defaults_to_disabled() {
3050 let defaults = JwksConfigFile::default();
3051 assert!(resolve_jwks_config(&defaults).expect("ok").tls.is_none());
3052 }
3053
3054 #[cfg(all(feature = "http", not(feature = "http-tls")))]
3055 #[test]
3056 fn jwks_tls_requires_cargo_feature() {
3057 let config = JwksConfigFile {
3058 enable: true,
3059 listen: "127.0.0.1:8201".to_string(),
3060 issuer: Some("https://basil.example.com".to_string()),
3061 tls: JwksTlsConfigFile {
3062 enable: true,
3063 cert_file: Some("/etc/basil/jwks-cert.pem".into()),
3064 key_file: Some("/etc/basil/jwks-key.pem".into()),
3065 },
3066 };
3067 let err = resolve_jwks_config(&config).expect_err("feature missing");
3068 assert!(err.to_string().contains("http-tls"));
3069 }
3070
3071 #[cfg(not(feature = "http"))]
3072 #[test]
3073 fn jwks_enable_requires_http_feature() {
3074 let config = temp_config(
3075 r#"
3076catalog = "/cfg/catalog.json"
3077policy = "/cfg/policy.json"
3078bundle = "/cfg/bundle.sealed"
3079
3080[jwks]
3081enable = true
3082"#,
3083 );
3084 let err = load_run_config(&overrides_for(config.clone())).expect_err("feature missing");
3085 assert!(err.to_string().contains("http cargo feature"));
3086 std::fs::remove_file(config).expect("remove temp config");
3087 }
3088
3089 #[cfg(feature = "http-tls")]
3090 #[test]
3091 fn jwks_tls_requires_cert_and_key_when_enabled() {
3092 let missing_key = JwksConfigFile {
3093 enable: true,
3094 listen: "127.0.0.1:8201".to_string(),
3095 issuer: Some("https://basil.example.com".to_string()),
3096 tls: JwksTlsConfigFile {
3097 enable: true,
3098 cert_file: Some("/etc/basil/jwks-cert.pem".into()),
3099 key_file: None,
3100 },
3101 };
3102 let err = resolve_jwks_config(&missing_key).expect_err("key required");
3103 assert!(err.to_string().contains("key-file"));
3104 }
3105
3106 #[cfg(feature = "otlp")]
3107 #[test]
3108 fn otel_logging_requires_non_empty_http_endpoint_when_enabled() {
3109 let missing = OpenTelemetryLoggingConfigFile {
3110 enable: true,
3111 endpoint: None,
3112 protocol: OpenTelemetryProtocol::Grpc,
3113 };
3114 assert!(required_otel_endpoint(&missing).is_err());
3115
3116 let empty = OpenTelemetryLoggingConfigFile {
3117 enable: true,
3118 endpoint: Some(" ".to_string()),
3119 protocol: OpenTelemetryProtocol::Grpc,
3120 };
3121 assert!(required_otel_endpoint(&empty).is_err());
3122
3123 let unix = OpenTelemetryLoggingConfigFile {
3124 enable: true,
3125 endpoint: Some("unix:///run/otel.sock".to_string()),
3126 protocol: OpenTelemetryProtocol::Grpc,
3127 };
3128 assert!(required_otel_endpoint(&unix).is_err());
3129
3130 for endpoint in [
3131 "//otel.example.com:4317",
3132 "ftp://otel.example.com:4317",
3133 "file:///tmp/otel.sock",
3134 "http://169.254.169.254/latest/meta-data/",
3135 "http://metadata.google.internal/computeMetadata/v1/",
3136 "https://user:pass@otel.example.com:4317",
3137 "https://otel.example.com:4317/#fragment",
3138 ] {
3139 let bad = OpenTelemetryLoggingConfigFile {
3140 enable: true,
3141 endpoint: Some(endpoint.to_string()),
3142 protocol: OpenTelemetryProtocol::Grpc,
3143 };
3144 assert!(
3145 required_otel_endpoint(&bad).is_err(),
3146 "OpenTelemetry endpoint `{endpoint}` should fail closed"
3147 );
3148 }
3149
3150 let http = OpenTelemetryLoggingConfigFile {
3151 enable: true,
3152 endpoint: Some("http://localhost:4317".to_string()),
3153 protocol: OpenTelemetryProtocol::Grpc,
3154 };
3155 assert_eq!(
3156 required_otel_endpoint(&http).expect("valid endpoint"),
3157 "http://localhost:4317/"
3158 );
3159 }
3160
3161 #[test]
3162 fn cli_overrides_win_over_config_file() {
3163 let config = temp_config(
3164 r#"
3165catalog = "/cfg/catalog.json"
3166policy = "/cfg/policy.json"
3167bundle = "/cfg/bundle.sealed"
3168socket = "/cfg/basil.sock"
3169vault-addr = "http://cfg-vault:8200"
3170"#,
3171 );
3172 let args = ConfigOverrides {
3173 config: Some(config.clone()),
3174 catalog: Some(PathBuf::from("/cli/catalog.json")),
3175 policy: Some(PathBuf::from("/cli/policy.json")),
3176 bundle: Some(PathBuf::from("/cli/bundle.sealed")),
3177 socket: Some("/cli/basil.sock".to_string()),
3178 vault_addr: Some("http://cli-vault:8200".to_string()),
3179 };
3180
3181 let loaded = load_run_config(&args).expect("load run config");
3182
3183 assert_eq!(loaded.setup.catalog, PathBuf::from("/cli/catalog.json"));
3184 assert_eq!(loaded.setup.policy, PathBuf::from("/cli/policy.json"));
3185 assert_eq!(loaded.setup.bundle, PathBuf::from("/cli/bundle.sealed"));
3186 assert_eq!(loaded.socket.as_deref(), Some("/cli/basil.sock"));
3187 assert_eq!(loaded.setup.vault_addr, "http://cli-vault:8200");
3188
3189 std::fs::remove_file(config).expect("remove temp config");
3190 }
3191
3192 #[test]
3193 fn run_cli_rejects_removed_startup_overrides() {
3194 #[derive(Debug, clap::Parser)]
3195 struct TestCli {
3196 #[command(flatten)]
3197 args: RunArgs,
3198 }
3199
3200 let err = TestCli::try_parse_from([
3201 "basil-agent-run",
3202 "--catalog",
3203 "/tmp/catalog.json",
3204 "--policy",
3205 "/tmp/policy.json",
3206 "--bundle",
3207 "/tmp/bundle.sealed",
3208 "--capability-policy",
3209 "off",
3210 ])
3211 .expect_err("removed flag rejected");
3212 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
3213 }
3214
3215 mod explain {
3222 use super::*;
3223 use crate::catalog::{Op, Pdp, load};
3224
3225 const CATALOG: &str = r#"{
3226 "schemaVersion": 1,
3227 "backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
3228 "keys": {
3229 "grafana.admin_password": {
3230 "class": "value", "backend": "bao", "engine": "kv2",
3231 "path": "secret/data/grafana/admin", "writable": true,
3232 "missing": "error", "description": "grafana admin value"
3233 }
3234 }
3235 }"#;
3236
3237 const POLICY: &str = r#"{
3238 "schemaVersion": 2,
3239 "subjects": {
3240 "svc.grafana": { "allOf": [ { "kind": "unix", "uid": 9002 } ] },
3241 "ops.wheel": { "allOf": [ { "kind": "unix", "gid": 10 } ] }
3242 },
3243 "roles": { "reader": ["get", "list"], "operator": ["set", "rotate"] },
3244 "rules": [
3245 { "id": "grafana-reader", "subjects": ["svc.grafana"], "action": ["role:reader"], "target": ["grafana.admin_password"] },
3246 { "id": "wheel-operator", "subjects": ["ops.wheel"], "action": ["role:operator"], "target": ["grafana.**"] }
3247 ],
3248 "config": {
3249 "names": { "users": { "9002": "svc-grafana" }, "groups": { "10": "wheel" } },
3250 "memberships": { "9002": [9002] }
3251 }
3252 }"#;
3253
3254 fn loaded() -> (
3257 crate::catalog::Catalog,
3258 crate::catalog::ResolvedPolicy,
3259 crate::catalog::Config,
3260 ) {
3261 let (catalog, policy, config, _w) = load(CATALOG, POLICY).expect("fixture loads");
3262 (catalog, policy, config)
3263 }
3264
3265 fn render_explanation_to_string(
3266 pdp: &Pdp,
3267 subject: &str,
3268 op: Op,
3269 key: &str,
3270 json: bool,
3271 ) -> String {
3272 let mut buf = Vec::new();
3273 render_explanation(&mut buf, pdp, subject, op, key, json).expect("render");
3274 String::from_utf8(buf).expect("utf8")
3275 }
3276
3277 #[test]
3278 fn json_allow_shape_carries_matched_rule_fields() {
3279 let (c, p, cfg) = loaded();
3280 let pdp = Pdp::new(&c, &p, &cfg);
3281 let out = render_explanation_to_string(
3282 &pdp,
3283 "svc.grafana",
3284 Op::Get,
3285 "grafana.admin_password",
3286 true,
3287 );
3288 let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3289 assert_eq!(doc["decision"], "allow");
3290 assert_eq!(doc["subject"], "svc.grafana");
3291 assert_eq!(doc["op"], "get");
3292 assert_eq!(doc["key"], "grafana.admin_password");
3293 assert_eq!(doc["via"], "subject:svc.grafana");
3294 assert_eq!(doc["matched_rule"]["rule"], "grafana-reader");
3295 assert_eq!(doc["matched_rule"]["via"], "subject:svc.grafana");
3296 assert!(doc["matched_rule"]["action"].is_string());
3297 assert!(doc["matched_rule"]["target"].is_string());
3298 }
3299
3300 #[test]
3301 fn json_deny_shape_carries_default_deny_reason() {
3302 let (c, p, cfg) = loaded();
3303 let pdp = Pdp::new(&c, &p, &cfg);
3304 let out = render_explanation_to_string(
3306 &pdp,
3307 "svc.grafana",
3308 Op::Set,
3309 "grafana.admin_password",
3310 true,
3311 );
3312 let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3313 assert_eq!(doc["decision"], "deny");
3314 assert_eq!(doc["reason"], "not_permitted");
3315 assert!(doc.get("via").is_none());
3317 assert!(doc.get("matched_rule").is_none());
3318
3319 let unknown =
3321 render_explanation_to_string(&pdp, "svc.grafana", Op::Get, "no.such.key", true);
3322 let doc: serde_json::Value = serde_json::from_str(&unknown).expect("json");
3323 assert_eq!(doc["decision"], "deny");
3324 assert_eq!(doc["reason"], "unknown_key");
3325 }
3326
3327 #[test]
3328 fn subject_name_selects_the_rule_to_explain() {
3329 let (c, p, cfg) = loaded();
3330 let pdp = Pdp::new(&c, &p, &cfg);
3331 let denied = render_explanation_to_string(
3332 &pdp,
3333 "svc.grafana",
3334 Op::Set,
3335 "grafana.admin_password",
3336 true,
3337 );
3338 let doc: serde_json::Value = serde_json::from_str(&denied).expect("json");
3339 assert_eq!(doc["decision"], "deny", "reader subject cannot set");
3340
3341 let allowed = render_explanation_to_string(
3342 &pdp,
3343 "ops.wheel",
3344 Op::Set,
3345 "grafana.admin_password",
3346 true,
3347 );
3348 let doc: serde_json::Value = serde_json::from_str(&allowed).expect("json");
3349 assert_eq!(doc["decision"], "allow", "operator subject can set");
3350 assert_eq!(doc["via"], "subject:ops.wheel");
3351 assert_eq!(doc["matched_rule"]["rule"], "wheel-operator");
3352 }
3353
3354 #[test]
3355 fn effective_mode_renders_granted_key_op_set() {
3356 let (c, p, cfg) = loaded();
3357 let pdp = Pdp::new(&c, &p, &cfg);
3358 let mut buf = Vec::new();
3359 render_effective(&mut buf, &pdp, "ops.wheel", true).expect("render effective");
3360 let doc: serde_json::Value = serde_json::from_slice(&buf).expect("effective json");
3361 assert_eq!(doc["subject"], "ops.wheel");
3362 let rows = doc["effective"].as_array().expect("effective rows");
3363 let pairs: Vec<(&str, &str)> = rows
3364 .iter()
3365 .filter_map(|r| Some((r["key"].as_str()?, r["op"].as_str()?)))
3366 .collect();
3367 assert!(
3368 pairs.contains(&("grafana.admin_password", "set")),
3369 "operator set granted: {pairs:?}"
3370 );
3371 assert!(
3372 pairs.contains(&("grafana.admin_password", "rotate")),
3373 "operator rotate granted: {pairs:?}"
3374 );
3375 assert!(
3376 rows.iter().all(|r| r["via"] == "subject:ops.wheel"),
3377 "every grant is via the wheel subject"
3378 );
3379
3380 let mut buf = Vec::new();
3383 render_effective(&mut buf, &pdp, "missing.subject", true).expect("render effective");
3384 let doc: serde_json::Value = serde_json::from_slice(&buf).expect("json");
3385 assert!(
3386 doc["effective"].as_array().expect("rows").is_empty(),
3387 "no grants -> empty effective set"
3388 );
3389 }
3390
3391 #[test]
3396 fn missing_op_key_without_effective_bails_cleanly() {
3397 let dir = std::env::temp_dir();
3398 let stamp = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4());
3399 let catalog_path = dir.join(format!("basil-explain-cat-{stamp}.json"));
3400 let policy_path = dir.join(format!("basil-explain-pol-{stamp}.json"));
3401 std::fs::write(&catalog_path, CATALOG).expect("write catalog");
3402 std::fs::write(&policy_path, POLICY).expect("write policy");
3403
3404 let args = ExplainArgs {
3405 subject: "svc.grafana".to_string(),
3406 op: None,
3407 key: None,
3408 effective: false,
3409 live: false,
3410 json: true,
3411 overrides: ConfigOverrides {
3412 config: None,
3413 catalog: Some(catalog_path.clone()),
3414 policy: Some(policy_path.clone()),
3415 bundle: None,
3416 socket: None,
3417 vault_addr: None,
3418 },
3419 };
3420
3421 let err = run_explain(&args).expect_err("missing op/key must bail");
3422 assert!(
3423 err.to_string().contains("--op and --key are required"),
3424 "clean bail, not a panic: {err}"
3425 );
3426
3427 std::fs::remove_file(&catalog_path).ok();
3428 std::fs::remove_file(&policy_path).ok();
3429 }
3430 }
3431}