Inicializa módulo base ldap_groups_sync

Cria super-módulo com infraestrutura compartilhada de regras de acesso
para os módulos de sincronização LDAP de grupos.

- GroupAccessRulesService: serviço parametrizável por config name
- AccessRulesFormBase: listagem/remoção de regras (classe abstrata)
- AccessRuleFormBase: formulário modal de criação/edição (classe abstrata)
- Sub-módulos ldap_departments_sync e ldap_research_groups_sync refatorados
  para estender as classes base com subclasses mínimas
- Traduções pt-br centralizadas em ldap_groups_sync.pt-br.po

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 09:55:54 -03:00
commit 346b897e25
104 changed files with 11315 additions and 0 deletions

View File

@@ -0,0 +1,771 @@
<?php
namespace Drupal\ldap_groups_sync\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseModalDialogCommand;
use Drupal\Core\Ajax\RedirectCommand;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for the modal form for creating or editing a single access rule.
*
* Opened from the main config form via a Drupal dialog (use-ajax link).
* Saves directly to the module's settings config and closes the modal on
* success, triggering a full page reload of the parent.
*
* Subclasses must implement getConfigName(), getFormId(), getAccessRulesRoute(),
* and getDefaultGroupTypeId().
*/
abstract class AccessRuleFormBase extends FormBase {
/**
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructor.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
EntityTypeManagerInterface $entity_type_manager,
EntityTypeBundleInfoInterface $bundle_info,
EntityFieldManagerInterface $entity_field_manager,
RouteMatchInterface $route_match
) {
$this->configFactory = $config_factory;
$this->entityTypeManager = $entity_type_manager;
$this->bundleInfo = $bundle_info;
$this->entityFieldManager = $entity_field_manager;
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('entity_type.manager'),
$container->get('entity_type.bundle.info'),
$container->get('entity_field.manager'),
$container->get('current_route_match')
);
}
/**
* Returns the config object name for the implementing module.
*
* @return string
* E.g. 'ldap_departments_sync.settings'.
*/
abstract protected function getConfigName(): string;
/**
* Returns the route name for the access rules listing page.
*
* @return string
* E.g. 'ldap_departments_sync.access_rules'.
*/
abstract protected function getAccessRulesRoute(): string;
/**
* Returns the default group type ID used when the config has no value set.
*
* @return string
* E.g. 'departments' or 'research_group'.
*/
abstract protected function getDefaultGroupTypeId(): string;
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $rule_index = 'new') {
$form['#prefix'] = '<div id="access-rule-form-wrapper">';
$form['#suffix'] = '</div>';
// Attach dialog library so the close button works.
$form['#attached']['library'][] = 'core/drupal.dialog.ajax';
// Resolve the rule index (from route or form_state for AJAX rebuilds).
$resolved_index = $form_state->get('rule_index') ?? $rule_index;
$form_state->set('rule_index', $resolved_index);
// Load existing rule data if editing.
$rule = $this->loadRule($resolved_index);
// ---- Resolve current entity_type and bundle (considering AJAX changes) ----
$entity_type_id = $form_state->getValue('entity_type') ?? ($rule['entity_type'] ?? '');
$bundle = $form_state->getValue('bundle') ?? ($rule['bundle'] ?? '');
// ---- Basic settings --------------------------------------------------------
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#description' => $this->t('Human-readable description of this rule.'),
'#default_value' => $rule['label'] ?? '',
'#required' => TRUE,
'#maxlength' => 128,
];
$form['enabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enabled'),
'#default_value' => $rule['enabled'] ?? TRUE,
];
// ---- Entity type -----------------------------------------------------------
$entity_type_options = $this->getContentEntityTypeOptions();
$form['entity_type'] = [
'#type' => 'select',
'#title' => $this->t('Entity Type'),
'#options' => $entity_type_options,
'#empty_option' => $this->t('- Select entity type -'),
'#default_value' => $entity_type_id,
'#required' => TRUE,
'#ajax' => [
'callback' => '::updateDependentFields',
'wrapper' => 'access-rule-form-wrapper',
'effect' => 'fade',
],
];
// ---- Bundle ----------------------------------------------------------------
$bundle_options = [];
if (!empty($entity_type_id)) {
$bundle_options = $this->getBundleOptions($entity_type_id);
}
$form['bundle'] = [
'#type' => 'select',
'#title' => $this->t('Bundle'),
'#description' => $this->t('Leave empty to apply to all bundles of the selected entity type.'),
'#options' => $bundle_options,
'#empty_option' => $this->t('- All bundles -'),
'#default_value' => $bundle,
'#ajax' => [
'callback' => '::updateDependentFields',
'wrapper' => 'access-rule-form-wrapper',
'effect' => 'fade',
],
];
// ---- Operations ------------------------------------------------------------
$form['operations'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Operations'),
'#options' => [
'create' => $this->t('Create'),
'update' => $this->t('Update'),
'delete' => $this->t('Delete'),
'view' => $this->t('View'),
],
'#default_value' => $rule['operations'] ?? ['create'],
'#required' => TRUE,
];
// ---- Mode ------------------------------------------------------------------
$form['mode'] = [
'#type' => 'radios',
'#title' => $this->t('Mode'),
'#options' => [
'restrictive' => $this->t('<strong>Restrictive</strong> — deny access to users who do not match this rule'),
'additive' => $this->t('<strong>Additive</strong> — only grant access; never deny other users'),
],
'#default_value' => $rule['mode'] ?? 'restrictive',
];
// ---- Membership requirements -----------------------------------------------
$form['membership'] = [
'#type' => 'fieldset',
'#title' => $this->t('Membership requirements'),
'#description' => $this->t(
'A user is authorized if they satisfy <strong>at least one card</strong>: '
. 'member of that group <em>and</em> holding at least one of the checked roles. '
. 'Each card can specify a different group and different roles.'
),
];
$membership_count = $form_state->get('membership_count');
// Prefer form state values (preserves user edits during AJAX rebuilds).
// Normalize from submitted format: checkboxes return [id => id|0], we
// need [id, ...] for #default_value.
$memberships_from_state = $form_state->getValue('memberships');
if ($memberships_from_state !== NULL) {
$existing_memberships = [];
foreach ($memberships_from_state as $mem) {
$existing_memberships[] = [
'group_id' => $mem['group_id'] ?? '',
'roles' => is_array($mem['roles'] ?? NULL)
? array_values(array_filter($mem['roles']))
: [],
];
}
}
else {
$existing_memberships = $rule['memberships'] ?? [];
}
if ($membership_count === NULL) {
$membership_count = max(1, count($existing_memberships));
$form_state->set('membership_count', $membership_count);
}
$group_options = $this->getGroupOptions();
$role_options = $this->getGroupRoleOptions();
// #tree => TRUE ensures values are nested as memberships[m][group_id],
// memberships[m][roles], etc. Without it, all group_id fields would
// collide at the top-level form values.
$form['membership']['memberships'] = [
'#type' => 'container',
'#tree' => TRUE,
];
// Render one card (container) per membership entry.
for ($m = 0; $m < $membership_count; $m++) {
$mem = $existing_memberships[$m] ?? ['group_id' => '', 'roles' => []];
$form['membership']['memberships'][$m] = [
'#type' => 'container',
'#attributes' => [
'class' => ['membership-card'],
'style' => 'border:1px solid #ccc;border-radius:4px;padding:12px 16px;margin-bottom:12px;background:#fafafa',
],
];
$form['membership']['memberships'][$m]['group_id'] = [
'#type' => 'select',
'#title' => $this->t('Group'),
'#options' => $group_options,
'#empty_option' => $this->t('- Select a group -'),
'#default_value' => $mem['group_id'] ?? '',
'#required' => FALSE,
];
// Checkboxes display the full label — no truncation.
$form['membership']['memberships'][$m]['roles'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Required roles <em>(user must hold at least one)</em>'),
'#options' => $role_options,
'#default_value' => $mem['roles'] ?? [],
];
$form['membership']['memberships'][$m]['remove'] = [
'#type' => 'submit',
'#value' => $this->t('Remove this group'),
'#name' => 'remove_membership_' . $m,
'#submit' => ['::removeOneMembership'],
'#ajax' => [
'callback' => '::updateDependentFields',
'wrapper' => 'access-rule-form-wrapper',
'effect' => 'fade',
],
'#limit_validation_errors' => [['memberships']],
'#attributes' => ['class' => ['button', 'button--danger', 'button--small']],
];
}
$form['membership']['add_membership'] = [
'#type' => 'submit',
'#value' => $this->t('+ Add group'),
'#submit' => ['::addMembership'],
'#ajax' => [
'callback' => '::updateDependentFields',
'wrapper' => 'access-rule-form-wrapper',
'effect' => 'fade',
],
'#limit_validation_errors' => [['memberships']],
'#attributes' => ['class' => ['button']],
];
// ---- Field conditions (update / delete / view only) -----------------------
$form['field_conditions_wrapper'] = [
'#type' => 'details',
'#title' => $this->t('Field conditions <em>(optional — only evaluated for update/delete/view)</em>'),
'#open' => !empty($rule['field_conditions']),
'#description' => $this->t('All conditions must be true for the rule to apply to an existing entity. For <em>create</em> operations conditions are ignored.'),
];
$field_options = [];
if (!empty($entity_type_id)) {
$field_options = $this->getFieldOptions($entity_type_id, $bundle ?: NULL);
}
$cond_count = $form_state->get('condition_count');
if ($cond_count === NULL) {
$cond_count = max(1, count($rule['field_conditions'] ?? []));
$form_state->set('condition_count', $cond_count);
}
$existing_conditions = $rule['field_conditions'] ?? [];
$form['field_conditions_wrapper']['field_conditions'] = [
'#type' => 'table',
'#header' => [
$this->t('Field'),
$this->t('Value'),
$this->t('Remove'),
],
];
for ($c = 0; $c < $cond_count; $c++) {
$cond = $existing_conditions[$c] ?? ['field' => '', 'value' => ''];
if (!empty($field_options)) {
$form['field_conditions_wrapper']['field_conditions'][$c]['field'] = [
'#type' => 'select',
'#options' => $field_options,
'#empty_option' => $this->t('- Select field -'),
'#default_value' => $cond['field'],
];
}
else {
$form['field_conditions_wrapper']['field_conditions'][$c]['field'] = [
'#type' => 'textfield',
'#placeholder' => $this->t('field_machine_name'),
'#default_value' => $cond['field'],
'#size' => 24,
];
}
$form['field_conditions_wrapper']['field_conditions'][$c]['value'] = [
'#type' => 'textfield',
'#placeholder' => $this->t('Expected value'),
'#default_value' => $cond['value'],
'#size' => 24,
];
$form['field_conditions_wrapper']['field_conditions'][$c]['remove'] = [
'#type' => 'checkbox',
'#default_value' => FALSE,
];
}
$form['field_conditions_wrapper']['condition_actions'] = [
'#type' => 'container',
];
$form['field_conditions_wrapper']['condition_actions']['add_condition'] = [
'#type' => 'submit',
'#value' => $this->t('Add condition'),
'#submit' => ['::addCondition'],
'#ajax' => [
'callback' => '::updateDependentFields',
'wrapper' => 'access-rule-form-wrapper',
'effect' => 'fade',
],
'#limit_validation_errors' => [['field_conditions']],
];
$form['field_conditions_wrapper']['condition_actions']['remove_conditions'] = [
'#type' => 'submit',
'#value' => $this->t('Remove selected conditions'),
'#submit' => ['::removeSelectedConditions'],
'#ajax' => [
'callback' => '::updateDependentFields',
'wrapper' => 'access-rule-form-wrapper',
'effect' => 'fade',
],
'#limit_validation_errors' => [['field_conditions']],
];
// ---- Submit ----------------------------------------------------------------
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save rule'),
'#button_type' => 'primary',
'#ajax' => [
'callback' => '::ajaxSave',
'wrapper' => 'access-rule-form-wrapper',
],
];
$form['actions']['cancel'] = [
'#type' => 'submit',
'#value' => $this->t('Cancel'),
'#submit' => [],
'#limit_validation_errors' => [],
'#ajax' => [
'callback' => '::cancelDialog',
],
'#attributes' => ['class' => ['button']],
];
return $form;
}
/**
* AJAX callback: replaces the entire form wrapper (entity_type/bundle/field
* changes and condition add/remove all reuse this single callback).
*/
public function updateDependentFields(array &$form, FormStateInterface $form_state) {
return $form;
}
/**
* Submit handler: adds a blank membership card.
*
* Explicitly normalizes current values and appends an empty entry so the
* new card has no pre-filled group or roles.
*/
public function addMembership(array &$form, FormStateInterface $form_state) {
$current = $form_state->getValue('memberships') ?? [];
// Normalize existing entries (checkboxes submit [id => id|0] format).
$normalized = [];
foreach ($current as $mem) {
$normalized[] = [
'group_id' => $mem['group_id'] ?? '',
'roles' => is_array($mem['roles'] ?? NULL)
? array_values(array_filter($mem['roles']))
: [],
];
}
// Append a truly empty entry for the new card.
$normalized[] = ['group_id' => '', 'roles' => []];
$form_state->setValue('memberships', $normalized);
$form_state->set('membership_count', count($normalized));
$form_state->setRebuild();
}
/**
* Submit handler: removes the single membership card whose button was clicked.
*/
public function removeOneMembership(array &$form, FormStateInterface $form_state) {
$trigger = $form_state->getTriggeringElement();
preg_match('/remove_membership_(\d+)/', $trigger['#name'] ?? '', $matches);
$index = isset($matches[1]) ? (int) $matches[1] : -1;
$memberships = $form_state->getValue('memberships') ?? [];
// Normalize and remove the target entry.
$normalized = [];
foreach ($memberships as $i => $mem) {
if ($i === $index) {
continue;
}
$normalized[] = [
'group_id' => $mem['group_id'] ?? '',
'roles' => is_array($mem['roles'] ?? NULL)
? array_values(array_filter($mem['roles']))
: [],
];
}
$form_state->setValue('memberships', $normalized);
$form_state->set('membership_count', max(1, count($normalized)));
$form_state->setRebuild();
}
/**
* Submit handler: adds a blank condition row.
*/
public function addCondition(array &$form, FormStateInterface $form_state) {
$form_state->set('condition_count', ($form_state->get('condition_count') ?? 1) + 1);
$form_state->setRebuild();
}
/**
* Submit handler: removes checked condition rows.
*/
public function removeSelectedConditions(array &$form, FormStateInterface $form_state) {
$conditions = $form_state->getValue('field_conditions') ?? [];
$kept = array_values(array_filter($conditions, fn($c) => empty($c['remove'])));
$form_state->setValue('field_conditions', $kept);
$form_state->set('condition_count', max(1, count($kept)));
$form_state->setRebuild();
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$operations = array_filter($form_state->getValue('operations') ?? []);
if (empty($operations)) {
$form_state->setErrorByName('operations', $this->t('Select at least one operation.'));
}
$memberships = $form_state->getValue('memberships') ?? [];
$valid = array_filter($memberships, fn($m) => !empty($m['group_id']));
if (empty($valid)) {
$form_state->setErrorByName('memberships', $this->t('Add at least one group membership requirement.'));
}
foreach ($valid as $idx => $mem) {
if (empty($mem['roles'])) {
$form_state->setErrorByName(
"memberships][$idx][roles",
$this->t('Select at least one required role for each group.')
);
}
}
}
/**
* AJAX callback: closes the modal and redirects after a successful save.
*
* The actual save is performed by submitForm(), which Drupal always runs
* before the AJAX callback. This callback must NOT call saveRule() again
* to avoid saving the rule twice.
*/
public function ajaxSave(array &$form, FormStateInterface $form_state) {
if ($form_state->hasAnyErrors()) {
// Redisplay the form with error messages.
return $form;
}
$response = new AjaxResponse();
$response->addCommand(new CloseModalDialogCommand());
$response->addCommand(new RedirectCommand(
Url::fromRoute($this->getAccessRulesRoute())->toString()
));
return $response;
}
/**
* AJAX callback: closes the modal without saving.
*/
public function cancelDialog(array &$form, FormStateInterface $form_state): AjaxResponse {
$response = new AjaxResponse();
$response->addCommand(new CloseModalDialogCommand());
return $response;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Runs for both AJAX and non-AJAX submissions.
$this->saveRule($form_state);
$form_state->setRedirectUrl(Url::fromRoute($this->getAccessRulesRoute()));
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Loads a rule from config by index ('new' returns defaults).
*/
protected function loadRule($rule_index): array {
if ($rule_index === 'new') {
return [];
}
$rules = $this->configFactory
->get($this->getConfigName())
->get('access_rules') ?? [];
return $rules[(int) $rule_index] ?? [];
}
/**
* Persists the rule to config.
*/
protected function saveRule(FormStateInterface $form_state): void {
$rule_index = $form_state->get('rule_index') ?? 'new';
$operations = array_values(array_filter($form_state->getValue('operations') ?? []));
$conditions_raw = $form_state->getValue('field_conditions') ?? [];
$conditions = [];
foreach ($conditions_raw as $cond) {
if (!empty($cond['remove']) || empty($cond['field'])) {
continue;
}
$conditions[] = [
'field' => $cond['field'],
'value' => $cond['value'] ?? '',
];
}
$memberships_raw = $form_state->getValue('memberships') ?? [];
$memberships = [];
foreach ($memberships_raw as $mem) {
if (empty($mem['group_id'])) {
continue;
}
// Checkboxes return [role_id => role_id] for checked, [role_id => 0] for unchecked.
$roles = array_values(array_filter((array) ($mem['roles'] ?? [])));
$memberships[] = [
'group_id' => (int) $mem['group_id'],
'roles' => $roles,
];
}
$new_rule = [
'label' => $form_state->getValue('label'),
'entity_type' => $form_state->getValue('entity_type'),
'bundle' => $form_state->getValue('bundle') ?? '',
'operations' => $operations,
'mode' => $form_state->getValue('mode') ?? 'restrictive',
'memberships' => $memberships,
'field_conditions' => $conditions,
'enabled' => (bool) $form_state->getValue('enabled'),
];
$config = $this->configFactory->getEditable($this->getConfigName());
$rules = $config->get('access_rules') ?? [];
if ($rule_index === 'new') {
$rules[] = $new_rule;
}
else {
$rules[(int) $rule_index] = $new_rule;
}
$config->set('access_rules', array_values($rules))->save();
}
/**
* Returns content entity type options suitable for a select element.
*/
protected function getContentEntityTypeOptions(): array {
$options = [];
foreach ($this->entityTypeManager->getDefinitions() as $id => $definition) {
if (!($definition instanceof \Drupal\Core\Entity\ContentEntityTypeInterface)) {
continue;
}
// Skip internal group-related and config entity types.
if (in_array($id, ['group_content', 'group_relationship'], TRUE)) {
continue;
}
$options[$id] = (string) $definition->getLabel();
}
asort($options);
return $options;
}
/**
* Returns bundle options for a given entity type.
*/
protected function getBundleOptions(string $entity_type_id): array {
$options = [];
try {
foreach ($this->bundleInfo->getBundleInfo($entity_type_id) as $bundle_id => $info) {
$options[$bundle_id] = (string) ($info['label'] ?? $bundle_id);
}
}
catch (\Exception $e) {
// Return empty if the entity type has no bundles.
}
asort($options);
return $options;
}
/**
* Returns field options for the given entity type and (optional) bundle.
*/
protected function getFieldOptions(string $entity_type_id, ?string $bundle): array {
$options = [];
try {
$bundle = $bundle ?: $entity_type_id;
$definitions = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle);
$skip = ['uuid', 'langcode', 'default_langcode', 'revision_translation_affected'];
foreach ($definitions as $name => $def) {
if (in_array($name, $skip, TRUE)) {
continue;
}
$options[$name] = (string) $def->getLabel() . ' (' . $name . ')';
}
}
catch (\Exception $e) {
// Return empty on error.
}
asort($options);
return $options;
}
/**
* Returns options for all groups of the configured group type.
*/
protected function getGroupOptions(): array {
$options = [];
try {
$group_type_id = $this->configFactory
->get($this->getConfigName())
->get('group_type_id') ?: $this->getDefaultGroupTypeId();
$groups = $this->entityTypeManager
->getStorage('group')
->loadByProperties(['type' => $group_type_id]);
foreach ($groups as $group) {
$options[$group->id()] = $group->label();
}
asort($options);
}
catch (\Exception $e) {
// Return empty on error.
}
return $options;
}
/**
* Returns options for all roles of the configured group type.
*/
protected function getGroupRoleOptions(): array {
$options = [];
try {
$group_type_id = $this->configFactory
->get($this->getConfigName())
->get('group_type_id') ?: $this->getDefaultGroupTypeId();
$roles = $this->entityTypeManager
->getStorage('group_role')
->loadByProperties(['group_type' => $group_type_id]);
foreach ($roles as $role) {
$scope_labels = [
'individual' => $this->t('Individual'),
'insider' => $this->t('Insider'),
'outsider' => $this->t('Outsider'),
];
$scope = $role->getScope();
$suffix = isset($scope_labels[$scope]) ? ' [' . $scope_labels[$scope] . ']' : '';
$options[$role->id()] = $role->label() . $suffix;
}
}
catch (\Exception $e) {
// Return empty on error.
}
return $options;
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace Drupal\ldap_groups_sync\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for listing and managing access rules in LDAP group sync modules.
*
* Individual rules are created and edited via a modal (AccessRuleFormBase).
* This page shows a summary table and allows bulk removal.
*
* Subclasses must implement getConfigName(), getFormId(), and
* getAccessRuleFormRoute().
*/
abstract class AccessRulesFormBase extends ConfigFormBase {
/**
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructor.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
TypedConfigManagerInterface $typed_config_manager,
EntityTypeManagerInterface $entity_type_manager
) {
parent::__construct($config_factory, $typed_config_manager);
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('config.typed'),
$container->get('entity_type.manager')
);
}
/**
* Returns the config object name for the implementing module.
*
* @return string
* E.g. 'ldap_departments_sync.settings'.
*/
abstract protected function getConfigName(): string;
/**
* Returns the route name for the single-rule edit/create form.
*
* @return string
* E.g. 'ldap_departments_sync.access_rule_form'.
*/
abstract protected function getAccessRuleFormRoute(): string;
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return [$this->getConfigName()];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form['#attached']['library'][] = 'core/drupal.dialog.ajax';
$form['description'] = [
'#type' => 'markup',
'#markup' => '<p>' . $this->t(
'Define rules that restrict or grant entity operations based on group membership.<br>'
. '<strong>Restrictive</strong>: if a matching rule exists and the user does not satisfy it, access is denied.<br>'
. '<strong>Additive</strong>: the rule only grants extra access; other users keep their default permissions.<br>'
. '<em>Field conditions</em> are only evaluated for update/delete/view (entity already exists).'
) . '</p>',
];
$existing_rules = $this->config($this->getConfigName())->get('access_rules') ?? [];
$form['summary'] = [
'#type' => 'table',
'#header' => [
$this->t('Label'),
$this->t('Entity type / Bundle'),
$this->t('Operations'),
$this->t('Groups / Roles'),
$this->t('Mode'),
$this->t('Status'),
$this->t('Edit'),
$this->t('Remove'),
],
'#empty' => $this->t('No access rules defined. Click "Add Rule" to create the first one.'),
'#attributes' => ['class' => ['access-rules-summary-table']],
];
foreach ($existing_rules as $i => $rule) {
$bundle_part = !empty($rule['bundle'])
? ' / <em>' . $rule['bundle'] . '</em>'
: ' / <em>' . $this->t('all bundles') . '</em>';
$form['summary'][$i]['label'] = [
'#markup' => '<strong>' . ($rule['label'] ?: $this->t('(no label)')) . '</strong>',
];
$form['summary'][$i]['entity_bundle'] = [
'#markup' => ($rule['entity_type'] ?? '?') . $bundle_part,
];
$form['summary'][$i]['operations'] = [
'#markup' => implode(', ', $rule['operations'] ?? []),
];
$membership_lines = [];
foreach ($rule['memberships'] ?? [] as $mem) {
try {
$group = $this->entityTypeManager->getStorage('group')->load($mem['group_id'] ?? 0);
$group_label = $group ? $group->label() : '#' . ($mem['group_id'] ?? '?');
}
catch (\Exception $e) {
$group_label = '#' . ($mem['group_id'] ?? '?');
}
$roles = implode(', ', $mem['roles'] ?? []);
$membership_lines[] = $group_label . ($roles ? ' <em>(' . $roles . ')</em>' : '');
}
$form['summary'][$i]['memberships_summary'] = [
'#markup' => $membership_lines
? implode('<br>', $membership_lines)
: '<em>' . $this->t('none') . '</em>',
];
$form['summary'][$i]['mode'] = [
'#markup' => ($rule['mode'] ?? 'restrictive') === 'additive'
? $this->t('Additive')
: $this->t('Restrictive'),
];
$form['summary'][$i]['enabled'] = [
'#markup' => ($rule['enabled'] ?? TRUE)
? '<span style="color:green">&#10003; ' . $this->t('On') . '</span>'
: '<span style="color:gray">&#10007; ' . $this->t('Off') . '</span>',
];
$form['summary'][$i]['edit'] = [
'#type' => 'link',
'#title' => $this->t('Edit'),
'#url' => Url::fromRoute($this->getAccessRuleFormRoute(), ['rule_index' => $i]),
'#attributes' => [
'class' => ['use-ajax', 'button', 'button--small'],
'data-dialog-type' => 'modal',
'data-dialog-options' => json_encode([
'width' => 860,
'title' => $this->t('Edit Access Rule'),
]),
],
];
$form['summary'][$i]['remove'] = [
'#type' => 'checkbox',
'#default_value' => FALSE,
];
}
$form['actions'] = ['#type' => 'actions'];
$form['actions']['add_rule'] = [
'#type' => 'link',
'#title' => $this->t('Add Rule'),
'#url' => Url::fromRoute($this->getAccessRuleFormRoute(), ['rule_index' => 'new']),
'#attributes' => [
'class' => ['use-ajax', 'button', 'button--primary'],
'data-dialog-type' => 'modal',
'data-dialog-options' => json_encode([
'width' => 860,
'title' => $this->t('New Access Rule'),
]),
],
];
$form['actions']['remove_selected'] = [
'#type' => 'submit',
'#value' => $this->t('Remove Selected'),
'#limit_validation_errors' => [['summary']],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$summary_values = $form_state->getValue('summary') ?? [];
$config = $this->configFactory()->getEditable($this->getConfigName());
$existing_rules = $config->get('access_rules') ?? [];
$kept = [];
foreach ($existing_rules as $i => $rule) {
if (empty($summary_values[$i]['remove'])) {
$kept[] = $rule;
}
}
$config->set('access_rules', array_values($kept))->save();
$this->messenger()->addStatus($this->t('Access rules updated.'));
}
}

View File

@@ -0,0 +1,276 @@
<?php
namespace Drupal\ldap_groups_sync;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Service to check entity access based on configured group membership rules.
*
* Each rule defines:
* - Which entity type + bundle it applies to
* - Which operations it controls (create, update, delete, view)
* - Which group members (identified by group field value) with which roles
* are allowed to perform those operations
* - Whether the rule is restrictive (deny others) or additive (only grant)
* - Optional field conditions on the entity (for update/delete/view)
*/
class GroupAccessRulesService {
/**
* Config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* Entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The config name for the module using this service.
*
* @var string
*/
protected string $configName;
/**
* Constructor.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
EntityTypeManagerInterface $entity_type_manager,
string $configName
) {
$this->configFactory = $config_factory;
$this->entityTypeManager = $entity_type_manager;
$this->configName = $configName;
}
/**
* Check access for an operation on an existing entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being accessed.
* @param string $operation
* The operation: view, update, delete.
* @param \Drupal\Core\Session\AccountInterface $account
* The user account.
*
* @return \Drupal\Core\Access\AccessResult
* The access result.
*/
public function checkAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
$rules = $this->getRulesForOperation(
$entity->getEntityTypeId(),
$entity->bundle(),
$operation
);
if (empty($rules)) {
return AccessResult::neutral();
}
$cache = (new CacheableMetadata())
->addCacheContexts(['user'])
->addCacheableDependency($entity)
->addCacheTags(['config:' . $this->configName]);
foreach ($rules as $rule) {
// For update/delete/view, check optional field conditions on the entity.
if (!$this->entityMatchesConditions($entity, $rule['field_conditions'] ?? [])) {
continue;
}
if ($this->userMatchesRule($account, $rule)) {
return AccessResult::allowed()->addCacheableDependency($cache);
}
if (($rule['mode'] ?? 'restrictive') === 'restrictive') {
return AccessResult::forbidden()->addCacheableDependency($cache);
}
}
return AccessResult::neutral()->addCacheableDependency($cache);
}
/**
* Check create access for an entity type and bundle.
*
* Field conditions are not evaluated here because the entity does not yet
* exist at creation time.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account.
* @param string $entity_type
* The entity type ID.
* @param string $bundle
* The bundle.
*
* @return \Drupal\Core\Access\AccessResult
* The access result.
*/
public function checkCreateAccess(AccountInterface $account, string $entity_type, string $bundle): AccessResult {
$rules = $this->getRulesForOperation($entity_type, $bundle, 'create');
if (empty($rules)) {
return AccessResult::neutral();
}
$cache = (new CacheableMetadata())
->addCacheContexts(['user'])
->addCacheTags(['config:' . $this->configName]);
foreach ($rules as $rule) {
if ($this->userMatchesRule($account, $rule)) {
return AccessResult::allowed()->addCacheableDependency($cache);
}
if (($rule['mode'] ?? 'restrictive') === 'restrictive') {
return AccessResult::forbidden()->addCacheableDependency($cache);
}
}
return AccessResult::neutral()->addCacheableDependency($cache);
}
/**
* Returns enabled rules that match the given entity type, bundle, and operation.
*
* @param string $entity_type
* Entity type ID.
* @param string $bundle
* Bundle machine name.
* @param string $operation
* Operation name (create, update, delete, view).
*
* @return array
* Matching rules.
*/
protected function getRulesForOperation(string $entity_type, string $bundle, string $operation): array {
$all_rules = $this->configFactory
->get($this->configName)
->get('access_rules') ?? [];
return array_values(array_filter($all_rules, function (array $rule) use ($entity_type, $bundle, $operation) {
if (!($rule['enabled'] ?? TRUE)) {
return FALSE;
}
if (($rule['entity_type'] ?? '') !== $entity_type) {
return FALSE;
}
// An empty bundle in the rule means "all bundles of this entity type".
if (!empty($rule['bundle']) && $rule['bundle'] !== $bundle) {
return FALSE;
}
if (!in_array($operation, $rule['operations'] ?? [], TRUE)) {
return FALSE;
}
return TRUE;
}));
}
/**
* Returns TRUE if the entity satisfies all field conditions of a rule.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
* @param array $conditions
* Array of conditions, each with 'field' and 'value' keys.
*
* @return bool
* TRUE if all conditions are met or there are no conditions.
*/
protected function entityMatchesConditions(EntityInterface $entity, array $conditions): bool {
foreach ($conditions as $condition) {
$field_name = $condition['field'] ?? '';
$expected = (string) ($condition['value'] ?? '');
if (empty($field_name) || !$entity->hasField($field_name)) {
return FALSE;
}
$field = $entity->get($field_name);
// Support simple value fields and entity reference fields.
$actual = (string) ($field->value ?? $field->target_id ?? '');
if ($actual !== $expected) {
return FALSE;
}
}
return TRUE;
}
/**
* Returns TRUE if the account satisfies the membership requirements of a rule.
*
* Each rule contains a `memberships` array where every entry pairs a group ID
* with its own set of required roles. A user is authorized if they match
* at least one entry: member of that group AND holding at least one of the
* entry's roles.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The account to check.
* @param array $rule
* The access rule configuration.
*
* @return bool
* TRUE if the user satisfies at least one membership+role requirement.
*/
protected function userMatchesRule(AccountInterface $account, array $rule): bool {
if (!$account->isAuthenticated()) {
return FALSE;
}
$memberships = $rule['memberships'] ?? [];
if (empty($memberships)) {
return FALSE;
}
try {
$storage = $this->entityTypeManager->getStorage('group');
}
catch (\Exception $e) {
return FALSE;
}
foreach ($memberships as $entry) {
$group_id = (int) ($entry['group_id'] ?? 0);
$required_roles = $entry['roles'] ?? [];
if (!$group_id || empty($required_roles)) {
continue;
}
$group = $storage->load($group_id);
if (!$group) {
continue;
}
$membership = $group->getMember($account);
if (!$membership) {
continue;
}
$member_role_ids = array_keys($membership->getRoles());
foreach ($required_roles as $required_role) {
if (in_array($required_role, $member_role_ids, TRUE)) {
return TRUE;
}
}
}
return FALSE;
}
}