Saltar a contenido

0005. Modelo de datos: usuarios, credenciales y sesiones

Estado: Propuesto Fecha: 2026-09-04 Autor: Duval Alcivar Módulo(s) afectado(s): svc-identidad (core_sigfa)

Contexto

El ADR 0001 define svc-identidad como dueño de usuarios, empresas, sucursales y permisos. Este ADR define las tablas del usuario y su acceso: la cuenta, a qué empresas puede ingresar, su alcance opcional por sucursal, sus sesiones/refresh. Los tipos de usuario (usuario/root) y los permisos individuales están en el ADR 0007; las de empresa y sucursal en el ADR 0004; la navegación (aplicaciones/módulos/menú) en el ADR 0006; los catálogos de país/ciudad/tipo de documento en el ADR 0003.

Convenciones generales (ADR 0004 global)

Convención Valor
PK id UUID (gen_random_uuid())
Columnas estándar created_at, updated_at, deleted_at, created_by, updated_by
Booleanos is_<...>is_activo
Tipos tipo_usuario_idcat_tipos_usuario (usuario | root | externo, ADR 0007/0022)
Prefijos mae_, rel_, trx_, log_, cat_ (ADR 0004 global)

Desde el ADR 0022, la identidad usa el patrón party: mae_entidades es la raíz y de ella "heredan" mae_personas (natural) y mae_personas_juridicas (jurídica externa). mae_usuarios referencia la entidad (entidad_id).


1. mae_usuarios — cuentas de usuario

Tabla global: un usuario puede pertenecer a múltiples empresas. No tiene empresa_id ni RLS porque no es de una sola empresa.

Campo Tipo Notas
id UUID PK
username VARCHAR(50) UNIQUE NOT NULL nombre de login
email VARCHAR(150) NOT NULL
password VARCHAR(255) NOT NULL hash Argon2id (ADR 0011)
password_temp BOOLEAN NOT NULL DEFAULT TRUE contraseña temporal (obliga a cambiarla)
entidad_id UUID NOT NULL FK → mae_entidades entidad asociada: persona natural o jurídica (ADR 0022)
tipo_usuario_id UUID NOT NULL FK → cat_tipos_usuario tipo de cuenta (ADR 0007/0022)
is_activo BOOLEAN DEFAULT TRUE
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL soft delete
created_by UUID
updated_by UUID
CREATE TABLE core.identidad.mae_usuarios (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username      VARCHAR(50) NOT NULL,
    email         VARCHAR(150) NOT NULL,
    password      VARCHAR(255) NOT NULL,
    password_temp   BOOLEAN NOT NULL DEFAULT TRUE,
    entidad_id      UUID NOT NULL,
    tipo_usuario_id UUID NOT NULL,
    is_activo       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT now(),
    updated_at      TIMESTAMPTZ DEFAULT now(),
    deleted_at      TIMESTAMPTZ NULL,
    created_by      UUID,
    updated_by      UUID,

    CONSTRAINT uq_mae_usuarios_username UNIQUE (username),
    CONSTRAINT fk_mae_usuarios_entidad
        FOREIGN KEY (entidad_id) REFERENCES core.identidad.mae_entidades(id),
    CONSTRAINT fk_mae_usuarios_tipo_usuario
        FOREIGN KEY (tipo_usuario_id) REFERENCES core.identidad.cat_tipos_usuario(id)
);
CREATE INDEX ix_mae_usuarios_entidad_id ON core.identidad.mae_usuarios (entidad_id);
CREATE INDEX ix_mae_usuarios_tipo_usuario_id ON core.identidad.mae_usuarios (tipo_usuario_id);
# models.py — SQLAlchemy 2.0
class MaeUsuario(Base):
    __tablename__ = "mae_usuarios"
    __table_args__ = {"schema": "core.identidad"}

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(150), nullable=False)
    password: Mapped[str] = mapped_column(String(255), nullable=False)
    password_temp: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
    entidad_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.mae_entidades.id"), nullable=False)
    tipo_usuario_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.cat_tipos_usuario.id"), nullable=False)
    entidad: Mapped["MaeEntidad"] = relationship(lazy="joined")
    tipo_usuario: Mapped["CatTipoUsuario"] = relationship(lazy="joined")
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

password guarda el hash Argon2id, password_temp = TRUE por defecto (el usuario debe cambiarla en su primer login). entidad_id referencia a mae_entidades (persona natural o jurídica); los campos de nombre/apellido o razón social viven en la entidad hija, no en el usuario. El tipo de cuenta es tipo_usuario_idcat_tipos_usuario (ADR 0007/0022).

2. mae_personas — datos personales de la persona natural

Entidad que agrupa los datos personales (nombre, documento, contacto), independiente de la cuenta de acceso. La cuenta la referencia vía entidad_id. Hereda de mae_entidades (ADR 0022): su id es el mismo de la entidad padre y no genera UUID propio.

Campo Tipo Notas
id UUID PK, FK → mae_entidades mismo id de la entidad (sin DEFAULT)
tipo_identificacion_id UUID FK → cat_tipos_identificacion opcional; catálogo global
identificacion VARCHAR(20) UNIQUE NOT NULL número de identificación
nombres VARCHAR(100) NOT NULL
apellidos VARCHAR(100) NOT NULL
fecha_nacimiento DATE opcional
genero_id UUID FK → cat_generos opcional; catálogo global
email VARCHAR(150)
telefono VARCHAR(20)
direccion VARCHAR(300)
is_activo BOOLEAN DEFAULT TRUE
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL
created_by UUID
updated_by UUID
CREATE TABLE core.identidad.mae_personas (
    id             UUID PRIMARY KEY
        REFERENCES core.identidad.mae_entidades(id),
    tipo_identificacion_id UUID,
    identificacion  VARCHAR(20) NOT NULL,
    nombres        VARCHAR(100) NOT NULL,
    apellidos      VARCHAR(100) NOT NULL,
    fecha_nacimiento DATE,
    genero_id      UUID,
    email          VARCHAR(150),
    telefono       VARCHAR(20),
    direccion      VARCHAR(300),
    is_activo      BOOLEAN DEFAULT TRUE,
    created_at     TIMESTAMPTZ DEFAULT now(),
    updated_at     TIMESTAMPTZ DEFAULT now(),
    deleted_at     TIMESTAMPTZ NULL,
    created_by     UUID,
    updated_by     UUID,

    CONSTRAINT uq_mae_personas_identificacion UNIQUE (identificacion),
    CONSTRAINT fk_mae_personas_tipo_identificacion
        FOREIGN KEY (tipo_identificacion_id)
        REFERENCES core.identidad.cat_tipos_identificacion(id),
    CONSTRAINT fk_mae_personas_genero
        FOREIGN KEY (genero_id) REFERENCES core.identidad.cat_generos(id)
);
class MaePersona(MaeEntidad):
    __tablename__ = "mae_personas"
    __table_args__ = {"schema": "core.identidad"}
    __mapper_args__ = {"polymorphic_identity": "PERSONA_NATURAL"}

    id: Mapped[uuid.UUID] = mapped_column(
        ForeignKey("core.identidad.mae_entidades.id"), primary_key=True
    )
    tipo_identificacion_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("core.identidad.cat_tipos_identificacion.id"))
    identificacion: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
    nombres: Mapped[str] = mapped_column(String(100), nullable=False)
    apellidos: Mapped[str] = mapped_column(String(100), nullable=False)
    fecha_nacimiento: Mapped[date | None] = mapped_column(Date)
    genero_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("core.identidad.cat_generos.id"))
    email: Mapped[str | None] = mapped_column(String(150))
    telefono: Mapped[str | None] = mapped_column(String(20))
    direccion: Mapped[str | None] = mapped_column(String(300))
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

2.b mae_entidades y mae_personas_juridicas (ADR 0022)

La raíz del patrón party (mae_entidades) y la persona jurídica externa (mae_personas_juridicas) se definen en el ADR 0022. Resumen:

Tabla Rol
mae_entidades Raíz polimórfica: id + tipo_entidad (PERSONA_NATURAL/PERSONA_JURIDICA)
mae_personas Subtipo natural (polymorphic_identity PERSONA_NATURAL)
mae_personas_juridicas Subtipo jurídico externo: nombre, nombre_comercial, identificacion + país/ciudad/tipo doc. (PERSONA_JURIDICA)

mae_usuarios.entidad_id apunta a mae_entidades; cada subtipo expone nombre_mostrar para consumirlo sin condicionales.

3. rel_usuario_empresa — acceso del usuario a empresas

Relación N:M usuario ↔ empresa. Define a qué empresas puede ingresar un usuario en el login (ADR 0010): es el chequeo de acceso inicial. El alta y la asignación de este acceso se gestionan en el ADR 0009. Un usuario puede tener acceso a varias empresas; la empresa no se deriva de la sucursal.

Campo Tipo Notas
id UUID PK
usuario_id UUID NOT NULL FK → mae_usuarios intra-servicio
sucursal_id UUID NOT NULL FK → mae_sucursales intra-servicio
is_activo BOOLEAN DEFAULT TRUE
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
deleted_at TIMESTAMPTZ NULL
created_by UUID
updated_by UUID

UNIQUE: (usuario_id, sucursal_id). La empresa de la sucursal se deriva de mae_sucursales.empresa_id (ADR 0004). Solo se crean filas para usuarios que deben operar con sucursales específicas.

UNIQUE: (usuario_id, empresa_id). El login valida que exista una fila activa para el empresa_id elegido. No requiere sucursales: una empresa sin sucursales sigue siendo accesible.

CREATE TABLE core.identidad.rel_usuario_empresa (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    usuario_id UUID NOT NULL,
    empresa_id BIGINT NOT NULL,
    is_activo  BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    deleted_at TIMESTAMPTZ NULL,
    created_by UUID,
    updated_by UUID,

    CONSTRAINT uq_rel_usuario_empresa UNIQUE (usuario_id, empresa_id),
    CONSTRAINT fk_rel_usuario_empresa_usuario
        FOREIGN KEY (usuario_id) REFERENCES core.identidad.mae_usuarios(id),
    CONSTRAINT fk_rel_usuario_empresa_empresa
        FOREIGN KEY (empresa_id) REFERENCES core.identidad.mae_empresas(id)
);
class RelUsuarioEmpresa(Base):
    __tablename__ = "rel_usuario_empresa"
    __table_args__ = (
        UniqueConstraint("usuario_id", "empresa_id", name="uq_rel_usuario_empresa"),
        {"schema": "core.identidad"},
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    usuario_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.mae_usuarios.id"), nullable=False)
    empresa_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("core.identidad.mae_empresas.id"), nullable=False)
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

4. rel_usuario_sucursal — alcance por sucursal (opcional, en consultas)

Asignación opcional de un usuario a sucursales de una empresa. No define acceso (eso es rel_usuario_empresa): se usa en las consultas de negocio para ubicar bodegas/almacenes de la sucursal o filtrar qué datos se muestran. Un usuario sin filas aquí ve toda la empresa; las validaciones de "esta operación requiere sucursal" son de cada módulo (ERP/CRM).

CREATE TABLE core.identidad.rel_usuario_sucursal (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    usuario_id  UUID NOT NULL,
    sucursal_id UUID NOT NULL,
    is_activo   BOOLEAN DEFAULT TRUE,
    created_at  TIMESTAMPTZ DEFAULT now(),
    updated_at  TIMESTAMPTZ DEFAULT now(),
    deleted_at  TIMESTAMPTZ NULL,
    created_by  UUID,
    updated_by  UUID,

    CONSTRAINT uq_rel_usuario_sucursal UNIQUE (usuario_id, sucursal_id),
    CONSTRAINT fk_rel_usuario_sucursal_usuario
        FOREIGN KEY (usuario_id) REFERENCES core.identidad.mae_usuarios(id),
    CONSTRAINT fk_rel_usuario_sucursal_sucursal
        FOREIGN KEY (sucursal_id) REFERENCES core.identidad.mae_sucursales(id)
);
class RelUsuarioSucursal(Base):
    __tablename__ = "rel_usuario_sucursal"
    __table_args__ = (
        UniqueConstraint("usuario_id", "sucursal_id", name="uq_rel_usuario_sucursal"),
        {"schema": "core.identidad"},
    )

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    usuario_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.mae_usuarios.id"), nullable=False)
    sucursal_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.mae_sucursales.id"), nullable=False)
    is_activo: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    updated_by: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)

5. trx_refresh_tokens — tokens de refresco (sesiones)

Campo Tipo Notas
id UUID PK
empresa_id BIGINT NOT NULL RLS
token_hash VARCHAR(64) UNIQUE NOT NULL SHA-256 del token opaco
usuario_id UUID NOT NULL FK → mae_usuarios
expiracion TIMESTAMPTZ NOT NULL
revocado BOOLEAN DEFAULT FALSE
revocado_en TIMESTAMPTZ NULL
user_agent VARCHAR(300)
ip VARCHAR(45) IPv4/IPv6
created_at TIMESTAMPTZ DEFAULT now()

Sin updated_at ni deleted_at: se revocan, no se borran.

CREATE TABLE core.identidad.trx_refresh_tokens (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    empresa_id   BIGINT NOT NULL,
    token_hash   VARCHAR(64) NOT NULL,
    usuario_id   UUID NOT NULL,
    expiracion   TIMESTAMPTZ NOT NULL,
    revocado     BOOLEAN DEFAULT FALSE,
    revocado_en  TIMESTAMPTZ NULL,
    user_agent   VARCHAR(300),
    ip           VARCHAR(45),
    created_at   TIMESTAMPTZ DEFAULT now(),

    CONSTRAINT uq_trx_refresh_tokens_hash UNIQUE (token_hash),
    CONSTRAINT fk_trx_refresh_tokens_usuario
        FOREIGN KEY (usuario_id) REFERENCES core.identidad.mae_usuarios(id)
);

-- RLS
ALTER TABLE core.identidad.trx_refresh_tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.trx_refresh_tokens
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class TrxRefreshToken(Base):
    __tablename__ = "trx_refresh_tokens"
    __table_args__ = {"schema": "core.identidad"}

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
    token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
    usuario_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.mae_usuarios.id"), nullable=False)
    expiracion: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    revocado: Mapped[bool] = mapped_column(Boolean, default=False)
    revocado_en: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    user_agent: Mapped[str | None] = mapped_column(String(300))
    ip: Mapped[str | None] = mapped_column(String(45))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

6. log_sesiones — historial de login/logout

Campo Tipo Notas
id UUID PK
empresa_id BIGINT NOT NULL RLS
usuario_id UUID NOT NULL FK → mae_usuarios
accion VARCHAR(20) NOT NULL login, logout, refresh, logout_all
token_jti UUID jti del JWT emitido
ip VARCHAR(45)
user_agent VARCHAR(300)
exitoso BOOLEAN NOT NULL
razon_fallo VARCHAR(100)
created_at TIMESTAMPTZ DEFAULT now()
CREATE TABLE core.identidad.log_sesiones (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    empresa_id   BIGINT NOT NULL,
    usuario_id   UUID NOT NULL,
    accion       VARCHAR(20) NOT NULL,
    token_jti    UUID,
    ip           VARCHAR(45),
    user_agent   VARCHAR(300),
    exitoso      BOOLEAN NOT NULL,
    razon_fallo  VARCHAR(100),
    created_at   TIMESTAMPTZ DEFAULT now(),

    CONSTRAINT fk_log_sesiones_usuario
        FOREIGN KEY (usuario_id) REFERENCES core.identidad.mae_usuarios(id)
);

-- RLS
ALTER TABLE core.identidad.log_sesiones ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento_empresa ON core.identidad.log_sesiones
    USING (empresa_id = NULLIF(current_setting('app.current_empresa_id', true), '')::BIGINT);
class LogSesion(Base):
    __tablename__ = "log_sesiones"
    __table_args__ = {"schema": "core.identidad"}

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    empresa_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
    usuario_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("core.identidad.mae_usuarios.id"), nullable=False)
    accion: Mapped[str] = mapped_column(String(20), nullable=False)
    token_jti: Mapped[uuid.UUID | None] = mapped_column(as_uuid=True)
    ip: Mapped[str | None] = mapped_column(String(45))
    user_agent: Mapped[str | None] = mapped_column(String(300))
    exitoso: Mapped[bool] = mapped_column(Boolean, nullable=False)
    razon_fallo: Mapped[str | None] = mapped_column(String(100))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

Resumen de tablas (usuarios)

Tabla Esquema PK empresa_id RLS Descripción
mae_usuarios core.identidad UUID no Cuentas (globales)
mae_entidades core.identidad UUID no Raíz party (ADR 0022)
mae_personas core.identidad UUID no Persona natural (hereda entidad)
mae_personas_juridicas core.identidad UUID no Persona jurídica externa (hereda entidad)
cat_tipos_usuario core.identidad UUID no Tipos de cuenta (usuario/root/externo)
rel_usuario_empresa core.identidad UUID (empresa_id) Acceso usuario ↔ empresa
rel_usuario_sucursal core.identidad UUID (sucursal_id) Alcance por sucursal (opcional)
trx_refresh_tokens core.identidad UUID BIGINT Tokens de sesión
log_sesiones core.identidad UUID BIGINT Historial login

Consecuencias

  • mae_usuarios es el corazón de la identidad: sub (UUID) del token, y actor_user_id/created_by/updated_by de toda la plataforma apuntan a él.
  • mae_personas (natural) y mae_personas_juridicas (jurídica externa) guardan los datos de la entidad; el usuario solo la referencia (entidad_idmae_entidades, ADR 0022).
  • El tipo de cuenta es tipo_usuario_idcat_tipos_usuario (usuario/root/externo), ya no un CHECK (ADR 0007/0022).
  • rel_usuario_empresa define el acceso: qué empresas puede elegir el usuario en el login (ADR 0010). No depende de sucursales. Su gestión operativa (alta/asignación) está en el ADR 0009.
  • rel_usuario_sucursal es opcional y no da acceso: delimita qué bodegas/almacenes o datos de sucursal se usan en las consultas de negocio. Un usuario sin filas aquí opera sobre toda la empresa.
  • Los tipos de usuario (usuario/root, columna tipo_usuario) y los permisos individuales (por módulo y por página) se definen en el ADR 0007; el JWT trae tipo_usuario y permisos para que cada servicio valide sin consultar BD (ADR 0011). El detalle de expiración de trx_refresh_tokens está en el ADR 0020 y su purga programada en el ADR 0021.