Saltar a contenido

Especificación Técnica: rel_usuario_permiso

Esquema: core.identidad
Base de Datos: core
Servicio: svc-identidad
Propósito: Relación N:M de permisos efectivos asignados a usuarios, con alcance por empresa.


1. Justificación y Mejoras de Arquitectura

  • Permite asignar un permiso a un usuario a nivel global (empresa_id NULL) o por empresa.
  • Unique (usuario_id, permiso_id, empresa_id) evita asignaciones duplicadas.
  • RLS mixto: registros globales visibles para todas las empresas y propios para la actual.

2. Definiciones de Implementación

Table core.identidad.rel_usuario_permiso {
    id                         uuid          [pk, default: `gen_random_uuid()`]
    empresa_id                 bigint       
    usuario_id                 uuid          [not null, ref: > mae_usuarios.id]
    permiso_id                 uuid          [not null, ref: > cat_permisos.id]

    // Auditoría General
    is_activo               boolean       [not null, default: `true`, note: 'Estado lógico del registro']
    created_at              timestamptz   [not null, default: `now()`, note: 'Fecha de creación']
    updated_at              timestamptz   [not null, default: `now()`, note: 'Fecha de última actualización']
    deleted_at              timestamptz   [note: 'Fecha de eliminación lógica']
    created_by              uuid          [note: 'Usuario creador']
    updated_by              uuid          [note: 'Usuario modificador']

    Indexes {
        (usuario_id, permiso_id, empresa_id) [unique, name: 'uq_rel_usuario_permiso']
    }

    // RLS mixto (globales + por empresa)
}
CREATE TABLE IF NOT EXISTS core.identidad.rel_usuario_permiso (
    id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
    empresa_id BIGINT,
    usuario_id UUID NOT NULL,
    permiso_id UUID NOT NULL,

    -- Auditoría General
    is_activo BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    created_by UUID,
    updated_by UUID,
    CONSTRAINT uq_rel_usuario_permiso UNIQUE (usuario_id, permiso_id, empresa_id),
    CONSTRAINT fk_rel_usuario_permiso_usuario FOREIGN KEY (usuario_id) REFERENCES core.identidad.mae_usuarios (id),
    CONSTRAINT fk_rel_usuario_permiso_permiso FOREIGN KEY (permiso_id) REFERENCES core.identidad.cat_permisos (id)
);

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

-- Comentarios
COMMENT ON COLUMN core.identidad.rel_usuario_permiso.empresa_id IS 'Empresa o NULL si es global';
COMMENT ON COLUMN core.identidad.rel_usuario_permiso.usuario_id IS 'Usuario';
COMMENT ON COLUMN core.identidad.rel_usuario_permiso.permiso_id IS 'Permiso';
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 RelUsuarioPermiso(Base):
    __tablename__ = 'rel_usuario_permiso'
    __table_args__ = (
        UniqueConstraint('usuario_id', 'permiso_id', 'empresa_id', name='uq_rel_usuario_permiso'),
        {"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 | None] = mapped_column(
        BigInteger,
        comment='Empresa o NULL si es global',
    )
    usuario_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        nullable=False,
        ForeignKey("mae_usuarios.id"),
        comment='Usuario',
    )
    permiso_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        nullable=False,
        ForeignKey("cat_permisos.id"),
        comment='Permiso',
    )

    # Auditoría General
    is_activo: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        server_default=text("true"),
        comment='Estado lógico del registro',
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        server_default=text("now()"),
        comment='Fecha de creación',
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        server_default=text("now()"),
        comment='Fecha de última actualización',
    )
    deleted_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True),
        comment='Fecha de eliminación lógica',
    )
    created_by: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True),
        comment='Usuario creador',
    )
    updated_by: Mapped[uuid.UUID | None] = mapped_column(
        UUID(as_uuid=True),
        comment='Usuario modificador',
    )
"""revision: core_id_0020
create table core.identidad.rel_usuario_permiso
"""

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


revision: str = 'core_id_0020'
down_revision: str | None = 'core_id_0019'
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
    op.create_table(
        'rel_usuario_permiso',
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("empresa_id", sa.BigInteger()),
        sa.Column("usuario_id", postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column("permiso_id", postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column("is_activo", sa.Boolean(), nullable=False, server_default=sa.text("true")),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
        sa.Column("deleted_at", sa.DateTime(timezone=True)),
        sa.Column("created_by", postgresql.UUID(as_uuid=True)),
        sa.Column("updated_by", postgresql.UUID(as_uuid=True)),
        sa.UniqueConstraint('usuario_id', 'permiso_id', 'empresa_id', name='uq_rel_usuario_permiso'),
        sa.ForeignKeyConstraint(['usuario_id'], ['core.identidad.mae_usuarios.id'], name='fk_rel_usuario_permiso_usuario'),
        sa.ForeignKeyConstraint(['permiso_id'], ['core.identidad.cat_permisos.id'], name='fk_rel_usuario_permiso_permiso'),
        schema='core.identidad',
    )

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


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