feat: enhance user statistics and tenant management
- Updated user statistics endpoint to include access token and hub tenant ID for improved data retrieval. - Refactored user listing functionality to support access token and hub tenant ID, ensuring accurate user data from the Hub. - Introduced new methods in UserService for fetching users with additional information from the Hub. - Enhanced security functions to ensure user-tenant relationships are maintained and synchronized with the Hub. - Improved frontend logic to handle company initialization and user authentication more effectively.
This commit is contained in:
@@ -27,6 +27,8 @@ _synced_tenant_ids: Set[int] = set()
|
||||
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
||||
# (mismo slug, diferente id).
|
||||
_tenant_id_aliases: Dict[int, int] = {}
|
||||
# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub.
|
||||
_tenant_id_hub_by_local: Dict[int, int] = {}
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
@@ -68,36 +70,173 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
|
||||
raise HTTPException(status_code=401, detail="Authentication error")
|
||||
|
||||
|
||||
def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> None:
|
||||
def _ensure_user_tenant_for_company(
|
||||
db: Session, keycloak_user_id: str, tenant_id: int, company_id: int
|
||||
) -> None:
|
||||
"""Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant)."""
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
existing = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
db.commit()
|
||||
return
|
||||
db.add(
|
||||
UserTenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_company_exists(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_name: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Garantiza que exista al menos una empresa en a76.company para el tenant.
|
||||
El tenant IS la empresa — se crea automáticamente al primer login.
|
||||
Garantiza al menos una empresa en a76.company para el tenant.
|
||||
|
||||
Primera vez: CompanyService.create_company_manually (seed catálogos por compañía),
|
||||
relación user_tenants y bootstrap_super_admin para el usuario del token.
|
||||
"""
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.general_catalogs.company.dto import CompanyCreateDTO
|
||||
from api.v1.modules.a76.general_catalogs.company.service import CompanyService
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
kc = hub_user.get("sub") if hub_user else None
|
||||
|
||||
try:
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
exists = db.query(Company).filter(Company.tenant_id == tenant_id).first()
|
||||
if not exists:
|
||||
company = Company(tenant_id=tenant_id, name=tenant_name)
|
||||
db.add(company)
|
||||
company = db.query(Company).filter(Company.tenant_id == tenant_id).first()
|
||||
|
||||
if not company:
|
||||
svc = CompanyService(db)
|
||||
username = "System"
|
||||
if hub_user:
|
||||
username = (
|
||||
hub_user.get("preferred_username")
|
||||
or hub_user.get("email")
|
||||
or hub_user.get("name")
|
||||
or "System"
|
||||
)
|
||||
company = svc.create_company_manually(
|
||||
CompanyCreateDTO(name=tenant_name),
|
||||
tenant_id=tenant_id,
|
||||
username=username,
|
||||
)
|
||||
logger.info(
|
||||
"Empresa creada vía CompanyService para tenant id=%s name=%r company_id=%s",
|
||||
tenant_id,
|
||||
tenant_name,
|
||||
company.id,
|
||||
)
|
||||
|
||||
if not company:
|
||||
logger.warning(
|
||||
"No hay empresa para tenant %s tras intento de creación automática", tenant_id
|
||||
)
|
||||
return
|
||||
|
||||
if company.name != tenant_name:
|
||||
company.name = tenant_name
|
||||
db.commit()
|
||||
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
|
||||
elif exists.name != tenant_name:
|
||||
exists.name = tenant_name
|
||||
db.commit()
|
||||
logger.info(f"Empresa actualizada para tenant id={tenant_id}: '{tenant_name}'")
|
||||
logger.info("Empresa actualizada para tenant id=%s: '%s'", tenant_id, tenant_name)
|
||||
|
||||
if kc:
|
||||
ut = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == kc,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.company_id == company.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ut:
|
||||
_ensure_user_tenant_for_company(db, kc, tenant_id, company.id)
|
||||
PermissionService(db).bootstrap_super_admin(kc, company.id)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
|
||||
logger.warning(
|
||||
"No se pudo asegurar empresa/usuario para tenant %s: %s", tenant_id, e
|
||||
)
|
||||
|
||||
|
||||
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
def _repair_user_company_link_if_needed(
|
||||
db: Session,
|
||||
tenant_id_effective: int,
|
||||
hub_user: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""Si ya hay empresa pero el usuario no tiene user_tenants, enlaza y hace bootstrap."""
|
||||
if not hub_user or not hub_user.get("sub"):
|
||||
return
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
kc = hub_user["sub"]
|
||||
company = (
|
||||
db.query(Company)
|
||||
.filter(Company.tenant_id == tenant_id_effective)
|
||||
.first()
|
||||
)
|
||||
if not company:
|
||||
return
|
||||
ut = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == kc,
|
||||
UserTenant.tenant_id == tenant_id_effective,
|
||||
UserTenant.company_id == company.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if ut:
|
||||
return
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, kc, tenant_id_effective, company.id)
|
||||
PermissionService(db).bootstrap_super_admin(kc, company.id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"No se pudo reparar enlace usuario-compañía tenant=%s: %s",
|
||||
tenant_id_effective,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_tenant_synced(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_slug: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Garantiza que el tenant del Hub exista en core.tenants local.
|
||||
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
|
||||
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
||||
"""
|
||||
if tenant_id in _synced_tenant_ids:
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
effective = int(_tenant_id_aliases.get(tenant_id, tenant_id))
|
||||
_repair_user_company_link_if_needed(db, effective, hub_user)
|
||||
return effective
|
||||
|
||||
try:
|
||||
# Importación local para evitar imports circulares
|
||||
@@ -116,7 +255,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
# Garantizar empresa aunque el tenant ya existiera
|
||||
_ensure_company_exists(db, tenant_id, name)
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
# Crear el tenant local con los datos disponibles del token.
|
||||
@@ -134,7 +273,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
||||
# Crear la empresa correspondiente al tenant recién sincronizado
|
||||
_ensure_company_exists(db, tenant_id, name)
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
except IntegrityError:
|
||||
@@ -154,8 +293,9 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
||||
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
||||
_tenant_id_aliases[tenant_id] = int(stale.id)
|
||||
_tenant_id_hub_by_local[int(stale.id)] = int(tenant_id)
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user)
|
||||
return int(stale.id)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
@@ -195,7 +335,9 @@ async def get_current_user(
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if tenant_id and tenant_slug:
|
||||
effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||
effective_tenant_id = _ensure_tenant_synced(
|
||||
db, int(tenant_id), str(tenant_slug), hub_user=user_info
|
||||
)
|
||||
if effective_tenant_id != int(tenant_id):
|
||||
logger.warning(
|
||||
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
||||
@@ -268,6 +410,23 @@ def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_hub_tenant_id_for_api(
|
||||
local_tenant_id: Optional[int], x_tenant_override: Optional[str]
|
||||
) -> int:
|
||||
"""
|
||||
ID de tenant en Hub (client_tenants) para llamadas a la API del Hub.
|
||||
|
||||
Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local,
|
||||
usa el mapeo inverso registrado en _ensure_tenant_synced.
|
||||
"""
|
||||
if x_tenant_override and str(x_tenant_override).strip().isdigit():
|
||||
return int(str(x_tenant_override).strip())
|
||||
if local_tenant_id is None:
|
||||
return 0
|
||||
lid = int(local_tenant_id)
|
||||
return int(_tenant_id_hub_by_local.get(lid, lid))
|
||||
|
||||
|
||||
def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]:
|
||||
"""
|
||||
Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me
|
||||
|
||||
Reference in New Issue
Block a user