o365-2.0.3/src/RolesService.php
src/RolesService.php
<?php
declare(strict_types=1);
namespace Drupal\o365;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\TempStore\TempStoreException;
use Drupal\user\UserInterface;
use GuzzleHttp\Exception\GuzzleException;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Log\LoggerInterface;
/**
* Service for managing user roles based on Microsoft 365 group membership.
*/
final class RolesService {
/**
* The logger service.
*
* @var \Psr\Log\LoggerInterface
*/
private LoggerInterface $logger;
/**
* Cached configured role mappings.
*
* @var array<string, string[]>|null
*/
private ?array $configuredRoles = NULL;
/**
* Cached safe roles list.
*
* @var string[]|null
*/
private ?array $safeRoles = NULL;
/**
* Cached user groups from Microsoft 365.
*
* @var array<int, array<string, mixed>>|null
*/
private ?array $userGroups = NULL;
/**
* The default role to give to users if any.
*
* @var string
*/
private string $defaultRole = '';
/**
* Constructs a RolesService object.
*/
public function __construct(
private readonly GraphService $graphService,
private readonly ConfigFactoryInterface $configFactory,
LoggerChannelFactoryInterface $loggerFactory,
) {
$this->logger = $loggerFactory->get('o365');
}
/**
* Updates user roles based on Microsoft 365 group membership.
*
* @param \Drupal\user\UserInterface $account
* The user account to update.
*
* @throws \Drupal\Core\Entity\EntityStorageException
* When the user account cannot be saved.
*/
public function handleRoles(UserInterface $account): void {
try {
$configuredRoles = $this->getConfiguredRoles();
$newRoles = $this->calculateUserRoles($configuredRoles);
$this->updateUserRoles($account, $newRoles);
}
catch (TempStoreException | GuzzleException | IdentityProviderException $e) {
$this->logger->error('Failed to update user roles: @message', [
'@message' => $e->getMessage(),
]);
throw new EntityStorageException('Unable to update user roles due to external service error.', 0, $e);
}
}
/**
* Calculates the roles a user should have based on their group membership.
*
* @param array<string, string[]> $configuredRoles
* The configured role mappings.
*
* @return string[]
* Array of role IDs the user should have.
*
* @throws \Drupal\Core\TempStore\TempStoreException
* @throws \GuzzleHttp\Exception\GuzzleException
* @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException
*/
private function calculateUserRoles(array $configuredRoles): array {
$userGroups = $this->getUserGroups();
if (empty($userGroups)) {
return [];
}
// Create lookup sets for efficient searching.
$groupLookup = $this->createGroupLookup($userGroups);
$assignedRoles = [];
foreach ($configuredRoles as $groupIdentifier => $roles) {
if (isset($groupLookup[$groupIdentifier])) {
foreach ($roles as $role) {
$assignedRoles[$role] = TRUE;
}
}
}
return array_keys($assignedRoles);
}
/**
* Creates a lookup array for efficient group membership checking.
*
* @param array<int, array<string, mixed>> $userGroups
* The user's Microsoft 365 groups.
*
* @return array<string, bool>
* Lookup array with group identifiers as keys.
*/
private function createGroupLookup(array $userGroups): array {
$lookup = [];
foreach ($userGroups as $group) {
// Support multiple identifiers per group.
$identifiers = [
$group['id'] ?? '',
$group['displayName'] ?? '',
$group['onPremisesSamAccountName'] ?? '',
];
foreach (array_filter($identifiers) as $identifier) {
$lookup[$identifier] = TRUE;
}
}
return $lookup;
}
/**
* Updates the user's roles by removing unsafe roles and adding new ones.
*
* @param \Drupal\user\UserInterface $account
* The user account to update.
* @param string[] $newRoles
* Array of role IDs to assign.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
private function updateUserRoles(UserInterface $account, array $newRoles): void {
$existingRoles = $account->getRoles(TRUE);
$safeRoles = $this->getSafeRoles();
$rolesToRemove = array_diff($existingRoles, $safeRoles);
$rolesToAdd = array_diff($newRoles, $existingRoles);
if (!empty($this->defaultRole) && !$account->hasRole($this->defaultRole)) {
$rolesToAdd[] = $this->defaultRole;
}
$hasChanges = FALSE;
// Remove roles that are not safe and not in the new roles list.
foreach ($rolesToRemove as $role) {
if (!in_array($role, $newRoles, TRUE)) {
$account->removeRole($role);
$hasChanges = TRUE;
}
}
// Add new roles.
foreach ($rolesToAdd as $role) {
$account->addRole($role);
$hasChanges = TRUE;
}
if ($hasChanges) {
$account->save();
$this->logger->info('Updated roles for user @uid', ['@uid' => $account->id()]);
}
}
/**
* Gets the configured roles from configuration.
*
* @return array<string, string[]>
* Array mapping group identifiers to role arrays.
*/
private function getConfiguredRoles(): array {
if ($this->configuredRoles !== NULL) {
return $this->configuredRoles;
}
$roleConfig = $this->configFactory->get('o365.role_settings');
$rolesMap = $roleConfig->get('roles_map');
// Get the default role if set in the config.
$defaultRole = $roleConfig->get('default_role');
if ($defaultRole !== '_none') {
$this->defaultRole = $defaultRole;
}
$this->configuredRoles = [];
if (!empty($rolesMap)) {
$mapping = array_filter(
array_map('trim', explode("\n", trim($rolesMap)))
);
foreach ($mapping as $line) {
$parts = array_map('trim', explode('|', $line));
if (count($parts) >= 2) {
$groupIdentifier = array_shift($parts);
$this->configuredRoles[$groupIdentifier] = array_filter($parts);
}
}
}
return $this->configuredRoles;
}
/**
* Gets the list of safe roles that should not be removed.
*
* @return string[]
* Array of safe role IDs.
*/
private function getSafeRoles(): array {
if ($this->safeRoles !== NULL) {
return $this->safeRoles;
}
$roleConfig = $this->configFactory->get('o365.role_settings');
$safeRoles = $roleConfig->get('safe_roles');
$this->safeRoles = [];
if (!empty($safeRoles)) {
$this->safeRoles = array_filter(
array_map('trim', explode("\n", $safeRoles))
);
}
// Make the default role a safe role so it doesn't get deleted.
if (!empty($this->defaultRole)) {
$this->safeRoles[] = $this->defaultRole;
}
return $this->safeRoles;
}
/**
* Gets the user's Microsoft 365 groups.
*
* @return array<int, array<string, mixed>>
* Array of group data from Microsoft Graph API.
*
* @throws \Drupal\Core\TempStore\TempStoreException
* @throws \GuzzleHttp\Exception\GuzzleException
* @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException
*/
private function getUserGroups(): array {
if ($this->userGroups !== NULL) {
return $this->userGroups;
}
$response = $this->graphService->getGraphData(
'/me/transitiveMemberOf/microsoft.graph.group?$select=id,displayName,onPremisesSamAccountName'
);
$this->userGroups = $response['value'] ?? [];
return $this->userGroups;
}
/**
* Clears cached data to force fresh retrieval.
*/
public function clearCache(): void {
$this->configuredRoles = NULL;
$this->safeRoles = NULL;
$this->userGroups = NULL;
}
}
