Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
audit** admin page shows a 24-hour summary and the most recent failures.
- Retention: a `nimbus prune` maintenance task drops audit rows older than
`API_AUDIT_RETENTION_DAYS` (default 30; `0` keeps everything).
- **Write auditing.** Also listens to core's `api.entry_written` event and records
each create/update/delete over the API — the acting token, the collection, and
the entry (its slug) — so the audit log is a full *who-changed-what* trail, not
just failures.
7 changes: 7 additions & 0 deletions src/ApiAdvancedPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class ApiAdvancedPlugin implements Plugin
public function register(PluginContext $context): void
{
$context->migrations()->register('001_audit', Schema::audit());
$context->migrations()->register('002_audit_target', Schema::auditTarget());

$storage = static fn (): PluginStorage => $context->storage();
$recorder = new AuditRecorder($storage);
Expand All @@ -49,6 +50,12 @@ static function (mixed $payload) use ($recorder): void {
$recorder->record('access_denied', $payload);
},
);
$context->events()->listen(
CoreEvents::API_ENTRY_WRITTEN,
static function (mixed $payload) use ($recorder): void {
$recorder->record('entry_written', $payload);
},
);

$log = new AuditLog($storage);
$context->adminPages()->register(
Expand Down
2 changes: 1 addition & 1 deletion src/AuditLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function __construct(callable $storage)
public function recent(): array
{
return ($this->storage)()->select(
'SELECT kind, reason, token_id, token_name, resource, action, ip, path, occurred_at
'SELECT kind, reason, token_id, token_name, resource, target, action, ip, path, occurred_at
FROM ' . Schema::TABLE . ' ORDER BY id DESC LIMIT ' . self::RECENT_LIMIT,
);
}
Expand Down
9 changes: 6 additions & 3 deletions src/AuditRecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ public function record(string $kind, mixed $payload): void

($this->storage)()->insert(
'INSERT INTO ' . Schema::TABLE . '
(kind, reason, token_id, token_name, resource, action, ip, path, occurred_at)
VALUES (:kind, :reason, :token_id, :token_name, :resource, :action, :ip, :path, :at)',
(kind, reason, token_id, token_name, resource, target, action, ip, path, occurred_at)
VALUES (:kind, :reason, :token_id, :token_name, :resource, :target, :action, :ip, :path, :at)',
$row,
);
}

/**
* Map an event payload to a stored row, or null to skip a malformed one.
* Handles both failure payloads (which carry `resource`) and write payloads
* (which carry `collection` + `slug`).
*
* @return array<string,mixed>|null
*/
Expand All @@ -60,7 +62,8 @@ public function entry(string $kind, mixed $payload): ?array
'reason' => $str('reason'),
'token_id' => $int('token_id'),
'token_name' => $str('token_name'),
'resource' => $str('resource'),
'resource' => $str('resource') ?? $str('collection'),
'target' => $str('slug'),
'action' => $str('action'),
'ip' => $str('ip'),
'path' => $str('path'),
Expand Down
25 changes: 14 additions & 11 deletions src/AuditView.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class AuditView
private const LABELS = [
'token_rejected' => 'Token rejected',
'access_denied' => 'Access denied',
'entry_written' => 'Entry written',
];

/**
Expand All @@ -28,38 +29,40 @@ public function html(array $recent, array $summary): string

$rejected = $summary['token_rejected'] ?? 0;
$denied = $summary['access_denied'] ?? 0;
$writes = $summary['entry_written'] ?? 0;
$html .= '<p class="nb-muted">Last 24 hours: '
. '<strong>' . $rejected . '</strong> rejected token' . ($rejected === 1 ? '' : 's') . ', '
. '<strong>' . $denied . '</strong> scope denial' . ($denied === 1 ? '' : 's') . '.</p>';
. '<strong>' . $denied . '</strong> scope denial' . ($denied === 1 ? '' : 's') . ', '
. '<strong>' . $writes . '</strong> write' . ($writes === 1 ? '' : 's') . '.</p>';

if ($recent === []) {
$html .= '<div class="nb-empty-panel"><span class="nb-empty-ic">🛡️</span>'
. '<h2>No API failures recorded</h2>'
. '<p>Rejected tokens and out-of-scope requests to the API will appear here.</p></div>';
. '<h2>Nothing recorded yet</h2>'
. '<p>API writes, rejected tokens, and out-of-scope requests will appear here.</p></div>';

return $html;
}

$html .= '<table class="nb-table"><thead><tr>'
. '<th>When</th><th>Kind</th><th>Detail</th><th>Token</th><th>IP</th><th>Path</th>'
. '<th>When</th><th>Kind</th><th>Detail</th><th>Target</th><th>Token</th><th>IP</th>'
. '</tr></thead><tbody>';

foreach ($recent as $row) {
$kind = (string) ($row['kind'] ?? '');
$detail = $kind === 'access_denied'
? $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? 'read')
: $e($row['reason'] ?? '');
$token = $row['token_name'] !== null && $row['token_name'] !== ''
? $e($row['token_name'])
: '<span class="nb-muted">—</span>';
$detail = match ($kind) {
'access_denied', 'entry_written' => $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? ''),
default => $e($row['reason'] ?? ''),
};
$target = ($row['target'] ?? '') !== '' ? $e($row['target']) : '<span class="nb-muted">—</span>';
$token = ($row['token_name'] ?? '') !== '' ? $e($row['token_name']) : '<span class="nb-muted">—</span>';

$html .= '<tr>'
. '<td>' . $e($row['occurred_at'] ?? '') . '</td>'
. '<td>' . $e(self::LABELS[$kind] ?? $kind) . '</td>'
. '<td>' . $detail . '</td>'
. '<td>' . $target . '</td>'
. '<td>' . $token . '</td>'
. '<td>' . $e($row['ip'] ?? '') . '</td>'
. '<td>' . $e($row['path'] ?? '') . '</td>'
. '</tr>';
}

Expand Down
21 changes: 15 additions & 6 deletions src/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@

/**
* The plugin's own append-only audit table (ADR 0005 — own tables, namespaced
* away from core `nb_*`). One row per recorded API failure. It never stores a
* token secret — only the id/name of an already-authenticated token, and the
* request's IP and path.
*
* Rows are not self-expiring; a long-lived install should prune old entries
* (a retention policy is a planned follow-up).
* away from core `nb_*`). One row per recorded API event — a failure (rejected
* token / scope denial) or a write (create/update/delete). It never stores a
* token secret, only the id/name of an already-authenticated token, plus the
* request's IP and path (and, for a write, which entry). Retention is handled by
* `nimbus prune` (see ApiAdvancedPlugin).
*/
final class Schema
{
Expand All @@ -37,4 +36,14 @@ public static function audit(): array
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
];
}

/**
* Adds the entry a write touched (its slug), for the write-audit trail.
*
* @return list<string>
*/
public static function auditTarget(): array
{
return ['ALTER TABLE ' . self::TABLE . ' ADD COLUMN target VARCHAR(191) NULL AFTER resource'];
}
}
16 changes: 16 additions & 0 deletions tests/AuditRecorderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ public function test_it_maps_a_scope_denial_with_the_token(): void
self::assertSame('read', $row['action']);
}

public function test_it_maps_a_write_from_a_collection_and_slug(): void
{
$row = $this->recorder->entry('entry_written', [
'token_id' => 5, 'token_name' => 'CI', 'collection' => 'posts', 'slug' => 'hello-world',
'action' => 'create', 'ip' => '198.51.100.9', 'path' => '/api/v1/collections/posts/entries',
]);

self::assertIsArray($row);
self::assertSame('entry_written', $row['kind']);
self::assertSame('posts', $row['resource'], 'collection maps to resource');
self::assertSame('hello-world', $row['target'], 'slug maps to target');
self::assertSame('create', $row['action']);
self::assertSame(5, $row['token_id']);
self::assertNull($row['reason']);
}

public function test_it_defaults_the_timestamp_when_absent(): void
{
$row = $this->recorder->entry('token_rejected', ['reason' => 'missing']);
Expand Down
19 changes: 18 additions & 1 deletion tests/AuditViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ public function test_it_renders_recent_failures_and_a_summary(): void
self::assertStringContainsString('pages:read', $html, 'the denied resource:action');
}

public function test_it_renders_a_write_with_its_target(): void
{
$html = (new AuditView())->html(
[[
'kind' => 'entry_written', 'resource' => 'posts', 'action' => 'create', 'target' => 'hello-world',
'token_name' => 'CI', 'ip' => '198.51.100.9', 'occurred_at' => '2026-08-17 10:00:00',
]],
['entry_written' => 2],
);

self::assertStringContainsString('Entry written', $html);
self::assertStringContainsString('posts:create', $html);
self::assertStringContainsString('hello-world', $html, 'the target entry');
self::assertStringContainsString('<strong>2</strong> writes', $html);
}

public function test_it_escapes_untrusted_values(): void
{
$html = (new AuditView())->html(
Expand All @@ -41,7 +57,8 @@ public function test_an_empty_log_reads_gracefully(): void
{
$html = (new AuditView())->html([], []);

self::assertStringContainsString('No API failures recorded', $html);
self::assertStringContainsString('Nothing recorded yet', $html);
self::assertStringContainsString('<strong>0</strong> rejected tokens', $html);
self::assertStringContainsString('<strong>0</strong> writes', $html);
}
}
7 changes: 6 additions & 1 deletion tests/PackageIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,14 @@ public function test_discovery_registers_the_migration_listeners_admin_page_and_
self::assertSame([], $diagnostics, 'a correctly installed package must load cleanly');
self::assertSame([ApiAdvancedPlugin::ID => $this->manifest()['name']], $loader->registered());

self::assertSame(['nimbuscms.api-advanced:001_audit'], array_column($migrations->all(), 'name'), 'its migration');
self::assertSame(
['nimbuscms.api-advanced:001_audit', 'nimbuscms.api-advanced:002_audit_target'],
array_column($migrations->all(), 'name'),
'its migrations',
);
self::assertTrue($events->hasListeners(CoreEvents::API_TOKEN_REJECTED), 'the rejection listener');
self::assertTrue($events->hasListeners(CoreEvents::API_ACCESS_DENIED), 'the scope-denial listener');
self::assertTrue($events->hasListeners(CoreEvents::API_ENTRY_WRITTEN), 'the write listener');
self::assertSame(['api-audit'], array_column($adminPages->all(), 'slug'), 'its admin page');
self::assertSame(['nimbuscms.api-advanced:prune-audit'], array_column($maintenance->all(), 'name'), 'its retention task');
}
Expand Down
Loading