src/Security/Voter/MatchVoter.php line 24

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