feature/permisos-keycloak-correccion
This commit is contained in:
@@ -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("/"):
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user