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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user