- Remove gitlinks (160000 mode) for chainfire, flaredb, iam - Add workspace contents as regular tracked files - Update flake.nix to use simple paths instead of builtins.fetchGit This resolves the nix build failure where submodule directories appeared empty in the nix store. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
353 lines
11 KiB
Rust
353 lines
11 KiB
Rust
//! mTLS (Mutual TLS) authentication
|
|
//!
|
|
//! Provides certificate-based authentication for service-to-service communication.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use iam_types::{Error, IamError, PrincipalKind, PrincipalRef, Result};
|
|
|
|
/// Certificate information extracted from mTLS connection
|
|
#[derive(Debug, Clone)]
|
|
pub struct CertificateInfo {
|
|
/// Common Name (CN) from the certificate subject
|
|
pub common_name: String,
|
|
/// Organization (O) from the certificate subject
|
|
pub organization: Option<String>,
|
|
/// Organizational Unit (OU) from the certificate subject
|
|
pub organizational_unit: Option<String>,
|
|
/// Serial number of the certificate
|
|
pub serial_number: String,
|
|
/// Certificate fingerprint (SHA-256)
|
|
pub fingerprint: String,
|
|
/// Not before timestamp (Unix seconds)
|
|
pub not_before: u64,
|
|
/// Not after timestamp (Unix seconds)
|
|
pub not_after: u64,
|
|
/// Subject Alternative Names (SANs)
|
|
pub sans: Vec<SubjectAltName>,
|
|
}
|
|
|
|
/// Subject Alternative Name types
|
|
#[derive(Debug, Clone)]
|
|
pub enum SubjectAltName {
|
|
/// DNS name
|
|
Dns(String),
|
|
/// URI
|
|
Uri(String),
|
|
/// IP address
|
|
Ip(String),
|
|
/// Email
|
|
Email(String),
|
|
}
|
|
|
|
/// Configuration for mTLS verifier
|
|
#[derive(Debug, Clone)]
|
|
pub struct MtlsVerifierConfig {
|
|
/// Mapping from CN patterns to principal references
|
|
/// Pattern can use * as wildcard
|
|
pub cn_mappings: HashMap<String, PrincipalMapping>,
|
|
/// Required organization
|
|
pub required_org: Option<String>,
|
|
/// Required organizational unit
|
|
pub required_ou: Option<String>,
|
|
/// Whether to validate certificate expiration
|
|
pub validate_expiration: bool,
|
|
}
|
|
|
|
/// Mapping configuration for a CN pattern
|
|
#[derive(Debug, Clone)]
|
|
pub struct PrincipalMapping {
|
|
/// Principal kind to assign
|
|
pub kind: PrincipalKind,
|
|
/// ID template (can use {cn}, {ou}, {o} placeholders)
|
|
pub id_template: String,
|
|
/// Optional node ID extraction from CN
|
|
pub node_id_from_cn: bool,
|
|
}
|
|
|
|
impl Default for MtlsVerifierConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
cn_mappings: HashMap::new(),
|
|
required_org: None,
|
|
required_ou: None,
|
|
validate_expiration: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MtlsVerifierConfig {
|
|
/// Create a new config
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Add a CN to principal mapping
|
|
pub fn add_mapping(mut self, cn_pattern: impl Into<String>, mapping: PrincipalMapping) -> Self {
|
|
self.cn_mappings.insert(cn_pattern.into(), mapping);
|
|
self
|
|
}
|
|
|
|
/// Set required organization
|
|
pub fn with_required_org(mut self, org: impl Into<String>) -> Self {
|
|
self.required_org = Some(org.into());
|
|
self
|
|
}
|
|
|
|
/// Set required OU
|
|
pub fn with_required_ou(mut self, ou: impl Into<String>) -> Self {
|
|
self.required_ou = Some(ou.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// mTLS certificate verifier
|
|
pub struct MtlsVerifier {
|
|
config: MtlsVerifierConfig,
|
|
}
|
|
|
|
impl MtlsVerifier {
|
|
/// Create a new mTLS verifier
|
|
pub fn new(config: MtlsVerifierConfig) -> Self {
|
|
Self { config }
|
|
}
|
|
|
|
/// Create with default service account mapping
|
|
pub fn with_default_sa_mapping() -> Self {
|
|
let config = MtlsVerifierConfig::new()
|
|
.add_mapping(
|
|
"*.service.internal",
|
|
PrincipalMapping {
|
|
kind: PrincipalKind::ServiceAccount,
|
|
id_template: "{cn}".into(),
|
|
node_id_from_cn: false,
|
|
},
|
|
)
|
|
.add_mapping(
|
|
"node-*.compute.internal",
|
|
PrincipalMapping {
|
|
kind: PrincipalKind::ServiceAccount,
|
|
id_template: "compute-agent".into(),
|
|
node_id_from_cn: true,
|
|
},
|
|
);
|
|
|
|
Self::new(config)
|
|
}
|
|
|
|
/// Verify a certificate and return the authenticated principal
|
|
pub fn verify(&self, cert_info: &CertificateInfo) -> Result<MtlsAuthResult> {
|
|
// 1. Validate expiration
|
|
if self.config.validate_expiration {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
|
|
if now < cert_info.not_before {
|
|
return Err(Error::Iam(IamError::InvalidToken(
|
|
"Certificate not yet valid".into(),
|
|
)));
|
|
}
|
|
|
|
if now > cert_info.not_after {
|
|
return Err(Error::Iam(IamError::InvalidToken(
|
|
"Certificate expired".into(),
|
|
)));
|
|
}
|
|
}
|
|
|
|
// 2. Validate organization if required
|
|
if let Some(required_org) = &self.config.required_org {
|
|
match &cert_info.organization {
|
|
Some(org) if org == required_org => {}
|
|
Some(org) => {
|
|
return Err(Error::Iam(IamError::AuthnFailed(format!(
|
|
"Invalid organization: expected {}, got {}",
|
|
required_org, org
|
|
))));
|
|
}
|
|
None => {
|
|
return Err(Error::Iam(IamError::AuthnFailed(
|
|
"Certificate missing organization".into(),
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Validate OU if required
|
|
if let Some(required_ou) = &self.config.required_ou {
|
|
match &cert_info.organizational_unit {
|
|
Some(ou) if ou == required_ou => {}
|
|
Some(ou) => {
|
|
return Err(Error::Iam(IamError::AuthnFailed(format!(
|
|
"Invalid organizational unit: expected {}, got {}",
|
|
required_ou, ou
|
|
))));
|
|
}
|
|
None => {
|
|
return Err(Error::Iam(IamError::AuthnFailed(
|
|
"Certificate missing organizational unit".into(),
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Find matching CN pattern
|
|
let (_pattern, mapping) = self
|
|
.find_matching_pattern(&cert_info.common_name)
|
|
.ok_or_else(|| {
|
|
Error::Iam(IamError::AuthnFailed(format!(
|
|
"No mapping for CN: {}",
|
|
cert_info.common_name
|
|
)))
|
|
})?;
|
|
|
|
// 5. Build principal reference
|
|
let principal_id = self.expand_template(&mapping.id_template, cert_info);
|
|
|
|
let principal_ref = PrincipalRef::new(mapping.kind.clone(), principal_id);
|
|
|
|
// 6. Extract node ID if configured
|
|
let node_id = if mapping.node_id_from_cn {
|
|
Some(self.extract_node_id(&cert_info.common_name))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(MtlsAuthResult {
|
|
principal_ref,
|
|
node_id,
|
|
certificate_fingerprint: cert_info.fingerprint.clone(),
|
|
})
|
|
}
|
|
|
|
fn find_matching_pattern(&self, cn: &str) -> Option<(&String, &PrincipalMapping)> {
|
|
for (pattern, mapping) in &self.config.cn_mappings {
|
|
if self.matches_pattern(pattern, cn) {
|
|
return Some((pattern, mapping));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn matches_pattern(&self, pattern: &str, value: &str) -> bool {
|
|
if pattern == "*" {
|
|
return true;
|
|
}
|
|
|
|
if !pattern.contains('*') {
|
|
return pattern == value;
|
|
}
|
|
|
|
// Simple glob matching
|
|
let parts: Vec<&str> = pattern.split('*').collect();
|
|
if parts.len() == 2 {
|
|
let (prefix, suffix) = (parts[0], parts[1]);
|
|
return value.starts_with(prefix) && value.ends_with(suffix);
|
|
}
|
|
|
|
// For more complex patterns, do exact match
|
|
pattern == value
|
|
}
|
|
|
|
fn expand_template(&self, template: &str, cert_info: &CertificateInfo) -> String {
|
|
let mut result = template.to_string();
|
|
result = result.replace("{cn}", &cert_info.common_name);
|
|
if let Some(o) = &cert_info.organization {
|
|
result = result.replace("{o}", o);
|
|
}
|
|
if let Some(ou) = &cert_info.organizational_unit {
|
|
result = result.replace("{ou}", ou);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn extract_node_id(&self, cn: &str) -> String {
|
|
// Extract node ID from CN like "node-abc123.compute.internal"
|
|
cn.split('.').next().unwrap_or(cn).to_string()
|
|
}
|
|
}
|
|
|
|
/// Result of mTLS authentication
|
|
#[derive(Debug, Clone)]
|
|
pub struct MtlsAuthResult {
|
|
/// Authenticated principal reference
|
|
pub principal_ref: PrincipalRef,
|
|
/// Node ID (if applicable)
|
|
pub node_id: Option<String>,
|
|
/// Certificate fingerprint
|
|
pub certificate_fingerprint: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_cert_info() -> CertificateInfo {
|
|
CertificateInfo {
|
|
common_name: "compute-agent.service.internal".into(),
|
|
organization: Some("cloud-platform".into()),
|
|
organizational_unit: Some("compute".into()),
|
|
serial_number: "123456".into(),
|
|
fingerprint: "sha256:abc123".into(),
|
|
not_before: 0,
|
|
not_after: u64::MAX,
|
|
sans: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_mtls_verification() {
|
|
let config = MtlsVerifierConfig::new()
|
|
.add_mapping(
|
|
"*.service.internal",
|
|
PrincipalMapping {
|
|
kind: PrincipalKind::ServiceAccount,
|
|
id_template: "{cn}".into(),
|
|
node_id_from_cn: false,
|
|
},
|
|
)
|
|
.with_required_org("cloud-platform");
|
|
|
|
let verifier = MtlsVerifier::new(config);
|
|
let cert = test_cert_info();
|
|
|
|
let result = verifier.verify(&cert).unwrap();
|
|
assert_eq!(result.principal_ref.kind, PrincipalKind::ServiceAccount);
|
|
assert_eq!(result.principal_ref.id, "compute-agent.service.internal");
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_id_extraction() {
|
|
let config = MtlsVerifierConfig::new().add_mapping(
|
|
"node-*.compute.internal",
|
|
PrincipalMapping {
|
|
kind: PrincipalKind::ServiceAccount,
|
|
id_template: "compute-agent".into(),
|
|
node_id_from_cn: true,
|
|
},
|
|
);
|
|
|
|
let verifier = MtlsVerifier::new(config);
|
|
|
|
let mut cert = test_cert_info();
|
|
cert.common_name = "node-abc123.compute.internal".into();
|
|
cert.organization = None;
|
|
|
|
let result = verifier.verify(&cert).unwrap();
|
|
assert_eq!(result.node_id, Some("node-abc123".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pattern_matching() {
|
|
let verifier = MtlsVerifier::new(MtlsVerifierConfig::default());
|
|
|
|
assert!(verifier.matches_pattern("*.example.com", "foo.example.com"));
|
|
assert!(verifier.matches_pattern("*.example.com", "bar.example.com"));
|
|
assert!(!verifier.matches_pattern("*.example.com", "foo.other.com"));
|
|
assert!(verifier.matches_pattern("exact-match", "exact-match"));
|
|
assert!(!verifier.matches_pattern("exact-match", "other"));
|
|
assert!(verifier.matches_pattern("*", "anything"));
|
|
}
|
|
}
|