Merge pull request 'feature/hub-admin-global-access' (#410) from feature/hub-admin-global-access into development

Reviewed-on: ADUANASOFT/anexo76#410
This commit is contained in:
2026-05-20 16:31:09 +00:00
15 changed files with 251 additions and 174 deletions

View File

@@ -3,7 +3,7 @@ import logging
import inspect
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
from api.v1.common.catalog_validation_errors import CatalogValidationError
@@ -155,16 +155,9 @@ class TenantCRUDRoutes(
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
from core.security import get_tenant_from_token
if all_companies:
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
# In all_companies mode, we don't filter by company_id,
# but we still need the tenant_id from the session/token.
# Hub admin: tenant_id=None → el servicio devuelve todas las empresas
tenant_id = resolve_tenant_id_required(current_user, db=db)
target_company_id = None
else:
tenant_id = validate_access_to_resource(
@@ -177,7 +170,7 @@ class TenantCRUDRoutes(
target_company_id = company_id
skip = (page - 1) * page_size
# Extraer todos los parámetros de búsqueda dinámicamente
# Excluimos los parámetros estándar de paginación y control
standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"}
@@ -245,14 +238,9 @@ class TenantCRUDRoutes(
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
from core.security import get_tenant_from_token
if all_companies:
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
# Hub admin: tenant_id=None → el servicio devuelve todas las empresas
tenant_id = resolve_tenant_id_required(current_user, db=db)
target_company_id = None
else:
tenant_id = validate_access_to_resource(

View File

@@ -17,7 +17,7 @@ from core.s3_keys import (
customs_broker_vu_doda_private_key_key,
customs_broker_vu_private_key_key,
)
from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource
from core.security import get_current_user, get_tenant_from_token, resolve_tenant_id_required, validate_access_to_resource
from core.storage_s3 import delete_object_if_exists, put_object_bytes
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
@@ -32,25 +32,13 @@ MAX_VU_CER_KEY_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_COVE_BYTES = 15 * 1024 * 1024 # 15 MB (xml/zip)
def _resolve_tenant_id_int(current_user: dict) -> int:
tid = get_tenant_from_token(current_user)
if tid is not None:
return int(tid)
raw = current_user.get("tenant_id")
if isinstance(raw, list) and raw:
raw = raw[0]
if raw is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID in token",
)
def _resolve_tenant_id_int(
current_user: dict,
db=None,
company_id: int = None,
):
"""Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
def _remove_stored_vu_path(ref: Optional[str]) -> None:
@@ -198,7 +186,7 @@ async def upload_customs_broker_vu_file(
- DODA: doda_certificate_path, doda_key_path, doda_xml_files_path
"""
validate_access_to_resource(db, company_id, current_user, ["customs_brokers.create"])
tenant_id = _resolve_tenant_id_int(current_user)
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:

View File

@@ -14,7 +14,7 @@ from core.config import settings
from core.database import get_core_db
from core.exceptions import ValidationException
from core.s3_keys import cove_acuse_pdf_key
from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource
from core.security import get_current_user, get_tenant_from_token, resolve_tenant_id_required, validate_access_to_resource
from api.v1.modules.core.tasks_tracking import track_and_dispatch
@@ -147,14 +147,10 @@ def check_cove_eligibility(
Evalúa si la factura tiene todos los datos necesarios (VU, factura, partidas)
para poder generar un COVE. No dispara la tarea Celery.
"""
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id_int = int(tenant_id)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = FacturaCoveDomainService(db)
eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id_int, company_id=company_id)
eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id, company_id=company_id)
return eligibility

View File

@@ -12,6 +12,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
class CompanyCreateDTO(BaseModel):
"""DTO para crear una empresa"""
# Solo requerido cuando hub_admin crea una empresa (no tiene tenant_id en token)
tenant_id: Optional[int] = Field(None, description="Tenant ID (requerido para hub_admin)")
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(

View File

@@ -10,7 +10,7 @@ from typing import List, Optional
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile
from fastapi import APIRouter, Depends, HTTPException, Request, status, File, UploadFile
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
@@ -23,7 +23,9 @@ from core.security import (
collect_user_role_names,
get_current_user,
get_tenant_from_token,
is_hub_admin,
resolve_effective_tenant_id_from_user,
resolve_tenant_id_required,
validate_access_to_resource,
)
from .....common.tenant_crud_routes import TenantCRUDRoutes
@@ -40,7 +42,7 @@ logger = logging.getLogger(__name__)
def _user_is_admin(current_user: dict) -> bool:
return "admin" in collect_user_role_names(current_user)
return "admin" in collect_user_role_names(current_user) or is_hub_admin(current_user)
def _assert_permission_any_company(
@@ -81,26 +83,13 @@ def _assert_permission_for_company(
return validate_access_to_resource(db, company_id, current_user, [permission_code])
def _resolve_tenant_id_int(current_user: dict) -> int:
"""Misma lógica que validate_access_to_resource: entero estable para BD y claves S3."""
tid = get_tenant_from_token(current_user)
if tid is not None:
return int(tid)
raw = current_user.get("tenant_id")
if isinstance(raw, list) and raw:
raw = raw[0]
if raw is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID in token",
)
def _resolve_tenant_id_int(
current_user: dict,
db: Session = None,
company_id: int = None,
) -> Optional[int]:
"""Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
def _is_s3_object_key(ref: Optional[str]) -> bool:
@@ -127,18 +116,27 @@ router = APIRouter(prefix="/company")
summary="Create a new company",
)
async def create_company(
request: Request,
data: CompanyCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
_assert_permission_any_company(db, current_user, "cat_company.create")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db)
if tenant_id is None:
# Prioridad: X-Tenant-Override del header (tenant seleccionado en el sidebar)
override = request.headers.get("X-Tenant-Override")
if override and str(override).strip().isdigit():
tenant_id = int(override)
# Fallback: campo tenant_id en el body (enviado por el frontend como respaldo)
elif data.tenant_id:
tenant_id = data.tenant_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hub admin: selecciona un tenant en el sidebar antes de crear una empresa",
)
service = CompanyService(db)
new_company = service.create_company_manually(data, tenant_id=tenant_id)
@@ -151,6 +149,7 @@ async def create_company(
summary="Get companies with pagination",
)
async def list_companies(
request: Request,
page: int = 1,
page_size: int = 50,
name: Optional[str] = None,
@@ -161,12 +160,12 @@ async def list_companies(
"""Get paginated list of companies for current tenant with optional filters"""
_assert_permission_any_company(db, current_user, "cat_company.view")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db)
# Hub admin: si hay X-Tenant-Override y aún no se resolvió tenant, filtra por ese tenant
if tenant_id is None and is_hub_admin(current_user):
override = request.headers.get("X-Tenant-Override")
if override and str(override).strip().isdigit():
tenant_id = int(override)
skip = (page - 1) * page_size
filters = {}
@@ -205,15 +204,32 @@ async def list_companies(
summary="Get all companies for current tenant",
)
async def get_my_companies(
request: Request,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Compañías accesibles: tenant del token/Hub (si existe) unión con membership en BD
(``user_company_roles``, ``user_company_permissions``, ``user_tenants``).
Un usuario solo con roles de app y sin ``tenant_id`` en /auth/me sigue pudiendo
listar sus compañías asignadas.
Hub admin: si hay X-Tenant-Override muestra solo ese tenant; si no, muestra todos.
"""
from core.security import collect_user_role_names
user_roles = collect_user_role_names(current_user)
# Hub admin: respeta X-Tenant-Override para filtrar por tenant seleccionado;
# sin override devuelve todo (visibilidad global).
if "hub_admin" in user_roles:
service = CompanyService(db)
override = request.headers.get("X-Tenant-Override")
query = db.query(Company).filter(Company.deleted_at.is_(None))
if override and str(override).strip().isdigit():
query = query.filter(Company.tenant_id == int(override))
all_companies = query.order_by(Company.name).all()
return [
CompanyResponseDTO.model_validate(service.flatten_company_dto(c))
for c in all_companies
]
_assert_permission_any_company(db, current_user, "cat_company.view")
user_id = current_user.get("sub") or current_user.get("id")
@@ -245,12 +261,7 @@ async def get_company(
"""Get a specific company by ID"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.view")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = CompanyService(db)
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
@@ -277,12 +288,7 @@ async def update_company(
"""Update a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = CompanyService(db)
updated_company = service.update(db, company_id, tenant_id, 0, data)
@@ -354,12 +360,7 @@ async def delete_company(
"""Delete a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.delete")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
success = CompanyService.delete(db, company_id, tenant_id, 0)
if not success:
@@ -385,7 +386,7 @@ async def upload_company_logo(
"""Upload a logo for a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = _resolve_tenant_id_int(current_user)
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
# Validar que la empresa existe
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
@@ -468,7 +469,7 @@ async def upload_company_certificate(
"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = _resolve_tenant_id_int(current_user)
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
# Validar que la empresa existe
service = CompanyService(db)

View File

@@ -44,7 +44,9 @@ class CompanyService:
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Company], int]:
"""Get all companies for a tenant with pagination"""
query = db.query(Company).filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
query = db.query(Company).filter(Company.deleted_at.is_(None))
if tenant_id is not None:
query = query.filter(Company.tenant_id == tenant_id)
# Apply filters if provided
if filters:
@@ -67,15 +69,10 @@ class CompanyService:
db: Session, company_id: int, tenant_id: int, company_id_unused: int
) -> Optional[Company]:
"""Get company by ID"""
return (
db.query(Company)
.filter(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None)
)
.first()
)
query = db.query(Company).filter(Company.id == company_id, Company.deleted_at.is_(None))
if tenant_id is not None:
query = query.filter(Company.tenant_id == tenant_id)
return query.first()
# ESTE ES EL MÉTODO VIEJO QUE CAUSABA PROBLEMAS (Lo dejamos por si acaso)
@staticmethod

View File

@@ -721,7 +721,6 @@ async def get_all_movements(
logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation")
return movements
return movements
except ValueError as e:
logger.warning(f"Validation error fetching all movements: {str(e)}")
raise HTTPException(
@@ -761,23 +760,22 @@ def generate_invoice_report_async(
# validate_access_to_resource returns the integer tenant_id from DB
tenant_id = validate_access_to_resource(db, company_id, current_user, ["report.process"])
# Guardia explícita: tenant_id debe ser un entero positivo antes del dispatch a Celery.
# Un valor inválido aquí generaría un reporte sin filtro de tenant o un crash en la tarea.
if not isinstance(tenant_id, int) or tenant_id <= 0:
raise HTTPException(
status_code=400,
detail="No se pudo determinar el tenant para esta empresa. Verifica que la empresa exista.",
)
# Serialize filters to dict for Celery
filter_data = filters.model_dump()
user_email = current_user.get('email')
# Trigger task
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in token",
)
task = track_and_dispatch(
db=db,
task=generate_invoice_movements_async,
tenant_id=int(tenant_id),
tenant_id=tenant_id,
company_id=company_id,
requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"),
task_name="generate_invoice_movements_async",

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, get_tenant_from_token
from core.security import get_current_user, resolve_tenant_id_required
from .models import TaskRun, TaskStatus
from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest
@@ -58,9 +58,7 @@ def list_tasks(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
tracker = TaskTrackerService(db)
if sync_active:
@@ -93,11 +91,12 @@ def get_task_detail(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
row = db.query(TaskRun).filter(TaskRun.task_id == task_id, TaskRun.tenant_id == tenant_id).first()
query = db.query(TaskRun).filter(TaskRun.task_id == task_id)
if tenant_id is not None:
query = query.filter(TaskRun.tenant_id == tenant_id)
row = query.first()
if not row:
raise HTTPException(status_code=404, detail="Task not found")
@@ -120,9 +119,7 @@ def sync_tasks(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
tracker = TaskTrackerService(db)
updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids)
return {"updated": updated}
@@ -133,15 +130,18 @@ def get_catalogs(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
groups = (
db.query(TaskRun.task_group).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.task_group).all()
)
names = db.query(TaskRun.task_name).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.task_name).all()
statuses = db.query(TaskRun.status).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.status).all()
groups_q = db.query(TaskRun.task_group)
names_q = db.query(TaskRun.task_name)
statuses_q = db.query(TaskRun.status)
if tenant_id is not None:
groups_q = groups_q.filter(TaskRun.tenant_id == tenant_id)
names_q = names_q.filter(TaskRun.tenant_id == tenant_id)
statuses_q = statuses_q.filter(TaskRun.tenant_id == tenant_id)
groups = groups_q.distinct().order_by(TaskRun.task_group).all()
names = names_q.distinct().order_by(TaskRun.task_name).all()
statuses = statuses_q.distinct().order_by(TaskRun.status).all()
return TaskCatalogsResponse(
task_groups=[g[0] for g in groups if g[0]],
task_names=[n[0] for n in names if n[0]],

View File

@@ -193,10 +193,12 @@ class TaskTrackerService:
self.db.refresh(task_run)
return task_run
def sync_active_tasks(self, tenant_id: int, task_ids: list[str] | None = None) -> int:
def sync_active_tasks(self, tenant_id: int | None, task_ids: list[str] | None = None) -> int:
query = self.db.query(TaskRun).filter(
TaskRun.tenant_id == tenant_id, TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value])
TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value])
)
if tenant_id is not None:
query = query.filter(TaskRun.tenant_id == tenant_id)
if task_ids:
query = query.filter(TaskRun.task_id.in_(task_ids))
rows = query.limit(200).all()
@@ -207,7 +209,7 @@ class TaskTrackerService:
def list_tasks(
self,
*,
tenant_id: int,
tenant_id: int | None,
page: int,
page_size: int,
status: list[str] | None = None,
@@ -217,7 +219,9 @@ class TaskTrackerService:
search: str | None = None,
order: str = "desc",
) -> tuple[list[TaskRun], int]:
query = self.db.query(TaskRun).filter(TaskRun.tenant_id == tenant_id)
query = self.db.query(TaskRun)
if tenant_id is not None:
query = query.filter(TaskRun.tenant_id == tenant_id)
if status:
query = query.filter(TaskRun.status.in_(status))
if task_group:

View File

@@ -15,6 +15,7 @@ from core.s3_keys import public_user_avatar_api_path, user_avatar_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
from core.security import (
get_current_user,
is_hub_admin,
resolve_hub_tenant_id_for_api,
validate_access_to_resource,
)
@@ -51,7 +52,7 @@ async def get_user_statistics(
Obtiene estadísticas de usuarios del tenant actual
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
@@ -82,7 +83,7 @@ async def list_users(
Lista todos los usuarios del tenant con paginación
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
@@ -342,7 +343,7 @@ async def get_user_detail(
Obtiene información detallada de un usuario específico
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
return await service.get_user(user_id)
@@ -357,7 +358,7 @@ async def create_new_user(
Crea un nuevo usuario a través del Hub y lo asocia al tenant
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
user = await service.create_user(
email=data.email,
username=data.username,
@@ -383,7 +384,7 @@ async def update_user_detail(
Actualiza información de un usuario
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
user = await service.update_user(
user_id=user_id,
first_name=data.first_name,
@@ -413,7 +414,7 @@ async def get_user_tenant_count(
tenant_id = validate_access_to_resource(
db, company_id, current_user, required_permissions=["user.view"]
)
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
count = service.get_user_tenant_count(user_id)
return {"tenant_count": count}
@@ -449,7 +450,7 @@ async def delete_user_route(
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
await service.delete_user(
user_id,
soft_delete=soft_delete,
@@ -472,6 +473,6 @@ async def change_user_password(
Cambia la contraseña de un usuario a través del Hub
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
await service.change_password(user_id, data.password, data.temporary)
return {"message": "Password changed successfully"}

View File

@@ -94,10 +94,11 @@ def _normalize_user(
class UserService:
"""Servicio para gestionar usuarios vía Hub"""
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None):
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None, *, is_hub_admin: bool = False):
self.db = db
self.tenant_id = tenant_id
self.company_id = company_id
self.is_hub_admin = is_hub_admin
def _get_license(self) -> License:
"""Obtiene la licencia del tenant actual"""
@@ -125,6 +126,8 @@ class UserService:
def _check_user_limit(self) -> None:
"""Verifica si se puede crear un nuevo usuario según la licencia"""
if self.is_hub_admin:
return
license = self._get_license()
# Contar usuarios activos del tenant
@@ -139,6 +142,11 @@ class UserService:
.scalar()
)
# max_users=NULL en BD indica licencia sin cuota (ilimitada).
# Comparar con None lanzaría TypeError — salida temprana explícita.
if license.max_users is None:
return
if active_users >= license.max_users:
raise HTTPException(
status_code=403,
@@ -577,7 +585,7 @@ class UserService:
con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token;
inactivos y fallback de conteos en BD local.
"""
max_users_allowed = 0
max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
hub_max_ok = False
active_users = 0
active_from_hub = False
@@ -595,8 +603,10 @@ class UserService:
)
if lic_resp.status_code == 200:
lic_body = lic_resp.json()
if lic_body.get("valid") and lic_body.get("max_users") is not None:
max_users_allowed = int(lic_body["max_users"])
if lic_body.get("valid"):
raw_max = lic_body.get("max_users")
# max_users=null → hub_admin sin cuota; None indica ilimitado
max_users_allowed = int(raw_max) if raw_max is not None else None
hub_max_ok = True
users_resp = client.get(
@@ -642,9 +652,14 @@ class UserService:
)
total_users = active_users + inactive_users
users_available = max(0, max_users_allowed - active_users)
# Cuando max_users_allowed es None la cuota es ilimitada (hub_admin)
users_available = (
max(0, max_users_allowed - active_users)
if max_users_allowed is not None
else None
)
usage_percentage = (
(active_users / max_users_allowed * 100) if max_users_allowed > 0 else 0
(active_users / max_users_allowed * 100) if max_users_allowed else 0.0
)
return {

View File

@@ -464,6 +464,60 @@ def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optio
return None
def is_hub_admin(current_user: Dict[str, Any]) -> bool:
"""True si el usuario tiene el rol hub_admin (super-admin del Hub con acceso global)."""
roles = current_user.get("roles")
if isinstance(roles, list) and "hub_admin" in roles:
return True
return bool(current_user.get("is_hub_admin"))
def resolve_tenant_id_required(
current_user: Dict[str, Any],
db: Optional["Session"] = None,
company_id: Optional[int] = None,
) -> Optional[int]:
"""
Retorna el tenant_id efectivo o lanza 400.
Hub admin sin tenant_id en token: resuelve desde la empresa si company_id está disponible,
o retorna None como sentinel de acceso global (sin filtro de tenant).
"""
tid = get_tenant_from_token(current_user)
if tid is not None:
return int(tid)
raw = current_user.get("tenant_id")
if isinstance(raw, list) and raw:
raw = raw[0]
if raw is not None:
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid tenant ID in token")
if is_hub_admin(current_user):
if db is not None and company_id is not None:
from sqlalchemy.exc import SQLAlchemyError as _SAError
try:
from api.v1.modules.a76.general_catalogs.company.models import Company
company = db.query(Company).filter(Company.id == company_id).first()
if company and company.tenant_id:
return int(company.tenant_id)
except _SAError as exc:
# Un error de BD no debe escalar silenciosamente a acceso global
logger.error(
"Error de BD al resolver tenant para hub_admin company_id=%s: %s",
company_id,
exc,
)
raise HTTPException(
status_code=500, detail="Error al resolver el tenant_id"
)
# Sin company_id disponible → sentinel None para acceso global sin filtro de tenant
return None
raise HTTPException(status_code=400, detail="Tenant ID not found in user data")
def user_has_app_company_membership(
db: Session, user_id: str, company_id: int
) -> bool:
@@ -616,8 +670,11 @@ def validate_company_access(
)
if not company:
print(f"DEBUG: validate_company_access: No se encontró la compañía {company_id} para el tenant {tenant_id}")
logger.warning(f"validate_company_access: No se encontró la compañía {company_id} para el tenant {tenant_id}")
logger.warning(
"validate_company_access: No se encontró la compañía %s para el tenant %s",
company_id,
tenant_id,
)
return company is not None
except Exception as e:
@@ -631,7 +688,7 @@ def validate_access_to_resource(
current_user: Dict[str, Any],
required_permissions: Optional[list[str]] = None,
require_all: bool = True,
) -> int:
) -> Optional[int]:
"""
Valida que el usuario tenga acceso a un recurso específico basado en company_id
y regresa el tenant_id. Opcionalmente verifica permisos.
@@ -652,9 +709,9 @@ def validate_access_to_resource(
tenant_id = resolve_effective_tenant_id_from_user(current_user)
# Admin global Keycloak / master: lista ``roles`` del Hub (/auth/me), con fallback JWT.
# Admin global Keycloak / master, o hub_admin del Hub.
all_user_roles = collect_user_role_names(current_user)
is_keycloak_admin = "admin" in all_user_roles
is_keycloak_admin = "admin" in all_user_roles or is_hub_admin(current_user)
# 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap
# Detectamos si no se requieren permisos (típico de /me)
@@ -662,18 +719,21 @@ def validate_access_to_resource(
if not is_keycloak_admin and not is_me_endpoint:
if not validate_company_access(db, company_id, current_user):
print(f"DEBUG: Acceso denegado a compañía {company_id}")
raise HTTPException(status_code=403, detail="Access denied to this company")
# Si no hay tenant_id, intentamos recuperarlo de la empresa
if not tenant_id:
from sqlalchemy.exc import SQLAlchemyError as _SAError
try:
from api.v1.modules.a76.general_catalogs.company.models import Company
company = db.query(Company).filter(Company.id == company_id).first()
if company:
tenant_id = company.tenant_id
except:
pass
except _SAError as exc:
logger.error(
"Error de BD al resolver tenant company_id=%s: %s", company_id, exc
)
raise HTTPException(status_code=500, detail="Error al resolver el tenant_id")
# Si aún no hay tenant_id y no es admin, error 400
if not tenant_id and not is_keycloak_admin and not is_me_endpoint:
@@ -682,7 +742,14 @@ def validate_access_to_resource(
# Verificar permisos locales
if required_permissions:
if is_keycloak_admin:
return tenant_id or 1
# hub_admin siempre debe tener tenant_id resuelto cuando se exigen permisos;
# retornar 1 silenciosamente sería acceso al tenant equivocado
if tenant_id is None:
raise HTTPException(
status_code=400,
detail="No se pudo resolver el tenant_id para la empresa especificada",
)
return int(tenant_id)
from api.v1.modules.core.permissions.service import PermissionService
user_id = current_user.get("sub") or current_user.get("id")
@@ -707,12 +774,25 @@ def validate_access_to_resource(
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
if has_access:
print(f"DEBUG: Auto-bootstrap exitoso para {user_id} en empresa {company_id}")
logger.info(
"Auto-bootstrap exitoso para user_id=%s company_id=%s", user_id, company_id
)
except Exception as e:
print(f"DEBUG: Error en auto-bootstrap de seguridad: {e}")
logger.warning(
"Error en auto-bootstrap de seguridad user_id=%s company_id=%s: %s",
user_id, company_id, e,
)
if not has_access:
print(f"DEBUG: Permiso denegado. Faltan: {required_permissions}")
raise HTTPException(status_code=403, detail="Permission denied")
return tenant_id or 1
# Nunca sustituir tenant_id=None/0 silenciosamente — un valor inválido aquí
# significaría acceso al tenant equivocado. Si llegamos aquí sin tenant_id
# válido para un usuario no-admin, es un estado inconsistente que debe fallar.
if not isinstance(tenant_id, int) or tenant_id <= 0:
if not is_keycloak_admin:
raise HTTPException(
status_code=400,
detail="No se pudo determinar el tenant_id para la empresa especificada",
)
return tenant_id # puede ser None solo para hub_admin sin required_permissions (acceso global)