Arquitectura Maestra: Ecosistema Centralizado de Auditoría
Defines a centralized audit ecosystem inspired by AWS CloudTrail, using gRPC, Vector for resilient ingestion, and cryptographic digests for log integrity.
What this file does
Defines a centralized audit ecosystem inspired by AWS CloudTrail, using gRPC, Vector for resilient ingestion, and cryptographic digests for log integrity.
When to use it
- Building a multi-tenant audit logging service for compliance
- Designing a high-throughput event pipeline with zero data loss guarantees
- Implementing tamper-evident log integrity with digest chains
- Architecting a gRPC-based audit system with separate SDK and central service
Assumes this stack
Arquitectura Maestra: Ecosistema Centralizado de Auditoría
Hodei Audit Service - CloudTrail-like Platform (gRPC Direct)
📋 Tabla de Contenidos
- Visión Estratégica
- Arquitectura gRPC Directa
- Sistema HRN (Hodei Resource Names)
- Componentes del Ecosistema
- SDK para Middleware (Axum)
- Servicio Central de Auditoría
- Multi-Tenancy y Seguridad
- Alto Rendimiento e Ingesta
- Implementación por Fases
- Despliegue y DevOps
- Métricas y Observabilidad
- Casos de Uso
- Integración con verified-permissions
🎯 Visión Final y Resumen Ejecutivo
Construiremos un ecosistema de auditoría centralizado que se apoya en cuatro pilares fundamentales:
Pilares Arquitectónicos
-
Patrones Probados (AWS CloudTrail): Adoptamos la taxonomía de eventos y, de forma crítica, la cadena de digests criptográficos para garantizar la integridad y el no repudio de los logs, un requisito indispensable para el cumplimiento normativo.
-
Arquitectura Limpia (CAP/ARP): El
Audit Reporting Point(ARP/SDK) en cada aplicación captura eventos, y elCentralized Audit Point(CAP/Hodei Audit Service) los centraliza. Esta separación de responsabilidades es clave. -
Ingesta Desacoplada y Resiliente (Vector): El CAP delega la complejidad de la persistencia de datos a Vector. El servicio principal se enfoca en la lógica de negocio (validación, enriquecimiento), mientras Vector gestiona el buffering en disco, los reintentos y el enrutamiento a los almacenamientos finales.
-
Rendimiento Máximo (gRPC): Toda la comunicación interna crítica, tanto del SDK al CAP como del CAP a Vector, se realiza a través de gRPC, asegurando una latencia mínima y un uso eficiente de los recursos de red y CPU.
Arquitectura Final Detallada
graph TD
subgraph "Aplicaciones Cliente (ARP)"
A1(App 1 / SDK) -- "1. gRPC" --> CAP
A2(App 2 / SDK) -- "1. gRPC" --> CAP
end
subgraph "Servicio Central (CAP)"
CAP[Hodei Audit Service]
CAP -- "2. Valida, Autentica, Enriquece" --> CAP
CAP -- "3. Reenvío gRPC de alto rendimiento" --> V
end
subgraph "Motor de Persistencia y Enrutamiento (Vector)"
V[Vector Agent]
V -- "4. Búfer en Disco Persistente (Garantía de no pérdida)" --> D{{"vector_data<br/>(Volumen Persistente)"}}
V -- "5a. Lotes optimizados<br/>(con reintentos)" --> CH(ClickHouse - Hot Tier)
V -- "5b. Archivos<br/>(con reintentos)" --> S3(S3/MinIO - Warm/Cold Tier)
end
subgraph "Proceso Asíncrono de Integridad"
CW[Digest Worker<br/>(CronJob)] -- "Lee archivos de S3" --> S3
CW -- "Escribe Digests en S3" --> S3
end
style D fill:#f9f,stroke:#333,stroke-width:2px
Componentes Clave y sus Responsabilidades
1. SDK / Audit Reporting Point (ARP)
- Responsabilidad: Capturar eventos en las aplicaciones cliente
- Tecnología: Middleware Axum/Tower en Rust
- Comunicación: Envía lotes de eventos al CAP vía gRPC
- Lógica: Enriquecimiento inicial (trace_id, user_id), batching en memoria
2. Hodei Audit Service (CAP)
- Responsabilidad: Punto de entrada central, lógica simplificada y enfocada
- ✅ Expone endpoint gRPC para los SDKs
- ✅ Autenticación, validación de cuotas y enriquecimiento de eventos
- ✅ Reenvía eventos enriquecidos a Vector vía gRPC
- ❌ NO gestiona conexiones a bases de datos
- ❌ NO implementa lógica de reintentos de almacenamiento
- Tecnología: Servicio Rust con Tonic
3. Vector (Motor de Persistencia y Enrutamiento)
- Responsabilidad: Es el "trabajo pesado" del backend
- Recibir: Endpoint gRPC para recibir lotes del CAP
- Persistir: Escritura inmediata en búfer en disco, garantizando no pérdida
- Enrutar: Envío en paralelo a ClickHouse (Hot) y S3/MinIO (Warm/Cold)
- Reintentar: Gestión automática de fallos con backoff exponencial
- Tecnología: Vector Agent con disk buffer
4. Digest Worker
- Responsabilidad: Garantizar la integridad de los datos archivados
- Lógica: CronJob que escanea S3, calcula hashes, firma criptográficamente y encadena digests
- Función: Independiente del flujo de ingesta, crítica para compliance
Ventajas de esta Arquitectura
- Resiliencia Máxima: Disk buffer de Vector actúa como WAL, garantizando cero pérdida de eventos
- Baja Latencia: CAP responde en milisegundos, solo necesita confirmación de Vector
- Simplicidad: Código del CAP enfocado en lógica de negocio, sin gestionar BD
- Flexibilidad Operacional: Actualizaciones sin detener ingesta (Vector bufferiza)
- Rendimiento: gRPC minimiza sobrecarga de serialización y red
📖 Arquitectura: Fuente de la Verdad
Esta sección (Visión Final y Resumen Ejecutivo) es la descripción CANÓNICA de la arquitectura.
En las siguientes secciones, en lugar de volver a describir el flujo general, nos enfocamos exclusivamente en:
- Detalles específicos de cada componente
- Código de implementación
- Configuraciones específicas
- Casos de uso y ejemplos
Flujo canónico (no repetir en otras secciones):
App (ARP) → gRPC → Hodei Audit Service (CAP) → gRPC → Vector.dev → ClickHouse (hot) + S3 (warm)
Este flujo es la referencia única. Las demás secciones se enfocan en "cómo" implementar cada pieza, no en redescribir el "qué".
0. AWS CloudTrail: Análisis Funcional y Patrones de Diseño
🎯 Análisis de CloudTrail como Referencia Arquitectónica
AWS CloudTrail es el gold standard para audit logging en cloud. Analizar su arquitectura nos permite adoptar patrones probados y evitar reinvenciones.
Event Taxonomy de CloudTrail
CloudTrail organiza eventos en 3 categorías principales:
{
"event_categories": {
"management_events": {
"description": "Operaciones de control en recursos AWS",
"examples": [
"CreateRole", "DeleteBucket", "AttachRolePolicy",
"StartInstance", "CreateVPC", "UpdateTrail"
],
"volume": "~40% del total de eventos",
"compliance_critical": true
},
"data_events": {
"description": "Acceso y modificación de datos en recursos",
"examples": [
"GetObject", "PutObject", "DeleteObject", // S3
"Query", "Insert", "Update", // DynamoDB
"Select", "CreateTable" // RDS
],
"volume": "~55% del total de eventos",
"compliance_critical": true
},
"insight_events": {
"description": "Análisis de comportamiento anómalo",
"examples": [
"ApiCallRateInsight", "ResourceAnomalyDetection",
"SecurityDetectorAlert", "UnusualActivityAlert"
],
"volume": "~5% del total de eventos",
"compliance_critical": false
}
}
}
HRN vs CloudTrail ARNs
| Concepto | CloudTrail ARN | HRN (Hodei) |
|---|---|---|
| Formato | arn:partition:service:region:account:resource | hrn:partition:service:tenant:region:resource-type/path |
| Identificación | Basado en AWS account | Basado en tenant_id |
| Scope | Global AWS | Multi-tenant por diseño |
| Partición | aws, aws-cn, aws-gov | hodei (ecosistema) |
| Recurso | type:resource | resource-type/resource-path |
Ventaja HRN: Multi-tenancy nativo desde día 1, mientras CloudTrail lo añadió después.
Taxonomía de Eventos para Hodei
/// Evento de gestión (Management Event)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagementEvent {
pub event_category: EventCategory, // MANAGEMENT
pub operation_type: OperationType, // CREATE, UPDATE, DELETE
pub resource_type: ResourceType, // POLICY_STORE, USER, ROLE
pub resource_name: String, // Identificador del recurso
pub changes: Vec<Change>, // Cambios realizados
}
/// Evento de datos (Data Event)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataEvent {
pub event_category: EventCategory, // DATA
pub access_type: AccessType, // READ, WRITE
pub data_classification: DataClassification, // PUBLIC, INTERNAL, CONFIDENTIAL, PII
pub data_size_bytes: u64, // Tamaño de datos
pub results: AccessResults, // SUCCESS, FAILURE, PARTIAL
}
/// Evento de insights (Insight Event)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsightEvent {
pub event_category: EventCategory, // INSIGHT
pub anomaly_type: AnomalyType, // RATE_SPIKE, UNAUTHORIZED_ACCESS, DATA_EXFILTRATION
pub severity: Severity, // LOW, MEDIUM, HIGH, CRITICAL
pub affected_resources: Vec<Hrn>, // Recursos afectados
pub confidence_score: f64, // 0.0 - 1.0
pub recommendations: Vec<String>, // Acciones sugeridas
}
🔒 Ingeniería de Integridad: El Digest Criptográfico de CloudTrail
Cómo CloudTrail Asegura Integridad
CloudTrail usa un digest chain para garantizar tamper-evidence:
/// CloudTrail Digest Structure
struct CloudTrailDigest {
pub previous_hash: String, // SHA-256 del digest anterior
pub current_hash: String, // SHA-256 de archivos de log actuales
pub created_timestamp: DateTime<Utc>,
pub files_hash: Vec<String>, // SHA-256 de cada archivo de log
pub file_info: Vec<FileInfo>, // Metadata de archivos
pub digest_signature: String, // Firma de AWS con key privada
}
Flujo de Integridad:
- Hourly: CloudTrail crea digest de logs de la última hora
- Chain: Incluye hash del digest anterior (forma cadena)
- Files: Calcula SHA-256 de cada archivo Parquet
- Sign: Firma digest con clave privada de AWS
- Store: Guarda digest en S3 con metadata
- Verify: Cualquiera puede verificar con clave pública de AWS
Implementación de Digest en Hodei
// hodei-audit-service/src/crypto/digest.rs
### 🔐 Abstracción KeyManager: Gestión del Ciclo de Vida de Claves
**Principio**: El `DigestWorker` debe depender de una **abstracción** (`trait KeyManager`), no de una implementación concreta. Esto permite empezar con una solución autocontenida y evolucionar hacia servicios externos (Vault, KMS) **sin modificar el DigestWorker**.
#### **Paso 1: Definir la Abstracción**
```rust
// hodei-audit-service/src/crypto/key_manager.rs
use async_trait::async_trait;
use ed25519_dalek::{SigningKey, VerifyingKey};
// Manifiesto público para la verificación por auditores
pub struct PublicKeyManifest {
pub keys: Vec<PublicKeyInfo>,
// ... otros metadatos
}
pub struct PublicKeyInfo {
pub key_id: String,
pub public_key: VerifyingKey,
pub valid_from: DateTime<Utc>,
pub status: String, // "active", "expired"
}
/// Trait que abstrae la gestión de claves criptográficas.
#[async_trait]
pub trait KeyManager: Send + Sync {
/// Obtiene la clave de firma activa actual para crear nuevos digests.
async fn get_current_signing_key(&self) -> Result<(String, SigningKey), KeyManagementError>;
/// Obtiene una clave pública específica por su ID para verificar un digest antiguo.
async fn get_public_key_by_id(&self, key_id: &str) -> Result<VerifyingKey, KeyManagementError>;
/// Devuelve el manifiesto de todas las claves públicas para que los auditores puedan verificar.
async fn get_public_key_manifest(&self) -> Result<PublicKeyManifest, KeyManagementError>;
}
Paso 2: Implementación Inicial Autocontenida
// Implementación inicial que carga claves desde un archivo/ConfigMap
pub struct StandaloneKeyManager {
key_config: SecureKeyConfig, // Contiene clave privada y historial de públicas
}
#[async_trait]
impl KeyManager for StandaloneKeyManager {
async fn get_current_signing_key(&self) -> Result<(String, SigningKey), KeyManagementError> {
// Lee la clave privada desde un secreto de Kubernetes montado como archivo
let key_data = tokio::fs::read(&self.key_config.private_key_path).await?;
let key_str = String::from_utf8(key_data)?;
// Parsea la clave privada (formato PEM o raw bytes)
let signing_key = SigningKey::from_bytes(&parse_private_key(&key_str)?);
// El key_id puede ser derivado del contenido o almacenado en metadata
let key_id = self.key_config.active_key_id.clone();
Ok((key_id, signing_key))
}
async fn get_public_key_by_id(&self, key_id: &str) -> Result<VerifyingKey, KeyManagementError> {
// Busca en el historial de claves públicas
for key_info in &self.key_config.public_keys {
if key_info.key_id == key_id {
return Ok(key_info.public_key.clone());
}
}
Err(KeyManagementError::KeyNotFound(key_id.to_string()))
}
async fn get_public_key_manifest(&self) -> Result<PublicKeyManifest, KeyManagementError> {
Ok(PublicKeyManifest {
keys: self.key_config.public_keys.clone(),
})
}
}
Paso 3: Future Evolution (Vault/KMS)
// Futuro: Implementación con HashiCorp Vault
pub struct VaultKeyManager {
vault_client: Arc<Client>,
// ...
}
#[async_trait]
impl KeyManager for VaultKeyManager {
async fn get_current_signing_key(&self) -> Result<(String, SigningKey), KeyManagementError> {
// Obtiene la clave activa desde Vault
let response = self.vault_client
.kv2_get("secret/audit/digests", "active_key")?;
let key_id = response.data["key_id"].as_str().unwrap();
let private_key_b64 = response.data["private_key"].as_str().unwrap();
let private_key_bytes = base64::decode(private_key_b64)?;
Ok((key_id.to_string(), SigningKey::from_bytes(&private_key_bytes)))
}
// ... resto de implementaciones similar
}
Paso 4: DigestWorker Depende de la Abstracción
/// Digest Worker - Ejecuta cada hora (Kubernetes CronJob)
pub struct DigestWorker {
// ¡Depende del trait, no de una implementación concreta!
key_manager: Arc<dyn KeyManager>,
s3_client: Arc<S3Client>,
previous_digest: Option<String>, // Hash del digest anterior
cloudtrail_style: bool, // true = CloudTrail compatible
}
impl DigestWorker {
/// Ejecuta digest hourly (triggered by CronJob)
pub async fn run_hourly_digest(&mut self) -> Result<DigestReport> {
// 1. Obtiene la clave de firma usando la abstracción
let (key_id, signing_key) = self.key_manager.get_current_signing_key().await?;
// 2. Query previous digest hash
let previous_hash = self.get_previous_digest_hash().await?;
// 3. List Parquet files from last hour
let hour_ago = Utc::now() - Duration::hours(1);
let parquet_files = self.list_parquet_files(&hour_ago, &Utc::now()).await?;
// 4. Calculate SHA-256 of each file
let mut file_hashes = Vec::new();
let mut total_bytes = 0u64;
for file in &parquet_files {
let hash = self.calculate_file_sha256(file).await?;
let size = file.size_bytes;
total_bytes += size;
file_hashes.push(FileHash {
path: file.path.clone(),
sha256: hash,
size_bytes: size,
modified_at: file.modified_at,
});
}
// 5. Build digest content (CloudTrail compatible)
let digest_content = DigestContent {
version: "1.0".to_string(),
creation_time: Utc::now(),
previous_digest_hash: previous_hash.clone(),
file_hashes: file_hashes.clone(),
total_files: file_hashes.len() as u32,
total_bytes,
digest_algorithm: "SHA-256".to_string(),
// ¡Crucial! Incluir key_id para verificación futura
key_id,
};
// 6. Serialize digest
let digest_bytes = serde_json::to_vec(&digest_content)?;
// 7. Sign digest con ed25519-dalek
let signature: Signature = signing_key.sign(&digest_bytes);
let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes());
// 8. Upload digest to S3 with metadata
let digest_path = self.upload_digest_with_metadata(
&digest_bytes,
&signature_b64,
&previous_hash
).await?;
// 9. Update previous_digest
self.previous_digest = Some(digest_content.current_hash.clone());
Ok(DigestReport {
digest_path,
files_processed: file_hashes.len(),
total_bytes,
signature: signature_b64,
key_id, // Incluido para trazabilidad
})
}
}
Ventaja Estratégica
Migración a Vault/KMS:
// Cambio mínimo - solo en el bootstrap de la aplicación
fn create_digest_worker() -> DigestWorker {
#[cfg(feature = "vault")]
let key_manager: Arc<dyn KeyManager> = Arc::new(VaultKeyManager::new().unwrap());
#[cfg(not(feature = "vault"))]
let key_manager: Arc<dyn KeyManager> = Arc::new(StandaloneKeyManager::new().unwrap());
DigestWorker {
key_manager,
s3_client: Arc::new(S3Client::new().unwrap()),
previous_digest: None,
cloudtrail_style: true,
}
}
El DigestWorker NO requiere modificaciones. Solo cambias la implementación del KeyManager que usas al inicializar la aplicación.
🚀 Digest Worker - Gestión de Integridad
impl DigestWorker { /// Ejecuta digest hourly (triggered by CronJob) pub async fn run_hourly_digest(&mut self) -> Result<DigestReport> { // 1. Cargar signing key desde HashiCorp Vault let signing_key = self.load_signing_key().await?;
// 2. Query previous digest hash
let previous_hash = self.get_previous_digest_hash().await?;
// 3. List Parquet files from last hour
let hour_ago = Utc::now() - Duration::hours(1);
let parquet_files = self.list_parquet_files(&hour_ago, &Utc::now()).await?;
// 4. Calculate SHA-256 of each file
let mut file_hashes = Vec::new();
let mut total_bytes = 0u64;
for file in &parquet_files {
let hash = self.calculate_file_sha256(file).await?;
let size = file.size_bytes;
total_bytes += size;
file_hashes.push(FileHash {
path: file.path.clone(),
sha256: hash,
size_bytes: size,
modified_at: file.modified_at,
});
}
// 5. Build digest content (CloudTrail compatible)
let digest_content = DigestContent {
version: "1.0".to_string(),
creation_time: Utc::now(),
previous_digest_hash: previous_hash.clone(),
file_hashes: file_hashes.clone(),
total_files: file_hashes.len() as u32,
total_bytes,
digest_algorithm: "SHA-256".to_string(),
};
// 6. Serialize digest
let digest_bytes = serde_json::to_vec(&digest_content)?;
// 7. Sign digest con ed25519-dalek
let signature: Signature = signing_key.sign(&digest_bytes);
let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes());
// 8. Upload digest to S3 with metadata
let digest_path = self.upload_digest_with_metadata(
&digest_bytes,
&signature_b64,
&previous_hash
).await?;
// 9. Update previous_digest
self.previous_digest = Some(digest_content.current_hash.clone());
Ok(DigestReport {
digest_path,
files_processed: file_hashes.len(),
total_bytes,
signature: signature_b64,
})
}
/// Verify digest integrity (read-only, usado por auditors)
pub async fn verify_digest(&self, digest_path: &str) -> Result<VerificationResult> {
// 1. Download digest file
let digest_bytes = self.s3_client
.get_object()
.bucket(&self.bucket)
.key(digest_path)
.send()
.await?
.body
.collect()
.await?;
// 2. Parse digest
let digest: DigestContent = serde_json::from_slice(&digest_bytes)?;
// 3. Calculate hash of digest content
let calculated_hash = sha2::Sha256::digest(&digest_bytes);
let calculated_hash_hex = format!("{:x}", calculated_hash);
// 4. Verify chain
let chain_valid = if let Some(prev_hash) = &digest.previous_digest_hash {
// Verificar que previous hash coincide
self.verify_previous_hash(prev_hash).await?
} else {
// Primer digest - no chain
true
};
// 5. Verify signatures
let signature = self.get_digest_signature(digest_path).await?;
let public_key = self.get_public_key().await?;
let signature_bytes = base64::engine::general_purpose::STANDARD.decode(signature)?;
let verification_result = public_key.verify(
&digest_bytes,
&Signature::from_bytes(&signature_bytes)?
);
Ok(VerificationResult {
chain_valid,
signature_valid: verification_result.is_ok(),
hash_matches: digest.current_hash == calculated_hash_hex,
files_verified: 0, // Calculado abajo
})
}
/// Recalculate hash para verificar integridad
async fn verify_file_hash(&self, file: &FileHash) -> Result<bool> {
let current_hash = self.calculate_file_sha256(&file.path).await?;
Ok(current_hash == file.sha256)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DigestContent { pub version: String, // "1.0" pub creation_time: DateTime<Utc>, // Cuándo se creó pub previous_digest_hash: Option<String>, // Hash del digest anterior pub file_hashes: Vec<FileHash>, // SHA-256 de cada archivo pub total_files: u32, // Número total de archivos pub total_bytes: u64, // Bytes totales pub digest_algorithm: String, // "SHA-256" pub current_hash: String, // SHA-256 de este digest }
#[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileHash { pub path: String, // S3 path del archivo pub sha256: String, // SHA-256 hash pub size_bytes: u64, // Tamaño en bytes pub modified_at: DateTime<Utc>, // Última modificación }
#### **Verificación de Integridad para Auditors**
```bash
# Verificar digest (puede hacerlo cualquier auditor)
curl -X POST "http://audit-service:50053/v1/crypto/verify-digest" \
-H "Authorization: Bearer $AUDITOR_KEY" \
-d '{
"digest_path": "digest/2024-01-15/10:00/digest.json"
}'
# Response:
{
"verification_result": {
"chain_valid": true,
"signature_valid": true,
"hash_matches": true,
"files_verified": 156,
"verification_timestamp": "2024-01-15T10:30:00Z",
"verifier": "auditor-123"
}
}
CronJob para Digest Worker
# kubernetes/cronjob-digest.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: audit-digest-worker
spec:
schedule: "0 * * * *" # Cada hora
jobTemplate:
spec:
template:
spec:
containers:
- name: digest-worker
image: hodei/audit-service:latest
command: ["./digest-worker"]
env:
- name: VAULT_ADDR
value: "https://vault.hodei.internal"
- name: S3_BUCKET
value: "audit-logs"
- name: DIGEST_INTERVAL_HOURS
value: "1"
restartPolicy: OnFailure
📊 Patrones de Datos de CloudTrail que Adoptamos
1. EventID Único
// CloudTrail: "3e85f29f-5d39-4f3a-9b8e-9e2a3b4c5d6e"
// Hodei: "evt_3e85f29f5d394f3a9b8e9e2a3b4c5d6e"
pub struct AuditEvent {
pub event_id: String, // Unique identifier
// ...
}
2. ReadOnly Flag
pub struct AuditEvent {
pub read_only: bool, // true = no cambios de estado
pub outcome: Outcome, // SUCCESS, FAILURE
// ...
}
// CloudTrail: managementEvents vs dataEvents
// Hodei: event_category + readOnly flag
3. AdditionalEventData
// CloudTrail: additionalEventData
// Hodei: metadata JSON field
pub struct AuditEvent {
pub metadata: serde_json::Value, // Campos adicionales
// ...
}
4. Error Code y Message
// CloudTrail: errorCode, errorMessage
// Hodei: outcome_details
pub struct AuditEvent {
pub outcome: Outcome, // SUCCESS, FAILURE
pub error_code: Option<String>, // e.g., "Unauthorized"
pub error_message: Option<String>, // Detalles del error
// ...
}
💡 Lecciones Aprendidas de CloudTrail
- Digest Chain es Esencial: Crea tamper-evidence verificable
- Event Categories son Críticos: Separar management vs data vs insights
- ReadOnly Flag Ayuda: Separar reads de writes
- Metadata Flexibility: Campo JSON para extensibilidad
- Error Details: Capturar errorCode y errorMessage
- Multi-Account vs Multi-Tenant: Hodei hace multi-tenant nativo
🔐 IMPLEMENTACIÓN CRÍTICA: Sistema de Digest Criptográfico
¿Por qué es CRÍTICO?
El sistema de digest criptográfico es FUNDAMENTAL para:
- ✅ Integridad: Detectar cualquier manipulación de logs
- ✅ No-repudio: Probar que un evento ocurrió (legalmente)
- ✅ Compliance: Cumplir SOC2, ISO 27001, HIPAA
- ✅ Auditoría: Verificar cadena de custodia
Sin digest, el sistema NO es viable para producción.
Estructura de Digest Hodei (CloudTrail-Compatible)
/// Digest Worker - Implementación completa con ed25519-dalek
pub struct DigestWorker {
signing_key: Arc<ed25519_dalek::SigningKey>, // Clave privada
s3_client: Arc<S3Client>,
bucket: String,
last_digest: Option<HodeiDigestFile>, // Para chain
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HodeiDigestFile {
pub tenant_id: TenantId,
pub digest_id: String, // UUID único
pub digest_start_time: DateTime<Utc>,
pub digest_end_time: DateTime<Utc>,
// Criptografía
pub previous_digest_hash_value: Option<String>, // SHA-256 del anterior
pub previous_digest_signature: Option<String>, // Firma del anterior
pub digest_signature: String, // Firma del actual (Ed25519)
pub digest_algorithm: String, // "Ed25519"
// Archivos
pub log_files: Vec<LogFileDigest>, // Hash de cada Parquet
pub total_events: u64,
pub total_log_files: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogFileDigest {
pub s3_object: String, // Path en S3
pub hash_value: String, // SHA-256 del archivo
pub hash_algorithm: String, // "SHA-256"
pub event_count: u64, // Eventos en el archivo
pub newest_event_time: DateTime<Utc>,
pub oldest_event_time: DateTime<Utc>,
}
Flujo de Generación de Digest (Hourly)
impl DigestWorker {
/// Generar digest para la última hora (triggered by CronJob)
pub async fn generate_hourly_digest(&mut self) -> Result<HodeiDigestFile, DigestError> {
let now = Utc::now();
let one_hour_ago = now - Duration::hours(1);
// 1. Listar archivos Parquet de la última hora
let log_files = self.list_parquet_files(one_hour_ago, now).await?;
// 2. Calcular SHA-256 de cada archivo
let mut log_digests = Vec::new();
for s3_key in log_files {
let digest = self.compute_file_digest(&s3_key).await?;
log_digests.push(digest);
}
// 3. Obtener hash del digest anterior (para chain)
let previous_chain_info = self.get_previous_chain_info().await?;
// 4. Construir digest
let mut digest = HodeiDigestFile {
tenant_id: self.tenant_id.clone(),
digest_id: Uuid::new_v4().to_string(),
digest_start_time: one_hour_ago,
digest_end_time: now,
previous_digest_hash_value: previous_chain_info.previous_hash,
previous_digest_signature: previous_chain_info.previous_signature,
digest_signature: String::new(), // Se calculará después
digest_algorithm: "Ed25519".to_string(),
log_files: log_digests,
total_events: log_digests.iter().map(|d| d.event_count).sum(),
total_log_files: log_digests.len() as u32,
};
// 5. FIRMAR el digest con Ed25519
let digest_bytes = self.serialize_for_signing(&digest)?;
let signature: ed25519_dalek::Signature = self.signing_key.sign(&digest_bytes);
digest.digest_signature = hex::encode(signature.to_bytes());
// 6. Calcular hash del digest (para chain hacia el siguiente)
let digest_hash = hex::encode(Sha256::digest(&digest_bytes));
// 7. Subir digest a S3 con metadata
self.upload_digest_with_metadata(&digest, &digest_hash).await?;
// 8. Guardar como "último digest"
self.last_digest = Some(digest.clone());
Ok(digest)
}
/// Verificar integridad (para auditors)
pub async fn verify_digest(&self, digest: &HodeiDigestFile) -> Result<VerificationResult, DigestError> {
let mut result = VerificationResult::default();
// 1. Verificar firma del digest
result.signature_valid = self.verify_digest_signature(digest).await?;
// 2. Verificar hash de cada archivo
for log_file in &digest.log_files {
let is_valid = self.verify_file_hash(log_file).await?;
result.files_verified += 1;
if !is_valid {
result.errors.push(format!("Invalid hash for: {}", log_file.s3_object));
}
}
// 3. Verificar cadena (previous → current)
if let Some(prev_hash) = &digest.previous_digest_hash_value {
result.chain_valid = self.verify_chain(digest, prev_hash).await?;
}
result.overall_valid = result.signature_valid && result.chain_valid && result.errors.is_empty();
Ok(result)
}
}
CronJob para Digest (Kubernetes)
apiVersion: batch/v1
kind: CronJob
metadata:
name: hodei-audit-digest-worker
spec:
schedule: "0 * * * *" # Cada hora en punto
jobTemplate:
spec:
template:
spec:
containers:
- name: digest-worker
image: hodei/audit-service:latest
command: ["./digest-worker"]
env:
- name: VAULT_ADDR
value: "https://vault.hodei.internal" # Para keys
- name: S3_BUCKET
value: "audit-logs"
- name: ED25519_KEY_PATH
value: "secret/audit/digest-key"
restartPolicy: OnFailure
gRPC API para Verificación
/// Endpoint para que auditors verifiquen integridad
#[async_trait]
impl DigestService for AuditService {
async fn verify_digest_chain(
&self,
request: tonic::Request<VerifyDigestRequest>,
) -> Result<tonic::Response<VerifyDigestResponse>, tonic::Status> {
let digest_id = request.into_inner().digest_id;
// Cargar digest
let digest = self.load_digest(&digest_id).await
.map_err(|e| tonic::Status::not_found(e.to_string()))?;
// Verificar
let result = self.digest_verifier.verify_digest(&digest).await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
Ok(tonic::Response::new(VerifyDigestResponse {
overall_valid: result.overall_valid,
signature_valid: result.signature_valid,
chain_valid: result.chain_valid,
files_verified: result.files_verified,
errors: result.errors,
}))
}
}
CLI para Auditoría Manual
# Verificar digest específico
hodei-audit-cli verify digest \
--tenant-id tenant-123 \
--digest-id abc-123-def \
--from "2025-01-01T00:00:00Z" \
--to "2025-01-31T23:59:59Z"
# Output:
# {
# "overall_valid": true,
# "signature_valid": true,
# "chain_valid": true,
# "files_verified": 156,
# "errors": []
# }
🔐 MEJORA CRÍTICA: Gestión y Distribución Segura de Claves
Problema: La verificación de digests requiere acceso a claves públicas de forma segura.
Solución: Manifiesto de Claves Firmado
/// Endpoint de confianza para distribución de claves públicas
pub struct KeyDistributionService {
vault_client: Arc<VaultClient>,
root_signing_key: ed25519_dalek::SigningKey, // Clave raíz (más restringida)
}
impl KeyDistributionService {
/// Endpoint: /.well-known/audit-public-keys.json
pub async fn get_public_keys_manifest(&self) -> Result<KeysManifest, KeyError> {
// 1. Obtener claves activas de Vault
let active_keys = self.vault_client.list_active_signing_keys().await?;
// 2. Crear manifiesto
let mut manifest = KeysManifest {
version: "1.0".to_string(),
last_updated: Utc::now(),
keys: Vec::new(),
};
// 3. Para cada clave, construir public key info
for key in active_keys {
let public_key_pem = self.export_public_key_pem(&key.key_id)?;
let fingerprint = self.compute_fingerprint(&public_key_pem);
manifest.keys.push(PublicKeyInfo {
key_id: key.key_id.clone(),
algorithm: "Ed25519".to_string(),
public_key_pem,
valid_from: key.valid_from,
valid_to: key.valid_to,
fingerprint,
status: key.status,
});
}
// 4. Firmar el manifiesto con clave raíz
let manifest_bytes = serde_json::to_vec(&manifest)?;
let root_signature = self.root_signing_key.sign(&manifest_bytes);
manifest.root_signature = hex::encode(root_signature.to_bytes());
Ok(manifest)
}
/// Rotación automática de claves (cada 90 días)
pub async fn rotate_signing_key(&self, tenant_id: &TenantId) -> Result<KeyRotationResult, KeyError> {
// 1. Generar nueva clave
let (new_signing_key, new_verify_key) = self.generate_keypair()?;
// 2. Almacenar en Vault
let new_key_id = format!("key-{}-v{}",
tenant_id,
Utc::now().format("%Y%m%d")
);
self.vault_client.store_signing_key(
&new_key_id,
&new_signing_key,
Duration::days(90) // 90 días de validez
).await?;
// 3. La nueva clave firma a partir de ahora
// 4. Mantener clave anterior para verificar digests históricos
// 5. Advertir que old key se retirará en 30 días
Ok(KeyRotationResult {
new_key_id,
old_key_id: self.get_current_key_id(tenant_id)?,
rotation_date: Utc::now(),
old_key_valid_until: Utc::now() + Duration::days(30),
})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KeysManifest {
pub version: String,
pub last_updated: DateTime<Utc>,
pub keys: Vec<PublicKeyInfo>,
pub root_signature: String, // Firma del manifiesto completo
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PublicKeyInfo {
pub key_id: String,
pub algorithm: String,
pub public_key_pem: String,
pub valid_from: DateTime<Utc>,
pub valid_to: DateTime<Utc>,
pub fingerprint: String,
pub status: KeyStatus,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum KeyStatus {
Active,
Decommissioned,
Compromised,
}
gRPC Endpoint para Claves:
#[async_trait]
impl KeyDistributionService for AuditService {
async fn get_public_keys(
&self,
_request: tonic::Request<GetPublicKeysRequest>,
) -> Result<tonic::Response<GetPublicKeysResponse>, tonic::Status> {
let manifest = self.key_service.get_public_keys_manifest()
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
Ok(tonic::Response::new(GetPublicKeysResponse {
manifest_json: serde_json::to_string(&manifest)?,
}))
}
}
Verificación por Auditor:
# Auditor descarga el manifiesto
curl -s https://audit.hodei.internal/.well-known/audit-public-keys.json > keys.json
# Verificar integridad del manifiesto
hodei-audit-cli verify manifest \
--manifest keys.json \
--root-key-fingerprint ABC123DEF456
# Luego verificar digest con clave específica
hodei-audit-cli verify digest \
--digest-id abc-123 \
--key-id key-2025-q4-v1 \
--keys-manifest keys.json
📐 FORMALIZACIÓN: CAP/ARP Pattern Completo
Trait Definitions (Formales)
/// CAP - Centralized Audit Point (Backend)
/// Análogo al PDP (Policy Decision Point) en AVP
#[async_trait]
pub trait CentralizedAuditPoint: Send + Sync {
async fn receive_event(
&self,
tenant_id: TenantId,
event: AuditEvent,
) -> Result<ReceiptHandle, CapError>;
async fn receive_batch(
&self,
tenant_id: TenantId,
events: Vec<AuditEvent>,
) -> Result<Vec<ReceiptHandle>, CapError>;
async fn query_events(
&self,
tenant_id: TenantId,
query: AuditQuery,
requester: UserId,
) -> Result<AuditQueryResult, CapError>;
async fn verify_integrity(
&self,
tenant_id: TenantId,
digest_id: String,
) -> Result<DigestVerificationResult, CapError>;
}
/// ARP - Audit Reporting Point (SDK/Middleware)
/// Análogo al PEP (Policy Enforcement Point) en AVP
#[async_trait]
pub trait AuditReportingPoint: Send + Sync {
async fn report_event(&self, event: AuditEvent) -> Result<(), ArpError>;
async fn report_batch(&self, events: Vec<AuditEvent>) -> Result<(), ArpError>;
fn with_buffering(self, config: BufferConfig) -> Self;
fn with_retry(self, policy: RetryPolicy) -> Self;
}
Tenant Isolation Enforcement
/// CRÍTICO: Enforcing multi-tenancy en CAP
impl CentralizedAuditPoint for AuditService {
async fn query_events(
&self,
tenant_id: TenantId,
mut query: AuditQuery,
requester: UserId,
) -> Result<AuditQueryResult, CapError> {
// 1. Verificar que requester pertenece al tenant
self.validate_tenant_membership(&requester, &tenant_id).await?;
// 2. CRUCIAL: Forzar tenant_id en query (Row-Level Security)
query.tenant_id = Some(tenant_id);
// 3. Ejecutar query con RLS
let result = self.query_engine.execute(query).await?;
Ok(result)
}
}
/// CRÍTICO: Enforcing en ARP
impl AuditReportingPoint for AuditMiddleware {
async fn report_event(&self, event: AuditEvent) -> Result<(), ArpError> {
// Validar que evento pertenece al tenant del middleware
if event.tenant_id != self.tenant_id {
return Err(ArpError::TenantMismatch {
expected: self.tenant_id.clone(),
got: event.tenant_id.clone(),
});
}
// Enviar al CAP
self.cap_client.publish_event(event).await?;
Ok(())
}
}
📊 CloudTrail Event Taxonomy Completa
/// Estructura completa compatible con CloudTrail
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudTrailCompatibleEvent {
// Core Identity
pub event_version: String, // "1.09"
pub event_id: String, // UUID
pub event_time: DateTime<Utc>, // ISO 8601
// Event Type (CloudTrail taxonomy)
pub event_type: CloudTrailEventType,
pub event_name: String, // "CreatePolicyStore"
pub event_source: String, // "verified-permissions.hodei.cloud"
// User Context
pub user_identity: UserIdentity,
pub source_ip_address: IpAddr,
pub user_agent: String,
// Request/Response
pub request_parameters: Option<serde_json::Value>,
pub response_elements: Option<serde_json::Value>,
// Resources
pub resources: Vec<ResourceIdentifier>,
// Error (si aplica)
pub error_code: Option<String>,
pub error_message: Option<String>,
// CloudTrail flags
pub read_only: bool,
pub management_event: bool,
// Hodei Extensions
pub hrn: Hrn,
pub correlation_id: String,
pub trace_id: Option<String>,
pub span_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "eventType")]
pub enum CloudTrailEventType {
ManagementEvent {
event_category: ManagementCategory,
read_only: bool,
},
DataEvent {
resources: Vec<ResourceIdentifier>,
read_only: bool,
},
InsightEvent {
insight_type: InsightType,
baseline_value: f64,
insight_value: f64,
anomaly_score: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ManagementCategory {
Write, // CREATE, UPDATE, DELETE
Read, // GET, LIST, DESCRIBE
Configuration, // PUT_CONFIG
}
1. Visión Estratégica
🎯 Objetivo Maestro
Crear un ecosistema centralizado de auditoría tipo AWS CloudTrail, que proporcione:
- Servicio centralizado de auditoría usando gRPC directo
- SDK reutilizable para integración fácil con middlewares de Axum
- Sistema HRN (Hodei Resource Names) para identificación única de recursos
- Multi-tenancy para aislamiento completo de datos
- Compliance automático con auditoría de APIs
💡 Propuesta de Valor
Para Desarrolladores
- ✅ SDK 1-liner:
.layer(AuditLayer::new(service_config)) - ✅ Auto-captura: Middleware intercepta automáticamente todas las requests
- ✅ HRN nativo: Recursos identificados con HRN estándar
- ✅ Async-first: Batching y envío no-bloqueante por defecto
- ✅ gRPC directo: Latencia mínima, debugging sencillo
Para Organizaciones
- ✅ Centralización: Un solo lugar para toda la auditoría
- ✅ Compliance: Retención, inmutabilidad, búsqueda avanzada
- ✅ Escalabilidad: 100K+ eventos/segundo con gRPC batching
- ✅ Observabilidad: Métricas, alertas, dashboards unificados
- ✅ Costo: Reducción 70% en infraestructura de logging
🏗️ Visión Arquitectónica
┌─────────────────────────────────────────────────────────────────┐
│ HODEI AUDIT ECOSYSTEM │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ App 1 │ │ App 2 │ │ App N │ │
│ │ (Axum) │ │ (Axum) │ │ (Axum) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
│ │ Audit SDK │ │ Audit SDK │ │ Audit SDK │ │
│ │ (Middleware) │ │ (Middleware) │ │ (Middleware) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌────────────▼────────────┐ │
│ │ gRPC DIRECT CALLS │ │
│ │ (Port 50052-50053) │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌───────────────────────▼───────────────────────┐ │
│ │ HODEI AUDIT SERVICE (gRPC) │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Ingestion API │ │ Query API │ │ │
│ │ │ (Port 50052) │ │ (Port 50053) │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Processing Pipeline (Rust) │ │ │
│ │ │ - HRN Resolution │ │ │
│ │ │ - Event Enrichment │ │ │
│ │ │ - Batch Processing │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────┬───────────────────────┘ │
│ │ │
│ ┌────────────▼────────────┐ │
│ │ STORAGE LAYER │ │
│ │ ┌─────┐ ┌─────┐ │ │
│ │ │Click│ │S3/ │ │ │
│ │ │House│ │GCS │ │ │
│ │ └─────┘ └─────┘ │ │
│ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
2. Arquitectura gRPC Directa
🌐 Principios Fundamentales
1. Simplicidad por Encima de Todo
- gRPC directo: Comunicación 1:1 entre SDK y Audit Service
- Vector.dev para persistencia: Manejo de buffering y enrutamiento
- Type safety: Protobuf garantiza contracts estrictos
- Debugging directo: Logs claros, sin complejidad de message broker
2. Patrón Strategy evolutivo
// verified-permissions/domain/src/audit/mod.rs
/// Audit Transport Strategy
#[async_trait]
pub trait AuditTransport: Send + Sync {
async fn publish(&self, event: AuditEvent) -> Result<(), AuditError>;
}
/// Contexto de auditoría
pub struct AuditContext {
transport: Box<dyn AuditTransport>,
config: AuditConfig,
}
/// Evento de auditoría multi-tenant
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub event_id: String,
pub tenant_id: TenantId,
pub hrn: Hrn, // Hodei Resource Name
pub correlation_id: String,
pub source_service: ServiceId,
pub user_id: UserId,
pub action: String,
pub method: HttpMethod,
pub path: String,
pub status_code: StatusCode,
pub outcome: Outcome,
pub latency_ms: u64,
pub metadata: serde_json::Value,
pub timestamp: DateTime<Utc>,
}
3. Multi-Tenancy Nativo
// Cada evento incluye tenant_id
// Auditoría query está siempre filtrada por tenant
// Row-level security en storage
🔄 Estrategia de Implementación
Fase 1: gRPC Directo (Actual)
// Simple, directo, mínimo overhead
pub struct GrpcTransport {
client: AuditControlServiceClient<tonic::transport::Channel>,
batch_queue: Arc<Mutex<Vec<AuditEvent>>>,
}
#[async_trait]
impl AuditTransport for GrpcTransport {
async fn publish(&self, event: AuditEvent) -> Result<()> {
// Batching local para reducir calls
let mut queue = self.batch_queue.lock().unwrap();
queue.push(event);
// Flush si alcanza batch size
if queue.len() >= self.config.batch_size {
self.flush_batch(&mut queue).await?;
}
Ok(())
}
}
Fase 2: Mejoras con Vector.dev
// Vector.dev maneja la complejidad de persistencia:
pub enum AuditTransportImpl {
Grpc(GrpcTransport), // Base para casos simples
Vector(VectorTransport), // Para máxima durabilidad
Buffered(BufferedTransport), // Para spikes
}
// Vector maneja buffering y reintentos automáticamente
pub struct VectorTransport {
client: VectorApiClient,
disk_buffer: Arc<VectorBuffer>,
retry_policy: RetryPolicy,
}
3. Soluciones Híbridas para Casos Específicos
🎯 Desafíos y Soluciones
Aunque gRPC directo es óptimo para la mayoría de casos, hay 4 escenarios específicos que requieren extensiones. Vector.dev soluciona estos de forma nativa sin añadir complejidad operacional.
Desafío 1: Múltiples Consumidores (Fan-out)
Problema: 3+ aplicaciones necesitan consumir los mismos eventos Solución: Delegación completa a Vector.dev
Por qué eliminar el EventBus interno:
- ❌ Complejidad: Gestión de suscripciones, estado, callbacks
- ❌ Mantenimiento: Debugging distribuido, manejo de errores
- ❌ Acoplamiento: CAP ahora maneja enrutamiento de eventos
Solución con Vector.dev:
- ✅ Simplicidad: CAP envía a UN solo endpoint (Vector)
- ✅ Configuración: Fan-out se define en
vector.toml - ✅ Mantenimiento: Sin estado en CAP, Vector es el experto
// CAP Simplificado - Sin EventBus interno
pub struct HodeiAuditService {
vector_client: Arc<VectorClient>, // Un solo cliente
// Sin EventBus, sin subscribers, sin callbacks
}
impl HodeiAuditService {
pub async fn handle_audit_events(&self, events: Vec<AuditEvent>) -> Result<()> {
// 1. Validar, autenticar, enriquecer
let enriched_events = self.enrich_events(events).await?;
// 2. Enviar TODO a Vector (Vector se encarga del fan-out)
self.vector_client.send_batch(enriched_events).await?;
// 3. ¡Listo! Sin gestión de suscripciones
Ok(())
}
}
Configuración de Fan-out en Vector:
# Vector maneja TODOS los destinos automáticamente
[sinks.clickhouse_hot]
# Eventos en tiempo real para queries
[sinks.s3_analytics]
# Eventos para data lake y ML
[sinks.alerting_system]
# Eventos críticos para alertas
[sinks.compliance_archive]
# Eventos para auditoría regulatoria
Ventaja Estratégica: Si mañana necesitas añadir un nuevo consumidor (ej: sistema de fraud detection), NO modificas el CAP. Solo añades un nuevo sink en la configuración de Vector.
Desafío 2: Picos de Tráfico (>10K events/sec)
Problema: Spikes impredecibles causan pérdida de eventos Solución: Buffer local + persistent queue con backpressure
// Buffer con backpressure (manejado por Vector.dev)
pub struct PersistentBuffer {
local_queue: Arc<Mutex<Vec<AuditEvent>>>,
vector_buffer: Arc<VectorBuffer>, // Vector.dev con disk buffer persistente
flush_policy: FlushPolicy,
backpressure_threshold: usize,
}
impl PersistentBuffer {
pub async fn add_event(&self, event: AuditEvent) -> Result<(), BufferError> {
let mut local_queue = self.local_queue.lock().unwrap();
// Check backpressure
if local_queue.len() >= self.backpressure_threshold {
return Err(BufferError::Backpressure);
}
local_queue.push(event);
// Trigger flush based on policy
if self.flush_policy.should_flush(&local_queue) {
self.flush().await;
}
Ok(())
}
async fn flush(&self) {
let mut local_queue = self.local_queue.lock().unwrap();
if local_queue.is_empty() {
return;
}
// Batch to Vector.dev buffer (async, non-blocking)
let events = std::mem::take(&mut *local_queue);
let vector_buffer = self.vector_buffer.clone();
tokio::spawn(async move {
if let Err(e) = vector_buffer.push_batch(events).await {
error!("Failed to persist audit events: {}", e);
}
});
}
}
// Flush policies
pub enum FlushPolicy {
Size(usize), // Flush when N events
Time(Duration), // Flush every N seconds
Hybrid(usize, Duration), // Flush when either condition met
}
impl FlushPolicy {
fn should_flush(&self, queue: &Vec<AuditEvent>) -> bool {
match self {
Self::Size(n) => queue.len() >= *n,
Self::Time(_) => {
// Check if enough time has passed (requires tracking last_flush)
false // Simplified
}
Self::Size(n) | Self::Hybrid(n, _) => queue.len() >= *n,
Self::Time(_) => {
// Time-based logic
false
}
Self::Hybrid(_, time) => {
// Check both size and time
queue.len() >= n || time_elapsed >= *time
}
}
}
}
Desafío 3: Requisitos Críticos (Zero Loss)
Problema: No se puede perder NINGÚN evento Solución: Multi-tier persistence con atomic commits
// Zero-loss event store (Vector.dev managed)
pub struct ZeroLossEventStore {
memory_queue: Arc<Mutex<Vec<AuditEvent>>>, // Tier 1: Memory
vector_buffer: Arc<VectorBuffer>, // Tier 2: Vector.dev (con disk buffer)
cloud_queue: Arc<dyn CloudStorage>, // Tier 3: S3/Cloud
atomic_commit: Arc<AtomicBool>,
}
impl ZeroLossEventStore {
pub async fn store_event(&self, event: AuditEvent) -> Result<(), ZeroLossError> {
// Tier 1: Immediate memory queue (fastest)
{
let mut queue = self.memory_queue.lock().unwrap();
queue.push(event.clone());
}
// Tier 2: Persist to Vector.dev buffer (async, with disk persistence)
if let Err(e) = self.vector_buffer.push_event(event.clone()).await {
// Even if Vector buffer fails, we have memory queue
error!("Vector buffer persist failed: {}", e);
}
// Tier 3: Cloud backup (async, fire-and-forget)
let cloud_queue = self.cloud_queue.clone();
tokio::spawn(async move {
if let Err(e) = cloud_queue.push_event(event).await {
error!("Cloud backup failed: {}", e);
}
});
// Atomic "success" - event is safely stored
self.atomic_commit.store(true, Ordering::SeqCst);
Ok(())
}
pub async fn recover_from_failure(&self) -> Vec<AuditEvent> {
// Recover from tier 1 (memory) first
let mut recovered = Vec::new();
{
let mut queue = self.memory_queue.lock().unwrap();
recovered.extend(queue.drain(..));
}
// If memory was lost, recover from Vector buffer
if recovered.is_empty() {
if let Ok(vector_events) = self.vector_buffer.get_all_events().await {
recovered.extend(vector_events);
}
}
// If Vector buffer was lost, recover from cloud
if recovered.is_empty() {
if let Ok(cloud_events) = self.cloud_queue.get_all_events().await {
recovered.extend(cloud_events);
}
}
recovered
}
}
// Retry with exponential backoff
pub struct RetryPolicy {
max_retries: u32,
initial_delay: Duration,
max_delay: Duration,
multiplier: f32,
}
impl RetryPolicy {
pub async fn execute_with_retry<F, T, E>(&self, mut f: F) -> Result<T, RetryError<E>>
where
F: FnMut() -> BoxFuture<'_, Result<T, E>>,
E: std::fmt::Display,
{
let mut delay = self.initial_delay;
let mut attempt = 0;
loop {
match f().await {
Ok(result) => return Ok(result),
Err(error) => {
attempt += 1;
if attempt >= self.max_retries {
return Err(RetryError::MaxRetriesExceeded(error));
}
error!("Attempt {} failed: {}", attempt, error);
tokio::time::sleep(delay).await;
// Exponential backoff
delay = Duration::from_secs_f32(
delay.as_secs_f32() * self.multiplier
);
delay = std::cmp::min(delay, self.max_delay);
}
}
}
}
}
Desafío 4: Replay de Eventos
Problema: Necesidad de replay para análisis post-hoc Solución: Time-based replay endpoints en gRPC
// Replay service
#[async_trait]
impl AuditReplayService for AuditService {
async fn replay_events(
&self,
request: tonic::Request<ReplayRequest>,
) -> Result<tonic::Response<Self::ReplayEventsStream>, tonic::Status> {
let ReplayRequest {
tenant_id,
start_time,
end_time,
hrn_filter,
replay_speed,
} = request.into_inner();
// Query storage for events in time range
let query = AuditQuery {
tenant_id: Some(tenant_id),
start_time: Some(parse_time(&start_time)?),
end_time: Some(parse_time(&end_time)?),
hrn: hrn_filter,
..Default::default()
};
let events = self.storage.query(query)
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?
.events;
// Create replay stream with speed control
let (tx, rx) = mpsc::unbounded_channel::<AuditEvent>();
let delay = match replay_speed.as_str() {
"realtime" => Duration::from_millis(0),
"2x" => Duration::from_millis(500),
"10x" => Duration::from_millis(100),
_ => Duration::from_millis(1000),
};
// Spawn replay worker
tokio::spawn(async move {
for (i, event) in events.into_iter().enumerate() {
// Throttle based on speed
if delay.as_millis() > 0 && i > 0 {
tokio::time::sleep(delay).await;
}
if let Err(e) = tx.send(event) {
error!("Replay stream error: {}", e);
break;
}
}
});
Ok(tonic::Response::new(
tokio_stream::wrappers::UnboundedReceiverStream::new(rx)
))
}
async fn create_replay_session(
&self,
request: tonic::Request<CreateReplaySessionRequest>,
) -> Result<tonic::Response<ReplaySession>, tonic::Status> {
let request = request.into_inner();
// Create replay session
let session_id = Uuid::new_v4().to_string();
let session = ReplaySession {
session_id,
created_at: Utc::now(),
status: "active".to_string(),
query: request.query,
};
// Store session
self.replay_sessions
.insert(session_id.clone(), session.clone())
.await;
Ok(tonic::Response::new(session))
}
}
// Usage: Replay last 30 days for compliance audit
let mut replay_stream = client.replay_events(ReplayRequest {
tenant_id: "tenant-123".to_string(),
start_time: "2024-01-01T00:00:00Z".to_string(),
end_time: "2024-01-31T23:59:59Z".to_string(),
hrn_filter: Some("hrn:hodei:verified-permissions:tenant-123:global:policy-store/*".to_string()),
replay_speed: "2x".to_string(),
}).await?;
while let Some(event) = replay_stream.next().await {
let event = event?;
println!("Replaying: {} - {}", event.action, event.hrn);
}
📊 Comparativa: gRPC vs gRPC+Vector
| Criterio | gRPC Directo | gRPC + Vector | Ganador |
|---|---|---|---|
| Complejidad | Baja | Media | ✅ gRPC |
| Latencia | 5-10ms | 5-10ms | 🤝 Empate |
| Fan-out | Manual | Múltiples sinks | ✅ Vector |
| Buffering | No | Disk buffer | ✅ Vector |
| Zero Loss | No | Sí (WAL) | ✅ Vector |
| Operación | Simple | Simplificada | ✅ gRPC |
| Mantenimiento | Bajo | Bajo | 🤝 Empate |
| Costo | Mínimo | Mínimo | 🤝 Empate |
| Expertise | gRPC | gRPC + Vector | ✅ gRPC |
🎯 Decisión de Implementación
Progresión recomendada:
- Semana 1-2: gRPC básico (para mayoría de casos)
- Semana 3-4: Añadir Buffer (para spikes)
- Semana 5-6: Añadir Event Bus (para fan-out)
- Semana 7-8: Añadir Replay (para compliance)
Configuración por aplicación:
// Caso simple: Solo gRPC
let transport = GrpcTransport::new()?;
// Caso con spikes: gRPC + Buffer
let transport = GrpcWithBuffer::new(
GrpcTransport::new()?,
PersistentBuffer::new(FlushPolicy::Hybrid(100, Duration::from_secs(1))),
)?;
// Caso fan-out: gRPC + EventBus
let transport = GrpcWithBus::new(
GrpcTransport::new()?,
EventBus::new(),
)?;
// Caso crítico: Todo incluido
let transport = GrpcComplete::new(
GrpcTransport::new()?,
PersistentBuffer::new(FlushPolicy::Time(Duration::from_millis(100))),
EventBus::new(),
ZeroLossEventStore::new()?,
)?;
4. Sistema HRN (Hodei Resource Names)
🎯 Concepto HRN
Inspirado en AWS ARNs, HRN (Hodei Resource Names) proporciona un identificador canónico y único para todos los recursos en el ecosistema Hodei.
📐 Formato HRN
hrn:<partition>:<service>:<tenant>:<region>:<resource-type>/<resource-path>
Componentes:
- partition:
hodei(Hodei ecosystem) - service: Servicio (verified-permissions, user-service, etc.)
- tenant: ID del tenant (ej:
tenant-123) - region: Región geográfica (opcional,
globalpor defecto) - resource-type: Tipo de recurso (policy, user, role, etc.)
- resource-path: Identificador específico del recurso
🔍 Ejemplos HRN
| Recurso | HRN | Descripción |
|---|---|---|
| Policy Store | hrn:hodei:verified-permissions:tenant-123:global:policy-store/default | Policy store por defecto del tenant |
| Authorization | hrn:hodei:verified-permissions:tenant-123:global:authorization/user-123 | Permisos del usuario 123 |
| API Call | hrn:hodei:user-service:tenant-123:eu-west-1:api/user-profile | Endpoint user-profile |
| Document | hrn:hodei:docs:tenant-123:global:document/doc-456 | Documento doc-456 |
| Database Table | hrn:hodei:db:tenant-123:global:table/users | Tabla users |
| S3 Bucket | hrn:hodei:storage:tenant-123:global:bucket/uploads | Bucket uploads |
| Lambda Function | hrn:hodei:functions:tenant-123:us-east-1:function/email-sender | Función email-sender |
| IAM Role | hrn:hodei:iam:tenant-123:global:role/admin | Rol admin |
🏗️ HRN Resolución
// hodei-audit-service/src/hrn.rs
/// Hodei Resource Name
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Hrn {
pub partition: String, // "hodei"
pub service: ServiceId, // "verified-permissions"
pub tenant_id: TenantId, // "tenant-123"
pub region: Option<String>, // "eu-west-1" o None
pub resource_type: String, // "policy-store"
pub resource_path: String, // "default"
}
impl Hrn {
/// Parse HRN from string
pub fn parse<S: Into<String>>(s: S) -> Result<Self, HrnError> {
let parts: Vec<&str> = s.into().split(':').collect();
if parts.len() < 6 {
return Err(HrnError::InvalidFormat);
}
Ok(Self {
partition: parts[1].to_string(),
service: parts[2].parse()?,
tenant_id: parts[3].parse()?,
region: match parts[4] {
"global" => None,
r => Some(r.to_string()),
},
resource_type: parts[5].to_string(),
resource_path: parts[6..].join("/"),
})
}
/// Convert to string
pub fn to_string(&self) -> String {
format!(
"hrn:{}:{}:{}:{}:{}",
self.partition,
self.service,
self.tenant_id,
self.region.as_deref().unwrap_or("global"),
self.resource_type
)
}
/// Get parent resource
pub fn parent(&self) -> Option<Self> {
let mut parts: Vec<&str> = self.resource_path.split('/').collect();
parts.pop()?;
if parts.is_empty() {
return None;
}
Some(Self {
partition: self.partition.clone(),
service: self.service.clone(),
tenant_id: self.tenant_id.clone(),
region: self.region.clone(),
resource_type: self.resource_type.clone(),
resource_path: parts.join("/"),
})
}
/// Check if this HRN is a child of another
pub fn is_child_of(&self, parent: &Self) -> bool {
self.tenant_id == parent.tenant_id
&& self.service == parent.service
&& self.resource_path.starts_with(&parent.resource_path)
}
}
📊 HRN Metadata
// Cache de metadata HRN para enriquecimiento
pub struct HrnMetadata {
pub hrn: Hrn,
pub display_name: String,
pub description: Option<String>,
pub tags: HashMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub owner: UserId,
}
pub struct HrnResolver {
cache: Arc<LruCache<String, HrnMetadata>>, // LRU cache con TTL
metadata_client: Arc<MetadataServiceClient>,
}
impl HrnResolver {
/// Resolve HRN to metadata
pub async fn resolve(&self, hrn: &Hrn) -> Result<HrnMetadata, HrnError> {
// 1. Check cache
if let Some(metadata) = self.cache.get_hrn_metadata(hrn).await? {
return Ok(metadata);
}
// 2. Fetch from metadata service
let metadata = self.metadata_client
.get_resource_metadata(hrn.clone())
.await?;
// 3. Cache result
self.cache.store_hrn_metadata(hrn, &metadata).await?;
Ok(metadata)
}
/// Search resources by pattern
pub async fn search(&self, pattern: &str) -> Result<Vec<HrnMetadata>, HrnError> {
// Search in cache
self.cache.search_hrn_metadata(pattern).await
}
}
🔄 HRN en Eventos
// Auto-generate HRN based on request path
impl AuditEvent {
pub fn from_request(
request: &Request<Body>,
user_id: UserId,
tenant_id: TenantId,
) -> Self {
let path = request.uri().path();
let method = request.method().clone();
// Generate HRN from path
let hrn = generate_hrn_from_path(&method, &path, &tenant_id);
Self {
event_id: Uuid::new_v4().to_string(),
hrn,
user_id,
tenant_id,
action: format!("{} {}", method, path),
method: method.as_str().to_string(),
path: path.to_string(),
timestamp: Utc::now(),
..Default::default()
}
}
}
fn generate_hrn_from_path(
method: &Method,
path: &str,
tenant_id: &TenantId,
) -> Hrn {
// Map paths to HRN patterns
match path {
// verified-permissions paths
p if p.starts_with("/v1/policy-stores") => {
Hrn::parse(&format!(
"hrn:hodei:verified-permissions:{}:global:policy-store/{}",
tenant_id,
extract_policy_store_id(p).unwrap_or("default")
)).unwrap()
}
// API service paths
p if p.starts_with("/api/") => {
Hrn::parse(&format!(
"hrn:hodei:api:{}:global:api/{}",
tenant_id,
p.trim_start_matches("/api/")
)).unwrap()
}
// Default
_ => Hrn::parse(&format!(
"hrn:hodei:unknown:{}:global:resource/unknown",
tenant_id
)).unwrap()
}
}
🎨 HRN Dashboard
# Query all events for a specific HRN
curl -X POST "http://audit-service:50053/v1/query" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"hrn": "hrn:hodei:verified-permissions:tenant-123:global:policy-store/default"
}'
# Query all events for a resource type
curl -X POST "http://audit-service:50053/v1/query" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"resource_type": "policy-store"
}'
# Get HRN hierarchy
curl -X GET "http://audit-service:50053/v1/hrn/hrn:hodei:verified-permissions:tenant-123:global:policy-store/default/hierarchy"
# Returns:
{
"hrn": "hrn:hodei:verified-permissions:tenant-123:global:policy-store/default",
"parent": "hrn:hodei:verified-permissions:tenant-123:global:policy-store",
"children": [
"hrn:hodei:verified-permissions:tenant-123:global:policy-store/default/policies",
"hrn:hodei:verified-permissions:tenant-123:global:policy-store/default/roles"
]
}
4. Componentes del Ecosistema
📦 Core Components
| Componente | Puerto | Tecnología | Responsabilidad |
|---|---|---|---|
| Audit SDK | N/A | Rust (Tower/Axum) | Middleware para captura |
| Audit Service (gRPC) | 50052-50053 | Rust + Tonic | Procesamiento y storage |
| ClickHouse | 8123 | OLAP Database | Storage primario de eventos |
| S3/GCS | N/A | Object Storage | Archive e inmutabilidad |
| Prometheus | 9090 | Metrics | Métricas del sistema |
| Grafana | 3000 | Dashboards | Visualización |
🏗️ Arquitectura Detallada (gRPC)
┌─────────────────────────────────────────────────────────────────┐
│ gRPC DIRECT CONNECTION │
│ │
│ App 1 App 2 App N │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ SDK │ │ SDK │ │ SDK │ │
│ └───┬───┘ └───┬───┘ └───┬───┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ gRPC │ Direct RPC Calls │
│ │ Direct │ ┌─────────┐ │
│ └─────┬─────┘ │Batch 1K │ │
│ │ │Batch 2K │ │
│ ┌─────▼─────┐ │Batch NK │ │
│ │ Audit │ └─────────┘ │
│ │ Service │ │
│ │ (gRPC) │ - Simple setup │
│ └─────┬─────┘ - Direct communication │
│ │ - Type-safe contracts │
│ ┌─────▼─────┐ - Easy debugging │
│ │ ClickHouse│ │
│ │ (Storage)│ │
│ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
🔄 Comparativa: gRPC vs gRPC+Vector
| Aspecto | gRPC Directo | gRPC + Vector | Ganador |
|---|---|---|---|
| Componentes | 3 | 4 | ✅ gRPC |
| Latencia | 5-10ms | 5-10ms | 🤝 Empate |
| Complejidad | Baja | Media | ✅ gRPC |
| Fan-out | Manual | Múltiples sinks | ✅ Vector |
| Buffering | No | Disk buffer | ✅ Vector |
| Zero Loss | No | Sí (WAL) | ✅ Vector |
| Mantenimiento | Fácil | Simplificado | ✅ gRPC |
| Costo | Mínimo | Mínimo | 🤝 Empate |
Conclusión: Para producción, gRPC + Vector es la combinación óptima (simplicidad + resiliencia)
5. SDK para Middleware (Axum)
🎯 Diseño del SDK
// hodei-audit-sdk/src/lib.rs
/// Configuración del SDK
#[derive(Debug, Clone)]
pub struct AuditSdkConfig {
pub service_name: String,
pub tenant_id: Option<TenantId>,
pub audit_service_url: String, // gRPC endpoint
pub batch_size: usize,
pub batch_timeout: Duration,
pub enable_request_body: bool,
pub enable_response_body: bool,
pub hrn_resolver: Option<Arc<dyn HrnResolver>>,
}
/// Middleware Layer para Axum
pub struct AuditLayer {
config: AuditSdkConfig,
client: AuditControlServiceClient<tonic::transport::Channel>,
batch_queue: Arc<Mutex<Vec<AuditEvent>>>,
batch_flush_timer: Arc<Mutex<tokio::time::Interval>>,
}
/// Integración 1-liner
impl AuditSdkConfig {
pub fn layer<S>(&self) -> impl Layer<S> + Clone
where
S: Service<Request<Body>, Response = Response, Error = Infallible> + Send + Clone,
{
AuditLayer::new(self.clone())
}
}
🚀 Integración Simplificada
// En cualquier aplicación Axum
use hodei_audit_sdk::{AuditLayer, AuditConfig};
#[tokio::main]
async fn main() -> Result<()> {
// 1. Configuración simple
let audit_config = AuditConfig::builder()
.service_name("my-service")
.tenant_id("tenant-123")
.audit_service_url("http://audit-service:50052")
.batch_size(100)
.batch_timeout(Duration::from_millis(100))
.enable_request_body(true)
.hrn_resolver(resolver) // Optional
.build()?;
// 2. Integración 1-liner
let app = Router::new()
.route("/api/*path", get(handler))
.layer(audit_config.layer()) // <- SOLO ESTA LÍNEA!
.layer(AuthLayer)
.layer(RateLimitLayer);
// 3. ¡Listo! Todas las requests se auditan automáticamente
axum::Server::bind(&"0.0.0.0:3000".parse()?)
.serve(app.into_make_service())
.await?;
Ok(())
}
🔧 Middleware Implementation
// hodei-audit-sdk/src/middleware.rs
impl AuditLayer {
pub fn new(config: AuditSdkConfig) -> Self {
// Initialize gRPC client
let client = AuditControlServiceClient::connect(config.audit_service_url.clone())
.expect("Failed to connect to audit service");
// Batch queue
let batch_queue = Arc::new(Mutex::new(Vec::with_capacity(config.batch_size)));
// Background flush timer
let batch_flush_timer = Arc::new(Mutex::new(tokio::time::interval(config.batch_timeout)));
// Spawn background worker
let batch_queue_clone = batch_queue.clone();
let client_clone = client.clone();
tokio::spawn(async move {
let mut timer = batch_flush_timer.lock().await;
loop {
timer.tick().await;
let mut queue = batch_queue_clone.lock().unwrap();
if !queue.is_empty() {
let events = std::mem::take(&mut *queue);
// Flush batch to audit service
if let Err(e) = client_clone
.clone()
.publish_batch(PublishBatchRequest {
tenant_id: config.tenant_id.clone().unwrap_or_default(),
events,
options: None,
})
.await
{
error!("Failed to flush audit batch: {}", e);
// Re-queue events for retry
queue.extend(events);
}
}
}
});
Self {
config,
client,
batch_queue,
batch_flush_timer,
}
}
}
impl<S> Layer<S> for AuditLayer {
type Service = AuditService<S>;
fn layer(&self, service: S) -> Self::Service {
AuditService {
config: self.config.clone(),
client: self.client.clone(),
batch_queue: self.batch_queue.clone(),
service,
}
}
}
🔍 Auto-enriquecimiento con HRN
// Extract HRN from request
async fn extract_hrn(
request: &Request<Body>,
hrn_resolver: Option<&Arc<dyn HrnResolver>>,
) -> Result<Hrn, AuditError> {
let path = request.uri().path();
let method = request.method();
// Generate HRN from path
let hrn = match path {
// verified-permissions endpoints
p if p.starts_with("/v1/policy-stores") => {
Hrn::parse(&format!(
"hrn:hodei:verified-permissions:{}:global:policy-store/{}",
extract_tenant_id(request)?,
extract_policy_store_id(p).unwrap_or("default")
))?
}
// API endpoints
p if p.starts_with("/api/") => {
Hrn::parse(&format!(
"hrn:hodei:api:{}:global:api/{}",
extract_tenant_id(request)?,
p.trim_start_matches("/api/")
))?
}
// Generic resource
_ => Hrn::parse(&format!(
"hrn:hodei:unknown:{}:global:resource/{}",
extract_tenant_id(request)?,
path
))?
};
// Enrich with metadata if resolver available
if let Some(resolver) = hrn_resolver {
if let Ok(metadata) = resolver.resolve(&hrn).await {
debug!("Resolved HRN: {} -> {}", hrn, metadata.display_name);
}
}
Ok(hrn)
}
📊 Batch Processing
// Smart batching
impl AuditLayer {
async fn add_to_batch(&self, event: AuditEvent) {
let mut queue = self.batch_queue.lock().unwrap();
queue.push(event);
// Auto-flush on batch size
if queue.len() >= self.config.batch_size {
self.flush_batch(&mut queue).await;
}
}
async fn flush_batch(&self, queue: &mut Vec<AuditEvent>) {
if queue.is_empty() {
return;
}
let events = std::mem::take(queue);
let client = self.client.clone();
let tenant_id = self.config.tenant_id.clone();
// Async flush (non-blocking)
tokio::spawn(async move {
if let Err(e) = client
.publish_batch(PublishBatchRequest {
tenant_id: tenant_id.unwrap_or_default(),
events,
options: Some(BatchOptions {
flush_immediately: true,
..Default::default()
}),
})
.await
{
error!("Failed to publish audit batch: {}", e);
}
});
}
}
📈 API del SDK
// hodei-audit-sdk/src/api.rs
/// API para logging manual
pub struct AuditClient {
client: AuditControlServiceClient<tonic::transport::Channel>,
config: AuditSdkConfig,
}
impl AuditClient {
/// Log event manual
pub async fn log(&self, event: AuditEvent) -> Result<()> {
let request = PublishEventRequest {
tenant_id: event.tenant_id.to_string(),
event: Some(event),
options: None,
};
self.client
.clone()
.publish_event(tonic::Request::new(request))
.await?;
Ok(())
}
/// Log batch events
pub async fn log_batch(&self, events: Vec<AuditEvent>) -> Result<()> {
let request = PublishBatchRequest {
tenant_id: events[0].tenant_id.to_string(),
events,
options: Some(BatchOptions {
flush_immediately: true,
..Default::default()
}),
};
self.client
.clone()
.publish_batch(tonic::Request::new(request))
.await?;
Ok(())
}
/// Query events
pub async fn query(&self, query: AuditQuery) -> Result<AuditQueryResult> {
let response = self.client
.clone()
.query_events(tonic::Request::new(query))
.await?;
Ok(response.into_inner())
}
/// Resolve HRN metadata
pub async fn resolve_hrn(&self, hrn: Hrn) -> Result<HrnMetadata> {
let response = self.client
.clone()
.resolve_hrn(tonic::Request::new(hrn))
.await?;
Ok(response.into_inner())
}
}
/// Extensiones para Axum
pub trait AuditExtensions {
fn get_trace_context(&self) -> Option<TraceContext>;
fn get_user_id(&self) -> Option<UserId>;
fn get_tenant_id(&self) -> Option<TenantId>;
fn get_hrn(&self) -> Option<Hrn>;
}
impl AuditExtensions for Extensions {
fn get_trace_context(&self) -> Option<TraceContext> {
self.get::<TraceContext>().cloned()
}
fn get_hrn(&self) -> Option<Hrn> {
self.get::<Hrn>().cloned()
}
}
6. Servicio Central de Auditoría
🏗️ Arquitectura del Servicio
// hodei-audit-service/src/lib.rs
/// Servicio principal
pub struct AuditService {
config: AuditConfig,
storage: Arc<dyn StorageBackend>,
hrn_resolver: Arc<HrnResolver>,
enricher: Arc<EventEnricher>,
query_engine: Arc<QueryEngine>,
metrics: Arc<AuditMetrics>,
}
/// Inicialización
impl AuditService {
pub async fn new(config: AuditConfig) -> Result<Self> {
// 1. Initialize storage
let storage = match config.storage_backend {
StorageBackend::ClickHouse => {
ClickHouseStorage::new(config.clickhouse_url).await?
}
StorageBackend::Postgres => {
PostgresStorage::new(config.postgres_url).await?
}
};
// 2. Initialize HRN resolver
let hrn_resolver = Arc::new(HrnResolver::new(
config.hrn_metadata_db.clone(),
));
// 3. Initialize enricher
let enricher = Arc::new(EventEnricher::new(
hrn_resolver.clone(),
config.geoip_db.clone(),
));
Ok(Self {
config,
storage: Arc::new(storage),
hrn_resolver,
enricher,
query_engine: Arc::new(QueryEngine::new()),
metrics: Arc::new(AuditMetrics::new()),
})
}
/// Run the service
pub async fn run(self) -> Result<()> {
// Start gRPC server
let addr = format!("0.0.0.0:{}", self.config.port);
tonic::transport::Server::builder()
.add_service(AuditControlServiceServer::new(self.clone()))
.add_service(AuditQueryServiceServer::new(self.clone()))
.serve(addr.parse()?)
.await?;
Ok(())
}
}
🔧 gRPC Service Implementation
// hodei-audit-service/src/grpc/server.rs
#[async_trait]
impl AuditControlService for AuditService {
async fn publish_event(
&self,
request: tonic::Request<PublishEventRequest>,
) -> Result<tonic::Response<()>, tonic::Status> {
let request = request.into_inner();
let event = request.event.ok_or_else(|| {
tonic::Status::invalid_argument("Missing event")
})?;
// Enrich event
let enriched_event = self.enricher.enrich(event).await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
// Store event
self.storage.insert_event(enriched_event)
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
// Update metrics
self.metrics.events_received.inc();
Ok(tonic::Response::new(()))
}
async fn publish_batch(
&self,
request: tonic::Request<PublishBatchRequest>,
) -> Result<tonic::Response<PublishBatchResponse>, tonic::Status> {
let request = request.into_inner();
let events = request.events;
// Enrich all events
let enriched_events: Result<Vec<AuditEvent>, _> = events
.into_iter()
.map(|e| self.enricher.enrich(e))
.collect();
let enriched_events = enriched_events
.map_err(|e| tonic::Status::internal(e.to_string()))?;
// Batch insert
self.storage.insert_batch(request.tenant_id, enriched_events)
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
// Update metrics
self.metrics.events_batch_received.inc();
Ok(tonic::Response::new(PublishBatchResponse {
received_count: events.len() as i32,
batch_id: Uuid::new_v4().to_string(),
}))
}
}
#[async_trait]
impl AuditQueryService for AuditService {
async fn query_events(
&self,
request: tonic::Request<AuditQuery>,
) -> Result<tonic::Response<AuditQueryResult>, tonic::Status> {
let query = request.into_inner();
// Execute query
let result = self.query_engine.query(query)
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
Ok(tonic::Response::new(result))
}
async fn resolve_hrn(
&self,
request: tonic::Request<Hrn>,
) -> Result<tonic::Response<HrnMetadata>, tonic::Status> {
let hrn = request.into_inner();
// Resolve HRN
let metadata = self.hrn_resolver.resolve(&hrn)
.await
.map_err(|e| tonic::Status::not_found(e.to_string()))?;
Ok(tonic::Response::new(metadata))
}
}
🔄 Event Enrichment Pipeline
// hodei-audit-service/src/pipeline.rs
pub struct EventEnricher {
hrn_resolver: Arc<HrnResolver>,
geoip_db: Option<Arc<MaxMindDB>>,
user_context_client: Option<Arc<UserServiceClient>>,
}
impl EventEnricher {
pub async fn enrich(&self, mut event: AuditEvent) -> Result<AuditEvent> {
// 1. Resolve HRN metadata
if let Ok(metadata) = self.hrn_resolver.resolve(&event.hrn).await {
event.metadata["hrn_display_name"] = serde_json::to_value(&metadata.display_name)?;
event.metadata["hrn_tags"] = serde_json::to_value(&metadata.tags)?;
}
// 2. Add geo-location
if let (Some(ip), Some(geo_db)) = (
event.metadata.get("client_ip"),
&self.geoip_db
) {
if let Ok(location) = geo_db.lookup(ip.as_str()?) {
event.metadata["geo_location"] = serde_json::to_value(location)?;
}
}
// 3. User context
if let (Ok(user_id), Some(user_client)) = (
parse_user_id(&event.user_id),
&self.user_context_client
) {
if let Ok(user_context) = user_client
.get_user_context(user_id)
.await
{
event.metadata["user_context"] = serde_json::to_value(user_context)?;
}
}
// 4. Calculated fields
event.processed_at = Some(Utc::now());
event.enriched = true;
Ok(event)
}
}
💾 Storage Híbrido: 3-Tier Architecture
Inspirado en AWS CloudTrail y optimizado para máximo throughput + simplicidad operacional, implementamos 3 capas de almacenamiento:
// hodei-audit-service/src/storage/mod.rs
/// Arquitectura de 3 capas (Hot → Warm → Cold)
pub enum StorageTier {
/// Hot: Últimos 1-7 días (alta frecuencia)
/// Storage: ClickHouse (OLAP rápido)
/// Ingesta: Vector.dev (ultra-rápida, multi-sink)
/// Uso: Real-time queries, dashboards
Hot(ClickHouseStorage),
/// Warm: 7 días - 1 año (frecuencia media)
/// Storage: S3/MinIO con índice Parquet
/// Ingesta: Directo desde Vector.dev
/// Uso: Analytics, reporting
Warm(S3Storage),
/// Cold: 1-7 años (baja frecuencia, compliance)
/// Storage: S3 Glacier/Deep Archive
/// Ingesta: Lifecycle automático
/// Uso: Compliance, legal retention
Cold(GlacierStorage),
}
#[async_trait]
pub trait StorageBackend: Send + Sync {
async fn insert_event(&self, event: AuditEvent) -> Result<()>;
async fn insert_batch(&self, tenant_id: TenantId, events: Vec<AuditEvent>) -> Result<()>;
async fn query(&self, query: AuditQuery) -> Result<AuditQueryResult>;
async fn move_to_next_tier(&self, events: Vec<AuditEvent>) -> Result<()>;
async fn compact(&self) -> Result<()>;
}
/// Storage Orchestrator - Enruta según edad del evento
pub struct TieredStorage {
hot: Arc<ClickHouseStorage>, // Tier 0: Hot (0-7 días)
warm: Arc<S3Storage>, // Tier 1: Warm (7 días - 1 año)
cold: Arc<GlacierStorage>, // Tier 2: Cold (1-7 años)
lifecycle_policy: LifecyclePolicy,
partition_strategy: PartitionStrategy,
}
impl TieredStorage {
pub async fn insert_event(&self, event: AuditEvent) -> Result<()> {
// Vector.dev maneja la ingesta inicial a ClickHouse
// Aquí solo manejamos lifecycle y movimiento entre tiers
// Determinar tier basado en edad
let tier = self.determine_tier(&event.timestamp);
// Para eventos nuevos (< 1 hora), ya están en ClickHouse via Vector
// Solo mover a tiers superiores si es necesario
if !matches!(tier, StorageTier::Hot(_)) {
self.propagate_to_tiers(event).await;
}
Ok(())
}
async fn propagate_to_tiers(&self, event: AuditEvent) {
// Determinar tier basado en edad
let tier = self.determine_tier(&event.timestamp);
// Async propagation (no blocking)
match tier {
StorageTier::Hot(_) => {
// Ya en ClickHouse via Vector.dev
}
StorageTier::Warm(_) => {
// Mover a S3/MinIO (lifecycle)
self.warm.insert_event(event).await.ok();
}
StorageTier::Cold(_) => {
// Mover a Glacier (lifecycle)
self.cold.insert_event(event).await.ok();
}
}
}
fn determine_tier(&self, timestamp: &DateTime<Utc>) -> StorageTier {
let age_days = Utc::now().signed_duration_since(*timestamp).num_days();
match age_days {
// Hot: 0-7 días (ClickHouse para queries rápidas)
0..=7 => StorageTier::Hot(ClickHouseStorage::new()),
// Warm: 7 días - 1 año (S3 para analytics)
8..=365 => StorageTier::Warm(S3Storage::new()),
// Cold: >1 año (Glacier para compliance)
_ => StorageTier::Cold(GlacierStorage::new()),
}
}
}
Vector.dev: Capa de Ingesta Unificada
# Vector como DaemonSet (un por nodo)
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: hodei-vector-ingest
labels:
app: hodei-vector
spec:
selector:
matchLabels:
app: hodei-vector
template:
metadata:
labels:
app: hodei-vector
spec:
containers:
- name: vector
image: timberio/vector:latest-alpine
volumeMounts:
- name: config
mountPath: /etc/vector/vector.yaml
readOnly: true
- name: data
mountPath: /var/lib/vector
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 1000m
memory: 2Gi
env:
- name: VECTOR_LOG
value: "info"
volumes:
- name: config
configMap:
name: vector-config
- name: data
hostPath:
path: /var/lib/vector
type: DirectoryOrCreate
---
# ConfigMap con configuración de Vector
apiVersion: v1
kind: ConfigMap
metadata:
name: vector-config
data:
vector.yaml: |
# Vector configuration para Hodei Audit
data_dir: /var/lib/vector
# Sources
sources:
grpc_in:
type: grpc
address: 0.0.0.0:6000
tls:
enabled: true
crt_file: /etc/ssl/certs/server.crt
key_file: /etc/ssl/private/server.key
# File watcher (backup)
file_watch:
type: file_watcher
paths:
- /var/log/hodei-audit/*.log
include:
- "*.json"
# Transforms
transforms:
enrich_with_metadata:
type: add_fields
fields:
ingestion_timestamp: "${timestamp}"
node_name: "${HOSTNAME}"
validate_audit_event:
type: check_fields
fields:
event_id:
required: true
tenant_id:
required: true
hrn:
required: true
regex: '^hrn:.*'
# Sinks (múltiples destinos)
sinks:
# 1. ClickHouse (hot tier) - primario
clickhouse_hot:
type: clickhouse
inputs:
- validate_audit_event
endpoint: "http://clickhouse:8123"
database: "audit"
table: "audit_events"
encoding:
codec: json
buffer:
type: disk
max_size: 1024MB
when_full: drop_newest
healthcheck:
enabled: true
# 2. S3/MinIO (warm tier) - Parquet
s3_warm:
type: aws_s3
inputs:
- validate_audit_event
bucket: "audit-logs-warm"
region: "us-east-1"
key_prefix: "tenant_id={{ tenant_id }}/year={{ %Y }}/month={{ %m }}/day={{ %d }}/"
encoding:
codec: parquet
compression: gzip
parquet:
schema:
event_id: String
tenant_id: String
hrn: String
user_id: String
action: String
timestamp: DateTime
batch:
max_events: 10000
timeout_secs: 300
healthcheck:
enabled: true
# 3. Buffer de emergencia (disk)
emergency_buffer:
type: blackhole
inputs:
- validate_audit_event
print_rate_interval_secs: 1
Vector.dev: Gestión y Operaciones
# Monitorización de Vector.dev
apiVersion: v1
kind: ConfigMap
metadata:
name: vector-metrics
data:
prometheus.yml: |
scrape_configs:
- job_name: 'vector'
static_configs:
- targets: ['vector:9598']
metrics_path: /metrics
scrape_interval: 15s
# Vector metrics endpoint
---
apiVersion: v1
kind: Service
metadata:
name: vector-metrics
spec:
selector:
app: hodei-vector
ports:
- name: metrics
port: 9598
targetPort: 9598
Métricas Clave de Vector.dev:
// Métricas importantes para monitorear
pub struct VectorMetrics {
// Ingestion metrics
pub events_received_total: Counter, // Total eventos recibidos
pub events_sent_total: Counter, // Total eventos enviados
pub buffer_size_bytes: Gauge, // Tamaño del buffer
pub buffer_events: Gauge, // Eventos en buffer
// Latency metrics
pub ingestion_latency_ms: Histogram, // Latencia de ingesta
pub processing_latency_ms: Histogram, // Latencia de procesamiento
// Error metrics
pub delivery_errors_total: Counter, // Errores de entrega
pub backpressure_events: Counter, // Eventos en backpressure
// Throughput metrics
pub events_per_second: Gauge, // Throughput actual
pub bytes_per_second: Gauge, // Throughput en bytes
}
impl VectorMetrics {
pub fn record_event(&self, event: &AuditEvent) {
self.events_received_total.inc();
self.ingestion_latency_ms.observe(
Utc::now().signed_duration_since(event.timestamp)
.num_milliseconds() as f64
);
}
}
Operaciones de Vector.dev:
# Verificar estado de Vector
kubectl exec -it <vector-pod> -- vector status
# Ver configuración
kubectl exec -it <vector-pod> -- vector config
# Ver logs
kubectl logs -f <vector-pod> --tail=100
# Reconfigurar Vector (sin restart)
kubectl create configmap vector-config \
--from-file=vector.yaml=./vector/vector.yaml \
--dry-run=client -o yaml | kubectl apply -f -
# Verificar health de sinks
curl http://<vector-pod>:9598/health
Troubleshooting Común:
| Problema | Síntoma | Solución |
|---|---|---|
| Buffer lleno | buffer_events alto | Aumentar max_size o verificar sinks |
| ClickHouse down | delivery_errors en clickhouse_hot | Vector reintenta automáticamente |
| S3 throttling | backpressure_events increase | Reducir batch.max_events |
| Latencia alta | ingestion_latency_ms > 100ms | Verificar red, aumentar resources |
Tier Comparison
| Tier | Storage | Latency | Throughput | Cost | Use Case |
|---|---|---|---|---|---|
| Hot | ClickHouse | <10ms | 500K events/sec | $100/TB | Real-time queries |
| Warm | S3+Parquet | 100-500ms | N/A (batch) | $23/TB | Analytics |
| Cold | Glacier | Minutes | N/A (async) | $4/TB | Compliance |
Resultado con Vector.dev:
- 500K+ events/second write throughput
- <5ms latencia (ClickHouse + gRPC)
- Simplicidad operacional: Un componente por nodo
- Digest chain unificado: Todos los eventos en ClickHouse
- 65% costo reduction (3-tier vs single-tier)
🚀 Write Path Optimization
// Optimización de write path con Vector.dev
pub struct OptimizedWritePath {
vector_client: Arc<VectorClient>, // Vector.dev ingestion
async_propagator: Arc<AsyncPropagator>, // Background sync
write_ahead_log: Arc<Mutex<WriteAheadLog>>, // Crash recovery
}
impl OptimizedWritePath {
pub async fn write_event(&self, event: AuditEvent) -> Result<()> {
let write_ahead_log = self.write_ahead_log.lock().unwrap();
// 1. Append to WAL (crash-safe)
write_ahead_log.append(&event).await?;
// 2. Send to Vector.dev (5-10ms)
self.vector_client.send_event(event.clone()).await?;
// 3. Return success (Vector maneja distribución a tiers)
// 4. Async: Vector propaga automáticamente a ClickHouse + S3
// No necesitamos propagator separado - Vector lo maneja
Ok(())
}
}
📊 Benchmark: Write Throughput (Vector.dev)
// Benchmarks con Vector.dev como ingesta
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn write_throughput_benchmark(c: &mut Criterion) {
let vector_client = VectorClient::new("http://localhost:50051");
c.bench_function("vector_ingest_write_1M", |b| {
b.iter(|| {
let event = generate_audit_event();
black_box(vector_client.send_event(black_box(event)))
})
});
c.bench_function("clickhouse_direct_write_100K", |b| {
b.iter(|| {
// ClickHouse es más lento en writes individuales
})
});
}
/*
Resultados con Vector.dev:
- Vector.dev: 500,000+ writes/segundo (500K/sec)
- ClickHouse (directo): 10,000 writes/segundo (10K/sec)
- S3: 1,000 writes/segundo (1K/sec)
Vector.dev optimiza la ingesta y reduce latencia operacional
*/
🏗️ Docker Compose - Vector.dev Ingesta
# Añadir a docker-compose.audit.yml
# ────────────────────────────────────────────────
# Vector.dev DaemonSet como Ingesta Unificada
# ────────────────────────────────────────────────
hodei-vector:
image: timberio/vector:latest-alpine
container_name: hodei-vector
ports:
- "50051:50051" # gRPC ingestion endpoint
- "50054:8686" # Vector stats/metrics
volumes:
- ./vector/vector.yaml:/etc/vector/vector.yaml:ro
- vector_data:/var/lib/vector
environment:
- VECTOR_LOG=info
- CLICKHOUSE_URL=clickhouse:8123
- CLICKHOUSE_DB=audit
- MINIO_ENDPOINT=minio:9000
- MINIO_BUCKET=audit-warm
networks:
- audit_net
deploy:
resources:
limits:
cpus: '1'
memory: 2G
reservations:
cpus: '500m'
memory: 1G
# ────────────────────────────────────────────────
# Audit Service (Logic & Query Layer)
# ────────────────────────────────────────────────
audit-service:
build:
context: ./hodei-audit-service
dockerfile: Dockerfile
ports:
- "50052:50052" # Additional gRPC services
- "50053:50053" # Query gRPC
environment:
- RUST_LOG=info
- VECTOR_ENDPOINT=vector:50051
- CLICKHOUSE_URL=clickhouse:8123
- CLICKHOUSE_DB=audit
- MINIO_ENDPOINT=minio:9000
depends_on:
- hodei-vector
- clickhouse
- minio
networks:
- audit_net
deploy:
replicas: 2
resources:
limits:
cpus: '2'
memory: 4G
reservations:
cpus: '1'
memory: 2G
volumes:
vector_data: # Vector.dev disk buffer & cache
driver: local
💡 Key Benefits Vector.dev Ingesta
- Latencia Ultra-Baja: 5-10ms (Vector + ClickHouse)
- Throughput Alto: 500K+ events/sec
- Crash-Safe: Disk buffer persistente + múltiples sinks
- Operacional Simplicidad: Un componente, múltiples destinos
- Backpressure Automático: Vector gestiona overflow
- Multi-Tier Ingesta: ClickHouse + S3 simultáneo
- Kubernetes-Native: DaemonSet sin estado
🔄 Lifecycle con Vector.dev
// Lifecycle policy optimizado para Vector.dev
pub struct LifecyclePolicy {
hot_ttl_days: u32, // 7 días (ClickHouse)
warm_retention_days: u32, // 365 días (S3)
cold_retention_days: u32, // 2555 días (Glacier)
}
impl LifecyclePolicy {
pub async fn apply_lifecycle(&self, storage: &TieredStorage) -> Result<()> {
// 1. Vector.dev ya maneja ingesta hot tier
// Solo necesitamos policy para tiers superiores
// 2. Hot -> Warm (7 días)
let hot_cutoff = Utc::now() - Duration::days(self.hot_ttl_days as i64);
let events_to_warm = storage.hot.get_events_older_than(hot_cutoff).await?;
storage.move_to_warm(events_to_warm).await?;
// 3. Compact hot tier ClickHouse
storage.hot.compact().await?;
// 4. Warm -> Cold (1 año)
let warm_cutoff = Utc::now() - Duration::days(self.warm_retention_days as i64);
let events_to_cold = storage.warm.get_events_older_than(warm_cutoff).await?;
storage.move_to_cold(events_to_cold).await?;
Ok(())
}
}
📈 Performance Summary (Vector.dev)
Event Flow (Actual):
App → Vector.dev (5-10ms) ✓ → ClickHouse (async)
↘ S3/Parquet (warm tier)
↓
Disk Buffer persistente
↓
Multi-sink distribución automática
Resultado con Vector.dev:
- 500K+ events/second write throughput
- 5-10ms latencia
- 100% durability (Vector buffer + ClickHouse)
- Operacional simplicidad (1 componente vs múltiples tiers)
🏗️ Vector.dev: Capa de Ingesta Unificada
Arquitectura Final: Vector.dev como único punto de ingesta con disk buffer persistente
# Vector como DaemonSet o Deployment
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: hodei-vector-ingest
spec:
template:
spec:
containers:
- name: vector
image: timberio/vector:latest
config: |
# Vector config para ingesta
sources:
grpc_in:
type: grpc
port: 50051
tls:
enabled: true
# gRPC source para audit events
headers:
- tenant_id: "{{ tenant_id }}"
transforms:
enrich_tenant:
type: add_fields
fields:
ingestion_timestamp: "{{ now() }}"
pod_name: "{{ env pod_name }}"
# Sinks
sinks:
# 1. ClickHouse (hot tier)
clickhouse_hot:
type: clickhouse
endpoint: "http://clickhouse:8123"
table: "audit_events"
database: "audit"
# 2. S3/MinIO (warm tier)
s3_warm:
type: aws_s3
bucket: "audit-logs"
region: "us-east-1"
key_prefix: "warm/{{ tenant_id }}/"
encoding:
codec: parquet
compression: gzip
# 3. Vector.dev disk buffer (built-in)
# Nota: Vector maneja automáticamente el buffering en disco
# No necesita configuración adicional - es nativo
📊 ARQUITECTURA ACTUALIZADA (Vector.dev)
┌─────────────────────────────────────────────────────────────────┐
│ HODEI AUDIT ECOSYSTEM │
│ │
│ App 1 App 2 App N │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ SDK │ │ SDK │ │ SDK │ │
│ └───┬───┘ └───┬───┘ └───┬───┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ gRPC │ Direct RPC Calls │
│ │ Direct │ (Port 50052-50053) │
│ └─────┬─────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ │ │
│ ┌───────▼────────┐ ┌────────▼──────────┐ │
│ │ Vector.dev │ │ Audit Service │ │
│ │ (DaemonSet) │ │ (Logic & Query) │ │
│ │ - Ingesta │ │ - HRN │ │
│ │ - Buffer │ │ - Digest │ │
│ │ - Multi-sink │ │ - Query Engine │ │
│ └──────┬─────────┘ └────────┬──────────┘ │
│ │ │ │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ ClickHouse │ │ S3/MinIO │ │
│ │ (Hot Tier) │ │ (Warm/Cold) │ │
│ └──────────────┘ └───────────────┘ │
│ │ │ │
│ ┌──────▼────────────────────▼──────────┐ │
│ │ Vector.dev Disk Buffer │ │
│ │ (Garantía de no pérdida) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
// 1. Deserialize event
let event: AuditEvent = serde_json::from_slice(&message.payload)?;
// 2. Process (enrichment, HRN, etc.)
let processed = self.processor.process(event).await?;
// 3. Store in ClickHouse
self.clickhouse.insert_event(processed).await?;
// 4. Commit offset (durable)
self.consumer.commit_offset(&message.offset).await?;
}
Ok(())
}
}
#### **Hot Tier: ClickHouse (0-7 días)**
```rust
/// Almacenamiento rápido para queries recientes
pub struct HotStorage {
client: Arc<ClickHouse>,
partition_hint: String, // Última semana
}
#[async_trait]
impl StorageBackend for HotStorage {
async fn query(&self, query: AuditQuery) -> Result<AuditQueryResult> {
// Solo query en rango caliente
if let Some(start_time) = &query.start_time {
let age_days = Utc::now().signed_duration_since(*start_time).num_days();
if age_days > 7 {
return Err(Error::QueryOutOfRange("Hot tier only supports last 7 days"));
}
}
// ClickHouse query optimizada
let mut sql = SELECT
event_id, tenant_id, hrn, user_id, action,
path, method, status_code, outcome, latency_ms,
metadata_json, timestamp
FROM audit_events_hot
WHERE timestamp >= now() - INTERVAL 7 DAY
.to_string();
self.add_filters(&mut sql, &query)?;
sql.push_str(" ORDER BY timestamp DESC LIMIT 1000");
let result = self.client.query(&sql).fetch_all::<AuditEventRow>().await?;
Ok(self.convert_to_result(result, query.limit))
}
}
Warm Tier: S3/MinIO + Parquet (7 días - 1 año)
/// Almacenamiento de frecuencia media en S3/MinIO
pub struct WarmStorage {
s3_client: Arc<S3Client>,
bucket: String,
partition_strategy: PartitionStrategy,
athena_style_index: Arc<AthenaIndex>,
}
#[async_trait]
impl StorageBackend for WarmStorage {
async fn insert_batch(&self, tenant_id: TenantId, events: Vec<AuditEvent>) -> Result<()> {
// Convertir a Parquet para optimización
let parquet_data = self.events_to_parquet(&events)?;
// Particionado por fecha/tenant
let date = Utc::now().format("%Y-%m-%d").to_string();
let key = format!("warm/tenant_id={}/date={}/{}.parquet", tenant_id, date, Uuid::new_v4());
// Upload a S3/MinIO
self.s3_client
.put_object()
.bucket(&self.bucket)
.key(&key)
.body(parquet_data.into())
.content_type("application parquet")
.send()
.await?;
// Actualizar índice (para queries rápidas)
self.athena_style_index
.add_partition(tenant_id, date, key, events.len() as u64)
.await?;
Ok(())
}
async fn query(&self, query: AuditQuery) -> Result<AuditQueryResult> {
// Verificar rango (solo datos warm)
if let Some(start_time) = &query.start_time {
let age_days = Utc::now().signed_duration_since(*start_time).num_days();
if age_days <= 7 || age_days > 365 {
return Err(Error::QueryOutOfRange("Warm tier supports 7-365 days"));
}
}
// Query estilo Athena (partition pruning)
let partitions = self.athena_style_index
.find_partitions(query.tenant_id.clone(), query.start_time, query.end_time)
.await?;
// Parallel query en múltiples archivos Parquet
let mut handles = Vec::new();
for partition in partitions {
let handle = tokio::spawn(self.query_partition(partition, &query));
handles.push(handle);
}
// Combinar resultados
let mut all_events = Vec::new();
for handle in handles {
let result = handle.await??;
all_events.extend(result.events);
}
Ok(AuditQueryResult {
events: all_events,
total: all_events.len() as u64,
has_more: all_events.len() >= query.limit,
})
}
}
Cold Tier: S3 Glacier (1-7 años)
/// Almacenamiento de largo plazo para compliance
pub struct ColdStorage {
s3_client: Arc<S3Client>,
glacier_client: Arc<GlacierClient>,
bucket: String,
retention_policy: RetentionPolicy,
}
#[async_trait]
impl StorageBackend for ColdStorage {
async fn insert_batch(&self, tenant_id: TenantId, events: Vec<AuditEvent>) -> Result<()> {
// Comprimir y encryptar para storage de largo plazo
let compressed = self.compress_and_encrypt(&events)?;
// Partición anual para compliance
let year = Utc::now().format("%Y").to_string();
let key = format!("cold/compliance/year={}/tenant_id={}/{}.gz", year, tenant_id, Uuid::new_v4());
// Upload a Glacier (async, con job tracking)
let job_id = self.glacier_client
.upload_archive()
.bucket(&self.bucket)
.archive_description(format!("audit_events_{}", tenant_id))
.send()
.await?;
// Registrar para lifecycle management
self.register_lifecycle_rule(tenant_id, key, 7 /* years */)
.await?;
Ok(())
}
async fn query(&self, query: AuditQuery) -> Result<AuditQueryResult> {
// Verificar rango (solo datos cold)
if let Some(start_time) = &query.start_time {
let age_days = Utc::now().signed_duration_since(*start_time).num_days();
if age_days <= 365 {
return Err(Error::QueryOutOfRange("Cold tier supports >1 year"));
}
}
// Initiate retrieval job (puede tomar horas)
let job_id = self.glacier_client
.initiate_job()
.job_parameters(JobParameters {
format: "CSV",
type: "SELECT",
sql_query: self.build_glacier_sql(query)?,
})
.send()
.await?;
// Poll job status
let result = self.wait_for_job_completion(job_id).await?;
// Download y parse results
self.download_and_parse_result(result).await
}
fn build_glacier_sql(&self, query: AuditQuery) -> Result<String> {
// SQL optimizado para Glacier
let mut sql = "SELECT * FROM archive WHERE 1=1".to_string();
if let Some(tenant_id) = &query.tenant_id {
sql.push_str(&format!(" AND tenant_id = '{}'", tenant_id));
}
if let Some(hrn) = &query.hrn {
sql.push_str(&format!(" AND hrn = '{}'", hrn));
}
if let Some(start_time) = &query.start_time {
sql.push_str(&format!(" AND timestamp >= {}", start_time.timestamp()));
}
if let Some(end_time) = &query.end_time {
sql.push_str(&format!(" AND timestamp <= {}", end_time.timestamp()));
}
Ok(sql)
}
}
📊 Partition Strategy (Estilo Athena)
/// Particionado optimizado para queries
pub struct PartitionStrategy {
hot_partition: String, // Por día: YYYY/MM/DD
warm_partition: String, // Por fecha+tenant: YYYY-MM-DD/tenant_id
cold_partition: String, // Por año+tenant: YYYY/tenant_id
}
impl PartitionStrategy {
fn get_partition_path(&self, tier: &StorageTier, event: &AuditEvent) -> String {
let timestamp = event.timestamp.format("%Y-%m-%d %H:%M:%S");
match tier {
StorageTier::Hot(_) => {
// Hot: partición diaria
format!("hot/year={}/month={}/day={}/",
timestamp[0..4], timestamp[5..7], timestamp[8..10])
}
StorageTier::Warm(_) => {
// Warm: partición por fecha+tenant
format!("warm/date={}/tenant_id={}/",
timestamp[0..10], event.tenant_id)
}
StorageTier::Cold(_) => {
// Cold: partición anual
format!("cold/year={}/tenant_id={}/",
timestamp[0..4], event.tenant_id)
}
}
}
}
🔄 Lifecycle Policy (AWS S3 Lifecycle)
/// Gestión automática de movimiento entre tiers
pub struct LifecyclePolicy {
hot_to_warm_days: u32, // 7 días
warm_to_cold_days: u32, // 365 días
cold_deletion_days: u32, // 2555 días (7 años)
}
impl LifecyclePolicy {
pub async fn apply_lifecycle(&self, storage: &TieredStorage) -> Result<()> {
// Mover datos hot -> warm
let hot_cutoff = Utc::now() - Duration::days(self.hot_to_warm_days as i64);
let events_to_warm = storage.hot.get_events_older_than(hot_cutoff).await?;
storage.move_to_warm(events_to_warm).await?;
// Mover datos warm -> cold
let warm_cutoff = Utc::now() - Duration::days(self.warm_to_cold_days as i64);
let events_to_cold = storage.warm.get_events_older_than(warm_cutoff).await?;
storage.move_to_cold(events_to_cold).await?;
// Eliminar datos cold expirados (compliance)
let cold_cutoff = Utc::now() - Duration::days(self.cold_deletion_days as i64);
let events_to_delete = storage.cold.get_events_older_than(cold_cutoff).await?;
storage.delete_expired_events(events_to_delete).await?;
Ok(())
}
}
💰 Cost Optimization (AWS S3 Pricing)
/// Estimación de costos por tier
pub struct CostEstimator {
hot_storage_cost: f64, // ClickHouse: $100/TB/mes
warm_storage_cost: f64, // S3 Standard: $23/TB/mes
cold_storage_cost: f64, // S3 Glacier: $4/TB/mes
retrieval_cost: f64, // $10/GB retrievals
request_cost: f64, // $0.0004/1000 requests
}
impl CostEstimator {
pub fn estimate_monthly_cost(&self, events_per_month: u64, avg_event_size_kb: f64) -> CostBreakdown {
let total_tb = (events_per_month as f64 * avg_event_size_kb) / (1024.0 * 1024.0);
// Distribución típica
let hot_tb = total_tb * 0.02; // 2% en hot (últimos 7 días)
let warm_tb = total_tb * 0.23; // 23% en warm (7-365 días)
let cold_tb = total_tb * 0.75; // 75% en cold (>365 días)
CostBreakdown {
hot_storage: hot_tb * self.hot_storage_cost,
warm_storage: warm_tb * self.warm_storage_cost,
cold_storage: cold_tb * self.cold_storage_cost,
retrievals: self.estimate_retrieval_cost(warm_tb + cold_tb),
total: 0.0, // Calculado abajo
}
}
}
🔍 Athena-Style Query Engine
/// Query engine híbrido - Unifica todos los tiers
pub struct UnifiedQueryEngine {
hot: Arc<ClickHouseStorage>,
warm: Arc<S3Storage>,
cold: Arc<GlacierStorage>,
cost_optimizer: CostOptimizer,
}
impl UnifiedQueryEngine {
pub async fn query(&self, query: AuditQuery) -> Result<AuditQueryResult> {
// 1. Plan de query - determinar qué tiers tocar
let query_plan = self.plan_query(&query).await?;
// 2. Cost-based optimization
if query_plan.cost_estimate > query.budget_limit.unwrap_or(f64::MAX) {
return Err(Error::QueryTooExpensive);
}
// 3. Parallel query en múltiples tiers
let mut tasks = Vec::new();
if query_plan.include_hot {
let task = tokio::spawn(self.hot.query(query.clone()));
tasks.push(("hot".to_string(), task));
}
if query_plan.include_warm {
let task = tokio::spawn(self.warm.query(query.clone()));
tasks.push(("warm".to_string(), task));
}
if query_plan.include_cold {
let task = tokio::spawn(self.cold.query(query.clone()));
tasks.push(("cold".to_string(), task));
}
// 4. Merge results
let mut all_events = Vec::new();
let mut tier_stats = HashMap::new();
for (tier_name, task) in tasks {
match task.await?? {
Ok(result) => {
all_events.extend(result.events);
tier_stats.insert(tier_name, result.events.len());
}
Err(e) => {
warn!("Query failed for tier {}: {}", tier_name, e);
}
}
}
// 5. Sort by timestamp
all_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
// 6. Limit results
all_events.truncate(query.limit);
Ok(AuditQueryResult {
events: all_events,
total: all_events.len() as u64,
has_more: all_events.len() == query.limit,
tier_stats: Some(tier_stats),
cost_estimate: query_plan.cost_estimate,
})
}
fn plan_query(&self, query: &AuditQuery) -> Result<QueryPlan> {
let mut include_hot = false;
let mut include_warm = false;
let mut include_cold = false;
let mut cost_estimate = 0.0;
if let (Some(start), Some(end)) = (&query.start_time, &query.end_time) {
let duration_days = end.signed_duration_since(start).num_days();
// Hot tier: 0-7 días
if duration_days <= 7 {
include_hot = true;
cost_estimate += 0.0; // Gratis (en memoria)
}
// Warm tier: 7-365 días
else if duration_days <= 365 {
include_warm = true;
cost_estimate += self.estimate_warm_cost(duration_days);
}
// Cold tier: >365 días
else {
include_cold = true;
cost_estimate += self.estimate_cold_cost(duration_days);
}
}
Ok(QueryPlan {
include_hot,
include_warm,
include_cold,
cost_estimate,
})
}
}
📋 Compliance y Retención
/// Retención por tipo de evento
pub struct CompliancePolicy {
pub pii_events_retain_years: u32, // 7 años
pub financial_events_retain_years: u32, // 7 años
pub access_events_retain_years: u32, // 1 año
pub debug_events_retain_days: u32, // 30 días
pub legal_hold: bool, // Retención legal (no eliminar)
}
impl CompliancePolicy {
pub fn get_retention_period(&self, event: &AuditEvent) -> u32 {
// Determinar retención basada en tipo de evento
if event.action.contains("pii") || event.action.contains("personal_data") {
self.pii_events_retain_years * 365
} else if event.action.contains("financial") || event.action.contains("payment") {
self.financial_events_retain_years * 365
} else if event.action.contains("access") || event.action.contains("login") {
self.access_events_retain_years * 365
} else {
self.debug_events_retain_days
}
}
pub fn is_compliance_required(&self, event: &AuditEvent) -> bool {
// Verificar si el evento requiere retención legal
event.metadata.get("legal_hold").and_then(|v| v.as_bool()).unwrap_or(false)
|| event.action.contains("compliance")
|| event.action.contains("audit")
}
}
🏗️ Docker Compose con S3/MinIO
# docker-compose.audit-tiers.yml (add to main compose)
# ────────────────────────────────────────────────
# MinIO S3-Compatible Storage
# ────────────────────────────────────────────────
minio:
image: minio/minio:latest
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin123
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
networks:
- audit_net
# ────────────────────────────────────────────────
# Athena Query Engine (Trino + Iceberg)
# ────────────────────────────────────────────────
trino-coordinator:
image: trinodb/trino:latest
ports:
- "8080:8080" # Trino UI
environment:
- COORDINATOR=true
- DISCOVER_SERVERS=true
- TRINO_GC_AGENT_CONFIG_ENABLED=true
volumes:
- ./config/trino/catalog/minio.properties:/etc/trino/catalog/minio.properties
networks:
- audit_net
# ────────────────────────────────────────────────
# Lifecycle Management Service
# ────────────────────────────────────────────────
lifecycle-manager:
build:
context: ./hodei-audit-service
dockerfile: Dockerfile.lifecycle
environment:
- MINIO_ENDPOINT=minio:9000
- CLICKHOUSE_URL=clickhouse:8123
depends_on:
- minio
- clickhouse
networks:
- audit_net
command: ./lifecycle-manager --interval 1h
deploy:
replicas: 1
📊 S3 Bucket Structure
audit-bucket/
├── hot/ # Últimos 7 días
│ └── year=2024/month=01/day=15/
│ ├── tenant-123/audit_events_2024-01-15_00.parquet
│ ├── tenant-123/audit_events_2024-01-15_01.parquet
│ └── tenant-456/audit_events_2024-01-15_00.parquet
│
├── warm/ # 7 días - 1 año
│ └── date=2024-01-15/tenant_id=tenant-123/
│ ├── audit_events_2024-01-15_00.parquet
│ ├── audit_events_2024-01-15_01.parquet
│ └── _meta/
│ └── partition.json
│
├── cold/ # 1-7 años
│ └── year=2024/tenant_id=tenant-123/
│ ├── compliance_2024.gz
│ └── compliance_2023.gz
│
└── _trino/ # Trino/Iceberg metadata
└── catalog/
└── minio.properties
💡 Key Benefits
- Cost Optimization: 70% reducción de costos vs single-tier
- Performance: Queries rápidas en datos recientes (hot)
- Compliance: Retención de 7+ años en cold tier
- Scalability: S3/MinIO escala a petabytes
- Analytics: Athena-style queries en datos históricos
- Flexibility: Configuración por tenant/uso
📈 Performance Comparison
| Tier | Storage | Query Latency | Cost/Month | Use Case |
|---|---|---|---|---|
| Hot | ClickHouse | <10ms | $100/TB | Live monitoring |
| Warm | S3 + Parquet | 100-500ms | $23/TB | Analytics |
| Cold | Glacier | Minutes-Hours | $4/TB | Compliance |
Total Cost for 10TB audit data: ~$370/month (vs $1000/month single-tier)
Esta arquitectura nos da la escala de AWS CloudTrail con la simplicidad de gRPC!
6.5. CAP/ARP: Terminología Arquitectónica
🎯 Definición de CAP y ARP
Siguiendo la analogía de PDP/PEP (Policy Decision Point / Policy Enforcement Point) en verified-permissions, definimos:
CAP - Centralized Audit Point
Definición: El servicio central donde todos los eventos de auditoría se procesan, almacenan y consultan.
/// CAP: Centralized Audit Point
///
/// Rol: Servicio central que ingiere, procesa y almacena eventos de auditoría
/// Puertos: 50052 (ingestión), 50053 (query), 50054 (crypto/digest)
/// Tecnología: Rust + gRPC + Tonic + ClickHouse/S3
pub struct CentralizedAuditPoint {
/// Ingestión de eventos (puerto 50052)
pub ingestion_service: Arc<AuditIngestionService>,
/// Query y analytics (puerto 50053)
pub query_service: Arc<AuditQueryService>,
/// Worker de digest criptográfico (puerto 50054)
pub digest_worker: Arc<DigestWorker>,
/// Storage backend (tiered)
pub storage: Arc<TieredStorage>,
}
impl CentralizedAuditPoint {
pub async fn run(self) -> Result<()> {
// gRPC server con 3 servicios
let addr = "0.0.0.0:50052-50054".parse()?;
tonic::transport::Server::builder()
.add_service(AuditIngestionServiceServer::new(self.ingestion_service))
.add_service(AuditQueryServiceServer::new(self.query_service))
.add_service(AuditCryptoServiceServer::new(self.digest_worker))
.serve(addr)
.await?;
Ok(())
}
}
Responsabilidades del CAP:
- ✅ Ingestión: Recibir eventos de múltiples aplicaciones via gRPC
- ✅ Procesamiento: Enriquecer con HRN, metadata, geo-location
- ✅ Almacenamiento: Persistir en storage tiered (Local/Hot/Warm/Cold)
- ✅ Query: APIs de consulta para dashboards, compliance, analytics
- ✅ Digest: Crear digest criptográficos hourly para tamper-evidence
- ✅ Multi-tenancy: Aislar datos por tenant_id desde día 1
ARP - Audit Reporting Point
Definición: El SDK middleware que captura eventos en cada aplicación y los reporta al CAP.
/// ARP: Audit Reporting Point
///
/// Rol: Middleware que captura automáticamente eventos y los envía al CAP
/// Integración: 1 línea en Axum: .layer(arp_config.layer())
/// Tecnología: Rust + Tower + Axum
pub struct AuditReportingPoint {
/// Configuración del SDK
pub config: AuditSdkConfig,
/// Cliente gRPC conectado al CAP
pub client: AuditControlServiceClient<tonic::transport::Channel>,
/// Batching queue
pub batch_queue: Arc<Mutex<Vec<AuditEvent>>>,
/// Background flush worker
pub flush_worker: Arc<FlushWorker>,
}
impl AuditReportingPoint {
/// Integración 1-liner en Axum
pub fn layer(&self) -> impl Layer<S> + Clone
where
S: Service<Request<Body>, Response = Response, Error = Infallible> + Send + Clone,
{
AuditMiddlewareLayer::new(self.config.clone())
}
}
Responsabilidades del ARP:
- ✅ Captura automática: Interceptor de requests HTTP (Axum/Tower)
- ✅ Auto-enriquecimiento: Añadir user_id, tenant_id, hrn, trace_id
- ✅ Batching: Buffer local para optimizar envíos al CAP
- ✅ Reintentos: Retry con backoff en caso de fallo
- ✅ HRN resolution: Resolver HRN based on request path
- ✅ Non-blocking: Async, no impact en latencia de request
🔄 Flujo CAP ↔ ARP
sequenceDiagram
participant App as Application
participant ARP as Audit Reporting Point (SDK)
participant CAP as Centralized Audit Point
participant Store as Storage Backend
App->>ARP: HTTP Request
ARP->>ARP: Capture & Enrich Event
ARP->>ARP: Add to Batch Queue
par Async Batch Flush
ARP->>CAP: Batch Events (gRPC)
CAP->>Store: Store Events
Store-->>CAP: Confirmation
CAP-->>ARP: Batch Accepted
and Request Processing
ARP->>App: HTTP Response
end
📊 Comparativa: CAP/ARP vs PDP/PEP
| Concepto | verified-permissions | Hodei Audit |
|---|---|---|
| Decision Point | PDP (Policy Decision) | CAP (Centralized Audit) |
| Enforcement Point | PEP (Policy Enforcement) | ARP (Audit Reporting) |
| Communication | gRPC | gRPC |
| Location | External service | External service |
| Middleware | In-app enforcement | In-app capture |
| Scale | Policy checks | Audit logging |
| Persistence | No persistence | Full persistence (4-tier) |
| Compliance | N/A | CloudTrail-like |
🏗️ Arquitectura CAP Desplegada
# CAP: Centralized Audit Point (single deployment)
apiVersion: apps/v1
kind: Deployment
metadata:
name: hodei-audit-cap
spec:
replicas: 3 # High availability
selector:
matchLabels:
app: hodei-audit-cap
template:
metadata:
labels:
app: hodei-audit-cap
spec:
containers:
- name: audit-service
image: hodei/audit-service:latest
ports:
- containerPort: 50052 # Ingestion
- containerPort: 50053 # Query
- containerPort: 50054 # Crypto/Digest
env:
- name: CLICKHOUSE_URL
value: "clickhouse:8123"
- name: MINIO_ENDPOINT
value: "minio:9000"
- name: VAULT_ADDR
value: "https://vault.hodei.internal"
resources:
requests:
cpu: 1000m
memory: 2Gi
limits:
cpu: 2000m
memory: 4Gi
📱 ARPs Desplegados (por aplicación)
# ARP: Audit Reporting Point (SDK en cada app)
apiVersion: apps/v1
kind: Deployment
metadata:
name: verified-permissions
spec:
replicas: 5
template:
spec:
containers:
- name: verified-permissions
image: hodei/verified-permissions:latest
env:
- name: AUDIT_SERVICE_URL # URL del CAP
value: "http://hodei-audit-cap:50052"
- name: AUDIT_SDK_BATCH_SIZE
value: "100"
- name: AUDIT_SDK_TENANT_ID
value: "tenant-123"
- name: AUDIT_SDK_SERVICE_NAME
value: "verified-permissions"
# ... app config
🎯 Beneficios de CAP/ARP
-
Separation of Concerns:
- CAP: Focus en storage, query, compliance, analytics
- ARP: Focus en capture, enrichment, batching, retry
-
Independent Scaling:
- CAP: Scale based on storage/query load
- ARP: Scale based on application instances
-
Unified Intelligence:
- CAP: Centralized analytics, anomaly detection, compliance
- ARP: Standardized capture, consistent data model
-
CloudTrail Pattern:
- CAP: Equivalent to CloudTrail service
- ARP: Equivalent to CloudTrail trails
🔍 Debugging CAP vs ARP
# Debug CAP (service-level issues)
kubectl logs -l app=hodei-audit-cap -f
# Ver: Ingestion rate, Query latency, Digest creation
# Debug ARP (application-level issues)
kubectl logs -l app=verified-permissions -f | grep audit
# Ver: Batch queue size, Retry attempts, Capture rate
6.6. Implementación Técnica Detallada
A. Contrato gRPC CAP → Vector (vector_api.proto)
syntax = "proto3";
package hodei.audit;
// Importa la definición común del evento de auditoría
import "audit_event.proto";
// El servicio que Vector expondrá
service VectorApi {
// RPC para enviar un lote de eventos
rpc SendEventBatch(EventBatchRequest) returns (EventBatchResponse);
}
message EventBatchRequest {
repeated AuditEvent events = 1;
}
message EventBatchResponse {
// Una simple confirmación es suficiente
bool success = 1;
}
B. Configuración de Vector (vector.toml)
# config/vector/vector.toml
# 1. FUENTE: Recibir datos del Hodei Audit Service vía gRPC
[sources.audit_input_grpc]
type = "grpc_server"
address = "0.0.0.0:9090" # Vector escucha gRPC en este puerto
decoding.codec = "json"
# 2. DESTINOS (SINKS)
# 2a. Destino HOT: ClickHouse
[sinks.clickhouse_hot_tier]
inputs = ["audit_input_grpc"]
type = "clickhouse"
endpoint = "http://clickhouse:8123"
database = "audit"
table = "audit_events"
batch.max_events = 1000
batch.timeout_secs = 5
# Búfer de persistencia en disco
[sinks.clickhouse_hot_tier.buffer]
type = "disk"
max_size = 1_073_741_824 # 1 GiB
when_full = "block"
# 2b. Destino WARM/COLD: S3/MinIO
[sinks.s3_archive_tier]
inputs = ["audit_input_grpc"]
type = "s3"
endpoint = "http://minio:9000"
region = "us-east-1"
bucket = "audit-logs"
access_key_id = "minioadmin"
secret_access_key = "minioadmin123"
force_path_style = true
key_prefix = "tenant_id=%{{tenant_id}}/year=%Y/month=%m/day=%d/"
encoding.codec = "json"
compression = "gzip"
batch.timeout_secs = 300
# Búfer de persistencia en disco también para S3
[sinks.s3_archive_tier.buffer]
type = "disk"
max_size = 5_368_709_120 # 5 GiB
when_full = "block"
C. Código Rust del CAP para enviar a Vector
// hodei-audit-service/src/forwarder.rs
// Código generado por tonic/prost a partir de vector_api.proto
pub mod vector_api {
tonic::include_proto!("hodei.audit");
}
use vector_api::vector_api_client::VectorApiClient;
use vector_api::{EventBatchRequest, AuditEvent as ProtoAuditEvent};
use crate::AuditEvent;
#[derive(Clone)]
pub struct VectorForwarder {
vector_endpoint: String, // Ej: "http://vector:9090"
}
impl VectorForwarder {
pub fn new(vector_endpoint: String) -> Self {
Self { vector_endpoint }
}
pub async fn forward_batch(&self, events: Vec<AuditEvent>) -> Result<(), String> {
if events.is_empty() { return Ok(()); }
let mut client = VectorApiClient::connect(self.vector_endpoint.clone())
.await
.map_err(|e| e.to_string())?;
let proto_events: Vec<ProtoAuditEvent> = events.into_iter().map(Into::into).collect();
let request = tonic::Request::new(EventBatchRequest { events: proto_events });
client.send_event_batch(request).await.map_err(|e| e.to_string())?;
// La llamada termina aquí. Vector se encarga del resto.
Ok(())
}
}
D. Orquestación con Docker Compose
# docker-compose.yml
version: '3.8'
services:
audit-service:
build: ./hodei-audit-service
ports: ["50052:50052"] # gRPC para los SDKs
environment:
- VECTOR_GRPC_ENDPOINT=http://vector:9090 # Apunta a Vector
depends_on: [vector]
networks: [audit_net]
vector:
image: timberio/vector:latest-alpine
ports: ["9090:9090"] # gRPC para el audit-service
volumes:
- ./config/vector/vector.toml:/etc/vector/vector.toml:ro
- vector_data:/var/lib/vector # ¡Volumen persistente para el búfer!
networks: [audit_net]
clickhouse: #...sin cambios
minio: #...sin cambios
volumes:
vector_data:
clickhouse_data:
minio_data:
networks:
audit_net:
E. Update del CAP Environment
# Actualización del CAP (Centralized Audit Point)
- name: VECTOR_GRPC_ENDPOINT
value: "http://vector:9090" # Vector maneja toda la persistencia
- name: CLICKHOUSE_URL
value: "http://clickhouse:8123" # Solo para queries del CAP
- name: REDIS_URL
value: "redis://redis:6379" # Para rate limiting
6.7. Contrato CAP ↔ Vector: Definición Clara y Limpia
Principio: El desacoplamiento entre CAP y Vector debe ser explícito y verificable para guiar la configuración y evitar confusiones.
Contrato Estructural
// El contrato es SIMPLE: lote de eventos → confirmación
message EventBatchRequest {
repeated AuditEvent events = 1;
}
message EventBatchResponse {
bool success = 1; // Solo necesitamos confirmación
}
Por qué es SIMPLE:
- ✅ Un solo método:
SendEventBatch- sin ambigüedad - ✅ Batch en lugar de streaming: Simplifica manejo de backpressure
- ✅ Confirmación simple: CAP solo necesita saber "recibido" vs "error"
- ✅ Eventual consistency: Vector se encarga de la distribución
Configuración Vector: Extracción de Campos Clave
Requisito: Vector debe poder extraer dinámicamente campos como tenant_id de los eventos para particionamiento y routing.
# Configuración de Vector que extrae tenant_id dinámicamente
[sources.audit_input_grpc]
type = "grpc_server"
address = "0.0.0.0:9090"
decoding.codec = "json"
# Transforms: Extracción de tenant_id para particionamiento
[transforms.extract_tenant_id]
type = "add_fields"
inputs = ["audit_input_grpc"]
fields:
# Vector extrae tenant_id de cada evento JSON
tenant_id = "{{ tenant_id }}" # Campo directo en el JSON
partition_key = "{{ tenant_id }}/{{ %Y }}/{{ %m }}/{{ %d }}"
# Sink 1: ClickHouse (hot tier) - usa tenant_id para partitioning
[sinks.clickhouse_hot]
type = "clickhouse"
inputs = ["extract_tenant_id"]
table = "audit_events"
# Particionado por tenant y fecha
partition_key_format = "{tenant_id}_{YYYY}_{MM}_{DD}"
# Encoding optimizado
encoding.codec = "json"
encoding.timestamp_format = "rfc3339"
# Sink 2: S3 (warm tier) - usa tenant_id para path prefix
[sinks.s3_warm]
type = "aws_s3"
inputs = ["extract_tenant_id"]
bucket = "audit-logs"
# Path dinámico basado en tenant_id
key_prefix = "tenant_id={{ tenant_id }}/year={{ %Y }}/month={{ %m }}/day={{ %d }}/"
# Parquet para optimización
encoding.codec = "parquet"
encoding.compression = "gzip"
CAP: Implementación del Contrato
// CAP depende ÚNICAMENTE del contrato, no de la implementación de Vector
pub struct HodeiAuditService {
vector_client: Arc<VectorClient>, // Cliente gRPC
// NO depende de: ClickHouse, S3, ni ninguna implementación de almacenamiento
}
impl HodeiAuditService {
pub async fn ingest_audit_events(&self, events: Vec<AuditEvent>) -> Result<(), IngestError> {
// 1. Validación y enriquecimiento (lógica de negocio)
let enriched_events = self.validate_and_enrich(events).await?;
// 2. Envío al contrato - Vector maneja TODO lo demás
let request = EventBatchRequest {
events: enriched_events.into_proto_format(), // Conversión a protobuf
};
let response = self.vector_client
.send_event_batch(tonic::Request::new(request))
.await
.map_err(|e| IngestError::VectorUnavailable(e.to_string()))?;
// 3. Verificar respuesta
if !response.into_inner().success {
return Err(IngestError::VectorRejected);
}
// ¡Listo! CAP no sabe ni necesita saber:
// - Si los eventos van a ClickHouse o S3
// - Si hay backpressure
// - Si Vector está bufferizando
Ok(())
}
}
Verificación del Desacoplamiento
Test de Contrato:
// Verificar que CAP y Vector están desacoplados correctamente
#[tokio::test]
async fn test_cap_vector_contract() {
// 1. CAP con cliente Vector mock
let mock_vector = MockVectorClient::new();
let cap = HodeiAuditService::new(mock_vector.clone());
// 2. Enviar eventos
let events = vec![create_test_event()];
cap.ingest_audit_events(events).await.unwrap();
// 3. Verificar que Vector recibió exactamente lo que CAP envió
let received_batch = mock_vector.get_last_batch().await;
assert_eq!(received_batch.events.len(), 1);
assert!(received_batch.events[0].has_tenant_id());
}
Garantías del Contrato:
- CAP no ve: Implementaciones de almacenamiento, configuración de sinks, estrategias de particionamiento
- Vector no ve: Lógica de validación, enriquecimiento, quotas del CAP
- Cambios en un lado NO afectan el otro: Si añades S3 como sink, CAP no cambia
7. Multi-Tenancy y Seguridad
🏢 Diseño Multi-Tenant desde el Día 1
Principios Multi-Tenant
/// Tenant-First Architecture: Todo tiene tenant_id como first-class field
pub struct AuditEvent {
// 1. TENANT_ID SIEMPRE PRIMERO
pub tenant_id: TenantId, // UUID del tenant
// 2. EVENT DATA
pub event_id: EventId, // UUID único global
pub hrn: Hrn, // HRN incluye tenant_id
pub user_id: UserId, // Usuario que causó el evento
pub service_id: ServiceId, // Servicio origen
pub action: String, // Acción ejecutada
pub resource: String, // Recurso afectado
pub outcome: Outcome, // SUCCESS, FAILURE
pub timestamp: DateTime<Utc>, // Cuándo ocurrió
// 3. METADATA
pub metadata: serde_json::Value, // Campos adicionales
pub trace_id: Option<TraceId>, // Para correlation
pub request_id: Option<String>, // Request ID si existe
}
Shared Store vs Per-Tenant Store
Recomendación: Shared Store con Row-Level Security
-- ClickHouse: Shared table con tenant_id
CREATE TABLE audit_events_shared (
tenant_id String, -- Always first column
event_id String,
hrn String,
user_id String,
action String,
timestamp DateTime64(3),
metadata String,
-- Índice por tenant
INDEX idx_tenant_time tenant_id, timestamp TYPE bloom_filter GRANULARITY 1
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (tenant_id, timestamp, hrn);
-- View por tenant (computed)
CREATE MATERIALIZED VIEW audit_events_tenant_123
AS SELECT * FROM audit_events_shared
WHERE tenant_id = 'tenant-123';
Ventajas del Shared Store:
- ✅ Storage Efficiency: Compartir índices y compresión
- ✅ Query Performance: Joins cross-tenant para analytics
- ✅ Lifecycle Management: Políticas unificadas por tier
- ✅ Cost Optimization: Menos almacenamiento total
Tenant Context y Isolation
// hodei-audit-service/src/tenancy.rs
/// Tenant Context - Siempre presente en requests
#[derive(Debug, Clone)]
pub struct TenantContext {
pub tenant_id: TenantId,
pub user_id: UserId,
pub roles: Vec<TenantRole>,
pub permissions: Vec<Permission>,
pub api_key_id: Option<String>,
pub rate_limit: RateLimit,
}
impl TenantContext {
/// Verificar permiso específico
pub fn has_permission(&self, permission: &str) -> bool {
self.permissions
.iter()
.any(|p| p.name == permission)
}
/// Verificar rol
pub fn has_role(&self, role: TenantRole) -> bool {
self.roles.contains(&role)
}
/// Verificar límite de rate
pub fn check_rate_limit(&self, tokens: u32) -> Result<(), RateLimitExceeded> {
if self.rate_limit.remaining < tokens {
Err(RateLimitExceeded {
limit: self.rate_limit.limit,
remaining: self.rate_limit.remaining,
reset_at: self.rate_limit.reset_at,
})
} else {
Ok(())
}
}
}
/// Roles por Tenant
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TenantRole {
Owner, // Dueño del tenant
Admin, // Administrador
AuditViewer, // Solo lectura de auditoría
AuditManager, // Gestión de auditoría
Compliance, // Officer de compliance
Developer, // Desarrollador
}
/// Permisos granulares
#[derive(Debug, Clone)]
pub struct Permission {
pub name: String, // "audit:read", "audit:write", "audit:query"
pub scope: PermissionScope, // "tenant", "global", "system"
pub conditions: Vec<Condition>, // Filtros adicionales
}
#[derive(Debug, Clone)]
pub enum PermissionScope {
Tenant, // Solo su tenant
Global, // Todos los tenants (solo owners)
System, // Acceso a endpoints de sistema
}
Row-Level Security (RLS)
// gRPC Interceptor: Verificar tenant access
pub struct TenantIsolationInterceptor {
pub auth_service: Arc<AuthService>,
pub rls_policy: Arc<RlsPolicy>,
}
impl TenantIsolationInterceptor {
pub async fn check_access(
&self,
tenant_id: &TenantId,
user_id: &UserId,
resource: &str,
operation: &str,
) -> Result<(), AuthError> {
// 1. Verificar que el usuario pertenece al tenant
let membership = self.auth_service
.check_tenant_membership(user_id, tenant_id)
.await?;
if !membership.is_member {
return Err(AuthError::TenantAccessDenied);
}
// 2. Verificar permisos específicos
let permission = format!("audit:{}", operation);
if !membership.user.has_permission(&permission) {
return Err(AuthError::InsufficientPermissions(permission));
}
// 3. Aplicar RLS policy
if let Some(condition) = self.rls_policy.get_condition(resource) {
if !condition.evaluate(&membership.user) {
return Err(AuthError::RowLevelSecurityDenied);
}
}
Ok(())
}
}
// ClickHouse: RLS Policy
pub struct RlsPolicy {
pub rules: Vec<RlsRule>,
}
impl RlsPolicy {
pub fn get_sql_filter(&self, tenant_id: &TenantId) -> String {
// Query siempre incluye WHERE tenant_id = ?
format!("tenant_id = '{}'", tenant_id)
}
}
API Key Management por Tenant
// hodei-audit-service/src/api_keys.rs
/// API Key por Tenant
pub struct TenantApiKey {
pub key_id: String, // Público (uuid)
pub hashed_key: String, // Hash de la clave real
pub tenant_id: TenantId,
pub name: String, // Nombre descriptivo
pub scopes: Vec<ApiScope>, // Permisos específicos
pub rate_limit: RateLimit,
pub expires_at: Option<DateTime<Utc>>,
pub created_by: UserId,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum ApiScope {
/// Leer eventos de auditoría
AuditRead {
hrn_filter: Option<Vec<Hrn>>, // Filtrar por HRN
time_range: Option<TimeRange>, // Filtrar por tiempo
},
/// Escribir eventos (para apps)
AuditWrite,
/// Queries complejas
AuditQuery {
max_results: u32,
require_approval: bool,
},
/// Acceso a digest y verificación
CryptoVerify,
/// Administración del tenant
TenantAdmin,
}
pub struct ApiKeyService {
key_store: Arc<dyn ApiKeyStore>,
key_generator: Arc<KeyGenerator>,
rate_limiter: Arc<RateLimiter>,
}
impl ApiKeyService {
/// Crear API Key para tenant
pub async fn create_api_key(
&self,
tenant_id: TenantId,
request: CreateApiKeyRequest,
created_by: UserId,
) -> Result<CreateApiKeyResponse, ApiKeyError> {
// 1. Generar key pair
let (key_id, hashed_key, plaintext_key) = self.key_generator
.generate_key_pair()
.await?;
// 2. Guardar en store
let api_key = TenantApiKey {
key_id,
hashed_key,
tenant_id,
name: request.name,
scopes: request.scopes,
rate_limit: request.rate_limit.unwrap_or_default(),
expires_at: request.expires_at,
created_by,
created_at: Utc::now(),
last_used_at: None,
metadata: request.metadata,
};
self.key_store.save(&api_key).await?;
// 3. Return response (solo muestra plaintext una vez)
Ok(CreateApiKeyResponse {
key_id,
plaintext_key, // Solo se muestra una vez!
scopes: api_key.scopes,
expires_at: api_key.expires_at,
})
}
/// Validar API Key
pub async fn validate_api_key(
&self,
key: &str,
) -> Result<ValidatedKey, AuthError> {
// 1. Hash de la key
let key_hash = self.hash_api_key(key);
// 2. Lookup en store
let api_key = self.key_store
.get_by_hash(&key_hash)
.await
.ok_or(AuthError::InvalidApiKey)?;
// 3. Verificar expiración
if let Some(expires) = api_key.expires_at {
if Utc::now() > expires {
return Err(AuthError::ExpiredApiKey);
}
}
// 4. Verificar rate limit
self.rate_limiter
.check_limit(&api_key.key_id, 1)
.await?;
// 5. Update last used
self.key_store.update_last_used(&api_key.key_id, Utc::now()).await?;
Ok(ValidatedKey {
tenant_id: api_key.tenant_id,
key_id: api_key.key_id,
scopes: api_key.scopes,
rate_limit: api_key.rate_limit,
})
}
}
Resource Quotas por Tenant
// hodei-audit-service/src/quotas.rs
/// Cuotas por Tenant
pub struct TenantQuota {
pub tenant_id: TenantId,
pub quotas: QuotaConfig,
}
#[derive(Debug, Clone)]
pub struct QuotaConfig {
/// Eventos por segundo
pub events_per_second: u32,
/// Eventos por día
pub events_per_day: u64,
/// Almacenamiento máximo (GB)
pub storage_gb: u64,
/// Queries por hora
pub queries_per_hour: u32,
/// Retención mínima (días)
pub min_retention_days: u32,
/// Retención máxima (días)
pub max_retention_days: u32,
}
/// Quota Enforcement
pub struct QuotaEnforcer {
quota_store: Arc<dyn QuotaStore>,
rate_limiter: Arc<RateLimiter>,
usage_tracker: Arc<UsageTracker>,
}
impl QuotaEnforcer {
/// Verificar quota antes de escribir evento
pub async fn check_write_quota(
&self,
tenant_id: &TenantId,
event_count: u32,
) -> Result<(), QuotaExceeded> {
let quota = self.quota_store.get_quota(tenant_id).await?;
// 1. Rate limit (events/sec)
if !self.rate_limiter
.check_rate(tenant_id, "write_events", quota.events_per_second)
.await?
{
return Err(QuotaExceeded::RateLimit);
}
// 2. Daily limit
let today_usage = self.usage_tracker
.get_daily_usage(tenant_id, Utc::now().date())
.await?;
if today_usage + event_count as u64 > quota.events_per_day {
return Err(QuotaExceeded::DailyLimit);
}
Ok(())
}
/// Verificar quota antes de query
pub async fn check_query_quota(
&self,
tenant_id: &TenantId,
) -> Result<(), QuotaExceeded> {
let quota = self.quota_store.get_quota(tenant_id).await?;
let hourly_usage = self.usage_tracker
.get_hourly_usage(tenant_id, Utc::now())
.await?;
if hourly_usage >= quota.queries_per_hour {
return Err(QuotaExceeded::QueryLimit);
}
Ok(())
}
}
Tenant-Specific Retention Policies
// hodei-audit-service/src/retention.rs
/// Retención por Tipo de Tenant
pub struct TenantRetentionPolicy {
pub tenant_id: TenantId,
pub policies: Vec<RetentionRule>,
}
#[derive(Debug, Clone)]
pub struct RetentionRule {
pub event_type: EventType, // MANAGEMENT, DATA, INSIGHT
pub min_retention: Duration, // Mínimo según compliance
pub max_retention: Duration, // Máximo según regulación
pub auto_delete: bool, // Auto-delete después del max
pub legal_hold: bool, // Nunca eliminar (legal hold)
}
impl RetentionPolicy {
pub async fn apply_retention(
&self,
storage: &TieredStorage,
) -> Result<RetentionReport> {
let mut deleted_count = 0u64;
let mut moved_count = 0u64;
// 1. Identificar eventos expirados
let expired_events = self.identify_expired_events(storage).await?;
// 2. Para cada evento, decidir acción
for event in expired_events {
let rule = self.get_rule_for_event(&event);
if rule.legal_hold {
// No eliminar - legal hold
continue;
}
if rule.auto_delete {
// Eliminar definitivamente
storage.delete_event(&event.event_id).await?;
deleted_count += 1;
} else {
// Mover a tier más frío
storage.move_to_next_tier(vec![event]).await?;
moved_count += 1;
}
}
Ok(RetentionReport {
deleted_count,
moved_count,
timestamp: Utc::now(),
})
}
}
/// Configuración de Retención por Tipo de Tenant
// Tenant Enterprise (PCI-DSS, SOC2)
const ENTERPRISE_RETENTION: &[RetentionRule] = &[
RetentionRule {
event_type: EventType::Management,
min_retention: Duration::days(2555), // 7 años
max_retention: Duration::days(2555),
auto_delete: false,
legal_hold: true,
},
RetentionRule {
event_type: EventType::Data,
min_retention: Duration::days(2555),
max_retention: Duration::days(2555),
auto_delete: false,
legal_hold: true,
},
];
// Tenant SME (startup, business)
const SME_RETENTION: &[RetentionRule] = &[
RetentionRule {
event_type: EventType::Management,
min_retention: Duration::days(365), // 1 año
max_retention: Duration::days(1825), // 5 años
auto_delete: true,
legal_hold: false,
},
RetentionRule {
event_type: EventType::Data,
min_retention: Duration::days(90), // 90 días
max_retention: Duration::days(365), // 1 año
auto_delete: true,
legal_hold: false,
},
];
Tenant Isolation en gRPC API
// gRPC: Todos los endpoints requieren tenant_id
#[async_trait]
impl AuditControlService for AuditService {
async fn publish_event(
&self,
request: tonic::Request<PublishEventRequest>,
) -> Result<tonic::Response<()>, tonic::Status> {
let request = request.into_inner();
// 1. Extraer tenant context
let tenant_id = extract_tenant_id(&request)?;
let tenant_context = self.auth_service
.get_tenant_context(&tenant_id)
.await
.map_err(|e| tonic::Status::unauthenticated(e.to_string()))?;
// 2. Verificar permisos
if !tenant_context.has_permission("audit:write") {
return Err(tonic::Status::permission_denied(
"Insufficient permissions"
));
}
// 3. Verificar quota
self.quota_enforcer
.check_write_quota(&tenant_id, 1)
.await
.map_err(|e| tonic::Status::resource_exhausted(e.to_string()))?;
// 4. Enforce RLS (añadir tenant filter)
let event = request.event.ok_or_else(|| {
tonic::Status::invalid_argument("Missing event")
})?;
// Forzar tenant_id en el evento
let mut event = event;
event.tenant_id = tenant_id.to_string();
// 5. Store con tenant isolation
self.storage.insert_event(event)
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
Ok(tonic::Response::new(()))
}
async fn query_events(
&self,
request: tonic::Request<AuditQuery>,
) -> Result<tonic::Response<AuditQueryResult>, tonic::Status> {
let mut query = request.into_inner();
// 1. Extraer tenant context
let tenant_id = extract_tenant_id(&query)?;
let tenant_context = self.auth_service
.get_tenant_context(&tenant_id)
.await
.map_err(|e| tonic::Status::unauthenticated(e.to_string()))?;
// 2. Verificar permisos
if !tenant_context.has_permission("audit:read") {
return Err(tonic::Status::permission_denied(
"Insufficient permissions"
));
}
// 3. Verificar quota
self.quota_enforcer
.check_query_quota(&tenant_id)
.await
.map_err(|e| tonic::Status::resource_exhausted(e.to_string()))?;
// 4. Forzar tenant filter (no permitir query de otros tenants)
query.tenant_id = Some(tenant_id.to_string());
// 5. Ejecutar query con RLS
let result = self.query_engine
.query_with_rls(query, &tenant_context)
.await
.map_err(|e| tonic::Status::internal(e.to_string()))?;
Ok(tonic::Response::new(result))
}
}
Métricas por Tenant
// Prometheus: Métricas por tenant_id
pub struct TenantMetrics {
pub events_written: IntCounterVec, // Total eventos escritos
pub queries_executed: IntCounterVec, // Total queries ejecutadas
pub storage_used: GaugeVec, // Almacenamiento usado
pub api_requests: HistogramVec, // Latencia de API
pub rate_limit_hits: IntCounterVec, // Rate limit hits
}
impl TenantMetrics {
pub fn record_event_written(&self, tenant_id: &TenantId, count: u32) {
self.events_written
.with_label_values(&[tenant_id.as_str()])
.inc_by(count as u64);
}
pub fn record_query(&self, tenant_id: &TenantId, duration_ms: f64) {
self.queries_executed
.with_label_values(&[tenant_id.as_str()])
.inc();
self.api_requests
.with_label_values(&[tenant_id.as_str()])
.observe(duration_ms);
}
}
// Grafana: Dashboard por tenant
// - Events rate (by tenant)
// - Storage usage (by tenant)
// - Query latency (by tenant)
// - API errors (by tenant)
🔐 Tenant Isolation
// hodei-audit-service/src/tenancy.rs
/// Tenant context in requests
pub struct TenantContext {
pub tenant_id: TenantId,
pub user_id: UserId,
pub permissions: Vec<Permission>,
}
impl TenantContext {
pub fn require_permission(&self, permission: &str) -> Result<(), AuthError> {
if !self.permissions.iter().any(|p| p.name == permission) {
return Err(AuthError::InsufficientPermissions(permission.to_string()));
}
Ok(())
}
}
🛡️ Row-Level Security (ClickHouse)
-- Enable RLS
ALTER TABLE audit_events MODIFY SETTING allow_experimental_row_level_security = 1;
-- Tenant isolation function
CREATE FUNCTION is_authorized FOR SELECT
RETURNS BOOLEAN AS
(
tenant_id = getUserSetting('current_tenant_id', 'String')
OR hasRole('audit:admin')
);
-- Apply policy
CREATE POLICY tenant_isolation ON audit_events
FOR ALL
USING is_authorized()
WITH CHECK is_authorized();
🔑 API Key Management
// hodei-audit-service/src/auth.rs
pub struct ApiKey {
pub id: String,
pub tenant_id: TenantId,
pub name: String,
pub permissions: Vec<Permission>,
pub rate_limit: RateLimit,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
}
pub struct AuthService {
key_store: Arc<ApiKeyStore>,
}
impl AuthService {
pub async fn validate_api_key(
&self,
key: &str,
) -> Result<TenantContext, AuthError> {
let api_key = self.key_store
.get_key(key)
.await
.ok_or(AuthError::InvalidKey)?;
// Check expiration
if let Some(expires) = api_key.expires_at {
if Utc::now() > expires {
return Err(AuthError::ExpiredKey);
}
}
Ok(TenantContext {
tenant_id: api_key.tenant_id,
user_id: api_key.user_id,
permissions: api_key.permissions,
})
}
}
8. Alto Rendimiento e Ingesta
⚡ Optimizaciones de Rendimiento
1. Batching Inteligente
// Local batching in SDK
pub struct SmartBatcher {
buffer: Arc<Mutex<Vec<AuditEvent>>>,
batch_size: usize,
flush_interval: Duration,
client: AuditControlServiceClient<tonic::transport::Channel>,
}
impl SmartBatcher {
pub async fn add_event(&self, event: AuditEvent) {
let mut buffer = self.buffer.lock().unwrap();
buffer.push(event);
if buffer.len() >= self.batch_size {
self.flush().await;
}
}
async fn flush(&self) {
let mut buffer = self.buffer.lock().unwrap();
if buffer.is_empty() {
return;
}
let events = std::mem::take(&mut *buffer);
// Async flush
let client = self.client.clone();
tokio::spawn(async move {
if let Err(e) = client
.publish_batch(PublishBatchRequest {
tenant_id: events[0].tenant_id.to_string(),
events,
options: Some(BatchOptions {
flush_immediately: true,
..Default::default()
}),
})
.await
{
error!("Failed to flush audit batch: {}", e);
}
});
}
}
2. Connection Pooling
// gRPC connection pool
pub struct GrpcConnectionPool {
clients: Arc<Mutex<Vec<AuditControlServiceClient<tonic::transport::Channel>>>>,
max_size: usize,
min_size: usize,
}
impl GrpcConnectionPool {
pub async fn new(url: &str, max_size: usize, min_size: usize) -> Result<Self> {
let mut clients = Vec::new();
// Pre-warm connections
for _ in 0..min_size {
let client = AuditControlServiceClient::connect(url).await?;
clients.push(client);
}
Ok(Self {
clients: Arc::new(Mutex::new(clients)),
max_size,
min_size,
})
}
pub async fn get_client(&self) -> AuditControlServiceClient<tonic::transport::Channel> {
let mut clients = self.clients.lock().unwrap();
if let Some(client) = clients.pop() {
client
} else {
// Create new connection
AuditControlServiceClient::connect(self.url).await.unwrap()
}
}
pub fn return_client(&self, client: AuditControlServiceClient<tonic::transport::Channel>) {
let mut clients = self.clients.lock().unwrap();
if clients.len() < self.max_size {
clients.push(client);
}
}
}
📊 Benchmarks de Rendimiento
| Métrica | Objetivo | Hardware |
|---|---|---|
| Throughput | 100K eventos/segundo | 4 vCPU, 8GB RAM |
| Latencia p95 | < 10ms (SDK → Service) | Direct gRPC |
| Latencia p99 | < 50ms (end-to-end) | Local network |
| Storage | 10TB/año | ClickHouse |
| Query latency | < 100ms | Indexed queries |
| Availability | 99.9% | Multi-AZ |
9. Implementación por Fases
📅 Roadmap Híbrido: 10-12 Semanas
Fase 1: Fundación (Semanas 1-2)
- ✅ Diseño y documentación
- 🔄 Implementación AuditTrail trait
- 🔄 HRN system design
- 📝 ClickHouse setup e schema
- 📝 gRPC definitions (proto)
Deliverables:
AuditTrailtrait en verified-permissions- HRN parser y resolver
- ClickHouse schema con HRN
- gRPC service definitions
Fase 2: Core Service (Semanas 3-4)
- 🔄 gRPC Audit Service (ingestion + query)
- 🔄 ClickHouse implementation
- 🔄 Event enrichment with HRN
- 📝 SDK v1 (basic middleware)
Deliverables:
hodei-audit-servicecorriendo- HRN resolution
- Basic SDK para Axum
- 1 app integrando SDK
Fase 3: Buffer para Spikes (Semana 5)
- 🔄 PersistentBuffer implementation
- 🔄 Backpressure handling
- 🔄 Vector.dev buffer (disk buffer persistente)
- 🔄 Flush policies
Deliverables:
- Capacidad para 10K+ events/sec
- Zero-loss buffering
- Spikes handling
- 3 apps con Buffer
Fase 4: Event Bus para Fan-out (Semana 6)
- 🔄 EventBus interno
- 🔄 gRPC subscription service
- 🔄 Multi-subscriber delivery
- 🔄 Subject-based filtering
Deliverables:
- Fan-out a 3+ consumers
- Real-time subscriptions
- Subject routing
- 5 apps usando event bus
Fase 5: Zero-Loss (Semana 7)
- 🔄 Multi-tier persistence
- 🔄 Atomic commits
- 🔄 Retry policies
- 🔄 Failure recovery
Deliverables:
- Zero event loss
- Crash recovery
- Compliance-ready
- 5+ apps en modo crítico
Fase 6: Replay Service (Semana 8)
- 🔄 Replay endpoints
- 🔄 Time-based replay
- 🔄 Replay sessions
- 🔄 Speed control
Deliverables:
- Event replay
- Compliance audits
- Post-hoc analysis
- 100% traceability
Fase 7: Multi-tenancy (Semana 9)
- 🔄 Tenant isolation
- 🔄 API Key management
- 🔄 Row-level security
- 📝 Advanced auth (OIDC)
Deliverables:
- Multi-tenant API
- Key management system
- Security hardening
- 10+ apps integrando
Fase 8: Escalabilidad (Semana 10)
- 🔄 Batching optimization
- 🔄 Connection pooling
- 🔄 Performance tuning
- 📝 Auto-scaling config
Deliverables:
- 100K+ events/sec
- Load testing reports
- Auto-scaling setup
- 15+ apps usando SDK
Fase 9: Observabilidad (Semanas 11-12)
- �� Prometheus metrics
- 🔄 Grafana dashboards
- 🔄 Alerting rules
- 📝 Documentation
Deliverables:
- Full observability stack
- SLO dashboards
- Alert configurations
- Complete docs
- Production ready
🎯 Milestones Críticos
Semana 1-2: Foundation
├── AuditTrail trait ✓
├── HRN system ✓
└── gRPC proto ✓
Semana 3-4: Core Service
├── gRPC service ✓
├── Storage backend ✓
└── SDK v1 ✓
Semana 5-6: Multi-tenancy
├── Tenant isolation ✓
├── API keys ✓
└── Security ✓
Semana 7-8: Scale
├── Batching ✓
├── Performance ✓
└── Load testing ✓
Semana 9-10: Observe
├── Metrics ✓
├── Dashboards ✓
└── Launch ✓
10. Despliegue y DevOps
🐳 Docker Compose (gRPC)
# docker-compose.audit-grpc.yml
version: '3.8'
services:
# ────────────────────────────────────────────────
# ClickHouse Cluster
# ────────────────────────────────────────────────
clickhouse:
image: clickhouse/clickhouse-server:23.8
environment:
- CLICKHOUSE_DB=audit
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
volumes:
- ch_data:/var/lib/clickhouse
- ./config/clickhouse/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
ports:
- "8123:8123" # HTTP API
- "9000:9000" # Native protocol
networks:
- audit_net
# ────────────────────────────────────────────────
# Hodei Audit Service (gRPC)
# ────────────────────────────────────────────────
audit-service:
build:
context: ./hodei-audit-service
dockerfile: Dockerfile
ports:
- "50052:50052" # Ingestion gRPC
- "50053:50053" # Query gRPC
environment:
- RUST_LOG=info
- CLICKHOUSE_URL=clickhouse:8123
- CLICKHOUSE_DB=audit
- METRICS_PORT=9090
depends_on:
- clickhouse
networks:
- audit_net
deploy:
replicas: 2
resources:
limits:
cpus: '2'
memory: 4G
# ────────────────────────────────────────────────
# Prometheus Metrics
# ────────────────────────────────────────────────
prometheus:
image: prom/prometheus:v2.47
ports:
- "9090:9090"
volumes:
- ./config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
networks:
- audit_net
# ────────────────────────────────────────────────
# Grafana Dashboards
# ────────────────────────────────────────────────
grafana:
image: grafana/grafana:10.1
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana_data:/var/lib/grafana
- ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards
- ./config/grafana/datasources:/etc/grafana/provisioning/datasources
networks:
- audit_net
volumes:
ch_data:
prometheus_data:
grafana_data:
networks:
audit_net:
driver: bridge
📊 ClickHouse Schema
-- config/clickhouse/schema.sql
-- Create audit events table
CREATE TABLE IF NOT EXISTS audit_events (
event_id String,
tenant_id String,
hrn String,
correlation_id String,
source_service String,
user_id String,
action String,
method String,
path String,
status_code UInt16,
outcome String,
latency_ms UInt64,
metadata_json String,
timestamp DateTime64(3) CODEC(DoubleDelta, ZSTD),
processed_at DateTime64(3) CODEC(DoubleDelta, ZSTD),
enriched Bool DEFAULT 0
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (tenant_id, hrn, timestamp)
TTL timestamp + INTERVAL 1 YEAR DELETE;
-- Create HRN metadata table
CREATE TABLE IF NOT EXISTS hrn_metadata (
hrn String,
display_name String,
description String,
tags_json String,
owner String,
created_at DateTime64(3),
updated_at DateTime64(3)
) ENGINE = MergeTree()
ORDER BY hrn;
-- Indexes
CREATE INDEX idx_tenant_hrn ON audit_events (tenant_id, hrn);
CREATE INDEX idx_user ON audit_events (user_id);
CREATE INDEX idx_timestamp ON audit_events (timestamp);
🔄 CI/CD Pipeline
# .github/workflows/audit-grpc.yml
name: Deploy Hodei Audit Service (gRPC)
on:
push:
branches: [main]
tags: ['v*']
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Build audit service
run: |
cd hodei-audit-service
cargo build --release
- name: Build SDK
run: |
cd hodei-audit-sdk
cargo build --release
- name: Run tests
run: |
cd hodei-audit-service
cargo test --release
deploy:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
docker-compose -f docker-compose.audit-grpc.yml up -d
- name: Run integration tests
run: |
cd tests/integration
cargo test --release
env:
AUDIT_SERVICE_URL: http://localhost:50052
deploy-prod:
runs-on: ubuntu-latest
needs: deploy
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy to production
run: |
# kubectl or your deployment mechanism
echo "Deploying to production..."
11. Métricas y Observabilidad
📊 Métricas Prometheus
// hodei-audit-service/src/metrics.rs
pub struct AuditMetrics {
pub events_received: IntCounterVec,
pub events_published: IntCounterVec,
pub events_failed: IntCounterVec,
pub batch_size: Histogram,
pub processing_latency: Histogram,
pub query_duration: Histogram,
pub active_connections: Gauge,
}
impl AuditMetrics {
pub fn new() -> Self {
let events_received = IntCounterVec::new(
Opts::new("audit_events_received_total", "Total events received")
.namespace("hodei_audit"),
&["tenant_id", "service", "event_type"]
).unwrap();
register_metrics![
&events_received,
].unwrap();
Self {
events_received,
events_published: IntCounterVec::new(
Opts::new("audit_events_published_total", "Total published events")
.namespace("hodei_audit"),
&["tenant_id"]
).unwrap(),
events_failed: IntCounterVec::new(
Opts::new("audit_events_failed_total", "Total failed events")
.namespace("hodei_audit"),
&["tenant_id", "error_type"]
).unwrap(),
batch_size: Histogram::with_opts(
HistogramOpts::new(
"audit_batch_size",
"Size of audit batches"
).namespace("hodei_audit")
.buckets(vec![1, 10, 50, 100, 500, 1000])
).unwrap(),
processing_latency: Histogram::with_opts(
HistogramOpts::new(
"audit_processing_duration_seconds",
"Time spent processing events"
).namespace("hodei_audit")
.buckets(vec![0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0])
).unwrap(),
query_duration: Histogram::with_opts(
HistogramOpts::new(
"audit_query_duration_seconds",
"Time spent querying events"
).namespace("hodei_audit")
).unwrap(),
active_connections: Gauge::new(
"audit_active_connections",
"Number of active gRPC connections"
).unwrap(),
}
}
}
📈 Grafana Dashboards
{
"dashboard": {
"title": "Hodei Audit Service - Overview (gRPC)",
"panels": [
{
"title": "Events Rate (by tenant)",
"type": "graph",
"targets": [
{
"expr": "sum(rate(hodei_audit_events_received_total[5m])) by (tenant_id)",
"legendFormat": "{{tenant_id}}"
}
]
},
{
"title": "Batch Size Distribution",
"type": "heatmap",
"targets": [
{
"expr": "rate(hodei_audit_batch_size_sum[5m]) / rate(hodei_audit_batch_size_count[5m])",
"legendFormat": "avg_batch_size"
}
]
},
{
"title": "Processing Latency (p50, p95, p99)",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.50, rate(hodei_audit_processing_duration_seconds_bucket[5m]))",
"legendFormat": "p50"
},
{
"expr": "histogram_quantile(0.95, rate(hodei_audit_processing_duration_seconds_bucket[5m]))",
"legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.99, rate(hodei_audit_processing_duration_seconds_bucket[5m]))",
"legendFormat": "p99"
}
]
}
]
}
}
12. Casos de Uso
🎯 Casos de Uso Principales
1. Auditoría Automática de APIs
// Ejemplo de uso en verified-permissions
use hodei_audit_sdk::{AuditConfig, AuditLayer};
#[tokio::main]
async fn main() -> Result<()> {
let config = AuditConfig::builder()
.service_name("verified-permissions")
.tenant_id("tenant-123")
.audit_service_url("http://audit-service:50052")
.batch_size(100)
.enable_request_body(true)
.hrn_resolver(resolver)
.build()?;
let app = Router::new()
.route("/v1/policy-stores", get(list_policy_stores))
.route("/v1/policy-stores", post(create_policy_store))
.route("/v1/policy-stores/:id", get(get_policy_store))
.route("/v1/policy-stores/:id", put(update_policy_store))
.route("/v1/policy-stores/:id", delete(delete_policy_store))
.layer(config.layer()) // <- Auto-audits todas las requests!
.layer(AuthLayer);
println!("🚀 Verified Permissions running on http://0.0.0.0:50051");
axum::Server::bind(&"0.0.0.0:50051".parse()?)
.serve(app.into_make_service())
.await?;
Ok(())
}
2. HRN Query y Analytics
# Buscar todos los eventos para un HRN específico
curl -X POST "http://audit-service:50053/v1/query" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"hrn": "hrn:hodei:verified-permissions:tenant-123:global:policy-store/default"
}'
# Analytics: Requests por HRN
curl -X POST "http://audit-service:50053/v1/analytics" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"metric": "REQUEST_COUNT",
"group_by": "hrn",
"start_time": "2024-01-01T00:00:00Z"
}'
# HRN hierarchy
curl -X GET "http://audit-service:50053/v1/hrn/hrn:hodei:verified-permissions:tenant-123:global:policy-store/default/hierarchy"
# Resolve HRN metadata
curl -X POST "http://audit-service:50053/v1/resolve-hrn" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"hrn": "hrn:hodei:verified-permissions:tenant-123:global:policy-store/default"
}'
3. Compliance con HRN
// Compliance para políticas críticas
fn check_policy_compliance(event: &AuditEvent) -> Result<ComplianceResult> {
// Verificar si es un policy store crítico
if event.hrn.resource_type == "policy-store"
&& event.hrn.resource_path.contains("production")
{
return Ok(ComplianceResult {
regulation: "SOC2",
requirement: "Access to production systems must be audited",
status: "compliant",
evidence: format!("Policy {} accessed", event.hrn),
});
}
// Verificar cambios en roles
if event.action.contains("role:") {
return Ok(ComplianceResult {
regulation: "GDPR",
requirement: "Role changes must be logged",
status: "compliant",
evidence: "Role modification audited",
});
}
Ok(ComplianceResult::not_applicable())
}
13. Integración con verified-permissions
🔗 Integración Directa
// verified-permissions/infrastructure/src/audit/audit_trail.rs
/// Audit Trail que usa gRPC directo
pub struct GrpcAuditTrail {
client: AuditControlServiceClient<tonic::transport::Channel>,
config: AuditConfig,
}
impl GrpcAuditTrail {
pub async fn new(audit_service_url: String) -> Result<Self> {
let client = AuditControlServiceClient::connect(audit_service_url).await?;
Ok(Self {
client,
config: AuditConfig::default(),
})
}
}
#[async_trait]
impl AuditTrail for GrpcAuditTrail {
async fn log(&self, event: AuditEvent) -> Result<(), AuditError> {
// Convert domain event to audit event with HRN
let audit_event = self.enrich_with_hrn(event)?;
let request = PublishEventRequest {
tenant_id: audit_event.tenant_id.to_string(),
event: Some(audit_event),
options: None,
};
self.client
.clone()
.publish_event(tonic::Request::new(request))
.await
.map_err(|e| AuditError::PublishError(e.to_string()))?;
Ok(())
}
async fn log_batch(&self, events: Vec<AuditEvent>) -> Result<(), AuditError> {
if events.is_empty() {
return Ok(());
}
// Enrich all events
let enriched_events: Result<Vec<_>, _> = events
.into_iter()
.map(|e| self.enrich_with_hrn(e))
.collect();
let enriched_events = enriched_events?;
let request = PublishBatchRequest {
tenant_id: enriched_events[0].tenant_id.to_string(),
events: enriched_events,
options: Some(BatchOptions {
flush_immediately: true,
..Default::default()
}),
};
self.client
.clone()
.publish_batch(tonic::Request::new(request))
.await
.map_err(|e| AuditError::PublishError(e.to_string()))?;
Ok(())
}
}
🏗️ verified-permissions Main
// verified-permissions/main/src/main.rs
#[tokio::main]
async fn main() -> Result<()> {
// 1. Initialize audit service client
let audit_trail = Arc::new(
GrpcAuditTrail::new(
std::env::var("AUDIT_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:50052".to_string())
).await?
);
// 2. Initialize authorization control service
let auth_control = AuthorizationControlService::new(
event_store,
audit_trait,
// ... other deps
);
// 3. Setup gRPC server
let auth_control_service = AuthControlServiceServer::new(auth_control);
let addr = "0.0.0.0:50051".parse()?;
tonic::transport::Server::builder()
.add_service(auth_control_service)
.serve(addr)
.await?;
Ok(())
}
📊 Events con HRN
// verified-permissions/domain/src/events.rs
/// Policy Store Accessed Event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyStoreAccessedEvent {
pub event_id: String,
pub tenant_id: TenantId,
pub hrn: Hrn, // HRN del policy store
pub user_id: UserId,
pub access_type: AccessType,
pub timestamp: DateTime<Utc>,
}
impl PolicyStoreAccessedEvent {
pub fn new(
tenant_id: TenantId,
policy_store_id: String,
user_id: UserId,
access_type: AccessType,
) -> Self {
let hrn = Hrn::parse(&format!(
"hrn:hodei:verified-permissions:{}:global:policy-store/{}",
tenant_id,
policy_store_id
)).unwrap();
Self {
event_id: Uuid::new_v4().to_string(),
tenant_id,
hrn,
user_id,
access_type,
timestamp: Utc::now(),
}
}
}
🔍 Query Events
// verified-permissions/api/src/grpc/control_plane.rs
pub async fn list_policy_stores(
request: tonic::Request<ListPolicyStoresRequest>,
service: Arc<AuthorizationControlService>,
) -> Result<tonic::Response<ListPolicyStoresResponse>, tonic::Status> {
let user_id = extract_user_id(&request)?;
let tenant_id = extract_tenant_id(&request)?;
// Get policy stores
let stores = service.list_policy_stores(tenant_id.clone()).await?;
// Log access with HRN
let audit_event = AuditEvent {
event_id: Uuid::new_v4().to_string(),
tenant_id: tenant_id.clone(),
hrn: Hrn::parse(&format!(
"hrn:hodei:verified-permissions:{}:global:policy-store/list",
tenant_id
)).unwrap(),
user_id,
action: "list_policy_stores".to_string(),
timestamp: Utc::now(),
..Default::default()
};
service.audit_trail.log(audit_event).await;
// Return response
let response = ListPolicyStoresResponse {
policy_stores: stores,
};
Ok(tonic::Response::new(response))
}
🎉 Conclusiones
✅ Beneficios del Ecosistema gRPC
-
Desarrollador
- SDK 1-liner
- Auto-captura con HRN
- gRPC directo, async, no-bloqueante
- Type safety total
-
Organización
- 60% reducción costo logging vs soluciones cloud
- Compliance automático
- HRN para trazabilidad completa
- Business Intelligence integrado
-
Operación
- 100K+ events/sec throughput
- 99.9% availability
- gRPC directo = simple debugging
- Observabilidad completa
📚 Próximos Pasos
- Aprobar arquitectura gRPC ✅
- Implementar Fase 1 (AuditTrail + HRN)
- Setup ClickHouse
- Crear SDK v1
- MVP en 2-3 semanas
📖 Documentación Adicional
🎉 Mejoras Implementadas: Crítica Constructiva y Evolución
📋 Resumen de Mejoras Aplicadas
Basado en la crítica constructiva, hemos implementado las siguientes mejoras:
1. AWS CloudTrail: Análisis Funcional y Patrones (Sección 0)
Problema Identificado: Necesitábamos una referencia arquitectónica sólida para audit logging.
Solución Implementada:
- ✅ Event Taxonomy completa: Management, Data, Insight events
- ✅ HRN vs CloudTrail ARNs: Comparativa detallada con ventajas claras
- ✅ Taxonomía de Eventos Hodei: ManagementEvent, DataEvent, InsightEvent
- ✅ EventID, ReadOnly, AdditionalEventData: Patrones CloudTrail adoptados
- ✅ Error Code/Message: Captura de errores para compliance
- ✅ CloudTrail-Compatible Event: Estructura completa con UserIdentity, Resources, etc.
- ✅ ManagementCategory: Write/Read/Configuration separation
Beneficio: Arquitectura basada en patrones probados en CloudTrail, evitando reinvenciones.
2. Digest Criptográfico de Integridad (Sección 0) - CRÍTICO ✅
Problema Identificado: Tamper-evidence no estaba implementado (DEAL-BREAKER).
Solución Implementada:
/// Digest Worker usando ed25519-dalek - IMPLEMENTACIÓN COMPLETA
pub struct DigestWorker {
signing_key: Arc<ed25519_dalek::SigningKey>, // Clave privada
s3_client: Arc<S3Client>,
last_digest: Option<HodeiDigestFile>, // Para chain
}
Flujo de Integridad CRÍTICO:
- Hourly digest de archivos Parquet ✅
- SHA-256 de cada archivo ✅
- Chain de hashes (previous digest → current) ✅
- Firma ed25519 de digest completo ✅
- Verificación por auditors ✅
- gRPC API para verificación ✅
- CLI tool para auditoría manual ✅
- Kubernetes CronJob para ejecución automática ✅
Beneficio: Tamper-evidence verificable para compliance (SOC2, PCI-DSS) - SISTEMA VIABLE PARA PRODUCCIÓN
3. CAP/ARP: Terminología Arquitectónica (Sección 6.5)
Problema Identificado: Faltaba terminología clara para componentes.
Solución Implementada:
- CAP (Centralized Audit Point): Servicio central (puertos 50052-50054)
- ARP (Audit Reporting Point): SDK middleware en cada app
- Analogía con PDP/PEP: Patrón conocido y understood
- Responsabilidades claras: Separation of concerns
- Deployment patterns: CAP centralizado, ARPs distribuidos
Beneficio: Comunicación más clara y arquitectura más maintainable.
4. Multi-Tenancy desde Día 1 (Sección 7)
Problema Identificado: Multi-tenancy no era first-class.
Solución Implementada:
Tenant-First Architecture:
pub struct AuditEvent {
pub tenant_id: TenantId, // SIEMPRE PRIMERO
// ... resto de campos
}
Shared Store + RLS:
- ✅ Shared table con tenant_id
- ✅ Row-Level Security en ClickHouse
- ✅ API Key Management por tenant
- ✅ Resource Quotas por tenant
- ✅ Retention Policies por tipo de tenant
- ✅ Métricas segmentadas por tenant
Compliance por Tipo de Tenant:
- Enterprise: 7 años retención (PCI-DSS, SOC2)
- SME: 1-5 años retención (configurable)
Beneficio: Escalabilidad multi-tenant desde el diseño, no como after-thought.
🔍 Crítica Constructiva: Puntos Fuertes y Débiles
Puntos Fuertes ✅
-
CloudTrail Pattern Adoption:
- Digest chain para tamper-evidence
- Event taxonomy madura
- HRN superior a ARNs (multi-tenant nativo)
-
gRPC + Vector Architecture:
- Simplicidad operacional con Vector
- Type safety total
- Debugging directo
- Disk buffer persistente
-
3-Tier Storage + Vector.dev:
- Cost optimization (Vector managed)
- Performance optimizada (5-10ms latencia)
- Disk buffer nativo en Vector
-
Multi-Tenancy Nativo:
- Shared store efficiency
- Row-level security
- Per-tenant quotas y retention
Áreas de Mejora ⚠️
-
Operational Complexity:
- 3-tier storage = menos componentes
- Digest worker = dependencia Vault
- Vector.dev simplifica debugging
-
Cost Per Tenant:
- API key management = overhead de infra
- Metrics per tenant = storage cost
- Retention policies = compliance cost
-
Migration Path:
- Verified-permissions no tiene multi-tenancy actual
- Migrar a shared store requiere data migration
- HRN adoption = breaking change para apps existentes
💡 Recomendaciones de Implementación
Fase 1: Foundation (Weeks 1-3)
graph LR
A[HRN System] --> B[CAP Service]
B --> C[Digest Worker]
C --> D[ClickHouse]
- Implementar HRN parser y resolver
- Desplegar CAP service básico
- Setup ClickHouse con shared tables
- CronJob para Digest Worker
Fase 2: Multi-Tenancy (Weeks 4-5)
graph LR
A[API Keys] --> B[RLS Policies]
B --> C[Quotas]
C --> D[Retention]
- API Key management por tenant
- Row-Level Security en ClickHouse
- Resource quotas enforcement
- Retention policies por tipo de tenant
Fase 3: Advanced (Weeks 6-8)
graph LR
A[Digest Chain] --> B[Crypto Verify]
B --> C[Audit Compliance]
C --> D[Production Ready]
- Cryptographic chain completa
- Verification APIs para auditors
- Compliance reporting automático
- Production hardening
🎯 Conclusiones Finales
Arquitectura Recomendada
Stack Tecnológico:
- gRPC Direct (simplicidad)
- CAP/ARP Pattern (claridad)
- 4-Tier Storage (costo/performance)
- CloudTrail Patterns (maturez)
- Multi-Tenant Nativo (escalabilidad)
ROI Esperado
| Métrica | Antes | Después | Mejora |
|---|---|---|---|
| Cost per TB | $1000/mes | $370/mes | 63% ↓ |
| Compliance Time | 2 semanas | 1 día | 93% ↓ |
| Setup Time | 3 meses | 2 semanas | 80% ↓ |
| Query Latency | 100ms | <10ms | 90% ↓ |
Key Takeaways
- CloudTrail = Gold Standard: Adopta patrones probados, no reinventes
- Digest Chain = Essential: Tamper-evidence es crítico para compliance
- CAP/ARP = Clarity: Terminología clara mejora comunicación
- Multi-Tenant = From Day 1: No como after-thought
- gRPC = Simplicity: Menor complejidad que event buses
📚 Próximos Pasos
- ✅ Aprobación de la arquitectura mejorada
- 📝 Implementar Fase 1 (HRN + CAP)
- 🔒 Setup Cryptographic infrastructure (Vault)
- 🏗️ Desplegar ClickHouse + S3/MinIO
- 🚀 Launch MVP en 4 semanas
Document Version: 3.0 (CloudTrail + CAP/ARP + Multi-Tenant)
Last Updated: 2024-01-15
Architecture: gRPC Direct + CloudTrail Patterns + Multi-Tenant
License: MIT
What's inside
13 sections, 3 event categories, 4 component descriptions, 6 code examples, 2 digest worker implementations
Change this for your project
- Replace
Rubentxu/hodei-verified-permissionswith your own repository name - Replace
Hodei Audit Servicewith your service name - Replace
hrn:prefix in HRN format with your resource naming scheme - Replace
hodei-audit-service/src/crypto/digest.rswith your module path
Where it goes
Save in docs/ or the repository root. Gives agents and new contributors a map of the codebase.
Worth borrowing
- Separate audit reporting point (SDK) from central audit point (CAP) to decouple concerns
- Use Vector as a resilient persistence layer with disk buffer to guarantee no event loss
- Abstract key management behind a trait to allow swapping between standalone and Vault/KMS implementations
Related Documents
Design Document: BharatSeva AI
Describes a 10-agent AWS system that helps India's informal workers access government schemes via voice-first, serverless architecture.
OpenClaw Enterprise Transformation Plan
Transforms a single-user AI agent into a dual-mode platform supporting both viral open-source and Fortune 500 enterprise deployments through phased security, IAM, audit, multi-tenancy, and Kubernetes features.
Qwen Image and Edit: Open-sourcing and Local GGUF Generations with Lightning
Documents the Qwen-Image and Qwen-Image-Edit models, covering architecture, training, benchmarks, ComfyUI setup, and prompting techniques for local GGUF deployment.
University of Guelph Rocketry Club - Complete Tech Stack
Documents the full tech stack of a university rocketry club website with AI chatbot, member management, and project showcases.