diff --git a/CHANGELOG.md b/CHANGELOG.md index f13c851..e9e65f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/ApiAdvancedPlugin.php b/src/ApiAdvancedPlugin.php index 5210aed..f3bc7c6 100644 --- a/src/ApiAdvancedPlugin.php +++ b/src/ApiAdvancedPlugin.php @@ -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); @@ -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( diff --git a/src/AuditLog.php b/src/AuditLog.php index 7dcc2df..91b2e17 100644 --- a/src/AuditLog.php +++ b/src/AuditLog.php @@ -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, ); } diff --git a/src/AuditRecorder.php b/src/AuditRecorder.php index 79231db..2f48ba9 100644 --- a/src/AuditRecorder.php +++ b/src/AuditRecorder.php @@ -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|null */ @@ -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'), diff --git a/src/AuditView.php b/src/AuditView.php index 0337baa..ff7650e 100644 --- a/src/AuditView.php +++ b/src/AuditView.php @@ -14,6 +14,7 @@ final class AuditView private const LABELS = [ 'token_rejected' => 'Token rejected', 'access_denied' => 'Access denied', + 'entry_written' => 'Entry written', ]; /** @@ -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 .= '

Last 24 hours: ' . '' . $rejected . ' rejected token' . ($rejected === 1 ? '' : 's') . ', ' - . '' . $denied . ' scope denial' . ($denied === 1 ? '' : 's') . '.

'; + . '' . $denied . ' scope denial' . ($denied === 1 ? '' : 's') . ', ' + . '' . $writes . ' write' . ($writes === 1 ? '' : 's') . '.

'; if ($recent === []) { $html .= '
🛡️' - . '

No API failures recorded

' - . '

Rejected tokens and out-of-scope requests to the API will appear here.

'; + . '

Nothing recorded yet

' + . '

API writes, rejected tokens, and out-of-scope requests will appear here.

'; return $html; } $html .= '' - . '' + . '' . ''; 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']) - : ''; + $detail = match ($kind) { + 'access_denied', 'entry_written' => $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? ''), + default => $e($row['reason'] ?? ''), + }; + $target = ($row['target'] ?? '') !== '' ? $e($row['target']) : ''; + $token = ($row['token_name'] ?? '') !== '' ? $e($row['token_name']) : ''; $html .= '' . '' . '' . '' + . '' . '' . '' - . '' . ''; } diff --git a/src/Schema.php b/src/Schema.php index dfa0d09..5a9a3e8 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -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 { @@ -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 + */ + public static function auditTarget(): array + { + return ['ALTER TABLE ' . self::TABLE . ' ADD COLUMN target VARCHAR(191) NULL AFTER resource']; + } } diff --git a/tests/AuditRecorderTest.php b/tests/AuditRecorderTest.php index 8f7e4a8..81b856e 100644 --- a/tests/AuditRecorderTest.php +++ b/tests/AuditRecorderTest.php @@ -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']); diff --git a/tests/AuditViewTest.php b/tests/AuditViewTest.php index fdcf348..7e63b51 100644 --- a/tests/AuditViewTest.php +++ b/tests/AuditViewTest.php @@ -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('2 writes', $html); + } + public function test_it_escapes_untrusted_values(): void { $html = (new AuditView())->html( @@ -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('0 rejected tokens', $html); + self::assertStringContainsString('0 writes', $html); } } diff --git a/tests/PackageIntegrationTest.php b/tests/PackageIntegrationTest.php index 454baa3..438923f 100644 --- a/tests/PackageIntegrationTest.php +++ b/tests/PackageIntegrationTest.php @@ -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'); }
WhenKindDetailTokenIPPathWhenKindDetailTargetTokenIP
' . $e($row['occurred_at'] ?? '') . '' . $e(self::LABELS[$kind] ?? $kind) . '' . $detail . '' . $target . '' . $token . '' . $e($row['ip'] ?? '') . '' . $e($row['path'] ?? '') . '