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!(!is_plaintext_non_loopback_http(addr), "{addr} must not warn");
2345 }
2346 }
2347
2348 fn overrides_for(config: PathBuf) -> ConfigOverrides {
2349 ConfigOverrides {
2350 config: Some(config),
2351 catalog: None,
2352 policy: None,
2353 bundle: None,
2354 socket: None,
2355 vault_addr: None,
2356 }
2357 }
2358
2359 fn catalog_with_invocation_keys(
2360 response_signing: crate::catalog::KeyEntry,
2361 request_encryption: crate::catalog::KeyEntry,
2362 ) -> crate::Catalog {
2363 let mut backends = BTreeMap::new();
2364 backends.insert(
2365 "bao".to_string(),
2366 crate::catalog::BackendRef {
2367 kind: crate::catalog::BackendKind::Vault,
2368 addr: "http://127.0.0.1:8200".to_string(),
2369 engines: Vec::new(),
2370 capabilities: Vec::new(),
2371 mint_key_types: Vec::new(),
2372 requires: Vec::new(),
2373 },
2374 );
2375 let mut keys = BTreeMap::new();
2376 keys.insert("broker.response".to_string(), response_signing);
2377 keys.insert("broker.request".to_string(), request_encryption);
2378 crate::Catalog {
2379 schema_version: 1,
2380 backends,
2381 keys,
2382 }
2383 }
2384
2385 fn catalog_key(
2386 class: Class,
2387 key_type: Option<KeyAlgorithm>,
2388 labels: &[&str],
2389 ) -> crate::catalog::KeyEntry {
2390 crate::catalog::KeyEntry {
2391 class,
2392 key_type,
2393 backend: "bao".to_string(),
2394 engine: None,
2395 path: "path".to_string(),
2396 public_path: matches!(class, Class::Sealing).then(|| "public/path".to_string()),
2397 writable: false,
2398 missing: MissingPolicy::Error,
2399 generate: None,
2400 sealing_pin: None,
2401 labels: crate::catalog::Labels(labels.iter().map(ToString::to_string).collect()),
2402 description: "test key".to_string(),
2403 }
2404 }
2405
2406 #[cfg(feature = "keystore-backend")]
2407 #[test]
2408 #[allow(clippy::too_many_lines)]
2409 fn run_config_file_supplies_all_startup_settings() {
2410 let config = temp_config(
2411 r#"
2412catalog = "/cfg/catalog.json"
2413policy = "/cfg/policy.json"
2414bundle = "/cfg/bundle.sealed"
2415socket = "/run/basil.sock"
2416vault-addr = "http://vault.internal:8200"
2417transit-mount = "basil-transit"
2418jwt-auth-mount = "jwt-basil"
2419jwt-role = "basil-agent"
2420jwt-audience = "openbao-prod"
2421svid-ttl-secs = 120
2422capability-policy = "degraded"
2423db-keystore-cipher = "aegis256"
2424onepassword-provider-uri = "onepassword://basil"
2425onepassword-project = "prod"
2426onepassword-profile = "agent"
2427max-encrypt-size = 4096
2428max-payload-size = 8192
2429grace-versions = 2
2430retain-versions = 5
2431retention-sweep-secs = 60
2432audit-log = "/var/log/basil/audit.jsonl"
2433no-reconcile = true
2434
2435[logging.stdout]
2436enable = false
2437
2438[logging.journald]
2439enable = true
2440
2441[logging.file]
2442enable = true
2443dir = "/var/log/basil"
2444prefix = "agent-"
2445rotation = "weekly"
2446
2447[logging.opentelemetry]
2448enable = true
2449endpoint = "http://localhost:4317"
2450protocol = "grpc"
2451
2452[unlock]
2453age-yubikey = true
2454bip39-phrase-file = "/secure/recovery.txt"
2455unlock-tpm = true
2456unlock-passphrase-file = "/secure/passphrase.txt"
2457unlock-passphrase-no-wipe = true
2458strict-bundle-perms = true
2459"#,
2460 );
2461 let args = ConfigOverrides {
2462 config: Some(config.clone()),
2463 catalog: None,
2464 policy: None,
2465 bundle: None,
2466 socket: None,
2467 vault_addr: None,
2468 };
2469
2470 let loaded_file = load_config_file(&args).expect("load config file");
2471 let loaded = load_run_config(&args).expect("load run config");
2472
2473 assert_eq!(loaded.setup.catalog, PathBuf::from("/cfg/catalog.json"));
2474 assert_eq!(loaded.setup.policy, PathBuf::from("/cfg/policy.json"));
2475 assert_eq!(loaded.setup.bundle, PathBuf::from("/cfg/bundle.sealed"));
2476 assert_eq!(loaded.socket.as_deref(), Some("/run/basil.sock"));
2477 assert_eq!(loaded.setup.vault_addr, "http://vault.internal:8200");
2478 assert_eq!(loaded.setup.transit_mount, "basil-transit");
2479 assert_eq!(loaded.setup.jwt_auth_mount, "jwt-basil");
2480 assert_eq!(loaded.setup.jwt_role, "basil-agent");
2481 assert_eq!(loaded.setup.jwt_audience, "openbao-prod");
2482 assert_eq!(loaded.setup.svid_ttl_secs, 120);
2483 assert_eq!(loaded.setup.capability_policy, CapabilityPolicy::Degraded);
2484 #[cfg(feature = "db-keystore")]
2485 assert_eq!(loaded.setup.db_keystore_cipher, "aegis256");
2486 assert_eq!(loaded.setup.onepassword_provider_uri, "onepassword://basil");
2487 assert_eq!(loaded.setup.onepassword_project, "prod");
2488 assert_eq!(loaded.setup.onepassword_profile, "agent");
2489 assert_eq!(loaded.max_encrypt_size, 4096);
2490 assert_eq!(loaded.max_payload_size, 8192);
2491 assert_eq!(loaded.grace_versions, 2);
2492 assert_eq!(loaded.retain_versions, Some(5));
2493 assert_eq!(loaded.retention_sweep_secs, 60);
2494 assert_eq!(
2495 loaded.audit_log.as_deref(),
2496 Some(Path::new("/var/log/basil/audit.jsonl"))
2497 );
2498 assert!(loaded.no_reconcile);
2499 assert_eq!(loaded_file.logging.stdout.enable, Some(false));
2500 assert!(!loaded_file.logging.stdout_enabled());
2501 assert!(loaded_file.logging.journald.enable);
2502 assert!(loaded_file.logging.file.enable);
2503 assert_eq!(
2504 loaded_file.logging.file.dir.as_deref(),
2505 Some(Path::new("/var/log/basil"))
2506 );
2507 assert_eq!(loaded_file.logging.file.prefix, "agent-");
2508 assert_eq!(
2509 loaded_file.logging.file.rotation,
2510 FileLoggingRotation::Weekly
2511 );
2512 assert!(loaded_file.logging.opentelemetry.enable);
2513 assert_eq!(
2514 (
2515 loaded_file.logging.opentelemetry.endpoint.as_deref(),
2516 loaded_file.logging.opentelemetry.protocol
2517 ),
2518 (Some("http://localhost:4317"), OpenTelemetryProtocol::Grpc)
2519 );
2520 assert!(loaded.setup.unlock.age_yubikey);
2521 assert!(loaded.setup.unlock.tpm);
2522 assert_eq!(
2523 loaded.setup.unlock.bip39_phrase_file.as_deref(),
2524 Some(Path::new("/secure/recovery.txt"))
2525 );
2526 assert_eq!(
2527 loaded.setup.unlock.passphrase_file.as_deref(),
2528 Some(Path::new("/secure/passphrase.txt"))
2529 );
2530 assert!(loaded.setup.unlock.passphrase_no_wipe);
2531 assert!(loaded.setup.unlock.strict_bundle_perms);
2532
2533 std::fs::remove_file(config).expect("remove temp config");
2534 }
2535
2536 #[test]
2537 fn socket_mode_config_accepts_octal_string_and_rejects_bad_values() {
2538 assert_eq!(parse_socket_mode("0600").expect("0600 parses"), 0o600);
2539 assert_eq!(parse_socket_mode("0o660").expect("0o660 parses"), 0o660);
2540 assert!(parse_socket_mode("").is_err());
2541 assert!(parse_socket_mode("0888").is_err());
2542 assert!(parse_socket_mode("10000").is_err());
2543 }
2544
2545 #[test]
2546 fn run_config_file_supplies_socket_ownership_settings() {
2547 let config = temp_config(
2548 r#"
2549catalog = "/cfg/catalog.json"
2550policy = "/cfg/policy.json"
2551bundle = "/cfg/bundle.sealed"
2552socket-mode = "0660"
2553socket-group = "basil-edge"
2554"#,
2555 );
2556 let args = ConfigOverrides {
2557 config: Some(config.clone()),
2558 catalog: None,
2559 policy: None,
2560 bundle: None,
2561 socket: None,
2562 vault_addr: None,
2563 };
2564
2565 let loaded = load_run_config(&args).expect("load run config");
2566
2567 assert_eq!(loaded.socket_mode, 0o660);
2568 assert_eq!(loaded.socket_group.as_deref(), Some("basil-edge"));
2569
2570 std::fs::remove_file(config).expect("remove temp config");
2571 }
2572
2573 #[test]
2574 fn socket_mode_defaults_to_owner_only() {
2575 let config = temp_config(
2576 r#"
2577catalog = "/cfg/catalog.json"
2578policy = "/cfg/policy.json"
2579bundle = "/cfg/bundle.sealed"
2580"#,
2581 );
2582 let args = ConfigOverrides {
2583 config: Some(config.clone()),
2584 catalog: None,
2585 policy: None,
2586 bundle: None,
2587 socket: None,
2588 vault_addr: None,
2589 };
2590
2591 let loaded = load_run_config(&args).expect("load run config");
2592
2593 assert_eq!(loaded.socket_mode, DEFAULT_SOCKET_MODE);
2594 assert!(loaded.socket_group.is_none());
2595
2596 std::fs::remove_file(config).expect("remove temp config");
2597 }
2598
2599 #[test]
2600 fn logging_defaults_stdout_and_journald_on_otel_off() {
2601 let config = AgentConfigFile::default();
2602 assert_eq!(config.logging.stdout.enable, None);
2603 assert!(config.logging.stdout_enabled());
2604 assert!(config.logging.journald.enable);
2605 assert!(!config.logging.file.enable);
2606 assert!(config.logging.file.dir.is_none());
2607 assert_eq!(config.logging.file.prefix, "basil-agent-");
2608 assert_eq!(config.logging.file.rotation, FileLoggingRotation::Daily);
2609 assert!(!config.logging.opentelemetry.enable);
2610 assert_eq!(
2611 config.logging.opentelemetry.protocol,
2612 OpenTelemetryProtocol::Grpc
2613 );
2614 assert!(config.logging.opentelemetry.endpoint.is_none());
2615 }
2616
2617 #[test]
2618 fn stdout_defaults_off_when_file_logging_enabled() {
2619 let config: AgentConfigFile = toml::from_str(
2620 r#"
2621[logging.file]
2622enable = true
2623dir = "/var/log/basil"
2624"#,
2625 )
2626 .expect("parse config");
2627 assert_eq!(config.logging.stdout.enable, None);
2628 assert!(!config.logging.stdout_enabled());
2629 }
2630
2631 #[test]
2632 fn stdout_explicit_config_overrides_file_logging_default() {
2633 let stdout_on: AgentConfigFile = toml::from_str(
2634 r#"
2635[logging.stdout]
2636enable = true
2637
2638[logging.file]
2639enable = true
2640dir = "/var/log/basil"
2641"#,
2642 )
2643 .expect("parse stdout-on config");
2644 assert_eq!(stdout_on.logging.stdout.enable, Some(true));
2645 assert!(stdout_on.logging.stdout_enabled());
2646
2647 let stdout_off: AgentConfigFile = toml::from_str(
2648 r"
2649[logging.stdout]
2650enable = false
2651",
2652 )
2653 .expect("parse stdout-off config");
2654 assert_eq!(stdout_off.logging.stdout.enable, Some(false));
2655 assert!(!stdout_off.logging.stdout_enabled());
2656 }
2657
2658 #[test]
2659 fn file_logging_path_alias_populates_dir() {
2660 let config: AgentConfigFile = toml::from_str(
2661 r#"
2662[logging.file]
2663enable = true
2664path = "/var/log/basil"
2665"#,
2666 )
2667 .expect("parse file config");
2668 assert_eq!(
2669 config.logging.file.dir.as_deref(),
2670 Some(Path::new("/var/log/basil"))
2671 );
2672 }
2673
2674 #[test]
2675 fn file_logging_requires_dir_when_enabled() {
2676 let config: AgentConfigFile = toml::from_str(
2677 r"
2678[logging.file]
2679enable = true
2680",
2681 )
2682 .expect("parse file config");
2683 let err = build_file_writer(&config.logging.file).expect_err("missing dir rejects");
2684 assert!(
2685 err.to_string()
2686 .contains("logging.file.dir is required when logging.file.enable=true")
2687 );
2688 }
2689
2690 #[test]
2691 fn file_logging_constructs_non_blocking_writer() {
2692 let dir = std::env::temp_dir().join(format!(
2693 "basil-agent-log-test-{}-{}",
2694 std::process::id(),
2695 uuid::Uuid::new_v4()
2696 ));
2697 std::fs::create_dir(&dir).expect("create log dir");
2698 let config = FileLoggingConfigFile {
2699 enable: true,
2700 dir: Some(dir.clone()),
2701 prefix: "agent-test".to_owned(),
2702 rotation: FileLoggingRotation::None,
2703 };
2704 let (writer, guard) = build_file_writer(&config).expect("build file writer");
2705 assert!(writer.is_some());
2706 drop(writer);
2707 drop(guard);
2708 std::fs::remove_dir_all(dir).expect("remove log dir");
2709 }
2710
2711 #[test]
2712 fn journald_disabled_produces_no_sink() {
2713 assert!(matches!(journald_sink(false), JournaldSink::Disabled));
2714 }
2715
2716 #[test]
2717 fn journald_without_socket_is_non_fatal_without_stderr_sink() {
2718 match journald_sink(true) {
2724 JournaldSink::Active(_) | JournaldSink::Unavailable(_) => {}
2725 JournaldSink::Disabled => panic!("enabled journald must not resolve to Disabled"),
2726 }
2727 }
2728
2729 #[cfg(feature = "http")]
2730 #[test]
2731 fn jwks_http_surface_is_disabled_by_default_no_port_opened() {
2732 let defaults = JwksConfigFile::default();
2737 assert!(!defaults.enable, "JWKS surface must default to disabled");
2738 assert_eq!(defaults.listen, "127.0.0.1:8201");
2739
2740 let config = temp_config(
2741 r#"
2742catalog = "/cfg/catalog.json"
2743policy = "/cfg/policy.json"
2744bundle = "/cfg/bundle.sealed"
2745"#,
2746 );
2747 let args = ConfigOverrides {
2748 config: Some(config.clone()),
2749 catalog: None,
2750 policy: None,
2751 bundle: None,
2752 socket: None,
2753 vault_addr: None,
2754 };
2755 let loaded = load_run_config(&args).expect("load run config");
2756 assert!(
2757 !loaded.jwks.enable,
2758 "default config leaves the JWKS port closed"
2759 );
2760 assert_eq!(
2761 loaded.jwks.listen,
2762 "127.0.0.1:8201".parse().expect("default listen")
2763 );
2764
2765 std::fs::remove_file(config).expect("remove temp config");
2766 }
2767
2768 #[test]
2769 fn invocation_service_requires_explicit_config_enable() {
2770 let defaults = InvocationConfigFile::default();
2771 assert!(
2772 !defaults.enable,
2773 "InvocationService must default to rejecting requests"
2774 );
2775 assert_eq!(defaults.max_ttl_secs, 60);
2776 assert_eq!(defaults.clock_skew_secs, 30);
2777 assert_eq!(defaults.replay_cache_capacity, 4096);
2778
2779 let config = temp_config(
2780 r#"
2781catalog = "/cfg/catalog.json"
2782policy = "/cfg/policy.json"
2783bundle = "/cfg/bundle.sealed"
2784"#,
2785 );
2786 let args = overrides_for(config.clone());
2787 let loaded = load_run_config(&args).expect("load run config");
2788 assert!(!loaded.invocation.enabled);
2789 assert!(loaded.invocation.broker_identity.is_none());
2790 assert!(loaded.invocation.request_encryption_key_id.is_none());
2791 std::fs::remove_file(config).expect("remove temp config");
2792
2793 let config = temp_config(
2794 r#"
2795catalog = "/cfg/catalog.json"
2796policy = "/cfg/policy.json"
2797bundle = "/cfg/bundle.sealed"
2798
2799[broker-identity]
2800id = "basil://prod/us-east-1/agent-a"
2801response-signing-key-id = "broker.response"
2802
2803[invocation]
2804enable = true
2805audience = ["basil://prod/us-east-1/agent-a"]
2806request-encryption-key-id = "broker.request"
2807max-ttl-secs = 45
2808clock-skew-secs = 7
2809replay-cache-capacity = 128
2810"#,
2811 );
2812 let args = overrides_for(config.clone());
2813 let loaded = load_run_config(&args).expect("load run config");
2814 assert!(loaded.invocation.enabled);
2815 let identity = loaded
2816 .invocation
2817 .broker_identity
2818 .as_ref()
2819 .expect("broker identity");
2820 assert_eq!(identity.id, "basil://prod/us-east-1/agent-a");
2821 assert_eq!(identity.response_signing_key_id, "broker.response");
2822 assert_eq!(
2823 loaded.invocation.request_encryption_key_id.as_deref(),
2824 Some("broker.request")
2825 );
2826 assert_eq!(
2827 loaded.invocation.audiences,
2828 vec!["basil://prod/us-east-1/agent-a".to_string()]
2829 );
2830 assert_eq!(loaded.invocation.max_ttl_secs, 45);
2831 assert_eq!(loaded.invocation.clock_skew_secs, 7);
2832 assert_eq!(loaded.invocation.replay_cache_capacity, 128);
2833
2834 std::fs::remove_file(config).expect("remove temp config");
2835
2836 let config = temp_config(
2837 r#"
2838catalog = "/cfg/catalog.json"
2839policy = "/cfg/policy.json"
2840bundle = "/cfg/bundle.sealed"
2841
2842[invocation]
2843enable = true
2844"#,
2845 );
2846 let args = overrides_for(config.clone());
2847 let err = load_run_config(&args).expect_err("enabled invocation requires audience");
2848 assert!(
2849 err.to_string()
2850 .contains("invocation.audience must be set when invocation.enable is true")
2851 );
2852
2853 std::fs::remove_file(config).expect("remove temp config");
2854 }
2855
2856 #[test]
2857 fn invocation_config_rejects_missing_keys_invalid_identity_and_bounds() {
2858 let config = temp_config(
2859 r#"
2860catalog = "/cfg/catalog.json"
2861policy = "/cfg/policy.json"
2862bundle = "/cfg/bundle.sealed"
2863
2864[broker-identity]
2865id = "https://not-basil.example/agent-a"
2866response-signing-key-id = "broker.response"
2867
2868[invocation]
2869enable = true
2870audience = ["basil://prod/us-east-1/agent-a"]
2871request-encryption-key-id = "broker.request"
2872"#,
2873 );
2874 let err = load_run_config(&overrides_for(config.clone()))
2875 .expect_err("non-basil broker id rejects");
2876 assert!(
2877 err.to_string()
2878 .contains("broker-identity.id must use the basil:// scheme")
2879 );
2880 std::fs::remove_file(config).expect("remove temp config");
2881
2882 let config = temp_config(
2883 r#"
2884catalog = "/cfg/catalog.json"
2885policy = "/cfg/policy.json"
2886bundle = "/cfg/bundle.sealed"
2887
2888[broker-identity]
2889id = "basil://prod/us-east-1/agent-a"
2890response-signing-key-id = "broker.response"
2891
2892[invocation]
2893enable = true
2894audience = ["basil://prod/us-east-1/agent-a"]
2895max-ttl-secs = 301
2896"#,
2897 );
2898 let err =
2899 load_run_config(&overrides_for(config.clone())).expect_err("excessive ttl rejects");
2900 assert!(
2901 err.to_string()
2902 .contains("invocation.max-ttl-secs must be at most 300 seconds")
2903 );
2904 std::fs::remove_file(config).expect("remove temp config");
2905
2906 let config = temp_config(
2907 r#"
2908catalog = "/cfg/catalog.json"
2909policy = "/cfg/policy.json"
2910bundle = "/cfg/bundle.sealed"
2911
2912[broker-identity]
2913id = "basil://prod/us-east-1/agent-a"
2914response-signing-key-id = "broker.response"
2915
2916[invocation]
2917enable = true
2918audience = ["basil://prod/us-east-1/agent-a"]
2919"#,
2920 );
2921 let err = load_run_config(&overrides_for(config.clone()))
2922 .expect_err("enabled invocation requires request encryption key");
2923 assert!(
2924 err.to_string()
2925 .contains("invocation.request-encryption-key-id is required")
2926 );
2927 std::fs::remove_file(config).expect("remove temp config");
2928 }
2929
2930 #[test]
2931 fn invocation_catalog_bindings_require_expected_class_and_use() {
2932 let invocation = InvocationRuntimeConfig {
2933 enabled: true,
2934 broker_identity: Some(BrokerIdentityRuntimeConfig {
2935 id: "basil://prod/us-east-1/agent-a".to_string(),
2936 response_signing_key_id: "broker.response".to_string(),
2937 }),
2938 audiences: vec!["basil://prod/us-east-1/agent-a".to_string()],
2939 request_encryption_key_id: Some("broker.request".to_string()),
2940 max_ttl_secs: 60,
2941 clock_skew_secs: 30,
2942 replay_cache_capacity: 4096,
2943 now_unix_override: None,
2944 };
2945 let valid = catalog_with_invocation_keys(
2946 catalog_key(
2947 Class::Asymmetric,
2948 Some(KeyAlgorithm::Ed25519),
2949 &["broker_key_use=response-signing"],
2950 ),
2951 catalog_key(
2952 Class::Sealing,
2953 Some(KeyAlgorithm::X25519),
2954 &["broker_key_use=request-encryption"],
2955 ),
2956 );
2957 validate_invocation_catalog_bindings(&invocation, &valid).expect("valid keys pass");
2958
2959 let wrong_response_class = catalog_with_invocation_keys(
2960 catalog_key(
2961 Class::Sealing,
2962 Some(KeyAlgorithm::X25519),
2963 &["broker_key_use=response-signing"],
2964 ),
2965 catalog_key(
2966 Class::Sealing,
2967 Some(KeyAlgorithm::X25519),
2968 &["broker_key_use=request-encryption"],
2969 ),
2970 );
2971 let err = validate_invocation_catalog_bindings(&invocation, &wrong_response_class)
2972 .expect_err("wrong response signing class rejects");
2973 assert!(
2974 err.to_string().contains(
2975 "broker response-signing key `broker.response` must be class `asymmetric`"
2976 )
2977 );
2978
2979 let wrong_request_use = catalog_with_invocation_keys(
2980 catalog_key(
2981 Class::Asymmetric,
2982 Some(KeyAlgorithm::Ed25519),
2983 &["broker_key_use=response-signing"],
2984 ),
2985 catalog_key(
2986 Class::Sealing,
2987 Some(KeyAlgorithm::X25519),
2988 &["broker_key_use=response-signing"],
2989 ),
2990 );
2991 let err = validate_invocation_catalog_bindings(&invocation, &wrong_request_use)
2992 .expect_err("wrong request encryption use rejects");
2993 assert!(err.to_string().contains(
2994 "broker request-encryption key `broker.request` must carry label `broker_key_use=request-encryption`"
2995 ));
2996 }
2997
2998 #[cfg(feature = "http")]
2999 #[test]
3000 fn jwks_http_surface_enables_and_parses_listen_from_config() {
3001 let config = temp_config(
3002 r#"
3003catalog = "/cfg/catalog.json"
3004policy = "/cfg/policy.json"
3005bundle = "/cfg/bundle.sealed"
3006
3007[jwks]
3008enable = true
3009listen = "0.0.0.0:9443"
3010"#,
3011 );
3012 let args = ConfigOverrides {
3013 config: Some(config.clone()),
3014 catalog: None,
3015 policy: None,
3016 bundle: None,
3017 socket: None,
3018 vault_addr: None,
3019 };
3020 let loaded = load_run_config(&args).expect("load run config");
3021 assert!(loaded.jwks.enable);
3022 assert_eq!(
3023 loaded.jwks.listen,
3024 "0.0.0.0:9443".parse().expect("custom listen")
3025 );
3026
3027 std::fs::remove_file(config).expect("remove temp config");
3028 }
3029
3030 #[cfg(feature = "http")]
3031 #[test]
3032 fn jwks_listen_must_be_a_valid_socket_addr() {
3033 let bad = JwksConfigFile {
3034 enable: true,
3035 listen: "not-an-addr".to_string(),
3036 issuer: None,
3037 tls: JwksTlsConfigFile::default(),
3038 };
3039 assert!(
3040 resolve_jwks_config(&bad).is_err(),
3041 "a malformed jwks.listen fails closed at startup"
3042 );
3043 }
3044
3045 #[cfg(feature = "http")]
3046 #[test]
3047 fn jwks_issuer_must_be_absolute_http_url_or_unset() {
3048 let none = JwksConfigFile {
3050 enable: true,
3051 listen: "127.0.0.1:8201".to_string(),
3052 issuer: None,
3053 tls: JwksTlsConfigFile::default(),
3054 };
3055 assert!(resolve_jwks_config(&none).expect("ok").issuer.is_none());
3056
3057 let bad = JwksConfigFile {
3059 enable: true,
3060 listen: "127.0.0.1:8201".to_string(),
3061 issuer: Some("basil.example.com".to_string()),
3062 tls: JwksTlsConfigFile::default(),
3063 };
3064 assert!(
3065 resolve_jwks_config(&bad).is_err(),
3066 "a non-http(s) issuer fails closed at startup"
3067 );
3068
3069 let good = JwksConfigFile {
3071 enable: true,
3072 listen: "127.0.0.1:8201".to_string(),
3073 issuer: Some("https://basil.example.com/".to_string()),
3074 tls: JwksTlsConfigFile::default(),
3075 };
3076 assert_eq!(
3077 resolve_jwks_config(&good).expect("ok").issuer.as_deref(),
3078 Some("https://basil.example.com")
3079 );
3080 }
3081
3082 #[cfg(feature = "http")]
3083 #[test]
3084 fn jwks_issuer_rejects_ssrf_prone_url_shapes() {
3085 for issuer in [
3086 "//basil.example.com",
3087 "ftp://basil.example.com",
3088 "file:///etc/passwd",
3089 "unix:///run/basil-jwks.sock",
3090 "http://169.254.169.254/latest/meta-data/",
3091 "http://metadata.google.internal/computeMetadata/v1/",
3092 "https://user:pass@basil.example.com",
3093 "https://basil.example.com/#fragment",
3094 ] {
3095 let bad = JwksConfigFile {
3096 enable: true,
3097 listen: "127.0.0.1:8201".to_string(),
3098 issuer: Some(issuer.to_string()),
3099 tls: JwksTlsConfigFile::default(),
3100 };
3101 assert!(
3102 resolve_jwks_config(&bad).is_err(),
3103 "jwks.issuer `{issuer}` should fail closed"
3104 );
3105 }
3106 }
3107
3108 #[cfg(feature = "http")]
3109 #[test]
3110 fn jwks_tls_defaults_to_disabled() {
3111 let defaults = JwksConfigFile::default();
3112 assert!(resolve_jwks_config(&defaults).expect("ok").tls.is_none());
3113 }
3114
3115 #[cfg(all(feature = "http", not(feature = "http-tls")))]
3116 #[test]
3117 fn jwks_tls_requires_cargo_feature() {
3118 let config = JwksConfigFile {
3119 enable: true,
3120 listen: "127.0.0.1:8201".to_string(),
3121 issuer: Some("https://basil.example.com".to_string()),
3122 tls: JwksTlsConfigFile {
3123 enable: true,
3124 cert_file: Some("/etc/basil/jwks-cert.pem".into()),
3125 key_file: Some("/etc/basil/jwks-key.pem".into()),
3126 },
3127 };
3128 let err = resolve_jwks_config(&config).expect_err("feature missing");
3129 assert!(err.to_string().contains("http-tls"));
3130 }
3131
3132 #[cfg(not(feature = "http"))]
3133 #[test]
3134 fn jwks_enable_requires_http_feature() {
3135 let config = temp_config(
3136 r#"
3137catalog = "/cfg/catalog.json"
3138policy = "/cfg/policy.json"
3139bundle = "/cfg/bundle.sealed"
3140
3141[jwks]
3142enable = true
3143"#,
3144 );
3145 let err = load_run_config(&overrides_for(config.clone())).expect_err("feature missing");
3146 assert!(err.to_string().contains("http cargo feature"));
3147 std::fs::remove_file(config).expect("remove temp config");
3148 }
3149
3150 #[cfg(feature = "http-tls")]
3151 #[test]
3152 fn jwks_tls_requires_cert_and_key_when_enabled() {
3153 let missing_key = JwksConfigFile {
3154 enable: true,
3155 listen: "127.0.0.1:8201".to_string(),
3156 issuer: Some("https://basil.example.com".to_string()),
3157 tls: JwksTlsConfigFile {
3158 enable: true,
3159 cert_file: Some("/etc/basil/jwks-cert.pem".into()),
3160 key_file: None,
3161 },
3162 };
3163 let err = resolve_jwks_config(&missing_key).expect_err("key required");
3164 assert!(err.to_string().contains("key-file"));
3165 }
3166
3167 #[cfg(feature = "otlp")]
3168 #[test]
3169 fn otel_logging_requires_non_empty_http_endpoint_when_enabled() {
3170 let missing = OpenTelemetryLoggingConfigFile {
3171 enable: true,
3172 endpoint: None,
3173 protocol: OpenTelemetryProtocol::Grpc,
3174 };
3175 assert!(required_otel_endpoint(&missing).is_err());
3176
3177 let empty = OpenTelemetryLoggingConfigFile {
3178 enable: true,
3179 endpoint: Some(" ".to_string()),
3180 protocol: OpenTelemetryProtocol::Grpc,
3181 };
3182 assert!(required_otel_endpoint(&empty).is_err());
3183
3184 let unix = OpenTelemetryLoggingConfigFile {
3185 enable: true,
3186 endpoint: Some("unix:///run/otel.sock".to_string()),
3187 protocol: OpenTelemetryProtocol::Grpc,
3188 };
3189 assert!(required_otel_endpoint(&unix).is_err());
3190
3191 for endpoint in [
3192 "//otel.example.com:4317",
3193 "ftp://otel.example.com:4317",
3194 "file:///tmp/otel.sock",
3195 "http://169.254.169.254/latest/meta-data/",
3196 "http://metadata.google.internal/computeMetadata/v1/",
3197 "https://user:pass@otel.example.com:4317",
3198 "https://otel.example.com:4317/#fragment",
3199 ] {
3200 let bad = OpenTelemetryLoggingConfigFile {
3201 enable: true,
3202 endpoint: Some(endpoint.to_string()),
3203 protocol: OpenTelemetryProtocol::Grpc,
3204 };
3205 assert!(
3206 required_otel_endpoint(&bad).is_err(),
3207 "OpenTelemetry endpoint `{endpoint}` should fail closed"
3208 );
3209 }
3210
3211 let http = OpenTelemetryLoggingConfigFile {
3212 enable: true,
3213 endpoint: Some("http://localhost:4317".to_string()),
3214 protocol: OpenTelemetryProtocol::Grpc,
3215 };
3216 assert_eq!(
3217 required_otel_endpoint(&http).expect("valid endpoint"),
3218 "http://localhost:4317/"
3219 );
3220 }
3221
3222 #[test]
3223 fn cli_overrides_win_over_config_file() {
3224 let config = temp_config(
3225 r#"
3226catalog = "/cfg/catalog.json"
3227policy = "/cfg/policy.json"
3228bundle = "/cfg/bundle.sealed"
3229socket = "/cfg/basil.sock"
3230vault-addr = "http://cfg-vault:8200"
3231"#,
3232 );
3233 let args = ConfigOverrides {
3234 config: Some(config.clone()),
3235 catalog: Some(PathBuf::from("/cli/catalog.json")),
3236 policy: Some(PathBuf::from("/cli/policy.json")),
3237 bundle: Some(PathBuf::from("/cli/bundle.sealed")),
3238 socket: Some("/cli/basil.sock".to_string()),
3239 vault_addr: Some("http://cli-vault:8200".to_string()),
3240 };
3241
3242 let loaded = load_run_config(&args).expect("load run config");
3243
3244 assert_eq!(loaded.setup.catalog, PathBuf::from("/cli/catalog.json"));
3245 assert_eq!(loaded.setup.policy, PathBuf::from("/cli/policy.json"));
3246 assert_eq!(loaded.setup.bundle, PathBuf::from("/cli/bundle.sealed"));
3247 assert_eq!(loaded.socket.as_deref(), Some("/cli/basil.sock"));
3248 assert_eq!(loaded.setup.vault_addr, "http://cli-vault:8200");
3249
3250 std::fs::remove_file(config).expect("remove temp config");
3251 }
3252
3253 #[test]
3254 fn run_cli_rejects_removed_startup_overrides() {
3255 #[derive(Debug, clap::Parser)]
3256 struct TestCli {
3257 #[command(flatten)]
3258 args: RunArgs,
3259 }
3260
3261 let err = TestCli::try_parse_from([
3262 "basil-agent-run",
3263 "--catalog",
3264 "/tmp/catalog.json",
3265 "--policy",
3266 "/tmp/policy.json",
3267 "--bundle",
3268 "/tmp/bundle.sealed",
3269 "--capability-policy",
3270 "off",
3271 ])
3272 .expect_err("removed flag rejected");
3273 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
3274 }
3275
3276 mod explain {
3283 use super::*;
3284 use crate::catalog::{Op, Pdp, load};
3285
3286 const CATALOG: &str = r#"{
3287 "schemaVersion": 1,
3288 "backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
3289 "keys": {
3290 "grafana.admin_password": {
3291 "class": "value", "backend": "bao", "engine": "kv2",
3292 "path": "secret/data/grafana/admin", "writable": true,
3293 "missing": "error", "description": "grafana admin value"
3294 }
3295 }
3296 }"#;
3297
3298 const POLICY: &str = r#"{
3299 "schemaVersion": 2,
3300 "subjects": {
3301 "svc.grafana": { "allOf": [ { "kind": "unix", "uid": 9002 } ] },
3302 "ops.wheel": { "allOf": [ { "kind": "unix", "gid": 10 } ] }
3303 },
3304 "roles": { "reader": ["get", "list"], "operator": ["set", "rotate"] },
3305 "rules": [
3306 { "id": "grafana-reader", "subjects": ["svc.grafana"], "action": ["role:reader"], "target": ["grafana.admin_password"] },
3307 { "id": "wheel-operator", "subjects": ["ops.wheel"], "action": ["role:operator"], "target": ["grafana.**"] }
3308 ],
3309 "config": {
3310 "names": { "users": { "9002": "svc-grafana" }, "groups": { "10": "wheel" } },
3311 "memberships": { "9002": [9002] }
3312 }
3313 }"#;
3314
3315 fn loaded() -> (
3318 crate::catalog::Catalog,
3319 crate::catalog::ResolvedPolicy,
3320 crate::catalog::Config,
3321 ) {
3322 let (catalog, policy, config, _w) = load(CATALOG, POLICY).expect("fixture loads");
3323 (catalog, policy, config)
3324 }
3325
3326 fn render_explanation_to_string(
3327 pdp: &Pdp,
3328 subject: &str,
3329 op: Op,
3330 key: &str,
3331 json: bool,
3332 ) -> String {
3333 let mut buf = Vec::new();
3334 render_explanation(&mut buf, pdp, subject, op, key, json).expect("render");
3335 String::from_utf8(buf).expect("utf8")
3336 }
3337
3338 #[test]
3339 fn json_allow_shape_carries_matched_rule_fields() {
3340 let (c, p, cfg) = loaded();
3341 let pdp = Pdp::new(&c, &p, &cfg);
3342 let out = render_explanation_to_string(
3343 &pdp,
3344 "svc.grafana",
3345 Op::Get,
3346 "grafana.admin_password",
3347 true,
3348 );
3349 let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3350 assert_eq!(doc["decision"], "allow");
3351 assert_eq!(doc["subject"], "svc.grafana");
3352 assert_eq!(doc["op"], "get");
3353 assert_eq!(doc["key"], "grafana.admin_password");
3354 assert_eq!(doc["via"], "subject:svc.grafana");
3355 assert_eq!(doc["matched_rule"]["rule"], "grafana-reader");
3356 assert_eq!(doc["matched_rule"]["via"], "subject:svc.grafana");
3357 assert!(doc["matched_rule"]["action"].is_string());
3358 assert!(doc["matched_rule"]["target"].is_string());
3359 }
3360
3361 #[test]
3362 fn json_deny_shape_carries_default_deny_reason() {
3363 let (c, p, cfg) = loaded();
3364 let pdp = Pdp::new(&c, &p, &cfg);
3365 let out = render_explanation_to_string(
3367 &pdp,
3368 "svc.grafana",
3369 Op::Set,
3370 "grafana.admin_password",
3371 true,
3372 );
3373 let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
3374 assert_eq!(doc["decision"], "deny");
3375 assert_eq!(doc["reason"], "not_permitted");
3376 assert!(doc.get("via").is_none());
3378 assert!(doc.get("matched_rule").is_none());
3379
3380 let unknown =
3382 render_explanation_to_string(&pdp, "svc.grafana", Op::Get, "no.such.key", true);
3383 let doc: serde_json::Value = serde_json::from_str(&unknown).expect("json");
3384 assert_eq!(doc["decision"], "deny");
3385 assert_eq!(doc["reason"], "unknown_key");
3386 }
3387
3388 #[test]
3389 fn subject_name_selects_the_rule_to_explain() {
3390 let (c, p, cfg) = loaded();
3391 let pdp = Pdp::new(&c, &p, &cfg);
3392 let denied = render_explanation_to_string(
3393 &pdp,
3394 "svc.grafana",
3395 Op::Set,
3396 "grafana.admin_password",
3397 true,
3398 );
3399 let doc: serde_json::Value = serde_json::from_str(&denied).expect("json");
3400 assert_eq!(doc["decision"], "deny", "reader subject cannot set");
3401
3402 let allowed = render_explanation_to_string(
3403 &pdp,
3404 "ops.wheel",
3405 Op::Set,
3406 "grafana.admin_password",
3407 true,
3408 );
3409 let doc: serde_json::Value = serde_json::from_str(&allowed).expect("json");
3410 assert_eq!(doc["decision"], "allow", "operator subject can set");
3411 assert_eq!(doc["via"], "subject:ops.wheel");
3412 assert_eq!(doc["matched_rule"]["rule"], "wheel-operator");
3413 }
3414
3415 #[test]
3416 fn effective_mode_renders_granted_key_op_set() {
3417 let (c, p, cfg) = loaded();
3418 let pdp = Pdp::new(&c, &p, &cfg);
3419 let mut buf = Vec::new();
3420 render_effective(&mut buf, &pdp, "ops.wheel", true).expect("render effective");
3421 let doc: serde_json::Value = serde_json::from_slice(&buf).expect("effective json");
3422 assert_eq!(doc["subject"], "ops.wheel");
3423 let rows = doc["effective"].as_array().expect("effective rows");
3424 let pairs: Vec<(&str, &str)> = rows
3425 .iter()
3426 .filter_map(|r| Some((r["key"].as_str()?, r["op"].as_str()?)))
3427 .collect();
3428 assert!(
3429 pairs.contains(&("grafana.admin_password", "set")),
3430 "operator set granted: {pairs:?}"
3431 );
3432 assert!(
3433 pairs.contains(&("grafana.admin_password", "rotate")),
3434 "operator rotate granted: {pairs:?}"
3435 );
3436 assert!(
3437 rows.iter().all(|r| r["via"] == "subject:ops.wheel"),
3438 "every grant is via the wheel subject"
3439 );
3440
3441 let mut buf = Vec::new();
3444 render_effective(&mut buf, &pdp, "missing.subject", true).expect("render effective");
3445 let doc: serde_json::Value = serde_json::from_slice(&buf).expect("json");
3446 assert!(
3447 doc["effective"].as_array().expect("rows").is_empty(),
3448 "no grants -> empty effective set"
3449 );
3450 }
3451
3452 #[test]
3457 fn missing_op_key_without_effective_bails_cleanly() {
3458 let dir = std::env::temp_dir();
3459 let stamp = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4());
3460 let catalog_path = dir.join(format!("basil-explain-cat-{stamp}.json"));
3461 let policy_path = dir.join(format!("basil-explain-pol-{stamp}.json"));
3462 std::fs::write(&catalog_path, CATALOG).expect("write catalog");
3463 std::fs::write(&policy_path, POLICY).expect("write policy");
3464
3465 let args = ExplainArgs {
3466 subject: "svc.grafana".to_string(),
3467 op: None,
3468 key: None,
3469 effective: false,
3470 live: false,
3471 json: true,
3472 overrides: ConfigOverrides {
3473 config: None,
3474 catalog: Some(catalog_path.clone()),
3475 policy: Some(policy_path.clone()),
3476 bundle: None,
3477 socket: None,
3478 vault_addr: None,
3479 },
3480 };
3481
3482 let err = run_explain(&args).expect_err("missing op/key must bail");
3483 assert!(
3484 err.to_string().contains("--op and --key are required"),
3485 "clean bail, not a panic: {err}"
3486 );
3487
3488 std::fs::remove_file(&catalog_path).ok();
3489 std::fs::remove_file(&policy_path).ok();
3490 }
3491 }
3492}