diff --git a/src/ApiAdvancedPlugin.php b/src/ApiAdvancedPlugin.php index f3bc7c6..d5cce9d 100644 --- a/src/ApiAdvancedPlugin.php +++ b/src/ApiAdvancedPlugin.php @@ -15,10 +15,13 @@ * features that don't belong in the lean core. The first is a **security audit * log** of API access failures. * - * It listens to the core best-effort `api.token_rejected` and `api.access_denied` - * events and records each into a table it owns, surfaced on an admin page. Those - * events fire only *after* the per-IP flood guard, so a flood is already `429`'d - * before it reaches here — the recording is bounded by the core rate limits. + * It listens to the core best-effort access events — `api.token_rejected`, + * `api.access_denied`, `api.entry_written` and `api.management_written` — and + * records each into a table it owns, surfaced on an admin page: a who-did-what + * trail of both failures and the content/structural writes an agent makes over + * MCP. The failure events fire only *after* the per-IP flood guard, so a flood is + * already `429`'d before it reaches here — recording is bounded by the core rate + * limits. * * It is the **second unrelated consumer** of the plugin event + storage * capabilities (after Analytics), the independent proof both were waiting for. @@ -56,6 +59,15 @@ static function (mixed $payload) use ($recorder): void { $recorder->record('entry_written', $payload); }, ); + // Management actions through the API/MCP — schema, media, users, tokens, + // settings (ADR 0009). The structural counterpart of entry_written, so an + // agent reshaping the CMS leaves a full who-did-what trail. + $context->events()->listen( + CoreEvents::API_MANAGEMENT_WRITTEN, + static function (mixed $payload) use ($recorder): void { + $recorder->record('management', $payload); + }, + ); $log = new AuditLog($storage); $context->adminPages()->register( diff --git a/src/AuditRecorder.php b/src/AuditRecorder.php index 2f48ba9..d6c0542 100644 --- a/src/AuditRecorder.php +++ b/src/AuditRecorder.php @@ -43,8 +43,8 @@ public function record(string $kind, mixed $payload): void /** * 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`). + * Handles failure payloads (which carry `resource`), content-write payloads + * (`collection` + `slug`), and management payloads (`capability` + `target`). * * @return array|null */ @@ -62,8 +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') ?? $str('collection'), - 'target' => $str('slug'), + 'resource' => $str('resource') ?? $str('collection') ?? $str('capability'), + 'target' => $str('target') ?? $str('slug'), 'action' => $str('action'), 'ip' => $str('ip'), 'path' => $str('path'), diff --git a/src/AuditView.php b/src/AuditView.php index ff7650e..5cde411 100644 --- a/src/AuditView.php +++ b/src/AuditView.php @@ -15,6 +15,7 @@ final class AuditView 'token_rejected' => 'Token rejected', 'access_denied' => 'Access denied', 'entry_written' => 'Entry written', + 'management' => 'Management', ]; /** @@ -30,10 +31,12 @@ public function html(array $recent, array $summary): string $rejected = $summary['token_rejected'] ?? 0; $denied = $summary['access_denied'] ?? 0; $writes = $summary['entry_written'] ?? 0; + $managed = $summary['management'] ?? 0; $html .= '

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

'; + . '' . $writes . ' write' . ($writes === 1 ? '' : 's') . ', ' + . '' . $managed . ' management action' . ($managed === 1 ? '' : 's') . '.

'; if ($recent === []) { $html .= '
🛡️' @@ -50,8 +53,8 @@ public function html(array $recent, array $summary): string foreach ($recent as $row) { $kind = (string) ($row['kind'] ?? ''); $detail = match ($kind) { - 'access_denied', 'entry_written' => $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? ''), - default => $e($row['reason'] ?? ''), + 'access_denied', 'entry_written', 'management' => $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? ''), + default => $e($row['reason'] ?? ''), }; $target = ($row['target'] ?? '') !== '' ? $e($row['target']) : ''; $token = ($row['token_name'] ?? '') !== '' ? $e($row['token_name']) : ''; diff --git a/tests/AuditRecorderTest.php b/tests/AuditRecorderTest.php index 81b856e..fddfd9e 100644 --- a/tests/AuditRecorderTest.php +++ b/tests/AuditRecorderTest.php @@ -66,6 +66,21 @@ public function test_it_maps_a_write_from_a_collection_and_slug(): void self::assertNull($row['reason']); } + public function test_it_maps_a_management_action_from_capability_and_target(): void + { + $row = $this->recorder->entry('management', [ + 'token_id' => 7, 'token_name' => 'agent', 'capability' => 'schema', 'action' => 'create_collection', + 'target' => 'events', 'ip' => 'stdio', 'path' => 'mcp', + ]); + + self::assertIsArray($row); + self::assertSame('management', $row['kind']); + self::assertSame('schema', $row['resource'], 'capability maps to resource'); + self::assertSame('events', $row['target'], 'target maps through'); + self::assertSame('create_collection', $row['action']); + self::assertSame(7, $row['token_id']); + } + public function test_it_defaults_the_timestamp_when_absent(): void { $row = $this->recorder->entry('token_rejected', ['reason' => 'missing']); diff --git a/tests/PackageIntegrationTest.php b/tests/PackageIntegrationTest.php index 438923f..21aed81 100644 --- a/tests/PackageIntegrationTest.php +++ b/tests/PackageIntegrationTest.php @@ -96,6 +96,7 @@ public function test_discovery_registers_the_migration_listeners_admin_page_and_ 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::assertTrue($events->hasListeners(CoreEvents::API_MANAGEMENT_WRITTEN), 'the management 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'); }