Saltar a contenido

0021. Limpieza programada de sesiones y tokens (expiración)

Estado: Propuesto Fecha: 2026-09-08 Autor: Duval Alcivar Módulo(s) afectado(s): svc-identidad (trx_refresh_tokens, trx_pre_tokens, log_sesiones)

Contexto

trx_refresh_tokens y log_sesiones (0005) nunca se borran ("se revocan, no se borran"), y trx_pre_tokens (0018) crece con cada login. Sin purga la tabla crece sin límite: backups más pesados, índices lentos y la ventana de replay-detección (0018) pierde sentido si el historial es infinito. El cron general del proyecto (transversal 0015, APScheduler) se fijó, pero falta el qué purgar y con qué retención en identidad.

Decisión

Un job APScheduler diario (app/jobs/purga_sesiones.py) que ejecuta detección idiomática en lotes, con retención por tabla y sin usar el rol de la API (el DELETE masivo global no puede pasar por el RLS de app.current_empresa_id).

1. Retención por tabla

Tabla Qué se borra Retención Volumen esperado en calma
trx_refresh_tokens revocados o expirados 30 días después de revocar/expirar solo la última ventana de sesiones
trx_pre_tokens usados o expirados 24 h ~1 día de logins
log_sesiones particionada por mes 12 meses online; lo viejo se suelta (DETACH/DROP) historial de auditoría reciente

Por qué 30 días de retención en refresh: la ventana de replay detection (0011/0018) depende de poder comparar el hash de un token reusado; borrar antes recorta esa capacidad forense.

2. Ejecución segura (RLS y sin bloqueos)

  • El job corre con un rol de mantenimiento (identidad_purga) con BYPASSRLS en las tres tablas — nunca expuesto a la API (un segundo pool de conexiones). La política RLS por app.current_empresa_id (0005) no puede borrar de todas las empresas desde una sola sesión; es lo que justifica el rol dedicado.
  • Borrado en lotes (loop de DELETE ... WHERE ctid IN (SELECT ... ORDER BY ... LIMIT 5000) con LOCK_TIMEOUT) para no dejar locks largos ni saturar autovacuum de una sola pasada.
  • Idempotente y multi-réplica: el arranque del job envuelve el cuerpo en pg_advisory_lock (p.ej. identidad_purga), de modo que con N réplicas solo una corre (transversal 0015, §4).
# app/jobs/purga_sesiones.py
from sqlalchemy import text
from sqlalchemy.orm import Session

RETENCION_REFRESH_DIAS = 30      # trx_refresh_tokens
RETENCION_PRE_TOKEN_HORAS = 24   # trx_pre_tokens
LOTE = 5000


def purgar_en_lotes(session: Session, sql: str, params: dict) -> int:
    total = 0
    while True:
        n = session.execute(text(sql), params).rowcount
        session.commit()
        total += n
        if n < LOTE:
            return total


def purga_sesiones(dbj: Session) -> dict:
    """dbj = pool del rol identidad_purga (BYPASSRLS). El advisory lock se
    toma y libera en la misma sesión que borra (con N réplicas solo corre una)."""
    conn = dbj.connection()
    conn.execute(text("SELECT pg_advisory_lock(731001)"))
    try:
        borrados = {
            "refresh": purgar_en_lotes(
                dbj,
                "DELETE FROM core.identidad.trx_refresh_tokens t "
                "WHERE ctid IN (SELECT ctid FROM core.identidad.trx_refresh_tokens "
                "               WHERE revocado = TRUE "
                "                  OR expiracion < now() - make_interval(days => :retencion) "
                "               LIMIT :lote)",
                {"retencion": RETENCION_REFRESH_DIAS, "lote": LOTE},
            ),
            "pre_token": purgar_en_lotes(
                dbj,
                "DELETE FROM core.identidad.trx_pre_tokens t "
                "WHERE ctid IN (SELECT ctid FROM core.identidad.trx_pre_tokens "
                "               WHERE usado_en IS NOT NULL "
                "                  OR expiracion < now() - make_interval(hours => :retencion) "
                "               LIMIT :lote)",
                {"retencion": RETENCION_PRE_TOKEN_HORAS, "lote": LOTE},
            ),
        }
    finally:
        conn.execute(text("SELECT pg_advisory_unlock(731001)"))
    return borrados

Nota: DELETE con LIMIT no es sintaxis válida de PostgreSQL; se borra por lotes vía ctid IN (SELECT ctid ... LIMIT :lote). log_sesiones no se toca con DELETE masivo: se particiona por mes (transversal 0014/0015, patrón audit_log) y el job solo suelta particiones más viejas de 12 meses (DETACH/DROP). El advisory lock hace que con N réplicas solo corra una.

3. Programación

# app/main.py
scheduler.add_job(
    purga_sesiones,
    CronTrigger(hour=3, minute=0),   # 03:00 America/Guayaquil (transversal 0015)
    id="purga-sesiones-identidad",
    replace_existing=True,
)

4. Monitoreo

  • Métrica identidad_purga_filas{tabla="..."} (lo borrado por corrida) y identidad_purga_duracion_ms.
  • Alerta si el job no corre en 48 h: la tabla vuelve a crecer sin que nadie lo note; alerta de fail si el advisory lock no se adquiere.

Alternativas descartadas

  • Purgar "lazy" en cada request — coste irregular y bloqueos en el hot path del login/refresh; la tabla además se mantiene grande entre accesos.
  • Sin purga — trx_refresh_tokens crece sin límite (cada login + rotación); backups y índices se degradan; la detección de replay se ahoga en datos viejos.
  • Cron del SO / CronJob de Kubernetes — descartado por el transversal 0015 (portabilidad, lógica no versionada).
  • DELETE masivo de una sola pasada — locks Hold fuertes y autovacuum a ráfagas; los lotes de 5000 lo mantienen negociable.
  • Usar el rol de la API con RLS — no puede: cada sesión ve una sola empresa (app.current_empresa_id); de ahí el rol identidad_purga con BYPASSRLS, fuera del alcance HTTP.

Consecuencias

  • Las tablas volátiles de identidad quedan con tamaño acotado y predecible; los backups dejan de crecer por sesiones fantasmas.
  • La ventana forense de replay (0018) queda explícitamente en 30 días para refresh y 24 h para pre_tokens.
  • log_sesiones conserva 12 meses online y suelta lo viejo por partición, sin tocar los datos con DELETE masivos.
  • Costo: un rol de base dedicado adicional (identidad_purga, BYPASSRLS) que nunca aparece en el código HTTP; hay que mantenerlo en la provisión del despliegue (ADR 0016).

Este ADR aplica el patrón del transversal 0015 (APScheduler, advisory lock, particiones del 0014 transversal) a las tablas de sesión del 0005 y del 0018. La caducidad que esta purga barre la fija el 0020.