From d1fcd236e48c081629c8c9c532a263beeeec30b8 Mon Sep 17 00:00:00 2001 From: marcos Date: Mon, 10 Aug 2026 10:58:56 -0600 Subject: [PATCH] fix(core): el bootstrap de permisos colgaba el rol de un tenant inexistente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Al entrar por primera vez a una compañia, /permissions/me creaba el rol super_admin con `tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1`. La sesion de ese request no trae contexto RLS, asi que caia en el respaldo: tenant_id = 1. En una instalacion real ese tenant no existe -- aqui son 11 y 17 -- y el INSERT moria con ForeignKeyViolation sobre company_roles_tenant_id_fkey. El fallo era silencioso hacia afuera: el except del bootstrap lo registraba como ERROR CRITICO y devolvia False, pero /permissions/me seguia respondiendo 200 con la lista de permisos VACIA. En pantalla se leia "No tienes permisos para realizar esta accion", que manda a revisar roles en vez de la base. Toda la API respondia 403. Dos arreglos: - `_resolve_tenant_id_for_company` tenia el respaldo sin implementar (`pass` con un comentario de plantilla), asi que devolvia None siempre que faltara el contexto RLS. Con esa funcion se scopean las consultas de sus CINCO llamadores, o sea que la lectura de permisos tampoco resolvia. Ahora consulta a76.company, la tabla de companias del CRM, con SQL crudo igual que seed_crm.py. - bootstrap_super_admin toma el tenant de la COMPANIA, con consulta directa y no por el helper: el helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser el de la compania. Para leer permisos esa preferencia esta bien y ahorra una consulta en el camino caliente; para escribir una fila atada por FK a a76.company y a core.tenants a la vez, no -- si difirieran, el rol naceria cruzado entre dos tenants. Si la compania no existe, aborta sin crear nada en vez de inventar un tenant. Verificado contra la base: bootstrap devuelve True en las dos companias del usuario, 70 permisos en cada una, y las filas quedan consistentes (rol de company 1 -> tenant 17, company 2 -> tenant 11). Suite sin regresion: 151 pasan. Ref: T2026-08-046 Co-Authored-By: Claude Opus 5 (1M context) --- .../v1/modules/core/permissions/service.py | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/backend/api/v1/modules/core/permissions/service.py b/backend/api/v1/modules/core/permissions/service.py index b31c8d0..256aeb1 100644 --- a/backend/api/v1/modules/core/permissions/service.py +++ b/backend/api/v1/modules/core/permissions/service.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Set, Optional, List from sqlalchemy.orm import Session -from sqlalchemy import and_, or_ +from sqlalchemy import and_, or_, text from core.database import RLS_TENANT_KEY from .cache import PermissionCache @@ -51,8 +51,18 @@ class PermissionService: return None try: - # Sin modelo de compañía en la plantilla — implementa la consulta aquí. - pass + # La tabla de compañías del CRM es ``a76.company`` (esquema legado). No tiene modelo + # ORM en este proyecto, así que se consulta con SQL crudo, igual que ``seed_crm.py``. + # + # Sin este respaldo la función devolvía None siempre que la sesión no traía contexto + # RLS, y con ella se scopean las consultas de permisos de los cinco llamadores de + # abajo: el efecto neto era que nadie resolvía permisos. + row = self.db.execute( + text("SELECT tenant_id FROM a76.company WHERE id = :company_id"), + {"company_id": company_id}, + ).first() + if row is not None and row[0] is not None: + return int(row[0]) except Exception as exc: logger.warning( "resolve_tenant_id_for_company_failed", @@ -470,8 +480,35 @@ class PermissionService: sync_res = self.sync_permissions() logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.") - # 1. Obtener el tenant_id (implementa con tu modelo de compañía) - tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1 + # 1. tenant_id del rol: se toma de la COMPAÑÍA, que es su fuente autoritativa. + # ``company_roles`` referencia a la vez a ``a76.company`` y a ``core.tenants``, así + # que el tenant del rol tiene que ser el de su compañía o la fila queda cruzada + # entre dos tenants. + # + # Antes esto era ``self.db.info.get(RLS_TENANT_KEY) or 1``. Cuando la sesión no + # traía contexto RLS —justo el caso de ``/permissions/me`` en el primer acceso— el + # rol se creaba con ``tenant_id=1``; en una instalación real ese tenant no existe y + # el INSERT moría con ForeignKeyViolation. El bootstrap quedaba a medias, sin rol + # ni permisos, toda la API respondía 403 y ``/permissions/me`` seguía devolviendo + # 200 con la lista vacía: el fallo se leía en pantalla como "no tienes permisos" + # en lugar de como el error de configuración que era. + # Se consulta la compañía DIRECTAMENTE y no vía ``_resolve_tenant_id_for_company``: + # ese helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser + # el de la compañía. Para leer permisos esa preferencia está bien y ahorra una + # consulta en el camino caliente; para escribir una fila atada por FK a las dos + # tablas, no: si difirieran, el rol nacería cruzado. + _row = self.db.execute( + text("SELECT tenant_id FROM a76.company WHERE id = :company_id"), + {"company_id": company_id}, + ).first() + if _row is None or _row[0] is None: + logger.error( + "Bootstrap: la compañía %s no existe en a76.company, no hay tenant al que " + "colgar el rol. Se aborta sin crear nada.", + company_id, + ) + return False + tenant_id = int(_row[0]) # 2. Buscar si ya existe el rol "super_admin" admin_role = self.db.query(CompanyRole).filter(