Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions crates/api-core/src/cfg/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,94 @@ pub struct CarbideConfig {
/// encrypted in Postgres and vault leaves the credential chain
/// entirely; when absent, vault remains the credential store.
pub secrets: Option<SecretsConfig>,

/// Certificate vending backend. Selected independently of the credential
/// store; absent means certs are issued from the credential Vault.
#[serde(default)]
pub certificates: CertificatesConfig,
}

/// `[certificates]` config section: selects the backend that vends machine and
/// service certificates, independently of where credentials are stored.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CertificatesConfig {
/// Which backend issues certificates. Defaults to sharing the credential
/// Vault client (historical behavior).
#[serde(default)]
pub backend: CertBackendKind,

/// Connection settings for a dedicated certificate Vault. Required when
/// `backend = "dedicated_vault"`, ignored otherwise.
#[serde(default)]
pub dedicated_vault: Option<DedicatedVaultTomlConfig>,
}

/// Tag selecting the certificate backend. The matching settings (if any) live
/// in their own sub-table, so the choice is explicit rather than inferred.
// The shared `Vault` suffix is intentional: both current backends are Vault
// backends. The lint resolves once a non-Vault backend is added.
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CertBackendKind {
/// Reuse the credential store's Vault client — one client, one token lease.
#[default]
SharedVault,
/// Use a dedicated Vault configured under `[certificates.dedicated_vault]`.
DedicatedVault,
}

/// `[certificates.dedicated_vault]` settings.
///
/// The connection-identifying fields are required, so a partial section fails
/// to parse rather than silently inheriting the credential Vault's process-wide
/// `VAULT_*` environment configuration.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DedicatedVaultTomlConfig {
/// Vault address, e.g. `https://vault-certs.example:8200`.
pub address: String,
/// PKI secrets-engine mount path on the target Vault.
pub pki_mount_location: String,
/// PKI role used to sign leaf certificates.
pub pki_role_name: String,
/// Token for root-token auth; required only when the pod has no Kubernetes
/// service-account token.
#[serde(default)]
pub token: Option<String>,
/// CA bundle that signs the target Vault's TLS cert. Defaults to the site
/// root / `VAULT_CACERT`.
#[serde(default)]
pub vault_cacert: Option<String>,
}

impl CertificatesConfig {
/// Convert the parsed section into the runtime certificate config, failing
/// fast if a dedicated backend was selected without its settings.
pub fn to_certificate_config(&self) -> eyre::Result<carbide_secrets::CertificateConfig> {
let backend = match self.backend {
CertBackendKind::SharedVault => carbide_secrets::CertBackend::SharedVault,
CertBackendKind::DedicatedVault => {
let dedicated = self.dedicated_vault.as_ref().ok_or_else(|| {
eyre::eyre!(
"[certificates] backend = \"dedicated_vault\" requires a \
[certificates.dedicated_vault] section"
)
})?;
carbide_secrets::CertBackend::DedicatedVault(
carbide_secrets::DedicatedVaultConfig {
address: dedicated.address.clone(),
pki_mount_location: dedicated.pki_mount_location.clone(),
pki_role_name: dedicated.pki_role_name.clone(),
token: dedicated.token.clone(),
vault_cacert: dedicated.vault_cacert.clone(),
},
)
}
};
Ok(carbide_secrets::CertificateConfig { backend })
}
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -2503,6 +2591,90 @@ mod tests {

const TEST_DATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/cfg/test_data");

#[test]
fn certificates_absent_defaults_to_shared_vault() {
let cfg: CertificatesConfig = serde_json::from_str("{}").unwrap();
assert_eq!(cfg.backend, CertBackendKind::SharedVault);
let runtime = cfg.to_certificate_config().unwrap();
assert!(matches!(
runtime.backend,
carbide_secrets::CertBackend::SharedVault
));
}

#[test]
fn certificates_explicit_shared_vault() {
let cfg: CertificatesConfig =
serde_json::from_str(r#"{"backend":"shared_vault"}"#).unwrap();
assert!(matches!(
cfg.to_certificate_config().unwrap().backend,
carbide_secrets::CertBackend::SharedVault
));
}

#[test]
fn certificates_dedicated_vault_maps_all_fields() {
let cfg: CertificatesConfig = serde_json::from_str(
r#"{
"backend": "dedicated_vault",
"dedicated_vault": {
"address": "https://vault-certs.example:8200",
"pki_mount_location": "pki",
"pki_role_name": "machine",
"token": "s.abc123"
}
}"#,
)
.unwrap();

match cfg.to_certificate_config().unwrap().backend {
carbide_secrets::CertBackend::DedicatedVault(dedicated) => {
assert_eq!(dedicated.address, "https://vault-certs.example:8200");
assert_eq!(dedicated.pki_mount_location, "pki");
assert_eq!(dedicated.pki_role_name, "machine");
assert_eq!(dedicated.token.as_deref(), Some("s.abc123"));
assert!(dedicated.vault_cacert.is_none());
}
other => panic!("expected dedicated vault backend, got {other:?}"),
}
}

#[test]
fn certificates_dedicated_vault_without_section_fails_fast() {
// backend selected but no settings -> must error rather than fall back
// to the credential Vault.
let cfg: CertificatesConfig =
serde_json::from_str(r#"{"backend":"dedicated_vault"}"#).unwrap();
let err = cfg.to_certificate_config().unwrap_err();
assert!(
err.to_string().contains("dedicated_vault"),
"unexpected error: {err}"
);
}

#[test]
fn certificates_dedicated_vault_missing_required_field_fails_parse() {
// `address` is required; omitting it must fail at parse time, not vend
// certs from a half-specified Vault.
let result: Result<CertificatesConfig, _> = serde_json::from_str(
r#"{
"backend": "dedicated_vault",
"dedicated_vault": {
"pki_mount_location": "pki",
"pki_role_name": "machine"
}
}"#,
);
assert!(result.is_err(), "expected parse error for missing address");
}

#[test]
fn certificates_unknown_field_rejected() {
let result: Result<CertificatesConfig, _> =
serde_json::from_str(r#"{"backend":"shared_vault","typo":true}"#);
assert!(result.is_err(), "deny_unknown_fields should reject typos");
}

#[test]
fn deserialize_serialize_machine_controller_config() {
let input = MachineStateControllerConfig {
Expand Down
25 changes: 20 additions & 5 deletions crates/api-core/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use carbide_kms_provider::{
};
use carbide_secrets::credentials::{CredentialManager, CredentialReader, CredentialWriter};
use carbide_secrets::{
CredentialConfig, ForgeVaultClient, MemoryCredentialStore, VaultConfig,
create_credential_manager_from, create_vault_client,
CredentialConfig, ForgeVaultClient, MemoryCredentialStore, SpiffeIdentity, VaultConfig,
create_certificate_provider, create_credential_manager_from, create_vault_client,
};
use carbide_utils::HostPortPair;
use eyre::WrapErr;
Expand Down Expand Up @@ -193,10 +193,25 @@ pub async fn run(

let vault_config = vault_config_for_site(&credential_config.vault, &carbide_config);

// One vault client serves every vault role below. PKI certificates stay
// on vault no matter which credential backend is configured.
// One vault client serves every credential vault role below.
let vault_client = create_vault_client(&vault_config, metrics.meter.clone())?;
let certificate_provider = vault_client.clone();

// Certificate vending is selected independently of the credential store.
// SharedVault (the default) reuses `vault_client` (no second client or token
// lease); a dedicated cert Vault decouples PKI issuance from credentials and
// is fully explicit, never inheriting the credential Vault's env config. The
// SPIFFE identity comes from the site-resolved credential Vault config so all
// backends mint under the same identity namespace.
let cert_config = carbide_config.certificates.to_certificate_config()?;
let certificate_provider = create_certificate_provider(
&cert_config,
&vault_client,
SpiffeIdentity {
trust_domain: vault_config.spiffe_trust_domain(),
machine_base_path: vault_config.spiffe_machine_base_path(),
},
metrics.meter.clone(),
)?;

let db_pool = setup::create_and_connect_postgres_pool(&carbide_config).await?;

Expand Down
1 change: 1 addition & 0 deletions crates/api-core/src/test_support/default_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ pub fn get() -> CarbideConfig {
tracing: TracingConfig::default(),
ntp_servers: vec![],
secrets: None,
certificates: Default::default(),
}
}

Expand Down
Loading