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