src/Security/Voter/MatchVoter.php line 23

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