Skip to content

Monitoring & Alerting ​

The plugin includes a real-time monitoring system that can detect suspicious activities and send notifications through various channels (email, webhooks, logs, etc.).

Table filters must match the stored source alias exactly, including case and any plugin prefix. Monitoring runs for persisted rows from TablePersister; it does not observe bulk SQL changes that bypass logging.

How It Works ​

The monitoring system:

  1. Listens to audit log events in real time
  2. Checks each log against configured rules
  3. Triggers alerts when rules match
  4. Sends notifications through configured channels

Configuration ​

Enable monitoring and configure rules in your config/app.php or config/app_local.php:

php
'AuditStash' => [
    'monitor' => [
        'enabled' => true,

        // Define monitoring rules
        'rules' => [
            // Detect mass deletions
            'mass_delete' => [
                'class' => \AuditStash\Monitor\Rule\MassDeleteRule::class,
                'threshold' => 10,              // Trigger if 10+ deletes
                'timeframe' => 300,             // Within 5 minutes (seconds)
                'tables' => ['Users', 'Orders'], // Only for these tables (optional)
                'severity' => 'critical',
                'channels' => ['email', 'log'],  // Send to these channels
            ],

            // Detect activity outside business hours
            'off_hours' => [
                'class' => \AuditStash\Monitor\Rule\UnusualTimeActivityRule::class,
                'business_hours' => ['start' => '08:00', 'end' => '18:00'],
                'business_days' => [1, 2, 3, 4, 5], // Monday-Friday (1=Monday, 7=Sunday)
                'tables' => ['FinancialRecords', 'Payroll'],
                'severity' => 'medium',
                'channels' => ['log', 'email'],
            ],

            // Alert when sensitive fields are touched
            'sensitive_fields' => [
                'class' => \AuditStash\Monitor\Rule\SensitiveFieldRule::class,
                'tables' => ['Users', 'ApiKeys'],         // Optional; empty = all sources
                'fields' => ['password', 'role', 'two_factor_secret', 'api_token'],
                'severity' => 'high',
                'channels' => ['log', 'email'],
            ],
        ],

        // Configure notification channels
        'channels' => [
            'email' => [
                'class' => \AuditStash\Monitor\Channel\EmailChannel::class,
                'to' => ['security@example.com', 'admin@example.com'],
                'from' => 'audit@example.com',
                'template' => 'AuditStash.audit_alert', // Email template (default)
            ],

            'webhook' => [
                'class' => \AuditStash\Monitor\Channel\WebhookChannel::class,
                'url' => 'https://hooks.example.com/audit-alerts',
                'headers' => [
                    'Authorization' => 'Bearer your-token-here',
                ],
                'retry' => 3, // Total HTTP attempts
            ],

            'log' => [
                'class' => \AuditStash\Monitor\Channel\LogChannel::class,
                'scope' => 'audit_alerts', // Log scope
            ],
        ],
    ],
],

Built-in Rules ​

MassDeleteRule ​

Detects when multiple delete operations occur within a short timeframe.

Configuration options:

  • threshold (int): Number of deletes to trigger alert (default: 10)
  • timeframe (int): Time window in seconds (default: 300)
  • tables (array): Specific tables to monitor (optional, monitors all if not set)
  • severity (string): Alert severity level (default: 'critical')

UnusualTimeActivityRule ​

Detects activity outside normal business hours.

Configuration options:

  • business_hours (array): Start and end times (default: 08:00-18:00)
    php
    ['start' => '08:00', 'end' => '18:00'];
  • business_days (array): Days of week to consider business days (default: [1,2,3,4,5])
    • 1 = Monday, 7 = Sunday
  • tables (array): Specific tables to monitor (optional)
  • severity (string): Alert severity level (default: 'medium')

SensitiveFieldRule ​

Detects modifications to fields that you've flagged as sensitive (passwords, tokens, role assignments, etc.). Looks at the changed payload for create / update events and at original for deletes, so password rotations and API-key revocations both fire.

Configuration options:

  • fields (array, required): Field names to flag. Empty means "no sensitive fields configured": the rule never matches.
  • tables (array): Restrict to these source tables. Empty (default) matches any source.
  • severity (string): Alert severity level (default: 'high')

Example:

php
'sensitive_fields' => [
    'class' => \AuditStash\Monitor\Rule\SensitiveFieldRule::class,
    'tables' => ['Users'],
    'fields' => ['password', 'role', 'email'],
    'severity' => 'high',
    'channels' => ['email', 'webhook'],
],

The alert message lists the matched fields and the alert context includes both matched_fields and configured_fields, so downstream channels can render which sensitive field actually fired.

Available Channels ​

EmailChannel ​

Sends alerts via email using CakePHP's Mailer.

Configuration:

  • to (string|array): Recipient email address(es)
  • from (string): Sender email address
  • template (string): Email template name (default: 'AuditStash.audit_alert')

Email Templates:

The plugin includes default HTML and text templates at:

  • templates/email/html/audit_alert.php
  • templates/email/text/audit_alert.php

You can override these by creating your own templates in your app's templates/email/ directory.

WebhookChannel ​

Posts alert data to an external URL as JSON.

Configuration:

  • url (string, required): Webhook URL
  • headers (array): Additional HTTP headers
  • retry (int): Total HTTP attempts, including the first (default: 1)

Payload format:

json
{
    "rule_name": "MassDelete",
    "severity": "critical",
    "message": "Mass deletion detected: 15 records deleted from users in the last 5 minute(s)",
    "audit_log": {
        "id": 123,
        "type": "delete",
        "source": "users",
        "primary_key": 456,
        "transaction_key": "550e8400-e29b-41d4-a716-446655440000",
        "created": "2024-03-15T14:30:00+00:00"
    },
    "context": {
        "threshold": 10,
        "timeframe_seconds": 300,
        "delete_count": 15,
        "table": "users"
    }
}

LogChannel ​

Writes alerts to CakePHP log files.

Configuration:

  • scope (string): Log scope name (default: 'audit_alerts')

Alerts are written to your configured log with appropriate severity levels:

  • critical → CRITICAL
  • high → ERROR
  • medium → WARNING
  • low → INFO

SlackChannel ​

Posts alerts to a Slack incoming webhook in Block Kit format so the message renders as a native card with header, severity-colored attachment, and structured context fields, instead of the raw JSON dump you'd get from WebhookChannel.

Configuration:

  • url (string, required): Slack incoming webhook URL (https://hooks.slack.com/services/T.../B.../...)
  • username (string): Override the bot display name
  • icon_emoji (string): Override the bot icon (e.g. :rotating_light:)
  • channel (string): Override the destination channel
  • headers (array), retry (int), timeout (int): see WebhookChannel
php
'slack' => [
    'class' => \AuditStash\Monitor\Channel\SlackChannel::class,
    'url' => 'https://hooks.slack.com/services/T/B/secret',
    'username' => 'AuditStash',
    'icon_emoji' => ':rotating_light:',
    'channel' => '#audit-alerts',
],

Slack returns body ok on success and 200 with non-ok body for malformed payloads: the channel verifies the body, not just the status code.

Each alert ends with a View entry in admin → link to the audit log row that triggered it. The URL is built via Router::url() and respects AuditStash.routePath and App.fullBaseUrl, so make sure App.fullBaseUrl is set to the externally-reachable host you want recipients to land on. To omit or customize the link, subclass SlackChannel and override formatPayload().

DiscordChannel ​

Posts alerts to a Discord webhook as an embed with title, color sidebar (decimal RGB derived from severity), and inline fields.

Configuration:

  • url (string, required): Discord webhook URL (https://discord.com/api/webhooks/.../...)
  • username (string): Override the bot display name
  • avatar_url (string): Override the bot avatar
  • headers (array), retry (int), timeout (int): see WebhookChannel
php
'discord' => [
    'class' => \AuditStash\Monitor\Channel\DiscordChannel::class,
    'url' => 'https://discord.com/api/webhooks/.../...',
    'username' => 'AuditStash',
    'avatar_url' => 'https://example.com/audit-bot.png',
],

Discord webhooks normally return 204 No Content on success: the channel accepts both that and any other 2xx status.

The embed title is rendered as a clickable link to the audit log row that triggered the alert, built from Router::url() and therefore respecting AuditStash.routePath and App.fullBaseUrl. To omit or customize the link, subclass DiscordChannel and override formatPayload().

Building your own channel ​

SlackChannel and DiscordChannel both extend AbstractWebhookChannel, which handles the HTTP/retry/error-logging plumbing. To add another platform (Microsoft Teams, Mattermost, Rocket.Chat, etc.), subclass it and implement just formatPayload():

php
namespace App\Monitor\Channel;

use AuditStash\Monitor\Alert;
use AuditStash\Monitor\Channel\AbstractWebhookChannel;

class MattermostChannel extends AbstractWebhookChannel
{
    protected function formatPayload(Alert $alert): array
    {
        return [
            'text' => sprintf('[%s] %s', strtoupper($alert->getSeverity()), $alert->getMessage()),
            // ...whatever Mattermost expects
        ];
    }
}

Override isAcceptable(Response $response) if the target service uses a non-standard success convention (Slack's ok body, Discord's 204 No Content), and createClient() if you need to inject a custom HTTP client (e.g. for tests).

Creating Custom Rules ​

Extend AbstractRule to create custom monitoring rules:

php
namespace App\Monitor\Rule;

use AuditStash\Model\Entity\AuditLog;
use AuditStash\Monitor\Rule\AbstractRule;

class CustomRule extends AbstractRule
{
    public function matches(AuditLog $auditLog): bool
    {
        // Return true if this log should trigger an alert
        return $auditLog->type === 'delete' && $auditLog->source === 'critical_data';
    }

    public function getSeverity(): string
    {
        return $this->getConfig('severity', 'high');
    }

    public function getMessage(AuditLog $auditLog): string
    {
        return sprintf(
            'Critical data deleted: %s',
            $auditLog->primary_key
        );
    }

    public function getContext(AuditLog $auditLog): array
    {
        return [
            'table' => $auditLog->source,
            'id' => $auditLog->primary_key,
        ];
    }
}

Then configure it:

php
'AuditStash' => [
    'monitor' => [
        'rules' => [
            'custom' => [
                'class' => \App\Monitor\Rule\CustomRule::class,
                'severity' => 'high',
                'channels' => ['email'],
            ],
        ],
    ],
],

Creating Custom Channels ​

Implement ChannelInterface::send(Alert $alert): bool for an application-specific transport. Return true only after the transport reports success. For a JSON webhook, extend AbstractWebhookChannel and implement the payload formatter; the base class handles HTTP requests, timeouts, retries, and response checking:

php
namespace App\Monitor\Channel;

use AuditStash\Monitor\Alert;
use AuditStash\Monitor\Channel\AbstractWebhookChannel;

class ApplicationWebhookChannel extends AbstractWebhookChannel
{
    protected function formatPayload(Alert $alert): array
    {
        return [
            'message' => $alert->getMessage(),
            'severity' => $alert->getSeverity(),
        ];
    }
}

Configure this class in AuditStash.monitor.channels with a url, and select that channel in a rule's channels list. Use the bundled Slack or Discord channel for those services' payload formats.

For example, add this channel alongside the others in your configuration:

php
'AuditStash' => [
    'monitor' => [
        'channels' => [
            'application' => [
                'class' => \App\Monitor\Channel\ApplicationWebhookChannel::class,
                'url' => 'https://hooks.example.com/audit-alerts',
            ],
        ],
    ],
],

Select 'application' in the rule's channels list.

Hooking Into the Monitor Lifecycle ​

Channels are the happy-path delivery mechanism. For suppression, alert replacement, or application-specific delivery, the monitor dispatches two CakePHP events around every alert. Listen with EventManager::instance()->on(...) exactly like any other CakePHP event.

EventWhenWhat you can do
AuditStash.Monitor.beforeAlertAfter a rule matches and the Alert has been built, before any channel runs. Carries rule, auditLog, alert.Call $event->stopPropagation() to suppress the alert (channels are skipped, afterAlert is not dispatched). Or replace the alert via $event->setData('alert', $newAlert): the channels and afterAlert will see the replaced instance. The Alert value object itself is immutable; mutation always goes through setData.
AuditStash.Monitor.afterAlertAfter every channel for the rule has been called. Carries rule, auditLog, alert, and results (a [channelName => bool] map of per-channel success).React on success / failure: write to your own incident store, page on partial failures, emit telemetry.

NOTE

Listener exceptions are not caught by the monitor: they propagate to the caller. Silent listener failures hide app bugs, so a buggy listener crashes the request loudly rather than masquerading as a rule failure. Wrap your own listener body in try/catch if you want different behavior.

For rule failures (anything thrown out of a rule's matches() or createAlert(), including Error subclasses like TypeError), the monitor catches the Throwable, logs it with the full exception in context (so Sentry / Monolog handlers attached via Cake\Log\Log capture the stack), and continues with the next rule. No separate event is emitted: the existing log pipeline handles the routing.

Suppress an alert under specific conditions ​

php
EventManager::instance()->on(
    'AuditStash.Monitor.beforeAlert',
    function (EventInterface $event): void {
        $alert = $event->getData('alert');
        // Don't page during the migration window for tenant 42.
        if ($alert->getAuditLog()->meta['tenant_id'] ?? null === 42) {
            $event->stopPropagation();
        }
    },
);

Redact PII before delivery ​

php
EventManager::instance()->on(
    'AuditStash.Monitor.beforeAlert',
    function (EventInterface $event): void {
        $alert = $event->getData('alert');
        $event->setData('alert', new Alert(
            $alert->getRuleName(),
            $alert->getSeverity(),
            $alert->getMessage(),
            $alert->getAuditLog(),
            // Strip user_email from the alert context before any channel sees it.
            array_diff_key($alert->getContext(), ['user_email' => 1]),
        ));
    },
);

Route to a non-channel target after delivery ​

php
EventManager::instance()->on(
    'AuditStash.Monitor.afterAlert',
    function (EventInterface $event) use ($incidentStore): void {
        if (in_array(false, $event->getData('results'), true)) {
            // At least one channel failed. Persist the alert to a fallback table
            // so on-call has something to triage even if Slack/email were down.
            $incidentStore->writeFallback($event->getData('alert'));
        }
    },
);

Forward rule exceptions to your error reporter ​

Rule exceptions go through Cake\Log\Log with the full Throwable in the log context, so any handler that knows how to unpack context.exception (Monolog's IntrospectionProcessor, the sentry/sentry Cake bridge, etc.) gets the stack for free. Wire your error reporter as a normal log handler:

php
'Log' => [
    'sentry' => [
        'className' => Sentry\Cake\Log\SentryLog::class,
        'levels' => ['error', 'critical', 'alert', 'emergency'],
        // ...
    ],
],

Both rule-evaluation failures and channel-send failures already emit error-level log entries with exception in the context.

The events fire on the global EventManager (the same one CakePHP uses for everything else), so you can wire them up from Application::bootstrap(), a dedicated listener class, or anywhere else that runs at request boot time.

Disabling Monitoring ​

To disable monitoring entirely:

php
'AuditStash' => [
    'monitor' => [
        'enabled' => false,
    ],
],

Or don't configure the monitor section at all.

Performance Considerations ​

  • Rules are checked in real time as audit logs are created
  • Use specific tables filters in rules to avoid checking every audit event
  • Webhook channels configured with retry greater than 1 repeat failed requests, which may slow down requests
  • Consider using LogChannel for high-volume environments and processing logs asynchronously
  • The monitoring system only activates when TablePersister is used (not with ElasticSearch)

Released under the MIT License.