src/Security/Voter/MatchVoter.php line 22

Open in your IDE?
  1. <?php
  2. namespace MedBrief\MSR\Security\Voter;
  3. use MedBrief\MSR\Entity\Expert;
  4. use MedBrief\MSR\Entity\Project;
  5. use MedBrief\MSR\Entity\User;
  6. use MedBrief\MSR\Repository\AccountRepository;
  7. use MedBrief\MSR\Repository\ExpertRepository;
  8. use MedBrief\MSR\Repository\ExpertUserRepository;
  9. use MedBrief\MSR\Repository\MatchLetterResponseRepository;
  10. use MedBrief\MSR\Repository\MessageThreadParticipantRepository;
  11. use MedBrief\MSR\Repository\ProjectRepository;
  12. use MedBrief\MSR\Service\ProjectMatch\MatchExpertUser;
  13. use MedBrief\MSR\Service\ProjectMatch\MatchMyExpertsThreadBuilder;
  14. use Override;
  15. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  16. use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
  17. use Symfony\Component\Security\Core\Authorization\Voter\Voter;
  18. use Symfony\Component\Security\Core\User\UserInterface;
  19. class MatchVoter extends Voter
  20. {
  21. public const VIEW_FIRM = 'MATCH_VIEW_FIRM';
  22. public const VIEW_EXPERT = 'MATCH_VIEW_EXPERT';
  23. public const VIEW_EXPERT_TERMS_ACCEPTED = 'MATCH_VIEW_EXPERT_TERMS_ACCEPTED';
  24. public const VIEW_MESSAGES_NAV = 'MATCH_VIEW_MESSAGES_NAV';
  25. public const VIEW_RECORDS_RADIOLOGY_TABS = 'MATCH_VIEW_RECORDS_RADIOLOGY_TABS';
  26. public const VIEW_PRIMARY_OR_SECONDARY_EXPERT = 'MATCH_VIEW_PRIMARY_OR_SECONDARY_EXPERT';
  27. public function __construct(
  28. private readonly AuthorizationCheckerInterface $auth,
  29. private readonly AccountRepository $accountRepository,
  30. private readonly ExpertRepository $expertRepository,
  31. private readonly MessageThreadParticipantRepository $messageThreadParticipantRepository,
  32. private readonly ProjectRepository $projectRepository,
  33. private readonly MatchLetterResponseRepository $matchLetterResponseRepository,
  34. private readonly ExpertUserRepository $expertUserRepository,
  35. private readonly MatchExpertUser $matchExpertUserService
  36. ) {
  37. }
  38. #[Override]
  39. protected function supports(string $attribute, mixed $subject): bool
  40. {
  41. if ($attribute === self::VIEW_MESSAGES_NAV || $attribute === self::VIEW_EXPERT_TERMS_ACCEPTED) {
  42. return $subject === null;
  43. }
  44. return \in_array($attribute, [self::VIEW_FIRM, self::VIEW_EXPERT, self::VIEW_RECORDS_RADIOLOGY_TABS, self::VIEW_PRIMARY_OR_SECONDARY_EXPERT], true)
  45. && $subject instanceof Project;
  46. }
  47. #[Override]
  48. protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
  49. {
  50. $user = $token->getUser();
  51. if (!$user instanceof UserInterface) {
  52. return false;
  53. }
  54. /** @var Project $project */
  55. $project = $subject;
  56. return match ($attribute) {
  57. self::VIEW_FIRM => $this->canViewAsFirm($user, $project),
  58. self::VIEW_EXPERT => $this->canViewAsExpert($user, $project),
  59. self::VIEW_EXPERT_TERMS_ACCEPTED => $this->canViewExpertTermsAccepted($user),
  60. self::VIEW_MESSAGES_NAV => $this->canViewMessagesNavigation($user),
  61. self::VIEW_RECORDS_RADIOLOGY_TABS => $this->canViewRecordsRadiologyTabs($user, $project),
  62. self::VIEW_PRIMARY_OR_SECONDARY_EXPERT => $this->canViewAsPrimaryOrSecondaryExpert($user, $project),
  63. default => false,
  64. };
  65. }
  66. /**
  67. * Determines if the user can view the match tab.
  68. * User roles allowed:
  69. * - MB super admins (ROLE_SUPER_ADMIN)
  70. * - MB admins (ROLE_ADMIN)
  71. * - Client super admins (ROLE_ACCOUNT_{accountId}_SUPERADMINISTRATOR)
  72. * - Client admins (ROLE_ACCOUNT_{accountId}_ADMINISTRATOR)
  73. * - Matter/Project managers:
  74. * - Role-based: ROLE_PROJECT_{projectId}_PROJECTMANAGER
  75. * - Entity-based: $project->getManager() === $user
  76. *
  77. * @param UserInterface $user
  78. * @param Project $project
  79. *
  80. * @return bool
  81. */
  82. private function canViewAsFirm(UserInterface $user, Project $project): bool
  83. {
  84. // Check if client has opted in to match and if the matter is of type Clinical Negligence and is a client matter, if classic matter, don't allow viewing of match
  85. if (!$project->getAccount()->getMatchOptIn()
  86. || $project->getMatterType() !== Project::MATTER_TYPE_CLINICAL_NEGLIGENCE
  87. || $project->getType() !== Project::TYPE_MATTER_FIRM
  88. || $project->getMatterRequest() === null
  89. ) {
  90. return false;
  91. }
  92. // MB admins
  93. if ($this->auth->isGranted('ROLE_SUPER_ADMIN') || $this->auth->isGranted('ROLE_ADMIN')) {
  94. return true;
  95. }
  96. // Client-level admins
  97. if ($this->auth->isGranted('ROLE_ACCOUNT_' . $project->getAccount()->getId() . '_SUPERADMINISTRATOR')) {
  98. return true;
  99. }
  100. if ($this->auth->isGranted('ROLE_ACCOUNT_' . $project->getAccount()->getId() . '_ADMINISTRATOR')) {
  101. return true;
  102. }
  103. // Matter/Project manager (role-based)
  104. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_PROJECTMANAGER')) {
  105. return true;
  106. }
  107. // Matter/Project manager (entity relationship fallback)
  108. if ($project->getManager() && $project->getManager() === $user) {
  109. return true;
  110. }
  111. return false;
  112. }
  113. /**
  114. * Determines if a user can view as an Expert.
  115. * Allows access to secondary users linked to an expert, but only if the expert has not confirmed a primary user yet.
  116. * Once a primary user is confirmed, only that user can view as the expert.
  117. *
  118. * @param UserInterface $user
  119. * @param Project $project
  120. *
  121. * @return bool
  122. */
  123. private function canViewAsExpert(UserInterface $user, Project $project): bool
  124. {
  125. // Invited Experts
  126. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERT')) {
  127. return true;
  128. }
  129. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERTVIEWER')) {
  130. return true;
  131. }
  132. $isPrimaryUserForExpert = $this->matchExpertUserService->isPrimaryUserForExpert($user);
  133. if ($isPrimaryUserForExpert) {
  134. // If the expert has not confirmed a primary user yet, secondary users are treated as primary
  135. $user = $this->matchExpertUserService->getPrimaryUserForExpert($user);
  136. }
  137. // Uninvited Experts can only view if they are participants in the message thread for the project
  138. if (!$user instanceof User) {
  139. return false;
  140. }
  141. return $this->messageThreadParticipantRepository->isUserParticipantInProject($project, $user);
  142. }
  143. /**
  144. * Determines if a user can view as an Expert.
  145. * Allows access to secondary users linked to an expert, but only if the expert has not confirmed a primary user yet.
  146. * Once a primary user is confirmed, only that user can view as the expert.
  147. *
  148. * @param UserInterface $user
  149. * @param Project $project
  150. *
  151. * @return bool
  152. */
  153. private function canViewAsPrimaryOrSecondaryExpert(UserInterface $user, Project $project): bool
  154. {
  155. // Invited Experts
  156. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERT')) {
  157. return true;
  158. }
  159. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERTVIEWER')) {
  160. return true;
  161. }
  162. //If the expert has not confirmed a primary user yet, secondary users are treated as primary
  163. $user = $this->matchExpertUserService->getPrimaryUserForExpert($user);
  164. // Uninvited Experts can only view if they are participants in the message thread for the project
  165. if (!$user instanceof User) {
  166. return false;
  167. }
  168. // Check if the user is a participant in any thread for the project
  169. return $this->messageThreadParticipantRepository->isUserParticipantInProject($project, $user);
  170. }
  171. /**
  172. * Limits access to certain project related functionality in the event that they are an expert,
  173. * but have no expert roles for the project yet.
  174. * Can be coupled with canViewAsExpert to show specific information, but limit others
  175. *
  176. * @param UserInterface $user
  177. * @param Project $project
  178. *
  179. * @return bool
  180. */
  181. private function canViewRecordsRadiologyTabs(UserInterface $user, Project $project): bool
  182. {
  183. $userType = $this->matchExpertUserService->getUserType($user);
  184. // If the user is an expert, we need to check if they have roles for the project
  185. if ($userType === MatchMyExpertsThreadBuilder::USER_TYPE_EXPERT) {
  186. // Experts without project roles are denied view access to the records and radiology tabs
  187. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERT')
  188. || $this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERTVIEWER')) {
  189. return true;
  190. }
  191. // Return false if they are an expert but don't have the expert role for the project
  192. return false;
  193. }
  194. // Currently there are no specific restrictions on viewing these tabs outside of this for now
  195. // so we return true
  196. return true;
  197. }
  198. /**
  199. * Determines if a user can see the messages navigation tab
  200. *
  201. * @param UserInterface $user
  202. *
  203. * @return bool
  204. */
  205. private function canViewMessagesNavigation(UserInterface $user): bool
  206. {
  207. // MB admins
  208. if ($this->auth->isGranted('ROLE_SUPER_ADMIN') || $this->auth->isGranted('ROLE_ADMIN')) {
  209. return true;
  210. }
  211. // If the user has the expert or expert viewer role, allow access
  212. if ($user instanceof User && ($user->isExpertOnly() || $user->isExpertViewerOnly())) {
  213. // Only experts with an expert profile
  214. if ($this->expertRepository->findOneBy(['user' => $user]) instanceof Expert) {
  215. return true;
  216. }
  217. }
  218. // Extract account IDs and project IDs from user roles
  219. $userRoles = $user->getRoles();
  220. $accountIds = [];
  221. $projectIds = [];
  222. foreach ($userRoles as $role) {
  223. if (preg_match('/^ROLE_ACCOUNT_(\d+)_/', $role, $matches)) {
  224. $accountIds[] = (int) $matches[1];
  225. }
  226. if (preg_match('/^ROLE_PROJECT_(\d+)_/', $role, $matches)) {
  227. $projectIds[] = (int) $matches[1];
  228. }
  229. }
  230. // Remove duplicates
  231. $accountIds = array_unique($accountIds);
  232. $projectIds = array_unique($projectIds);
  233. // Check if any accounts have match enabled
  234. if (!empty($accountIds)) {
  235. $accounts = $this->accountRepository->findBy(['id' => $accountIds]);
  236. foreach ($accounts as $account) {
  237. if ($account->getMatchOptIn()) {
  238. return true;
  239. }
  240. }
  241. }
  242. // Check if any projects with accounts have match enabled
  243. if (!empty($projectIds)) {
  244. $projects = $this->projectRepository->findBy(['id' => $projectIds]);
  245. foreach ($projects as $project) {
  246. $account = $project->getAccount();
  247. if ($account && $account->getMatchOptIn()) {
  248. return true;
  249. }
  250. }
  251. }
  252. return false;
  253. }
  254. /**
  255. * Determines if an expert user has accepted the expert terms.
  256. *
  257. * @param UserInterface $user
  258. *
  259. * @return bool
  260. */
  261. private function canViewExpertTermsAccepted(UserInterface $user): bool
  262. {
  263. // If the user has the expert or expert viewer role, allow access
  264. if ($user instanceof User && ($user->isExpertOnly() || $user->isExpertViewerOnly())) {
  265. // Only experts with an expert profile
  266. if ($this->expertRepository->findOneBy(['user' => $user]) instanceof Expert) {
  267. // Check if the expert has accepted the terms
  268. if ($this->matchLetterResponseRepository->hasExpertAcceptedTerms($user)) {
  269. return true;
  270. }
  271. }
  272. }
  273. // Default deny
  274. return false;
  275. }
  276. }