From 9d9a7037113566decfe923a72d27308ad1ed4b86 Mon Sep 17 00:00:00 2001 From: Taslan Graham Date: Tue, 5 Nov 2024 15:54:34 -0500 Subject: [PATCH] pkp/pkp-lib#10571 WIP: Add checks to limit email template access by usergroups --- .../PKPEmailTemplateController.php | 8 ++- classes/decision/steps/Email.php | 9 ++- classes/emailTemplate/Repository.php | 49 +++++++++++++++++ classes/emailTemplate/maps/Schema.php | 55 +++++++++++++++++-- classes/mail/Repository.php | 48 +++++++++++++--- .../v3_5_0/I10403_EmailTemplateRoleAccess.php | 35 ++++++++++++ controllers/grid/queries/form/QueryForm.php | 13 ++++- .../form/PKPStageParticipantNotifyForm.php | 19 +++++-- 8 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 classes/migration/upgrade/v3_5_0/I10403_EmailTemplateRoleAccess.php diff --git a/api/v1/emailTemplates/PKPEmailTemplateController.php b/api/v1/emailTemplates/PKPEmailTemplateController.php index 87081dd56fc..697f8e6c790 100644 --- a/api/v1/emailTemplates/PKPEmailTemplateController.php +++ b/api/v1/emailTemplates/PKPEmailTemplateController.php @@ -153,7 +153,7 @@ public function getMany(Request $illuminateRequest): JsonResponse Hook::call('API::emailTemplates::params', [$collector, $illuminateRequest]); - $emailTemplates = $collector->getMany(); + $emailTemplates = Repo::emailTemplate()->filterTemplatesByUserAccess($collector->getMany()->all(), $request->getUser(), $request->getContext()->getId()); return response()->json([ 'itemsMax' => $collector->getCount(), @@ -176,6 +176,12 @@ public function get(Request $illuminateRequest): JsonResponse ], Response::HTTP_NOT_FOUND); } + if (!Repo::emailTemplate()->isTemplateAccessibleToUser($request->getUser(), $emailTemplate, $request->getContext()->getId())) { + return response()->json([ + 'error' => __('api.emailTemplates.404.templateNotFound') + ], Response::HTTP_NOT_FOUND); + } + return response()->json(Repo::emailTemplate()->getSchemaMap()->map($emailTemplate), Response::HTTP_OK); } diff --git a/classes/decision/steps/Email.php b/classes/decision/steps/Email.php index 42ca8b5aaed..ab3200d6a9d 100644 --- a/classes/decision/steps/Email.php +++ b/classes/decision/steps/Email.php @@ -128,16 +128,21 @@ protected function getEmailTemplates(): array $emailTemplates = collect(); if ($this->mailable::getEmailTemplateKey()) { $emailTemplate = Repo::emailTemplate()->getByKey($context->getId(), $this->mailable::getEmailTemplateKey()); - if ($emailTemplate) { + if (Repo::emailTemplate()->isTemplateAccessibleToUser($request->getUser(), $emailTemplate, $context->getId())) { $emailTemplates->add($emailTemplate); } Repo::emailTemplate() ->getCollector($context->getId()) ->alternateTo([$this->mailable::getEmailTemplateKey()]) ->getMany() - ->each(fn (EmailTemplate $e) => $emailTemplates->add($e)); + ->each(function (EmailTemplate $e) use ($context, $request, $emailTemplates) { + if (Repo::emailTemplate()->isTemplateAccessibleToUser($request->getUser(), $e, $context->getId())) { + $emailTemplates->add($e); + } + }); } + return Repo::emailTemplate()->getSchemaMap()->mapMany($emailTemplates)->toArray(); } diff --git a/classes/emailTemplate/Repository.php b/classes/emailTemplate/Repository.php index 5916d94606c..08c2153bd0a 100644 --- a/classes/emailTemplate/Repository.php +++ b/classes/emailTemplate/Repository.php @@ -15,10 +15,13 @@ use APP\emailTemplate\DAO; use APP\facades\Repo; +use Illuminate\Support\Facades\DB; use PKP\context\Context; use PKP\core\PKPRequest; use PKP\plugins\Hook; +use PKP\security\Role; use PKP\services\PKPSchemaService; +use PKP\user\User; use PKP\validation\ValidatorFactory; class Repository @@ -208,4 +211,50 @@ public function restoreDefaults($contextId): array Hook::call('EmailTemplate::restoreDefaults', [&$deletedKeys, $contextId]); return $deletedKeys; } + + + public function getGroupsAssignedToTemplate(string $key, int $contextId): array + { + // FIXME - can this be replaced with eloquent? + return DB::table('email_template_role_access') + ->where('email_key', $key) + ->where('context_id', $contextId) + ->pluck('user_group_id') + ->toArray(); + } + + + public function isTemplateAccessibleToUser(User $user, EmailTemplate $template, int $contextId): bool + { + if ($user->hasRole([Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_MANAGER,], $contextId)) { + return true; + } + + $userUserGroups = Repo::userGroup()->userUserGroups($user->getId(), $contextId)->all(); + $templateUserGroups = $this->getGroupsAssignedToTemplate($template->getData('key'), $contextId); + $userHasAccess = false; + + foreach ($userUserGroups as $userGroup) { + if (in_array($userGroup->getId(), $templateUserGroups) || $template->getData('isUnrestricted')) { + $userHasAccess = true; + break; + } + } + + return $userHasAccess; + } + + /** + * Filters a list of EmailTemplates to return only those accessible by a specified user. + * + * @param array $templates List of EmailTemplate objects to filter. + * @param User $user The user whose access level is used for filtering. + * + * @return \Illuminate\Support\Collection Filtered list of EmailTemplate objects accessible to the user. + */ + public function filterTemplatesByUserAccess(array $templates, User $user, int $contextId): \Illuminate\Support\Collection + { + return collect(array_filter($templates, fn (EmailTemplate $template) => $this->isTemplateAccessibleToUser($user, $template, $contextId))); + } + } diff --git a/classes/emailTemplate/maps/Schema.php b/classes/emailTemplate/maps/Schema.php index 4773a259f68..52d1fe4571d 100644 --- a/classes/emailTemplate/maps/Schema.php +++ b/classes/emailTemplate/maps/Schema.php @@ -13,6 +13,8 @@ namespace PKP\emailTemplate\maps; +use APP\core\Application; +use APP\facades\Repo; use Illuminate\Support\Enumerable; use PKP\core\PKPApplication; use PKP\emailTemplate\EmailTemplate; @@ -40,10 +42,12 @@ public function map(EmailTemplate $item): array * Summarize an email template * * Includes properties with the apiSummary flag in the email template schema. + * + * @param null|mixed $mailableClass */ - public function summarize(EmailTemplate $item): array + public function summarize(EmailTemplate $item, $mailableClass = null): array { - return $this->mapByProperties($this->getSummaryProps(), $item); + return $this->mapByProperties($this->getSummaryProps(), $item, $mailableClass); } /** @@ -64,20 +68,59 @@ public function mapMany(Enumerable $collection): Enumerable * * @see self::summarize */ - public function summarizeMany(Enumerable $collection): Enumerable + public function summarizeMany(Enumerable $collection, string $mailableClass = null): Enumerable { $this->collection = $collection; - return $collection->map(function ($item) { - return $this->summarize($item); + return $collection->map(function ($item) use ($mailableClass) { + return $this->summarize($item, $mailableClass); }); } /** * Map schema properties of an Announcement to an assoc array */ - protected function mapByProperties(array $props, EmailTemplate $item): array + protected function mapByProperties(array $props, EmailTemplate $item, string $mailableClass = null): array { $output = []; + + $mailableClass = $mailableClass ?? Repo::mailable()->getMailableByEmailTemplate($item); + + if(!$mailableClass) { + error_log('TEMPLATE NAME ' . $item->getData('key')); + error_log('TEMPLATE ALTERNATE TO ' . $item->getData('alternateTo') ?? ''); + } + + + // some mailable are not found during some operations such as performing a search for templates. So ensure mailable exist before using + if($mailableClass) { + $isUserGroupsAssignable = Repo::mailable()->isGroupsAssignableToTemplates($mailableClass); + + if ($isUserGroupsAssignable) { + $output['assignableUserGroups'] = []; + $output['assignedUserGroupIds'] = []; + } else { + // get roles for mailable + $roles = $mailableClass::getFromRoleIds(); + // Get the groups for each role + $userGroups = []; + $roleNames = Application::get()->getRoleNames(); + + foreach (Repo::userGroup()->getByRoleIds($roles, $this->context->getId())->all() as $group) { + $roleId = $group->getRoleId(); + $userGroups[] = [ + 'id' => $group->getId(), + 'name' => $group->getLocalizedName(), + 'roleId' => $roleId, + 'roleName' => $roleNames[$roleId]]; + } + + $output['assignableUserGroups'] = $userGroups; + // Get the current user groups assigned to the template + $output['assignedUserGroupIds'] = Repo::emailTemplate()->getGroupsAssignedToTemplate($item->getData('key'), Application::get()->getRequest()->getContext()->getId()); + } + } + + foreach ($props as $prop) { switch ($prop) { case '_href': diff --git a/classes/mail/Repository.php b/classes/mail/Repository.php index 4a6b8c85107..dcc72275538 100644 --- a/classes/mail/Repository.php +++ b/classes/mail/Repository.php @@ -18,6 +18,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use PKP\context\Context; +use PKP\emailTemplate\EmailTemplate; use PKP\mail\mailables\DecisionNotifyOtherAuthors; use PKP\mail\mailables\EditReviewNotify; use PKP\mail\mailables\ReviewCompleteNotifyEditors; @@ -116,12 +117,13 @@ public function summarizeMailable(string $class): array 'name' => $class::getName(), 'supportsTemplates' => $class::getSupportsTemplates(), 'toRoleIds' => $class::getToRoleIds(), + 'canAssignUserGroupToTemplates' => $this->isGroupsAssignableToTemplates($class), ]; } /** * Get a full description of a mailable's properties, including any - * assigned email templates + * assigned email templates that are accessible to user */ public function describeMailable(string $class, int $contextId): array { @@ -137,16 +139,14 @@ public function describeMailable(string $class, int $contextId): array $defaultTemplate = Repo::emailTemplate()->getByKey($contextId, $class::getEmailTemplateKey()); + $request = Application::get()->getRequest(); + $user = $request->getUser(); + + // Limit templates to only those accessible to the user's user group(s) + $userAccessibleTemplates = Repo::emailTemplate()->filterTemplatesByUserAccess(array_merge([$defaultTemplate], $templates->values()->toArray()), $user, $contextId); $data['emailTemplates'] = Repo::emailTemplate() ->getSchemaMap() - ->summarizeMany( - collect( - array_merge( - [$defaultTemplate], - $templates->values()->toArray() - ) - ) - ) + ->summarizeMany($userAccessibleTemplates, $class) ->values(); } @@ -206,6 +206,21 @@ protected function isMailableConfigurable(string $class, Context $context): bool return true; } + + // Check if the templates of a given mailable can be assigned to specific groups + /** + * @param Mailable|string $mailable - Mailable class or qualified string referencing the class + */ + public function isGroupsAssignableToTemplates(Mailable|string $mailable): bool + { + return !empty(array_intersect($mailable::getGroupIds(), [ + Mailable::GROUP_SUBMISSION, + Mailable::GROUP_REVIEW, + Mailable::GROUP_COPYEDITING, + Mailable::GROUP_PRODUCTION, + ])); + } + /** * Get the mailables used in this app */ @@ -265,4 +280,19 @@ public function map(): Collection mailables\ValidateEmailSite::class, ]); } + + /** + * Gets the mailable for a given email template + * + * @param EmailTemplate $template + * + * Note: This does not discover/find mailbles defined within plugins + * + * @return string|null - Fully Qualified Class Name of a mailable + */ + public function getMailableByEmailTemplate(EmailTemplate $template): ?string + { + $emailKey = $template->getData('alternateTo') ?? $template->getData('key'); + return $this->map()->first(fn ($class) => $class::getEmailTemplateKey() === $emailKey); + } } diff --git a/classes/migration/upgrade/v3_5_0/I10403_EmailTemplateRoleAccess.php b/classes/migration/upgrade/v3_5_0/I10403_EmailTemplateRoleAccess.php new file mode 100644 index 00000000000..8662b51de88 --- /dev/null +++ b/classes/migration/upgrade/v3_5_0/I10403_EmailTemplateRoleAccess.php @@ -0,0 +1,35 @@ +bigInteger('email_template_role_access_id')->autoIncrement(); + $table->string('email_key', 255); + $table->bigInteger('context_id'); + $table->bigInteger('user_group_id'); + + $table->foreign('context_id')->references('journal_id')->on('journals')->onDelete('cascade'); + $table->foreign('user_group_id')->references('user_group_id')->on('user_groups')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::drop('email_template_role_access'); + } +} diff --git a/controllers/grid/queries/form/QueryForm.php b/controllers/grid/queries/form/QueryForm.php index 30b49e412f8..b32f5ced11b 100644 --- a/controllers/grid/queries/form/QueryForm.php +++ b/controllers/grid/queries/form/QueryForm.php @@ -93,7 +93,7 @@ public function __construct($request, $assocType, $assocId, $stageId, $queryId = ]); Note::create([ - 'userId' => $request->getUser()->getId(), + 'userId' => $request->getUser()->getId(), 'assocType' => Application::ASSOC_TYPE_QUERY, 'assocId' => $query->id, ]); @@ -297,11 +297,17 @@ public function fetch($request, $template = null, $display = false, $actionArgs $mailable = $this->getStageMailable($context, $submission); $data = $mailable->getData(); $defaultTemplate = Repo::emailTemplate()->getByKey($context->getId(), $mailable::getEmailTemplateKey()); - $templateKeySubjectPairs = [$mailable::getEmailTemplateKey() => $defaultTemplate->getLocalizedData('name')]; + + // check to ensure user's user group has access to the templates for the mailable + if(Repo::emailTemplate()->isTemplateAccessibleToUser($user, $defaultTemplate, $context->getId())) { + $templateKeySubjectPairs[$mailable::getEmailTemplateKey()] = $defaultTemplate->getLocalizedData('name'); + } + $alternateTemplates = Repo::emailTemplate()->getCollector($context->getId()) ->alternateTo([$mailable::getEmailTemplateKey()]) ->getMany(); - foreach ($alternateTemplates as $alternateTemplate) { + + foreach (Repo::emailTemplate()->filterTemplatesByUserAccess($alternateTemplates->all(), $user, $context->getId()) as $alternateTemplate) { $templateKeySubjectPairs[$alternateTemplate->getData('key')] = Mail::compileParams( $alternateTemplate->getLocalizedData('name'), $data @@ -309,6 +315,7 @@ public function fetch($request, $template = null, $display = false, $actionArgs } } + $templateMgr->assign('templates', $templateKeySubjectPairs); // Get currently selected participants in the query diff --git a/controllers/grid/users/stageParticipant/form/PKPStageParticipantNotifyForm.php b/controllers/grid/users/stageParticipant/form/PKPStageParticipantNotifyForm.php index d70d4fe0f08..eacfacfa1e7 100644 --- a/controllers/grid/users/stageParticipant/form/PKPStageParticipantNotifyForm.php +++ b/controllers/grid/users/stageParticipant/form/PKPStageParticipantNotifyForm.php @@ -103,15 +103,22 @@ public function fetch($request, $template = null, $display = false) $mailable = $this->getStageMailable($context, $submission); $data = $mailable->getData(); $defaultTemplate = Repo::emailTemplate()->getByKey($context->getId(), $mailable::getEmailTemplateKey()); - $templates = [$mailable::getEmailTemplateKey() => $defaultTemplate->getLocalizedData('name')]; + + $templates = []; + if (Repo::emailTemplate()->isTemplateAccessibleToUser($user, $defaultTemplate, $context->getId())) { + $templates[$mailable::getEmailTemplateKey()] = $defaultTemplate->getLocalizedData('name'); + } $alternateTemplates = Repo::emailTemplate()->getCollector($context->getId()) ->alternateTo([$mailable::getEmailTemplateKey()]) ->getMany(); + foreach ($alternateTemplates as $alternateTemplate) { - $templates[$alternateTemplate->getData('key')] = Mail::compileParams( - $alternateTemplate->getLocalizedData('name'), - $data - ); + if (Repo::emailTemplate()->isTemplateAccessibleToUser($user, $alternateTemplate, $context->getId())) { + $templates[$alternateTemplate->getData('key')] = Mail::compileParams( + $alternateTemplate->getLocalizedData('name'), + $data + ); + } } } @@ -212,7 +219,7 @@ public function sendMessage(int $userId, Submission $submission, Request $reques // Create a head note $headNote = Note::create([ - 'userId' => $request->getUser()->getId(), + 'userId' => $request->getUser()->getId(), 'assocType' => PKPApplication::ASSOC_TYPE_QUERY, 'assocId' => $query->id, 'title' => Mail::compileParams(