From 41132aadd82362cfd0abfc00540f1ec6ee27bdef Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Thu, 15 Oct 2026 17:23:53 +0200 Subject: [PATCH] #169 - Allow to disable the security configuration overriding the application main firewall - New security.configure_firewall option (default true, same behaviour as before). The security configuration is added with loadFromExtension(), after the application one: it overrides the provider, login form and logout of the application main firewall. With false, no security configuration is added (neither the process_user_provider user provider nor the firewall): the application configures them, e.g. on a firewall dedicated to the UI. The option is read in prepend(), before the bundle configuration is processed: it must be a literal boolean. - monolog, doctrine_migrations and security configurations are skipped when the bundle is not enabled (getExtension() LogicException). - Docs: these configurations are appended, not prepended (only the Messenger one is); configuring the security in the application. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + docs/index.md | 6 +- docs/reference/01-bundle_configuration.md | 8 +- docs/reference/03-users_and_security.md | 57 +++++++++++- .../CleverAgeUiProcessExtension.php | 91 ++++++++++++++----- src/DependencyInjection/Configuration.php | 3 +- .../CleverAgeUiProcessExtensionTest.php | 48 ++++++++++ .../DependencyInjection/ConfigurationTest.php | 7 +- 8 files changed, 189 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d9340..a021859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Latest * [#149](https://github.com/cleverage/ui-process-bundle/issues/149) Clean up composer.json: remove the unused `symfony/dotenv`, `symfony/runtime`, `ext-ctype` and `ext-iconv` requirements (applications relying on the bundle to install `symfony/dotenv` / `symfony/runtime` must require them, as the Symfony skeleton does); replace `doctrine/common` by `doctrine/collections` (only `ArrayCollection` is used); require `twig/twig`, `symfony/twig-bundle`, `psr/log`, `doctrine/persistence`, `doctrine/migrations` and `symfony/event-dispatcher-contracts`, used directly but only installed transitively; add `twig/extra-bundle` and `symfony/property-access` (used by the tests) to the dev requirements. * [#150](https://github.com/cleverage/ui-process-bundle/issues/150) Require `symfony/monolog-bundle` and `monolog/monolog`, used directly by the bundle (log handlers, `monolog` configuration prepended, `monolog.logger` tags) but only installed through `cleverage/process-bundle`, which may stop requiring MonologBundle (cleverage/process-bundle#269). +## Fixes +* [#169](https://github.com/cleverage/ui-process-bundle/issues/169) Security: the configuration added by the bundle to the `security` extension (`process_user_provider` user provider, provider, login form, logout and API token authenticator of the `main` firewall) overrides the application one, it can now be disabled with the new `security.configure_firewall` option (default `true`, same behaviour as before; `false`: no `security` configuration is added, the application configures the provider and the firewall, e.g. a firewall dedicated to the UI). The `monolog`, `doctrine_migrations` and `security` configurations are skipped when the bundle is not enabled, instead of a `LogicException`. The documentation no longer says that these configurations are prepended (only the Messenger one is). Update documentation, add tests. + v3.2 ------ diff --git a/docs/index.md b/docs/index.md index 7ea8a9d..3b2bc05 100644 --- a/docs/index.md +++ b/docs/index.md @@ -72,7 +72,8 @@ See [routes](reference/03-users_and_security.md#routes). ### Security The bundle automatically configures a user provider and the `main` firewall (login form, logout, API token -authenticator). You only need a password hasher for the bundle `User` entity, which is the Symfony default: +authenticator), overriding the provider, login form and logout of your `main` firewall. You only need a password hasher +for the bundle `User` entity, which is the Symfony default: ```yaml # config/packages/security.yaml @@ -81,7 +82,8 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' ``` -See [users & security](reference/03-users_and_security.md). +To configure the security in your application instead (e.g. on a dedicated firewall), set +`clever_age_ui_process.security.configure_firewall` to `false`. See [users & security](reference/03-users_and_security.md). ### Database diff --git a/docs/reference/01-bundle_configuration.md b/docs/reference/01-bundle_configuration.md index 6978f81..da0b2d2 100644 --- a/docs/reference/01-bundle_configuration.md +++ b/docs/reference/01-bundle_configuration.md @@ -11,6 +11,7 @@ Every key is optional. Default values are shown below. clever_age_ui_process: security: roles: ['ROLE_ADMIN'] + configure_firewall: true logs: store_in_database: true database_level: Info # Debug when kernel.environment is "dev" @@ -38,6 +39,7 @@ Options | Key | Type | Default | Description | |---------|------------|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| | `roles` | `string[]` | `['ROLE_ADMIN']` | Roles that can be assigned to a user in the UI user edit form. `ROLE_USER` is always granted to every user (see [users & security](03-users_and_security.md)). | +| `configure_firewall` | `bool` | `true` | Add the `process_user_provider` user provider and configure the `main` firewall of the application (overriding its provider, login form and logout). `false`: no `security` configuration is added, configure it in your application (see [users & security](03-users_and_security.md#configuring-the-security-in-your-application)). Must be a literal boolean (no parameter nor environment variable). | ### logs @@ -103,11 +105,13 @@ parameters: Configuration added to other bundles ------------------------------------ -The bundle prepends some configuration to other bundles, so that it works out of the box: +The bundle configures other bundles, so that it works out of the box. The Messenger configuration is prepended: your +application configuration overrides it. The other ones are added after your application configuration, so they override +it. Each one is skipped when the bundle is not enabled. | Bundle | Configuration | |----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `monolog` | `pb_ui_file` and `pb_ui_orm` handlers (with their `*_filter` handlers) listening to the `cleverage_process` and `cleverage_process_task` channels. See [process executions & logs](04-process_executions_and_logs.md). | | `doctrine_migrations` | Migrations path `CleverAge\UiProcessBundle\Migrations`. | | `framework` | Messenger `execute_process` transport (`doctrine://default`, no retry) and routing of `ProcessExecuteMessage` to it. See [messenger](08-messenger.md). | -| `security` | `process_user_provider` user provider and the `main` firewall. See [users & security](03-users_and_security.md). | +| `security` | `process_user_provider` user provider and the `main` firewall, unless `security.configure_firewall` is `false`. See [users & security](03-users_and_security.md). | diff --git a/docs/reference/03-users_and_security.md b/docs/reference/03-users_and_security.md index 696e148..c322339 100644 --- a/docs/reference/03-users_and_security.md +++ b/docs/reference/03-users_and_security.md @@ -4,7 +4,7 @@ Users & security Security configuration ---------------------- -The bundle prepends the following configuration to the `security` extension: +The bundle adds the following configuration to the `security` extension: ```yaml security: @@ -26,7 +26,13 @@ security: clear_site_data: '*' ``` -So your application firewall must be named `main`. The only required application configuration is a password +This configuration is added **after** the application one, so it overrides it: the `provider`, `form_login` and `logout` +of your `main` firewall are replaced by the bundle ones (e.g. the `provider: users_in_memory` of the default Symfony +recipe), and the API token authenticator is added to it. So your application firewall must be named `main`, and it is +dedicated to the UI. To keep the control of your security configuration, disable it, see +[configuring the security in your application](#configuring-the-security-in-your-application). + +With this configuration, the only required application configuration is a password hasher for the `User` entity (the Symfony default configuration is enough): ```yaml @@ -38,6 +44,53 @@ security: The UI pages are protected by `#[IsGranted]` attributes, no `access_control` rule is required. +### Configuring the security in your application + +Set `clever_age_ui_process.security.configure_firewall` to `false` (it must be a literal boolean, not a parameter nor an +environment variable): the bundle no longer adds any `security` configuration, neither the user provider nor the +firewall. Configure them in your application, on the firewall and with the provider of your choice. The UI requires: + +- a user provider of the `CleverAge\UiProcessBundle\Entity\User` entity, by `email`; +- a firewall covering the UI routes, with a login form on the `process_login` route and the logout on the + `process_logout` route; +- the `cleverage_ui_process.security.http_process_execution_authenticator` authenticator on the firewall covering the + `http_process_execute` route, for the [HTTP API](06-http_api.md). + +For instance, with the same behaviour as the bundle configuration on a firewall dedicated to the UI: + +```yaml +# config/packages/clever_age_ui_process.yaml +clever_age_ui_process: + security: + configure_firewall: false +``` + +```yaml +# config/packages/security.yaml +security: + providers: + process_user_provider: + entity: + class: CleverAge\UiProcessBundle\Entity\User + property: email + firewalls: + process: + pattern: ^/(process|http/process) + provider: process_user_provider + custom_authenticator: [cleverage_ui_process.security.http_process_execution_authenticator] + form_login: + login_path: process_login + check_path: process_login + logout: + path: process_logout + target: process_login + clear_site_data: '*' + main: + # ... your application firewall +``` + +The `pattern` depends on the prefix of the [routes](#routes) (here the default ones). + Roles ----- diff --git a/src/DependencyInjection/CleverAgeUiProcessExtension.php b/src/DependencyInjection/CleverAgeUiProcessExtension.php index a790d6a..402e2fc 100644 --- a/src/DependencyInjection/CleverAgeUiProcessExtension.php +++ b/src/DependencyInjection/CleverAgeUiProcessExtension.php @@ -18,6 +18,7 @@ use CleverAge\UiProcessBundle\Controller\Admin\UserCrudController; use CleverAge\UiProcessBundle\Entity\User; use CleverAge\UiProcessBundle\Message\ProcessExecuteMessage; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; @@ -37,7 +38,7 @@ public function load(array $configs, ContainerBuilder $container): void $env = $container->getParameter('kernel.environment'); $configuration = new Configuration($env); /** @var array{ - * security: array{roles: list}, + * security: array{roles: list, configure_firewall: bool}, * logs: array{store_in_database: bool, database_level: string, file_level: string, report_increment_level: string, database_max_days: ?int, file_max_days: ?int}, * design: array{logo_path: string}, * notification: array{enabled: bool, ...}, @@ -83,9 +84,68 @@ public function load(array $configs, ContainerBuilder $container): void } /** - * Update default configuration for migrations, twig form theme, assets ... + * Configure the other bundles: Monolog handlers, migrations path, Messenger transport and security. + * + * The Messenger configuration is prepended (the application configuration wins). The other ones are appended, so + * they override the application configuration: in particular the security one replaces the provider, login form + * and logout of the application "main" firewall, unless the "security.configure_firewall" option is false. */ public function prepend(ContainerBuilder $container): void + { + if ($container->hasExtension('monolog')) { + $this->configureMonolog($container); + } + if ($container->hasExtension('doctrine_migrations')) { + $container->loadFromExtension( + 'doctrine_migrations', + [ + 'migrations_paths' => ['CleverAge\UiProcessBundle\Migrations' => __DIR__.'/../Migrations'], + ] + ); + } + $container->prependExtensionConfig( + 'framework', + [ + 'messenger' => [ + 'transport' => [ + [ + 'name' => 'execute_process', + 'dsn' => 'doctrine://default', + 'retry_strategy' => ['max_retries' => 0], + ], + ], + 'routing' => [ + ProcessExecuteMessage::class => 'execute_process', + ], + ], + ] + ); + if ($container->hasExtension('security') && $this->isFirewallConfigured($container)) { + $this->configureSecurity($container); + } + } + + /** + * The "security.configure_firewall" option, read before the bundle configuration is processed: the last value + * defined wins, as when it is processed. + */ + private function isFirewallConfigured(ContainerBuilder $container): bool + { + $configureFirewall = true; + foreach ($container->getExtensionConfig($this->getAlias()) as $config) { + if (!\is_array($config['security'] ?? null) || !\array_key_exists('configure_firewall', $config['security'])) { + continue; + } + $configureFirewall = $config['security']['configure_firewall']; + if (!\is_bool($configureFirewall)) { + throw new InvalidConfigurationException(\sprintf('The "%s.security.configure_firewall" option must be a boolean, "%s" given: parameters and environment variables are not supported, as it is read before they are resolved.', $this->getAlias(), get_debug_type($configureFirewall))); + } + } + + return $configureFirewall; + } + + private function configureMonolog(ContainerBuilder $container): void { $container->loadFromExtension( 'monolog', @@ -112,29 +172,10 @@ public function prepend(ContainerBuilder $container): void ], ] ); - $container->loadFromExtension( - 'doctrine_migrations', - [ - 'migrations_paths' => ['CleverAge\UiProcessBundle\Migrations' => __DIR__.'/../Migrations'], - ] - ); - $container->prependExtensionConfig( - 'framework', - [ - 'messenger' => [ - 'transport' => [ - [ - 'name' => 'execute_process', - 'dsn' => 'doctrine://default', - 'retry_strategy' => ['max_retries' => 0], - ], - ], - 'routing' => [ - ProcessExecuteMessage::class => 'execute_process', - ], - ], - ] - ); + } + + private function configureSecurity(ContainerBuilder $container): void + { $container->loadFromExtension( 'security', [ diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 41a2f76..0df0ebe 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -46,7 +46,8 @@ protected function addSecuritySection(ArrayNodeDefinition $node): void ->children() ->arrayNode('security')->addDefaultsIfNotSet() ->children() - ->arrayNode('roles')->defaultValue(['ROLE_ADMIN'])->scalarPrototype()->end() // Roles displayed inside user edit form + ->arrayNode('roles')->defaultValue(['ROLE_ADMIN'])->scalarPrototype()->end()->end() // Roles displayed inside user edit form + ->booleanNode('configure_firewall')->defaultTrue()->end() // add the process_user_provider user provider and configure the main firewall (see CleverAgeUiProcessExtension::prepend()), false to configure the security of the UI in the application ->end() ->end() ->end() diff --git a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php index eb0d05c..bab0091 100644 --- a/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeUiProcessExtensionTest.php @@ -24,6 +24,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; @@ -228,6 +229,53 @@ public function testPrepend(): void ); } + public function testPrependWithoutFirewallConfiguration(): void + { + $container = $this->createContainer('prod'); + foreach (['monolog', 'doctrine_migrations', 'security'] as $alias) { + $container->registerExtension($this->createExtension($alias)); + } + $container->loadFromExtension('security', ['firewalls' => ['main' => ['provider' => 'app_users']]]); + $container->registerExtension(new CleverAgeUiProcessExtension()); + $container->loadFromExtension('clever_age_ui_process', ['security' => ['configure_firewall' => true]]); + $container->loadFromExtension('clever_age_ui_process', ['security' => ['configure_firewall' => false]]); + + (new CleverAgeUiProcessExtension())->prepend($container); + + // Only the application configuration: the last "configure_firewall" value wins + self::assertSame( + [['firewalls' => ['main' => ['provider' => 'app_users']]]], + $container->getExtensionConfig('security') + ); + self::assertCount(1, $container->getExtensionConfig('monolog')); + self::assertCount(1, $container->getExtensionConfig('doctrine_migrations')); + } + + public function testPrependWithAParameterAsFirewallConfiguration(): void + { + $container = $this->createContainer('prod'); + $container->registerExtension($this->createExtension('security')); + $container->registerExtension(new CleverAgeUiProcessExtension()); + $container->loadFromExtension('clever_age_ui_process', ['security' => ['configure_firewall' => '%env(bool:UI_FIREWALL)%']]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The "clever_age_ui_process.security.configure_firewall" option must be a boolean, "string" given'); + + (new CleverAgeUiProcessExtension())->prepend($container); + } + + public function testPrependWithoutTheConfiguredBundles(): void + { + $container = $this->createContainer('prod'); + + (new CleverAgeUiProcessExtension())->prepend($container); + + self::assertSame([], $container->getExtensionConfig('monolog')); + self::assertSame([], $container->getExtensionConfig('doctrine_migrations')); + self::assertSame([], $container->getExtensionConfig('security')); + self::assertCount(1, $container->getExtensionConfig('framework')); + } + public function testPrependedFrameworkConfigurationComesFirst(): void { $container = $this->createContainer('prod'); diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 52b8831..b04ddc2 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -43,6 +43,7 @@ public function testDefaultConfiguration(string $env, string $expectedLevel): vo [ 'security' => [ 'roles' => ['ROLE_ADMIN'], + 'configure_firewall' => true, ], 'logs' => [ 'store_in_database' => true, @@ -84,6 +85,7 @@ public function testCustomConfiguration(): void [ 'security' => [ 'roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR'], + 'configure_firewall' => false, ], 'logs' => [ 'store_in_database' => false, @@ -111,7 +113,7 @@ public function testCustomConfiguration(): void ], ], $this->process('dev', [ - 'security' => ['roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR']], + 'security' => ['roles' => ['ROLE_ADMIN', 'ROLE_OPERATOR'], 'configure_firewall' => false], 'logs' => [ 'store_in_database' => false, 'database_level' => 'Error', @@ -147,7 +149,7 @@ public function testPartialConfigurationKeepsOtherDefaults(): void ], $config['logs'] ); - self::assertSame(['roles' => ['ROLE_ADMIN']], $config['security']); + self::assertSame(['roles' => ['ROLE_ADMIN'], 'configure_firewall' => true], $config['security']); } public function testLastConfigurationWins(): void @@ -184,6 +186,7 @@ public static function provideInvalidConfigurations(): iterable ]; yield 'roles not an array' => [['security' => ['roles' => 'ROLE_ADMIN']], 'clever_age_ui_process.security.roles']; yield 'role not a scalar' => [['security' => ['roles' => [['ROLE_ADMIN']]]], 'clever_age_ui_process.security.roles']; + yield 'configure_firewall not a boolean' => [['security' => ['configure_firewall' => 'no']], 'clever_age_ui_process.security.configure_firewall']; yield 'logo_path not a scalar' => [['design' => ['logo_path' => ['a.png']]], 'clever_age_ui_process.design.logo_path']; yield 'unknown notification status' => [ ['notification' => ['statuses' => ['started']]],