Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ITKDev: Entity audit module #7

Merged
merged 13 commits into from
Dec 12, 2024
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);
jekuaitk marked this conversation as resolved.
Show resolved Hide resolved
}
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);
}
}

jekuaitk marked this conversation as resolved.
Show resolved Hide resolved
// 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
Loading