diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 673aeca5..5329a9dd 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -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( diff --git a/backend/api/v1/modules/a76/customs_brokers/routes.py b/backend/api/v1/modules/a76/customs_brokers/routes.py index 03d0d45c..74da7465 100644 --- a/backend/api/v1/modules/a76/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/customs_brokers/routes.py @@ -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: diff --git a/backend/api/v1/modules/a76/factura_cove/routes.py b/backend/api/v1/modules/a76/factura_cove/routes.py index 969feaa0..e9f16786 100644 --- a/backend/api/v1/modules/a76/factura_cove/routes.py +++ b/backend/api/v1/modules/a76/factura_cove/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 00191e97..395befcb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -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: @@ -133,11 +122,11 @@ async def create_company( ): _assert_permission_any_company(db, current_user, "cat_company.create") - tenant_id = current_user.get("tenant_id") - if not tenant_id: + tenant_id = resolve_tenant_id_required(current_user, db=db) + if tenant_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", + detail="Hub admin: debe especificar tenant_id para crear una empresa", ) service = CompanyService(db) @@ -161,12 +150,7 @@ 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) skip = (page - 1) * page_size filters = {} @@ -214,6 +198,24 @@ async def get_my_companies( Un usuario solo con roles de app y sin ``tenant_id`` en /auth/me sigue pudiendo listar sus compañías asignadas. """ + from core.security import collect_user_role_names + user_roles = collect_user_role_names(current_user) + + # Hub admin: visibilidad global sobre todas las compañías sin restricciones + # de tenant ni licencia. El Hub ya validó el rol en /auth/me. + if "hub_admin" in user_roles: + service = CompanyService(db) + all_companies = ( + db.query(Company) + .filter(Company.deleted_at.is_(None)) + .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 +247,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 +274,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 +346,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 +372,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 +455,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) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index a45b11ea..42a84244 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py index 43d713d7..7c85311e 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -764,20 +764,12 @@ def generate_invoice_report_async( # 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", - ) + + # tenant_id ya validado por validate_access_to_resource 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", diff --git a/backend/api/v1/modules/core/tasks_tracking/routes.py b/backend/api/v1/modules/core/tasks_tracking/routes.py index c7141e69..8ab99d83 100644 --- a/backend/api/v1/modules/core/tasks_tracking/routes.py +++ b/backend/api/v1/modules/core/tasks_tracking/routes.py @@ -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]], diff --git a/backend/api/v1/modules/core/tasks_tracking/service.py b/backend/api/v1/modules/core/tasks_tracking/service.py index d3485d68..500b71aa 100644 --- a/backend/api/v1/modules/core/tasks_tracking/service.py +++ b/backend/api/v1/modules/core/tasks_tracking/service.py @@ -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: diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 8856d43d..4dd1a10d 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -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"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 9d7f1b4b..36035d23 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -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 @@ -595,8 +598,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; usamos 0 como sentinel + max_users_allowed = int(raw_max) if raw_max is not None else 0 hub_max_ok = True users_resp = client.get( diff --git a/backend/core/security.py b/backend/core/security.py index 9fa07dd8..12a1bfdd 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -464,6 +464,51 @@ 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: + 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 Exception: + pass + # Sin company_id disponible: sentinel None → el servicio no filtra por 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: @@ -652,9 +697,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)