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/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py index 97421872..7ff5f030 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -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( 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..012e7306 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -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) 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..3563c0cc 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -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", 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..f34474bc 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 @@ -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 { diff --git a/backend/core/security.py b/backend/core/security.py index 9fa07dd8..1db3dacc 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -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) diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 7934463e..4367ac53 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -256,6 +256,8 @@ export interface Company { } export interface CompanyCreate { + /** Solo requerido cuando hub_admin crea una empresa (no tiene tenant_id en token) */ + tenant_id?: number | null; name?: string | null; rfc?: string | null; curp?: string | null; diff --git a/frontend/src/lib/components/dashboard/general_catalogs/company/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/company/create-edit-dialog.svelte index 933f4680..a347593d 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/company/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/company/create-edit-dialog.svelte @@ -104,6 +104,8 @@ // Construir payload limpiando strings vacíos a null const dataToSend = { + // Hub admin no tiene tenant_id en token; lo tomamos de la empresa activa + tenant_id: companyStore.activeCompany?.tenant_id ?? undefined, name: formData.name.trim(), rfc: formData.rfc.trim(), curp: formData.curp.trim() || null, diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 71af52c8..be094a8f 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -589,6 +589,8 @@ const payload = { ...formData, + // Hub admin no tiene tenant_id en token; se infiere de la empresa activa en contexto + tenant_id: companyStore.activeCompany?.tenant_id ?? undefined, name: formData.name.trim(), rfc: formData.rfc.trim(), responsible: