Skip to content

Commit

Permalink
Role export access in Leads, Forms, Reports (#12884)
Browse files Browse the repository at this point in the history
* Role export access in Leads, Forms, Reports

* Refactor to enable permission for export

* Fix PHP Stan

* Add migration

* CS fixes

* Rector fixes

* Add unit tests

* Fix PHP Stan with Rector

* Fix PHP Stan issues

* Refactor addCustomPermission to specify parameter types.

* Add permission check before adding export button to contact index page.

* Refactor isAdmin() method to specify return type as bool.

* Support export permission for export of single contact csv

* Remove unnecessary dependencies from lead bundle config.

* Add missing permissions to existing roles during migration.

* Add 'postAction' key to test form data array.

* Fixed indentation in list.html.twig and added conditional logic for export buttons based on permissions and class existence.

* Fixed syntax error in setting buttons array in list.html.twig

---------

Co-authored-by: John Linhart <admin@escope.cz>
Co-authored-by: Ruth Cheesley <ruth@ruthcheesley.co.uk>
  • Loading branch information
3 people committed Apr 22, 2024
1 parent 35d288d commit aad47c7
Show file tree
Hide file tree
Showing 16 changed files with 426 additions and 65 deletions.
Expand Up @@ -196,7 +196,7 @@ public function isGranted($userPermissions, $name, $level)
if (!isset($userPermissions[$name])) {
// the user doesn't have implicit access
return false;
} elseif ($this->permissions[$name]['full'] & $userPermissions[$name]) {
} elseif (isset($this->permissions[$name]['full']) && $this->permissions[$name]['full'] & $userPermissions[$name]) {
return true;
} else {
// otherwise test for specific level
Expand Down Expand Up @@ -272,7 +272,7 @@ public function getPermissionRatio(array $data)
if (in_array('full', $perms)) {
if (1 === count($perms)) {
// full is the only permission so count as 1
if (!empty($data[$level]) && in_array('full', $data[$level])) {
if (!empty($data[$level]) && !in_array('full', $data[$level])) {
++$totalGranted;
}
} else {
Expand Down Expand Up @@ -302,6 +302,31 @@ public function parseForJavascript(array &$perms): void
{
}

/**
* @param array<int|string> $permissions
*/
protected function addCustomPermission(string $level, array $permissions): void
{
$this->permissions[$level] = $permissions;
}

/**
* Adds a custom permission to the form builder, i.e. config only bundles.
*
* @param array<string> $choices
* @param array<string> $data
*/
protected function addCustomFormFields(string $bundle, string $level, FormBuilderInterface &$builder, string $label, array $choices, array $data): void
{
$builder->add("$bundle:$level", PermissionListType::class, [
'choices' => $choices,
'label' => $label,
'data' => (!empty($data[$level]) ? $data[$level] : []),
'bundle' => $bundle,
'level' => $level,
]);
}

/**
* Adds the standard permission set of view, edit, create, delete, publish and full.
*
Expand Down
@@ -0,0 +1,218 @@
<?php

declare(strict_types=1);

namespace Mautic\CoreBundle\Tests\Functional\Controller;

use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\ReportBundle\Entity\Report;
use Mautic\UserBundle\Entity\Permission;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class ExportControllerTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;

public const PERMISSION_LEAD_EXPORT = 'lead:export:enable';
public const PERMISSION_FORM_EXPORT = 'form:export:enable';
public const PERMISSION_REPORT_EXPORT = 'report:export:enable';

public function testContactExportAction(): void
{
$permissions = [
1024 => 'lead:export:enable',
34 => 'lead:leads:create',
];
$this->createAndLoginUser($permissions);

$this->client->request(Request::METHOD_GET, '/s/contacts');
$this->assertStringContainsString('Export to CSV', $this->client->getResponse()->getContent());
$this->client->request(Request::METHOD_GET, '/s/contacts/batchExport?filetype=csv');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}

public function testFormExportAction(): void
{
$permissions = [
1024 => 'form:export:enable',
34 => 'form:forms:create',
];
$this->createAndLoginUser($permissions);

$formId = $this->createForm();

$this->client->request(Request::METHOD_GET, '/s/forms/results/'.$formId);
$this->assertStringContainsString('Export to CSV', $this->client->getResponse()->getContent());
$this->client->request(Request::METHOD_GET, '/s/forms/results/'.$formId.'/export');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}

public function testReportExportAction(): void
{
$permissions = [
1024 => 'report:export:enable',
34 => 'lead:leads:create',
36 => 'report:reports:create',
];
$this->createAndLoginUser($permissions);

$contact = new Lead();
$contact->setDateAdded(new \DateTime());

$this->em->persist($contact);
$this->em->flush();

$report = new Report();
$report->setName('Contact report');
$report->setSource('leads');
$coulmns = [
'l.id',
];
$report->setColumns($coulmns);

$this->getContainer()->get('mautic.report.model.report')->saveEntity($report);

// Check the details page
$this->client->request('GET', '/s/reports/view/'.$report->getId());
Assert::assertTrue($this->client->getResponse()->isOk());

$this->client->request(Request::METHOD_GET, '/s/reports/view/'.$report->getId().'');
$this->assertStringContainsString('Export to CSV', $this->client->getResponse()->getContent());
$this->client->request(Request::METHOD_GET, '/s/reports/view/'.$report->getId().'/export');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}

/**
* @param array<string|int> $permissions
*/
private function createAndLoginUser(array $permissions): User
{
// Create non-admin role
$role = $this->createRole();
// Create permissions to update user for the role
foreach ($permissions as $bitwise => $permission) {
$this->createPermission($permission, $role, $bitwise);
}
// Create non-admin user
$user = $this->createUser($role);

$this->em->flush();
$this->em->detach($role);
/** @phpstan-ignore-next-line */
$this->loginUser($user->getUsername());
/** @phpstan-ignore-next-line */
$this->client->setServerParameter('PHP_AUTH_USER', $user->getUsername());
$this->client->setServerParameter('PHP_AUTH_PW', 'mautic');

return $user;
}

private function createPermission(string $rawPermission, Role $role, int $bitwise): void
{
$parts = explode(':', $rawPermission);
$permission = new Permission();
$permission->setBundle($parts[0]);
$permission->setName($parts[1]);
$permission->setRole($role);
$permission->setBitwise($bitwise);

$this->em->persist($permission);
}

private function createRole(bool $isAdmin = false): Role
{
$role = new Role();
$role->setName('Role');
$role->setIsAdmin($isAdmin);

$this->em->persist($role);

return $role;
}

private function createUser(Role $role): User
{
$user = new User();
$user->setFirstName('John');
$user->setLastName('Doe');
$user->setUsername('john.doe');
$user->setEmail('john.doe@email.com');
$encoder = self::$container->get('security.encoder_factory')->getEncoder($user);
$user->setPassword($encoder->encodePassword('mautic', null));
$user->setRole($role);

$this->em->persist($user);

return $user;
}

private function createForm(): int
{
$formPayload = [
'name' => 'Test Form',
'formType' => 'standalone',
'description' => 'API test',
'postAction' => 'return',
'fields' => [
[
'label' => 'firstname',
'alias' => 'firstname',
'type' => 'text',
],
[
'label' => 'email',
'alias' => 'email',
'type' => 'email',
'leadField' => 'email',
],
[
'label' => 'description',
'alias' => 'description',
'type' => 'textarea',
],
[
'label' => 'checkbox',
'alias' => 'checkbox',
'type' => 'checkboxgrp',
'properties' => [
'syncList' => 0,
'optionlist' => [
'list' => [
[
'label' => 'val1',
'value' => 'val1',
],
[
'label' => 'val2',
'value' => 'val2',
],
[
'label' => 'val3',
'value' => 'val3',
],
],
],
'labelAttributes' => null,
],
],
[
'label' => 'Submit',
'alias' => 'submit',
'type' => 'button',
],
],
];

$this->client->request('POST', '/api/forms/new', $formPayload);
$clientResponse = $this->client->getResponse();
$this->assertEquals(Response::HTTP_CREATED, $clientResponse->getStatusCode(), $clientResponse->getContent());
$response = json_decode($clientResponse->getContent(), true);

return $response['form']['id'];
}
}
2 changes: 2 additions & 0 deletions app/bundles/CoreBundle/Translations/en_US/messages.ini
Expand Up @@ -345,6 +345,8 @@ mautic.core.permissions.publishown="Publish Own"
mautic.core.permissions.view="View"
mautic.core.permissions.viewother="View Others"
mautic.core.permissions.viewown="View Own"
mautic.core.permissions.export="Export access"
mautic.core.permissions.enable="Enable"
mautic.core.popupblocked="It seems the browser is blocking popups. Please enable popups for this site and try again."
mautic.core.position="Position"
mautic.core.signature="Signature"
Expand Down
8 changes: 8 additions & 0 deletions app/bundles/CoreBundle/Twig/Helper/SecurityHelper.php
Expand Up @@ -28,6 +28,14 @@ public function getName(): string
return 'security';
}

/**
* Helper function to check if user is an Admin.
*/
public function isAdmin(): bool
{
return $this->security->isAdmin();
}

/**
* Helper function to check if the logged in user has access to an entity.
*
Expand Down
5 changes: 5 additions & 0 deletions app/bundles/FormBundle/Controller/ResultController.php
Expand Up @@ -171,6 +171,7 @@ public function indexAction(Request $request, PageHelperFactoryInterface $pageHe
'form:forms:editother',
$form->getCreatedBy()
),
'enableExportPermission'=> $this->security->isAdmin() || $this->security->isGranted('form:export:enable', 'MATCH_ONE'),
],
'contentTemplate' => '@MauticForm/Result/list.html.twig',
'passthroughVars' => [
Expand Down Expand Up @@ -279,6 +280,10 @@ public function exportAction(Request $request, $objectId, $format = 'csv')
$formPage = $session->get('mautic.form.page', 1);
$returnUrl = $this->generateUrl('mautic_form_index', ['page' => $formPage]);

if (!$this->security->isAdmin() && !$this->security->isGranted('form:export:enable', 'MATCH_ONE')) {
return $this->accessDenied();
}

if (null === $form) {
// redirect back to form list
return $this->postActionRedirect(
Expand Down
70 changes: 36 additions & 34 deletions app/bundles/FormBundle/Resources/views/Result/list.html.twig
Expand Up @@ -22,42 +22,44 @@

{% block actions %}
{% set buttons = [] %}
{% set buttons = buttons|merge([{
'attr': {
'target': '_new',
'data-toggle': '',
'class': 'btn btn-default btn-nospin',
'href': path('mautic_form_export', {'objectId': form.id, 'format': 'html'}),
},
'btnText': 'mautic.form.result.export.html'|trans,
'iconClass': 'ri-file-code-line',
'primary': true,
}]) %}
{% if enableExportPermission is not empty %}
{% set buttons = buttons|merge([{
'attr': {
'target': '_new',
'data-toggle': '',
'class': 'btn btn-default btn-nospin',
'href': path('mautic_form_export', {'objectId': form.id, 'format': 'html'}),
},
'btnText': 'mautic.form.result.export.html'|trans,
'iconClass': 'ri-file-code-line',
'primary': true,
}]) %}

{% set buttons = buttons|merge([{
'attr': {
'data-toggle': '',
'class': 'btn btn-default btn-nospin',
'href': path('mautic_form_export', {'objectId': form.id, 'format': 'csv'}),
},
'btnText': 'mautic.form.result.export.csv'|trans,
'iconClass': 'ri-file-text-line',
'primary': true,
}]) %}
{% set buttons = buttons|merge([{
'attr': {
'data-toggle': '',
'class': 'btn btn-default btn-nospin',
'href': path('mautic_form_export', {'objectId': form.id, 'format': 'csv'}),
},
'btnText': 'mautic.form.result.export.csv'|trans,
'iconClass': 'ri-file-text-line',
'primary': true,
}]) %}

{# This is q quick way to check if the class exists, if the class exists add this btn #}
{% if constant('\\PhpOffice\\PhpSpreadsheet\\Spreadsheet::VISIBILITY_VISIBLE') is defined %}
{% set buttons = buttons|merge([{
'attr': {
'data-toggle': '',
'class': 'btn btn-default btn-nospin',
'href': path('mautic_form_export', {'objectId': form.id, 'format': 'xlsx'}),
},
'btnText': 'mautic.form.result.export.xlsx'|trans,
'iconClass': 'ri-file-excel-line',
'primary': true,
}]) %}
{% endif %}
{# This is q quick way to check if the class exists, if the class exists add this btn #}
{% if constant('\\PhpOffice\\PhpSpreadsheet\\Spreadsheet::VISIBILITY_VISIBLE') is defined %}
{% set buttons = buttons|merge([{
'attr': {
'data-toggle': '',
'class': 'btn btn-default btn-nospin',
'href': path('mautic_form_export', {'objectId': form.id, 'format': 'xlsx'}),
},
'btnText': 'mautic.form.result.export.xlsx'|trans,
'iconClass': 'ri-file-excel-line',
'primary': true,
}]) %}
{% endif %}
{% endif %}

{% set buttons = buttons|merge([{
'attr': {
Expand Down

0 comments on commit aad47c7

Please sign in to comment.