Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pkp/pkp-lib#10571 limit email template access by user groups #10581

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
45a86ac
pkp/pkp-lib#10571 WIP: Add checks to limit email template access by u…
taslangraham Nov 5, 2024
f702945
pkp/pkp-lib#10571 WIP: Allow admins and managers to assign user group…
taslangraham Nov 6, 2024
ced55fc
pkp/pkp-lib#10571 WIP: add support for unrestricted templates
taslangraham Nov 8, 2024
13fdaf2
pkp/pkp-lib#10571 Convert queries to eloquent
taslangraham Nov 9, 2024
7ba258c
Update migration logic
taslangraham Nov 10, 2024
7b9785c
pkp/pkp-lib#10571 Update emailTemplate schema
taslangraham Nov 12, 2024
5411845
pkp/pkp-lib#10571 Update locales
taslangraham Nov 12, 2024
0f4d4f5
Added back accidentally deleted migration and fix errors
taslangraham Nov 12, 2024
de58aab
pkp/pkp-lib#10571 Move migration into v3.6 folder
taslangraham Nov 12, 2024
362d0be
pkp/pkp-lib#10571 add additional access checks
taslangraham Nov 13, 2024
c4fe1b7
pkp/pkp-lib#10571 Add migration to install process
taslangraham Nov 13, 2024
20aaeaa
pkp/pkp-lib#10571 Make default templates unrestricted on Context crea…
taslangraham Nov 14, 2024
7b1fae9
pkp/pkp-lib#10571 Make templates assignable to all user groups
taslangraham Nov 14, 2024
cf5219a
pkp/pkp-lib#10571 Allow restriction to be set during template install…
taslangraham Nov 15, 2024
2ae5004
Code cleanup
taslangraham Nov 18, 2024
80068ac
pkp/pkp-lib#10571 Update access on template deletion and template res…
taslangraham Nov 21, 2024
d2e9586
pkp/pkp-lib#10571 Remove unnecessary email form components and clean …
taslangraham Nov 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion api/v1/emailTemplates/PKPEmailTemplateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public function getMany(Request $illuminateRequest): JsonResponse

Hook::call('API::emailTemplates::params', [$collector, $illuminateRequest]);

$emailTemplates = $collector->getMany();
$emailTemplates = collect(Repo::emailTemplate()->filterTemplatesByUserAccess($collector->getMany(), $request->getUser(), $request->getContext()->getId()));

return response()->json([
'itemsMax' => $collector->getCount(),
Expand All @@ -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);
}

Expand All @@ -198,6 +204,9 @@ public function add(Request $illuminateRequest): JsonResponse

$emailTemplate = Repo::emailTemplate()->newDataObject($params);
Repo::emailTemplate()->add($emailTemplate);

Repo::emailTemplate()->setEmailTemplateAccess($emailTemplate, $requestContext->getId(), $params['assignedUserGroupIds'], $params['isUnrestricted']);

$emailTemplate = Repo::emailTemplate()->getByKey($emailTemplate->getData('contextId'), $emailTemplate->getData('key'));

return response()->json(Repo::emailTemplate()->getSchemaMap()->map($emailTemplate), Response::HTTP_OK);
Expand Down Expand Up @@ -235,6 +244,11 @@ public function edit(Request $illuminateRequest): JsonResponse
$params['contextId'] = $requestContext->getId();
}


// If the user submitted an empty list (meaning all user groups were unchecked), the empty array is converted to null in the request's data.
// Convert back to an empty array.
$params['assignedUserGroupIds'] = $params['assignedUserGroupIds'] ?: [];

$errors = Repo::emailTemplate()->validate(
$emailTemplate,
$params,
Expand All @@ -246,6 +260,7 @@ public function edit(Request $illuminateRequest): JsonResponse
}

Repo::emailTemplate()->edit($emailTemplate, $params);
Repo::emailTemplate()->setEmailTemplateAccess($emailTemplate, $requestContext->getId(), $params['assignedUserGroupIds'], $params['isUnrestricted']);

$emailTemplate = Repo::emailTemplate()->getByKey(
// context ID is null if edited for the first time
Expand Down Expand Up @@ -276,6 +291,13 @@ public function delete(Request $illuminateRequest): JsonResponse
$props = Repo::emailTemplate()->getSchemaMap()->map($emailTemplate);
Repo::emailTemplate()->delete($emailTemplate);

// Default templates are not deleted - instead, their body and subject fields are reset.
// Only delete access group data for custom templates.
// Custom templates will have an alternateTo (which is the default)
if($emailTemplate->getData('alternateTo')) {
Repo::emailTemplate()->deleteTemplateGroupAccess($requestContext->getId(), [$illuminateRequest->route('key')]);
}

return response()->json($props, Response::HTTP_OK);
}

Expand Down
28 changes: 28 additions & 0 deletions classes/components/forms/emailTemplate/EmailTemplateForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@

namespace PKP\components\forms\emailTemplate;

use APP\core\Application;
use APP\facades\Repo;
use PKP\components\forms\FieldOptions;
use PKP\components\forms\FieldPreparedContent;
use PKP\components\forms\FieldText;
use PKP\components\forms\FormComponent;
use PKP\userGroup\UserGroup;

class EmailTemplateForm extends FormComponent
{
Expand All @@ -30,6 +34,14 @@ public function __construct(string $action, array $locales)
$this->method = 'POST';
$this->locales = $locales;

$userGroups = collect();
Repo::userGroup()->getCollector()
->filterByContextIds([Application::get()->getRequest()->getContext()->getId()])
->getMany()->each(fn (UserGroup $group) => $userGroups->add([
'value' => $group->getId(),
'label' => $group->getLocalizedName()
]));

$this->addField(new FieldText('name', [
'label' => __('common.name'),
'description' => __('manager.emailTemplate.name.description'),
Expand All @@ -46,6 +58,22 @@ public function __construct(string $action, array $locales)
'isMultilingual' => true,
'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist',
'plugins' => 'paste,link,lists',
]))
->addField(new FieldOptions('isUnrestricted', [
'label' => __('admin.workflow.email.userGroup.assign.unrestricted'),
'groupId' => 'isUnrestricted',
'description' => __('admin.workflow.email.userGroup.unrestricted.template.note'),
'type' => 'checkbox',
'options' => [
['value' => true, 'label' => __('admin.workflow.email.userGroup.assign.unrestricted')],
],
'value' => true
]))
->addField(new FieldOptions('assignedUserGroupIds', [
'label' => __('admin.workflow.email.userGroup.allowed'),
'type' => 'checkbox',
'value' => [],
'options' => $userGroups
]));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,7 @@ public function fetchTemplateBody(array $args, PKPRequest $request): ?JSONMessag
};
$template = Repo::emailTemplate()->getByKey($context->getId(), $request->getUserVar('template'));

if (!$template) {
if (!$template || ! Repo::emailTemplate()->isTemplateAccessibleToUser($request->getUser(), $template, $context->getId())) {
return null;
}

Expand Down
8 changes: 6 additions & 2 deletions classes/decision/steps/Email.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,18 @@ protected function getEmailTemplates(): array
$emailTemplates = collect();
if ($this->mailable::getEmailTemplateKey()) {
$emailTemplate = Repo::emailTemplate()->getByKey($context->getId(), $this->mailable::getEmailTemplateKey());
if ($emailTemplate) {
if ($emailTemplate && 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 $template) use ($context, $request, $emailTemplates) {
if (Repo::emailTemplate()->isTemplateAccessibleToUser($request->getUser(), $template, $context->getId())) {
$emailTemplates->add($template);
}
});
}

return Repo::emailTemplate()->getSchemaMap()->mapMany($emailTemplates)->toArray();
Expand Down
55 changes: 53 additions & 2 deletions classes/emailTemplate/DAO.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,17 @@ public function getMainEmailTemplatesFilename()
* skipping others
* @param bool $skipExisting If true, do not install email templates
* that already exist in the database
* @param bool $recordTemplateGroupAccess - If true, records the templates as unrestricted. For versions 3.6 or higher, this value should be set to true when calling `installEmailTemplates`.
* By default, it is set to false to ensure compatibility with older processes (e.g., migrations)
* where the `email_template_user_group_access` table may not exist at the time of execution.
*
*/
public function installEmailTemplates(
string $templatesFile,
array $locales = [],
?string $emailKey = null,
bool $skipExisting = false
bool $skipExisting = false,
$recordTemplateGroupAccess = false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mention this directly below, but I don't think this optional parameter will be necessary. I think the assumption of "unrestricted if no additional data provided" below is enough.

): bool {
$xmlDao = new XMLDAO();
$data = $xmlDao->parseStruct($templatesFile, ['email']);
Expand Down Expand Up @@ -281,6 +285,20 @@ public function installEmailTemplates(
$this->installAlternateEmailTemplates($contextId, $attrs['key']);
}
}

if ($recordTemplateGroupAccess) {
// Default to true if `isUnrestricted` is not set.
$isUnrestricted = $attrs['isUnrestricted'] ?? '1';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check is enough to cover any plugin-related issues or the email template XML not having this flag. I don't think you need the $recordTempalteGroupAccess parameter at all.


if ($isUnrestricted !== '1' && $isUnrestricted !== '0') {
throw new Exception('Invalid value given for the `isUnrestricted` attribute on the ' . $attrs['key'] . ' template.');
}

$contextIds = app()->get('context')->getIds();
foreach ($contextIds as $contextId) {
Repo::emailTemplate()->markTemplateAsUnrestricted($attrs['key'], (bool)$isUnrestricted, $contextId);
}
}
}
return true;
}
Expand Down Expand Up @@ -381,7 +399,7 @@ public function installAlternateEmailTemplates(int $contextId, ?string $emailKey
'Tried to install email template as an alternate to `' . $alternateTo . '`, but no default template exists with this key. Installing ' . $alternateTo . ' email template first',
E_USER_WARNING
);
$this->installEmailTemplates(Repo::emailTemplate()->dao->getMainEmailTemplatesFilename(), [], $alternateTo);
$this->installEmailTemplates(Repo::emailTemplate()->dao->getMainEmailTemplatesFilename(), [], $alternateTo, false, true);
}

DB::table($this->table)->insert([
Expand Down Expand Up @@ -448,4 +466,37 @@ protected function getUniqueKey(EmailTemplate $emailTemplate): string

return $key;
}


/**
* Sets email template's unrestricted status to their defaults
*/
public function setTemplateDefaultUnrestirctedSetting(int $contextId, ?array $emailKeys = null)
{
$xmlDao = new XMLDAO();
$data = $xmlDao->parseStruct($this->getMainEmailTemplatesFilename(), ['email']);

if (!isset($data['email'])) {
return false;
}

foreach ($data['email'] as $entry) {
$attrs = $entry['attributes'];

if ($emailKeys !== null && !in_array($attrs['key'], $emailKeys)) {
continue;
}

// Default to true if `isUnrestricted` is not set.
$isUnrestricted = $attrs['isUnrestricted'] ?? '1';

if ($isUnrestricted !== '1' && $isUnrestricted !== '0') {
throw new Exception('Invalid value given for the `isUnrestricted` attribute on the ' . $attrs['key'] . ' template.');
}

Repo::emailTemplate()->markTemplateAsUnrestricted($attrs['key'], (bool)$isUnrestricted, $contextId);
}

return true;
}
}
58 changes: 58 additions & 0 deletions classes/emailTemplate/EmailTemplateAccessGroup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs file docblock.


/**
* @file classes/emailTemplate/EmailTemplateAccessGroup.php
*
* Copyright (c) 2014-2024 Simon Fraser University
* Copyright (c) 2000-2024 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class EmailTemplateAccessGroup
*
* @ingroup emailTemplate
*
* @brief Eloquent model for email template user group access
*/

namespace PKP\emailTemplate;

use Eloquence\Behaviours\HasCamelCasing;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class EmailTemplateAccessGroup extends Model
{
use HasCamelCasing;

public $timestamps = false;
protected $primaryKey = 'email_template_user_group_access_id';
protected $table = 'email_template_user_group_access';
protected $fillable = ['userGroupId', 'contextId', 'emailKey'];


/**
* Scope a query to only include email template access records for email templates with specific keys.
*/
public function scopeWithEmailKey(Builder $query, ?array $keys): Builder
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would include a comment on what these scopes do for reference (e.g. optionally filter by email key). Same for the two below as well.

{
return $query->when(!empty($keys), function ($query) use ($keys) {
return $query->whereIn('email_key', $keys);
});
}

/**
* Scope a query to only include email template access records that are related to a specific context ID.
*/
public function scopeWithContextId(Builder $query, int $contextId): Builder
{
return $query->where('context_id', $contextId);
}

/**
* Scope a query to only include email template access records for specific user group IDs.
*/
public function scopeWithGroupIds(Builder $query, array $ids): Builder
{
return $query->whereIn('user_group_id', $ids);
}
}
Loading