ソースを参照

feat: subject filter for audit log; show actor-emitted rows on detail

Adds a `subject_kind` + `subject_id` filter to /api/v1/admin/audit-log
that ORs against (target_*, actor_*) so a single search returns both
admin actions on an entity and events the entity itself emitted.

The reporter detail "Recent reports" table and the consumer detail
"Last activity" table now use this filter, picking up
`report.received` and `blocklist.requested` rows alongside the
existing admin-action rows. Both stay capped at 10. The "View all in
audit log" links point at /app/audit with the same subject filter,
and the audit page shows a small banner explaining the filter when
active so an operator can clear it intentionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 週間 前
コミット
717c0a5c2b

+ 1 - 0
api/CHANGELOG.md

@@ -24,6 +24,7 @@ with the UI's tags in this monorepo.
 
 ### Changed
 - Dashboard `/api/v1/admin/stats/dashboard` replaces the single-series `bans_by_day_7d` (manual-block creations per day) with `blocked_ips_by_day_7d`, a per-category time series of distinct IPs reported per UTC day. Shape is `{days: string[], series: [{category, counts}]}`. Categories with zero activity in the window still appear as flat-zero series so the legend stays stable.
+- `/api/v1/admin/audit-log` now accepts `subject_kind` + `subject_id` query parameters (must be supplied together; otherwise 400). When set, the row matches if the (kind, id) pair matches *either* the audit row's target *or* its actor — so per-entity detail pages can list both admin actions on the entity and events the entity emitted (`report.received`, `blocklist.requested`).
 
 ## [1.0.0] — 2026-05-01
 

+ 14 - 0
api/public/openapi.yaml

@@ -991,6 +991,20 @@ paths:
           in: query
           schema:
             type: string
+        - name: subject_kind
+          in: query
+          description: |
+            With `subject_id`, returns rows where the (kind, id) pair matches
+            either the audit row's target OR its actor. Useful for per-entity
+            detail pages that want both admin actions on the entity and
+            events emitted by it (`report.received`, `blocklist.requested`).
+            Both halves must be supplied together; otherwise 400.
+          schema:
+            type: string
+        - name: subject_id
+          in: query
+          schema:
+            type: string
         - name: from
           in: query
           schema:

+ 14 - 0
api/src/Application/Admin/AuditController.php

@@ -67,6 +67,20 @@ final class AuditController
             $filters['entity_id'] = $params['entity_id'];
         }
 
+        // Subject filter (used by reporter/consumer detail pages):
+        // both halves must be present together — a kind without an id
+        // would silently widen the result set and is almost always a bug.
+        $subjectKind = isset($params['subject_kind']) && is_string($params['subject_kind']) ? $params['subject_kind'] : '';
+        $subjectId = isset($params['subject_id']) && is_string($params['subject_id']) ? $params['subject_id'] : '';
+        if ($subjectKind !== '' || $subjectId !== '') {
+            if ($subjectKind === '' || $subjectId === '') {
+                $errors['subject'] = 'subject_kind and subject_id must be supplied together';
+            } else {
+                $filters['subject_kind'] = $subjectKind;
+                $filters['subject_id'] = $subjectId;
+            }
+        }
+
         if (isset($params['from']) && is_string($params['from']) && $params['from'] !== '') {
             $normalized = self::normalizeIsoTimestamp($params['from']);
             if ($normalized === null) {

+ 12 - 0
api/src/Infrastructure/Audit/AuditRepository.php

@@ -55,6 +55,8 @@ class AuditRepository extends RepositoryBase
      *     action?: ?string,
      *     entity_type?: ?string,
      *     entity_id?: ?string,
+     *     subject_kind?: ?string,
+     *     subject_id?: ?string,
      *     from?: ?string,
      *     to?: ?string,
      * } $filters
@@ -88,6 +90,16 @@ class AuditRepository extends RepositoryBase
             $where[] = 'target_id = :entity_id';
             $params['entity_id'] = $filters['entity_id'];
         }
+        // `subject_*` matches a row whose target OR actor is the given (kind, id)
+        // pair. Used by the reporter / consumer detail pages so a single
+        // page lists both admin actions on the entity and events the entity
+        // itself emitted (report.received, blocklist.requested).
+        if (!empty($filters['subject_kind']) && !empty($filters['subject_id'])) {
+            $where[] = '((target_type = :subject_kind AND target_id = :subject_id)'
+                . ' OR (actor_kind = :subject_kind AND actor_id = :subject_id))';
+            $params['subject_kind'] = $filters['subject_kind'];
+            $params['subject_id'] = (string) $filters['subject_id'];
+        }
         if (!empty($filters['from'])) {
             $where[] = 'created_at >= :from';
             $params['from'] = $filters['from'];

+ 39 - 0
api/tests/Integration/Admin/AuditLogControllerTest.php

@@ -54,6 +54,45 @@ final class AuditLogControllerTest extends AppTestCase
         self::assertSame('manual_block', $body['items'][0]['entity_type']);
     }
 
+    public function testSubjectFilterUnionsActorAndTarget(): void
+    {
+        $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
+
+        // (1) admin updated reporter #5 — target=reporter, actor=user
+        $this->seedAudit('user', '1', 'reporter.updated', 'reporter', '5', '{}', $now);
+        // (2) reporter #5 emitted a report.received — actor=reporter, target=report
+        $this->seedAudit('reporter', '5', 'report.received', 'report', '99', '{}', $now);
+        // (3) different reporter — must NOT appear in subject_kind=reporter,subject_id=5
+        $this->seedAudit('reporter', '6', 'report.received', 'report', '100', '{}', $now);
+        // (4) unrelated row — must NOT appear
+        $this->seedAudit('user', '1', 'manual_block.created', 'manual_block', '7', '{}', $now);
+
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request(
+            'GET',
+            '/api/v1/admin/audit-log?subject_kind=reporter&subject_id=5',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        $body = $this->decode($resp);
+        self::assertSame(2, $body['total']);
+        $actions = array_map(static fn (array $r): string => $r['action'], $body['items']);
+        self::assertContains('reporter.updated', $actions);
+        self::assertContains('report.received', $actions);
+        self::assertNotContains('manual_block.created', $actions);
+    }
+
+    public function testSubjectKindWithoutIdReturns400(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request(
+            'GET',
+            '/api/v1/admin/audit-log?subject_kind=reporter',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(400, $resp->getStatusCode());
+        self::assertArrayHasKey('subject', $this->decode($resp)['details']);
+    }
+
     public function testInvalidActorKindReturns400(): void
     {
         $token = $this->createToken(TokenKind::Admin, Role::Viewer);

+ 1 - 0
ui/CHANGELOG.md

@@ -23,6 +23,7 @@ with the api's tags in this monorepo.
 
 ### Changed
 - Dashboard "Bans (7 days)" line chart replaced by a stacked bar chart of distinct blocked IPs per day broken down by category (last 7 days). Empty categories still render as zero series so the legend doesn't churn between renders.
+- Reporter detail page's "Recent reports" table and consumer detail page's "Last activity" table now include the `report.received` / `blocklist.requested` audit rows the entity itself emitted, alongside the existing admin-action rows. The "View all in audit log" link points at `/app/audit?subject_kind=…&subject_id=…`, and the audit page shows a banner explaining the subject filter when active.
 
 ## [1.0.0] — 2026-05-01
 

+ 15 - 0
ui/resources/views/pages/audit/index.twig

@@ -32,7 +32,22 @@
         <div class="mt-4 rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">{{ error }}</div>
     {% endif %}
 
+    {% if filters.subject_kind and filters.subject_id %}
+        <div class="mt-4 flex items-center justify-between rounded-md border border-indigo-200 bg-indigo-50 px-4 py-2 text-sm text-indigo-900 dark:border-indigo-800 dark:bg-indigo-950 dark:text-indigo-200">
+            <span>
+                Filtering events for {{ filters.subject_kind }} <span class="font-mono">#{{ filters.subject_id }}</span>
+                — both events <em>about</em> it and events emitted <em>by</em> it.
+            </span>
+            <a href="/app/audit" class="text-xs font-medium underline">clear</a>
+        </div>
+    {% endif %}
+
     <form id="audit-filter-form" method="get" action="/app/audit" class="mt-4 grid grid-cols-2 gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-sm dark:border-slate-800 dark:bg-slate-900 md:grid-cols-7">
+        {# Subject filter is set by per-entity links (reporter/consumer detail) and
+           preserved across re-submits of this form so a manual filter change
+           doesn't silently widen the result. Form has no UI control for it. #}
+        {% if filters.subject_kind %}<input type="hidden" name="subject_kind" value="{{ filters.subject_kind }}">{% endif %}
+        {% if filters.subject_id %}<input type="hidden" name="subject_id" value="{{ filters.subject_id }}">{% endif %}
         <div>
             <label for="f-actor-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Actor kind</label>
             <select id="f-actor-kind" name="actor_kind" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">

+ 1 - 1
ui/resources/views/pages/consumers/edit.twig

@@ -61,7 +61,7 @@
                 <h2 class="text-sm font-semibold tracking-tight">Last activity</h2>
                 <p class="text-xs text-slate-500 dark:text-slate-400">10 most recent audit entries for this consumer.</p>
             </div>
-            <a href="/app/audit?entity_type=consumer&amp;entity_id={{ consumer.id }}" class="whitespace-nowrap text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400">View all in audit log →</a>
+            <a href="/app/audit?subject_kind=consumer&amp;subject_id={{ consumer.id }}" class="whitespace-nowrap text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400">View all in audit log →</a>
         </header>
         {% if audit_items|default([])|length > 0 %}
             <div x-data="{ open: null }">

+ 1 - 1
ui/resources/views/pages/reporters/edit.twig

@@ -58,7 +58,7 @@
                 <h2 class="text-sm font-semibold tracking-tight">Recent reports</h2>
                 <p class="text-xs text-slate-500 dark:text-slate-400">10 most recent audit entries for this reporter.</p>
             </div>
-            <a href="/app/audit?entity_type=reporter&amp;entity_id={{ reporter.id }}" class="whitespace-nowrap text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400">View all in audit log →</a>
+            <a href="/app/audit?subject_kind=reporter&amp;subject_id={{ reporter.id }}" class="whitespace-nowrap text-xs font-medium text-indigo-600 hover:underline dark:text-indigo-400">View all in audit log →</a>
         </header>
         {% if audit_items|default([])|length > 0 %}
             <div x-data="{ open: null }">

+ 1 - 1
ui/src/ApiClient/AdminClient.php

@@ -372,7 +372,7 @@ final class AdminClient
     public function listAuditLog(int $actingUserId, array $filters, int $page = 1, int $pageSize = 50): array
     {
         $query = ['page' => $page, 'page_size' => $pageSize];
-        foreach (['actor_kind', 'actor_id', 'action', 'entity_type', 'entity_id', 'from', 'to'] as $key) {
+        foreach (['actor_kind', 'actor_id', 'action', 'entity_type', 'entity_id', 'subject_kind', 'subject_id', 'from', 'to'] as $key) {
             if (isset($filters[$key]) && $filters[$key] !== '' && $filters[$key] !== null) {
                 $query[$key] = $filters[$key];
             }

+ 2 - 0
ui/src/Controllers/AuditController.php

@@ -70,6 +70,8 @@ final class AuditController
             'action' => self::clean($params['action'] ?? null),
             'entity_type' => self::clean($params['entity_type'] ?? null),
             'entity_id' => self::clean($params['entity_id'] ?? null),
+            'subject_kind' => self::clean($params['subject_kind'] ?? null),
+            'subject_id' => self::clean($params['subject_id'] ?? null),
             'from' => self::clean($params['from'] ?? null),
             'to' => self::clean($params['to'] ?? null),
         ];

+ 5 - 1
ui/src/Controllers/ConsumersController.php

@@ -98,9 +98,13 @@ final class ConsumersController
 
         $auditItems = [];
         try {
+            // `subject_*` matches both events targeting this consumer (admin
+            // CRUD) and events the consumer emitted itself
+            // (`blocklist.requested`), so the activity table is a single
+            // chronological view.
             $auditPage = $this->admin->listAuditLog(
                 $user->userId,
-                ['entity_type' => 'consumer', 'entity_id' => (string) $id],
+                ['subject_kind' => 'consumer', 'subject_id' => (string) $id],
                 1,
                 10,
             );

+ 4 - 1
ui/src/Controllers/ReportersController.php

@@ -93,9 +93,12 @@ final class ReportersController
 
         $auditItems = [];
         try {
+            // `subject_*` matches both events targeting this reporter (admin
+            // CRUD) and events the reporter emitted itself (`report.received`),
+            // so the recent-activity table is a single chronological view.
             $auditPage = $this->admin->listAuditLog(
                 $user->userId,
-                ['entity_type' => 'reporter', 'entity_id' => (string) $id],
+                ['subject_kind' => 'reporter', 'subject_id' => (string) $id],
                 1,
                 10,
             );