<?php
namespace App\Security;
use App\Entity\ConsorciPropietarioContenido;
use App\Entity\PropietarioContenido;
use App\Entity\PropietarioContenidoHasUsuarioHermes;
use App\Entity\UsuarioHermes;
use App\Enum\RolEnum;
use App\Interfaces\UsuarioHermesRepositoryInterface;
use App\Services\AsymmetricEncryptionService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
/**
* Servicio para controlar el acceso a recursos protegidos basado en la relación
* entre UsuarioHermes y PropietarioContenido.
*/
class PropietarioContenidoAuthorizationService
{
private FlashBagInterface $flashBag;
public function __construct(
private TokenStorageInterface $tokenStorage,
private Security $security,
private UrlGeneratorInterface $urlGenerator,
private RequestStack $requestStack,
private AsymmetricEncryptionService $asymmetricEncryptionService,
private UsuarioHermesRepositoryInterface $usuarioHermesRepository,
) {
$this->flashBag = $this->requestStack->getSession()->getFlashBag();
}
/**
* Verifica si el usuario tiene acceso a recursos asociados a los tipos de PropietarioContenido especificados.
*
* @param array $rolesPermitidos Array de roles permitidos de RolEnum (ej. [RolEnum::PARTICIPANTE, RolEnum::FORMADOR])
* @param string|null $rolEsperado Rol esperado para este recurso (opcional)
* @param bool $redirectOnFailure Si debe redirigir al usuario en caso de fallo de autorización
* @param bool $throwException Si debe lanzar excepciones personalizadas en lugar de devolver false
* @param string $severityLevel Nivel de severidad para los mensajes de error (info, warning, error, critical)
* @return bool|RedirectResponse True si autorizado, RedirectResponse si no autorizado y redirectOnFailure=true
* @throws \App\Exception\Authorization\PropietarioContenidoAuthorizationException Si el acceso es denegado y $throwException=true
*/
public function verificarAcceso(
array $rolesPermitidos,
?string $rolEsperado = null,
bool $redirectOnFailure = true,
bool $throwException = false,
string $severityLevel = \App\Exception\Authorization\PropietarioContenidoAuthorizationException::SEVERITY_ERROR
) {
// --- INICIO: Lógica de Autologin ---
$request = $this->requestStack->getCurrentRequest();
if ($request && $tokenParam = $request->query->get('autologinToken')) {
try {
$decryptedPayload = $this->asymmetricEncryptionService->hybridDecrypt($tokenParam);
// Validar expiración y payload
if (is_array($decryptedPayload) && isset($decryptedPayload['userId'], $decryptedPayload['expires']) && $decryptedPayload['expires'] > time()) {
$user = $this->usuarioHermesRepository->findById($decryptedPayload['userId']);
if ($user) {
// Seleccionar automáticamente el primer PropietarioContenido activo
$propietarioSeleccionado = $this->seleccionarPropietarioContenidoActivo($user);
// Crear y almacenar el token de autenticación
$authToken = new PostAuthenticationToken($user, 'main', $user->getRoles() ?? []);
$this->tokenStorage->setToken($authToken);
// Guardar en sesión
$session = $this->requestStack->getSession();
$session->set('_security_main', serialize($authToken));
// Guardar PropietarioContenido y rol activo en la sesión si se seleccionó uno
if ($propietarioSeleccionado) {
$rolSeleccionado = RolEnum::getRolFromClass(get_class($propietarioSeleccionado));
if ($rolSeleccionado) {
$session->set('rol', $rolSeleccionado);
}
}
$session->save();
// Redirigir a la misma URL pero sin el token para limpiarla
$queryParams = $request->query->all();
unset($queryParams['autologinToken']);
$redirectUrl = $this->urlGenerator->generate($request->attributes->get('_route'), array_merge($request->attributes->get('_route_params'), $queryParams));
return new RedirectResponse($redirectUrl);
}
}
} catch (\Exception $e) {
// Token inválido o expirado. Mostrar mensaje flash y continuar flujo normal.
$this->flashBag->add('error', 'El enlace de acceso automático ha expirado o no es válido. Por favor, inicie sesión normalmente.');
}
}
// --- FIN: Lógica de Autologin ---
// Verificar si hay usuario logueado
$token = $this->tokenStorage->getToken();
if (!$token || !$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
if ($throwException) {
throw new \App\Exception\Authorization\NoUserException(
'Debe iniciar sesión para acceder a este recurso.',
$severityLevel
);
}
if ($redirectOnFailure) {
$this->flashBag->add('error', 'Debe iniciar sesión para acceder a este recurso.');
return new RedirectResponse($this->urlGenerator->generate('login_request_credentials'));
}
return false;
}
/** @var UsuarioHermes $usuario */
$usuario = $token->getUser();
// Obtener relaciones del usuario con PropietarioContenido
$propietarioRelaciones = $usuario->getPropietarioContenidoHasUsuarioHermes();
if ($propietarioRelaciones->isEmpty()) {
if ($throwException) {
throw new \App\Exception\Authorization\NoRelationsException(
'Su cuenta no está asociada a ninguna entidad.',
$severityLevel
);
}
if ($redirectOnFailure) {
$this->flashBag->add('error', 'Su cuenta no está asociada a ninguna entidad.');
return new RedirectResponse($this->urlGenerator->generate('home'));
}
return false;
}
// Buscar si hay al menos un PropietarioContenido del tipo permitido
$coincidenciaEncontrada = false;
$relacionSinValidar = false;
foreach ($propietarioRelaciones as $relacion) {
$propietarioContenido = $relacion->getPropietarioContenido();
$rolPropietario = RolEnum::getRolFromClass(get_class($propietarioContenido));
if ($rolPropietario && in_array($rolPropietario, $rolesPermitidos)) {
// Verificar si la relación está validada
if ($relacion->getValidatedAt() === null) {
$relacionSinValidar = true;
continue;
}
// Si se requiere un rol específico, verificar que el usuario lo tenga
if ($rolEsperado !== null && !$this->security->isGranted($rolEsperado)) {
if ($throwException) {
throw new \App\Exception\Authorization\IncorrectRoleException(
$rolEsperado,
'Para acceder a este recurso necesita cambiar a un rol diferente.',
$severityLevel
);
}
if ($redirectOnFailure) {
$this->flashBag->add('warning', 'Para acceder a este recurso necesita cambiar a un rol diferente.');
return new RedirectResponse($this->urlGenerator->generate('home'));
}
return false;
}
$coincidenciaEncontrada = true;
// FIX-INC-733-H3: setea propietario_contenido_id_activo cuando session NULL
// o el id activo no corresponde al tipo del rol del propietario validado.
$session = $this->requestStack->getSession();
$propietarioActivoId = $session->get('propietario_contenido_id_activo');
$needsUpdate = false;
if ($propietarioActivoId === null) {
$needsUpdate = true;
} else {
// Comprobar si el id activo en sesion corresponde al tipo de rol validado
$matchTipo = false;
foreach ($propietarioRelaciones as $rel2) {
$pc2 = $rel2->getPropietarioContenido();
if ($pc2->getId() === $propietarioActivoId
&& RolEnum::getRolFromClass(get_class($pc2)) === $rolPropietario) {
$matchTipo = true;
break;
}
}
if (!$matchTipo) {
$needsUpdate = true;
}
}
if ($needsUpdate) {
$session->set('propietario_contenido_id_activo', $propietarioContenido->getId());
}
break;
}
}
// Si hay una relación sin validar pero ninguna validada
if (!$coincidenciaEncontrada && $relacionSinValidar) {
if ($throwException) {
throw new \App\Exception\Authorization\RelationNotValidatedException(
$rolesPermitidos,
'Su cuenta aún no ha sido activada. Por favor, contacte con el administrador.',
$severityLevel
);
}
if ($redirectOnFailure) {
$this->flashBag->add('warning', 'Su cuenta aún no ha sido activada. Por favor, contacte con el administrador.');
return new RedirectResponse($this->urlGenerator->generate('home'));
}
return false;
}
// Si no se encontró ninguna coincidencia
if (!$coincidenciaEncontrada) {
if ($throwException) {
throw new \App\Exception\Authorization\NoMatchingRolException(
$rolesPermitidos,
'No tiene permisos para acceder al recurso solicitado.',
$severityLevel
);
}
if ($redirectOnFailure) {
$this->flashBag->add('error', 'No tiene permisos para acceder al recurso solicitado.');
return new RedirectResponse($this->urlGenerator->generate('home'));
}
return false;
}
return true;
}
public function canManageEmployee(UsuarioHermes $usuarioActual, PropietarioContenidoHasUsuarioHermes $relacion, ?int $selectedEntidadId = null)
{
// Verificar si es usuario privilegiado y tiene selectedEntidadId
if ($this->isConsorciPrivilegiado($usuarioActual) && $selectedEntidadId) {
return $this->canManageEmployeeForSelectedEntity($usuarioActual, $relacion, $selectedEntidadId);
}
// Lógica original para usuarios no privilegiados
$typePropietarioContenido = get_class($relacion->getPropietarioContenido());
if ($usuarioActual->getPropietarioContenidos()->filter(fn($pc)=>in_array(get_class($pc), [$typePropietarioContenido, ConsorciPropietarioContenido::class]))){
return true;
}
return false;
}
public function canViewEmployee(UsuarioHermes $usuarioActual, PropietarioContenidoHasUsuarioHermes $relacion, ?int $selectedEntidadId = null)
{
return $this->canManageEmployee($usuarioActual, $relacion, $selectedEntidadId);
}
public function canCreateEmployee(UsuarioHermes $usuarioActual, ?\App\Entity\PropietarioContenido $propietarioContenido = null, ?int $selectedEntidadId = null)
{
// Para usuarios privilegiados, verificar que tengan permisos sobre la entidad seleccionada
if ($this->isConsorciPrivilegiado($usuarioActual) && $selectedEntidadId) {
return $this->canCreateEmployeeForSelectedEntity($usuarioActual, $selectedEntidadId);
}
return true;
}
public function canDeleteEmployee(UsuarioHermes $usuarioActual, PropietarioContenido $propietarioContenido, ?int $selectedEntidadId = null): bool
{
// Para usuarios privilegiados, verificar que el empleado pertenezca a la entidad seleccionada
if ($this->isConsorciPrivilegiado($usuarioActual) && $selectedEntidadId) {
return $this->canDeleteEmployeeForSelectedEntity($usuarioActual, $propietarioContenido, $selectedEntidadId);
}
return true;
}
/**
* Verifica si el usuario autenticado es un "Usuario Consorci Privilegiado".
* Un usuario es privilegiado si tiene asociada al menos una entidad de tipo ConsorciPropietarioContenido.
*
* @param UsuarioHermes|null $usuario Usuario a verificar. Si es null, usa el usuario actual.
* @return bool True si es usuario privilegiado, false en caso contrario
*/
public function isConsorciPrivilegiado(?UsuarioHermes $usuario = null): bool
{
if ($usuario === null) {
$token = $this->tokenStorage->getToken();
if (!$token || !($token->getUser() instanceof UsuarioHermes)) {
return false;
}
$usuario = $token->getUser();
}
$propietarioRelaciones = $usuario->getPropietarioContenidoHasUsuarioHermes();
foreach ($propietarioRelaciones as $relacion) {
$propietarioContenido = $relacion->getPropietarioContenido();
if ($propietarioContenido instanceof ConsorciPropietarioContenido) {
return true;
}
}
return false;
}
/**
* Obtiene la entidad Consorci asociada al usuario privilegiado.
*
* @param UsuarioHermes|null $usuario Usuario a verificar. Si es null, usa el usuario actual.
* @return \App\Entity\Consorci|null La entidad Consorci asociada o null si no se encuentra
*/
public function getConsorciAssociated(?UsuarioHermes $usuario = null): ?\App\Entity\Consorci
{
if ($usuario === null) {
$token = $this->tokenStorage->getToken();
if (!$token || !($token->getUser() instanceof UsuarioHermes)) {
return null;
}
$usuario = $token->getUser();
}
$propietarioRelaciones = $usuario->getPropietarioContenidoHasUsuarioHermes();
foreach ($propietarioRelaciones as $relacion) {
$propietarioContenido = $relacion->getPropietarioContenido();
if ($propietarioContenido instanceof ConsorciPropietarioContenido) {
return $propietarioContenido->getEntidadAsociada();
}
}
return null;
}
/**
* Verifica si un usuario privilegiado puede gestionar un empleado específico para una entidad seleccionada.
*/
private function canManageEmployeeForSelectedEntity(UsuarioHermes $usuarioActual, PropietarioContenidoHasUsuarioHermes $relacion, int $selectedEntidadId): bool
{
$propietarioContenido = $relacion->getPropietarioContenido();
$entidadAsociada = $propietarioContenido->getEntidadAsociada();
return $entidadAsociada && $entidadAsociada->getId() === $selectedEntidadId;
}
/**
* Verifica si un usuario privilegiado puede crear empleados para una entidad seleccionada.
*/
private function canCreateEmployeeForSelectedEntity(UsuarioHermes $usuarioActual, int $selectedEntidadId): bool
{
// Los usuarios privilegiados pueden crear empleados para cualquier entidad
// La validación adicional se puede implementar aquí si es necesario
return true;
}
/**
* Verifica si un usuario privilegiado puede eliminar un empleado específico para una entidad seleccionada.
*/
private function canDeleteEmployeeForSelectedEntity(UsuarioHermes $usuarioActual, PropietarioContenido $propietarioContenido, int $selectedEntidadId): bool
{
$entidadAsociada = $propietarioContenido->getEntidadAsociada();
return $entidadAsociada && $entidadAsociada->getId() === $selectedEntidadId;
}
public function canEditConvocatoria(?\Symfony\Component\Security\Core\User\UserInterface $user, mixed $convocatoria)
{
return true;
}
/**
* Verifica si el propietario_contenido es organizador del grupo dado.
*
* Trazabilidad:
* - HU-389v2-02 CA-4 (forbidden no-organizador)
* - ADR-003 autorizacion endpoint pending-members
*
* Logica: el propietario es organizador si tiene rol CONSORCI (gestiona todos
* los grupos), ENTIDAD_COLABORATIVA con el grupo en sus expedientes, o FORMADOR
* con el grupo entre los que imparte. La resolucion fina queda como TODO; la
* firma publica + la clase ya permiten que los tests mocken este servicio.
*
* @param int $grupoId
* @param int $propietarioContenidoId
* @return bool
*/
public function isOrganizadorDelGrupo(int $grupoId, int $propietarioContenidoId): bool
{
// INC-389v2 demo MVP: CONSORCI siempre es organizador (rol global).
// Resolución vía Security del usuario actual (sin EntityManager directo).
$user = $this->security->getUser();
if (!$user instanceof UsuarioHermes) {
return false;
}
foreach ($user->getPropietarioContenidoHasUsuarioHermes() as $rel) {
$pc = $rel->getPropietarioContenido();
if ($pc instanceof \App\Entity\ConsorciPropietarioContenido) {
return true;
}
}
// TODO HU-389v2-followup: FORMADOR (grupos que imparte), ENTIDAD_COLABORATIVA (grupos que organiza).
return false;
}
/**
* Selecciona automáticamente el primer PropietarioContenido activo del usuario.
* Prioriza los que sean rol PARTICIPANTE si los hay.
*
* @param UsuarioHermes $user Usuario para el cual seleccionar el PropietarioContenido
* @return PropietarioContenido|null El PropietarioContenido seleccionado o null si no se encontró
*/
private function seleccionarPropietarioContenidoActivo(UsuarioHermes $user): ?PropietarioContenido
{
$propietarioRelaciones = $user->getPropietarioContenidoHasUsuarioHermes();
if ($propietarioRelaciones->isEmpty()) {
return null;
}
$propietariosActivos = [];
$propietariosParticipante = [];
// Buscar PropietarioContenido activos (validados)
foreach ($propietarioRelaciones as $relacion) {
// Solo considerar relaciones validadas
if ($relacion->getValidatedAt() === null) {
continue;
}
$propietarioContenido = $relacion->getPropietarioContenido();
$rolPropietario = RolEnum::getRolFromClass(get_class($propietarioContenido));
if ($rolPropietario) {
$propietariosActivos[] = $propietarioContenido;
// Separar los de rol PARTICIPANTE para priorizarlos
if ($rolPropietario === RolEnum::PARTICIPANTE) {
$propietariosParticipante[] = $propietarioContenido;
}
}
}
// Si no hay PropietarioContenido activos, no hacer nada
if (empty($propietariosActivos)) {
return null;
}
// Priorizar rol PARTICIPANTE si existe, sino tomar el primero activo
$propietarioSeleccionado = !empty($propietariosParticipante)
? $propietariosParticipante[0]
: $propietariosActivos[0];
return $propietarioSeleccionado;
}
}