fix(core,crm): habilitar RBAC por carril sin romper el entorno dev

- Crea el módulo faltante permissions/seed_v2.py (register_core_permissions), que
  sync_permissions y bootstrap_super_admin importan; sin él ambos fallaban con
  ImportError y el auto-bootstrap dev quedaba inoperante.
- seed: ensure_company (a76.company id=1, requerida por la FK de company_roles) y
  seed_carril_roles (Ventas, Operaciones, Facturación, Consulta) con sus permisos;
  pobla el PermissionRegistry importando los permisos de cada dominio.
- Verificado end-to-end: con enforcement activo el usuario dev auto-bootstrapea a
  super_admin y crm/ops/fin responden 200 (no 403).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-15 09:07:53 -06:00
parent e79705e6e3
commit 3ea8d5f3ef
2 changed files with 57 additions and 6 deletions

View File

@@ -0,0 +1,27 @@
"""Registro de permisos base del núcleo (core).
``PermissionService.sync_permissions`` y ``bootstrap_super_admin`` importan este
módulo por su efecto secundario: dar de alta en el ``PermissionRegistry`` los
permisos transversales del sistema antes de sincronizarlos a la base de datos.
Los permisos de cada dominio (crm, ops, fin) se registran en el
``register_permissions()`` de su propio módulo al importar sus routers.
"""
from .registry import registry
MODULE = "core"
def register_core_permissions() -> None:
"""Da de alta los permisos base del sistema (idempotente)."""
registry.register(code="core.access", description="Acceso al sistema", module=MODULE, action="access")
registry.register(code="core.admin", description="Administración del sistema", module=MODULE, action="admin")
registry.register(
code="core.permissions.manage",
description="Gestionar roles y permisos",
module=MODULE,
action="manage",
)
register_core_permissions()

View File

@@ -33,6 +33,10 @@ from api.v1.modules.fin.invoices import service as invoices_service
from api.v1.modules.fin.invoices.dto import PaymentCreate from api.v1.modules.fin.invoices.dto import PaymentCreate
from api.v1.modules.core.permissions.models import CompanyRole, Permission, RolePermission from api.v1.modules.core.permissions.models import CompanyRole, Permission, RolePermission
from api.v1.modules.core.permissions.service import PermissionService from api.v1.modules.core.permissions.service import PermissionService
# Efecto secundario: poblar el PermissionRegistry con los permisos de cada dominio
import api.v1.modules.crm.permissions # noqa: F401
import api.v1.modules.ops.permissions # noqa: F401
import api.v1.modules.fin.permissions # noqa: F401
from core.database import CoreSessionLocal from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID # Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
@@ -383,16 +387,35 @@ def seed_invoicing_and_events(db) -> None:
# Carriles del proceso (R-T-07): a qué módulos/acciones puede acceder cada rol. # Carriles del proceso (R-T-07): a qué módulos/acciones puede acceder cada rol.
# clave = (code, nombre); valor = función que decide si un permiso pertenece al rol. # clave = (code, nombre); valor = función que decide si un permiso pertenece al rol.
def _carril_roles() -> dict: def _carril_roles() -> dict:
# cada función recibe el CÓDIGO del permiso (str) y decide si pertenece al carril
return { return {
("ventas", "Ventas"): lambda p: p.module == "crm" or p in {"ops.access", "ops.shipment.view"}, ("ventas", "Ventas"): lambda c: c.startswith("crm.") or c in {"ops.access", "ops.shipment.view"},
("operaciones", "Operaciones"): lambda p: p.module == "ops" ("operaciones", "Operaciones"): lambda c: c.startswith("ops.")
or p in {"crm.access", "crm.account.view", "crm.quote.view", "crm.service_request.view", "fin.access", "fin.invoice.view"}, or c in {"crm.access", "crm.account.view", "crm.quote.view", "crm.service_request.view", "fin.access", "fin.invoice.view"},
("facturacion", "Facturación"): lambda p: p.module == "fin" ("facturacion", "Facturación"): lambda c: c.startswith("fin.")
or p in {"ops.access", "ops.shipment.view", "crm.access", "crm.account.view"}, or c in {"ops.access", "ops.shipment.view", "crm.access", "crm.account.view"},
("consulta", "Consulta"): lambda p: p.endswith(".access") or p.endswith(".view"), ("consulta", "Consulta"): lambda c: c.endswith(".access") or c.endswith(".view"),
} }
def ensure_company(db) -> None:
"""Garantiza la empresa dev (a76.company id=1), requerida por la FK company_id de
los roles/permisos (core.company_roles → a76.company). La plantilla no la crea."""
from sqlalchemy import text
exists = db.execute(text("SELECT 1 FROM a76.company WHERE id = :id"), {"id": COMPANY_ID}).first()
if exists:
print(f"• Empresa id={COMPANY_ID} ya existe (a76.company)")
return
db.execute(
text("INSERT INTO a76.company (id, tenant_id) VALUES (:id, :tid)"),
{"id": COMPANY_ID, "tid": TENANT_ID},
)
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
print(f"✓ Empresa demo id={COMPANY_ID} creada (a76.company)")
def seed_carril_roles(db) -> None: def seed_carril_roles(db) -> None:
"""Crea los roles por carril del proceso con sus permisos (R-T-07). Idempotente.""" """Crea los roles por carril del proceso con sus permisos (R-T-07). Idempotente."""
# Poblar el catálogo de permisos desde el registry de código # Poblar el catálogo de permisos desde el registry de código
@@ -436,6 +459,7 @@ def main() -> None:
seed_suppliers_and_related(db) seed_suppliers_and_related(db)
seed_commercial_and_ops(db) seed_commercial_and_ops(db)
seed_invoicing_and_events(db) seed_invoicing_and_events(db)
ensure_company(db)
seed_carril_roles(db) seed_carril_roles(db)
print("\nSeed CRM completado.") print("\nSeed CRM completado.")
finally: finally: