feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del Workspace. Cambios: - my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios con tenant, autocrea una por defecto en el primer acceso. - POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario. GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo). - security.validate_access_to_resource: si el token no trae tenant (hub_admin), resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar por compañía seleccionada. - set-active: fija sso_tenant_id/pub con el tenant de la compañía (override). - Pantalla "Compañías" (nav) para crear/listar/seleccionar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,12 @@ from .dto import (
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -432,60 +438,145 @@ async def get_my_companies(
|
||||
)
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from sqlalchemy import text
|
||||
import logging as _logging
|
||||
|
||||
_log = _logging.getLogger(__name__)
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
if not tenant_id:
|
||||
# Usuario sin tenant (p. ej. hub_admin global): no hay compañía que resolver.
|
||||
return []
|
||||
tenant_id = int(tenant_id)
|
||||
|
||||
# La compañía es una entidad REAL del CRM (a76.company, heredada de Anexo76).
|
||||
# Se listan las compañías del tenant; si no hay ninguna, se crea una por defecto
|
||||
# en el primer acceso (nombre = el del tenant).
|
||||
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
|
||||
# defecto en el primer acceso y asegura la membresía.
|
||||
if tenant_id:
|
||||
tenant_id = int(tenant_id)
|
||||
exists = db.execute(
|
||||
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"),
|
||||
{"tid": tenant_id},
|
||||
).fetchone()
|
||||
if not exists:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
default_name = (
|
||||
(tenant.name if tenant else None)
|
||||
or current_user.get("tenant_slug")
|
||||
or "Mi empresa"
|
||||
)
|
||||
created = db.execute(
|
||||
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
|
||||
{"tid": tenant_id, "name": default_name},
|
||||
).fetchone()
|
||||
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
|
||||
if user_id:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, int(created[0]))
|
||||
except Exception as exc:
|
||||
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
|
||||
|
||||
# 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona
|
||||
# también para hub_admin sin tenant en el token: verá las compañías que creó
|
||||
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
|
||||
if not user_id:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text("SELECT id, name, rfc, logo FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
|
||||
{"tid": tenant_id},
|
||||
text(
|
||||
"""
|
||||
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id
|
||||
FROM a76.company c
|
||||
WHERE c.id IN (
|
||||
SELECT company_id FROM core.user_tenants
|
||||
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
|
||||
UNION
|
||||
SELECT company_id FROM core.user_company_roles
|
||||
WHERE user_id = :uid AND is_active
|
||||
)
|
||||
ORDER BY c.id
|
||||
"""
|
||||
),
|
||||
{"uid": str(user_id)},
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
default_name = (
|
||||
(tenant.name if tenant else None)
|
||||
or current_user.get("tenant_slug")
|
||||
or "Mi empresa"
|
||||
)
|
||||
created = db.execute(
|
||||
text(
|
||||
"INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) "
|
||||
"RETURNING id, name, rfc, logo"
|
||||
),
|
||||
{"tid": tenant_id, "name": default_name},
|
||||
).fetchone()
|
||||
# Alinear la secuencia por si hubo inserts con id explícito (seed dev).
|
||||
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
rows = [created]
|
||||
_log.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
|
||||
|
||||
# Asegurar el vínculo usuario↔compañía por cada compañía del tenant.
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
companies = []
|
||||
for r in rows:
|
||||
cid = int(r[0])
|
||||
if user_id:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
|
||||
except Exception as exc:
|
||||
_log.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
|
||||
companies.append({
|
||||
"id": cid,
|
||||
"name": r[1] or "Mi empresa",
|
||||
"tenant_id": tenant_id,
|
||||
return [
|
||||
{
|
||||
"id": int(r[0]),
|
||||
"name": r[1] or "Empresa",
|
||||
"tenant_id": int(r[4]),
|
||||
"rfc": r[2],
|
||||
"logo": r[3],
|
||||
"is_active": True,
|
||||
})
|
||||
return companies
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class _CreateCompanyDTO(BaseModel):
|
||||
name: str
|
||||
tenant_id: int
|
||||
rfc: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/assignable-tenants")
|
||||
async def assignable_tenants(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Tenants disponibles para asignar una compañía nueva. El tenant lo crea el
|
||||
Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
|
||||
|
||||
q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712
|
||||
tid = resolve_effective_tenant_id_from_user(current_user)
|
||||
if tid and not is_hub_admin(current_user):
|
||||
q = q.filter(Tenant.id == int(tid))
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()]
|
||||
|
||||
|
||||
@router.post("/companies", status_code=201)
|
||||
async def create_company(
|
||||
data: _CreateCompanyDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
|
||||
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
|
||||
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import (
|
||||
resolve_effective_tenant_id_from_user,
|
||||
is_hub_admin,
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
|
||||
name = (data.name or "").strip()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
|
||||
|
||||
tid = int(data.tenant_id)
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
|
||||
|
||||
if not is_hub_admin(current_user):
|
||||
own = resolve_effective_tenant_id_from_user(current_user)
|
||||
if own is None or int(own) != tid:
|
||||
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
|
||||
|
||||
created = db.execute(
|
||||
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
|
||||
{"t": tid, "n": name, "r": (data.rfc or None)},
|
||||
).fetchone()
|
||||
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
cid = int(created[0])
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if user_id:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
|
||||
except Exception as exc:
|
||||
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
|
||||
|
||||
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}
|
||||
|
||||
@@ -666,6 +666,20 @@ def validate_access_to_resource(
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
|
||||
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
|
||||
# por compañía seleccionada cuando el token no está ligado a un tenant.
|
||||
if tenant_id is None and company_id:
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
tenant_id = int(row[0])
|
||||
except Exception as exc:
|
||||
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
|
||||
|
||||
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
|
||||
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
|
||||
# Se reemplazó el antiguo "admin" in realm_access.roles para que la
|
||||
|
||||
Reference in New Issue
Block a user