Add role-based access controls for export functionality

Introduced `CHILL_MAIN_COMPOSE_EXPORT` and `CHILL_MAIN_GENERATE_SAVED_EXPORT` roles for managing export creation and execution permissions. Updated access checks, menu routing, and templates to align with the new roles. Added a migration to extend existing permission groups with the `CHILL_MAIN_COMPOSE_EXPORT` role.
This commit is contained in:
Julien Fastré 2025-04-17 17:34:09 +02:00
parent fc8e3789e0
commit edeb8edbea
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
10 changed files with 123 additions and 27 deletions

View File

@ -14,6 +14,7 @@ namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
@ -40,6 +41,10 @@ final readonly class ExportIndexController
throw new AccessDeniedHttpException('Only regular user can see this page');
}
if (!$this->security->isGranted(ChillExportVoter::COMPOSE_EXPORT)) {
throw new AccessDeniedHttpException(sprintf('Require the %s role', ChillExportVoter::COMPOSE_EXPORT));
}
$exports = $this->exportManager->getExportsGrouped(true);
$lastExecutions = [];

View File

@ -20,6 +20,7 @@ use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Form\SavedExportType;
use Chill\MainBundle\Repository\ExportGenerationRepository;
use Chill\MainBundle\Repository\SavedExportRepositoryInterface;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\ExportGenerationVoter;
use Chill\MainBundle\Security\Authorization\SavedExportVoter;
use Doctrine\ORM\EntityManagerInterface;
@ -170,8 +171,8 @@ final readonly class SavedExportController
{
$user = $this->security->getUser();
if (!$this->security->isGranted('ROLE_USER') || !$user instanceof User) {
throw new AccessDeniedHttpException();
if (!$this->security->isGranted(ChillExportVoter::GENERATE_SAVED_EXPORT) || !$user instanceof User) {
throw new AccessDeniedHttpException(sprintf('Missing role: %s', ChillExportVoter::GENERATE_SAVED_EXPORT));
}
$exports = array_filter(

View File

@ -78,6 +78,7 @@ use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Config\FileLocator;
@ -332,6 +333,9 @@ class ChillMainExtension extends Extension implements
'strategy' => 'unanimous',
'allow_if_all_abstain' => false,
],
'role_hierarchy' => [
ChillExportVoter::COMPOSE_EXPORT => ChillExportVoter::GENERATE_SAVED_EXPORT,
],
]);
// add crud api

View File

@ -1,9 +1,11 @@
<ul class="nav nav-pills justify-content-center">
{% if is_granted('CHILL_MAIN_COMPOSE_EXPORT') %}
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_index') }}" class="nav-link {% if current == 'common' %}active{% endif %}">
{{ 'Exports list'|trans }}
</a>
</li>
{% endif %}
<li class="nav-item">
<a href="{{ chill_path_forward_return_path('chill_main_export_saved_list_my') }}" class="nav-link {% if current == 'my' %}active{% endif %}">
{{ 'saved_export.Saved exports'|trans }}

View File

@ -49,9 +49,9 @@ class SectionMenuBuilder implements LocalMenuBuilderInterface
);
}
if ($this->authorizationChecker->isGranted(ChillExportVoter::EXPORT)) {
if ($this->authorizationChecker->isGranted(ChillExportVoter::GENERATE_SAVED_EXPORT)) {
$menu->addChild($this->translator->trans('Export Menu'), [
'route' => 'chill_main_export_index',
'route' => 'chill_main_export_saved_list_my',
])
->setExtras([
'icons' => ['upload'],

View File

@ -11,35 +11,34 @@ declare(strict_types=1);
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Export\DirectExportInterface;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\ExportManager;
use Chill\MainBundle\Security\ProvideRoleHierarchyInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ChillExportVoter extends Voter
class ChillExportVoter extends Voter implements ProvideRoleHierarchyInterface
{
final public const EXPORT = 'chill_export';
/**
* Role which give access to the creation of new export from the export itself.
*/
final public const COMPOSE_EXPORT = 'CHILL_MAIN_COMPOSE_EXPORT';
/**
* Role which give access to the execution and edition to the saved exports, but not for creating new ones.
*/
final public const GENERATE_SAVED_EXPORT = 'CHILL_MAIN_GENERATE_SAVED_EXPORT';
private readonly VoterHelperInterface $helper;
public function __construct(VoterHelperFactoryInterface $voterHelperFactory, ExportManager $exportManager)
public function __construct(VoterHelperFactoryInterface $voterHelperFactory)
{
$this->helper = $voterHelperFactory
->generate(self::class)
->addCheckFor(null, [self::EXPORT])
->addCheckFor(null, [self::COMPOSE_EXPORT, self::GENERATE_SAVED_EXPORT])
->build();
}
protected function supports($attribute, $subject): bool
{
if (
($subject instanceof ExportInterface or $subject instanceof DirectExportInterface)
&& $attribute === $subject->requiredRole()
) {
return true;
}
return $this->helper->supports($attribute, $subject);
}
@ -47,4 +46,21 @@ class ChillExportVoter extends Voter
{
return $this->helper->voteOnAttribute($attribute, $subject, $token);
}
public function getRolesWithHierarchy(): array
{
return ['export.role.export_role' => [
self::COMPOSE_EXPORT, self::GENERATE_SAVED_EXPORT,
]];
}
public function getRoles(): array
{
return [self::COMPOSE_EXPORT, self::GENERATE_SAVED_EXPORT];
}
public function getRolesWithoutScope(): array
{
return $this->getRoles();
}
}

View File

@ -35,7 +35,7 @@ class SavedExportVoter extends Voter
self::SHARE,
];
public function __construct(private ExportManager $exportManager, private Security $security) {}
public function __construct(private ExportManager $exportManager) {}
protected function supports($attribute, $subject): bool
{

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250417135712 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the role CHILL_MAIN_COMPOSE_EXPORT to the existing permissions groups which contains already a permission related to list or stat';
}
public function up(Schema $schema): void
{
$this->addSql(
<<<'SQL'
CREATE TEMPORARY TABLE to_create AS (
SELECT DISTINCT permissionsgroup_rolescope.permissionsgroup_id, 'CHILL_MAIN_COMPOSE_EXPORT' AS role
FROM permissionsgroup_rolescope
JOIN public.role_scopes rs on permissionsgroup_rolescope.rolescope_id = rs.id
WHERE role LIKE '%STATS%' or role LIKE '%LIST%'
)
SQL
);
$this->addSql(
<<<'SQL'
INSERT INTO role_scopes(id, scope_id, role)
SELECT nextval('role_scopes_id_seq'), null, 'CHILL_MAIN_COMPOSE_EXPORT'
WHERE NOT EXISTS (SELECT 1 FROM role_scopes s WHERE role like 'CHILL_MAIN_COMPOSE_EXPORT')
SQL
);
$this->addSql('ALTER TABLE to_create ADD COLUMN rolescope_id INT');
$this->addSql(
<<<'SQL'
UPDATE to_create SET rolescope_id = (
SELECT id FROM role_scopes
WHERE to_create.role = role_scopes.role)
SQL
);
$this->addSql(
<<<'SQL'
INSERT INTO permissionsgroup_rolescope (permissionsgroup_id, rolescope_id)
SELECT to_create.permissionsgroup_id, to_create.rolescope_id FROM to_create
SQL
);
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException();
}
}

View File

@ -714,8 +714,12 @@ notification:
mark_as_read: Marquer comme lu
mark_as_unread: Marquer comme non-lu
CHILL_MAIN_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder
CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés
export:
role:
export_role: Exports
generation:
Export generation is pending: La génération de l'export est en cours
Export generation is pending_short: En cours

View File

@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\PersonBundle\DependencyInjection;
use Chill\MainBundle\DependencyInjection\MissingBundleException;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\PersonBundle\Controller\AccompanyingPeriodCommentApiController;
use Chill\PersonBundle\Controller\AccompanyingPeriodResourceApiController;
use Chill\PersonBundle\Controller\AdministrativeStatusController;
@ -1027,8 +1026,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
'role_hierarchy' => [
PersonVoter::UPDATE => [PersonVoter::SEE],
PersonVoter::CREATE => [PersonVoter::SEE],
PersonVoter::LISTS => [ChillExportVoter::EXPORT],
PersonVoter::STATS => [ChillExportVoter::EXPORT],
// accompanying period
AccompanyingPeriodVoter::SEE_DETAILS => [AccompanyingPeriodVoter::SEE],
AccompanyingPeriodVoter::CREATE => [AccompanyingPeriodVoter::SEE_DETAILS],