diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..a4b263e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,23 @@
+#### Link to ticket
+
+Please add a link to the ticket being addressed by this change.
+
+#### Description
+
+Please include a short description of the suggested change and the reasoning behind the approach you have chosen.
+
+#### Screenshot of the result
+
+If your change affects the user interface you should include a screenshot of the result with the pull request.
+
+#### Checklist
+
+- [ ] My code passes our static analysis suite.
+- [ ] My code passes our continuous integration process.
+
+If your code does not pass all the requirements on the checklist you have to add a comment explaining why this change
+should be exempt from the list.
+
+#### Additional comments or questions
+
+If you have any further comments or questions for the reviewer please add them here.
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
new file mode 100644
index 0000000..42565ef
--- /dev/null
+++ b/.github/workflows/pr.yml
@@ -0,0 +1,137 @@
+on: pull_request
+name: Review
+jobs:
+ changelog:
+ runs-on: ubuntu-latest
+ name: Changelog should be updated
+ strategy:
+ fail-fast: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 2
+
+ - name: Git fetch
+ run: git fetch
+
+ - name: Check that changelog has been updated.
+ run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0
+
+ test-composer-files:
+ name: Validate composer
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: [ '8.1' ]
+ dependency-version: [ prefer-lowest, prefer-stable ]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: Validate composer files
+ run: |
+ composer validate --strict composer.json
+ # Check that dependencies resolve.
+ composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
+
+ php-coding-standards:
+ name: PHP coding standards
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: [ '8.1' ]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: Install Dependencies
+ run: |
+ composer install --no-interaction --no-progress
+ - name: PHPCS
+ run: |
+ composer coding-standards-check/phpcs
+
+ php-code-analysis:
+ name: PHP code analysis
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: [ '8.1' ]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: Code analysis
+ run: |
+ ./scripts/code-analysis
+
+ markdownlint:
+ runs-on: ubuntu-latest
+ name: markdownlint
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ - name: Get yarn cache directory path
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+ - name: Cache yarn packages
+ uses: actions/cache@v2
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+ - name: Yarn install
+ uses: actions/setup-node@v2
+ with:
+ node-version: '20'
+ - run: yarn install
+ - name: markdownlint
+ run: yarn coding-standards-check/markdownlint
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d8a7996
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+composer.lock
+vendor/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..07231e3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+- First version of the module
+
+[Unreleased]: https://github.com/OS2web/os2web_audit/compare/develop...HEAD
diff --git a/README.md b/README.md
index 4607955..95f75ea 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,49 @@
-# OS2Form Audit Module
+# OS2Web Audit
-OS2Form Audit is a Drupal module that helps track changes and perform audit on
-OS2Form events.
-
-## Requirements
--
-- PHP 8.1 or higher
-- Drupal 8 or 9
-- Composer for managing PHP dependencies
+This audit module can be used to track changes and perform audit logging on
+drupal sites.
## Features
--
-- Detailed audit log entries.
-- Support for all OS2Form entity types.
-- Easily extendable for custom use case.
-## Installation
+This module includes three plugins that facilitate logging information to Loki,
+files, or to the database through Drupal's watchdog logger.
-### Composer
+These logging providers are designed using Drupal's plugin APIs. Consequently,
+it opens up possibilities for creating new AuditLogger plugins within other
+modules, thus enhancing the functionality of this audit logging.
-### Drush
+## Installation
+
+Enable the module and go to the modules setting page at
+`/admin/config/os2web_audit/settings/`.
-### Admin UI
+```shell
+composer require os2web/os2web_audit
+drush pm:enable os2web_audit
+```
-Alternatively, you can enable this module via Drupal admin UI by visiting the
-extent page (`/admin/modules`).
+### Drush
-## Configuration
+The module provides a Drush command named audit:log. This command enables you
+to log a test message to the configured logger. The audit:log command accepts a
+string that represents the message to be logged.
-Navigate to `/admin/config/os2form/audit` to configure the module.
+The message provided, will be logged twice, once as an informational message
+and once as an error message.
-Some additional (brief) information on Usage, Extending/Overriding, and
-Maintainers could go here.
+```shell
+drush audit:log 'This is a test message'
+```
## Usage
-Describe how to use this module after installation.
+The module exposes a simple `Logger` service which can log an `info` and `error`
+messages.
-## Extending and Overriding
+Inject the logger service named `os2web_audit.logger` and send messages into the
+logger as shown below:
-Describe how to extend or override this module's functionality.
+```php
+$msg = sprintf('Fetch personal data from service with parameter: %s', $param);
+$this->auditLogger->info('Lookup', $msg);
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..8e1325b
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,64 @@
+{
+ "name": "os2web/os2web_audit",
+ "type": "drupal-module",
+ "description": "Drupal OS2 module that provides audit logging for Danish Municipalities",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "license": "EUPL-1.2",
+ "repositories": {
+ "drupal": {
+ "type": "composer",
+ "url": "https://packages.drupal.org/8"
+ },
+ "assets": {
+ "type": "composer",
+ "url": "https://asset-packagist.org"
+ }
+ },
+ "require": {
+ "ext-curl": "*",
+ "php": "^8.1",
+ "drush/drush": "^11.6"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1",
+ "drupal/coder": "^8.3",
+ "mglaman/phpstan-drupal": "^1.1",
+ "phpstan/extension-installer": "^1.3",
+ "phpstan/phpstan-deprecation-rules": "^1.1",
+ "phpunit/phpunit": "^9.5"
+ },
+ "extra" : {
+ "composer-exit-on-patch-failure": false,
+ "enable-patching" : true,
+ "patches": {
+ }
+ },
+ "scripts": {
+ "code-analysis/phpstan": [
+ "phpstan analyse"
+ ],
+ "code-analysis": [
+ "@code-analysis/phpstan"
+ ],
+ "coding-standards-check/phpcs": [
+ "phpcs --standard=phpcs.xml.dist"
+ ],
+ "coding-standards-check": [
+ "@coding-standards-check/phpcs"
+ ],
+ "coding-standards-apply/phpcs": [
+ "phpcbf --standard=phpcs.xml.dist"
+ ],
+ "coding-standards-apply": [
+ "@coding-standards-apply/phpcs"
+ ]
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "phpstan/extension-installer": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ }
+}
diff --git a/os2web_audit.info.yml b/os2web_audit.info.yml
new file mode 100644
index 0000000..fddb829
--- /dev/null
+++ b/os2web_audit.info.yml
@@ -0,0 +1,5 @@
+name: "OS2web Audit"
+description: 'OS2web Audit Module (log events in the system)'
+type: module
+core_version_requirement: ^8 || ^9 || ^10
+configure: os2web_audit.plugin_settings_local_tasks
diff --git a/os2web_audit.links.menu.yml b/os2web_audit.links.menu.yml
new file mode 100644
index 0000000..c6d73da
--- /dev/null
+++ b/os2web_audit.links.menu.yml
@@ -0,0 +1,5 @@
+os2web_audit.admin_settings:
+ title: 'OS2web Audit settings'
+ parent: system.admin_config_system
+ description: 'Settings for the OS2 Audit module'
+ route_name: os2web_audit.plugin_settings_local_tasks
diff --git a/os2web_audit.links.task.yml b/os2web_audit.links.task.yml
new file mode 100644
index 0000000..31e7570
--- /dev/null
+++ b/os2web_audit.links.task.yml
@@ -0,0 +1,5 @@
+os2web_audit.plugin_settings_tasks:
+ title: 'OS2web Audit settings'
+ route_name: os2web_audit.plugin_settings_local_tasks
+ base_route: os2web_audit.plugin_settings_local_tasks
+ deriver: Drupal\os2web_audit\Plugin\Derivative\LocalTask
diff --git a/os2web_audit.routing.yml b/os2web_audit.routing.yml
new file mode 100644
index 0000000..cd46d48
--- /dev/null
+++ b/os2web_audit.routing.yml
@@ -0,0 +1,8 @@
+os2web_audit.plugin_settings_local_tasks:
+ path: '/admin/config/os2web_audit/settings/{type}'
+ defaults:
+ _controller: '\Drupal\os2web_audit\Controller\LocalTasksController::dynamicTasks'
+ _title: 'OS2web Audit settings'
+ type: ''
+ requirements:
+ _permission: 'administer site'
diff --git a/os2web_audit.services.yml b/os2web_audit.services.yml
new file mode 100644
index 0000000..e98a186
--- /dev/null
+++ b/os2web_audit.services.yml
@@ -0,0 +1,14 @@
+services:
+ plugin.manager.os2web_audit_logger:
+ class: Drupal\os2web_audit\Plugin\LoggerManager
+ parent: default_plugin_manager
+
+ os2web_audit.logger:
+ class: Drupal\os2web_audit\Service\Logger
+ arguments: ['@plugin.manager.os2web_audit_logger', '@config.factory', '@current_user', '@logger.factory']
+
+ os2web_audit.commands:
+ class: Drupal\os2web_audit\Commands\AuditLogDrushCommands
+ arguments: ['@os2web_audit.logger']
+ tags:
+ - { name: drush.command }
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..52fcd34
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "license": "UNLICENSED",
+ "private": true,
+ "devDependencies": {
+ "markdownlint-cli": "^0.32.2"
+ },
+ "scripts": {
+ "coding-standards-check/markdownlint": "yarn markdownlint --ignore LICENSE.md --ignore vendor --ignore node_modules '*.md' 'modules/os2forms_digital_post/**/*.md'",
+ "coding-standards-check": "yarn coding-standards-check/markdownlint",
+ "coding-standards-apply/markdownlint": "yarn markdownlint --ignore LICENSE.md --ignore vendor --ignore node_modules '*.md' 'modules/os2forms_digital_post/**/*.md' --fix",
+ "coding-standards-apply": "yarn coding-standards-apply/markdownlint"
+ }
+}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..e6cd9bb
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,23 @@
+
+
+ OS2web Audit PHP Code Sniffer configuration
+
+ .
+ vendor/
+ node_modules/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..2715fef
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,28 @@
+parameters:
+ level: 6
+ paths:
+ - ./
+ excludePaths:
+ # @see https://github.com/mglaman/drupal-check/issues/261#issuecomment-1030141772/
+ - vendor
+ - '*/node_modules/*'
+ ignoreErrors:
+ # This is how drupal works....
+ - '#Unsafe usage of new static\(\).#'
+ - '#getEditableConfigNames\(\) return type has no value type specified in iterable type array#'
+ - '#buildForm\(\) has parameter \$form with no value type specified in iterable type array.#'
+ - '#buildForm\(\) return type has no value type specified in iterable type array.#'
+ - '#validateForm\(\) has parameter \$form with no value type specified in iterable type array.#'
+ - '#submitForm\(\) has parameter \$form with no value type specified in iterable type array.#'
+ - '#getDerivativeDefinitions\(\) has parameter \$base_plugin_definition with no value type specified in iterable type array.#'
+ - '#getDerivativeDefinitions\(\) return type has no value type specified in iterable type array.#'
+ - '#LoggerManager::__construct\(\) has parameter \$namespaces with no value type specified in iterable type Traversable.#'
+ - '#__construct\(\) has parameter \$configuration with no value type specified in iterable type array.#'
+ - '#getConfiguration\(\) return type has no value type specified in iterable type array.#'
+ - '#setConfiguration\(\) has parameter \$configuration with no value type specified in iterable type array.#'
+ - '#defaultConfiguration\(\) return type has no value type specified in iterable type array.#'
+ - '#buildConfigurationForm\(\) has parameter \$form with no value type specified in iterable type array.#'
+ - '#buildConfigurationForm\(\) return type has no value type specified in iterable type array.#'
+ - '#validateConfigurationForm\(\) has parameter \$form with no value type specified in iterable type array.#'
+ - '#submitConfigurationForm\(\) has parameter \$form with no value type specified in iterable type array.#'
+ - '#getForm\(\) invoked with 2 parameters, 1 required.#'
diff --git a/scripts/code-analysis b/scripts/code-analysis
new file mode 100755
index 0000000..f3544cd
--- /dev/null
+++ b/scripts/code-analysis
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+
+script_dir=$(pwd)
+module_name=$(basename "$script_dir")
+drupal_dir=vendor/drupal-module-code-analysis
+# Relative to $drupal_dir
+module_path=web/modules/contrib/$module_name
+
+cd "$script_dir" || exit
+
+drupal_composer() {
+ composer --working-dir="$drupal_dir" --no-interaction "$@"
+}
+
+# Create new Drupal 9 project
+if [ ! -f "$drupal_dir/composer.json" ]; then
+ composer --no-interaction create-project drupal/recommended-project:^9 "$drupal_dir"
+fi
+# Copy our code into the modules folder
+mkdir -p "$drupal_dir/$module_path"
+# https://stackoverflow.com/a/15373763
+rsync --archive --compress . --filter=':- .gitignore' --exclude "$drupal_dir" --exclude .git "$drupal_dir/$module_path"
+
+drupal_composer config minimum-stability dev
+
+# Allow ALL plugins
+# https://getcomposer.org/doc/06-config.md#allow-plugins
+drupal_composer config --no-plugins allow-plugins true
+
+drupal_composer require wikimedia/composer-merge-plugin
+drupal_composer config extra.merge-plugin.include "$module_path/composer.json"
+# https://www.drupal.org/project/drupal/issues/3220043#comment-14845434
+drupal_composer require --dev symfony/phpunit-bridge
+
+# Run PHPStan
+(cd "$drupal_dir" && vendor/bin/phpstan --configuration="$module_path/phpstan.neon")
diff --git a/src/Annotation/AuditLoggerProvider.php b/src/Annotation/AuditLoggerProvider.php
new file mode 100644
index 0000000..9dc966c
--- /dev/null
+++ b/src/Annotation/AuditLoggerProvider.php
@@ -0,0 +1,44 @@
+auditLogger->info('test', $log_message, FALSE, ['from' => 'drush']);
+ $this->auditLogger->error('test', $log_message, TRUE, ['from' => 'drush']);
+ }
+
+}
diff --git a/src/Controller/LocalTasksController.php b/src/Controller/LocalTasksController.php
new file mode 100644
index 0000000..46a1dbf
--- /dev/null
+++ b/src/Controller/LocalTasksController.php
@@ -0,0 +1,58 @@
+formBuilder = $formBuilder;
+ $this->configFactory = $configFactory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container): LocalTasksController|static {
+ return new static(
+ $container->get('form_builder'),
+ $container->get('config.factory'),
+ );
+ }
+
+ /**
+ * Get dynamic tasks.
+ *
+ * @param string|null $type
+ * The type of form to retrieve. Defaults to NULL.
+ *
+ * @return array
+ * An array containing the form definition.
+ */
+ public function dynamicTasks(string $type = NULL): array {
+ if (empty($type)) {
+ return $this->formBuilder->getForm('\Drupal\os2web_audit\Form\SettingsForm');
+ }
+
+ return $this->formBuilder->getForm('\Drupal\os2web_audit\Form\PluginSettingsForm', $type);
+ }
+
+}
diff --git a/src/Exception/AuditException.php b/src/Exception/AuditException.php
new file mode 100644
index 0000000..1ebd9ee
--- /dev/null
+++ b/src/Exception/AuditException.php
@@ -0,0 +1,37 @@
+pluginName = $pluginName;
+ }
+ }
+
+ /**
+ * Name of the plugin that started the exception.
+ *
+ * @return string
+ * Name of the plugin if given else "Unknown plugin".
+ */
+ public function getPluginName(): string {
+ return $this->pluginName;
+ }
+
+}
diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php
new file mode 100644
index 0000000..4d89181
--- /dev/null
+++ b/src/Exception/ConnectionException.php
@@ -0,0 +1,11 @@
+manager = $manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container): static {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('plugin.manager.os2web_audit_logger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getConfigName(): string {
+ return 'os2web_audit.plugin_settings';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames(): array {
+ return [$this->getConfigName()];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return $this->getConfigName() . '_settings_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+ $plugin_id = $form_state->getBuildInfo()['args'][0];
+ $instance = $this->getPluginInstance($plugin_id);
+ $form = $instance->buildConfigurationForm($form, $form_state);
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state): void {
+ $plugin_id = $form_state->getBuildInfo()['args'][0];
+ $instance = $this->getPluginInstance($plugin_id);
+ $instance->validateConfigurationForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state): void {
+ $plugin_id = $form_state->getBuildInfo()['args'][0];
+ $instance = $this->getPluginInstance($plugin_id);
+ $instance->submitConfigurationForm($form, $form_state);
+
+ $config = $this->config($this->getConfigName());
+ $config->set($plugin_id, $instance->getConfiguration());
+ $config->save();
+
+ parent::submitForm($form, $form_state);
+ }
+
+ /**
+ * Returns plugin instance for a given plugin id.
+ *
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ *
+ * @return object
+ * Plugin instance.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\PluginException
+ */
+ public function getPluginInstance(string $plugin_id): object {
+ $configuration = $this->config($this->getConfigName())->get($plugin_id);
+
+ return $this->manager->createInstance($plugin_id, $configuration ?? []);
+ }
+
+}
diff --git a/src/Form/PluginSettingsFormInterface.php b/src/Form/PluginSettingsFormInterface.php
new file mode 100644
index 0000000..c8921d0
--- /dev/null
+++ b/src/Form/PluginSettingsFormInterface.php
@@ -0,0 +1,20 @@
+get('config.factory'),
+ $container->get('plugin.manager.os2web_audit_logger')
+ );
+ }
+
+ /**
+ * The name of the configuration setting.
+ *
+ * @var string
+ */
+ public static string $configName = 'os2web_audit.settings';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames(): array {
+ return [self::$configName];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId(): string {
+ return 'os2web_audit_admin_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+ $config = $this->config(self::$configName);
+
+ $plugins = $this->loggerManager->getDefinitions();
+ ksort($plugins);
+ $options = array_map(function ($plugin) {
+ /** @var \Drupal\Core\StringTranslation\TranslatableMarkup $title */
+ $title = $plugin['title'];
+ return $title->render();
+ }, $plugins);
+
+ $form['provider'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Log provider'),
+ '#description' => $this->t('Select the logger provider you which to use'),
+ '#options' => $options,
+ '#default_value' => $config->get('provider'),
+ ];
+
+ 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('provider', $form_state->getValue('provider'))
+ ->save();
+ }
+
+}
diff --git a/src/Plugin/AuditLogger/AuditLoggerInterface.php b/src/Plugin/AuditLogger/AuditLoggerInterface.php
new file mode 100644
index 0000000..d76eaeb
--- /dev/null
+++ b/src/Plugin/AuditLogger/AuditLoggerInterface.php
@@ -0,0 +1,32 @@
+ $metadata
+ * Additional metadata associated with the log entry. Defaults to an empty
+ * array.
+ *
+ * @throws \Drupal\os2web_audit\Exception\ConnectionException
+ * If unable to connect to the Loki endpoint.
+ * @throws \Drupal\os2web_audit\Exception\AuditException
+ * Errors in logging the packet.
+ */
+ public function log(string $type, int $timestamp, string $message, array $metadata = []): void;
+
+}
diff --git a/src/Plugin/AuditLogger/File.php b/src/Plugin/AuditLogger/File.php
new file mode 100644
index 0000000..e0eb7ae
--- /dev/null
+++ b/src/Plugin/AuditLogger/File.php
@@ -0,0 +1,101 @@
+setConfiguration($configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function log(string $type, int $timestamp, string $message, array $metadata = []): void {
+ // Code to write the entity to a file.
+ // This is just a placeholder and won't write the data.
+ file_put_contents(
+ $this->configuration['file'],
+ json_encode([
+ 'type' => $type,
+ 'epoc' => $timestamp,
+ 'line' => $message,
+ 'metadata' => $metadata,
+ ]) . PHP_EOL,
+ FILE_APPEND | LOCK_EX);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfiguration(): array {
+ return $this->configuration;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setConfiguration(array $configuration): static {
+ $this->configuration = $configuration + $this->defaultConfiguration();
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function defaultConfiguration(): array {
+ return [
+ 'file' => '/tmp/audit.log',
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
+
+ $form['file'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('The complete path and name of the file where log entries are stored.'),
+ '#default_value' => $this->configuration['file'],
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
+ // @todo Implement validateConfigurationForm() method.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
+ if (!$form_state->getErrors()) {
+ $values = $form_state->getValues();
+ $configuration = [
+ 'file' => $values['file'],
+ ];
+ $this->setConfiguration($configuration);
+ }
+ }
+
+}
diff --git a/src/Plugin/AuditLogger/Loki.php b/src/Plugin/AuditLogger/Loki.php
new file mode 100644
index 0000000..cfd5627
--- /dev/null
+++ b/src/Plugin/AuditLogger/Loki.php
@@ -0,0 +1,160 @@
+setConfiguration($configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws \Drupal\os2web_audit\Exception\ConnectionException
+ * If unable to connect to the Loki endpoint.
+ * @throws \Drupal\os2web_audit\Exception\AuditException
+ * Errors in logging the packet.
+ */
+ public function log(string $type, int $timestamp, string $message, array $metadata = []): void {
+ $client = new LokiClient([
+ 'entrypoint' => $this->configuration['entrypoint'],
+ 'auth' => $this->configuration['auth'],
+ ]);
+
+ // Add 'identity' to metadata to be able to filter out all messages from
+ // this site in loki.
+ if (!empty($this->configuration['identity'])) {
+ $metadata['identity'] = $this->configuration['identity'];
+ }
+
+ // Convert timestamp to nanoseconds.
+ $client->send($type, $timestamp * 1000000000, $message, $metadata);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfiguration(): array {
+ return $this->configuration;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setConfiguration(array $configuration): static {
+ $this->configuration = $configuration + $this->defaultConfiguration();
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function defaultConfiguration(): array {
+ return [
+ 'entrypoint' => 'http://loki:3100',
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
+ $form['entrypoint'] = [
+ '#type' => 'url',
+ '#title' => $this->t('Entry Point URL'),
+ '#required' => TRUE,
+ '#default_value' => $this->configuration['entrypoint'],
+ ];
+
+ $form['auth'] = [
+ '#tree' => TRUE,
+ 'username' => [
+ '#type' => 'textfield',
+ '#title' => $this->t('Username'),
+ '#default_value' => $this->configuration['auth']['username'],
+ ],
+ 'password' => [
+ '#type' => 'password',
+ '#title' => $this->t('Password'),
+ '#default_value' => $this->configuration['auth']['password'],
+ ],
+ ];
+
+ $form['identity'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Identity'),
+ '#default_value' => $this->configuration['identity'],
+ '#description' => $this->t('A string that will be attached to every log sendt to loki'),
+ ];
+
+ $form['curl_options'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('cURL Options'),
+ '#default_value' => $this->configuration['curl_options'],
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
+ $values = $form_state->getValues();
+
+ // Validate entrypoint.
+ if (filter_var($values['entrypoint'], FILTER_VALIDATE_URL) === FALSE) {
+ $form_state->setErrorByName('entrypoint', $this->t('Invalid URL.'));
+ }
+
+ $curlOptions = array_filter(explode(',', $values['curl_options']));
+ foreach ($curlOptions as $option) {
+ [$key] = explode(' =>', $option);
+ $key = trim($key);
+ if (!(str_starts_with($key, 'CURLOPT') && defined($key))) {
+ $form_state->setErrorByName('curl_options', $this->t('%option is not a valid cURL option.', ['%option' => $key]));
+ break;
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
+ if (!$form_state->getErrors()) {
+ $values = $form_state->getValues();
+ $configuration = [
+ 'entrypoint' => $values['entrypoint'],
+ 'auth' => [
+ 'username' => $values['auth']['username'],
+ 'password' => $values['auth']['password'],
+ ],
+ 'identity' => $values['identity'],
+ 'curl_options' => $values['curl_options'],
+ ];
+ $this->setConfiguration($configuration);
+ }
+ }
+
+}
diff --git a/src/Plugin/AuditLogger/Watchdog.php b/src/Plugin/AuditLogger/Watchdog.php
new file mode 100644
index 0000000..b368bb9
--- /dev/null
+++ b/src/Plugin/AuditLogger/Watchdog.php
@@ -0,0 +1,44 @@
+logger->get('os2web_audit')->info('%type: %line (%data)', [
+ 'type' => $type,
+ 'line' => $message,
+ 'data' => $data,
+ ]);
+ }
+
+}
diff --git a/src/Plugin/Derivative/LocalTask.php b/src/Plugin/Derivative/LocalTask.php
new file mode 100644
index 0000000..c9858db
--- /dev/null
+++ b/src/Plugin/Derivative/LocalTask.php
@@ -0,0 +1,64 @@
+get('plugin.manager.os2web_audit_logger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws \ReflectionException
+ */
+ public function getDerivativeDefinitions($base_plugin_definition): array {
+ $plugins = $this->loggerManager->getDefinitions();
+ ksort($plugins);
+
+ // Sadly, it seems that it is not possible to just invalidate the
+ // deriver/menu cache stuff. To get the local tasks menu links. So instead
+ // of clearing all caches on settings save to only show selected plugins, we
+ // show em all.
+ $options = array_map(function ($plugin) {
+ // Only the plugins that provide configuration options.
+ $reflector = new \ReflectionClass($plugin['class']);
+ if ($reflector->implementsInterface('Drupal\Component\Plugin\ConfigurableInterface')) {
+ /** @var \Drupal\Core\StringTranslation\TranslatableMarkup $title */
+ $title = $plugin['title'];
+ return $title->render();
+ }
+ }, $plugins);
+
+ foreach (['settings' => 'Settings'] + $options as $plugin => $title) {
+ $this->derivatives[$plugin] = $base_plugin_definition;
+ $this->derivatives[$plugin]['title'] = $title;
+ $this->derivatives[$plugin]['route_parameters'] = ['type' => $plugin];
+ if ($plugin === 'settings') {
+ $this->derivatives[$plugin]['route_parameters']['type'] = '';
+ }
+ }
+
+ return $this->derivatives;
+ }
+
+}
diff --git a/src/Plugin/LoggerManager.php b/src/Plugin/LoggerManager.php
new file mode 100644
index 0000000..751864a
--- /dev/null
+++ b/src/Plugin/LoggerManager.php
@@ -0,0 +1,34 @@
+alterInfo('os2web_audit_logger_info');
+ $this->setCacheBackend($cache_backend, 'os2web_audit_logger_plugins');
+ }
+
+}
diff --git a/src/Service/Logger.php b/src/Service/Logger.php
new file mode 100644
index 0000000..8101a05
--- /dev/null
+++ b/src/Service/Logger.php
@@ -0,0 +1,110 @@
+ $metadata
+ * Additional metadata for the log message. Default is an empty array.
+ */
+ public function info(string $type, string $line, bool $logUser = TRUE, array $metadata = []): void {
+ $this->log($type, time(), $line, $logUser, $metadata + ['level' => 'info']);
+ }
+
+ /**
+ * Logs a message at error level.
+ *
+ * @param string $type
+ * The type of event to log (auth, lookup etc.)
+ * @param string $line
+ * The log message.
+ * @param bool $logUser
+ * Log information about the current logged-in user (need to track who has
+ * lookup information in external services). Default: false.
+ * @param array $metadata
+ * Additional metadata for the log message. Default is an empty array.
+ */
+ public function error(string $type, string $line, bool $logUser = TRUE, array $metadata = []): void {
+ $this->log($type, time(), $line, $logUser, $metadata + ['level' => 'error']);
+ }
+
+ /**
+ * Logs a message using a plugin-specific logger.
+ *
+ * @param string $type
+ * The type of event to log (auth, lookup etc.)
+ * @param int $timestamp
+ * The timestamp for the log message.
+ * @param string $line
+ * The log message.
+ * @param bool $logUser
+ * Log information about the current logged-in user (need to track who has
+ * lookup information in external services). Default: false.
+ * @param array $metadata
+ * Additional metadata for the log message. Default is an empty array.
+ */
+ private function log(string $type, int $timestamp, string $line, bool $logUser = FALSE, array $metadata = []): void {
+ $config = $this->configFactory->get(SettingsForm::$configName);
+ $plugin_id = $config->get('provider');
+ $configuration = $this->configFactory->get(PluginSettingsForm::getConfigName())->get($plugin_id);
+
+ if ($logUser) {
+ // Add user id to the log message metadata.
+ $metadata['userId'] = $this->currentUser->getEmail();
+ }
+
+ try {
+ /** @var \Drupal\os2web_audit\Plugin\AuditLogger\AuditLoggerInterface $logger */
+ $logger = $this->loggerManager->createInstance($plugin_id, $configuration ?? []);
+ $logger->log($type, $timestamp, $line, $metadata);
+ }
+ catch (PluginException $e) {
+ $this->watchdog->get('os2web_audit')->error($e->getMessage());
+ }
+ catch (AuditException | ConnectionException $e) {
+ // Change metadata into string.
+ $data = implode(', ', array_map(function ($key, $value) {
+ return $key . " => " . $value;
+ }, array_keys($metadata), $metadata));
+
+ // Fallback to send log message info watchdog.
+ $msg = sprintf("Plugin: %s, Type: %s, Msg: %s, Metadata: %s", $e->getPluginName(), $type, $line, $data);
+ $this->watchdog->get('os2web_audit')->info($msg);
+ $this->watchdog->get('os2web_audit_error')->error($e->getMessage());
+ }
+ }
+
+}
diff --git a/src/Service/LokiClient.php b/src/Service/LokiClient.php
new file mode 100644
index 0000000..1b35df5
--- /dev/null
+++ b/src/Service/LokiClient.php
@@ -0,0 +1,182 @@
+
+ */
+ protected array $basicAuth = [];
+
+ /**
+ * Custom options for CURL command.
+ *
+ * @var array
+ */
+ protected array $customCurlOptions = [];
+
+ /**
+ * Curl handler.
+ *
+ * @var \CurlHandle|null
+ */
+ private ?\CurlHandle $connection = NULL;
+
+ /**
+ * Default constructor.
+ *
+ * @param array> $apiConfig
+ * Configuration for the loki connection.
+ */
+ public function __construct(
+ array $apiConfig,
+ ) {
+ $this->entrypoint = $this->getEntrypoint($apiConfig['entrypoint']);
+ $this->customCurlOptions = $apiConfig['curl_options'] ?? [];
+
+ if (isset($apiConfig['auth']) && !empty($apiConfig['auth']['username']) && !empty($apiConfig['auth']['password'])) {
+ $this->basicAuth = $apiConfig['auth'];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws \Drupal\os2web_audit\Exception\ConnectionException
+ * If unable to connect to the Loki endpoint.
+ * @throws \Drupal\os2web_audit\Exception\AuditException
+ * Errors in logging the packet.
+ */
+ public function send(string $label, int $epoch, string $line, array $metadata = []): void {
+ $packet = [
+ 'streams' => [
+ [
+ 'stream' => [
+ 'type' => $label,
+ ],
+ 'values' => [
+ [(string) $epoch, $line],
+ ],
+ ],
+ ],
+ ];
+
+ if (!empty($metadata)) {
+ $packet['streams'][0]['stream'] += $metadata;
+ }
+
+ $this->sendPacket($packet);
+ }
+
+ /**
+ * Ensure the URL to entry point is correct.
+ *
+ * @param string $entrypoint
+ * Entry point URL.
+ *
+ * @return string
+ * The entry point URL formatted without a slash in the ending.
+ */
+ private function getEntrypoint(string $entrypoint): string {
+ if (!str_ends_with($entrypoint, '/')) {
+ return $entrypoint;
+ }
+
+ return substr($entrypoint, 0, -1);
+ }
+
+ /**
+ * Send a packet to the Loki ingestion endpoint.
+ *
+ * @param array $packet
+ * The packet to send.
+ *
+ * @throws \Drupal\os2web_audit\Exception\ConnectionException
+ * If unable to connect to the Loki endpoint.
+ * @throws \Drupal\os2web_audit\Exception\AuditException
+ * Errors in logging the packet.
+ */
+ private function sendPacket(array $packet): void {
+ try {
+ $payload = json_encode($packet, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+ catch (\JsonException $e) {
+ throw new AuditException(
+ message: 'Payload could not be encoded.',
+ previous: $e,
+ pluginName: 'Loki',
+ );
+ }
+
+ if (NULL === $this->connection) {
+ $url = sprintf('%s/loki/api/v1/push', $this->entrypoint);
+ $this->connection = curl_init($url);
+
+ if (FALSE === $this->connection) {
+ throw new ConnectionException(
+ message: 'Unable to connect to ' . $url,
+ pluginName: 'Loki',
+ );
+ }
+ }
+
+ if (FALSE !== $this->connection) {
+ $curlOptions = array_replace(
+ [
+ CURLOPT_CONNECTTIMEOUT_MS => 500,
+ CURLOPT_TIMEOUT_MS => 200,
+ CURLOPT_CUSTOMREQUEST => 'POST',
+ CURLOPT_RETURNTRANSFER => TRUE,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'Content-Length: ' . strlen($payload),
+ ],
+ ],
+ $this->customCurlOptions
+ );
+
+ if (!empty($this->basicAuth)) {
+ $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
+ $curlOptions[CURLOPT_USERPWD] = implode(':', $this->basicAuth);
+ }
+
+ curl_setopt_array($this->connection, $curlOptions);
+ $result = curl_exec($this->connection);
+
+ if (FALSE === $result) {
+ throw new ConnectionException(
+ message: 'Error sending packet to Loki',
+ pluginName: 'Loki',
+ );
+ }
+
+ if (curl_errno($this->connection)) {
+ throw new AuditException(
+ message: curl_error($this->connection),
+ code: curl_errno($this->connection),
+ pluginName: 'Loki',
+ );
+ }
+ }
+ }
+
+}
diff --git a/src/Service/LokiClientInterface.php b/src/Service/LokiClientInterface.php
new file mode 100644
index 0000000..44ef364
--- /dev/null
+++ b/src/Service/LokiClientInterface.php
@@ -0,0 +1,40 @@
+", ""]
+ * ]
+ * }
+ * ]
+ * }
+ *
+ * @param string $label
+ * Loki global label to use.
+ * @param int $epoch
+ * Unix epoch in nanoseconds.
+ * @param string $line
+ * The log line to send.
+ * @param array $metadata
+ * Extra labels/metadata to filter on.
+ *
+ * @see https://grafana.com/docs/loki/latest/reference/api/#ingest-logs
+ */
+ public function send(string $label, int $epoch, string $line, array $metadata = []): void;
+
+}