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