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; + +}