#![cfg_attr(test, allow(clippy::indexing_slicing))]
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::catalog::{Class, KeyAlgorithm};
use crate::seal::{BackendCred, CredBundle};
use crate::service::broker::{BrokerIdentityRuntimeConfig, InvocationRuntimeConfig};
use crate::{
AuditLog, Backend, BackendKind, BackendManager, BrokerLimits, BrokerState, CapabilityPolicy,
DEFAULT_MAX_ENCRYPT_SIZE, DEFAULT_MAX_PAYLOAD_SIZE, DEFAULT_ROTATION_GRACE_VERSIONS,
DEFAULT_SOCKET_MODE, DEFAULT_SVID_TTL_SECS, JwtRevocationStore, ReloadActor, ReloadInputs,
ServerConfig, SpiffeConfig, SpiffeVaultBackend, VaultBackend, enforce_capabilities, load,
reload_generation, run_grpc,
};
use crate::{bundle_cli, doctor, init, unlock};
use anyhow::{Context, Result, bail};
#[cfg(feature = "keystore-backend")]
use basil_keystore_backend::StoreConfig;
#[cfg(feature = "otlp")]
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
#[cfg(feature = "otlp")]
use opentelemetry_otlp::{Protocol, WithExportConfig};
#[cfg(feature = "otlp")]
use opentelemetry_sdk::logs::SdkLoggerProvider;
use serde::Deserialize;
use serde::de::{self, Visitor};
use tokio::signal::unix::{SignalKind, signal};
use tracing::{info, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
const MAX_INVOCATION_TTL_SECS: u32 = 300;
const MAX_INVOCATION_CLOCK_SKEW_SECS: u32 = 300;
const MAX_INVOCATION_REPLAY_CACHE_CAPACITY: usize = 1_000_000;
const BROKER_KEY_USE_LABEL: &str = "broker_key_use";
const BROKER_RESPONSE_SIGNING_USE: &str = "response-signing";
const BROKER_REQUEST_ENCRYPTION_USE: &str = "request-encryption";
#[derive(Debug, clap::Args)]
pub struct RunArgs {
#[command(flatten)]
overrides: ConfigOverrides,
}
#[derive(Debug, clap::Args)]
pub struct ExplainArgs {
#[arg(long)]
subject: String,
#[arg(long, value_parser = parse_op, required_unless_present = "effective")]
op: Option<crate::catalog::Op>,
#[arg(long, required_unless_present = "effective")]
key: Option<String>,
#[arg(long)]
effective: bool,
#[arg(long, conflicts_with = "effective")]
live: bool,
#[arg(long)]
json: bool,
#[command(flatten)]
overrides: ConfigOverrides,
}
impl ExplainArgs {
#[must_use]
pub const fn is_live(&self) -> bool {
self.live
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub fn op_token(&self) -> Option<&'static str> {
self.op.map(crate::catalog::Op::token)
}
#[must_use]
pub fn key(&self) -> Option<&str> {
self.key.as_deref()
}
#[must_use]
pub const fn json(&self) -> bool {
self.json
}
}
fn parse_op(s: &str) -> Result<crate::catalog::Op, String> {
crate::catalog::Op::parse(s).map_err(|e| e.to_string())
}
#[derive(Debug, clap::Args)]
pub struct DoctorArgs {
#[arg(long)]
json: bool,
#[arg(long)]
keys: bool,
#[arg(long)]
strict: bool,
#[command(flatten)]
overrides: ConfigOverrides,
}
struct BackendDefaults<'a> {
vault_addr: &'a str,
transit_mount: &'a str,
jwt_auth_mount: &'a str,
jwt_role: &'a str,
jwt_audience: &'a str,
svid_ttl: Duration,
#[cfg(feature = "db-keystore")]
db_keystore_cipher: &'a str,
#[cfg(feature = "keystore-backend")]
onepassword_provider_uri: &'a str,
#[cfg(feature = "keystore-backend")]
onepassword_project: &'a str,
#[cfg(feature = "keystore-backend")]
onepassword_profile: &'a str,
}
async fn backend_from_cred(
name: &str,
backend_ref: &crate::catalog::BackendRef,
cred: &BackendCred,
defaults: &BackendDefaults<'_>,
) -> Result<Box<dyn Backend>> {
match (backend_ref.kind, cred) {
(BackendKind::Keystore, _) => backend_from_keystore_cred(name, backend_ref, cred, defaults),
(BackendKind::AwsKms, cred) => backend_from_aws_kms_cred(name, cred).await,
(BackendKind::GcpKms, cred) => backend_from_gcp_kms_cred(name, cred).await,
(
BackendKind::Vault,
BackendCred::DbKeystoreDek { .. }
| BackendCred::OnePassword { .. }
| BackendCred::AwsKms { .. }
| BackendCred::GcpKms { .. },
) => {
bail!("backend `{name}`: non-vault credential cannot construct a vault backend")
}
(BackendKind::Vault, cred) => backend_from_vault_cred(name, cred, defaults).await,
}
}
#[cfg(feature = "aws-kms")]
async fn backend_from_aws_kms_cred(name: &str, cred: &BackendCred) -> Result<Box<dyn Backend>> {
match cred {
BackendCred::AwsKms { region, profile } => {
let backend = crate::core::backend::aws_kms::AwsKmsBackend::new(region, profile)
.await
.with_context(|| format!("building aws-kms backend for `{name}`"))?;
Ok(Box::new(backend))
}
_ => bail!("backend `{name}`: kind `aws-kms` requires an `AwsKms` credential"),
}
}
#[cfg(not(feature = "aws-kms"))]
#[allow(clippy::unused_async)]
async fn backend_from_aws_kms_cred(name: &str, _cred: &BackendCred) -> Result<Box<dyn Backend>> {
bail!("backend `{name}`: kind `aws-kms` requires the aws-kms feature")
}
#[cfg(feature = "gcp-kms")]
async fn backend_from_gcp_kms_cred(name: &str, cred: &BackendCred) -> Result<Box<dyn Backend>> {
match cred {
BackendCred::GcpKms {
project,
location,
key_ring,
service_account_json,
} => {
let backend = crate::core::backend::gcp_kms::GcpKmsBackend::new(
project,
location,
key_ring,
service_account_json
.as_ref()
.map(zero_secrets::SecretString::expose_secret),
)
.await
.with_context(|| format!("building gcp-kms backend for `{name}`"))?;
Ok(Box::new(backend))
}
_ => bail!("backend `{name}`: kind `gcp-kms` requires a `GcpKms` credential"),
}
}
#[cfg(not(feature = "gcp-kms"))]
#[allow(clippy::unused_async)]
async fn backend_from_gcp_kms_cred(name: &str, _cred: &BackendCred) -> Result<Box<dyn Backend>> {
bail!("backend `{name}`: kind `gcp-kms` requires the gcp-kms feature")
}
async fn backend_from_vault_cred(
name: &str,
cred: &BackendCred,
defaults: &BackendDefaults<'_>,
) -> Result<Box<dyn Backend>> {
match cred {
BackendCred::VaultToken { token, addr } => {
let addr = addr.as_deref().unwrap_or(defaults.vault_addr);
let backend = VaultBackend::new(addr, token.expose_secret(), defaults.transit_mount)
.with_context(|| format!("building token backend for `{name}`"))?;
Ok(Box::new(backend))
}
BackendCred::VaultAppRole {
role_id,
secret_id,
addr,
} => {
let addr = addr.as_deref().unwrap_or(defaults.vault_addr);
let token = unlock::approle_login(addr, role_id, secret_id.expose_secret())
.await
.with_context(|| format!("AppRole login for backend `{name}`"))?;
let backend = VaultBackend::new(addr, token.as_str(), defaults.transit_mount)
.with_context(|| format!("building approle backend for `{name}`"))?;
Ok(Box::new(backend))
}
BackendCred::SpiffeSigner { key_pem, spiffe_id } => {
anyhow::ensure!(
!defaults.jwt_role.trim().is_empty(),
"backend `{name}`: SpiffeSigner cred requires a jwt role \
(config key `jwt-role`); none set, failing closed"
);
let cfg = SpiffeConfig {
vault_addr: defaults.vault_addr.to_string(),
transit_mount: defaults.transit_mount.to_string(),
jwt_auth_mount: defaults.jwt_auth_mount.to_string(),
role: defaults.jwt_role.to_string(),
spiffe_id: spiffe_id.clone(),
audience: defaults.jwt_audience.to_string(),
svid_ttl: defaults.svid_ttl,
};
let backend = SpiffeVaultBackend::from_signer(key_pem.expose_secret(), cfg)
.with_context(|| format!("building spiffe-signer backend for `{name}`"))?;
Ok(Box::new(backend))
}
BackendCred::DbKeystoreDek { .. }
| BackendCred::OnePassword { .. }
| BackendCred::AwsKms { .. }
| BackendCred::GcpKms { .. } => {
bail!("backend `{name}`: non-vault credential cannot construct a vault backend")
}
BackendCred::Opaque { kind, .. } => bail!(
"backend `{name}`: opaque cred of kind `{kind}` has no backend constructor; fail closed"
),
}
}
#[cfg(feature = "keystore-backend")]
fn backend_from_keystore_cred(
name: &str,
backend_ref: &crate::catalog::BackendRef,
cred: &BackendCred,
defaults: &BackendDefaults<'_>,
) -> Result<Box<dyn Backend>> {
#[cfg(not(feature = "db-keystore"))]
let _ = backend_ref;
match cred {
#[cfg(feature = "db-keystore")]
BackendCred::DbKeystoreDek { dek } => {
let backend = crate::KeystoreBackend::open(StoreConfig::DbKeystore {
path: PathBuf::from(&backend_ref.addr),
cipher: defaults.db_keystore_cipher.to_string(),
dek: dek.clone(),
})
.with_context(|| format!("building db-keystore backend for `{name}`"))?;
Ok(Box::new(backend))
}
#[cfg(not(feature = "db-keystore"))]
BackendCred::DbKeystoreDek { .. } => {
bail!("backend `{name}`: DbKeystoreDek requires the db-keystore feature")
}
#[cfg(feature = "onepassword")]
BackendCred::OnePassword {
provider_uri,
project,
profile,
} => {
let provider_uri = default_if_empty(provider_uri, defaults.onepassword_provider_uri);
let project = default_if_empty(project, defaults.onepassword_project);
let profile = default_if_empty(profile, defaults.onepassword_profile);
anyhow::ensure!(
!provider_uri.trim().is_empty(),
"backend `{name}`: `OnePassword` cred requires provider_uri or config key \
`onepassword-provider-uri`"
);
let backend = crate::KeystoreBackend::open(StoreConfig::OnePassword {
provider_uri: provider_uri.to_string(),
project: project.to_string(),
profile: profile.to_string(),
})
.with_context(|| format!("building onepassword backend for `{name}`"))?;
Ok(Box::new(backend))
}
#[cfg(not(feature = "onepassword"))]
BackendCred::OnePassword { .. } => {
bail!("backend `{name}`: `OnePassword` requires the onepassword feature")
}
BackendCred::VaultToken { .. }
| BackendCred::VaultAppRole { .. }
| BackendCred::SpiffeSigner { .. } => {
bail!("backend `{name}`: vault credential cannot construct a keystore backend")
}
BackendCred::AwsKms { .. } | BackendCred::GcpKms { .. } => {
bail!("backend `{name}`: KMS credential cannot construct a keystore backend")
}
BackendCred::Opaque { kind, .. } => bail!(
"backend `{name}`: opaque cred of kind `{kind}` has no keystore constructor; fail closed"
),
}
}
#[cfg(not(feature = "keystore-backend"))]
fn backend_from_keystore_cred(
name: &str,
_backend_ref: &crate::catalog::BackendRef,
_cred: &BackendCred,
_defaults: &BackendDefaults<'_>,
) -> Result<Box<dyn Backend>> {
bail!("backend `{name}`: kind `keystore` requires the keystore-backend feature")
}
#[cfg(feature = "keystore-backend")]
fn default_if_empty<'a>(value: &'a str, default: &'a str) -> &'a str {
if value.trim().is_empty() {
default
} else {
value
}
}
async fn build_manager(
defaults: &BackendDefaults<'_>,
catalog: crate::Catalog,
creds: &CredBundle,
) -> Result<(BackendManager, String)> {
anyhow::ensure!(
!catalog.backends.is_empty(),
"catalog declares no backends; nothing to route to"
);
let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
let mut label: Option<String> = None;
for (name, backend_ref) in &catalog.backends {
let cred = creds.backends.get(name).with_context(|| {
format!("sealed bundle has no credential for catalog backend `{name}`")
})?;
let backend = backend_from_cred(name, backend_ref, cred, defaults).await?;
if label.is_none() {
label = Some(backend.kind().to_string());
}
backends.insert(name.clone(), backend);
}
let backend_label = label.unwrap_or_else(|| "unknown".to_string());
let manager = BackendManager::new(catalog, backends)
.context("constructing backend manager from catalog")?;
Ok((manager, backend_label))
}
#[derive(Debug, Clone, clap::Args)]
pub struct ConfigOverrides {
#[arg(short = 'c', long, env = "BASIL_CONFIG")]
pub(crate) config: Option<PathBuf>,
#[arg(long, env = "BASIL_CATALOG")]
pub(crate) catalog: Option<PathBuf>,
#[arg(long, env = "BASIL_POLICY")]
pub(crate) policy: Option<PathBuf>,
#[arg(long, env = "BASIL_BUNDLE")]
pub(crate) bundle: Option<PathBuf>,
#[arg(long, env = "BASIL_SOCKET")]
pub(crate) socket: Option<String>,
#[arg(long, env = "VAULT_ADDR")]
pub(crate) vault_addr: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct AgentConfigFile {
pub(crate) catalog: Option<PathBuf>,
pub(crate) policy: Option<PathBuf>,
pub(crate) bundle: Option<PathBuf>,
pub(crate) socket: Option<String>,
pub(crate) socket_mode: Option<SocketMode>,
pub(crate) socket_group: Option<String>,
pub(crate) vault_addr: Option<String>,
pub(crate) transit_mount: Option<String>,
pub(crate) jwt_auth_mount: Option<String>,
pub(crate) jwt_role: Option<String>,
pub(crate) jwt_audience: Option<String>,
pub(crate) svid_ttl_secs: Option<u64>,
pub(crate) capability_policy: Option<String>,
#[cfg(feature = "keystore-backend")]
pub(crate) db_keystore_cipher: Option<String>,
#[cfg(feature = "keystore-backend")]
pub(crate) onepassword_provider_uri: Option<String>,
#[cfg(feature = "keystore-backend")]
pub(crate) onepassword_project: Option<String>,
#[cfg(feature = "keystore-backend")]
pub(crate) onepassword_profile: Option<String>,
pub(crate) max_encrypt_size: Option<usize>,
pub(crate) max_payload_size: Option<usize>,
pub(crate) grace_versions: Option<u32>,
pub(crate) retain_versions: Option<u32>,
pub(crate) retention_sweep_secs: Option<u64>,
pub(crate) audit_log: Option<PathBuf>,
pub(crate) no_reconcile: Option<bool>,
pub(crate) logging: LoggingConfigFile,
pub(crate) unlock: UnlockConfigFile,
pub(crate) broker_identity: BrokerIdentityConfigFile,
pub(crate) invocation: InvocationConfigFile,
pub(crate) jwks: JwksConfigFile,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct LoggingConfigFile {
stdout: StdoutLoggingSinkConfigFile,
journald: LoggingSinkConfigFile,
file: FileLoggingConfigFile,
opentelemetry: OpenTelemetryLoggingConfigFile,
}
impl Default for LoggingConfigFile {
fn default() -> Self {
Self {
stdout: StdoutLoggingSinkConfigFile::default(),
journald: LoggingSinkConfigFile { enable: true },
file: FileLoggingConfigFile::default(),
opentelemetry: OpenTelemetryLoggingConfigFile::default(),
}
}
}
impl LoggingConfigFile {
fn stdout_enabled(&self) -> bool {
self.stdout.enable.unwrap_or(!self.file.enable)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
struct StdoutLoggingSinkConfigFile {
enable: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
struct LoggingSinkConfigFile {
enable: bool,
}
impl Default for LoggingSinkConfigFile {
fn default() -> Self {
Self { enable: true }
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
struct FileLoggingConfigFile {
enable: bool,
#[serde(alias = "path")]
dir: Option<PathBuf>,
prefix: String,
rotation: FileLoggingRotation,
}
impl Default for FileLoggingConfigFile {
fn default() -> Self {
Self {
enable: false,
dir: None,
prefix: "basil-agent-".to_owned(),
rotation: FileLoggingRotation::Daily,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum FileLoggingRotation {
None,
Hourly,
Daily,
Weekly,
}
impl From<FileLoggingRotation> for tracing_appender::rolling::Rotation {
fn from(rotation: FileLoggingRotation) -> Self {
match rotation {
FileLoggingRotation::None => Self::NEVER,
FileLoggingRotation::Hourly => Self::HOURLY,
FileLoggingRotation::Daily => Self::DAILY,
FileLoggingRotation::Weekly => Self::WEEKLY,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
struct OpenTelemetryLoggingConfigFile {
enable: bool,
endpoint: Option<String>,
protocol: OpenTelemetryProtocol,
}
impl Default for OpenTelemetryLoggingConfigFile {
fn default() -> Self {
Self {
enable: false,
endpoint: None,
protocol: OpenTelemetryProtocol::Grpc,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum OpenTelemetryProtocol {
Grpc,
HttpBinary,
HttpJson,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SocketMode(pub(crate) u32);
impl SocketMode {
const MAX: u32 = 0o7777;
}
impl<'de> Deserialize<'de> for SocketMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(SocketModeVisitor)
}
}
struct SocketModeVisitor;
impl Visitor<'_> for SocketModeVisitor {
type Value = SocketMode;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an octal socket mode string like \"0660\" or integer mode")
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
let mode = u32::try_from(value).map_err(|_| E::custom("socket mode is too large"))?;
validate_socket_mode(mode)
.map(SocketMode)
.map_err(E::custom)
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
let mode = u32::try_from(value).map_err(|_| E::custom("socket mode must be positive"))?;
validate_socket_mode(mode)
.map(SocketMode)
.map_err(E::custom)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
parse_socket_mode(value).map(SocketMode).map_err(E::custom)
}
}
fn parse_socket_mode(value: &str) -> Result<u32, String> {
let trimmed = value.trim();
let octal = trimmed.strip_prefix("0o").unwrap_or(trimmed);
if octal.is_empty() {
return Err("socket mode must not be empty".to_string());
}
if !octal.chars().all(|c| matches!(c, '0'..='7')) {
return Err(format!("socket mode `{value}` must be octal"));
}
let mode = u32::from_str_radix(octal, 8)
.map_err(|err| format!("socket mode `{value}` is invalid: {err}"))?;
validate_socket_mode(mode)
}
fn validate_socket_mode(mode: u32) -> Result<u32, String> {
if mode > SocketMode::MAX {
return Err(format!("socket mode {mode:o} exceeds 07777"));
}
Ok(mode)
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct UnlockConfigFile {
age_yubikey: Option<bool>,
bip39_phrase_file: Option<PathBuf>,
unlock_tpm: Option<bool>,
unlock_passphrase_file: Option<PathBuf>,
unlock_passphrase_no_wipe: Option<bool>,
strict_bundle_perms: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct BrokerIdentityConfigFile {
id: Option<String>,
response_signing_key_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct InvocationConfigFile {
enable: bool,
audience: Vec<String>,
request_encryption_key_id: Option<String>,
max_ttl_secs: u32,
clock_skew_secs: u32,
replay_cache_capacity: usize,
}
impl Default for InvocationConfigFile {
fn default() -> Self {
Self {
enable: false,
audience: Vec::new(),
request_encryption_key_id: None,
max_ttl_secs: basil_proto::invocation::DEFAULT_EXPIRES_AFTER_SECS,
clock_skew_secs: 30,
replay_cache_capacity: 4096,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct JwksConfigFile {
enable: bool,
listen: String,
issuer: Option<String>,
tls: JwksTlsConfigFile,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct JwksTlsConfigFile {
enable: bool,
cert_file: Option<PathBuf>,
key_file: Option<PathBuf>,
}
impl Default for JwksConfigFile {
fn default() -> Self {
Self {
enable: false,
listen: "127.0.0.1:8201".to_string(),
issuer: None,
tls: JwksTlsConfigFile::default(),
}
}
}
#[cfg(feature = "http")]
#[derive(Debug, Clone)]
struct JwksConfig {
enable: bool,
listen: std::net::SocketAddr,
issuer: Option<String>,
tls: Option<crate::jwks::JwksTlsConfig>,
}
#[derive(Debug, Clone)]
struct SetupArgs {
catalog: PathBuf,
policy: PathBuf,
bundle: PathBuf,
vault_addr: String,
transit_mount: String,
jwt_auth_mount: String,
jwt_role: String,
jwt_audience: String,
svid_ttl_secs: u64,
capability_policy: CapabilityPolicy,
#[cfg(feature = "db-keystore")]
db_keystore_cipher: String,
#[cfg(feature = "keystore-backend")]
onepassword_provider_uri: String,
#[cfg(feature = "keystore-backend")]
onepassword_project: String,
#[cfg(feature = "keystore-backend")]
onepassword_profile: String,
unlock: unlock::UnlockArgs,
}
#[derive(Debug, Clone)]
struct RunConfig {
socket: Option<String>,
socket_mode: u32,
socket_group: Option<String>,
max_encrypt_size: usize,
max_payload_size: usize,
grace_versions: u32,
retain_versions: Option<u32>,
retention_sweep_secs: u64,
audit_log: Option<PathBuf>,
no_reconcile: bool,
invocation: InvocationRuntimeConfig,
#[cfg(feature = "http")]
jwks: JwksConfig,
setup: SetupArgs,
}
fn parse_capability_policy(s: &str) -> Result<CapabilityPolicy, String> {
s.parse()
}
fn load_run_config(overrides: &ConfigOverrides) -> Result<RunConfig> {
let file = load_config_file(overrides)?;
let setup = build_setup(&file, overrides)?;
#[cfg(feature = "http")]
let jwks = resolve_jwks_config(&file.jwks)?;
#[cfg(not(feature = "http"))]
reject_jwks_config(&file.jwks)?;
Ok(RunConfig {
socket: overrides.socket.clone().or(file.socket),
socket_mode: file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0),
socket_group: file.socket_group,
max_encrypt_size: file.max_encrypt_size.unwrap_or(DEFAULT_MAX_ENCRYPT_SIZE),
max_payload_size: file.max_payload_size.unwrap_or(DEFAULT_MAX_PAYLOAD_SIZE),
grace_versions: file
.grace_versions
.unwrap_or(DEFAULT_ROTATION_GRACE_VERSIONS),
retain_versions: file.retain_versions,
retention_sweep_secs: file.retention_sweep_secs.unwrap_or(3600),
audit_log: file.audit_log,
no_reconcile: file.no_reconcile.unwrap_or(false),
invocation: resolve_invocation_config(&file.broker_identity, &file.invocation)?,
#[cfg(feature = "http")]
jwks,
setup,
})
}
fn resolve_invocation_config(
identity_file: &BrokerIdentityConfigFile,
file: &InvocationConfigFile,
) -> Result<InvocationRuntimeConfig> {
if file.max_ttl_secs == 0 {
bail!("invocation.max-ttl-secs must be greater than zero");
}
if file.max_ttl_secs > MAX_INVOCATION_TTL_SECS {
bail!("invocation.max-ttl-secs must be at most {MAX_INVOCATION_TTL_SECS} seconds");
}
if file.clock_skew_secs > MAX_INVOCATION_CLOCK_SKEW_SECS {
bail!(
"invocation.clock-skew-secs must be at most {MAX_INVOCATION_CLOCK_SKEW_SECS} seconds"
);
}
if file.replay_cache_capacity == 0 {
bail!("invocation.replay-cache-capacity must be greater than zero");
}
if file.replay_cache_capacity > MAX_INVOCATION_REPLAY_CACHE_CAPACITY {
bail!(
"invocation.replay-cache-capacity must be at most {MAX_INVOCATION_REPLAY_CACHE_CAPACITY}"
);
}
let broker_identity = resolve_broker_identity_config(identity_file)?;
let request_encryption_key_id = optional_nonempty_config(
"invocation.request-encryption-key-id",
file.request_encryption_key_id.as_ref(),
)?;
let mut audiences = Vec::with_capacity(file.audience.len());
for audience in &file.audience {
let trimmed = audience.trim();
if trimmed.is_empty() {
bail!("invocation.audience must not contain blank values");
}
validate_basil_identity_uri("invocation.audience", trimmed)?;
audiences.push(trimmed.to_string());
}
audiences.sort();
audiences.dedup();
if file.enable && audiences.is_empty() {
bail!("invocation.audience must be set when invocation.enable is true");
}
if file.enable && broker_identity.is_none() {
bail!(
"broker-identity.id and broker-identity.response-signing-key-id are required when invocation.enable is true"
);
}
if file.enable && request_encryption_key_id.is_none() {
bail!("invocation.request-encryption-key-id is required when invocation.enable is true");
}
Ok(InvocationRuntimeConfig {
enabled: file.enable,
broker_identity,
audiences,
request_encryption_key_id,
max_ttl_secs: file.max_ttl_secs,
clock_skew_secs: file.clock_skew_secs,
replay_cache_capacity: file.replay_cache_capacity,
now_unix_override: None,
})
}
fn resolve_broker_identity_config(
file: &BrokerIdentityConfigFile,
) -> Result<Option<BrokerIdentityRuntimeConfig>> {
let id = optional_nonempty_config("broker-identity.id", file.id.as_ref())?;
let response_signing_key_id = optional_nonempty_config(
"broker-identity.response-signing-key-id",
file.response_signing_key_id.as_ref(),
)?;
match (id, response_signing_key_id) {
(None, None) => Ok(None),
(Some(id), Some(response_signing_key_id)) => {
validate_basil_identity_uri("broker-identity.id", &id)?;
Ok(Some(BrokerIdentityRuntimeConfig {
id,
response_signing_key_id,
}))
}
(None, Some(_)) | (Some(_), None) => {
bail!(
"broker-identity.id and broker-identity.response-signing-key-id must be set together"
)
}
}
}
fn optional_nonempty_config(field: &str, value: Option<&String>) -> Result<Option<String>> {
let Some(raw) = value else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("{field} must not be empty");
}
Ok(Some(trimmed.to_string()))
}
fn validate_basil_identity_uri(field: &str, value: &str) -> Result<()> {
let parsed =
reqwest::Url::parse(value).with_context(|| format!("parsing {field} `{value}`"))?;
if parsed.scheme() != "basil" {
bail!("{field} must use the basil:// scheme");
}
if parsed.host_str().is_none_or(str::is_empty) {
bail!("{field} must include a host component");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!("{field} must not include userinfo");
}
if parsed.query().is_some() || parsed.fragment().is_some() {
bail!("{field} must not include query or fragment components");
}
if parsed.path() == "/" || parsed.path().trim_matches('/').is_empty() {
bail!("{field} must include a non-empty path component");
}
Ok(())
}
pub(crate) fn validate_invocation_catalog_bindings(
invocation: &InvocationRuntimeConfig,
catalog: &crate::Catalog,
) -> Result<()> {
if !invocation.enabled {
return Ok(());
}
let identity = invocation
.broker_identity
.as_ref()
.context("broker identity is required when invocation is enabled")?;
validate_response_signing_key(catalog, &identity.response_signing_key_id)?;
let request_encryption_key_id = invocation
.request_encryption_key_id
.as_deref()
.context("request encryption key is required when invocation is enabled")?;
validate_request_encryption_key(catalog, request_encryption_key_id)
}
fn catalog_key<'a>(
catalog: &'a crate::Catalog,
key_id: &str,
field: &'static str,
) -> Result<&'a crate::catalog::KeyEntry> {
catalog
.keys
.get(key_id)
.with_context(|| format!("{field} references unknown catalog key `{key_id}`"))
}
fn validate_response_signing_key(catalog: &crate::Catalog, key_id: &str) -> Result<()> {
let key = catalog_key(catalog, key_id, "broker-identity.response-signing-key-id")?;
if key.class != Class::Asymmetric {
bail!("broker response-signing key `{key_id}` must be class `asymmetric`");
}
let Some(algorithm) = key.key_type else {
bail!("broker response-signing key `{key_id}` must declare keyType");
};
if !is_broker_response_signing_algorithm(algorithm) {
bail!(
"broker response-signing key `{key_id}` has unsupported keyType `{}`",
algorithm.token()
);
}
validate_broker_key_use(
key_id,
key,
BROKER_RESPONSE_SIGNING_USE,
"broker response-signing key",
)
}
fn validate_request_encryption_key(catalog: &crate::Catalog, key_id: &str) -> Result<()> {
let key = catalog_key(catalog, key_id, "invocation.request-encryption-key-id")?;
if key.class != Class::Sealing {
bail!("broker request-encryption key `{key_id}` must be class `sealing`");
}
validate_broker_key_use(
key_id,
key,
BROKER_REQUEST_ENCRYPTION_USE,
"broker request-encryption key",
)
}
fn validate_broker_key_use(
key_id: &str,
key: &crate::catalog::KeyEntry,
expected: &str,
role: &str,
) -> Result<()> {
match key.labels.get(BROKER_KEY_USE_LABEL) {
Some(actual) if actual == expected => Ok(()),
Some(actual) => bail!(
"{role} `{key_id}` must carry label `{BROKER_KEY_USE_LABEL}={expected}`, got `{actual}`"
),
None => bail!("{role} `{key_id}` must carry label `{BROKER_KEY_USE_LABEL}={expected}`"),
}
}
const fn is_broker_response_signing_algorithm(algorithm: KeyAlgorithm) -> bool {
matches!(
algorithm,
KeyAlgorithm::Ed25519
| KeyAlgorithm::Rsa2048
| KeyAlgorithm::EcdsaP256
| KeyAlgorithm::MlDsa44
| KeyAlgorithm::MlDsa65
| KeyAlgorithm::MlDsa87
)
}
#[cfg(feature = "http")]
fn resolve_jwks_config(file: &JwksConfigFile) -> Result<JwksConfig> {
let listen = file
.listen
.parse::<std::net::SocketAddr>()
.with_context(|| format!("parsing jwks.listen `{}`", file.listen))?;
let issuer = match file.issuer.as_deref().map(str::trim) {
Some(s) if !s.is_empty() => Some(
parse_public_http_url("jwks.issuer", s)?
.to_string()
.trim_end_matches('/')
.to_string(),
),
_ => None,
};
Ok(JwksConfig {
enable: file.enable,
listen,
issuer,
tls: resolve_jwks_tls_config(&file.tls)?,
})
}
#[cfg(not(feature = "http"))]
fn reject_jwks_config(file: &JwksConfigFile) -> Result<()> {
if file.enable {
bail!("jwks.enable requires the http cargo feature");
}
if file.tls.enable {
bail!("jwks.tls.enable requires the http cargo feature");
}
Ok(())
}
#[cfg(feature = "http")]
fn resolve_jwks_tls_config(file: &JwksTlsConfigFile) -> Result<Option<crate::jwks::JwksTlsConfig>> {
if !file.enable {
return Ok(None);
}
#[cfg(not(feature = "http-tls"))]
{
anyhow::bail!("jwks.tls.enable requires the http-tls cargo feature");
}
#[cfg(feature = "http-tls")]
{
let cert_file = file
.cert_file
.clone()
.context("jwks.tls.cert-file is required when jwks.tls.enable = true")?;
let key_file = file
.key_file
.clone()
.context("jwks.tls.key-file is required when jwks.tls.enable = true")?;
Ok(Some(crate::jwks::JwksTlsConfig {
cert_file,
key_file,
}))
}
}
#[cfg(any(feature = "http", feature = "otlp"))]
fn parse_public_http_url(field: &str, value: &str) -> Result<reqwest::Url> {
let parsed =
reqwest::Url::parse(value).with_context(|| format!("parsing {field} `{value}`"))?;
match parsed.scheme() {
"http" | "https" => {}
scheme => bail!("{field} must use http or https, got `{scheme}`"),
}
if !parsed.username().is_empty() || parsed.password().is_some() {
bail!("{field} must not include userinfo");
}
if parsed.fragment().is_some() {
bail!("{field} must not include a fragment");
}
reject_metadata_host(field, &parsed)?;
Ok(parsed)
}
#[cfg(any(feature = "http", feature = "otlp"))]
fn reject_metadata_host(field: &str, parsed: &reqwest::Url) -> Result<()> {
let Some(host) = parsed.host_str() else {
return Ok(());
};
if host.eq_ignore_ascii_case("metadata.google.internal") {
bail!("{field} must not target cloud instance metadata hosts");
}
if let Ok(ip) = host.parse::<std::net::IpAddr>()
&& is_metadata_ip(ip)
{
bail!("{field} must not target link-local or cloud instance metadata addresses");
}
Ok(())
}
#[cfg(any(feature = "http", feature = "otlp"))]
fn is_metadata_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(addr) => addr.is_link_local() || addr.octets() == [100, 100, 100, 200],
std::net::IpAddr::V6(addr) => addr.is_unicast_link_local(),
}
}
fn load_key_probe_config(overrides: &ConfigOverrides) -> Result<SetupArgs> {
let file = load_config_file(overrides)?;
let mut setup = build_setup(&file, overrides)?;
setup.unlock.passphrase_no_wipe = true;
Ok(setup)
}
pub(crate) fn load_config_file(overrides: &ConfigOverrides) -> Result<AgentConfigFile> {
let Some(path) = &overrides.config else {
return Ok(AgentConfigFile::default());
};
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading config from {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing config from {}", path.display()))
}
struct LoggingGuards {
file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
#[cfg(feature = "otlp")]
otel_provider: Option<SdkLoggerProvider>,
}
#[cfg(feature = "otlp")]
type OtelTracingLayer =
OpenTelemetryTracingBridge<SdkLoggerProvider, opentelemetry_sdk::logs::SdkLogger>;
#[cfg(feature = "otlp")]
type OptionalOtelLayer = (Option<OtelTracingLayer>, Option<SdkLoggerProvider>);
#[cfg(feature = "otlp")]
impl LoggingGuards {
fn shutdown(self) {
let Self {
file_guard,
otel_provider,
} = self;
drop(file_guard);
if let Some(provider) = otel_provider
&& let Err(err) = provider.shutdown()
{
warn!(error = %err, "failed to shut down OpenTelemetry logger provider");
}
}
}
#[cfg(not(feature = "otlp"))]
impl LoggingGuards {
fn shutdown(self) {
let Self { file_guard } = self;
drop(file_guard);
}
}
enum JournaldSink {
Disabled,
Active(tracing_journald::Layer),
Unavailable(String),
}
fn journald_sink(enabled: bool) -> JournaldSink {
if !enabled {
return JournaldSink::Disabled;
}
match tracing_journald::layer() {
Ok(layer) => JournaldSink::Active(layer),
Err(err) => JournaldSink::Unavailable(err.to_string()),
}
}
fn init_logging(config: &LoggingConfigFile) -> Result<LoggingGuards> {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let stdout_layer = config.stdout_enabled().then(fmt::layer);
let (journald_layer, journald_error) = match journald_sink(config.journald.enable) {
JournaldSink::Disabled => (None, None),
JournaldSink::Active(layer) => (Some(layer), None),
JournaldSink::Unavailable(err) => (None, Some(err)),
};
let (file_writer, file_guard) = build_file_writer(&config.file)?;
let file_layer = file_writer.map(|writer| fmt::layer().with_writer(writer));
#[cfg(feature = "otlp")]
let (otel_layer, otel_provider) = build_otel_layer(&config.opentelemetry)?;
#[cfg(not(feature = "otlp"))]
ensure_otel_disabled(&config.opentelemetry)?;
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(stdout_layer)
.with(journald_layer)
.with(file_layer);
#[cfg(feature = "otlp")]
let subscriber = subscriber.with(otel_layer);
subscriber
.try_init()
.context("initializing tracing subscriber")?;
if let Some(err) = journald_error {
eprintln!("journald log sink unavailable: {err}");
}
Ok(LoggingGuards {
file_guard,
#[cfg(feature = "otlp")]
otel_provider,
})
}
type FileMakeWriter = tracing_appender::non_blocking::NonBlocking;
fn build_file_writer(
config: &FileLoggingConfigFile,
) -> Result<(
Option<FileMakeWriter>,
Option<tracing_appender::non_blocking::WorkerGuard>,
)> {
if !config.enable {
return Ok((None, None));
}
let Some(dir) = config.dir.as_deref() else {
bail!("logging.file.dir is required when logging.file.enable=true");
};
let appender = match tracing_appender::rolling::RollingFileAppender::builder()
.rotation(config.rotation.into())
.filename_prefix(config.prefix.clone())
.build(dir)
{
Ok(appender) => appender,
Err(err) => {
eprintln!("file log sink unavailable at {}: {err}", dir.display());
return Ok((None, None));
}
};
let writer = ReportingWriter::new(appender);
let (non_blocking, guard) = tracing_appender::non_blocking(writer);
Ok((Some(non_blocking), Some(guard)))
}
struct ReportingWriter<W> {
inner: W,
}
impl<W> ReportingWriter<W> {
const fn new(inner: W) -> Self {
Self { inner }
}
}
impl<W: Write> Write for ReportingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.inner.write(buf) {
Ok(size) => Ok(size),
Err(err) => {
eprintln!("failed to write to file log sink: {err}");
Err(err)
}
}
}
fn flush(&mut self) -> io::Result<()> {
match self.inner.flush() {
Ok(()) => Ok(()),
Err(err) => {
eprintln!("failed to flush file log sink: {err}");
Err(err)
}
}
}
}
#[cfg(not(feature = "otlp"))]
fn ensure_otel_disabled(config: &OpenTelemetryLoggingConfigFile) -> Result<()> {
if config.enable {
bail!(
"logging.opentelemetry.enable=true but this basil binary was built without the `otlp` feature"
);
}
Ok(())
}
#[cfg(feature = "otlp")]
fn build_otel_layer(config: &OpenTelemetryLoggingConfigFile) -> Result<OptionalOtelLayer> {
if !config.enable {
return Ok((None, None));
}
let endpoint = required_otel_endpoint(config)?;
let exporter = match config.protocol {
OpenTelemetryProtocol::Grpc => opentelemetry_otlp::LogExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.build(),
OpenTelemetryProtocol::HttpBinary => opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.with_endpoint(endpoint)
.build(),
OpenTelemetryProtocol::HttpJson => opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_protocol(Protocol::HttpJson)
.with_endpoint(endpoint)
.build(),
}
.context("building OpenTelemetry log exporter")?;
let provider = SdkLoggerProvider::builder()
.with_batch_exporter(exporter)
.build();
let layer = OpenTelemetryTracingBridge::new(&provider);
Ok((Some(layer), Some(provider)))
}
#[cfg(feature = "otlp")]
fn required_otel_endpoint(config: &OpenTelemetryLoggingConfigFile) -> Result<String> {
let endpoint = config
.endpoint
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.context(
"logging.opentelemetry.endpoint is required when OpenTelemetry logging is enabled",
)?;
Ok(parse_public_http_url("logging.opentelemetry.endpoint", endpoint)?.to_string())
}
fn build_setup(file: &AgentConfigFile, overrides: &ConfigOverrides) -> Result<SetupArgs> {
let catalog = required_path(
overrides.catalog.clone().or_else(|| file.catalog.clone()),
"catalog",
)?;
let policy = required_path(
overrides.policy.clone().or_else(|| file.policy.clone()),
"policy",
)?;
let bundle = required_path(
overrides.bundle.clone().or_else(|| file.bundle.clone()),
"bundle",
)?;
let vault_addr = overrides
.vault_addr
.clone()
.or_else(|| file.vault_addr.clone())
.unwrap_or_else(|| "http://127.0.0.1:8200".to_string());
let capability_policy = file
.capability_policy
.as_deref()
.map_or(Ok(CapabilityPolicy::Strict), parse_capability_policy)
.map_err(|err| anyhow::anyhow!("parsing config key `capability-policy`: {err}"))?;
Ok(SetupArgs {
catalog,
policy,
bundle,
vault_addr,
transit_mount: file
.transit_mount
.clone()
.unwrap_or_else(|| "transit".to_string()),
jwt_auth_mount: file
.jwt_auth_mount
.clone()
.unwrap_or_else(|| "jwt".to_string()),
jwt_role: file.jwt_role.clone().unwrap_or_default(),
jwt_audience: file
.jwt_audience
.clone()
.unwrap_or_else(|| "openbao".to_string()),
svid_ttl_secs: file.svid_ttl_secs.unwrap_or(DEFAULT_SVID_TTL_SECS),
capability_policy,
#[cfg(feature = "db-keystore")]
db_keystore_cipher: file
.db_keystore_cipher
.clone()
.unwrap_or_else(|| "aegis256".to_string()),
#[cfg(feature = "keystore-backend")]
onepassword_provider_uri: file.onepassword_provider_uri.clone().unwrap_or_default(),
#[cfg(feature = "keystore-backend")]
onepassword_project: file.onepassword_project.clone().unwrap_or_default(),
#[cfg(feature = "keystore-backend")]
onepassword_profile: file.onepassword_profile.clone().unwrap_or_default(),
unlock: unlock::UnlockArgs {
age_yubikey: file.unlock.age_yubikey.unwrap_or(false),
bip39_phrase_file: file.unlock.bip39_phrase_file.clone(),
tpm: file.unlock.unlock_tpm.unwrap_or(false),
passphrase_file: file.unlock.unlock_passphrase_file.clone(),
passphrase_no_wipe: file.unlock.unlock_passphrase_no_wipe.unwrap_or(false),
strict_bundle_perms: file.unlock.strict_bundle_perms.unwrap_or(false),
},
})
}
fn required_path(path: Option<PathBuf>, name: &str) -> Result<PathBuf> {
path.with_context(|| {
format!(
"`{name}` is required; set it in the config file or pass --{name} / the BASIL_{} env var",
name.to_ascii_uppercase()
)
})
}
struct Prepared {
catalog: Arc<crate::Catalog>,
policy: crate::ResolvedPolicy,
config: crate::Config,
manager: BackendManager,
backend_label: String,
}
async fn prepare_manager(setup: &SetupArgs) -> Result<Prepared> {
if setup.vault_addr.starts_with("http://") && !setup.vault_addr.contains("127.0.0.1") {
warn!(addr = %setup.vault_addr, "talking to vault over plaintext HTTP");
}
let catalog_json = std::fs::read_to_string(&setup.catalog)
.with_context(|| format!("reading catalog from {}", setup.catalog.display()))?;
let policy_json = std::fs::read_to_string(&setup.policy)
.with_context(|| format!("reading policy from {}", setup.policy.display()))?;
let (catalog, policy, config, warnings) =
load(&catalog_json, &policy_json).context("loading catalog/policy")?;
for w in &warnings {
warn!(warning = %w, "catalog/policy load warning");
}
info!(
keys = catalog.keys.len(),
backends = catalog.backends.len(),
"loaded catalog + policy",
);
let creds = unlock::open_bundle_at_startup(&setup.bundle, &setup.unlock)
.context("unlocking sealed credential bundle")?;
info!(
backend_creds = creds.backends.len(),
"sealed bundle unlocked",
);
let defaults = BackendDefaults {
vault_addr: &setup.vault_addr,
transit_mount: &setup.transit_mount,
jwt_auth_mount: &setup.jwt_auth_mount,
jwt_role: &setup.jwt_role,
jwt_audience: &setup.jwt_audience,
svid_ttl: Duration::from_secs(setup.svid_ttl_secs),
#[cfg(feature = "db-keystore")]
db_keystore_cipher: &setup.db_keystore_cipher,
#[cfg(feature = "keystore-backend")]
onepassword_provider_uri: &setup.onepassword_provider_uri,
#[cfg(feature = "keystore-backend")]
onepassword_project: &setup.onepassword_project,
#[cfg(feature = "keystore-backend")]
onepassword_profile: &setup.onepassword_profile,
};
let (manager, backend_label) = build_manager(&defaults, catalog, &creds).await?;
let catalog = manager.catalog();
drop(creds);
Ok(Prepared {
catalog,
policy,
config,
manager,
backend_label,
})
}
fn enforce_startup_capabilities(catalog: &crate::Catalog, policy: CapabilityPolicy) -> Result<()> {
let cap = enforce_capabilities(catalog, policy).context("backend capability check failed")?;
info!(
policy = %policy,
enforced = cap.enforced,
skipped_undeclared = cap.skipped_undeclared,
warnings = cap.warnings,
"capability check complete",
);
Ok(())
}
async fn run_daemon(args: RunArgs) -> Result<()> {
let run_config = load_run_config(&args.overrides)?;
let Prepared {
catalog,
policy,
config,
manager,
backend_label,
} = prepare_manager(&run_config.setup).await?;
validate_invocation_catalog_bindings(&run_config.invocation, &catalog)
.context("validating invocation broker identity and keys")?;
enforce_startup_capabilities(&catalog, run_config.setup.capability_policy)?;
if run_config.no_reconcile {
warn!("startup reconcile skipped (no-reconcile); missing keys will fail at request time");
} else {
let summary = manager
.reconcile()
.await
.context("reconciling catalog against backends")?;
info!(
present = summary.present,
generated = summary.generated,
warned = summary.warned,
"catalog reconcile complete",
);
}
let limits = BrokerLimits {
max_encrypt_size: run_config.max_encrypt_size,
max_payload_size: run_config.max_payload_size,
grace_versions: run_config.grace_versions,
svid_ttl_secs: run_config.setup.svid_ttl_secs,
retain_versions: run_config.retain_versions,
};
let jwt_revocations = JwtRevocationStore::load_from_manager(&manager)
.await
.context("loading JWT-SVID revocation deny-list")?;
let mut state =
BrokerState::with_limits(catalog, policy, config, manager, backend_label, limits)
.with_jwt_revocations(jwt_revocations)
.with_reload_inputs(ReloadInputs {
catalog_path: run_config.setup.catalog.clone(),
policy_path: run_config.setup.policy.clone(),
});
let audit_reopen = if let Some(audit_path) = &run_config.audit_log {
let audit = Arc::new(
AuditLog::open(audit_path)
.with_context(|| format!("opening audit log at {}", audit_path.display()))?,
);
info!(path = %audit_path.display(), "JSONL audit log enabled");
state = state.with_audit_log(Arc::clone(&audit));
Some(audit)
} else {
None
};
let state = Arc::new(state);
spawn_sighup_handler(Arc::clone(&state), audit_reopen);
spawn_retention_sweep(Arc::clone(&state), run_config.retention_sweep_secs);
let socket_path = run_config
.socket
.unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
let server_config = ServerConfig {
socket_path,
socket_mode: run_config.socket_mode,
socket_group: run_config.socket_group,
invocation: run_config.invocation,
};
#[cfg(feature = "http")]
let jwks_shutdown = run_config.jwks.enable.then(|| {
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let http_state = Arc::clone(&state);
let listen = run_config.jwks.listen;
let http_config = crate::jwks::JwksHttpConfig {
issuer: run_config.jwks.issuer.clone(),
tls: run_config.jwks.tls.clone(),
};
let handle = tokio::spawn(async move {
let shutdown = async {
let _ = rx.await;
};
if let Err(err) = crate::jwks::serve(http_state, listen, http_config, shutdown).await {
warn!(error = %err, "JWKS HTTP surface terminated with error");
}
});
(tx, handle)
});
let grpc_result = run_grpc(server_config, state).await;
#[cfg(feature = "http")]
if let Some((tx, handle)) = jwks_shutdown {
let _ = tx.send(());
if let Err(err) = handle.await {
warn!(error = %err, "JWKS HTTP surface task join failed");
}
}
grpc_result?;
Ok(())
}
fn spawn_sighup_handler(state: Arc<BrokerState>, audit: Option<Arc<AuditLog>>) {
let handle = tokio::spawn(async move {
let mut hangup = match signal(SignalKind::hangup()) {
Ok(signal) => signal,
Err(err) => {
warn!(error = %err, "SIGHUP handler disabled");
return;
}
};
while hangup.recv().await.is_some() {
handle_sighup_reload(&state).await;
if let Some(audit) = &audit {
audit.request_reopen();
info!("SIGHUP: requested audit log reopen");
}
}
});
std::mem::drop(handle);
}
async fn handle_sighup_reload(state: &BrokerState) {
match reload_generation(state) {
Ok(outcome) => {
info!(
previous_generation = outcome.previous_generation,
generation = outcome.new_generation,
keys = outcome.key_count,
grants = outcome.grant_count,
"SIGHUP: catalog/policy reload applied",
);
state.record_reload(
outcome.previous_generation,
outcome.new_generation,
"applied",
"signal",
ReloadActor::Sighup,
);
if let Err(err) = state.refresh_jwt_revocations().await {
warn!(
error = %err,
generation = outcome.new_generation,
"SIGHUP: JWT-SVID revocation deny-list refresh failed; previous in-memory set still serving",
);
state.record_reload(
outcome.new_generation,
outcome.new_generation,
"revocation_refresh_failed",
"signal",
ReloadActor::Sighup,
);
}
}
Err(err) => {
let active = state.active_generation_id();
warn!(
error = %err,
generation = active,
"SIGHUP: catalog/policy reload rejected; previous generation still serving",
);
state.record_reload(
active,
active,
"rejected",
err.audit_reason(),
ReloadActor::Sighup,
);
}
}
}
fn spawn_retention_sweep(state: Arc<BrokerState>, interval_secs: u64) {
let limits = state.limits();
if limits.retain_versions.is_none() || interval_secs == 0 {
return;
}
let handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
interval.tick().await;
if let Err(err) = state.manager().sweep_all_retention(limits).await {
warn!(error = %err, "retention sweep failed");
}
}
});
std::mem::drop(handle);
}
fn load_doctor_inputs(overrides: &ConfigOverrides) -> Result<doctor::DoctorInputs> {
let file = load_config_file(overrides)?;
let invocation = resolve_invocation_config(&file.broker_identity, &file.invocation)?;
let setup = build_setup(&file, overrides)?;
let socket = overrides
.socket
.clone()
.or(file.socket)
.unwrap_or_else(|| crate::DEFAULT_SOCKET_PATH.to_string());
let socket_mode = file.socket_mode.map_or(DEFAULT_SOCKET_MODE, |mode| mode.0);
let capability_policy = setup.capability_policy;
let unlock_passphrase_selected = setup.unlock.passphrase_file.is_some();
let unlock_bip39_selected = setup.unlock.bip39_phrase_file.is_some();
let unlock_age_yubikey_selected = setup.unlock.age_yubikey;
Ok(doctor::DoctorInputs {
catalog: setup.catalog,
policy: setup.policy,
bundle: setup.bundle,
socket,
socket_mode,
socket_group: file.socket_group,
capability_policy,
invocation,
unlock_passphrase_selected,
unlock_bip39_selected,
unlock_age_yubikey_selected,
})
}
async fn run_doctor(args: DoctorArgs) -> Result<()> {
let inputs = load_doctor_inputs(&args.overrides)?;
let mut report = doctor::run_doctor(&inputs, doctor::EnabledFeatures::current()).await;
if args.keys {
let mut key_rows = doctor_key_material_rows(&args.overrides).await;
report.checks.append(&mut key_rows);
report = doctor::DoctorReport::from_checks(report.checks);
}
if args.json {
println!("{}", doctor::render_json(&report)?);
} else {
print!("{}", doctor::render_human(&report));
}
if report.should_exit_nonzero(args.strict) {
std::process::exit(1);
}
Ok(())
}
async fn doctor_key_material_rows(overrides: &ConfigOverrides) -> Vec<doctor::CheckResult> {
let setup = match load_key_probe_config(overrides) {
Ok(setup) => setup,
Err(err) => {
return doctor::key_material_rows(Err(format!(
"could not resolve key-probe configuration: {err:#}"
)));
}
};
let Prepared { manager, .. } = match prepare_manager(&setup).await {
Ok(prepared) => prepared,
Err(err) => {
return doctor::key_material_rows(Err(format!(
"could not build authenticated backend manager: {err:#}"
)));
}
};
match manager.check().await {
Ok(report) => doctor::key_material_rows(Ok(&report)),
Err(err) => doctor::key_material_rows(Err(err.to_string())),
}
}
pub fn run_explain(args: &ExplainArgs) -> Result<()> {
use crate::catalog::Pdp;
let file = load_config_file(&args.overrides)?;
let catalog_path = required_path(
args.overrides
.catalog
.clone()
.or_else(|| file.catalog.clone()),
"catalog",
)?;
let policy_path = required_path(
args.overrides
.policy
.clone()
.or_else(|| file.policy.clone()),
"policy",
)?;
let catalog_json = std::fs::read_to_string(&catalog_path)
.with_context(|| format!("reading catalog from {}", catalog_path.display()))?;
let policy_json = std::fs::read_to_string(&policy_path)
.with_context(|| format!("reading policy from {}", policy_path.display()))?;
let (catalog, policy, config, warnings) =
load(&catalog_json, &policy_json).context("loading catalog/policy")?;
for w in &warnings {
warn!(warning = %w, "catalog/policy load warning");
}
let pdp = Pdp::new(&catalog, &policy, &config);
if args.effective {
return print_effective(&pdp, &args.subject, args.json);
}
let (Some(op), Some(key)) = (args.op, args.key.as_deref()) else {
bail!("--op and --key are required unless --effective is given");
};
print_explanation(&pdp, &args.subject, op, key, args.json)
}
fn print_effective(pdp: &crate::catalog::Pdp, subject: &str, json: bool) -> Result<()> {
let mut out = std::io::stdout().lock();
render_effective(&mut out, pdp, subject, json)
}
fn render_effective(
out: &mut impl std::io::Write,
pdp: &crate::catalog::Pdp,
subject: &str,
json: bool,
) -> Result<()> {
let grants = pdp.effective(subject);
if json {
let rows: Vec<serde_json::Value> = grants
.iter()
.map(|g| {
serde_json::json!({
"key": g.key,
"op": g.op.token(),
"via": allow_via_json(&g.via),
"rule": g.rule_id,
})
})
.collect();
let doc = serde_json::json!({ "subject": subject, "effective": rows });
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
return Ok(());
}
writeln!(
out,
"effective permissions for subject {subject}: {} grant(s)",
grants.len()
)?;
for g in &grants {
let rule = g.rule_id.as_deref().unwrap_or("<public-class>");
writeln!(
out,
" ALLOW {} {} via {} [{rule}]",
g.op.token(),
g.key,
allow_via_json(&g.via),
)?;
}
if grants.is_empty() {
writeln!(out, " (none: default-deny)")?;
}
Ok(())
}
fn print_explanation(
pdp: &crate::catalog::Pdp,
subject: &str,
op: crate::catalog::Op,
key: &str,
json: bool,
) -> Result<()> {
let mut out = std::io::stdout().lock();
render_explanation(&mut out, pdp, subject, op, key, json)
}
fn render_explanation(
out: &mut impl std::io::Write,
pdp: &crate::catalog::Pdp,
subject: &str,
op: crate::catalog::Op,
key: &str,
json: bool,
) -> Result<()> {
use crate::catalog::Decision;
let ex = pdp.explain_subject(subject, op, key);
if json {
let mut obj = serde_json::Map::new();
obj.insert("subject".into(), subject.into());
obj.insert("op".into(), op.token().into());
obj.insert("key".into(), key.into());
match &ex.decision {
Decision::Allow { via } => {
obj.insert("decision".into(), "allow".into());
obj.insert("via".into(), allow_via_json(via).into());
let matched = ex.matched.as_ref().map_or(serde_json::Value::Null, |m| {
serde_json::json!({
"rule": m.rule_id,
"via": allow_via_json(&m.via),
"subject": m.subject,
"action": m.action,
"target": m.target,
})
});
obj.insert("matched_rule".into(), matched);
}
Decision::Deny { reason } => {
obj.insert("decision".into(), "deny".into());
obj.insert("reason".into(), deny_reason_json(*reason).into());
}
}
writeln!(
out,
"{}",
serde_json::to_string_pretty(&serde_json::Value::Object(obj))?
)?;
return Ok(());
}
match &ex.decision {
Decision::Allow { via } => {
writeln!(
out,
"ALLOW subject {subject} {} {key} (via {})",
op.token(),
allow_via_json(via)
)?;
match &ex.matched {
Some(m) => writeln!(
out,
" matched subject `{}` (rule `{}`): action `{}` over target `{}`",
m.subject, m.rule_id, m.action, m.target
)?,
None => {
writeln!(
out,
" matched the world-readable public-class rule (no policy rule needed)"
)?;
}
}
}
Decision::Deny { reason } => {
writeln!(
out,
"DENY subject {subject} {} {key} ({})",
op.token(),
deny_reason_json(*reason)
)?;
writeln!(out, " {}", deny_explanation(*reason))?;
}
}
Ok(())
}
fn allow_via_json(via: &crate::catalog::AllowVia) -> String {
use crate::catalog::AllowVia;
match via {
AllowVia::Subject(subject) => format!("subject:{subject}"),
AllowVia::PublicClass => "public_class".to_string(),
}
}
const fn deny_reason_json(reason: crate::catalog::DenyReason) -> &'static str {
use crate::catalog::DenyReason;
match reason {
DenyReason::UnknownKey => "unknown_key",
DenyReason::NotWritable => "not_writable",
DenyReason::NotPermitted => "not_permitted",
}
}
const fn deny_explanation(reason: crate::catalog::DenyReason) -> &'static str {
use crate::catalog::DenyReason;
match reason {
DenyReason::UnknownKey => "key is not in the catalog",
DenyReason::NotWritable => {
"the key is not writable (write hard-cap), denied regardless of policy"
}
DenyReason::NotPermitted => "no policy grant matches this (subject, op, key): default-deny",
}
}
pub async fn run_agent(args: RunArgs) -> Result<()> {
let overrides = args.overrides.clone();
Box::pin(run_with_config_logging(&overrides, run_daemon(args))).await
}
pub async fn run_doctor_command(args: DoctorArgs) -> Result<()> {
let overrides = args.overrides.clone();
if args.json {
let mut logging = logging_config_for_overrides(&overrides)?;
logging.stdout.enable = Some(false);
let logging_guards = init_logging(&logging)?;
let result = run_doctor(args).await;
logging_guards.shutdown();
result
} else {
Box::pin(run_with_config_logging(&overrides, run_doctor(args))).await
}
}
pub fn run_init(socket: Option<&str>, args: &init::InitArgs) -> Result<()> {
let logging_guards = init_logging(&LoggingConfigFile::default())?;
let result = init::run(args, socket);
logging_guards.shutdown();
result
}
pub fn run_bundle(command: bundle_cli::BundleCommand) -> Result<()> {
let logging_guards = init_logging(&LoggingConfigFile::default())?;
let result = bundle_cli::run(command);
logging_guards.shutdown();
result
}
async fn run_with_config_logging(
overrides: &ConfigOverrides,
fut: impl std::future::Future<Output = Result<()>>,
) -> Result<()> {
let logging = logging_config_for_overrides(overrides)?;
let logging_guards = init_logging(&logging)?;
let result = fut.await;
logging_guards.shutdown();
result
}
fn logging_config_for_overrides(overrides: &ConfigOverrides) -> Result<LoggingConfigFile> {
Ok(load_config_file(overrides)?.logging)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::catalog::MissingPolicy;
use clap::Parser as _;
fn temp_config(contents: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"basil-agent-config-test-{}-{}.toml",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::write(&path, contents).expect("write temp config");
path
}
fn overrides_for(config: PathBuf) -> ConfigOverrides {
ConfigOverrides {
config: Some(config),
catalog: None,
policy: None,
bundle: None,
socket: None,
vault_addr: None,
}
}
fn catalog_with_invocation_keys(
response_signing: crate::catalog::KeyEntry,
request_encryption: crate::catalog::KeyEntry,
) -> crate::Catalog {
let mut backends = BTreeMap::new();
backends.insert(
"bao".to_string(),
crate::catalog::BackendRef {
kind: crate::catalog::BackendKind::Vault,
addr: "http://127.0.0.1:8200".to_string(),
engines: Vec::new(),
capabilities: Vec::new(),
mint_key_types: Vec::new(),
requires: Vec::new(),
},
);
let mut keys = BTreeMap::new();
keys.insert("broker.response".to_string(), response_signing);
keys.insert("broker.request".to_string(), request_encryption);
crate::Catalog {
schema_version: 1,
backends,
keys,
}
}
fn catalog_key(
class: Class,
key_type: Option<KeyAlgorithm>,
labels: &[&str],
) -> crate::catalog::KeyEntry {
crate::catalog::KeyEntry {
class,
key_type,
backend: "bao".to_string(),
engine: None,
path: "path".to_string(),
public_path: matches!(class, Class::Sealing).then(|| "public/path".to_string()),
writable: false,
missing: MissingPolicy::Error,
generate: None,
sealing_pin: None,
labels: crate::catalog::Labels(labels.iter().map(ToString::to_string).collect()),
description: "test key".to_string(),
}
}
#[cfg(feature = "keystore-backend")]
#[test]
fn run_config_file_supplies_all_startup_settings() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
socket = "/run/basil.sock"
vault-addr = "http://vault.internal:8200"
transit-mount = "basil-transit"
jwt-auth-mount = "jwt-basil"
jwt-role = "basil-agent"
jwt-audience = "openbao-prod"
svid-ttl-secs = 120
capability-policy = "degraded"
db-keystore-cipher = "aegis256"
onepassword-provider-uri = "onepassword://basil"
onepassword-project = "prod"
onepassword-profile = "agent"
max-encrypt-size = 4096
max-payload-size = 8192
grace-versions = 2
retain-versions = 5
retention-sweep-secs = 60
audit-log = "/var/log/basil/audit.jsonl"
no-reconcile = true
[logging.stdout]
enable = false
[logging.journald]
enable = true
[logging.file]
enable = true
dir = "/var/log/basil"
prefix = "agent-"
rotation = "weekly"
[logging.opentelemetry]
enable = true
endpoint = "http://localhost:4317"
protocol = "grpc"
[unlock]
age-yubikey = true
bip39-phrase-file = "/secure/recovery.txt"
unlock-tpm = true
unlock-passphrase-file = "/secure/passphrase.txt"
unlock-passphrase-no-wipe = true
strict-bundle-perms = true
"#,
);
let args = ConfigOverrides {
config: Some(config.clone()),
catalog: None,
policy: None,
bundle: None,
socket: None,
vault_addr: None,
};
let loaded_file = load_config_file(&args).expect("load config file");
let loaded = load_run_config(&args).expect("load run config");
assert_eq!(loaded.setup.catalog, PathBuf::from("/cfg/catalog.json"));
assert_eq!(loaded.setup.policy, PathBuf::from("/cfg/policy.json"));
assert_eq!(loaded.setup.bundle, PathBuf::from("/cfg/bundle.sealed"));
assert_eq!(loaded.socket.as_deref(), Some("/run/basil.sock"));
assert_eq!(loaded.setup.vault_addr, "http://vault.internal:8200");
assert_eq!(loaded.setup.transit_mount, "basil-transit");
assert_eq!(loaded.setup.jwt_auth_mount, "jwt-basil");
assert_eq!(loaded.setup.jwt_role, "basil-agent");
assert_eq!(loaded.setup.jwt_audience, "openbao-prod");
assert_eq!(loaded.setup.svid_ttl_secs, 120);
assert_eq!(loaded.setup.capability_policy, CapabilityPolicy::Degraded);
#[cfg(feature = "db-keystore")]
assert_eq!(loaded.setup.db_keystore_cipher, "aegis256");
assert_eq!(loaded.setup.onepassword_provider_uri, "onepassword://basil");
assert_eq!(loaded.setup.onepassword_project, "prod");
assert_eq!(loaded.setup.onepassword_profile, "agent");
assert_eq!(loaded.max_encrypt_size, 4096);
assert_eq!(loaded.max_payload_size, 8192);
assert_eq!(loaded.grace_versions, 2);
assert_eq!(loaded.retain_versions, Some(5));
assert_eq!(loaded.retention_sweep_secs, 60);
assert_eq!(
loaded.audit_log.as_deref(),
Some(Path::new("/var/log/basil/audit.jsonl"))
);
assert!(loaded.no_reconcile);
assert_eq!(loaded_file.logging.stdout.enable, Some(false));
assert!(!loaded_file.logging.stdout_enabled());
assert!(loaded_file.logging.journald.enable);
assert!(loaded_file.logging.file.enable);
assert_eq!(
loaded_file.logging.file.dir.as_deref(),
Some(Path::new("/var/log/basil"))
);
assert_eq!(loaded_file.logging.file.prefix, "agent-");
assert_eq!(
loaded_file.logging.file.rotation,
FileLoggingRotation::Weekly
);
assert!(loaded_file.logging.opentelemetry.enable);
assert_eq!(
(
loaded_file.logging.opentelemetry.endpoint.as_deref(),
loaded_file.logging.opentelemetry.protocol
),
(Some("http://localhost:4317"), OpenTelemetryProtocol::Grpc)
);
assert!(loaded.setup.unlock.age_yubikey);
assert!(loaded.setup.unlock.tpm);
assert_eq!(
loaded.setup.unlock.bip39_phrase_file.as_deref(),
Some(Path::new("/secure/recovery.txt"))
);
assert_eq!(
loaded.setup.unlock.passphrase_file.as_deref(),
Some(Path::new("/secure/passphrase.txt"))
);
assert!(loaded.setup.unlock.passphrase_no_wipe);
assert!(loaded.setup.unlock.strict_bundle_perms);
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn socket_mode_config_accepts_octal_string_and_rejects_bad_values() {
assert_eq!(parse_socket_mode("0600").expect("0600 parses"), 0o600);
assert_eq!(parse_socket_mode("0o660").expect("0o660 parses"), 0o660);
assert!(parse_socket_mode("").is_err());
assert!(parse_socket_mode("0888").is_err());
assert!(parse_socket_mode("10000").is_err());
}
#[test]
fn run_config_file_supplies_socket_ownership_settings() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
socket-mode = "0660"
socket-group = "basil-edge"
"#,
);
let args = ConfigOverrides {
config: Some(config.clone()),
catalog: None,
policy: None,
bundle: None,
socket: None,
vault_addr: None,
};
let loaded = load_run_config(&args).expect("load run config");
assert_eq!(loaded.socket_mode, 0o660);
assert_eq!(loaded.socket_group.as_deref(), Some("basil-edge"));
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn socket_mode_defaults_to_owner_only() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
"#,
);
let args = ConfigOverrides {
config: Some(config.clone()),
catalog: None,
policy: None,
bundle: None,
socket: None,
vault_addr: None,
};
let loaded = load_run_config(&args).expect("load run config");
assert_eq!(loaded.socket_mode, DEFAULT_SOCKET_MODE);
assert!(loaded.socket_group.is_none());
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn logging_defaults_stdout_and_journald_on_otel_off() {
let config = AgentConfigFile::default();
assert_eq!(config.logging.stdout.enable, None);
assert!(config.logging.stdout_enabled());
assert!(config.logging.journald.enable);
assert!(!config.logging.file.enable);
assert!(config.logging.file.dir.is_none());
assert_eq!(config.logging.file.prefix, "basil-agent-");
assert_eq!(config.logging.file.rotation, FileLoggingRotation::Daily);
assert!(!config.logging.opentelemetry.enable);
assert_eq!(
config.logging.opentelemetry.protocol,
OpenTelemetryProtocol::Grpc
);
assert!(config.logging.opentelemetry.endpoint.is_none());
}
#[test]
fn stdout_defaults_off_when_file_logging_enabled() {
let config: AgentConfigFile = toml::from_str(
r#"
[logging.file]
enable = true
dir = "/var/log/basil"
"#,
)
.expect("parse config");
assert_eq!(config.logging.stdout.enable, None);
assert!(!config.logging.stdout_enabled());
}
#[test]
fn stdout_explicit_config_overrides_file_logging_default() {
let stdout_on: AgentConfigFile = toml::from_str(
r#"
[logging.stdout]
enable = true
[logging.file]
enable = true
dir = "/var/log/basil"
"#,
)
.expect("parse stdout-on config");
assert_eq!(stdout_on.logging.stdout.enable, Some(true));
assert!(stdout_on.logging.stdout_enabled());
let stdout_off: AgentConfigFile = toml::from_str(
r#"
[logging.stdout]
enable = false
"#,
)
.expect("parse stdout-off config");
assert_eq!(stdout_off.logging.stdout.enable, Some(false));
assert!(!stdout_off.logging.stdout_enabled());
}
#[test]
fn file_logging_path_alias_populates_dir() {
let config: AgentConfigFile = toml::from_str(
r#"
[logging.file]
enable = true
path = "/var/log/basil"
"#,
)
.expect("parse file config");
assert_eq!(
config.logging.file.dir.as_deref(),
Some(Path::new("/var/log/basil"))
);
}
#[test]
fn file_logging_requires_dir_when_enabled() {
let config: AgentConfigFile = toml::from_str(
r#"
[logging.file]
enable = true
"#,
)
.expect("parse file config");
let err = build_file_writer(&config.logging.file).expect_err("missing dir rejects");
assert!(
err.to_string()
.contains("logging.file.dir is required when logging.file.enable=true")
);
}
#[test]
fn file_logging_constructs_non_blocking_writer() {
let dir = std::env::temp_dir().join(format!(
"basil-agent-log-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir(&dir).expect("create log dir");
let config = FileLoggingConfigFile {
enable: true,
dir: Some(dir.clone()),
prefix: "agent-test".to_owned(),
rotation: FileLoggingRotation::None,
};
let (writer, guard) = build_file_writer(&config).expect("build file writer");
assert!(writer.is_some());
drop(writer);
drop(guard);
std::fs::remove_dir_all(dir).expect("remove log dir");
}
#[test]
fn journald_disabled_produces_no_sink() {
assert!(matches!(journald_sink(false), JournaldSink::Disabled));
}
#[test]
fn journald_without_socket_is_non_fatal_without_stderr_sink() {
match journald_sink(true) {
JournaldSink::Active(_) | JournaldSink::Unavailable(_) => {}
JournaldSink::Disabled => panic!("enabled journald must not resolve to Disabled"),
}
}
#[cfg(feature = "http")]
#[test]
fn jwks_http_surface_is_disabled_by_default_no_port_opened() {
let defaults = JwksConfigFile::default();
assert!(!defaults.enable, "JWKS surface must default to disabled");
assert_eq!(defaults.listen, "127.0.0.1:8201");
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
"#,
);
let args = ConfigOverrides {
config: Some(config.clone()),
catalog: None,
policy: None,
bundle: None,
socket: None,
vault_addr: None,
};
let loaded = load_run_config(&args).expect("load run config");
assert!(
!loaded.jwks.enable,
"default config leaves the JWKS port closed"
);
assert_eq!(
loaded.jwks.listen,
"127.0.0.1:8201".parse().expect("default listen")
);
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn invocation_service_requires_explicit_config_enable() {
let defaults = InvocationConfigFile::default();
assert!(
!defaults.enable,
"InvocationService must default to rejecting requests"
);
assert_eq!(defaults.max_ttl_secs, 60);
assert_eq!(defaults.clock_skew_secs, 30);
assert_eq!(defaults.replay_cache_capacity, 4096);
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
"#,
);
let args = overrides_for(config.clone());
let loaded = load_run_config(&args).expect("load run config");
assert!(!loaded.invocation.enabled);
assert!(loaded.invocation.broker_identity.is_none());
assert!(loaded.invocation.request_encryption_key_id.is_none());
std::fs::remove_file(config).expect("remove temp config");
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[broker-identity]
id = "basil://prod/us-east-1/agent-a"
response-signing-key-id = "broker.response"
[invocation]
enable = true
audience = ["basil://prod/us-east-1/agent-a"]
request-encryption-key-id = "broker.request"
max-ttl-secs = 45
clock-skew-secs = 7
replay-cache-capacity = 128
"#,
);
let args = overrides_for(config.clone());
let loaded = load_run_config(&args).expect("load run config");
assert!(loaded.invocation.enabled);
let identity = loaded
.invocation
.broker_identity
.as_ref()
.expect("broker identity");
assert_eq!(identity.id, "basil://prod/us-east-1/agent-a");
assert_eq!(identity.response_signing_key_id, "broker.response");
assert_eq!(
loaded.invocation.request_encryption_key_id.as_deref(),
Some("broker.request")
);
assert_eq!(
loaded.invocation.audiences,
vec!["basil://prod/us-east-1/agent-a".to_string()]
);
assert_eq!(loaded.invocation.max_ttl_secs, 45);
assert_eq!(loaded.invocation.clock_skew_secs, 7);
assert_eq!(loaded.invocation.replay_cache_capacity, 128);
std::fs::remove_file(config).expect("remove temp config");
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[invocation]
enable = true
"#,
);
let args = overrides_for(config.clone());
let err = load_run_config(&args).expect_err("enabled invocation requires audience");
assert!(
err.to_string()
.contains("invocation.audience must be set when invocation.enable is true")
);
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn invocation_config_rejects_missing_keys_invalid_identity_and_bounds() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[broker-identity]
id = "https://not-basil.example/agent-a"
response-signing-key-id = "broker.response"
[invocation]
enable = true
audience = ["basil://prod/us-east-1/agent-a"]
request-encryption-key-id = "broker.request"
"#,
);
let err = load_run_config(&overrides_for(config.clone()))
.expect_err("non-basil broker id rejects");
assert!(
err.to_string()
.contains("broker-identity.id must use the basil:// scheme")
);
std::fs::remove_file(config).expect("remove temp config");
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[broker-identity]
id = "basil://prod/us-east-1/agent-a"
response-signing-key-id = "broker.response"
[invocation]
enable = true
audience = ["basil://prod/us-east-1/agent-a"]
max-ttl-secs = 301
"#,
);
let err =
load_run_config(&overrides_for(config.clone())).expect_err("excessive ttl rejects");
assert!(
err.to_string()
.contains("invocation.max-ttl-secs must be at most 300 seconds")
);
std::fs::remove_file(config).expect("remove temp config");
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[broker-identity]
id = "basil://prod/us-east-1/agent-a"
response-signing-key-id = "broker.response"
[invocation]
enable = true
audience = ["basil://prod/us-east-1/agent-a"]
"#,
);
let err = load_run_config(&overrides_for(config.clone()))
.expect_err("enabled invocation requires request encryption key");
assert!(
err.to_string()
.contains("invocation.request-encryption-key-id is required")
);
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn invocation_catalog_bindings_require_expected_class_and_use() {
let invocation = InvocationRuntimeConfig {
enabled: true,
broker_identity: Some(BrokerIdentityRuntimeConfig {
id: "basil://prod/us-east-1/agent-a".to_string(),
response_signing_key_id: "broker.response".to_string(),
}),
audiences: vec!["basil://prod/us-east-1/agent-a".to_string()],
request_encryption_key_id: Some("broker.request".to_string()),
max_ttl_secs: 60,
clock_skew_secs: 30,
replay_cache_capacity: 4096,
now_unix_override: None,
};
let valid = catalog_with_invocation_keys(
catalog_key(
Class::Asymmetric,
Some(KeyAlgorithm::Ed25519),
&["broker_key_use=response-signing"],
),
catalog_key(
Class::Sealing,
Some(KeyAlgorithm::X25519),
&["broker_key_use=request-encryption"],
),
);
validate_invocation_catalog_bindings(&invocation, &valid).expect("valid keys pass");
let wrong_response_class = catalog_with_invocation_keys(
catalog_key(
Class::Sealing,
Some(KeyAlgorithm::X25519),
&["broker_key_use=response-signing"],
),
catalog_key(
Class::Sealing,
Some(KeyAlgorithm::X25519),
&["broker_key_use=request-encryption"],
),
);
let err = validate_invocation_catalog_bindings(&invocation, &wrong_response_class)
.expect_err("wrong response signing class rejects");
assert!(
err.to_string().contains(
"broker response-signing key `broker.response` must be class `asymmetric`"
)
);
let wrong_request_use = catalog_with_invocation_keys(
catalog_key(
Class::Asymmetric,
Some(KeyAlgorithm::Ed25519),
&["broker_key_use=response-signing"],
),
catalog_key(
Class::Sealing,
Some(KeyAlgorithm::X25519),
&["broker_key_use=response-signing"],
),
);
let err = validate_invocation_catalog_bindings(&invocation, &wrong_request_use)
.expect_err("wrong request encryption use rejects");
assert!(err.to_string().contains(
"broker request-encryption key `broker.request` must carry label `broker_key_use=request-encryption`"
));
}
#[cfg(feature = "http")]
#[test]
fn jwks_http_surface_enables_and_parses_listen_from_config() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[jwks]
enable = true
listen = "0.0.0.0:9443"
"#,
);
let args = ConfigOverrides {
config: Some(config.clone()),
catalog: None,
policy: None,
bundle: None,
socket: None,
vault_addr: None,
};
let loaded = load_run_config(&args).expect("load run config");
assert!(loaded.jwks.enable);
assert_eq!(
loaded.jwks.listen,
"0.0.0.0:9443".parse().expect("custom listen")
);
std::fs::remove_file(config).expect("remove temp config");
}
#[cfg(feature = "http")]
#[test]
fn jwks_listen_must_be_a_valid_socket_addr() {
let bad = JwksConfigFile {
enable: true,
listen: "not-an-addr".to_string(),
issuer: None,
tls: JwksTlsConfigFile::default(),
};
assert!(
resolve_jwks_config(&bad).is_err(),
"a malformed jwks.listen fails closed at startup"
);
}
#[cfg(feature = "http")]
#[test]
fn jwks_issuer_must_be_absolute_http_url_or_unset() {
let none = JwksConfigFile {
enable: true,
listen: "127.0.0.1:8201".to_string(),
issuer: None,
tls: JwksTlsConfigFile::default(),
};
assert!(resolve_jwks_config(&none).expect("ok").issuer.is_none());
let bad = JwksConfigFile {
enable: true,
listen: "127.0.0.1:8201".to_string(),
issuer: Some("basil.example.com".to_string()),
tls: JwksTlsConfigFile::default(),
};
assert!(
resolve_jwks_config(&bad).is_err(),
"a non-http(s) issuer fails closed at startup"
);
let good = JwksConfigFile {
enable: true,
listen: "127.0.0.1:8201".to_string(),
issuer: Some("https://basil.example.com/".to_string()),
tls: JwksTlsConfigFile::default(),
};
assert_eq!(
resolve_jwks_config(&good).expect("ok").issuer.as_deref(),
Some("https://basil.example.com")
);
}
#[cfg(feature = "http")]
#[test]
fn jwks_issuer_rejects_ssrf_prone_url_shapes() {
for issuer in [
"//basil.example.com",
"ftp://basil.example.com",
"file:///etc/passwd",
"unix:///run/basil-jwks.sock",
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"https://user:pass@basil.example.com",
"https://basil.example.com/#fragment",
] {
let bad = JwksConfigFile {
enable: true,
listen: "127.0.0.1:8201".to_string(),
issuer: Some(issuer.to_string()),
tls: JwksTlsConfigFile::default(),
};
assert!(
resolve_jwks_config(&bad).is_err(),
"jwks.issuer `{issuer}` should fail closed"
);
}
}
#[cfg(feature = "http")]
#[test]
fn jwks_tls_defaults_to_disabled() {
let defaults = JwksConfigFile::default();
assert!(resolve_jwks_config(&defaults).expect("ok").tls.is_none());
}
#[cfg(all(feature = "http", not(feature = "http-tls")))]
#[test]
fn jwks_tls_requires_cargo_feature() {
let config = JwksConfigFile {
enable: true,
listen: "127.0.0.1:8201".to_string(),
issuer: Some("https://basil.example.com".to_string()),
tls: JwksTlsConfigFile {
enable: true,
cert_file: Some("/etc/basil/jwks-cert.pem".into()),
key_file: Some("/etc/basil/jwks-key.pem".into()),
},
};
let err = resolve_jwks_config(&config).expect_err("feature missing");
assert!(err.to_string().contains("http-tls"));
}
#[cfg(not(feature = "http"))]
#[test]
fn jwks_enable_requires_http_feature() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
[jwks]
enable = true
"#,
);
let err = load_run_config(&overrides_for(config.clone())).expect_err("feature missing");
assert!(err.to_string().contains("http cargo feature"));
std::fs::remove_file(config).expect("remove temp config");
}
#[cfg(feature = "http-tls")]
#[test]
fn jwks_tls_requires_cert_and_key_when_enabled() {
let missing_key = JwksConfigFile {
enable: true,
listen: "127.0.0.1:8201".to_string(),
issuer: Some("https://basil.example.com".to_string()),
tls: JwksTlsConfigFile {
enable: true,
cert_file: Some("/etc/basil/jwks-cert.pem".into()),
key_file: None,
},
};
let err = resolve_jwks_config(&missing_key).expect_err("key required");
assert!(err.to_string().contains("key-file"));
}
#[cfg(feature = "otlp")]
#[test]
fn otel_logging_requires_non_empty_http_endpoint_when_enabled() {
let missing = OpenTelemetryLoggingConfigFile {
enable: true,
endpoint: None,
protocol: OpenTelemetryProtocol::Grpc,
};
assert!(required_otel_endpoint(&missing).is_err());
let empty = OpenTelemetryLoggingConfigFile {
enable: true,
endpoint: Some(" ".to_string()),
protocol: OpenTelemetryProtocol::Grpc,
};
assert!(required_otel_endpoint(&empty).is_err());
let unix = OpenTelemetryLoggingConfigFile {
enable: true,
endpoint: Some("unix:///run/otel.sock".to_string()),
protocol: OpenTelemetryProtocol::Grpc,
};
assert!(required_otel_endpoint(&unix).is_err());
for endpoint in [
"//otel.example.com:4317",
"ftp://otel.example.com:4317",
"file:///tmp/otel.sock",
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"https://user:pass@otel.example.com:4317",
"https://otel.example.com:4317/#fragment",
] {
let bad = OpenTelemetryLoggingConfigFile {
enable: true,
endpoint: Some(endpoint.to_string()),
protocol: OpenTelemetryProtocol::Grpc,
};
assert!(
required_otel_endpoint(&bad).is_err(),
"OpenTelemetry endpoint `{endpoint}` should fail closed"
);
}
let http = OpenTelemetryLoggingConfigFile {
enable: true,
endpoint: Some("http://localhost:4317".to_string()),
protocol: OpenTelemetryProtocol::Grpc,
};
assert_eq!(
required_otel_endpoint(&http).expect("valid endpoint"),
"http://localhost:4317/"
);
}
#[test]
fn cli_overrides_win_over_config_file() {
let config = temp_config(
r#"
catalog = "/cfg/catalog.json"
policy = "/cfg/policy.json"
bundle = "/cfg/bundle.sealed"
socket = "/cfg/basil.sock"
vault-addr = "http://cfg-vault:8200"
"#,
);
let args = ConfigOverrides {
config: Some(config.clone()),
catalog: Some(PathBuf::from("/cli/catalog.json")),
policy: Some(PathBuf::from("/cli/policy.json")),
bundle: Some(PathBuf::from("/cli/bundle.sealed")),
socket: Some("/cli/basil.sock".to_string()),
vault_addr: Some("http://cli-vault:8200".to_string()),
};
let loaded = load_run_config(&args).expect("load run config");
assert_eq!(loaded.setup.catalog, PathBuf::from("/cli/catalog.json"));
assert_eq!(loaded.setup.policy, PathBuf::from("/cli/policy.json"));
assert_eq!(loaded.setup.bundle, PathBuf::from("/cli/bundle.sealed"));
assert_eq!(loaded.socket.as_deref(), Some("/cli/basil.sock"));
assert_eq!(loaded.setup.vault_addr, "http://cli-vault:8200");
std::fs::remove_file(config).expect("remove temp config");
}
#[test]
fn run_cli_rejects_removed_startup_overrides() {
#[derive(Debug, clap::Parser)]
struct TestCli {
#[command(flatten)]
args: RunArgs,
}
let err = TestCli::try_parse_from([
"basil-agent-run",
"--catalog",
"/tmp/catalog.json",
"--policy",
"/tmp/policy.json",
"--bundle",
"/tmp/bundle.sealed",
"--capability-policy",
"off",
])
.expect_err("removed flag rejected");
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
mod explain {
use super::*;
use crate::catalog::{Op, Pdp, load};
const CATALOG: &str = r#"{
"schemaVersion": 1,
"backends": { "bao": { "kind": "vault", "addr": "https://127.0.0.1:8200" } },
"keys": {
"grafana.admin_password": {
"class": "value", "backend": "bao", "engine": "kv2",
"path": "secret/data/grafana/admin", "writable": true,
"missing": "error", "description": "grafana admin value"
}
}
}"#;
const POLICY: &str = r#"{
"schemaVersion": 2,
"subjects": {
"svc.grafana": { "allOf": [ { "kind": "unix", "uid": 9002 } ] },
"ops.wheel": { "allOf": [ { "kind": "unix", "gid": 10 } ] }
},
"roles": { "reader": ["get", "list"], "operator": ["set", "rotate"] },
"rules": [
{ "id": "grafana-reader", "subjects": ["svc.grafana"], "action": ["role:reader"], "target": ["grafana.admin_password"] },
{ "id": "wheel-operator", "subjects": ["ops.wheel"], "action": ["role:operator"], "target": ["grafana.**"] }
],
"config": {
"names": { "users": { "9002": "svc-grafana" }, "groups": { "10": "wheel" } },
"memberships": { "9002": [9002] }
}
}"#;
fn loaded() -> (
crate::catalog::Catalog,
crate::catalog::ResolvedPolicy,
crate::catalog::Config,
) {
let (catalog, policy, config, _w) = load(CATALOG, POLICY).expect("fixture loads");
(catalog, policy, config)
}
fn render_explanation_to_string(
pdp: &Pdp,
subject: &str,
op: Op,
key: &str,
json: bool,
) -> String {
let mut buf = Vec::new();
render_explanation(&mut buf, pdp, subject, op, key, json).expect("render");
String::from_utf8(buf).expect("utf8")
}
#[test]
fn json_allow_shape_carries_matched_rule_fields() {
let (c, p, cfg) = loaded();
let pdp = Pdp::new(&c, &p, &cfg);
let out = render_explanation_to_string(
&pdp,
"svc.grafana",
Op::Get,
"grafana.admin_password",
true,
);
let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(doc["decision"], "allow");
assert_eq!(doc["subject"], "svc.grafana");
assert_eq!(doc["op"], "get");
assert_eq!(doc["key"], "grafana.admin_password");
assert_eq!(doc["via"], "subject:svc.grafana");
assert_eq!(doc["matched_rule"]["rule"], "grafana-reader");
assert_eq!(doc["matched_rule"]["via"], "subject:svc.grafana");
assert!(doc["matched_rule"]["action"].is_string());
assert!(doc["matched_rule"]["target"].is_string());
}
#[test]
fn json_deny_shape_carries_default_deny_reason() {
let (c, p, cfg) = loaded();
let pdp = Pdp::new(&c, &p, &cfg);
let out = render_explanation_to_string(
&pdp,
"svc.grafana",
Op::Set,
"grafana.admin_password",
true,
);
let doc: serde_json::Value = serde_json::from_str(&out).expect("json");
assert_eq!(doc["decision"], "deny");
assert_eq!(doc["reason"], "not_permitted");
assert!(doc.get("via").is_none());
assert!(doc.get("matched_rule").is_none());
let unknown =
render_explanation_to_string(&pdp, "svc.grafana", Op::Get, "no.such.key", true);
let doc: serde_json::Value = serde_json::from_str(&unknown).expect("json");
assert_eq!(doc["decision"], "deny");
assert_eq!(doc["reason"], "unknown_key");
}
#[test]
fn subject_name_selects_the_rule_to_explain() {
let (c, p, cfg) = loaded();
let pdp = Pdp::new(&c, &p, &cfg);
let denied = render_explanation_to_string(
&pdp,
"svc.grafana",
Op::Set,
"grafana.admin_password",
true,
);
let doc: serde_json::Value = serde_json::from_str(&denied).expect("json");
assert_eq!(doc["decision"], "deny", "reader subject cannot set");
let allowed = render_explanation_to_string(
&pdp,
"ops.wheel",
Op::Set,
"grafana.admin_password",
true,
);
let doc: serde_json::Value = serde_json::from_str(&allowed).expect("json");
assert_eq!(doc["decision"], "allow", "operator subject can set");
assert_eq!(doc["via"], "subject:ops.wheel");
assert_eq!(doc["matched_rule"]["rule"], "wheel-operator");
}
#[test]
fn effective_mode_renders_granted_key_op_set() {
let (c, p, cfg) = loaded();
let pdp = Pdp::new(&c, &p, &cfg);
let mut buf = Vec::new();
render_effective(&mut buf, &pdp, "ops.wheel", true).expect("render effective");
let doc: serde_json::Value = serde_json::from_slice(&buf).expect("effective json");
assert_eq!(doc["subject"], "ops.wheel");
let rows = doc["effective"].as_array().expect("effective rows");
let pairs: Vec<(&str, &str)> = rows
.iter()
.filter_map(|r| Some((r["key"].as_str()?, r["op"].as_str()?)))
.collect();
assert!(
pairs.contains(&("grafana.admin_password", "set")),
"operator set granted: {pairs:?}"
);
assert!(
pairs.contains(&("grafana.admin_password", "rotate")),
"operator rotate granted: {pairs:?}"
);
assert!(
rows.iter().all(|r| r["via"] == "subject:ops.wheel"),
"every grant is via the wheel subject"
);
let mut buf = Vec::new();
render_effective(&mut buf, &pdp, "missing.subject", true).expect("render effective");
let doc: serde_json::Value = serde_json::from_slice(&buf).expect("json");
assert!(
doc["effective"].as_array().expect("rows").is_empty(),
"no grants -> empty effective set"
);
}
#[test]
fn missing_op_key_without_effective_bails_cleanly() {
let dir = std::env::temp_dir();
let stamp = format!("{}-{}", std::process::id(), uuid::Uuid::new_v4());
let catalog_path = dir.join(format!("basil-explain-cat-{stamp}.json"));
let policy_path = dir.join(format!("basil-explain-pol-{stamp}.json"));
std::fs::write(&catalog_path, CATALOG).expect("write catalog");
std::fs::write(&policy_path, POLICY).expect("write policy");
let args = ExplainArgs {
subject: "svc.grafana".to_string(),
op: None,
key: None,
effective: false,
live: false,
json: true,
overrides: ConfigOverrides {
config: None,
catalog: Some(catalog_path.clone()),
policy: Some(policy_path.clone()),
bundle: None,
socket: None,
vault_addr: None,
},
};
let err = run_explain(&args).expect_err("missing op/key must bail");
assert!(
err.to_string().contains("--op and --key are required"),
"clean bail, not a panic: {err}"
);
std::fs::remove_file(&catalog_path).ok();
std::fs::remove_file(&policy_path).ok();
}
}
}