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_id → cat_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_entidadeses la raíz y de ella "heredan"mae_personas(natural) ymae_personas_juridicas(jurídica externa).mae_usuariosreferencia 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)
passwordguarda el hash Argon2id,password_temp = TRUEpor defecto (el usuario debe cambiarla en su primer login).entidad_idreferencia amae_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 estipo_usuario_id→cat_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 demae_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 elempresa_idelegido. 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_atnideleted_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 | sí | Tokens de sesión |
log_sesiones |
core.identidad | UUID | BIGINT | sí | Historial login |
Consecuencias¶
mae_usuarioses el corazón de la identidad:sub(UUID) del token, yactor_user_id/created_by/updated_byde toda la plataforma apuntan a él.mae_personas(natural) ymae_personas_juridicas(jurídica externa) guardan los datos de la entidad; el usuario solo la referencia (entidad_id→mae_entidades, ADR 0022).- El tipo de cuenta es
tipo_usuario_id→cat_tipos_usuario(usuario/root/externo), ya no unCHECK(ADR 0007/0022). rel_usuario_empresadefine 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_sucursales 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, columnatipo_usuario) y los permisos individuales (por módulo y por página) se definen en el ADR 0007; el JWT traetipo_usuarioypermisospara que cada servicio valide sin consultar BD (ADR 0011). El detalle de expiración detrx_refresh_tokensestá en el ADR 0020 y su purga programada en el ADR 0021.