src/Security/Voter/MatchVoter.php line 25

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