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

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

View File

@@ -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)