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 (