From 97bb5c23b6b5c36732ba0e5de7a21a202020de71 Mon Sep 17 00:00:00 2001 From: hreyes Date: Mon, 4 May 2026 13:22:18 -0600 Subject: [PATCH] feature/permisos-keycloak-correccion --- .../api/v1/modules/a76/audit_log/router.py | 105 +++++++++--- .../a76/general_catalogs/company/routes.py | 29 ++-- .../a76/general_catalogs/company/service.py | 32 +++- .../api/v1/modules/core/permissions/routes.py | 63 +++++--- .../v1/modules/core/permissions/service.py | 28 ++++ backend/core/security.py | 151 +++++++++++++++--- .../src/lib/api/dashboard/a76/audit_files.ts | 22 ++- .../src/lib/api/dashboard/a76/audit_log.ts | 116 +++++++------- frontend/src/lib/auth.ts | 38 ++++- .../src/lib/components/sidebar/modules.ts | 1 + frontend/src/lib/stores/company.svelte.ts | 8 +- .../routes/dashboard/audit_logs/+page.svelte | 12 ++ .../dashboard/audit_logs/bitacora-tab.svelte | 37 ++++- .../dashboard/audit_logs/files-tab.svelte | 39 ++++- .../src/routes/dashboard/users/+page.svelte | 32 +++- 15 files changed, 563 insertions(+), 150 deletions(-) diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py index 4339286f..eff2f188 100644 --- a/backend/api/v1/modules/a76/audit_log/router.py +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -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("/"): diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 31eb0912..10c1d5c6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -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 ] diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 962292d8..a45b11ea 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -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 ( diff --git a/backend/api/v1/modules/core/permissions/routes.py b/backend/api/v1/modules/core/permissions/routes.py index 903db2ce..31a55e21 100644 --- a/backend/api/v1/modules/core/permissions/routes.py +++ b/backend/api/v1/modules/core/permissions/routes.py @@ -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) diff --git a/backend/api/v1/modules/core/permissions/service.py b/backend/api/v1/modules/core/permissions/service.py index b5f30f5d..d55fa25a 100644 --- a/backend/api/v1/modules/core/permissions/service.py +++ b/backend/api/v1/modules/core/permissions/service.py @@ -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: diff --git a/backend/core/security.py b/backend/core/security.py index 328ebdce..c2e12023 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -427,6 +427,129 @@ def resolve_hub_tenant_id_for_api( return int(_tenant_id_hub_by_local.get(lid, lid)) +def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optional[int]: + """ + tenant_id efectivo del usuario: claims del token vía get_tenant_from_token, + luego fallback a ``tenant_id`` plano del Hub (puede venir como lista). + + Contrato Hub: no es obligatorio que todo usuario tenga ``tenant_id`` en /auth/me; + el acceso por compañía puede basarse solo en RBAC local (ver ``user_has_app_company_membership``). + """ + tid = get_tenant_from_token(current_user) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if raw is None: + return None + if isinstance(raw, list) and raw: + raw = raw[0] + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def user_has_app_company_membership( + db: Session, user_id: str, company_id: int +) -> bool: + """ + True si el usuario tiene fila activa en RBAC de la app o en core.user_tenants + para esa compañía (independiente del tenant en el JWT). + """ + if not user_id: + return False + try: + from api.v1.modules.core.permissions.models import ( + UserCompanyPermission, + UserCompanyRole, + ) + from api.v1.modules.core.user_tenant.models import UserTenant + + if ( + db.query(UserCompanyRole) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, # noqa: E712 + ) + .first() + ): + return True + if ( + db.query(UserCompanyPermission) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.is_active == True, # noqa: E712 + ) + .first() + ): + return True + if ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.company_id == company_id, + UserTenant.is_active == True, # noqa: E712 + ) + .first() + ): + return True + except Exception as e: + logger.error("Error checking app company membership: %s", e) + return False + return False + + +def collect_company_ids_from_app_membership( + db: Session, user_id: str +) -> Set[int]: + """IDs de compañía donde el usuario tiene rol, permiso directo o user_tenants.""" + ids: Set[int] = set() + if not user_id: + return ids + try: + from api.v1.modules.core.permissions.models import ( + UserCompanyPermission, + UserCompanyRole, + ) + from api.v1.modules.core.user_tenant.models import UserTenant + + for (cid,) in ( + db.query(UserCompanyRole.company_id) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.is_active == True, # noqa: E712 + ) + .distinct() + .all() + ): + ids.add(int(cid)) + for (cid,) in ( + db.query(UserCompanyPermission.company_id) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.is_active == True, # noqa: E712 + ) + .distinct() + .all() + ): + ids.add(int(cid)) + for (cid,) in ( + db.query(UserTenant.company_id) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.is_active == True, # noqa: E712 + ) + .distinct() + .all() + ): + ids.add(int(cid)) + except Exception as e: + logger.error("Error collecting company ids from membership: %s", e) + return ids + + def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]: """ Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me @@ -454,26 +577,20 @@ def validate_company_access( db: Session, company_id: int, current_user: Dict[str, Any] ) -> bool: """ - Valida que el usuario tenga acceso a la compañía solicitada + Valida acceso a la compañía: (1) tenant del token/Hub alineado con la empresa, o + (2) membership en la app (RBAC / user_tenants) para ese ``company_id``. - Args: - company_id: ID de la compañía a la que se quiere acceder - current_user: Información del usuario actual desde el token - - Returns: - True si el usuario tiene acceso, False en caso contrario - - Nota: - Verifica que la compañía pertenezca al tenant del usuario consultando la BD. + El contrato con el Hub puede no incluir ``tenant_id`` para todos los usuarios; + en ese caso el acceso se basa en asignaciones en PostgreSQL. """ + user_id = current_user.get("sub") or current_user.get("id") + if user_id and user_has_app_company_membership(db, str(user_id), company_id): + return True - tenant_id = get_tenant_from_token(current_user) - - # Si no hay tenant_id en el token, denegar acceso + tenant_id = resolve_effective_tenant_id_from_user(current_user) if not tenant_id: return False - # Consultar si la compañía pertenece al tenant try: from api.v1.modules.a76.general_catalogs.company.models import Company @@ -485,7 +602,7 @@ def validate_company_access( return company is not None except Exception as e: - logger.error(f"Error validating company access: {str(e)}") + logger.error("Error validating company access: %s", e) return False @@ -514,9 +631,7 @@ def validate_access_to_resource( HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos """ - tenant_id = get_tenant_from_token(current_user) - if not tenant_id: - tenant_id = current_user.get("tenant_id") + tenant_id = resolve_effective_tenant_id_from_user(current_user) # Admin global Keycloak / master: lista ``roles`` del Hub (/auth/me), con fallback JWT. all_user_roles = collect_user_role_names(current_user) diff --git a/frontend/src/lib/api/dashboard/a76/audit_files.ts b/frontend/src/lib/api/dashboard/a76/audit_files.ts index def3ba73..10b06a87 100644 --- a/frontend/src/lib/api/dashboard/a76/audit_files.ts +++ b/frontend/src/lib/api/dashboard/a76/audit_files.ts @@ -29,18 +29,22 @@ export interface AuditFileListResponse { } export const AuditFilesAPI = { - list: async (params?: { - path?: string; - continuation_token?: string; - max_keys?: number; - }): Promise => { + list: async ( + companyId: number, + params?: { + path?: string; + continuation_token?: string; + max_keys?: number; + } + ): Promise => { const query = new URLSearchParams(); + query.set('company_id', String(companyId)); if (params?.path) query.set('path', params.path); if (params?.continuation_token) query.set('continuation_token', params.continuation_token); if (params?.max_keys) query.set('max_keys', String(params.max_keys)); const qs = query.toString(); - const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH; + const endpoint = `${BASE_PATH}?${qs}`; const response = await api.get(endpoint); if (response.error || !response.data) { throw new Error(response.error || 'Failed to list tenant files'); @@ -48,6 +52,8 @@ export const AuditFilesAPI = { return response.data; }, - downloadBlob: (path: string) => - api.getBlob(`${BASE_PATH}/download?path=${encodeURIComponent(path)}`) + downloadBlob: (companyId: number, path: string) => + api.getBlob( + `${BASE_PATH}/download?company_id=${companyId}&path=${encodeURIComponent(path)}` + ) }; diff --git a/frontend/src/lib/api/dashboard/a76/audit_log.ts b/frontend/src/lib/api/dashboard/a76/audit_log.ts index 0997e54c..7c272dbd 100644 --- a/frontend/src/lib/api/dashboard/a76/audit_log.ts +++ b/frontend/src/lib/api/dashboard/a76/audit_log.ts @@ -3,71 +3,77 @@ import { api } from '$lib/api'; const BASE_PATH = '/v1/a76/audit-log'; export interface AuditLog { - spec_id: number; - reference: string; - procedure: string; // "Procedimiento" - movement: string; // "Movimiento" - username: string; - date: string; // "YYYY-MM-DD" - time: string; // "HH:MM:SS" - timestamp: string; // ISO - system: string; - operation_type?: string; - table_name?: string; - old_values?: any; - new_values?: any; + spec_id: number; + reference: string; + procedure: string; // "Procedimiento" + movement: string; // "Movimiento" + username: string; + date: string; // "YYYY-MM-DD" + time: string; // "HH:MM:SS" + timestamp: string; // ISO + system: string; + operation_type?: string; + table_name?: string; + old_values?: any; + new_values?: any; } export interface AuditLogResponse { - data: AuditLog[]; - total: number; - page: number; - page_size: number; + data: AuditLog[]; + total: number; + page: number; + page_size: number; } export interface AuditLogParams { - page?: number; - page_size?: number; - search?: string; - username?: string; - procedure?: string; - reference?: string; - date_from?: string; - date_to?: string; + company_id: number; + page?: number; + page_size?: number; + search?: string; + username?: string; + procedure?: string; + reference?: string; + date_from?: string; + date_to?: string; } export const AuditLogAPI = { - getLogs: async (params: AuditLogParams = {}): Promise => { - const query = new URLSearchParams(); - if (params.page) query.append('page', params.page.toString()); - if (params.page_size) query.append('page_size', params.page_size.toString()); - if (params.search) query.append('search', params.search); - if (params.username) query.append('username', params.username); - if (params.procedure) query.append('procedure', params.procedure); - if (params.reference) query.append('reference', params.reference); - if (params.date_from) query.append('date_from', params.date_from); - if (params.date_to) query.append('date_to', params.date_to); + getLogs: async (params: AuditLogParams): Promise => { + const query = new URLSearchParams(); + query.set('company_id', String(params.company_id)); + if (params.page) query.append('page', params.page.toString()); + if (params.page_size) query.append('page_size', params.page_size.toString()); + if (params.search) query.append('search', params.search); + if (params.username) query.append('username', params.username); + if (params.procedure) query.append('procedure', params.procedure); + if (params.reference) query.append('reference', params.reference); + if (params.date_from) query.append('date_from', params.date_from); + if (params.date_to) query.append('date_to', params.date_to); - const response = await api.get(`${BASE_PATH}/bitacora?${query.toString()}`); - if (response.error || !response.data) { - throw new Error(response.error || 'Failed to fetch audit logs'); - } - return response.data; - }, + const response = await api.get(`${BASE_PATH}/bitacora?${query.toString()}`); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch audit logs'); + } + return response.data; + }, - getProcedures: async (): Promise => { - const response = await api.get(`${BASE_PATH}/bitacora/procedimientos`); - if (response.error || !response.data) { - throw new Error(response.error || 'Failed to fetch procedures'); - } - return response.data; - }, + getProcedures: async (companyId: number): Promise => { + const response = await api.get( + `${BASE_PATH}/bitacora/procedimientos?company_id=${companyId}` + ); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch procedures'); + } + return response.data; + }, - getDetail: async (specId: number): Promise => { - const response = await api.get(`${BASE_PATH}/bitacora/${specId}/detalle`); - if (response.error || !response.data) { - throw new Error(response.error || 'Failed to fetch audit log detail'); - } - return response.data; - } + getDetail: async (specId: number, companyId: number): Promise => { + const response = await api.get( + `${BASE_PATH}/bitacora/${specId}/detalle?company_id=${companyId}` + ); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to fetch audit log detail'); + } + return response.data; + } }; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 490d7c7b..2567b5de 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -9,7 +9,7 @@ */ import Keycloak from 'keycloak-js'; -import { writable, derived } from 'svelte/store'; +import { writable, derived, get } from 'svelte/store'; import { browser } from '$app/environment'; import { clearAccessTokenOnDocument, @@ -92,7 +92,9 @@ export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated); export const currentUser = derived(authStore, ($a) => $a.user); /** - * Verifica si el usuario tiene un permiso específico + * Verifica si el usuario tiene un permiso específico. + * `user.permissions` debe incluir códigos de la app (p. ej. `user.view`); suelen + * cargarse vía {@link syncCompanyPermissions}, no solo desde /auth/me del Hub. */ export function userHasPermission(user: User | null, permission: string): boolean { if (!user) return false; @@ -323,10 +325,42 @@ export const login = async (credentials: { // User info // ───────────────────────────────────────────────────────── +/** + * Permisos efectivos RBAC de la app para la compañía (backend: GET .../permissions/me). + * Sin esto, `userHasPermission` solo ve lo que venga en /auth/me del Hub. + */ +export async function syncCompanyPermissions(companyId: number): Promise { + if (!browser || !Number.isFinite(companyId)) return; + try { + const { api } = await import('./api'); + const res = await api.get<{ permissions: string[] }>( + `/v1/core/permissions/me?company_id=${companyId}` + ); + if (res.error || res.data === undefined) return; + const perms = res.data.permissions; + if (!Array.isArray(perms)) return; + const state = get(authStore); + if (!state.user) return; + authStore.setUser({ + ...state.user, + permissions: perms + }); + } catch (e) { + console.warn('[auth] syncCompanyPermissions:', e); + } +} + export const refreshPermissions = async () => { const token = getToken(); if (token) { await loadUserInfo(token); + try { + const { companyStore } = await import('./stores/company.svelte'); + const cid = companyStore.activeCompany?.id; + if (cid) await syncCompanyPermissions(cid); + } catch { + // ignore + } return true; } return false; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 08930b11..78631f18 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -89,6 +89,7 @@ export function getSidebarData(): SidebarData { url: "/dashboard/audit_logs", icon: Shield, items: [], + permission: 'audit_logs.view', }, { diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 9d824f82..da0cedf3 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -155,10 +155,16 @@ class CompanyStore { }, body: JSON.stringify({ companyId: company.id }), credentials: 'include' - }); + }); } catch (error) { console.error('Error setting active company cookie:', error); } + try { + const { syncCompanyPermissions } = await import('$lib/auth'); + await syncCompanyPermissions(company.id); + } catch (e) { + console.warn('syncCompanyPermissions:', e); + } } // Despachar evento personalizado solo si: diff --git a/frontend/src/routes/dashboard/audit_logs/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte index cf73d9d1..547c305c 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.svelte +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -8,9 +8,13 @@ import TasksTab from './tasks-tab.svelte'; import FilesTab from './files-tab.svelte'; import type { PageData } from './$types'; + import { currentUser, userHasPermission } from '$lib/auth'; + import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; let { data }: { data: PageData } = $props(); + const canViewAuditLogs = $derived(userHasPermission($currentUser, 'audit_logs.view')); + let tabValue = $state(data.initialTab); $effect(() => { @@ -43,6 +47,13 @@ } +{#if !canViewAuditLogs} +
+ +
+{:else}
@@ -88,3 +99,4 @@
+{/if} diff --git a/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte b/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte index 407b76db..c31f3b8b 100644 --- a/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte +++ b/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte @@ -12,6 +12,7 @@ import * as Table from '$lib/components/ui/table'; import { translateAuditMovement, translateAuditProcedure } from '$lib/utils/audit-log-i18n'; import { RefreshCw, Search } from 'lucide-svelte'; + import { companyStore } from '$lib/stores/company.svelte'; let logs: AuditLog[] = []; let total = 0; @@ -39,12 +40,25 @@ return isSpanish() ? es : en; } + function activeCompanyId(): number | undefined { + return companyStore.activeCompany?.id; + } + async function loadLogs() { if (loading) return; + const cid = activeCompanyId(); + if (!cid) { + error = text('Selecciona una compañía activa.', 'Select an active company.'); + logs = []; + total = 0; + hasMore = false; + return; + } loading = true; error = null; try { const params: AuditLogParams = { + company_id: cid, page, page_size: pageSize, search: search || undefined, @@ -72,8 +86,13 @@ } async function loadProcedures() { + const cid = activeCompanyId(); + if (!cid) { + procedures = []; + return; + } try { - procedures = await AuditLogAPI.getProcedures(); + procedures = await AuditLogAPI.getProcedures(cid); } catch (e) { console.error(e); } @@ -139,10 +158,24 @@ } } - onMount(() => { + $effect(() => { + const cid = companyStore.activeCompany?.id; + if (!cid) { + logs = []; + total = 0; + procedures = []; + loading = false; + return; + } + page = 1; + hasMore = true; + logs = []; + loading = false; void loadProcedures(); void loadLogs(); + }); + onMount(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasMore && !loading) { diff --git a/frontend/src/routes/dashboard/audit_logs/files-tab.svelte b/frontend/src/routes/dashboard/audit_logs/files-tab.svelte index 49d6bdbd..a13992de 100644 --- a/frontend/src/routes/dashboard/audit_logs/files-tab.svelte +++ b/frontend/src/routes/dashboard/audit_logs/files-tab.svelte @@ -1,5 +1,4 @@ diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 6efdc635..ef9f25e9 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -50,7 +50,6 @@ // --- PERMISOS --- const canView = $derived(userHasPermission($currentUser, 'user.view')); const canCreate = $derived(userHasPermission($currentUser, 'user.create')); - const canEdit = $derived(userHasPermission($currentUser, 'user.update')); const canDelete = $derived(userHasPermission($currentUser, 'user.delete')); const canManage = $derived(userHasPermission($currentUser, 'user.manage')); @@ -60,6 +59,29 @@ const canEditRoles = $derived(userHasPermission($currentUser, 'roles.edit')); const canDeleteRoles = $derived(userHasPermission($currentUser, 'roles.delete')); + /** Pestaña "Roles y Permisos": plantillas de rol; alineado con backend (no solo roles.view). */ + const canAccessRolesTab = $derived( + userHasPermission($currentUser, 'roles.view') || + userHasPermission($currentUser, 'user.manage') || + userHasPermission($currentUser, 'user.update') + ); + + /** Ver/editar datos del usuario (contraseña, perfil). Requiere update o manage, no basta user.view. */ + const canEditUserProfile = $derived( + userHasPermission($currentUser, 'user.update') || + userHasPermission($currentUser, 'user.manage') + ); + + /** + * Gestionar roles/permisos de un usuario (escudo). Alineado con API: update, manage o roles.view. + * Nota: la lista de usuarios solo exige user.view; sin update/manage/roles.view estos botones quedan deshabilitados. + */ + const canManageUserRoles = $derived( + userHasPermission($currentUser, 'user.update') || + userHasPermission($currentUser, 'user.manage') || + userHasPermission($currentUser, 'roles.view') + ); + // ============================================================================ // HELPERS PARA FORMATEO DE PERMISOS // ============================================================================ @@ -1436,7 +1458,7 @@ variant="ghost" size="icon" onclick={() => openRolesDialog(user)} - disabled={!canEdit} + disabled={!canManageUserRoles} > @@ -1450,7 +1472,7 @@ variant="ghost" size="icon" onclick={() => openPasswordDialog(user)} - disabled={!canEdit} + disabled={!canEditUserProfile} > @@ -1464,7 +1486,7 @@ variant="ghost" size="icon" onclick={() => openEditDialog(user)} - disabled={!canEdit} + disabled={!canEditUserProfile} > @@ -1499,7 +1521,7 @@ - {#if !canViewRoles} + {#if !canAccessRolesTab}