CreaRack-SL

Cifrado en reposo de secretos de canales de notificación (sa4 A2, 2ª mitad)

Resumen ejecutivo

La primera mitad de sa4 A2 redactaba los secretos de NotificationChannel.config en la API (GET devolvía REDACTED). La segunda mitad los cifra en la base de datos con Fernet (idéntico al patrón de StoredCredential.encrypted_data), de modo que un dump de BD nunca expone webhooks, tokens o Authorization headers en plaintext.

Cambio: PR #72, commit 1fe6098 (2026-06-07)

Archivos clave:

  • monitoring/services/notification_service.py — funciones encrypt_channel_config / decrypt_channel_config
  • monitoring/migrations/0021_encrypt_notification_channel_config.py — migración idempotente
  • monitoring/api/itsm.py — endpoints create_channel / update_channel ahora cifran
  • core/management/commands/reencrypt_credentials.py — ampliado para incluir NotificationChannel.config
  • Tests: tests/api/test_monitoring_sa4_sa5.py::TestChannelConfigEncryption (+3 tests)

Motivación (sa4)

sa4 (Auditoría de Seguridad A2) exigía eliminar la exposición de secretos de notificación. Se ejecutó en dos fases:

  1. 1ª mitad (ya hecha): redacción en la API (cliente ve REDACTED, no el token real)
  2. 2ª mitad (este commit): cifrado en reposo en la BD (dump de BD no expone secretos)

Antes de esta feature, un administrator que hacía backup de PostgreSQL podía ver en plaintext:

  • URLs de webhooks (https://hooks.example/token-secret)
  • Tokens de autenticación (Bearer abc123xyz)
  • Headers de autorización (X-API-Key: ...)

Ahora todos esos valores están almacenados como tokens Fernet, imposibles de leer sin la clave de desencriptación.

Patrón de cifrado

Estructura del config

NotificationChannel.config es un JSONField con estructura variable según el tipo de canal:

Webhook:

{
  "url": "https://example.com/hook",
  "headers": {
    "Authorization": "Bearer token123",
    "X-Custom": "public-value"
  }
}

Slack / Teams:

{
  "url": "https://hooks.slack.com/...",
  "token": "xoxb-..."
}

Qué se cifra

Solo valores mapeados en _SECRET_CONFIG_KEYS y _SECRET_HEADER_NAMES:

  • Keys: url, token, password, api_key, webhook_url
  • Headers: Authorization, X-API-Key, X-Auth-Token, etc.

Ejemplo post-cifrado (vista en la BD):

{
  "url": "gAAAAABm1234567890...(token Fernet)...==",
  "headers": {
    "Authorization": "gAAAAABm1234567890...(token Fernet)...==",
    "X-Custom": "public-value"
  }
}

El cliente ve (API GET con redacción + cifrado en BD):

{
  "url": "REDACTED",
  "headers": {
    "Authorization": "REDACTED",
    "X-Custom": "public-value"
  }
}

Migración de datos (monitoring/0021)

Función: encrypt_existing_configs(apps, schema_editor)

  • Itera todas las filas de NotificationChannel
  • Para cada fila con config tipo dict:
    • Llama a encrypt_channel_config(config) (idempotente)
    • Si cambió algo → guarda con update_fields=["config"]
  • Idempotencia: si ya es Fernet, no vuelve a encriptar
  • Reverse: no-op (nunca devolver a plaintext en rollback, expone secretos)

Ejecución: Dokploy la aplica automáticamente en el deploy. Si se interrumpe y repite, es segura (idempotente).

Integración con la API

create_channel

NotificationChannel.objects.create(
    ...
    config=encrypt_channel_config(data.config),  # ← Cifra antes de guardar
    ...
)

update_channel

c.config = merge_preserving_secrets(data.config, c.config)  # ← Preserva secretos si cliente envía REDACTED
c.config = encrypt_channel_config(c.config)  # ← (Re)cifra antes de guardar
c.save()

El patrón merge_preserving_secrets + encrypt_channel_config permite que un cliente:

  1. Haga GET (obtiene REDACTED)
  2. Haga cambios a otros campos (name, risk_levels, etc.)
  3. Reenvíe el JSON con REDACTED en lugar del secreto real
  4. PUT → merge preserva el secreto cifrado antiguo, encripta cualquier nuevo plaintext

Integración con el dispatcher

Todos los dispatchers (webhooks, Slack, Teams, email) ahora descifran justo antes de usar:

def _send_webhook(channel, payload):
    cfg = decrypt_channel_config(channel.config)  # ← Descifra en memoria
    url = cfg.get("url", "")  # ← URL en plaintext solo en este punto
    return _post_pinned(url, payload, extra_headers=cfg.get("headers", {}))

Ventana de exposición: reducida a solo la llamada HTTP al webhook (inevitable, el webhook debe recibirla).

Rotation de clave (reencrypt_credentials)

El command manage.py reencrypt_credentials (usado para rotar claves de cifrado) ahora incluye NotificationChannel.config:

def _process_notification_channels(self, dry_run, totals):
    for ch in NotificationChannel.objects.all().iterator():
        # Top-level secrets + headers
        new_config, changed = self._reencrypt_mapping(ch.config, ...)
        if changed and not dry_run:
            ch.config = new_config
            ch.save(update_fields=["config"])

Esto permite cambiar la clave Fernet sin perder secretos (desencripta con clave vieja, encripta con clave nueva).

Testing

Clase: tests/api/test_monitoring_sa4_sa5.py::TestChannelConfigEncryption

  1. test_secret_is_encrypted_at_rest:

    • POST /api/.../channels con URL plaintext
    • En la BD, la URL debe ser un token Fernet (verificado con CredentialManager.is_encrypted(...))
    • El plaintext “hooks.example” no aparece en el JSON almacenado
  2. test_dispatch_roundtrip_decrypts:

    • Crea config con URL + headers secretos
    • Encripta
    • Desencripta
    • Verifica que los valores originales se recuperan exactamente
    • Non-secret fields (e.g., addresses: []) quedan intactos
  3. test_encrypt_is_idempotent:

    • Encripta dos veces
    • Resultado idéntico (no dobla-encriptación)

Suite entera: 318 tests passed, 1 skipped (sa4/sa5 + cobertura general).

Huella de riesgo (smoke test en STAGE obligatorio)

El CHANGELOG advierte:

⚠️ Toca el envío de notificaciones en vivo → requiere smoke en STAGE antes del merge (footgun auth/crypto).

Riesgos residuales:

  • Clave de desencriptación perdida/corrupta → webhooks fallan silenciosamente (fallback "")
  • Migración interrumpida → filas a medio migrar (toleradas por idempotencia)
  • Versión vieja del código contra BD nueva (encriptada) → el dispatcher obtiene tokens Fernet, intenta usarlos como URL → HTTP 400 en el webhook

Smoke test cobertura:

  • Crear canal → verificar que llega cifrado a la BD
  • Enviar notificación de prueba → dispatcher desencripta y envía webhook
  • Update con REDACTED → verifica que secreto sobrevive
  • Migración en segundo plano → todos los canales existentes cifrados

Calendario

  • Desarrollo: Sesión 112 (2026-06-07)
  • Merge: PR #72 aprobado y mergeado a main (1fe6098)
  • Deploy: Dokploy en STAGE + smoke + PROD en siguiente ventana
  • Backward compatibility: Full (API redacta desde antes, BD simplemente ahora también cifra)

Véase también

  • [[entity—monitoring—service—encrypt-channel-config]]
  • [[entity—monitoring—service—decrypt-channel-config]]
  • [[entity—core—model—stored-credential]]
  • [[entity—monitoring—model—notification-channel]]
  • [[concept—saas—security-at-rest]]