You are here

class KeycloakRoleMatcher in Keycloak OpenID Connect 8

Role matcher service.

Provides methods for matching Keycloak user group rules to Drupal user roles.

Notice: Method names, parameters and comments will use the term 'group' to refer to Keycloak user groups and the term 'role' when referring to Drupal user roles.

Hierarchy

Expanded class hierarchy of KeycloakRoleMatcher

1 file declares its use of KeycloakRoleMatcher
Keycloak.php in src/Plugin/OpenIDConnectClient/Keycloak.php
1 string reference to 'KeycloakRoleMatcher'
keycloak.services.yml in ./keycloak.services.yml
keycloak.services.yml
1 service uses KeycloakRoleMatcher
keycloak.role_matcher in ./keycloak.services.yml
Drupal\keycloak\Service\KeycloakRoleMatcher

File

src/Service/KeycloakRoleMatcher.php, line 23

Namespace

Drupal\keycloak\Service
View source
class KeycloakRoleMatcher {
  use StringTranslationTrait;

  /**
   * A configuration object containing Keycloak client settings.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $config;

  /**
   * The logger factory.
   *
   * @var Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  protected $loggerFactory;

  /**
   * Constructs a RoleManager service object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
   *   The string translation service.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
   *   The logger factory.
   */
  public function __construct(ConfigFactoryInterface $config_factory, TranslationInterface $string_translation, LoggerChannelFactoryInterface $logger_factory) {
    $this->config = $config_factory
      ->get('openid_connect.settings.keycloak');
    $this->stringTranslation = $string_translation;
    $this->loggerFactory = $logger_factory;
  }

  /**
   * Whether Keycloak groups to Drupal roles synchronization is enabled.
   *
   * @return bool
   *   TRUE, if the synchronization is enabled, FALSE otherwise.
   */
  public function isEnabled() {
    return $this->config
      ->get('enabled') && $this->config
      ->get('settings.keycloak_groups.enabled');
  }

  /**
   * Whether there are defined Keycloak role rules.
   *
   * @return bool
   *   TRUE, if rules were defined, FALSE otherwise.
   */
  public function hasRoleRules() {
    return !empty($this->config
      ->get('settings.keycloak_groups.rules'));
  }

  /**
   * Return the Keycloak role rules.
   *
   * @param bool $enabled_only
   *   (Optional) Whether to return enabled rules only.
   *   Defaults to FALSE.
   *
   * @return array
   *   Array of role rules. Each rule is an associative array with
   *   the following keys:
   *   - id:               (Internal) ID of the rule.
   *   - weight:           The weight of the rule.
   *   - role:             Drupal role ID this rule applies to.
   *   - action:           Action to take, if the rule matches.
   *   - operation:        Rule matching operation.
   *   - pattern:          Value to evaluate.
   *   - case_sensitive:   Whether the pattern must match case-sensitive.
   *   - enabled:          Whether the rule is enabled.
   */
  public function getRoleRules($enabled_only = FALSE) {
    $rules = $this->config
      ->get('settings.keycloak_groups.rules');

    // Make sure we return an array.
    if (empty($rules)) {
      $rules = [];
    }
    elseif ($enabled_only) {
      $rules = array_filter($rules, function ($rule) {
        return $rule['enabled'];
      });
    }
    return $rules;
  }

  /**
   * Retrieve Keycloak groups from user information.
   *
   * @param string $attribute
   *   Keycloak groups claim identifier.
   * @param array $userinfo
   *   User info array as returned by
   *   \Drupal\keycloak\Plugin\OpenIDConnectClient\Keycloak::retrieveUserInfo().
   *
   * @return array
   *   Extracted user groups.
   */
  public function getGroups($attribute, array $userinfo) {

    // Whether the user information is empty.
    if (empty($userinfo)) {

      // No group attribute. Return empty array.
      return [];
    }

    // Walk the attribute path to retrieve the user groups.
    $attribute_path = explode('.', $attribute);
    while (!empty($attribute_path)) {
      $segment = array_shift($attribute_path);
      if (isset($userinfo[$segment])) {
        $userinfo = $userinfo[$segment];
      }
      else {
        $userinfo = [];
        break;
      }
    }
    return $userinfo;
  }

  /**
   * Return the user groups claim name.
   *
   * @return string
   *   The configured (fully qualified) user groups claim name.
   */
  public function getUserGroupsClaimName() {
    return $this->config
      ->get('settings.keycloak_groups.claim_name');
  }

  /**
   * Whether splitting group paths is enabled.
   *
   * @return bool
   *   TRUE, if splitting group paths is enabled, FALSE otherwise.
   */
  public function isSplitGroupsEnabled() {
    return $this->config
      ->get('settings.keycloak_groups.split_groups');
  }

  /**
   * Return the maximum allowed nesting level for group path splitting.
   *
   * @return int
   *   The maximum allowed nesting limit of split group paths.
   */
  public function getSplitGroupsLimit() {
    return $this->config
      ->get('settings.keycloak_groups.split_groups_limit');
  }

  /**
   * Applies user role rules to the given user account.
   *
   * @param \Drupal\user\UserInterface $account
   *   User account.
   * @param array $userinfo
   *   Associative array with user information.
   * @param bool $save_changes
   *   (Optional) Whether to save the account after the rules have
   *   been applied.
   *   Defaults to FALSE.
   *
   * @return bool
   *   TRUE, if the rules were applied, FALSE otherwise.
   */
  public function applyRoleRules(UserInterface &$account, array $userinfo, $save_changes = FALSE) {
    $rules = $this
      ->getRoleRules(TRUE);
    if (empty($rules)) {
      return TRUE;
    }

    // Extract groups from userinfo.
    $groups = $this
      ->getGroups($this
      ->getUserGroupsClaimName(), $userinfo);

    // Split group paths, if enabled.
    if (!empty($groups) && $this
      ->isSplitGroupsEnabled()) {
      $groups = $this
        ->getSplitGroups($groups, $this
        ->getSplitGroupsLimit());
    }
    $roles = $this
      ->getRoleOptions();
    $operations = $this
      ->getEvalOperationOptions();

    // Walk the rules and apply them.
    foreach ($rules as $rule) {
      $result = $this
        ->evalRoleRule($groups, $rule);
      if ($result) {
        switch ($rule['action']) {
          case 'add':
            if ($this
              ->isDebugMode()) {
              $this
                ->getLogger()
                ->debug('Add user role @role to @user, as evaluation "@operation @pattern" matches @groups.', [
                '@role' => $roles[$rule['role']],
                '@user' => $account
                  ->getAccountName(),
                '@operation' => $operations[$rule['operation']],
                '@pattern' => $rule['pattern'],
                '@groups' => print_r($groups, TRUE),
              ]);
            }
            $account
              ->addRole($rule['role']);
            break;
          case 'remove':
            if ($this
              ->isDebugMode()) {
              $this
                ->getLogger()
                ->debug('Remove user role @role from @user, as evaluation "@operation @pattern" matches @groups.', [
                '@role' => $roles[$rule['role']],
                '@user' => $account
                  ->getAccountName(),
                '@operation' => $operations[$rule['operation']],
                '@pattern' => $rule['pattern'],
                '@groups' => print_r($groups, TRUE),
              ]);
            }
            $account
              ->removeRole($rule['role']);
            break;
          default:
            break;
        }
      }
    }

    // Whether to save the user account.
    if ($save_changes) {
      $account
        ->save();
    }
    return TRUE;
  }

  /**
   * Return an options array of available role evaluation operations.
   *
   * @return array
   *   Array of available role evaluation operations that can be used
   *   as select / radio / checkbox options.
   */
  public function getEvalOperationOptions() {
    $operations = [
      'equal' => $this
        ->t('exact match'),
      'not_equal' => $this
        ->t('no match'),
      'starts_with' => $this
        ->t('starts with'),
      'starts_not_with' => $this
        ->t('starts not with'),
      'ends_with' => $this
        ->t('ends with'),
      'ends_not_with' => $this
        ->t('ends not with'),
      'contains' => $this
        ->t('contains'),
      'contains_not' => $this
        ->t('contains not'),
      'empty' => $this
        ->t('no groups given'),
      'not_empty' => $this
        ->t('any group given'),
      'regex' => $this
        ->t('regex match'),
      'not_regex' => $this
        ->t('no regex match'),
    ];
    return $operations;
  }

  /**
   * Return all available user roles as options array.
   *
   * @param bool $exclude_locked
   *   (Optional) Whether to exclude the system locked roles 'Anonymous' and
   *   'Authenticated'.
   *   Defaults to TRUE.
   *
   * @return array
   *   Array of user roles that can be used as select / radio / checkbox
   *   options.
   */
  public function getRoleOptions($exclude_locked = TRUE) {
    $role_options = [];
    $roles = Role::loadMultiple();
    foreach ($roles as $role) {
      $role_id = $role
        ->id();
      if ($exclude_locked && ($role_id == RoleInterface::ANONYMOUS_ID || $role_id == RoleInterface::AUTHENTICATED_ID)) {
        continue;
      }
      $role_options[$role_id] = $role
        ->label();
    }
    return $role_options;
  }

  /**
   * Return a regex evaluation pattern for user group role rules.
   *
   * @param string $pattern
   *   User entered search pattern.
   * @param string $operation
   *   Evaluation operation to conduct.
   * @param bool $case_sensitive
   *   Whether the resulting pattern shall be case-sensitive.
   *
   * @return string
   *   PCRE pattern for role rule evaluation.
   */
  protected function getEvalPattern($pattern, $operation = 'equal', $case_sensitive = TRUE) {

    // Quote regular expression characters in regular pattern string.
    if ($operation != 'regex' && $operation != 'not_regex') {
      $pattern = preg_quote($pattern, '/');
    }

    // Construct a PCRE pattern for the given operation.
    switch ($operation) {
      case 'starts_with':
      case 'starts_not_with':
        $pattern = '/^' . $pattern . '/';
        break;
      case 'ends_with':
      case 'ends_not_with':
        $pattern = '/' . $pattern . '$/';
        break;
      case 'contains':
      case 'contains_not':
      case 'regex':
      case 'not_regex':
        $pattern = '/' . $pattern . '/';
        break;
      case 'not_equal':
      default:
        $pattern = '/^' . $pattern . '$/';
        break;
    }

    // Whether the pattern shall not be case sensitive.
    if (!$case_sensitive) {
      $pattern = $pattern . 'i';
    }
    return $pattern;
  }

  /**
   * Return split user groups.
   *
   * Keycloak user groups can be nested. This helper method flattens
   * nested group paths to an one-level array of group path segments.
   *
   * @param array $groups
   *   Array of user group paths as returned by Keycloak.
   * @param int $max_level
   *   (Optional) Maximum level to split into the result. If a level
   *   greater than 0 is given, the splitting will ignore user groups
   *   with a higher nesting level. Level counting starts at 1. If a
   *   maximum of 0 is given, ALL levels will be included.
   *   Defaults to 0.
   *
   * @return array
   *   Transformed user groups array.
   */
  protected function getSplitGroups(array $groups, $max_level = 0) {
    $target = [];
    foreach ($groups as $group) {
      $segments = explode('/', trim($group, '/'));
      if ($max_level > 0) {
        $segments = array_slice($segments, 0, $max_level);
      }
      $target = array_merge($target, $segments);
    }
    return array_unique($target);
  }

  /**
   * Check, if the given rule matches the user groups.
   *
   * This method applies the given user group rule to the user groups
   * and evaluates, whether the rule action should be executed or not.
   *
   * @param array $groups
   *   User groups to evaluate.
   * @param array $rule
   *   User group rule to evaluate.
   *
   * @return bool
   *   TRUE, if the rule matches the groups, FALSE otherwise.
   */
  protected function evalRoleRule(array $groups, array $rule) {

    // Whether teh rule is disabled.
    if (!$rule['enabled']) {
      return FALSE;
    }
    $operation = $rule['operation'];

    // Check the 'empty' operation.
    if ($operation == 'empty') {
      return empty($groups);
    }

    // Check the 'not_empty' operation.
    if ($operation == 'not_empty') {
      return !empty($groups);
    }
    $pattern = $this
      ->getEvalPattern($rule['pattern'], $operation, $rule['case_sensitive']);

    // Apply the pattern to the user groups.
    $result = preg_grep($pattern, $groups);

    // Evaluate the result.
    // 'not' operations are TRUE, if the result array is empty.
    if ($operation == 'not_equal' || $operation == 'starts_not_with' || $operation == 'ends_not_with' || $operation == 'contains_not' || $operation == 'not_regex') {
      return empty($result);
    }

    // All other operations are TRUE, if the result array is not empty.
    return !empty($result);
  }

  /**
   * Return Keycloak logger.
   *
   * @return \Psr\Log\LoggerInterface
   *   Logger instance for the Keycloak module.
   */
  public function getLogger() {
    return $this->loggerFactory
      ->get('openid_connect_keycloak');
  }

  /**
   * Whether the Keycloak client is in verbose debug mode.
   *
   * @return bool
   *   TRUE, if debug mode is enabled, FALSE otherwise.
   */
  public function isDebugMode() {
    return $this->config
      ->get('settings.debug');
  }

}

Members

Namesort descending Modifiers Type Description Overrides
KeycloakRoleMatcher::$config protected property A configuration object containing Keycloak client settings.
KeycloakRoleMatcher::$loggerFactory protected property The logger factory.
KeycloakRoleMatcher::applyRoleRules public function Applies user role rules to the given user account.
KeycloakRoleMatcher::evalRoleRule protected function Check, if the given rule matches the user groups.
KeycloakRoleMatcher::getEvalOperationOptions public function Return an options array of available role evaluation operations.
KeycloakRoleMatcher::getEvalPattern protected function Return a regex evaluation pattern for user group role rules.
KeycloakRoleMatcher::getGroups public function Retrieve Keycloak groups from user information.
KeycloakRoleMatcher::getLogger public function Return Keycloak logger.
KeycloakRoleMatcher::getRoleOptions public function Return all available user roles as options array.
KeycloakRoleMatcher::getRoleRules public function Return the Keycloak role rules.
KeycloakRoleMatcher::getSplitGroups protected function Return split user groups.
KeycloakRoleMatcher::getSplitGroupsLimit public function Return the maximum allowed nesting level for group path splitting.
KeycloakRoleMatcher::getUserGroupsClaimName public function Return the user groups claim name.
KeycloakRoleMatcher::hasRoleRules public function Whether there are defined Keycloak role rules.
KeycloakRoleMatcher::isDebugMode public function Whether the Keycloak client is in verbose debug mode.
KeycloakRoleMatcher::isEnabled public function Whether Keycloak groups to Drupal roles synchronization is enabled.
KeycloakRoleMatcher::isSplitGroupsEnabled public function Whether splitting group paths is enabled.
KeycloakRoleMatcher::__construct public function Constructs a RoleManager service object.
StringTranslationTrait::$stringTranslation protected property The string translation service. 1
StringTranslationTrait::formatPlural protected function Formats a string containing a count of items.
StringTranslationTrait::getNumberOfPlurals protected function Returns the number of plurals supported by a given language.
StringTranslationTrait::getStringTranslation protected function Gets the string translation service.
StringTranslationTrait::setStringTranslation public function Sets the string translation service to use. 2
StringTranslationTrait::t protected function Translates a string to the current language or to a given language.