Saltar a contenido

Especificación Técnica: log_sesiones

Esquema: core.identidad
Base de Datos: core
Servicio: svc-identidad
Propósito: Bitácora transaccional de intentos y eventos de sesión (login/logout/fallos).


1. Justificación y Mejoras de Arquitectura

  • Registro liviano orientado a escritura intensiva (audit created).
  • Capacidad de correlación por token_jti e IP en diagnósticos.
  • RLS por empresa actual mantiene la confidencialidad de la bitácora.

2. Definiciones de Implementación

Table core.identidad.log_sesiones {
    id                         uuid          [pk, default: `gen_random_uuid()`]
    empresa_id                 bigint        [not null]
    usuario_id                 uuid          [not null, ref: > mae_usuarios.id]
    accion                     varchar(20)  
    token_jti                  uuid         
    ip                         varchar(45)  
    user_agent                 varchar(300) 
    exitoso                    boolean      
    razon_fallo                varchar(100) 

    // Auditoría General
    created_at              timestamptz   [not null, default: `now()`, note: 'Fecha de creación']

    // RLS por empresa actual
}
CREATE TABLE IF NOT EXISTS core.identidad.log_sesiones (
    id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
    empresa_id BIGINT NOT NULL,
    usuario_id UUID NOT NULL,
    accion VARCHAR(20),
    token_jti UUID,
    ip VARCHAR(45),
    user_agent VARCHAR(300),
    exitoso BOOLEAN,
    razon_fallo VARCHAR(100),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT fk_log_sesiones_usuario FOREIGN KEY (usuario_id) REFERENCES core.identidad.mae_usuarios (id)
);

-- Row-Level Security
ALTER TABLE core.identidad.log_sesiones ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.log_sesiones
    FOR ALL
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);

-- Comentarios
COMMENT ON COLUMN core.identidad.log_sesiones.empresa_id IS 'Empresa de la sesión';
COMMENT ON COLUMN core.identidad.log_sesiones.usuario_id IS 'Usuario involucrado';
COMMENT ON COLUMN core.identidad.log_sesiones.accion IS 'Acción registrada (login, logout, fallo)';
COMMENT ON COLUMN core.identidad.log_sesiones.token_jti IS 'Identificador del JWT emitido';
COMMENT ON COLUMN core.identidad.log_sesiones.ip IS 'IP origen';
COMMENT ON COLUMN core.identidad.log_sesiones.user_agent IS 'User-Agent del cliente';
COMMENT ON COLUMN core.identidad.log_sesiones.exitoso IS 'Resultado de la acción';
COMMENT ON COLUMN core.identidad.log_sesiones.razon_fallo IS 'Motivo del fallo';
from datetime import datetime, date
from typing import Optional
import uuid

from sqlalchemy import (
    BigInteger,
    Boolean,
    CheckConstraint,
    Date,
    DateTime,
    ForeignKey,
    Identity,
    Index,
    Integer,
    SmallInteger,
    String,
    Text,
    UniqueConstraint,
    text,
)
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass

class LogSesion(Base):
    __tablename__ = 'log_sesiones'
    __table_args__ =         {"schema": 'core.identidad'},

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        primary_key=True,
        server_default=text("gen_random_uuid()"),
    )
    empresa_id: Mapped[int] = mapped_column(
        BigInteger,
        nullable=False,
        comment='Empresa de la sesión',
    )
    usuario_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        nullable=False,
        ForeignKey("mae_usuarios.id"),
        comment='Usuario involucrado',
    )
    accion: Mapped[str | None] = mapped_column(
        String(20),
        comment='Acción registrada (login, logout, fallo)',
    )
    token_jti: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True),
        comment='Identificador del JWT emitido',
    )
    ip: Mapped[str | None] = mapped_column(
        String(45),
        comment='IP origen',
    )
    user_agent: Mapped[str | None] = mapped_column(
        String(300),
        comment='User-Agent del cliente',
    )
    exitoso: Mapped[bool | None] = mapped_column(
        Boolean,
        comment='Resultado de la acción',
    )
    razon_fallo: Mapped[str | None] = mapped_column(
        String(100),
        comment='Motivo del fallo',
    )

    # Auditoría General
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        server_default=text("now()"),
        comment='Fecha de creación',
    )
"""revision: core_id_0012
create table core.identidad.log_sesiones
"""

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


revision: str = 'core_id_0012'
down_revision: str | None = 'core_id_0011'
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
    op.create_table(
        'log_sesiones',
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("empresa_id", sa.BigInteger(), nullable=False),
        sa.Column("usuario_id", postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column("accion", sa.String(length=20)),
        sa.Column("token_jti", postgresql.UUID(as_uuid=True)),
        sa.Column("ip", sa.String(length=45)),
        sa.Column("user_agent", sa.String(length=300)),
        sa.Column("exitoso", sa.Boolean()),
        sa.Column("razon_fallo", sa.String(length=100)),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
        sa.ForeignKeyConstraint(['usuario_id'], ['core.identidad.mae_usuarios.id'], name='fk_log_sesiones_usuario'),
        schema='core.identidad',
    )

    op.execute(
        """
        ALTER TABLE core.identidad.log_sesiones ENABLE ROW LEVEL SECURITY;
        CREATE POLICY aislamiento_empresa
            ON core.identidad.log_sesiones
            FOR ALL
            USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
        """
    )


def downgrade() -> None:
    op.drop_table('log_sesiones', schema='core.identidad')