Guía de respuestas y soluciones razonadas: Módulo 05 — Seguridad y operación#
Esta guía técnica de referencia presenta las soluciones canónicas, el análisis formal de arquitectura de seguridad, los modelos de cálculo estadístico de percentiles, los patrones de migración de datos sin tiempo de inactividad, las matrices forenses de diagnóstico transversal y las respuestas exhaustivas a las preguntas de diagnóstico socrático para las lecciones 16 a 18 del curso.
Intenta resolver primero cada ejercicio y responder las preguntas por tu cuenta antes de consultar esta guía. El aprendizaje profundo en seguridad y operaciones de sistemas no proviene de memorizar directivas o configuraciones de servidor, sino de comprender los mecanismos físicos y lógicos que operan en los límites de red, motores de persistencia y navegadores web.
Para profundizar en cualquier término técnico utilizado en estas soluciones, consulta el glosario técnico.
Lección 16 — Autenticación, autorización y sesiones#
1. Modelo de amenazas (Threat Model) formal por capas#
Para diseñar la seguridad de una API de recursos privados (como una plataforma de notas o registros confidenciales), construimos una matriz exhaustiva de amenazas según la metodología STRIDE / OWASP:
| Actor de Amenaza | Vector de Ataque | Activo Vulnerable | Impacto y Severidad | Control Defensivo Arquitectónico |
|---|---|---|---|---|
| Usuario Autenticado Malicioso (Usuario 9) | Manipulación de identificadores directos de objetos (BOLA/IDOR). Envío de peticiones PATCH /notes/481 con "userId": 7 forjado en el cuerpo JSON. | Integridad y confidencialidad de la nota 481 (propiedad del usuario 7). | Crítica (CVSS 8.5): Modificación o lectura no autorizada de recursos ajenos. | Verificación obligatoria de propiedad por recurso: El backend extrae el Principal exclusivamente del contexto de sesión seguro e incorpora AND owner_id = $2 en la cláusula WHERE del UPDATE SQL. |
Sitio Web Malicioso de Terceros (evil.com) | Falsificación de Petición en Sitios Cruzados (CSRF). Induce al navegador de la víctima a emitir un formulario POST /notes aprovechando cookies activas. | Integridad de la cuenta y estado del usuario en el backend. | Alta: Creación de recursos o mutaciones no deseadas en nombre del usuario. | Atributos de cookie y tokens: Configuración de SameSite=Lax o SameSite=Strict en la cookie de sesión, combinada con validación de cabeceras Sec-Fetch-Site: same-origin y tokens anti-CSRF criptográficos. |
| Script Inyectado en Navegador Víctima | Secuestro de credenciales mediante Cross-Site Scripting (XSS). Acceso a document.cookie para exfiltrar tokens de sesión. | Confidencialidad de la sesión activa del usuario. | Crítica: Suplantación de identidad completa en cualquier cliente remoto. | Atributo HttpOnly y CSP: La directiva HttpOnly impide el acceso programático desde JavaScript; la Política de Seguridad de Contenido (CSP) restringe los orígenes autorizados para ejecutar scripts. |
| Atacante de Red (Man-in-the-Middle) | Intercepción de tráfico HTTP en redes Wi-Fi públicas no cifradas. Captura de identificadores de sesión en tránsito. | Confidencialidad de la sesión en red. | Crítica: Secuestro inmediato de sesión activa. | Cifrado TLS y atributo Secure: Imponer HTTPS estricto con cabecera Strict-Transport-Security (HSTS) y directiva Secure obligatoria en toda cookie sensible. |
| Atacante con volcado de base de datos | Ataque de diccionario y tablas arcoíris contra contraseñas filtradas. | Credenciales maestras de los usuarios. | Catastrófica: Compromiso masivo de cuentas. | Hashing lento adaptativo: Almacenamiento exclusivo con Argon2id (costo de memoria: 64 MB, iteraciones: 3, paralelismo: 4) o bcrypt con sal aleatoria criptográfica de 16 bytes. |
2. Implementación canónica: Autorización por recurso atómica en Rust y SQL#
Consulta SQL atómica en repositorio#
Para eliminar de raíz condiciones de carrera del tipo TOCTOU (Time-Of-Check to Time-Of-Use), unificamos la verificación de pertenencia y la actualización en una única sentencia atómica de base de datos:
-- Actualización atómica condicionada por propiedad:
UPDATE notes
SET title = $1, updated_at = NOW()
WHERE id = $2 AND owner_id = $3;
Si la nota 481 no existe, o si pertenece al usuario 7 pero el owner_id provisto por el Principal es 9, PostgreSQL reporta que la consulta modificó exactamente 0 filas. El servicio traduce este resultado directamente a un error de autorización sin filtrar datos.
Código ejecutable en Rust estándar (rustc --test)#
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NoteId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticatedPrincipal {
pub user_id: UserId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
pub id: NoteId,
pub owner_id: UserId,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthzError {
NotFound,
Forbidden,
}
pub struct SecureNoteRepository {
storage: HashMap<NoteId, Note>,
}
impl SecureNoteRepository {
pub fn new() -> Self {
Self { storage: HashMap::new() }
}
pub fn seed(&mut self, note: Note) {
self.storage.insert(note.id, note);
}
pub fn get_note_secure(
&self,
principal: &AuthenticatedPrincipal,
note_id: NoteId,
) -> Result<&Note, AuthzError> {
let note = self.storage.get(¬e_id).ok_or(AuthzError::NotFound)?;
if note.owner_id != principal.user_id {
return Err(AuthzError::Forbidden);
}
Ok(note)
}
/// Simulates atomic execution of:
/// UPDATE notes SET title = $1 WHERE id = $2 AND owner_id = $3
pub fn update_title_atomic(
&mut self,
principal: &AuthenticatedPrincipal,
note_id: NoteId,
new_title: &str,
) -> Result<&Note, AuthzError> {
let note = self.storage.get_mut(¬e_id).ok_or(AuthzError::NotFound)?;
if note.owner_id != principal.user_id {
return Err(AuthzError::Forbidden);
}
note.title = new_title.to_string();
Ok(note)
}
}
3. Configuración integral de cookies, sesiones y defensa anti-CSRF#
Una cookie de sesión segura debe emitirse mediante el siguiente encabezado HTTP:
Set-Cookie: sid=s%3A_a8f9c2d1e4b7890a3c2e1f4d; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400; Domain=example.com
Anatomía de directivas de seguridad:#
Secure: El navegador solo transmitirá la cookie a través de conexiones cifradas con TLS (https://), impidiendo el espionaje en texto plano.HttpOnly: Prohíbe terminantemente la lectura de la cookie mediante APIs del DOM (document.cookie), protegiendo el identificador contra scripts inyectados mediante ataques XSS.SameSite=Lax: El navegador retiene la cookie en peticiones mutantes cruzadas (POSToPUToriginadas en sitios de terceros), previniendo ataques CSRF comunes, mientras permite que el usuario mantenga su sesión activa si hace clic en un enlace legítimo entrante (GET).- *Rotación obligatoria del identificador de sesión (Session Fixation Defense):
- En el instante exacto en que un usuario anónimo introduce credenciales válidas en
POST /api/login, el servidor debe destruir el identificador de sesión anónimo anterior* y emitir uno completamente nuevo generado mediante un generador criptográfico seguro (CSPRNG). - Doble verificación Anti-CSRF para operaciones mutantes:
- Para peticiones AJAX/Fetch que modifiquen estado, la aplicación debe requerir un token anti-CSRF transmitido en un encabezado personalizado (ej.
X-CSRF-Token: <token>) o validar la cabecera estándarSec-Fetch-Site: same-origin. Dado que los sitios web de terceros no pueden forzar la inclusión de cabeceras personalizadas en peticiones cross-origin simples sin una negociación CORS previa que será denegada, la protección es total.
4. Matriz comparativa y arquitectónica: XSS vs CSRF vs CORS#
| Dimensión de Análisis | XSS (Cross-Site Scripting) | CSRF (Cross-Site Request Forgery) | CORS (Cross-Origin Resource Sharing) |
|---|---|---|---|
| ¿Qué es conceptualmente? | Vulnerabilidad de inyección de código ejecutable (JavaScript) en el cliente. | Vulnerabilidad de explotación de credenciales ambientales automáticas del navegador. | Mecanismo de relajación de la Política del Mismo Origen (Same-Origin Policy). No es una vulnerabilidad ni un control de acceso del servidor. |
| Mecanismo del navegador abusado | Ejecución de scripts no confiables en el contexto de seguridad del origen legítimo. | El comportamiento del navegador de adjuntar automáticamente cookies Cookie: sid=... a cualquier petición dirigida al dominio de origen de la cookie. | Scripts que intentan inspeccionar el contenido de respuestas HTTP provenientes de un origen ajeno sin autorización de cabeceras. |
| ¿Quién ejecuta la llamada? | El script del atacante que corre dentro de la página de la víctima. | El navegador de la víctima inducido por un formulario o imagen alojado en un sitio malicioso externo. | El navegador validando si permite a un script en frontend.com leer la respuesta de api.backend.com. |
| Impacto real | Acceso total al DOM, robo de credenciales en localStorage, ejecución de cualquier acción autorizada en el origen. | Ejecución de acciones de escritura (transferencias, cambio de contraseña) sin que el usuario sea consciente. | Si se configura Access-Control-Allow-Origin: * con credenciales activas, terceros pueden leer datos privados del usuario. |
| Mitigación efectiva | Sanitización y codificación contextual de salida; CSP estricta; atributo HttpOnly. | Cookies con SameSite=Lax/Strict; tokens anti-CSRF criptográficos; verificación de Origin. | Configurar listas blancas explícitas de orígenes autorizados; nunca exponer comodines (*) con credenciales. |
5. Respuestas exhaustivas a las preguntas socráticas de la Lección 16#
Pregunta 1: La falacia de la seguridad del lado del cliente (UI vs API)#
- Inciso A (Mecanismo del atacante): El atacante abre la consola del sistema o una herramienta de red (como Postman o
curl) y emite directamente:
curl -X DELETE https://api.example.com/api/projects/42 -H "Cookie: sid=attacker_session" Dado que la petición viaja directamente sobre el socket TCP hacia el servidor web sin pasar por el motor de renderizado del navegador, el hecho de que el componente React haya retornado null para el botón "Eliminar proyecto" es completamente irrelevante.
- Inciso B (Clasificación OWASP y regla de frontera): Se manifiesta la vulnerabilidad BOLA / IDOR (Broken Object Level Authorization, Top 1 de OWASP API Security). La regla de frontera violada es: "Toda decisión de autorización debe ser evaluada y aplicada de forma autoritativa en el servidor para cada solicitud individual; la interfaz de usuario solo ofrece ergonomía visual, jamás seguridad".
Pregunta 2: Condiciones de carrera TOCTOU en autorización#
- Inciso A (Intercalado destructivo):
- Transacción 1 (Usuario expulsado): El servicio ejecuta
SELECT owner_id, team_id FROM documents WHERE id = 10;. Comprueba que en ese milisegundo el usuario aún figura como miembro del equipo. - Transacción 2 (Administrador concurrente): Justo antes del siguiente paso, el administrador ejecuta
DELETE FROM team_members WHERE user_id = 9; COMMIT;, revocando de inmediato los permisos del usuario. - Transacción 1 (Mutación retrasada): El servicio del usuario expulsado reanuda su ejecución y corre
UPDATE documents SET body = '...' WHERE id = 10; COMMIT;. La mutación se persiste con éxito a pesar de que el usuario ya no tenía permiso alguno.
- Inciso B (Consulta SQL atómica mitigadora):
UPDATE documents
SET body = $1, updated_at = NOW()
WHERE id = $2 AND team_id IN (
SELECT team_id FROM team_members WHERE user_id = $3
);
Esta consulta evalúa la membresía dentro del mismo bloqueo de fila de la sentencia UPDATE. Si el usuario fue expulsado, la subconsulta retorna vacío y la mutación afecta 0 filas, cerrando la ventana TOCTOU.
Pregunta 3: Compromiso de divulgación: 403 Forbidden vs 404 Not Found#
- Inciso A (Información filtrada con
403): Si el servidor responde403 Forbiddenpara recursos ajenos existentes y404 Not Foundpara recursos inexistentes, un atacante que recorra iterativamente los identificadores/api/therapy-sessions/1a1000sabrá con absoluta certeza qué IDs corresponden a sesiones de terapia reales activas en el sistema, lo que representa una gravísima fuga de metadatos confidenciales (Resource Enumeration / Harvesting). - Inciso B (Costo operativo y de soporte con
404): Si un paciente legítimo experimenta un error de permisos o un desajuste temporal en su sesión e intenta abrir una sesión de terapia que sí le pertenece, recibirá un404 Not Found. El paciente llamará a soporte técnico reportando que sus historiales fueron "borrados del sistema", lo que complica enormemente el diagnóstico y genera alarma innecesaria. - Recomendación: Utilizar
404 Not Foundde forma consistente para entidades personales privadas (ocultando existencia), y reservar403 Forbiddenpara recursos de espacio compartido donde la existencia de la entidad ya es pública para los involucrados.
Pregunta 4: La anatomía de CSRF y el alcance real de HttpOnly#
- Inciso A (Falsedad de la afirmación): La afirmación es completamente falsa. El atributo
HttpOnlysolo bloquea el acceso a la cookie desde el entorno de ejecución de JavaScript en el DOM (document.cookie). Sin embargo, en un ataque CSRF tradicional, el sitio malicioso no necesita leer la cookie: incluye un formulario HTML<form action="https://bank.com/transfer" method="POST">que se envía automáticamente. El navegador, por especificación histórica, adjunta de forma automática todas las cookies pertenecientes al dominiobank.com, incluyendo las marcadas comoHttpOnly. - Inciso B (Diferencia de impacto): Con
HttpOnlypero sinSameSite, el sitio del atacante no puede leer la respuesta devuelta por el banco (la Política del Mismo Origen bloquea la lectura del iframe o de la respuesta HTTP cruzada). Sin embargo, el servidor sí ejecutó la mutación en la base de datos (la transferencia bancaria se consumó). La brecha de integridad ocurre a pesar de que la confidencialidad de la respuesta permanezca intacta.
Pregunta 5: Funciones de hash criptográfico vs Derivación de claves adaptativas#
- Inciso A (Debilidad de SHA-256): Algoritmos como SHA-256 o SHA-512 están diseñados matemáticamente para maximizar el rendimiento computacional (procesar gigabytes por segundo en hardware simple). Si un atacante roba el archivo de contraseñas, una única tarjeta gráfica moderna (GPU) puede calcular más de 5,000 a 8,000 millones de hashes SHA-256 por segundo. Incluso con una sal aleatoria, el atacante puede romper contraseñas humanas comunes en cuestión de horas o minutos mediante ataques de fuerza bruta paralelos masivos.
- Inciso B (Propiedades de Argon2id y bcrypt):
Argon2ides un algoritmo con memoria dura (memory-hard). Requiere intencionalmente que cada intento de cálculo ocupe una cantidad significativa de memoria RAM (por ejemplo, 64 Megabytes) y múltiples pasadas temporales. Las GPUs y los chips ASIC cuentan con miles de núcleos pero una cantidad muy limitada de memoria rápida por núcleo: al exigir 64 MB de RAM por cada intento, se satura el ancho de banda del bus de memoria del chip, reduciendo la velocidad de cálculo del atacante de miles de millones de intentos por segundo a unos pocos cientos, volviendo financieramente inviable la rotura por fuerza bruta.
Lección 17 — Entrega, operación y observabilidad#
1. Cálculo exacto de percentiles y análisis de colas pesadas#
Consideremos el conjunto exacto de 100 observaciones ordenadas de latencia:
- 95 solicitudes tardaron 20 ms.
- 4 solicitudes tardaron 40 ms.
- 1 solicitud tardó 3000 ms.
Aritmética paso a paso:#
- Tiempo total acumulado:
Total = (95 × 20 ms) + (4 × 40 ms) + (1 × 3000 ms) = 1900 + 160 + 3000 = 5060 ms
- Media aritmética:
Media = (5060 ms) / (100) = 50.6 ms
- Cálculo de percentiles por el método Nearest-Rank:
Fórmula de posición ordinal: k = ⌈ p × n ⌉, con n = 100:
-
p50(Mediana): k = ⌈ 0.50 × 100 ⌉ = 50 arrow Muestra en posición 50 = 20 ms. -p95: k = ⌈ 0.95 × 100 ⌉ = 95 arrow Muestra en posición 95 = 20 ms (es la última muestra del bloque de 20 ms). -p96: k = ⌈ 0.96 × 100 ⌉ = 96 arrow Muestra en posición 96 = 40 ms (primera muestra del bloque de 40 ms). -p99: k = ⌈ 0.99 × 100 ⌉ = 99 arrow Muestra en posición 99 = 40 ms (última muestra del bloque de 40 ms). -p100/ Máximo: k = ⌈ 1.00 × 100 ⌉ = 100 arrow Muestra en posición 100 = 3000 ms.
Demostración del desplazamiento de frontera (Boundary Shift):#
¿Qué ocurre si solo una de las peticiones de 20 ms se ralentiza a 40 ms?
- Nueva distribución: 94 solicitudes de 20 ms, 5 solicitudes de 40 ms y 1 solicitud de 3000 ms.
- Nuevo total: 1880 + 200 + 3000 = 5080 ms.
- Nueva media: (5080) / (100) = 50.8 ms (un incremento imperceptible de solo 0.2 ms).
- Nuevo
p95:
k = ⌈ 0.95 × 100 ⌉ = 95. Dado que las muestras de 20 ms ahora solo cubren las posiciones 1 a 94, la posición 95 contiene ahora una muestra de 40 ms. ¡El percentil p95 reportado en el tablero de control salta bruscamente de 20 ms a 40 ms, duplicando su valor!
2. Implementación en Rust estándar: Percentiles y telemetría estructurada sanitizada#
El siguiente módulo completo compila y se verifica con rustc --test:
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct LatencyDistribution {
sorted_ms: Vec<u64>,
}
impl LatencyDistribution {
pub fn new(mut samples: Vec<u64>) -> Result<Self, &'static str> {
if samples.is_empty() {
return Err("Sample vector cannot be empty");
}
samples.sort_unstable();
Ok(Self { sorted_ms: samples })
}
pub fn total(&self) -> u64 {
self.sorted_ms.iter().sum()
}
pub fn mean(&self) -> f64 {
self.total() as f64 / self.sorted_ms.len() as f64
}
pub fn nearest_rank_percentile(&self, p: f64) -> u64 {
assert!(p > 0.0 && p <= 1.0, "p must be in (0.0, 1.0]");
let n = self.sorted_ms.len() as f64;
let rank = (p * n).ceil() as usize;
let clamped = rank.clamp(1, self.sorted_ms.len());
self.sorted_ms[clamped - 1]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SafeLogEvent {
pub request_id: String,
pub method: String,
pub path: String,
pub status: u16,
pub duration_ms: u64,
pub safe_headers: HashMap<String, String>,
}
impl SafeLogEvent {
pub fn build_sanitized(
request_id: &str,
method: &str,
path: &str,
status: u16,
duration_ms: u64,
raw_headers: &[(&str, &str)],
) -> Self {
let mut safe_headers = HashMap::new();
for (key, val) in raw_headers {
let lower = key.to_ascii_lowercase();
if lower == "authorization" || lower == "cookie" || lower == "set-cookie" || lower == "x-api-key" {
safe_headers.insert(key.to_string(), "[REDACTED]".to_string());
} else {
safe_headers.insert(key.to_string(), val.to_string());
}
}
Self {
request_id: request_id.to_string(),
method: method.to_string(),
path: path.to_string(),
status,
duration_ms,
safe_headers,
}
}
}
#[cfg(test)]
mod tests_lesson17 {
use super::*;
#[test]
fn verify_latency_calculations() {
let mut samples = Vec::with_capacity(100);
samples.extend(std::iter::repeat(20).take(95));
samples.extend(std::iter::repeat(40).take(4));
samples.extend(std::iter::repeat(3000).take(1));
let dist = LatencyDistribution::new(samples).unwrap();
assert_eq!(dist.total(), 5060);
assert!((dist.mean() - 50.6).abs() < 1e-9);
assert_eq!(dist.nearest_rank_percentile(0.50), 20);
assert_eq!(dist.nearest_rank_percentile(0.95), 20);
assert_eq!(dist.nearest_rank_percentile(0.96), 40);
assert_eq!(dist.nearest_rank_percentile(0.99), 40);
assert_eq!(dist.nearest_rank_percentile(1.00), 3000);
}
#[test]
fn verify_sanitization() {
let headers = [
("Authorization", "Bearer secret_session_token"),
("Cookie", "sid=secret"),
("Content-Type", "application/json"),
];
let event = SafeLogEvent::build_sanitized("req-123", "GET", "/notes", 200, 20, &headers);
assert_eq!(event.safe_headers.get("Authorization").unwrap(), "[REDACTED]");
assert_eq!(event.safe_headers.get("Cookie").unwrap(), "[REDACTED]");
assert_eq!(event.safe_headers.get("Content-Type").unwrap(), "application/json");
}
}
3. Matriz arquitectónica: Sondas de vida (Liveness) vs Disponibilidad (Readiness)#
| Característica | Sonda de Vida (Liveness Probe) | Sonda de Disponibilidad (Readiness Probe) |
|---|---|---|
| Pregunta fundamental | "¿Sigue el proceso en ejecución o está congelado en un deadlock?" | "¿Está el contenedor capacitado para recibir y procesar tráfico de red ahora?" |
| Ámbito de inspección | Estrictamente local y en memoria: bucle de eventos activo, ausencia de bloqueos internos en hilos de Tokio. | Verificación de dependencias: Conectividad con PostgreSQL (SELECT 1), caché Redis disponible, migraciones aplicadas. |
| Acción del orquestador si falla | Envía SIGKILL y reinicia el contenedor de forma destructiva. | Desconecta el contenedor del balanceador de carga. No mata el proceso. |
| Peligro de mala configuración | Si consulta la base de datos central, cualquier congestión provoca una *tormenta de reinicios en cascada (Liveness Storm)* que colapsa toda la infraestructura. | Si no se implementa, peticiones de clientes llegarán a contenedores que aún no han inicializado sus pools de conexiones, produciendo errores 502/503 evitables. |
4. Plan de migración de base de datos en 5 fases (Expand-and-Contract)#
Evolución de owner_id: u64 a author_identifier: VARCHAR(64) sin tiempo de inactividad:
- Fase 1 (Expand):
``
sql ALTER TABLE notes ADD COLUMN author_identifier VARCHAR(64) NULL;`` - Fase 2 (Dual Write):
Despliegue de aplicación versión v2. Escribe ambas columnas en cada inserción o edición:
``
sql INSERT INTO notes (id, title, owner_id, author_identifier) VALUES ($1, $2, $3, $4);`Las lecturas continúan consultandoowner_id`. Si se detecta un fallo, el rollback a v1 es completamente seguro. - Fase 3 (Backfill por lotes en segundo plano):
``
sql UPDATE notes SET author_identifier = 'user_' || owner_id WHERE id IN ( SELECT id FROM notes WHERE author_identifier IS NULL LIMIT 1000 );`` - Fase 4 (Switch Reads):
Despliegue de aplicación versión v3. Lee exclusivamente de
author_identifiery mantiene la doble escritura activa de respaldo. - Fase 5 (Contract):
Despliegue de versión v4 (código antiguo retirado). Se aplican restricciones de integridad y se libera almacenamiento en PostgreSQL:
``
sql ALTER TABLE notes ALTER COLUMN author_identifier SET NOT NULL; ALTER TABLE notes DROP COLUMN owner_id;``
5. Respuestas exhaustivas a las preguntas socráticas de la Lección 17#
Pregunta 1: La tiranía del promedio y la matemática de colas pesadas#
- Inciso A (Cumplimiento de SLA):
- El SLA exige: "El 99% de las peticiones debe responder en ≤ 50 ms".
- En el Servicio A, según Nearest-Rank (k = ⌈ 0.99 × 100 ⌉ = 99), la posición 99 vale 40 ms. Por lo tanto, el Servicio A CUMPLE holgadamente el SLA (40 ms ≤ 50 ms), a pesar de tener una petición aislada de 3000 ms en la posición 100.
- En el Servicio B, la posición 99 contiene una muestra de 90 ms. Por lo tanto, el Servicio B VIOLA el SLA (90 ms > 50 ms), a pesar de registrar exactamente la misma media aritmética (50.6 ms) que el Servicio A.
- Inciso B (Sensibilidad discreta en límites): En el Servicio A, las posiciones 1 a 95 son de 20 ms. Como p95 apunta exactamente a la posición ⌈ 0.95 × 100 ⌉ = 95, toma el valor 20 ms. Si tan solo una petición pasa de 20 ms a 40 ms, el conjunto de 20 ms abarca únicamente hasta la posición 94; por lo tanto, la posición 95 es ocupada por el siguiente bloque (40 ms), duplicando la métrica reportada. Esto ilustra cómo los percentiles en conjuntos finitos actúan como funciones escalonadas altamente sensibles a valores en las fronteras de corte.
Pregunta 2: El colapso en cascada de las sondas de vida (Liveness Storm)#
- Inciso A (Secuencia cronológica del colapso):
- La base de datos entra en un bloqueo transitorio de 3 segundos por mantenimiento.
- Las sondas de vida de los 20 contenedores intentan ejecutar su query SQL y agotan el timeout de 1 segundo.
- A los 2 segundos, el orquestador declara los 20 procesos como "muertos" y envía
SIGKILLa todos ellos. - Los 20 contenedores se reinician simultáneamente. Al iniciar, cada uno intenta abrir su pool de conexiones hacia PostgreSQL (20 × 20 = 400 conexiones TCP/TLS concurrentes).
- La base de datos, en lugar de recuperarse del bloqueo, se ahoga ante la avalancha de 400 intentos de conexión simultáneos (Thundering Herd).
- Las nuevas sondas de vida vuelven a fallar por timeout, encerrando al clúster en un bucle infinito de reinicios destructivos.
- Inciso B (Readiness vs Liveness): La sonda de disponibilidad (Readiness) no reinicia el contenedor; simplemente retira su IP de la tabla de enrutamiento del balanceador de carga. Si PostgreSQL se sobrecarga, los contenedores dejan de recibir tráfico de nuevos usuarios (aliviando la carga sobre la base de datos), pero los procesos se mantienen vivos en memoria, evitando el impacto devastador de los arranques en frío.
Pregunta 3: El mito del "Rollback del binario" ante migraciones de datos destructivas#
- Inciso A (Fallo del rollback ingenuo): Cuando se hace rollback al contenedor v1, el código ejecutable antiguo espera encontrar la columna
billing_addressen la tablausers. Como la migración ya ejecutóALTER TABLE users DROP COLUMN billing_address;, PostgreSQL responde a cada consulta del binario v1 con el errorcolumn "billing_address" does not exist. Todas las rutas de usuarios colapsan con500 Internal Server Error, convirtiendo un fallo parcial de la versión 2 en una catástrofe total. - Inciso B (Protección de Expand-and-Contract): Bajo el esquema de 5 fases, la columna antigua no se elimina hasta que la nueva versión esté consolidada y validada en producción durante días. En las fases 1, 2, 3 y 4, la columna antigua sigue existiendo en el motor SQL y recibiendo datos consistentes, permitiendo hacer rollback del binario a la versión previa en cualquier momento sin riesgo de incompatibilidad de esquema.
Pregunta 4: Sanitización de telemetría y límites de privacidad#
- Inciso A (Riesgos regulatorios y de seguridad): Registrar encabezados HTTP sin filtrar captura tokens de sesión (
Cookie: sid=...), credenciales de autenticación (Authorization: Bearer ...) y claves de API (X-Api-Key). Cualquier operador, auditor o atacante con acceso a la plataforma de observabilidad puede secuestrar cuentas de usuarios. Además, constituye una violación grave de los estándares PCI-DSS (si viajan datos de pago) y GDPR/CCPA (almacenamiento no consentido de datos personales en registros de depuración). - Inciso B (Diseño de la política de sanitización): La política debe censurar con
[REDACTED]las clavesAuthorization,Cookie,Set-Cookie,X-Api-Keyy cualquier campo de contraseñas. Por el contrario, debe preservar sin censura:X-Request-ID,traceparent,User-Agent, método HTTP, ruta normalizada (ej./api/notes/:iden lugar de IDs literales si son datos sensibles) y códigos de estado HTTP para garantizar trazabilidad.
Pregunta 5: Definición formal de RTO / RPO y la falacia del respaldo no probado#
- Inciso A (Cálculo del RPO): El RPO (Recovery Point Objective) mide la cantidad máxima de datos generados que se acepta perder ante un siniestro. Si los respaldos se toman exclusivamente una vez al día a las 02:00 AM y el desastre ocurre a las 01:50 AM del día siguiente, la pérdida de datos abarca 23 horas y 50 minutos de transacciones de usuarios, lo que en la mayoría de las empresas viola flagrantemente el SLA contractual.
- Inciso B (La falacia del respaldo no restaurado): Un proceso de respaldo automatizado solo prueba que un programa pudo leer bloques de disco y emitir un archivo
.dumphacia S3. Un respaldo real se demuestra únicamente cuando un sistema automatizado toma ese archivo, lo descarga en un servidor limpio, recrea el clúster de PostgreSQL, aplica los archivos de log de transacciones (WAL), y comprueba que las consultas de negocio retornan datos coherentes. Factores como incompatibilidad de extensiones, corrupción de bloques comprimidos, falta de claves de descifrado KMS o saturación de disco durante la restauración impiden con frecuencia recuperar sistemas en producción si no han sido ensayados de antemano.
Lección 18 — Diagnóstico integral: seguir evidencia por capas#
1. Matriz de diagnóstico transversal de 3 capas#
| Capa del Stack | Hecho Observable Irrefutable | Hipótesis Causal Falsable | Prueba Refutadora (Falsifier) | Cambio Mínimo Verificable |
|---|---|---|---|---|
| Capa 1: Cliente Web / DOM (TypeScript) | El usuario observa que tras guardar "Draft B" la pantalla parpadea y muestra "Draft A". La pestaña Network revela que la respuesta de A llegó 200 ms después que la de B. | La promesa de la petición lenta A resolvió en último lugar y sobrescribió el estado local de React sin verificar vigencia. | Agregar un contador secuencial monotónico (seq). Si al resolver la promesa res.seq < state.currentSeq, descartar la actualización. Si el problema persiste, la hipótesis queda refutada. | Almacenar el número de secuencia en el estado local de React o vincular un AbortController que cancele peticiones superseded. |
| Capa 2: Backend HTTP / Dominio (Rust) | Al consultar la nota en la base de datos, el registro contiene el texto de Alicia ("Draft A") a pesar de que Bob guardó "Draft B" segundos antes. | El backend carece de control de concurrencia optimista y ejecutó una mutación ciega (Lost Update). | Incorporar la cláusula AND version = $expected. Si la petición de Alicia retorna HTTP 409 Conflict, la hipótesis queda demostrada y el fallo mitigado. | Incorporar columna version en UPDATE y devolver HTTP 409 si rows_affected == 0. |
| Capa 3: Persistencia y Red (PostgreSQL + Outbox) | La nota se actualizó en PostgreSQL pero el microservicio de auditoría externo no registró ningún evento de guardado. | La llamada HTTP al servicio de auditoría ocurrió fuera de la transacción de PostgreSQL y se perdió por timeout de red (Dual-Write Problem). | Encolar el evento en una tabla outbox dentro de la transacción ACID de la nota. Si tras un corte de red el evento permanece en outbox con estado Pending, la consistencia queda garantizada. | Implementar el patrón Transactional Outbox y un worker en segundo plano con reintentos idempotentes. |
2. Implementación canónica: OCC y Transactional Outbox en Rust (rustc --test)#
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteRecord {
pub id: u64,
pub title: String,
pub version: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutboxStatus {
Pending,
Dispatched,
Failed { attempts: u32 },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboxRecord {
pub id: u64,
pub aggregate_id: u64,
pub event_type: String,
pub payload: String,
pub status: OutboxStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConcurrencyError {
NotFound,
OptimisticLockConflict { expected_version: u64, actual_version: u64 },
}
pub struct FullStackStore {
notes: HashMap<u64, NoteRecord>,
outbox: Vec<OutboxRecord>,
next_outbox_id: u64,
}
impl FullStackStore {
pub fn new() -> Self {
Self {
notes: HashMap::new(),
outbox: Vec::new(),
next_outbox_id: 1,
}
}
pub fn seed_note(&mut self, id: u64, title: &str) {
self.notes.insert(
id,
NoteRecord {
id,
title: title.to_string(),
version: 1,
},
);
}
pub fn get_note(&self, id: u64) -> Option<&NoteRecord> {
self.notes.get(&id)
}
/// Simulates:
/// BEGIN;
/// UPDATE notes SET title = $1, version = version + 1 WHERE id = $2 AND version = $3;
/// INSERT INTO outbox (aggregate_id, event_type, payload, status) VALUES ($2, 'NoteUpdated', $4, 'PENDING');
/// COMMIT;
pub fn update_note_occ(
&mut self,
id: u64,
expected_version: u64,
new_title: &str,
) -> Result<u64, ConcurrencyError> {
let note = self.notes.get_mut(&id).ok_or(ConcurrencyError::NotFound)?;
if note.version != expected_version {
return Err(ConcurrencyError::OptimisticLockConflict {
expected_version,
actual_version: note.version,
});
}
note.title = new_title.to_string();
note.version += 1;
let new_version = note.version;
let outbox_id = self.next_outbox_id;
self.next_outbox_id += 1;
self.outbox.push(OutboxRecord {
id: outbox_id,
aggregate_id: id,
event_type: "NoteUpdated".to_string(),
payload: format!("{{\"title\":\"{}\",\"version\":{}}}", new_title, new_version),
status: OutboxStatus::Pending,
});
Ok(new_version)
}
pub fn pending_outbox_count(&self) -> usize {
self.outbox
.iter()
.filter(|r| matches!(r.status, OutboxStatus::Pending))
.count()
}
pub fn process_outbox_worker(&mut self, simulate_remote_success: bool) {
for record in self.outbox.iter_mut() {
if matches!(record.status, OutboxStatus::Pending) {
if simulate_remote_success {
record.status = OutboxStatus::Dispatched;
} else {
record.status = OutboxStatus::Failed { attempts: 1 };
}
}
}
}
}
#[cfg(test)]
mod tests_lesson18 {
use super::*;
#[test]
fn verify_occ_conflict_and_outbox_atomicity() {
let mut store = FullStackStore::new();
store.seed_note(52, "Initial");
// Bob updates first (version 1 -> 2)
let v2 = store.update_note_occ(52, 1, "Draft B").unwrap();
assert_eq!(v2, 2);
// Alicia arrives late with stale version 1
let conflict = store.update_note_occ(52, 1, "Draft A");
assert!(matches!(conflict, Err(ConcurrencyError::OptimisticLockConflict { .. })));
// Verify Bob's draft remains intact
assert_eq!(store.get_note(52).unwrap().title, "Draft B");
// Outbox contains exactly 1 pending event (Bob's edit)
assert_eq!(store.pending_outbox_count(), 1);
// Dispatch outbox successfully
store.process_outbox_worker(true);
assert_eq!(store.pending_outbox_count(), 0);
}
}
3. Solución en Frontend (TypeScript): Guardia de secuencia y AbortController#
Para evitar que respuestas asíncronas lentas sobrescriban el estado visual más reciente:
interface NoteEditorState {
title: string;
version: number;
latestSequenceNumber: number;
isSaving: boolean;
error: string | null;
}
class NoteEditorController {
private currentAbortController: AbortController | null = null;
private sequenceCounter = 0;
async saveNote(noteId: string, newTitle: string, expectedVersion: number): Promise<void> {
// 1. Abort any previous pending request to save network bandwidth
if (this.currentAbortController) {
this.currentAbortController.abort();
}
this.currentAbortController = new AbortController();
// 2. Increment request sequence number
const thisSequence = ++this.sequenceCounter;
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'If-Match': `"${expectedVersion}"`, // Standard ETag / Version header
},
body: JSON.stringify({ title: newTitle }),
signal: this.currentAbortController.signal,
});
// 3. Drop stale out-of-order response if a newer request was dispatched
if (thisSequence < this.sequenceCounter) {
console.warn(`Discarding stale response sequence #${thisSequence}`);
return;
}
if (response.status === 409) {
// Trigger interactive conflict resolution UI
renderConflictDialog("Another collaborator has modified this note. Please review their changes.");
return;
}
if (!response.ok) {
throw new Error(`Server returned HTTP ${response.status}`);
}
const updated = await response.json();
updateLocalState({ title: updated.title, version: updated.version });
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') {
// Expected abort, do not alter UI
return;
}
showNetworkWarning("Connection lost. Your changes are preserved locally in your draft.");
}
}
}
4. Respuestas exhaustivas a las preguntas socráticas de la Lección 18#
Pregunta 1: La carrera asíncrona de respuestas fuera de orden en el cliente#
- Inciso A (Regresión de estado): A las 10:00:00.500, la respuesta de B finaliza con éxito y el estado local se actualiza a
"Título Largo y Completo". A las 10:00:00.900, la promesa retrasada de la Petición A resuelve y su manejador ejecuta ciegamentesetLocalTitle("Título Corto"). El texto largo que el usuario había terminado de escribir desaparece de la pantalla, siendo reemplazado por la versión incompleta anterior. Este fenómeno es una regresión de estado en la interfaz. - Inciso B (AbortController vs Reversión en servidor): Invocar
abort()cancela la recepción de bytes y rechaza la promesa en el navegador, pero no tiene ningún efecto retroactivo sobre el servidor. Si los paquetes HTTP de la Petición A ya alcanzaron el socket del backend y la consulta SQL ya ejecutóCOMMITantes de que el cliente abortara, la base de datos ya fue mutada. Por ello, la cancelación del cliente debe combinarse obligatoriamente con Control de Concurrencia Optimista (OCC) en el servidor.
Pregunta 2: Control de concurrencia optimista y código de conflicto HTTP#
- Inciso A (Filas afectadas en SQL): El Operador 1 modificó la fila primero, incrementando
versionde 4 a 5. Cuando la petición del Operador 2 ejecutaWHERE id = $2 AND version = 4, ninguna fila en la base de datos cumple ambas condiciones. PostgreSQL retornarows_affected = 0. - Inciso B (Código HTTP y comportamiento en UI): El backend debe retornar
HTTP 409 Conflictcon un cuerpo estructurado que describa la discrepancia:
{"error": "CONFLICT", "message": "Resource has been modified by another user", "current_version": 5}
La interfaz del Operador 2 jamás debe sobrescribir ciegamente los datos ni borrar el formulario: debe presentar un cuadro de diálogo de conciliación visual que muestre los cambios realizados por el Operador 1 ("En Proceso") junto al intento del Operador 2 ("Cancelado por Cliente"), permitiendo al usuario decidir conscientemente cuál conservar.
Pregunta 3: El patrón Transactional Outbox frente al problema de la doble escritura#
- Inciso A (Punto de quiebre en doble escritura): Si el servidor ejecuta
tx.commit()y milisegundos después intentaaudit_client.post(...), el proceso puede fallar por un corte de red, un timeout o la caída del contenedor. La base de datos ya confirmó el débito de $100 dólares, pero el evento jamás llegó al servicio de auditoría, violando la integridad de cumplimiento legal. - Inciso B (Inversión del orden): Si el desarrollador llama primero a Kafka/HTTP y luego ejecuta
tx.commit(), cualquier violación de restricción en PostgreSQL (ej. saldo insuficiente o bloqueo) provocará unROLLBACKde la transacción SQL. Sin embargo, el mensaje en Kafka ya fue publicado y es irrevocable, provocando que los auditores investiguen una transferencia que en realidad jamás existió. - Inciso C (Garantía de Transactional Outbox): Al insertar el evento en la tabla
outboxdentro del mismo bloque transaccional de PostgreSQL (BEGIN ... INSERT INTO outbox ... COMMIT), ambas operaciones comparten el mismo motor de almacenamiento. La transacción es puramente local y ACID: o persisten ambas escrituras o no persiste ninguna. El worker en segundo plano se encarga de despachar el evento con garantía de entrega at-least-once.
Pregunta 4: La ambigüedad del timeout de red y el Problema de los Dos Generales#
- Inciso A (Los 3 estados físicos posibles ante un timeout):
- La petición se perdió en la red antes de llegar a la tarjeta de red del servidor (la orden de compra nunca se creó).
- El servidor procesó la orden, debitó el dinero de la tarjeta y guardó la compra, pero el paquete de confirmación se perdió en el cable de retorno.
- El servidor continúa procesando la transacción de forma lenta y aún no concluye la escritura en base de datos.
- Inciso B (Riesgo y mitigación con
Idempotency-Key): Si la aplicación reintenta ciegamente sin clave de idempotencia, y el servidor se encontraba en el estado 2 o 3, la segunda llamada creará una segunda orden idéntica, cobrando $1,000 dólares en lugar de $500. Al enviarIdempotency-Key: e4a7..., el servidor consulta su tabla de operaciones procesadas: si la clave ya existe, no vuelve a procesar el cobro ni a crear una orden; retorna inmediatamente la respuesta de la orden original ya confirmada.
Pregunta 5: La disciplina de la falsabilidad en el diagnóstico forense#
- Inciso A (Crítica a la hipótesis no falsable): Culpar vagamente a un bug en el compilador o en el framework es una falacia de elusión: no especifica qué condición de entrada reproducible detona el comportamiento ni qué medición en el sistema demostraría que la afirmación es incorrecta. En el 99.99% de los incidentes de producción, el fallo reside en la configuración o en la lógica de aplicación del equipo, no en el compilador.
- Inciso B (Estructura de diagnóstico en 4 cuadrantes):
- Hecho empírico observado: Durante la hora pico (100 req/s), el 5% de las peticiones a
/api/loginarrojaHTTP 500tras una demora exacta de 5000 ms. - Hipótesis causal falsable: El pool de conexiones de PostgreSQL tiene un tamaño máximo de 10 conexiones (
max_connections = 10), las consultas quedan encoladas esperando una conexión disponible y son canceladas al cumplirse el timeout de adquisición de 5 segundos. - *Prueba de refutación (Falsifier):* Elevar temporalmente el tamaño del pool a 30 conexiones o monitorear la métrica
pool.wait_duration_msypool.available_connections. Si las conexiones libres nunca llegan a 0 y los errores 500 persisten, la hipótesis queda formalmente refutada. - Evidencia decisiva esperada: Confirmar mediante métricas estructuradas que el tiempo de espera en el pool (
db_pool_wait_ms) sube a 5000 ms en las peticiones que retornan error 500.
Criterio de revisión general del módulo 05#
Una solución profesional de arquitectura y operaciones se distingue porque:
- Jamás asume que una verificación visual en el frontend protege los datos de la empresa: toda decisión de acceso se revalida atómicamente en el servidor.
- Reconoce la naturaleza no normal y asimétrica de la latencia en redes distribuidas, utilizando percentiles (
p95,p99) en lugar de medias aritméticas engañosas. - Protege la infraestructura contra tormentas de reinicios en cascada manteniendo las sondas de vida puramente locales en memoria y delegando la supervisión de dependencias a las sondas de disponibilidad.
- Diseña migraciones de base de datos desacopladas mediante el patrón Expand-and-Contract en 5 fases y reconoce que un respaldo sin simulacro de restauración es un deseo, no una salvaguarda.
- Formula hipótesis científicamente falsables y diseña sistemas tolerantes a la ambigüedad de red mediante control de concurrencia optimista, Transactional Outbox y contratos idempotentes.
Enlaces de navegación#
- Regresar a la lección 16. Autenticación, autorización y sesiones
- Regresar a la lección 17. Entrega, operación y observabilidad
- Regresar a la lección 18. Diagnóstico integral: seguir evidencia por capas
- Consultar el glosario técnico
- Volver a la ruta general en el mapa del curso