src/Security/Voter/MatchVoter.php line 26

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