Skip to content

Commit

Permalink
Merge pull request #7 from OS2web/feature/entities
Browse files Browse the repository at this point in the history
ITKDev: Entity audit module
  • Loading branch information
cableman authored Dec 12, 2024
2 parents 8b58dc0 + 84e2716 commit c0efbac
Show file tree
Hide file tree
Showing 11 changed files with 331 additions and 2 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions modules/os2web_audit_entity/README.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions modules/os2web_audit_entity/os2web_audit_entity.info.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions modules/os2web_audit_entity/os2web_audit_entity.install
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/**
* @file
* This module enabled os2web audit entity default options.
*/

/**
* Implements hook_install().
*
* We need to change the modules weight to ensure that all other changes to
* webform submission data have been executed before this module.
*
* The class is set in os2forms_encrypt_entity_type_alter().
*/
function os2web_audit_entity_install(): void {
module_set_weight('os2web_audit_entity', 19999);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
os2web_audit_entity.admin_settings:
title: 'OS2web Audit entity settings'
parent: system.admin_config_system
description: 'Settings for the OS2web Audit entity module'
route_name: os2web_audit_entity.settings
130 changes: 130 additions & 0 deletions modules/os2web_audit_entity/os2web_audit_entity.module
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

/**
* @file
* Hooks into drupal and collect logging data.
*/

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\os2web_audit_entity\Form\SettingsForm;

/**
* Implements hook_entity_insert().
*/
function os2web_audit_entity_entity_insert(EntityInterface $entity): void {
$msg = sprintf('Entity (%d) of type "%s" created.', $entity->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);
}
7 changes: 7 additions & 0 deletions modules/os2web_audit_entity/os2web_audit_entity.routing.yml
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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;
}

110 changes: 110 additions & 0 deletions modules/os2web_audit_entity/src/Form/SettingsForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Drupal\os2web_audit_entity\Form;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
* Class SettingsForm.
*
* This is the settings for the module.
*/
class SettingsForm extends ConfigFormBase {

/**
* {@inheritdoc}
*/
public function __construct(
ConfigFactoryInterface $configFactory,
private EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($configFactory);
}

/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->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();
}

}
2 changes: 1 addition & 1 deletion os2web_audit.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
9 changes: 9 additions & 0 deletions src/Service/Logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,6 +25,7 @@ public function __construct(
private readonly ConfigFactoryInterface $configFactory,
private readonly AccountProxyInterface $currentUser,
private readonly LoggerChannelFactoryInterface $watchdog,
private readonly RequestStack $requestStack,
) {
}

Expand Down Expand Up @@ -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 ?? []);
Expand Down

0 comments on commit c0efbac

Please sign in to comment.