diff --git a/.changes/unreleased/Feature-20250904-181032.yaml b/.changes/unreleased/Feature-20250904-181032.yaml new file mode 100644 index 000000000..145299a36 --- /dev/null +++ b/.changes/unreleased/Feature-20250904-181032.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Add a command to generate a list of permissions +time: 2025-09-04T18:10:32.334524026+02:00 +custom: + Issue: "" + SchemaChange: No schema change diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 97a2be27d..a53bfe8cf 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -27,11 +27,11 @@ Chill is a comprehensive web application built as a set of Symfony bundles. It i ## Project Structure -Note: This is a project which exists from a long time ago, and we found multiple structure inside each bundle. When having the choice, the developers should choose the new structure. +Note: This is a project that's existed for a long time, and throughout the years we've used multiple structures inside each bundle. When having the choice, the developers should choose the new structure. The project follows a standard Symfony bundle structure: - `/src/Bundle/`: Contains all the Chill bundles. The code is either at the root of the bundle directory, or within a `src/` directory (preferred). See psr4 mapping at the root's `composer.json`. -- each bundle come with his own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside to the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way). +- each bundle comes with its own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way). - `/docs/`: Contains project documentation Each bundle typically has the following structure: @@ -46,13 +46,13 @@ Each bundle typically has the following structure: ### A special word about TicketBundle -The ticket bundle is developed using a kind of "Command" pattern. The controller fill a "Command", and a "CommandHandler" handle this command. They are savec in the `src/Bundle/ChillTicketBundle/src/Action` directory. +The ticket bundle is developed using a kind of "Command" pattern. The controller fills a "Command," and a "CommandHandler" handles this command. They are saved in the `src/Bundle/ChillTicketBundle/src/Action` directory. ## Development Guidelines ### Building and Configuration Instructions -All the command should be run through the `symfony` command, which will configure the required variables. +All the commands should be run through the `symfony` command, which will configure the required variables. For assets, we must ensure that we use node at version `^20.0.0`. This is done using `nvm use 20`. @@ -87,7 +87,7 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u docker compose up -d ``` -5. **Set Up the Database**: +6. **Set Up the Database**: ```bash # Create the database symfony console doctrine:database:create @@ -99,20 +99,20 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u symfony console doctrine:fixtures:load ``` -6. **Build Assets**: +7. **Build Assets**: ```bash nvm use 20 yarn run encore dev ``` -7. **Start the Development Server**: +8. **Start the Development Server**: ```bash symfony server:start -d ``` #### Docker Setup -The project includes Docker configuration for easier development: +The project includes a Docker configuration for easier development: 1. **Start Docker Services**: ```bash @@ -153,9 +153,9 @@ Key configuration files: Each time a doctrine entity is created, we generate migration to adapt the database. -The migration are created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace `, where the namespace is the relevant namespace for migration. As this is a bash script, do not forget to quote the `\` (`\` must become `\\` in your command). +The migration is created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace `, where the namespace is the relevant namespace for migration. As this is a bash script, remember to quote the `\` (`\` must become `\\` in your command). -Each bundle has his own namespace for migration (always ask me to confirm that command, with a list of updated / created entities so that I can confirm you that it is ok): +Each bundle has his own namespace for migration (always ask me to confirm that command with a list of updated / created entities so that I can confirm to you that it is ok): - `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`; - `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`; @@ -183,7 +183,7 @@ Once created the, comment's classes should be removed and a description of the c When we need to use a DateTime or DateTimeImmutable that need to express "now", we prefer the usage of `Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities, -where injection does not work when restoring an entity from database, but usually possible in services. +where injection does not work when restoring an entity from a database, but usually possible in services. In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface` where we have full and easy control of the date. @@ -198,9 +198,9 @@ The project uses PHPUnit for testing. Each bundle has its own test suite, and th For creating mock, we prefer using prophecy (library phpspec/prophecy). -##### Useful helpers and tips that avoid create a mock +##### Useful helpers and tips that avoid creating a mock -Some notable implementations that are tests helper, and avoid to create a mock: +Some notable implementations that are test helpers and avoid creating a mock: - `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`; - `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above); @@ -297,7 +297,7 @@ class TicketTest extends TestCase #### Test Database -For tests that require a database, the project uses postgresql database filled by fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file. +For tests that require a database, the project uses a postgresql database filled with fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file. ### Code Quality Tools diff --git a/src/Bundle/ChillMainBundle/Command/DumpListPermissionsCommand.php b/src/Bundle/ChillMainBundle/Command/DumpListPermissionsCommand.php new file mode 100644 index 000000000..cd5510eb3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Command/DumpListPermissionsCommand.php @@ -0,0 +1,35 @@ +roleDumper->dumpAsMarkdown(); + $output->writeln($markdown); + + return Command::SUCCESS; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php b/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php index b71b2d102..d1ec99cd3 100644 --- a/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php +++ b/src/Bundle/ChillMainBundle/Export/ExportConfigNormalizer.php @@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface; use Chill\MainBundle\Repository\RegroupmentRepositoryInterface; /** - * @phpstan-type NormalizedData array{centers: array{centers: list, regroupments: list}, export: array{form: array, version: int}, filters: array, version: int}>, aggregators: array, version: int}>, pick_formatter: string, formatter: array{form: array, version: int}} + * @phpstan-type NormalizedData array{centers: array{centers: list, regroupments: list}, export: array{form: array, version: int}, filters: array, version: int}>, aggregators: array, version: int}>, pick_formatter?: string, formatter: array{form: array, version: int}} */ class ExportConfigNormalizer { diff --git a/src/Bundle/ChillMainBundle/Security/RoleDumper.php b/src/Bundle/ChillMainBundle/Security/RoleDumper.php new file mode 100644 index 000000000..4bd9b1944 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Security/RoleDumper.php @@ -0,0 +1,86 @@ +roleProvider->getRoles(); + $rolesWithoutScopes = $this->roleProvider->getRolesWithoutScopes(); + + // Group roles by title + $groups = []; + foreach ($roles as $role) { + $title = $this->roleProvider->getRoleTitle($role); + $title ??= 'Other'; + $groups[$title][] = $role; + } + + // Sort groups by title + ksort($groups, SORT_NATURAL | SORT_FLAG_CASE); + + $lines = []; + foreach ($groups as $title => $roleList) { + // Sort roles by translated label for deterministic output + usort($roleList, function (string $a, string $b): int { + $ta = $this->translator->trans($a); + $tb = $this->translator->trans($b); + + return strcasecmp($ta, $tb); + }); + + $translatedTitle = $this->translator->trans($title); + $lines[] = '## '.$translatedTitle; + + foreach ($roleList as $role) { + // Translate primary role + $translatedRole = $this->translator->trans($role); + + // Scope marker: (S) if needs scope, (~~S~~) if no scope required + $needsScope = !in_array($role, $rolesWithoutScopes, true); + $scopeMarker = $needsScope ? '(S)' : '(~~S~~)'; + + // Compute dependent roles from hierarchy (exclude itself) + $reachable = $this->roleHierarchy->getReachableRoleNames([$role]); + $dependents = array_values(array_filter($reachable, static fn (string $r): bool => $r !== $role)); + + // Translate dependents and sort deterministically + $translatedDependents = array_map(fn (string $r) => $this->translator->trans($r), $dependents); + sort($translatedDependents, SORT_NATURAL | SORT_FLAG_CASE); + + if (count($translatedDependents) > 0) { + $lines[] = sprintf('- **%s** %s: %s', $translatedRole, $scopeMarker, implode(', ', $translatedDependents)); + } else { + $lines[] = sprintf('- **%s** %s', $translatedRole, $scopeMarker); + } + } + + // Add a blank line between groups + $lines[] = ''; + } + + // Trim possible trailing blank line + $markdown = rtrim(implode("\n", $lines)); + + return $markdown."\n"; // End with newline for POSIX friendliness + } +} diff --git a/src/Bundle/ChillMainBundle/Security/RoleProvider.php b/src/Bundle/ChillMainBundle/Security/RoleProvider.php index ccaa92409..738c47f36 100644 --- a/src/Bundle/ChillMainBundle/Security/RoleProvider.php +++ b/src/Bundle/ChillMainBundle/Security/RoleProvider.php @@ -52,12 +52,8 @@ class RoleProvider /** * Get the title for each role. - * - * @param string $role - * - * @return string the title of the role */ - public function getRoleTitle($role) + public function getRoleTitle(string $role): ?string { $this->initializeRolesTitlesCache(); @@ -73,7 +69,7 @@ class RoleProvider /** * initialize the array for caching role and titles. */ - private function initializeRolesTitlesCache() + private function initializeRolesTitlesCache(): void { // break if already initialized if (null !== $this->rolesTitlesCache) { diff --git a/src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php b/src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php new file mode 100644 index 000000000..8b7751c6f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Security/RoleDumperTest.php @@ -0,0 +1,98 @@ + [self::R_PERSON_SEE, self::R_PERSON_UPDATE], + 'Report' => [self::R_REPORT_SEE], + ]; + } + }; + + $roleProvider = new RoleProvider([$provider]); + + // Fake role hierarchy: UPDATE implies SEE; others none + $roleHierarchy = new class () implements RoleHierarchyInterface { + public function getReachableRoleNames(array $roles): array + { + $output = []; + foreach ($roles as $r) { + $output[] = $r; + if ('CHILL_PERSON_UPDATE' === $r) { + $output[] = 'CHILL_PERSON_SEE'; + } + } + + return array_values(array_unique($output)); + } + }; + + // Fake translator that clearly shows translation applied + $translator = new class () implements TranslatorInterface { + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + return 'T('.$id.')'; + } + + public function getLocale(): string + { + return 'en'; + } + }; + + $dumper = new RoleDumper($roleProvider, $roleHierarchy, $translator); + $md = $dumper->dumpAsMarkdown(); + + $expected = "## T(Person)\n" + ."- **T(CHILL_PERSON_SEE)** (S)\n" + ."- **T(CHILL_PERSON_UPDATE)** (S): T(CHILL_PERSON_SEE)\n\n" + ."## T(Report)\n" + ."- **T(CHILL_REPORT_SEE)** (~~S~~)\n"; + + self::assertSame($expected, $md); + } +} diff --git a/src/Bundle/ChillMainBundle/config/services/command.yaml b/src/Bundle/ChillMainBundle/config/services/command.yaml index 158bf3fab..313ca28c5 100644 --- a/src/Bundle/ChillMainBundle/config/services/command.yaml +++ b/src/Bundle/ChillMainBundle/config/services/command.yaml @@ -80,3 +80,7 @@ services: Chill\MainBundle\Command\SynchronizeEntityInfoViewsCommand: tags: - {name: console.command} + + Chill\MainBundle\Command\DumpListPermissionsCommand: + autoconfigure: true + autowire: true