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
20 changes: 16 additions & 4 deletions src/ApiAdvancedPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions src/AuditRecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed>|null
*/
Expand All @@ -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'),
Expand Down
9 changes: 6 additions & 3 deletions src/AuditView.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class AuditView
'token_rejected' => 'Token rejected',
'access_denied' => 'Access denied',
'entry_written' => 'Entry written',
'management' => 'Management',
];

/**
Expand All @@ -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 .= '<p class="nb-muted">Last 24 hours: '
. '<strong>' . $rejected . '</strong> rejected token' . ($rejected === 1 ? '' : 's') . ', '
. '<strong>' . $denied . '</strong> scope denial' . ($denied === 1 ? '' : 's') . ', '
. '<strong>' . $writes . '</strong> write' . ($writes === 1 ? '' : 's') . '.</p>';
. '<strong>' . $writes . '</strong> write' . ($writes === 1 ? '' : 's') . ', '
. '<strong>' . $managed . '</strong> management action' . ($managed === 1 ? '' : 's') . '.</p>';

if ($recent === []) {
$html .= '<div class="nb-empty-panel"><span class="nb-empty-ic">🛡️</span>'
Expand All @@ -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']) : '<span class="nb-muted">—</span>';
$token = ($row['token_name'] ?? '') !== '' ? $e($row['token_name']) : '<span class="nb-muted">—</span>';
Expand Down
15 changes: 15 additions & 0 deletions tests/AuditRecorderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
1 change: 1 addition & 0 deletions tests/PackageIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
Loading