feat(auth): add hub_admin global access support across modules

- Add is_hub_admin() and resolve_tenant_id_required() to core/security
- hub_admin resolves tenant from company or uses None as global sentinel
- Update routes and services to skip tenant filter when tenant_id is None
- UserService accepts is_hub_admin flag for cross-tenant user management
- get_my_companies returns all companies for hub_admin without tenant restriction
This commit is contained in:
2026-05-15 09:22:35 -05:00
parent 0c876b11bf
commit 125405458c
11 changed files with 156 additions and 153 deletions

View File

@@ -3,7 +3,7 @@ import logging
import inspect import inspect
from core.database import get_core_db 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 fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
from api.v1.common.catalog_validation_errors import CatalogValidationError from api.v1.common.catalog_validation_errors import CatalogValidationError
@@ -155,16 +155,9 @@ class TenantCRUDRoutes(
db: Session = Depends(self.db_dependency), db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency),
): ):
from core.security import get_tenant_from_token
if all_companies: if all_companies:
tenant_id = get_tenant_from_token(current_user) # Hub admin: tenant_id=None → el servicio devuelve todas las empresas
if not tenant_id: tenant_id = resolve_tenant_id_required(current_user, db=db)
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.
target_company_id = None target_company_id = None
else: else:
tenant_id = validate_access_to_resource( tenant_id = validate_access_to_resource(
@@ -177,7 +170,7 @@ class TenantCRUDRoutes(
target_company_id = company_id target_company_id = company_id
skip = (page - 1) * page_size skip = (page - 1) * page_size
# Extraer todos los parámetros de búsqueda dinámicamente # Extraer todos los parámetros de búsqueda dinámicamente
# Excluimos los parámetros estándar de paginación y control # Excluimos los parámetros estándar de paginación y control
standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"} 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), db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency),
): ):
from core.security import get_tenant_from_token
if all_companies: if all_companies:
tenant_id = get_tenant_from_token(current_user) # Hub admin: tenant_id=None → el servicio devuelve todas las empresas
if not tenant_id: tenant_id = resolve_tenant_id_required(current_user, db=db)
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
target_company_id = None target_company_id = None
else: else:
tenant_id = validate_access_to_resource( 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_doda_private_key_key,
customs_broker_vu_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 core.storage_s3 import delete_object_if_exists, put_object_bytes
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes 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) MAX_COVE_BYTES = 15 * 1024 * 1024 # 15 MB (xml/zip)
def _resolve_tenant_id_int(current_user: dict) -> int: def _resolve_tenant_id_int(
tid = get_tenant_from_token(current_user) current_user: dict,
if tid is not None: db=None,
return int(tid) company_id: int = None,
raw = current_user.get("tenant_id") ):
if isinstance(raw, list) and raw: """Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
raw = raw[0] return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
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 _remove_stored_vu_path(ref: Optional[str]) -> None: 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 - DODA: doda_certificate_path, doda_key_path, doda_xml_files_path
""" """
validate_access_to_resource(db, company_id, current_user, ["customs_brokers.create"]) 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) broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker: if not broker:

View File

@@ -14,7 +14,7 @@ from core.config import settings
from core.database import get_core_db from core.database import get_core_db
from core.exceptions import ValidationException from core.exceptions import ValidationException
from core.s3_keys import cove_acuse_pdf_key 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 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) Evalúa si la factura tiene todos los datos necesarios (VU, factura, partidas)
para poder generar un COVE. No dispara la tarea Celery. para poder generar un COVE. No dispara la tarea Celery.
""" """
tenant_id = get_tenant_from_token(current_user) tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id_int = int(tenant_id)
service = FacturaCoveDomainService(db) 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 return eligibility

View File

@@ -23,7 +23,9 @@ from core.security import (
collect_user_role_names, collect_user_role_names,
get_current_user, get_current_user,
get_tenant_from_token, get_tenant_from_token,
is_hub_admin,
resolve_effective_tenant_id_from_user, resolve_effective_tenant_id_from_user,
resolve_tenant_id_required,
validate_access_to_resource, validate_access_to_resource,
) )
from .....common.tenant_crud_routes import TenantCRUDRoutes from .....common.tenant_crud_routes import TenantCRUDRoutes
@@ -40,7 +42,7 @@ logger = logging.getLogger(__name__)
def _user_is_admin(current_user: dict) -> bool: 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( 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]) return validate_access_to_resource(db, company_id, current_user, [permission_code])
def _resolve_tenant_id_int(current_user: dict) -> int: def _resolve_tenant_id_int(
"""Misma lógica que validate_access_to_resource: entero estable para BD y claves S3.""" current_user: dict,
tid = get_tenant_from_token(current_user) db: Session = None,
if tid is not None: company_id: int = None,
return int(tid) ) -> Optional[int]:
raw = current_user.get("tenant_id") """Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
if isinstance(raw, list) and raw: return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
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 _is_s3_object_key(ref: Optional[str]) -> bool: 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") _assert_permission_any_company(db, current_user, "cat_company.create")
tenant_id = current_user.get("tenant_id") tenant_id = resolve_tenant_id_required(current_user, db=db)
if not tenant_id: if tenant_id is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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) service = CompanyService(db)
@@ -161,12 +150,7 @@ async def list_companies(
"""Get paginated list of companies for current tenant with optional filters""" """Get paginated list of companies for current tenant with optional filters"""
_assert_permission_any_company(db, current_user, "cat_company.view") _assert_permission_any_company(db, current_user, "cat_company.view")
tenant_id = current_user.get("tenant_id") tenant_id = resolve_tenant_id_required(current_user, db=db)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
skip = (page - 1) * page_size skip = (page - 1) * page_size
filters = {} 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 Un usuario solo con roles de app y sin ``tenant_id`` en /auth/me sigue pudiendo
listar sus compañías asignadas. 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") _assert_permission_any_company(db, current_user, "cat_company.view")
user_id = current_user.get("sub") or current_user.get("id") 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""" """Get a specific company by ID"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.view") _assert_permission_for_company(db, company_id, current_user, "cat_company.view")
tenant_id = current_user.get("tenant_id") tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
service = CompanyService(db) service = CompanyService(db)
company = CompanyService.get_by_id(db, company_id, tenant_id, 0) company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
@@ -277,12 +274,7 @@ async def update_company(
"""Update a company""" """Update a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit") _assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = current_user.get("tenant_id") tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
service = CompanyService(db) service = CompanyService(db)
updated_company = service.update(db, company_id, tenant_id, 0, data) updated_company = service.update(db, company_id, tenant_id, 0, data)
@@ -354,12 +346,7 @@ async def delete_company(
"""Delete a company""" """Delete a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.delete") _assert_permission_for_company(db, company_id, current_user, "cat_company.delete")
tenant_id = current_user.get("tenant_id") tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
success = CompanyService.delete(db, company_id, tenant_id, 0) success = CompanyService.delete(db, company_id, tenant_id, 0)
if not success: if not success:
@@ -385,7 +372,7 @@ async def upload_company_logo(
"""Upload a logo for a company""" """Upload a logo for a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit") _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 # Validar que la empresa existe
company = CompanyService.get_by_id(db, company_id, tenant_id, 0) 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") _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 # Validar que la empresa existe
service = CompanyService(db) service = CompanyService(db)

View File

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

View File

@@ -764,20 +764,12 @@ def generate_invoice_report_async(
# Serialize filters to dict for Celery # Serialize filters to dict for Celery
filter_data = filters.model_dump() filter_data = filters.model_dump()
user_email = current_user.get('email') user_email = current_user.get('email')
# Trigger task # tenant_id ya validado por validate_access_to_resource
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( task = track_and_dispatch(
db=db, db=db,
task=generate_invoice_movements_async, task=generate_invoice_movements_async,
tenant_id=int(tenant_id), tenant_id=tenant_id,
company_id=company_id, company_id=company_id,
requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"), requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"),
task_name="generate_invoice_movements_async", 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 sqlalchemy.orm import Session
from core.database import get_core_db 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 .models import TaskRun, TaskStatus
from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest
@@ -58,9 +58,7 @@ def list_tasks(
current_user: dict[str, Any] = Depends(get_current_user), current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
tenant_id = get_tenant_from_token(current_user) tenant_id = resolve_tenant_id_required(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tracker = TaskTrackerService(db) tracker = TaskTrackerService(db)
if sync_active: if sync_active:
@@ -93,11 +91,12 @@ def get_task_detail(
current_user: dict[str, Any] = Depends(get_current_user), current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
tenant_id = get_tenant_from_token(current_user) tenant_id = resolve_tenant_id_required(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
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: if not row:
raise HTTPException(status_code=404, detail="Task not found") 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), current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
tenant_id = get_tenant_from_token(current_user) tenant_id = resolve_tenant_id_required(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tracker = TaskTrackerService(db) tracker = TaskTrackerService(db)
updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids) updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids)
return {"updated": updated} return {"updated": updated}
@@ -133,15 +130,18 @@ def get_catalogs(
current_user: dict[str, Any] = Depends(get_current_user), current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
tenant_id = get_tenant_from_token(current_user) tenant_id = resolve_tenant_id_required(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
groups = ( groups_q = db.query(TaskRun.task_group)
db.query(TaskRun.task_group).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.task_group).all() names_q = db.query(TaskRun.task_name)
) statuses_q = db.query(TaskRun.status)
names = db.query(TaskRun.task_name).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.task_name).all() if tenant_id is not None:
statuses = db.query(TaskRun.status).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.status).all() 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( return TaskCatalogsResponse(
task_groups=[g[0] for g in groups if g[0]], task_groups=[g[0] for g in groups if g[0]],
task_names=[n[0] for n in names if n[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) self.db.refresh(task_run)
return 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( 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: if task_ids:
query = query.filter(TaskRun.task_id.in_(task_ids)) query = query.filter(TaskRun.task_id.in_(task_ids))
rows = query.limit(200).all() rows = query.limit(200).all()
@@ -207,7 +209,7 @@ class TaskTrackerService:
def list_tasks( def list_tasks(
self, self,
*, *,
tenant_id: int, tenant_id: int | None,
page: int, page: int,
page_size: int, page_size: int,
status: list[str] | None = None, status: list[str] | None = None,
@@ -217,7 +219,9 @@ class TaskTrackerService:
search: str | None = None, search: str | None = None,
order: str = "desc", order: str = "desc",
) -> tuple[list[TaskRun], int]: ) -> 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: if status:
query = query.filter(TaskRun.status.in_(status)) query = query.filter(TaskRun.status.in_(status))
if task_group: 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.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
from core.security import ( from core.security import (
get_current_user, get_current_user,
is_hub_admin,
resolve_hub_tenant_id_for_api, resolve_hub_tenant_id_for_api,
validate_access_to_resource, validate_access_to_resource,
) )
@@ -51,7 +52,7 @@ async def get_user_statistics(
Obtiene estadísticas de usuarios del tenant actual Obtiene estadísticas de usuarios del tenant actual
""" """
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) 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 "" auth_header = request.headers.get("Authorization") or ""
token = ( token = (
auth_header[7:].strip() auth_header[7:].strip()
@@ -82,7 +83,7 @@ async def list_users(
Lista todos los usuarios del tenant con paginación Lista todos los usuarios del tenant con paginación
""" """
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) 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 "" auth_header = request.headers.get("Authorization") or ""
token = ( token = (
auth_header[7:].strip() auth_header[7:].strip()
@@ -342,7 +343,7 @@ async def get_user_detail(
Obtiene información detallada de un usuario específico Obtiene información detallada de un usuario específico
""" """
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) 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) 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 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"]) 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( user = await service.create_user(
email=data.email, email=data.email,
username=data.username, username=data.username,
@@ -383,7 +384,7 @@ async def update_user_detail(
Actualiza información de un usuario Actualiza información de un usuario
""" """
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"]) 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 = await service.update_user(
user_id=user_id, user_id=user_id,
first_name=data.first_name, first_name=data.first_name,
@@ -413,7 +414,7 @@ async def get_user_tenant_count(
tenant_id = validate_access_to_resource( tenant_id = validate_access_to_resource(
db, company_id, current_user, required_permissions=["user.view"] 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) count = service.get_user_tenant_count(user_id)
return {"tenant_count": count} return {"tenant_count": count}
@@ -449,7 +450,7 @@ async def delete_user_route(
hub_tid = resolve_hub_tenant_id_for_api( hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override") 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( await service.delete_user(
user_id, user_id,
soft_delete=soft_delete, 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 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"]) 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) await service.change_password(user_id, data.password, data.temporary)
return {"message": "Password changed successfully"} return {"message": "Password changed successfully"}

View File

@@ -94,10 +94,11 @@ def _normalize_user(
class UserService: class UserService:
"""Servicio para gestionar usuarios vía Hub""" """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.db = db
self.tenant_id = tenant_id self.tenant_id = tenant_id
self.company_id = company_id self.company_id = company_id
self.is_hub_admin = is_hub_admin
def _get_license(self) -> License: def _get_license(self) -> License:
"""Obtiene la licencia del tenant actual""" """Obtiene la licencia del tenant actual"""
@@ -125,6 +126,8 @@ class UserService:
def _check_user_limit(self) -> None: def _check_user_limit(self) -> None:
"""Verifica si se puede crear un nuevo usuario según la licencia""" """Verifica si se puede crear un nuevo usuario según la licencia"""
if self.is_hub_admin:
return
license = self._get_license() license = self._get_license()
# Contar usuarios activos del tenant # Contar usuarios activos del tenant
@@ -595,8 +598,10 @@ class UserService:
) )
if lic_resp.status_code == 200: if lic_resp.status_code == 200:
lic_body = lic_resp.json() lic_body = lic_resp.json()
if lic_body.get("valid") and lic_body.get("max_users") is not None: if lic_body.get("valid"):
max_users_allowed = int(lic_body["max_users"]) 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 hub_max_ok = True
users_resp = client.get( users_resp = client.get(

View File

@@ -464,6 +464,51 @@ def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optio
return None 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( def user_has_app_company_membership(
db: Session, user_id: str, company_id: int db: Session, user_id: str, company_id: int
) -> bool: ) -> bool:
@@ -652,9 +697,9 @@ def validate_access_to_resource(
tenant_id = resolve_effective_tenant_id_from_user(current_user) 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) 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 # 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap
# Detectamos si no se requieren permisos (típico de /me) # Detectamos si no se requieren permisos (típico de /me)