diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a17ac9b03e6df97c4d5268da505a79d3ce97afc2..faa3f5e10ba82c2ec1c4a9dd2a2e78583b9ddd8b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -39,46 +39,46 @@ variables: # Opt in variables: Broaden test coverage. ################ OPT_IN_TEST_CURRENT: 1 - OPT_IN_TEST_PREVIOUS_MAJOR: 0 - OPT_IN_TEST_PREVIOUS_MINOR: 0 - OPT_IN_TEST_NEXT_MINOR: 0 - OPT_IN_TEST_NEXT_MAJOR: 0 - OPT_IN_TEST_MAX_PHP: 0 + OPT_IN_TEST_PREVIOUS_MAJOR: 1 + OPT_IN_TEST_PREVIOUS_MINOR: 1 + OPT_IN_TEST_NEXT_MINOR: 1 + OPT_IN_TEST_NEXT_MAJOR: 1 + OPT_IN_TEST_MAX_PHP: 1 # Speed up the execution of the Tests. - # _PHPUNIT_CONCURRENT: 1 + _PHPUNIT_CONCURRENT: 1 # # Convenient for debugging: Enable in issue forks for testing. # _SHOW_ENVIRONMENT_VARIABLES: 1 # Strict PHPSTAN validation: Enforce the *maximum* rule level: 9. # _PHPSTAN_LEVEL: 9 -# # -# # CSPELL overrides and configuration. -# # -# cspell: -# # Require spellcheck to pass. -# allow_failure: false +# +# CSPELL overrides and configuration. +# +cspell: + # Require spellcheck to pass. + allow_failure: false -# # -# # ESLINT overrides and configuration. -# # -# eslint: -# # Require eslint to pass. -# allow_failure: false +# +# ESLINT overrides and configuration. +# +eslint: + # Require eslint to pass. + allow_failure: false -# # -# # PHPCS overrides and configuration. -# # -# phpcs: -# # Require phpcs to pass. -# allow_failure: false +# +# PHPCS overrides and configuration. +# +phpcs: + # Require phpcs to pass. + allow_failure: false -# # -# # PHPSTAN overrides and configuration. -# # -# phpstan: -# # Require phpstan to pass. -# allow_failure: false +# +# PHPSTAN overrides and configuration. +# +phpstan: + # Require phpstan to pass. + allow_failure: false # # # # PHPUNIT overrides and configuration. diff --git a/README.md b/README.md index ad0263f9117e8f6eaf7a6eb958b20efadac13f26..fa641e756fcc2ec00865b522e3fe081fd91a3a00 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This very lightweight module allows additional permissions to be created and managed through an administration form. It uses the menu access system to allow -or dissalow access. +or disallow access. On an administration page, a user is able to create a permission with name and paths. These permissions can then be assigned to roles on the permissions page. @@ -40,6 +40,7 @@ information, see See add/edit/delete permissions in /admin/people/custom-permissions/list. ## Maintainers +<!--- cspell:disable --> Alphabetical by username: @@ -59,3 +60,5 @@ Alphabetical by organization name: - [Agaric](https://www.drupal.org/agaric) - [Salsa Digital](https://www.drupal.org/salsa-digital) - [UTIKS](https://www.drupal.org/utiks) + +<!--- cspell:enable --> diff --git a/config_perms.install b/config_perms.install index db2d4cf9eb26a34c060440914748c3201b3370cc..79aa05ac724f85341092f98cc65d76ef0ad2358f 100644 --- a/config_perms.install +++ b/config_perms.install @@ -1,7 +1,18 @@ <?php /** - * Updates the paths to its entities. + * @file + * Contains update hooks for the Config Perms module. + */ + +/** + * Converts old path-based permission entries to route-based entries. + * + * Parses the 'path' value of each custom permission config entity and + * attempts to resolve it to a route name. If successful, replaces the + * 'path' key with a 'route' key in the configuration. + * + * This ensures all custom permissions are stored in route-based format. */ function config_perms_update_8201() { $config_factory = \Drupal::configFactory(); @@ -31,7 +42,11 @@ function config_perms_update_8201() { } /** - * Adds the "administer config permissions" permission. + * Grants new "administer config permissions" to roles with site config access. + * + * Ensures backward compatibility by granting the new, more specific permission + * to any role that previously had the broader + * "administer site configuration" permission. */ function config_perms_update_8202() { $roles = \Drupal::entityTypeManager()->getStorage('user_role')->loadMultiple(); @@ -40,6 +55,7 @@ function config_perms_update_8202() { // There is now a dedicated permission to configure the module, to keep the // things working let's provide this new permission to the users that were // already able to configure the module before. + // Assign new permission to preserve current access for administrators. if ($role->hasPermission('administer site configuration')) { $role->grantPermission('administer config permissions'); $role->save(); diff --git a/src/Form/ConfigPermListForm.php b/src/Form/ConfigPermListForm.php index 982c6f30cc254b770f8d80227ab42c38d0be0f40..eca8534bfe4a391be03d8c890d4b3f2cd481cb74 100755 --- a/src/Form/ConfigPermListForm.php +++ b/src/Form/ConfigPermListForm.php @@ -10,28 +10,31 @@ use Drupal\Core\Routing\RouteProviderInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** - * Class ConfigPermListForm. + * Provides the admin configuration form for managing custom permissions. + * + * Allows site administrators to enable, disable, edit, add, and delete + * route-based permissions for configuration access. * * @package Drupal\config_perms\Form */ class ConfigPermListForm extends FormBase { /** - * Router Provider service. + * The router provider service. * - * @var \Drupal\Core\Routing\RouteProvider + * @var \Drupal\Core\Routing\RouteProviderInterface */ protected $routerProvider; /** - * Router Builder service. + * The router builder service. * - * @var \Drupal\Core\Routing\RouteBuilder + * @var \Drupal\Core\Routing\RouteBuilderInterface */ protected $routerBuilder; /** - * Class constructor. + * Constructs a new ConfigPermListForm object. * * @param \Drupal\Core\Routing\RouteProviderInterface $router_provider * The router provider service. @@ -47,9 +50,7 @@ class ConfigPermListForm extends FormBase { * {@inheritdoc} */ public static function create(ContainerInterface $container) { - // Instantiates this form class. return new static( - // Load the service required to construct this class. $container->get('router.route_provider'), $container->get('router.builder') ); @@ -63,25 +64,38 @@ class ConfigPermListForm extends FormBase { } /** - * {@inheritdoc} + * Builds the permissions administration form. + * + * Displays a table of custom permissions with options to modify or add new + * ones. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + * + * @return array + * The complete form render array. */ public function buildForm(array $form, FormStateInterface $form_state) { - + // Form initialization and description fieldset. $form['perms'] = [ '#type' => 'fieldset', '#title' => $this->t('Custom Permissions'), '#description' => '<p>' . $this->t("Please note that the order in which permissions are granted are as follows:") . '</p>' . "<ul> - <li>" . $this->t("Custom permissions only support routes") . "</li>\n - <li>" . $this->t("User 1 still maintains full control") . "</li>\n - <li>" . $this->t("Remove the permission 'Administer site configuration' from roles you wish to give access to only specified custom site configuration permissions") . "</li>\n - </ul>", - '#collapsible' => 1, - '#collapsed' => 0, + <li>" . $this->t("Custom permissions only support routes") . "</li>\n + <li>" . $this->t("User 1 still maintains full control") . "</li>\n + <li>" . $this->t("Remove the permission 'Administer site configuration' from roles you wish to give access to only specified custom site configuration permissions") . "</li>\n + </ul>", + '#collapsible' => TRUE, + '#collapsed' => FALSE, ]; + // Load all existing custom permissions. $perms = CustomPermsEntity::loadMultiple(); + // Table header. $header = [ $this->t('Enabled'), $this->t('Name'), @@ -90,6 +104,7 @@ class ConfigPermListForm extends FormBase { '', ]; + // Setup the editable table. $form['perms']['local'] = [ '#type' => 'table', '#header' => $header, @@ -97,63 +112,46 @@ class ConfigPermListForm extends FormBase { '#suffix' => '</div>', ]; + // Populate table rows for each permission. /** @var \Drupal\config_perms\Entity\CustomPermsEntity $perm */ foreach ($perms as $key => $perm) { - $form['perms']['local'][$key] = ['#tree' => TRUE]; $form['perms']['local'][$key]['status'] = [ '#type' => 'checkbox', '#default_value' => $perm->status(), ]; - $form['perms']['local'][$key]['name'] = [ '#type' => 'textfield', '#default_value' => $perm->label(), '#size' => 30, ]; - $form['perms']['local'][$key]['route'] = [ '#type' => 'textarea', '#default_value' => $perm->getRoute(), '#size' => 50, '#rows' => 1, ]; - - // Delete link. - $delete_link = $perm->toLink($this->t('Delete'), 'delete-form'); - $form['perms']['local'][$key]['delete'] = $delete_link->toRenderable(); + $form['perms']['local'][$key]['delete'] = $perm->toLink($this->t('Delete'), 'delete-form')->toRenderable(); $form['perms']['local'][$key]['id'] = [ '#type' => 'hidden', '#default_value' => $perm->id(), ]; } - $num_new = $form_state->getValue('num_new'); - if (empty($num_new)) { - $form_state->setValue('num_new', '0'); - } - - for ($i = 0; $i < $form_state->getValue('num_new'); $i++) { - $form['perms']['local']['new']['status'] = [ - '#type' => 'checkbox', - '#default_value' => '', - ]; - $form['perms']['local']['new']['name'] = [ - '#type' => 'textfield', - '#default_value' => '', - '#size' => 30, - ]; + // Initialize new row handling. + $num_new = $form_state->getValue('num_new') ?? 0; + $form_state->setValue('num_new', $num_new); - $form['perms']['local']['new']['route'] = [ - '#type' => 'textarea', - '#default_value' => '', - '#rows' => 2, - '#size' => 50, + for ($i = 0; $i < $num_new; $i++) { + $form['perms']['local']['new'] = [ + 'status' => ['#type' => 'checkbox'], + 'name' => ['#type' => 'textfield', '#size' => 30], + 'route' => ['#type' => 'textarea', '#rows' => 2, '#size' => 50], ]; - } + // Add permission button. $form['perms']['add']['status'] = [ '#name' => 'status', '#id' => 'edit-local-status', @@ -166,6 +164,7 @@ class ConfigPermListForm extends FormBase { ], ]; + // Save button. $form['submit'] = [ '#type' => 'submit', '#value' => $this->t('Save'), @@ -175,14 +174,29 @@ class ConfigPermListForm extends FormBase { } /** - * Callback for add button. + * AJAX callback to refresh the table after adding a new permission. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + * + * @return array + * The rendered portion of the form to update. */ public function configPermsAdminFormAddCallback($form, $form_state) { return $form['perms']['local']; } /** - * Submit for add button. + * Submit handler for the "Add permission" button. + * + * Increments the number of new permission rows. + * + * @param array $form + * The form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. */ public function configPermsAdminFormAddSubmit($form, &$form_state) { $form_state->setValue('num_new', $form_state->getValue('num_new') + 1); @@ -197,27 +211,24 @@ class ConfigPermListForm extends FormBase { $perms = CustomPermsEntity::loadMultiple(); foreach ($values['local'] as $key => $perm) { - - if (empty($perm['name']) && empty($perm['route']) && $key != 'new') { - $entity = CustomPermsEntity::load($perm['id']); - $entity->delete(); + if (empty($perm['name']) && empty($perm['route']) && $key !== 'new') { + CustomPermsEntity::load($perm['id'])->delete(); } else { if (empty($perm['name'])) { - $form_state->setErrorByName("local][" . $key . "", $this->t("The name cannot be empty.")); + $form_state->setErrorByName("local][$key", $this->t("The name cannot be empty.")); } - if (empty($perm['route'])) { - $form_state->setErrorByName("local][" . $key . "", $this->t("The route cannot be empty.")); + $form_state->setErrorByName("local][$key", $this->t("The route cannot be empty.")); } if (array_key_exists($this->configPermsGenerateMachineName($perm['name']), $perms) && !isset($perm['id'])) { - $form_state->setErrorByName("local][" . $key . "", $this->t("A permission with that name already exists.")); + $form_state->setErrorByName("local][$key", $this->t("A permission with that name already exists.")); } if (!empty($perm['route'])) { $routes = config_perms_parse_path($perm['route']); foreach ($routes as $route) { if (count($this->routerProvider->getRoutesByNames([$route])) < 1) { - $form_state->setErrorByName("local][" . $key . "", $this->t("The route @route is invalid.", ['@route' => $perm['route']])); + $form_state->setErrorByName("local][$key", $this->t("The route @route is invalid.", ['@route' => $perm['route']])); } } } @@ -233,15 +244,14 @@ class ConfigPermListForm extends FormBase { $perms = CustomPermsEntity::loadMultiple(); foreach ($values['local'] as $key => $data) { - // If new permission. - if ($key == 'new') { + if ($key === 'new') { $entity = CustomPermsEntity::create(); $entity->set('id', $this->configPermsGenerateMachineName($data['name'])); } else { - // Update || Insert. $entity = $perms[$data['id']]; } + $entity->set('label', $data['name']); $entity->set('route', $data['route']); $entity->set('status', $data['status']); @@ -253,7 +263,16 @@ class ConfigPermListForm extends FormBase { } /** - * Generate a machine name given a string. + * Generates a machine name from a string. + * + * Converts a string to a lowercase, underscore-separated format suitable + * for use as an ID. + * + * @param string $string + * The human-readable string. + * + * @return string + * The machine-friendly identifier. */ public function configPermsGenerateMachineName($string) { return strtolower(preg_replace('/[^a-zA-Z0-9_]+/', '_', $string)); diff --git a/src/Form/CustomPermsEntityDeleteForm.php b/src/Form/CustomPermsEntityDeleteForm.php index e2bef0be2b9baa711753499470571fb30a61f1ea..1fbd34575a79ddc51f22eb6d9a6a6ee5fd5c1ee9 100755 --- a/src/Form/CustomPermsEntityDeleteForm.php +++ b/src/Form/CustomPermsEntityDeleteForm.php @@ -4,6 +4,7 @@ namespace Drupal\config_perms\Form; use Drupal\Core\Entity\EntityConfirmFormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RouteBuilderInterface; use Drupal\Core\Url; /** @@ -11,6 +12,32 @@ use Drupal\Core\Url; */ class CustomPermsEntityDeleteForm extends EntityConfirmFormBase { + /** + * The router builder. + * + * @var \Drupal\Core\Routing\RouteBuilderInterface + */ + protected $routerBuilder; + + /** + * Constructs a new CustomPermsEntityDeleteForm. + * + * @param \Drupal\Core\Routing\RouteBuilderInterface $router_builder + * The router builder service. + */ + public function __construct(RouteBuilderInterface $router_builder) { + $this->routerBuilder = $router_builder; + } + + /** + * {@inheritdoc} + */ + public static function create($container) { + return new static( + $container->get('router.builder') + ); + } + /** * {@inheritdoc} */ @@ -39,15 +66,13 @@ class CustomPermsEntityDeleteForm extends EntityConfirmFormBase { $this->entity->delete(); $this->messenger()->addMessage( - $this->t('content @type: deleted @label.', - [ - '@type' => $this->entity->bundle(), - '@label' => $this->entity->label(), - ] - ) + $this->t('content @type: deleted @label.', [ + '@type' => $this->entity->bundle(), + '@label' => $this->entity->label(), + ]) ); - \Drupal::service('router.builder')->rebuild(); + $this->routerBuilder->rebuild(); $form_state->setRedirectUrl($this->getCancelUrl()); } diff --git a/src/Routing/RouteSubscriber.php b/src/Routing/RouteSubscriber.php index 8e15245b82fd225a8ff31669e8c08db33182fb1b..11dd3bcaa027d2e3111dea88760796ff68c50834 100755 --- a/src/Routing/RouteSubscriber.php +++ b/src/Routing/RouteSubscriber.php @@ -7,15 +7,26 @@ use Symfony\Component\Routing\RouteCollection; use Drupal\config_perms\Entity\CustomPermsEntity; /** - * Class RouteSubscriber. + * Subscribes to route alterations for the config_perms module. + * + * Overrides route access requirements based on enabled custom permission + * entities. If a route is matched by a custom permission and the permission + * is active, the route's access control is replaced by the custom + * config_perms access checker. * * @package Drupal\config_perms\Routing - * Listens to the dynamic route events. */ class RouteSubscriber extends RouteSubscriberBase { /** - * {@inheritdoc} + * Alters existing routes to apply config_perms access control. + * + * This method loads all enabled CustomPermsEntity instances and applies + * their route definitions. If a matching route exists, its access + * requirements are overridden to use the _config_perms_access_check only. + * + * @param \Symfony\Component\Routing\RouteCollection $collection + * The collection of defined routes in the system. */ protected function alterRoutes(RouteCollection $collection) { $custom_perms = CustomPermsEntity::loadMultiple(); @@ -25,8 +36,7 @@ class RouteSubscriber extends RouteSubscriberBase { $routes = config_perms_parse_path($custom_perm->getRoute()); foreach ($routes as $route) { if ($route = $collection->get($route)) { - // This overrides the route requirements removing all the other - // access checkers and leaving only our access checker. + // Override route requirements to use only our custom access check. $route->setRequirements(['_config_perms_access_check' => 'TRUE']); } } diff --git a/tests/src/Functional/ConfigPermsTest.php b/tests/src/Functional/ConfigPermsTest.php index 352e7986290b73e3a59f27aa49b130e15a2b88c1..8b8218b73b147b0e29b65edf8fde64e8187e6f8f 100644 --- a/tests/src/Functional/ConfigPermsTest.php +++ b/tests/src/Functional/ConfigPermsTest.php @@ -5,7 +5,11 @@ namespace Drupal\Tests\config_perms\Functional; use Drupal\Tests\BrowserTestBase; /** - * Tests that the perms are working. + * Functional tests for the config_perms module. + * + * Verifies that custom configuration permissions behave as expected, + * including default behavior, correct access restriction, and permission + * overrides. * * @group config_perms */ @@ -21,6 +25,12 @@ class ConfigPermsTest extends BrowserTestBase { */ protected static $modules = ['config_perms']; + /** + * Tests access to the custom permissions admin page based on permissions. + * + * Verifies that a user with 'administer config permissions' can access + * the page, while a user without it cannot. + */ public function testAdministerConfigPermsPermission() { $user_with_permission = $this->drupalCreateUser(['administer config permissions']); $user_without_permission = $this->drupalCreateUser(); @@ -39,7 +49,10 @@ class ConfigPermsTest extends BrowserTestBase { } /** - * Tests that the permissions are applied correctly. + * Tests that permissions are enforced for accessing account settings. + * + * Confirms that a user with the 'Administer account settings' permission + * can view the settings page, while one without cannot. */ public function testPermissions() { $user_with_permission = $this->drupalCreateUser(['Administer account settings']); @@ -57,11 +70,13 @@ class ConfigPermsTest extends BrowserTestBase { $this->drupalGet('admin/config/people/accounts'); $this->assertSession()->statusCodeEquals(403); $this->drupalLogout(); - } /** - * Tests that the permissions are overridden correctly. + * Tests that core permissions can be overridden by config_perms settings. + * + * Ensures that a user with the custom 'Administer account settings' + * permission has access, while a user with only the core permission does not. */ public function testOverridePermissions() { $user_custom_permission = $this->drupalCreateUser(['Administer account settings']);