diff --git a/CHANGELOG.md b/CHANGELOG.md index fc99935..5b30a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add new module to track user accessing webform submissions. +- Added remote ip to all log lines. + ## [0.1.1] - 2024-11-19 - Made Watchdog default logger -- Updated Watchlog logger +- Updated watchdog logger ## [0.1.0] - 2024-10-21 diff --git a/modules/os2web_audit_entity/README.md b/modules/os2web_audit_entity/README.md new file mode 100644 index 0000000..032f472 --- /dev/null +++ b/modules/os2web_audit_entity/README.md @@ -0,0 +1,27 @@ +# OS2web audit entity + +This module tries to log information about entity access and changes. + +## Webform submission + +This module integrates with [OS2Forms][os2forms-link], which utilizes the Webform module. + +If you are logging users who have accessed Webform submissions but no data is being recorded, ensure the patches +provided by this module are applied to the Webform module. + +**Note:** The patch cannot be applied via Composer because Composer does not support relative paths to patches outside +the webroot. Additionally, as the location of this module within the site can vary, applying the patch automatically +could potentially break the Composer installation. + +### Why this patch + +When implementing audit logging for webform submissions in Drupal, particularly to track who accessed the data: + +- Using `hook_entity_storage_load()` presents challenges with webform submissions due to their reliance on revisions. +- This is because the hook gets triggered before the storage handler finishes loading the submission data. + +To address this issue, a custom hook, `hook_webform_post_load_data()`, is introduced. +This custom hook is invoked after the webform has successfully loaded the submission data for a given submission +revision. + +[os2forms-link]: https://github.com/OS2Forms/os2forms diff --git a/modules/os2web_audit_entity/os2web_audit_entity.info.yml b/modules/os2web_audit_entity/os2web_audit_entity.info.yml new file mode 100644 index 0000000..2e421c7 --- /dev/null +++ b/modules/os2web_audit_entity/os2web_audit_entity.info.yml @@ -0,0 +1,6 @@ +name: "OS2web Audit logging entity access" +description: "Logs CRUD events for entities" +type: module +core_version_requirement: ^8 || ^9 || ^10 +dependencies: + - os2web_audit:os2web_audit diff --git a/modules/os2web_audit_entity/os2web_audit_entity.install b/modules/os2web_audit_entity/os2web_audit_entity.install new file mode 100644 index 0000000..2737a3a --- /dev/null +++ b/modules/os2web_audit_entity/os2web_audit_entity.install @@ -0,0 +1,18 @@ +id(), $entity->getEntityTypeId()); + os2web_audit_entity_log($msg); +} + +/** + * Implements hook_entity_update(). + */ +function os2web_audit_entity_entity_update(EntityInterface $entity): void { + $msg = sprintf('Entity (%d) of type "%s" updated.', $entity->id(), $entity->getEntityTypeId()); + os2web_audit_entity_log($msg); +} + +/** + * Implements hook_entity_delete(). + */ +function os2web_audit_entity_entity_delete(EntityInterface $entity): void { + $msg = sprintf('Entity (%d) of type "%s" deleted.', $entity->id(), $entity->getEntityTypeId()); + os2web_audit_entity_log($msg); +} + +/** + * Implements hook_entity_storage_load(). + * + * Logs access for file entities. + */ +function os2web_audit_entity_entity_storage_load(mixed $entities, string $entity_type): void { + foreach ($entities as $entity) { + if ($entity_type == 'file') { + /** @var \Drupal\file\Entity\File $entity */ + $fid = $entity->id(); + $uri = $entity->getFileUri(); + $msg = sprintf('File (%d) accessed. Uri "%s"', $fid, $uri); + os2web_audit_entity_log($msg); + } + } +} + +/** + * Implements hook_webform_post_load_data(). + */ +function os2web_audit_entity_webform_post_load_data(mixed $submissions): void { + foreach ($submissions as $submission) { + // Try to check for _cpr field for extra logging information. + $personal = ''; + $filterFields = []; + + // Detect field of type that contains "cpr" in name or where field name + // contains "cpr". + $webform = $submission->getWebform(); + $elements = $webform->getElementsDecoded(); + foreach ($elements as $fieldName => $element) { + if (str_contains(strtolower($element['#type']), 'cpr') || str_contains(strtolower($fieldName), 'cpr')) { + $filterFields[] = $fieldName; + } + } + + $submissionData = $submission->getData(); + if (!empty($filterFields)) { + foreach ($filterFields as $field) { + $cpr = $submissionData[$field]; + $personal .= sprintf(' CPR "%s" in field "%s".', $cpr ?: 'null', $field); + } + } + + // Attachments download. + $request = \Drupal::request(); + if (preg_match('~(.*)/print/pdf/(.*)|(.*)\d.*/attachment(.*)~', $request->getPathInfo())) { + // We know that a webform submission has been loaded and this is a print + // pdf path. This indicates that this is an attachment download action. + $msg = sprintf('Webform submission (%d) downloaded as attachment.%s Webform id "%s".', $submission->id(), $personal, $submission->getWebform()->id()); + os2web_audit_entity_log($msg); + + // Exit to prevent double log entry. + return; + } + + $msg = sprintf('Webform submission (%d) looked up.%s Webform id "%s".', $submission->id(), $personal, $submission->getWebform()->id()); + os2web_audit_entity_log($msg); + } +} + +/** + * Check if the accounts roles are in the array of API roles. + * + * @param \Drupal\Core\Session\AccountInterface $account + * User account. + * + * @return bool + * If roles found TRUE else FALSE. + */ +function os2web_audit_entity_is_api_user(AccountInterface $account): bool { + $roles = $account->getRoles(); + + $config = \Drupal::config(SettingsForm::$configName); + $selectedRoles = $config->get('roles'); + + return !empty(array_intersect($roles, array_keys(array_filter($selectedRoles)))); +} + +/** + * Simple logger wrapper. + * + * @param string $message + * Message to log. + */ +function os2web_audit_entity_log(string $message): void { + /** @var \Drupal\os2web_audit\Service\Logger $logger */ + $logger = \Drupal::service('os2web_audit.logger'); + + // Detect user type. + $account = \Drupal::currentUser(); + $metadata['userId'] = $account->getEmail(); + $metadata['userType'] = os2web_audit_entity_is_api_user($account) ? 'api' : 'web'; + $logger->info('Entity', $message, FALSE, $metadata); +} diff --git a/modules/os2web_audit_entity/os2web_audit_entity.routing.yml b/modules/os2web_audit_entity/os2web_audit_entity.routing.yml new file mode 100644 index 0000000..e1795d7 --- /dev/null +++ b/modules/os2web_audit_entity/os2web_audit_entity.routing.yml @@ -0,0 +1,7 @@ +os2web_audit_entity.settings: + path: '/admin/config/os2web_audit/entity' + defaults: + _form: '\Drupal\os2web_audit_entity\Form\SettingsForm' + _title: 'OS2web Audit entity settings' + requirements: + _permission: 'administer site' diff --git a/modules/os2web_audit_entity/patches/webform_hook_webform_post_load_data.diff b/modules/os2web_audit_entity/patches/webform_hook_webform_post_load_data.diff new file mode 100644 index 0000000..b28cb6d --- /dev/null +++ b/modules/os2web_audit_entity/patches/webform_hook_webform_post_load_data.diff @@ -0,0 +1,14 @@ +diff --git a/src/WebformSubmissionStorage.php b/src/WebformSubmissionStorage.php +index 4e14c3c..4c2d1c9 100644 +--- a/src/WebformSubmissionStorage.php ++++ b/src/WebformSubmissionStorage.php +@@ -168,6 +168,9 @@ class WebformSubmissionStorage extends SqlContentEntityStorage implements Webfor + /** @var \Drupal\webform\WebformSubmissionInterface[] $webform_submissions */ + $webform_submissions = parent::doLoadMultiple($ids); + $this->loadData($webform_submissions); ++ ++ \Drupal::moduleHandler()->invokeAll('webform_post_load_data', [$webform_submissions]); ++ + return $webform_submissions; + } + diff --git a/modules/os2web_audit_entity/src/Form/SettingsForm.php b/modules/os2web_audit_entity/src/Form/SettingsForm.php new file mode 100644 index 0000000..68ac2d3 --- /dev/null +++ b/modules/os2web_audit_entity/src/Form/SettingsForm.php @@ -0,0 +1,110 @@ +get('config.factory'), + $container->get('entity_type.manager') + ); + } + + /** + * The name of the configuration setting. + * + * @var string + */ + public static string $configName = 'os2web_audit_entity.settings'; + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames(): array { + return [self::$configName]; + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'os2web_audit_entity_admin_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $items = []; + $roles = $this->getRoles(); + foreach ($roles as $role) { + $items[$role->id()] = $role->label(); + } + + $config = $this->config(self::$configName); + + $form['roles'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Select API access roles'), + '#description' => $this->t('The selected roles will be use to determine who is accessing entities through the API.'), + '#options' => $items, + '#default_value' => $config->get('roles') ?? [], + '#required' => TRUE, + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + parent::submitForm($form, $form_state); + + $this->config(self::$configName) + ->set('roles', $form_state->getValue('roles')) + ->save(); + } + + /** + * Get all roles. + * + * @return array<\Drupal\Core\Entity\EntityInterface> + * An array of role entities. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + private function getRoles() { + // Use the role storage to load roles. + $roleStorage = $this->entityTypeManager->getStorage('user_role'); + + return $roleStorage->loadMultiple(); + } + +} diff --git a/os2web_audit.services.yml b/os2web_audit.services.yml index 8d48828..34ec888 100644 --- a/os2web_audit.services.yml +++ b/os2web_audit.services.yml @@ -5,4 +5,4 @@ services: os2web_audit.logger: class: Drupal\os2web_audit\Service\Logger - arguments: ['@plugin.manager.os2web_audit_logger', '@config.factory', '@current_user', '@logger.factory'] + arguments: ['@plugin.manager.os2web_audit_logger', '@config.factory', '@current_user', '@logger.factory', '@request_stack'] diff --git a/src/Service/Logger.php b/src/Service/Logger.php index ab07551..8d3b383 100644 --- a/src/Service/Logger.php +++ b/src/Service/Logger.php @@ -11,6 +11,7 @@ use Drupal\os2web_audit\Form\PluginSettingsForm; use Drupal\os2web_audit\Form\SettingsForm; use Drupal\os2web_audit\Plugin\LoggerManager; +use Symfony\Component\HttpFoundation\RequestStack; /** * Class Logger. @@ -24,6 +25,7 @@ public function __construct( private readonly ConfigFactoryInterface $configFactory, private readonly AccountProxyInterface $currentUser, private readonly LoggerChannelFactoryInterface $watchdog, + private readonly RequestStack $requestStack, ) { } @@ -86,6 +88,13 @@ private function log(string $type, int $timestamp, string $line, bool $logUser = $metadata['userId'] = $this->currentUser->getEmail(); } + // Log request IP for information more information. + $request = $this->requestStack->getCurrentRequest(); + $ip_address = $request->getClientIp(); + if (!is_null($ip_address)) { + $line .= sprintf(' Remote ip: %s', $ip_address); + } + try { /** @var \Drupal\os2web_audit\Plugin\AuditLogger\AuditLoggerInterface $logger */ $logger = $this->loggerManager->createInstance($plugin_id, $configuration ?? []);