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 .= ''
- . '| When | Kind | Detail | Token | IP | Path | '
+ . 'When | Kind | Detail | Target | Token | IP | '
. '
';
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 .= ''
. '| ' . $e($row['occurred_at'] ?? '') . ' | '
. '' . $e(self::LABELS[$kind] ?? $kind) . ' | '
. '' . $detail . ' | '
+ . '' . $target . ' | '
. '' . $token . ' | '
. '' . $e($row['ip'] ?? '') . ' | '
- . '' . $e($row['path'] ?? '') . ' | '
. '
';
}
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');
}