feature/permisos-keycloak-correccion

This commit is contained in:
2026-05-04 13:22:18 -06:00
parent 376b99d1f9
commit 97bb5c23b6
15 changed files with 563 additions and 150 deletions

View File

@@ -7,10 +7,10 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_, desc, distinct
from sqlalchemy import or_, desc
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, validate_access_to_resource
from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket
from .models import AuditLog
from .schemas import (
@@ -42,13 +42,6 @@ _SEGMENT_LABELS = {
}
def _tenant_id_from_user(current_user: Dict[str, Any]) -> int:
tenant_id = get_tenant_from_token(current_user) or current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=401, detail="User context is invalid")
return int(tenant_id)
def _normalize_relative_path(raw: Optional[str]) -> str:
if not raw:
return ""
@@ -181,6 +174,7 @@ def _build_breadcrumbs(
@router.get("/bitacora", response_model=AuditLogListResponse)
async def get_bitacora(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
search: Optional[str] = None,
@@ -189,13 +183,24 @@ async def get_bitacora(
reference: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_core_db)
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Get legacy audit log (Bitácora)
Bitácora por compañía. Requiere permiso ``audit_logs.view``.
"""
query = db.query(AuditLog)
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
query = db.query(AuditLog).filter(
AuditLog.company_id == company_id,
AuditLog.tenant_id == tenant_id,
)
# Filters
if date_from:
query = query.filter(AuditLog.date >= date_from)
@@ -237,22 +242,59 @@ async def get_bitacora(
}
@router.get("/bitacora/procedimientos", response_model=List[str])
async def get_procedures(db: Session = Depends(get_core_db)):
async def get_procedures(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Get distinct list of procedures for filters
Lista de procedimientos para filtros (alcance compañía). Requiere ``audit_logs.view``.
"""
results = db.query(distinct(AuditLog.procedure))\
.order_by(AuditLog.procedure)\
.all()
# verify if result is tuple
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
results = (
db.query(AuditLog.procedure)
.filter(
AuditLog.company_id == company_id,
AuditLog.tenant_id == tenant_id,
)
.distinct()
.order_by(AuditLog.procedure)
.all()
)
return [r[0] for r in results if r[0]]
@router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse)
async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)):
async def get_audit_detail(
spec_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Get full detail of a log entry
Detalle de un registro de bitácora. Requiere ``audit_logs.view``.
"""
log = db.query(AuditLog).filter(AuditLog.spec_id == spec_id).first()
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
log = (
db.query(AuditLog)
.filter(
AuditLog.spec_id == spec_id,
AuditLog.company_id == company_id,
AuditLog.tenant_id == tenant_id,
)
.first()
)
if not log:
raise HTTPException(status_code=404, detail="Log entry not found")
return log
@@ -260,6 +302,7 @@ async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)):
@router.get("/files", response_model=AuditFileBrowserResponse)
async def list_tenant_files(
company_id: int = Query(..., description="Company ID"),
path: Optional[str] = Query(default="", description="Ruta relativa de navegación."),
continuation_token: Optional[str] = Query(default=None),
max_keys: int = Query(default=100, ge=1, le=500),
@@ -268,11 +311,17 @@ async def list_tenant_files(
):
"""
Explorador de archivos de solo lectura para Auditoría.
Requiere permiso ``audit_logs.view``; el prefijo S3 sigue al tenant de la compañía.
"""
if not should_ensure_s3_bucket():
raise HTTPException(status_code=400, detail="S3 storage is disabled")
tenant_id = _tenant_id_from_user(current_user)
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
tenant_prefix = _tenant_prefix(tenant_id)
rel_path = _normalize_relative_path(path)
list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix
@@ -349,16 +398,24 @@ async def list_tenant_files(
@router.get("/files/download")
async def download_tenant_file(
company_id: int = Query(..., description="Company ID"),
path: str = Query(..., description="Ruta relativa del archivo a descargar."),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Descarga segura (backend streaming) de archivos autorizados.
Requiere ``audit_logs.view``.
"""
if not should_ensure_s3_bucket():
raise HTTPException(status_code=400, detail="S3 storage is disabled")
tenant_id = _tenant_id_from_user(current_user)
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["audit_logs.view"],
)
tenant_prefix = _tenant_prefix(tenant_id)
rel_path = _normalize_relative_path(path)
if not rel_path or rel_path.endswith("/"):

View File

@@ -18,7 +18,12 @@ from core.config import settings
from core.database import get_core_db
from core.s3_keys import company_certificate_key, company_logo_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
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_effective_tenant_id_from_user,
validate_access_to_resource,
)
from .....common.tenant_crud_routes import TenantCRUDRoutes
from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
@@ -156,19 +161,23 @@ async def get_my_companies(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Get all companies that belong to the current user's tenant"""
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",
)
"""
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.
"""
user_id = current_user.get("sub") or current_user.get("id")
tenant_id = resolve_effective_tenant_id_from_user(current_user)
service = CompanyService(db)
companies = service.get_companies_by_tenant(tenant_id)
companies = service.get_companies_for_session_user(
keycloak_user_id=str(user_id) if user_id else None,
tenant_id_from_token=tenant_id,
)
return [
CompanyResponseDTO.model_validate(service.flatten_company_dto(company))
CompanyResponseDTO.model_validate(service.flatten_company_dto(company))
for company in companies
]

View File

@@ -4,7 +4,7 @@ Capa de servicio para lógica de negocio de empresa
import logging
from datetime import datetime
from typing import List, Optional, Tuple, Dict, Any
from typing import List, Optional, Tuple, Dict, Any, Set
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
@@ -824,6 +824,36 @@ class CompanyService:
.all()
)
def get_companies_for_session_user(
self,
keycloak_user_id: Optional[str],
tenant_id_from_token: Optional[int],
) -> List[Company]:
"""
Compañías visibles para el usuario: unión de (a) todas las del tenant si el
token/Hub aporta tenant_id, y (b) compañías con membership RBAC o user_tenants.
Permite usuarios sin tenant_id en el JWT pero con roles asignados en la app.
"""
from core.security import collect_company_ids_from_app_membership
company_ids: Set[int] = set(
collect_company_ids_from_app_membership(
self.db, keycloak_user_id or ""
)
)
if tenant_id_from_token is not None:
for c in self.get_companies_by_tenant(int(tenant_id_from_token)):
company_ids.add(c.id)
if not company_ids:
return []
return (
self.db.query(Company)
.filter(Company.id.in_(company_ids), Company.deleted_at.is_(None))
.order_by(Company.name)
.all()
)
def exists_company(self, tenant_id: int) -> bool:
"""Check if a company exists for a tenant"""
return (

View File

@@ -42,6 +42,25 @@ from .schemas import (
router = APIRouter(prefix="/permissions", tags=["permissions"])
def _tenant_if_roles_or_user_admin(
db: Session,
company_id: int,
current_user: Dict[str, Any],
) -> int:
"""
Acceso a plantillas de rol y asignaciones en compañía: ``roles.view`` o gestión de
usuarios (``user.view`` / ``user.update`` / ``user.manage``). Evita exigir
``roles.view`` a quien solo administra usuarios con permisos de app.
"""
return validate_access_to_resource(
db,
company_id,
current_user,
["roles.view", "user.view", "user.update", "user.manage"],
require_all=False,
)
# RUTAS DE CONSULTA DE PERMISOS
@@ -137,8 +156,8 @@ async def list_company_roles(
Lista todos los roles del companye actual.
TODO: Agregar verificación de permisos
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
query = db.query(CompanyRole).filter(
CompanyRole.company_id == company_id
)
@@ -156,13 +175,12 @@ async def get_user_permissions(
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
permission_service: PermissionService = Depends(get_permission_service),
_: None = Depends(RequirePermission("user.view")),
):
"""
Obtiene los permisos y roles de un usuario específico.
Requiere permiso: user.view
Requiere roles.view o permisos de gestión de usuarios en la compañía.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
permissions = permission_service.get_user_permissions(user_id, company_id)
roles = permission_service.get_user_roles(user_id, company_id)
@@ -268,7 +286,7 @@ async def list_user_roles(
from .models import UserCompanyRole
from sqlalchemy.orm import joinedload
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
query = db.query(UserCompanyRole).options(
joinedload(UserCompanyRole.company_role)
@@ -305,7 +323,7 @@ async def assign_user_role(
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
assigner_id = current_user.get("sub") or current_user.get("id")
# Verificar que el rol existe
@@ -361,7 +379,7 @@ async def remove_user_role(
"""
from .models import UserCompanyRole
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
user_role = db.query(UserCompanyRole).filter(
UserCompanyRole.id == user_role_id,
@@ -664,7 +682,7 @@ async def get_role_permissions(
"""
from .models import RolePermission
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
# Verificar que el rol existe y pertenece al company
role = (
@@ -722,7 +740,7 @@ async def assign_permission_to_role(
"""
from .models import RolePermission
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
# Verificar que el rol existe
role = (
@@ -788,7 +806,7 @@ async def assign_multiple_permissions_to_role(
"""
from .models import RolePermission
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
# Verificar que el rol existe
role = (
@@ -857,7 +875,7 @@ async def remove_permission_from_role(
"""
from .models import RolePermission
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
# Buscar la asignación
role_permission = (
@@ -898,7 +916,7 @@ async def assign_role(
Asigna un rol a un usuario en el companye actual.
TODO: Agregar verificación de permisos
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
assigner_id = current_user.get("sub") or current_user.get("id")
@@ -929,13 +947,18 @@ async def grant_permission(
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
permission_service: PermissionService = Depends(get_permission_service),
_: None = Depends(RequirePermission("permissions.grant")),
):
"""
Concede un permiso directo a un usuario en el companye actual.
Requiere permiso: permissions.grant
Requiere ``permissions.grant`` o gestión de usuarios (``user.manage`` / ``user.update``).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
["permissions.grant", "user.manage", "user.update"],
require_all=False,
)
assigner_id = current_user.get("sub") or current_user.get("id")
@@ -1064,7 +1087,7 @@ async def get_user_individual_permissions(
from .models import UserCompanyPermission
from sqlalchemy.orm import joinedload
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
user_permissions = (
db.query(UserCompanyPermission)
@@ -1101,7 +1124,7 @@ async def get_user_effective_permissions(
from .models import UserCompanyPermission, UserCompanyRole, RolePermission
from sqlalchemy.orm import joinedload
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
# 1. Obtener permisos de roles
role_permissions_query = (
@@ -1182,7 +1205,7 @@ async def assign_user_permission(
"""
from .models import UserCompanyPermission
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
# Verificar que el permiso existe
permission = db.query(Permission).filter(Permission.id == request.permission_id).first()
@@ -1245,7 +1268,7 @@ async def remove_user_permission(
"""
from .models import UserCompanyPermission
tenant_id = validate_access_to_resource(db, company_id, current_user, ["roles.view"])
tenant_id = _tenant_if_roles_or_user_admin(db, company_id, current_user)
user_permission = (
db.query(UserCompanyPermission)

View File

@@ -3,6 +3,7 @@ Servicio de gestión de permisos multi-tenant.
Proporciona funciones para verificar y obtener permisos de usuarios por companye.
"""
import logging
from datetime import datetime
from typing import Set, Optional, List
from sqlalchemy.orm import Session
@@ -15,6 +16,8 @@ from .models import (
UserCompanyPermission,
)
logger = logging.getLogger(__name__)
class PermissionService:
"""
@@ -25,6 +28,24 @@ class PermissionService:
def __init__(self, db: Session):
self.db = db
def _ensure_user_tenant_row_for_company(
self, user_id: str, company_id: int
) -> None:
"""Mantiene core.user_tenants alineado con asignaciones RBAC de la app."""
try:
from api.v1.modules.a76.general_catalogs.company.models import Company
from core.security import _ensure_user_tenant_for_company
company = (
self.db.query(Company).filter(Company.id == company_id).first()
)
if company:
_ensure_user_tenant_for_company(
self.db, user_id, company.tenant_id, company_id
)
except Exception as e:
logger.warning("user_tenants sync skipped: %s", e)
def get_user_permissions(
self, user_id: str, company_id: int, use_cache: bool = False
) -> Set[str]:
@@ -219,7 +240,9 @@ class PermissionService:
existing.assigned_at = datetime.utcnow()
existing.assigned_by = assigned_by
self.db.commit()
self._ensure_user_tenant_row_for_company(user_id, company_id)
return existing
self._ensure_user_tenant_row_for_company(user_id, company_id)
return existing
# Crear nueva asignación
@@ -234,6 +257,8 @@ class PermissionService:
self.db.commit()
self.db.refresh(user_role)
self._ensure_user_tenant_row_for_company(user_id, company_id)
return user_role
def grant_direct_permission(
@@ -279,6 +304,7 @@ class PermissionService:
existing.assigned_by = assigned_by
existing.expires_at = expires_at
self.db.commit()
self._ensure_user_tenant_row_for_company(user_id, company_id)
return existing
# Crear nuevo permiso directo
@@ -295,6 +321,8 @@ class PermissionService:
self.db.commit()
self.db.refresh(user_permission)
self._ensure_user_tenant_row_for_company(user_id, company_id)
return user_permission
def sync_permissions(self) -> dict: