photoncloud-monorepo/iam/crates/iam-authn/src/mtls.rs
centra 8f94aee1fa Fix R8: Convert submodule gitlinks to regular directories
- 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>
2025-12-09 16:51:20 +09:00

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"));
}
}