src/Security/Voter/AttributeOnlyVoter.php line 25

Open in your IDE?
  1. <?php
  2. namespace MedBrief\MSR\Security\Voter;
  3. use InvalidArgumentException;
  4. use MedBrief\MSR\Entity\Account;
  5. use MedBrief\MSR\Entity\Expert;
  6. use MedBrief\MSR\Entity\Firm;
  7. use MedBrief\MSR\Entity\Project;
  8. use MedBrief\MSR\Entity\User;
  9. use MedBrief\MSR\Repository\ExpertUserRepository;
  10. use MedBrief\MSR\Role\ParsedRole;
  11. use MedBrief\MSR\Service\EntityHelper\UserHelper;
  12. use Override;
  13. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  14. use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
  15. use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
  16. use Symfony\Component\Security\Core\User\UserInterface;
  17. /**
  18. * This Voter handles the voting on checks that don't involve a specific
  19. * entity. Things like checking if a user can do some task like add a new
  20. * Account, or Project etc
  21. */
  22. class AttributeOnlyVoter implements VoterInterface
  23. {
  24. // these should be expanded over time as we need them
  25. public const CREATE_ACCOUNT = 'CREATE_ACCOUNT';
  26. public const CREATE_PROJECT = 'CREATE_PROJECT';
  27. public const CREATE_SERVICE_OPTION = 'CREATE_SERVICE_OPTION';
  28. public const CREATE_BILLING_ITEM = 'CREATE_BILLING_ITEM';
  29. public const CREATE_INVOICE = 'CREATE_INVOICE';
  30. public const CREATE_SORTING_MEMO_PHRASE = 'CREATE_SORTING_MEMO_PHRASE';
  31. public const CREATE_MATTER_REQUEST = 'CREATE_MATTER_REQUEST';
  32. public const CREATE_RECORDS_REQUEST_CENTRE_PARENT = 'CREATE_RECORDS_REQUEST_CENTRE_PARENT';
  33. public const CREATE_RECORDS_REQUEST_CENTRE_CHILD = 'CREATE_RECORDS_REQUEST_CENTRE_CHILD';
  34. public const CREATE_RECORDS_REQUEST_CENTRE_CONTACT = 'CREATE_RECORDS_REQUEST_CENTRE_CONTACT';
  35. public const CREATE_RECORDS_REQUEST_LETTER_TEMPLATE = 'CREATE_RECORDS_REQUEST_LETTER_TEMPLATE';
  36. public const CREATE_FIRM = 'CREATE_FIRM';
  37. // Manage records request centre contact
  38. public const MANAGE_RECORDS_REQUEST_CENTRE_CONTACT = 'MANAGE_RECORDS_REQUEST_CENTRE_CONTACT';
  39. public const LIST_ACCOUNT = 'LIST_ACCOUNT';
  40. public const LIST_PROJECT = 'LIST_PROJECT';
  41. public const LIST_PROJECT_CLOSED = 'LIST_PROJECT_CLOSED';
  42. public const LIST_USER = 'LIST_USER';
  43. public const LIST_SERVICE_OPTION = 'LIST_SERVICE_OPTION';
  44. public const LIST_BILLING_ITEM = 'LIST_BILLING_ITEM';
  45. public const LIST_PENDING_INVOICE = 'LIST_PENDING_INVOICE';
  46. public const LIST_INVOICE = 'LIST_INVOICE';
  47. public const LIST_SORTING_MEMO_PHRASE = 'LIST_SORTING_MEMO_PHRASE';
  48. public const LIST_MATTER_REQUEST = 'LIST_MATTER_REQUEST';
  49. public const LIST_RECORDS_REQUEST_CENTRE_PARENT = 'LIST_RECORDS_REQUEST_CENTRE_PARENT';
  50. public const LIST_RECORDS_REQUEST_CENTRE_CHILD = 'LIST_RECORDS_REQUEST_CENTRE_CHILD';
  51. public const LIST_RECORDS_REQUEST_CENTRE_ARCHIVED = 'LIST_RECORDS_REQUEST_CENTRE_ARCHIVED';
  52. public const LIST_RECORDS_REQUEST_LETTER_TEMPLATE = 'LIST_RECORDS_REQUEST_LETTER_TEMPLATE';
  53. public const LIST_FIRM = 'LIST_FIRM';
  54. public const LIST_PROJECT_TYPE = 'LIST_PROJECT_TYPE';
  55. public const LIST_PROJECT_RENEWALS = 'LIST_PROJECT_RENEWALS';
  56. public const LIST_MATCH_LETTER = 'LIST_MATCH_LETTER';
  57. public const LIST_MATCH_LETTER_RESPONSE = 'LIST_MATCH_LETTER_RESPONSE';
  58. public const USER_ADMINISTRATION = 'USER_ADMINISTRATION'; // indicates full user administration rights (CRUD)
  59. public const HUMAN_RESOURCE_ADMINISTRATION = 'HUMAN_RESOURCE_ADMINISTRATION'; // indicates full human resource administration rights (CRUD)
  60. public const USER_HELP_ADMINISTRATION = 'USER_HELP_ADMINISTRATION'; // indicates user Help Guide administration rights (CRUD)
  61. public const PRIORITISE_DISCS = 'PRIORITISE_DISCS';
  62. // Allow specific Users to mark stuffs as billed
  63. public const MARK_AS_BILLED = 'MARK_AS_BILLED';
  64. // Grants access to the 'Advanced' fields of all ServiceableInterface entities.
  65. public const SERVICE_REQUEST_ADVANCED = 'SERVICE_REQUEST_ADVANCED';
  66. public const ALLOW_USER_TYPE_CHANGE = 'ALLOW_USER_TYPE_CHANGE';
  67. // Migrated from ReportsBundle
  68. public const CREATE_REPORT = 'CREATE_REPORT';
  69. public const LIST_REPORT = 'LIST_REPORT';
  70. public const DOWNLOAD_REPORT = 'DOWNLOAD_REPORT';
  71. // Download Lists
  72. public const DOWNLOAD_PROJECT_LIST = 'DOWNLOAD_PROJECT_LIST';
  73. // User profile access
  74. public const VIEW_MY_PROFILE = 'VIEW_MY_PROFILE';
  75. public function __construct(
  76. private readonly AuthorizationCheckerInterface $authorizationChecker,
  77. private readonly UserHelper $userHelper,
  78. private readonly ExpertUserRepository $expertUserRepository,
  79. ) {
  80. }
  81. public function supportsAttribute($attribute): bool
  82. {
  83. return in_array($attribute, [
  84. self::CREATE_ACCOUNT,
  85. self::CREATE_PROJECT,
  86. self::LIST_ACCOUNT,
  87. self::LIST_PROJECT,
  88. self::LIST_USER,
  89. self::USER_ADMINISTRATION,
  90. self::USER_HELP_ADMINISTRATION,
  91. self::LIST_PROJECT_CLOSED,
  92. self::PRIORITISE_DISCS,
  93. self::LIST_SERVICE_OPTION,
  94. self::CREATE_SERVICE_OPTION,
  95. self::LIST_BILLING_ITEM,
  96. self::CREATE_BILLING_ITEM,
  97. self::CREATE_INVOICE,
  98. self::LIST_PENDING_INVOICE,
  99. self::LIST_INVOICE,
  100. self::HUMAN_RESOURCE_ADMINISTRATION,
  101. self::SERVICE_REQUEST_ADVANCED,
  102. self::MARK_AS_BILLED,
  103. self::CREATE_SORTING_MEMO_PHRASE,
  104. self::LIST_SORTING_MEMO_PHRASE,
  105. self::LIST_MATTER_REQUEST,
  106. self::LIST_MATCH_LETTER,
  107. self::LIST_MATCH_LETTER_RESPONSE,
  108. self::CREATE_MATTER_REQUEST,
  109. self::CREATE_RECORDS_REQUEST_CENTRE_PARENT,
  110. self::LIST_RECORDS_REQUEST_CENTRE_PARENT,
  111. self::CREATE_RECORDS_REQUEST_CENTRE_CHILD,
  112. self::LIST_RECORDS_REQUEST_CENTRE_CHILD,
  113. self::CREATE_RECORDS_REQUEST_CENTRE_CONTACT,
  114. self::MANAGE_RECORDS_REQUEST_CENTRE_CONTACT,
  115. self::LIST_RECORDS_REQUEST_CENTRE_ARCHIVED,
  116. self::CREATE_RECORDS_REQUEST_LETTER_TEMPLATE,
  117. self::LIST_RECORDS_REQUEST_LETTER_TEMPLATE,
  118. self::ALLOW_USER_TYPE_CHANGE,
  119. self::CREATE_REPORT,
  120. self::LIST_REPORT,
  121. self::DOWNLOAD_REPORT,
  122. self::DOWNLOAD_PROJECT_LIST,
  123. self::LIST_FIRM,
  124. self::CREATE_FIRM,
  125. self::LIST_PROJECT_TYPE,
  126. self::LIST_PROJECT_RENEWALS,
  127. self::VIEW_MY_PROFILE,
  128. 'EA_EXECUTE_ACTION',
  129. ]);
  130. }
  131. public function supportsClass($class): bool
  132. {
  133. // the class is inconsequential for this Voter
  134. return true;
  135. }
  136. /**
  137. *
  138. * @param mixed $entity
  139. */
  140. #[Override]
  141. public function vote(TokenInterface $token, $entity, array $attributes)
  142. {
  143. /**
  144. * START: This is common code for all Voter::vote() methods
  145. */
  146. // check if class of this object is supported by this voter
  147. if (!$this->supportsClass($entity && !is_array($entity) ? $entity::class : '')) {
  148. return VoterInterface::ACCESS_ABSTAIN;
  149. }
  150. // set the attribute to check against
  151. $attribute = $attributes[0];
  152. // check if the given attribute is covered by this voter
  153. if (!$this->supportsAttribute($attribute)) {
  154. return VoterInterface::ACCESS_ABSTAIN;
  155. }
  156. // check if the voter is used correct, only allow one attribute
  157. // this isn't a requirement, it's just one easy way for you to
  158. // design your voter
  159. if (1 !== count($attributes)) {
  160. throw new InvalidArgumentException(
  161. 'Only one attribute is allowed for medbrief Voters.'
  162. );
  163. }
  164. // get current logged in user
  165. /** @var User $user */
  166. $user = $token->getUser();
  167. // make sure there is a user object (i.e. that the user is logged in)
  168. if (!$user instanceof UserInterface) {
  169. return VoterInterface::ACCESS_DENIED;
  170. }
  171. // Check if the user can view the 'My Profile' section
  172. if ($attribute === self::VIEW_MY_PROFILE) {
  173. return $this->canViewMyProfile($user);
  174. }
  175. // Universally disable this for now, as we will launch without the ability to add new ones.
  176. if ($attribute === self::CREATE_SORTING_MEMO_PHRASE && !$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN')) {
  177. return VoterInterface::ACCESS_DENIED;
  178. }
  179. // Only ROLE_ADMIN and ROLE_SUPER_ADMIN can PRIORITISE_DISCS at this stage.
  180. if ($attribute === self::PRIORITISE_DISCS && $this->authorizationChecker->isGranted('ROLE_ADMIN')) {
  181. return VoterInterface::ACCESS_GRANTED;
  182. }
  183. // Only ROLE_HR_ADMIN can HUMAN_RESOURCE_ADMINISTRATION at this stage.
  184. // The ROLE_HR_ADMIN role can only be added via a command.
  185. if ($attribute === self::HUMAN_RESOURCE_ADMINISTRATION && (!$this->authorizationChecker->isGranted('ROLE_HR_ADMIN') || $this->authorizationChecker->isGranted('IS_IMPERSONATOR'))) {
  186. return VoterInterface::ACCESS_DENIED;
  187. }
  188. // Only SUPER_ADMIN can CREATE_SERVICE_OPTION at this stage.
  189. if ($attribute === self::CREATE_SERVICE_OPTION && !$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN')) {
  190. return VoterInterface::ACCESS_DENIED;
  191. }
  192. // Only SUPER_ADMIN can CREATE_BILLING_ITEM at this stage.
  193. if ($attribute === self::CREATE_BILLING_ITEM && !$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN')) {
  194. return VoterInterface::ACCESS_DENIED;
  195. }
  196. // Only SUPER_ADMIN can CREATE_RECORDS_REQUEST_LETTER_TEMPLATE.
  197. if ($attribute === self::CREATE_RECORDS_REQUEST_LETTER_TEMPLATE && !$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN')) {
  198. return VoterInterface::ACCESS_DENIED;
  199. }
  200. // Only SUPER_ADMIN can LIST_RECORDS_REQUEST_LETTER_TEMPLATE.
  201. if ($attribute === self::LIST_RECORDS_REQUEST_LETTER_TEMPLATE && !$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN')) {
  202. return VoterInterface::ACCESS_DENIED;
  203. }
  204. // Only Super Admins and those with isBillingAdmin can mark ServiceRequests as billed.
  205. if ($attribute === self::MARK_AS_BILLED) {
  206. if ($this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN') || $user->isBillingAdmin()) {
  207. return VoterInterface::ACCESS_GRANTED;
  208. }
  209. return VoterInterface::ACCESS_DENIED;
  210. }
  211. // Only Help Guide Admins can create, edit or delete.
  212. if ($attribute === self::USER_HELP_ADMINISTRATION) {
  213. if ($this->authorizationChecker->isGranted('ROLE_HELP_ADMIN')) {
  214. return VoterInterface::ACCESS_GRANTED;
  215. }
  216. return VoterInterface::ACCESS_DENIED;
  217. }
  218. // Super Admin users can do everything
  219. if ($this->authorizationChecker->isGranted('ROLE_ADMIN')) {
  220. return VoterInterface::ACCESS_GRANTED;
  221. }
  222. /**
  223. * END: Common code for all Voter:vote() methods. Put custom logic below.
  224. */
  225. $this->userHelper->setUser($user);
  226. // if we are looking if the user may create a matter ('Classic' matter creation process)
  227. // if the user is a client administrator for at least one account
  228. if ($attribute == self::CREATE_PROJECT && ($user->isAccountAdministrator() || $user->isAccountProjectManager())) {
  229. // Grab all accounts that the user has a role on
  230. $accountsWithRole = $this->userHelper->getAccountsWithAnyRole();
  231. foreach ($accountsWithRole as $account) {
  232. if ($account->hasAccessToClassicMatterCreation()) {
  233. // Look for at least one account that has access to the 'Classic' matter creation form
  234. return VoterInterface::ACCESS_GRANTED;
  235. }
  236. }
  237. }
  238. // If the User is a Super Admin, Admin, Client SuperAdmin, Client Admin, Client PM, Matter PM
  239. if ($attribute == self::LIST_PROJECT_RENEWALS && ($user->isAccountAdministrator() || $user->isAccountProjectManager() || $user->isProjectManager())) {
  240. return VoterInterface::ACCESS_GRANTED;
  241. }
  242. // if we are looking if the user may create a matter ('Standard' matter creation process)
  243. if ($attribute === self::CREATE_MATTER_REQUEST || $attribute === self::LIST_MATTER_REQUEST) {
  244. if ($user instanceof Firm) {
  245. // If a Firm is authenticated, they by default have access to create/list matter requests
  246. return VoterInterface::ACCESS_GRANTED;
  247. }
  248. // if the user is a client administrator for at least one account
  249. if ($user->isAccountAdministrator() || $user->isAccountProjectManager() || $user->isAccountTechnicalAdmin()) {
  250. // Grab all accounts that the user has a role on
  251. $accountsWithRole = $this->userHelper->getAccountsWithAnyRole();
  252. foreach ($accountsWithRole as $account) {
  253. if ($account->hasAccessToStandardMatterCreation()) {
  254. // Look for at least one account that has access to the 'Standard' matter creation form
  255. return VoterInterface::ACCESS_GRANTED;
  256. }
  257. }
  258. }
  259. }
  260. // if we are looking if the user may view a list of accounts
  261. if ($attribute == self::LIST_ACCOUNT) {
  262. // if the user is a client administrator for at least one account,
  263. // then they may se a list of Accounts
  264. if ($user->isAccountAdministrator()) {
  265. return VoterInterface::ACCESS_GRANTED;
  266. }
  267. if ($user->isAccountTechnicalAdmin()) {
  268. return VoterInterface::ACCESS_GRANTED;
  269. }
  270. }
  271. //if we are looking if the user may view the 'Type' search filter in the matter dashboard, matter list, closed matters list
  272. if ($attribute == self::LIST_PROJECT_TYPE) {
  273. if ($user->isAccountAdministrator() && !$user->isAccountTechnicalAdmin()) {
  274. return VoterInterface::ACCESS_GRANTED;
  275. }
  276. //if the user has an expert role but not an expert viewer role
  277. if ($user->isExpertOnly() && !$user->isExpertViewerOnly()) {
  278. return VoterInterface::ACCESS_GRANTED;
  279. }
  280. return VoterInterface::ACCESS_DENIED;
  281. }
  282. // if we are looking if the user may view a list of projects
  283. if ($attribute == self::LIST_PROJECT) {
  284. // if the user is an expert agency administrator let them pass.
  285. // this should probably be done in a better way, tying permissions
  286. // to an entity in one place somehow....
  287. if ($user->isExpertAgencyAdministrator()) {
  288. return VoterInterface::ACCESS_GRANTED;
  289. }
  290. $accessibleAccounts = $this->userHelper->getAccountIdsWithAnyRole();
  291. $accessibleProjects = $this->userHelper->getProjectIdsWithAnyRole();
  292. // if the user has been assigned at least one Account or Project
  293. // role, then they have access
  294. if ($accessibleAccounts !== [] || $accessibleProjects !== []) {
  295. return VoterInterface::ACCESS_GRANTED;
  296. }
  297. }
  298. // Check if the User can download a list of projects for all the Accounts they have access to.
  299. if ($attribute === self::DOWNLOAD_PROJECT_LIST) {
  300. return $this->canDownloadProjectList($user);
  301. }
  302. if ($attribute === self::LIST_FIRM) {
  303. return $this->canListFirm($user);
  304. }
  305. // If we get to the end of this function, then no decisions have been
  306. // made so we deny access
  307. return VoterInterface::ACCESS_DENIED;
  308. }
  309. /**
  310. * Checks if a user can download a project list.
  311. *
  312. * @param User $user
  313. */
  314. protected function canDownloadProjectList(User $user): int
  315. {
  316. if ($user) {
  317. switch (true) {
  318. case $user->isAccountProjectManager():
  319. case $user->isAccountAdministrator():
  320. return self::ACCESS_GRANTED;
  321. }
  322. }
  323. return self::ACCESS_DENIED;
  324. }
  325. private function canListFirm(User $user): int
  326. {
  327. if ($user && $user->isAccountTechnicalAdmin()) {
  328. return self::ACCESS_GRANTED;
  329. }
  330. return self::ACCESS_DENIED;
  331. }
  332. /**
  333. * Determines if a user can view the 'My Profile' section of their account section.
  334. *
  335. * Access is granted only if the user has ONLY Expert or Scanner roles
  336. * (Expert, Expert View Only, Scanner, Scanner Download) that are NOT
  337. * for Disclosure matters.
  338. *
  339. * @param User $user
  340. *
  341. * @return int
  342. */
  343. private function canViewMyProfile(User $user): int
  344. {
  345. // Load the user into the UserHelper
  346. $this->userHelper->setUser($user);
  347. // If the user has an Expert profile already, grant access.
  348. $expertEntity = $this->expertUserRepository->getExpertByUser($user);
  349. if ($expertEntity instanceof Expert) {
  350. return self::ACCESS_GRANTED;
  351. }
  352. $parsedRoles = $this->userHelper->getParsedRoles();
  353. $hasExpertRole = false;
  354. /**
  355. * It was decided in June 2026 that we will only allow access
  356. * to the profile page for users we've already added to our expert DB.
  357. * This is so that we have more control over who gets included. Currently,
  358. * we do not have a review system for experts who complete their own profile.
  359. */
  360. // // The user may only view their profile if:
  361. // // 1) ALL of their roles are Project-level Expert or Scanner roles
  362. // // 2) At least one role is an Expert role on a non-Disclosure project
  363. // // (scanner-only users should not see this page)
  364. // // Disclosure and Scanner roles are permitted but do not qualify on their own.
  365. // /** @var ParsedRole $parsedRole */
  366. // foreach ($parsedRoles as $parsedRole) {
  367. // // If subject isn't a Project, deny
  368. // if (!($parsedRole->getSubject() instanceof Project)) {
  369. // return self::ACCESS_DENIED;
  370. // }
  371. // // If the role is neither Expert nor Scanner, deny
  372. // if ($parsedRole->isRoleLevelExpert() === false && $parsedRole->isRoleLevelScanner() === false) {
  373. // return self::ACCESS_DENIED;
  374. // }
  375. // // Only count non-Disclosure Expert roles as qualifying
  376. // if ($parsedRole->isRoleLevelExpert() && $parsedRole->isDisclosureSubject() === false) {
  377. // $hasExpertRole = true;
  378. // }
  379. // }
  380. // // Also check role invitations with the same criteria:
  381. // // All invitations must be Project-level Expert or Scanner roles.
  382. // // Only non-Disclosure Expert invitations count as qualifying.
  383. // /** @var ParsedRole $parsedRole */
  384. // foreach ($this->userHelper->getRoleInvitationsParsedRoles() as $parsedRole) {
  385. // // If subject isn't a Project, deny
  386. // if (!($parsedRole->getSubject() instanceof Project)) {
  387. // return self::ACCESS_DENIED;
  388. // }
  389. // // If it's not an Expert or Scanner role, deny
  390. // if ($parsedRole->isRoleLevelExpert() === false && $parsedRole->isRoleLevelScanner() === false) {
  391. // return self::ACCESS_DENIED;
  392. // }
  393. // // Only count non-Disclosure Expert invitations as qualifying
  394. // if ($parsedRole->isRoleLevelExpert() && $parsedRole->isDisclosureSubject() === false) {
  395. // $hasExpertRole = true;
  396. // }
  397. // }
  398. return $hasExpertRole ? self::ACCESS_GRANTED : self::ACCESS_DENIED;
  399. }
  400. }