Просмотр исходного кода

feat: per-reporter/consumer audit-log toggle on edit pages

Adds an `audit_enabled` boolean column to `reporters` and `consumers`
(default true) editable via the admin PATCH endpoints and surfaced as a
checkbox on the corresponding edit forms.

Audit emission for `report.received` and `blocklist.requested` now ANDs
the entity-level flag with the global app_settings toggle — either side
flipping off is enough to silence the row, without needing a global
change for one chatty source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 неделя назад
Родитель
Сommit
f47973313b

+ 1 - 0
api/CHANGELOG.md

@@ -18,6 +18,7 @@ with the UI's tags in this monorepo.
 ### Added
 ### Added
 - Public-endpoint audit emission: `POST /api/v1/report` writes a `report.received` entry attributed to the reporter, and `GET /api/v1/blocklist` writes a `blocklist.requested` entry (including 304s) attributed to the consumer.
 - Public-endpoint audit emission: `POST /api/v1/report` writes a `report.received` entry attributed to the reporter, and `GET /api/v1/blocklist` writes a `blocklist.requested` entry (including 304s) attributed to the consumer.
 - `app_settings` key/value table plus `GET/PATCH /api/v1/admin/app-settings` (admin-only) exposing the two audit toggles (`audit_report_received_enabled`, `audit_blocklist_request_enabled`) so the high-volume rows can be silenced at runtime without a container restart.
 - `app_settings` key/value table plus `GET/PATCH /api/v1/admin/app-settings` (admin-only) exposing the two audit toggles (`audit_report_received_enabled`, `audit_blocklist_request_enabled`) so the high-volume rows can be silenced at runtime without a container restart.
+- Per-entity `audit_enabled` boolean on `reporters` and `consumers` (default true) editable via the admin PATCH endpoints. Audit emits only when both the global toggle and the entity-level flag are true (AND, not OR).
 - New audit actions: `report.received`, `blocklist.requested`, `app_settings.updated`.
 - New audit actions: `report.received`, `blocklist.requested`, `app_settings.updated`.
 
 
 ## [1.0.0] — 2026-05-01
 ## [1.0.0] — 2026-05-01

+ 34 - 0
api/db/migrations/20260501140000_add_audit_enabled_to_reporters_consumers.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * Per-entity audit toggle. Defaults to enabled so existing reporters and
+ * consumers keep emitting audit rows after the migration runs; admins can
+ * silence individual chatty endpoints from the edit page.
+ *
+ * Effective rule: audit emits only when BOTH the global flag in
+ * `app_settings` and the per-entity flag here are true. AND, not OR —
+ * either side is sufficient to suppress.
+ */
+final class AddAuditEnabledToReportersConsumers extends BaseMigration
+{
+    public function up(): void
+    {
+        $this->table('reporters')
+            ->addColumn('audit_enabled', 'boolean', ['null' => false, 'default' => true, 'after' => 'is_active'])
+            ->update();
+
+        $this->table('consumers')
+            ->addColumn('audit_enabled', 'boolean', ['null' => false, 'default' => true, 'after' => 'is_active'])
+            ->update();
+    }
+
+    public function down(): void
+    {
+        $this->table('reporters')->removeColumn('audit_enabled')->update();
+        $this->table('consumers')->removeColumn('audit_enabled')->update();
+    }
+}

+ 6 - 0
api/public/openapi.yaml

@@ -1474,6 +1474,9 @@ components:
           maximum: 2
           maximum: 2
         is_active:
         is_active:
           type: boolean
           type: boolean
+        audit_enabled:
+          type: boolean
+          description: When false, suppresses `report.received` audit rows for this reporter even if the global toggle is on.
     Consumer:
     Consumer:
       type: object
       type: object
       properties:
       properties:
@@ -1489,6 +1492,9 @@ components:
           type: integer
           type: integer
         is_active:
         is_active:
           type: boolean
           type: boolean
+        audit_enabled:
+          type: boolean
+          description: When false, suppresses `blocklist.requested` audit rows for this consumer even if the global toggle is on.
         last_pulled_at:
         last_pulled_at:
           type: string
           type: string
           format: date-time
           format: date-time

+ 8 - 0
api/src/Application/Admin/ConsumersController.php

@@ -181,6 +181,13 @@ final class ConsumersController
                 $fields['is_active'] = $body['is_active'] ? 1 : 0;
                 $fields['is_active'] = $body['is_active'] ? 1 : 0;
             }
             }
         }
         }
+        if (array_key_exists('audit_enabled', $body)) {
+            if (!is_bool($body['audit_enabled'])) {
+                $errors['audit_enabled'] = 'must be boolean';
+            } else {
+                $fields['audit_enabled'] = $body['audit_enabled'] ? 1 : 0;
+            }
+        }
 
 
         if ($errors !== []) {
         if ($errors !== []) {
             return self::validationFailed($response, $errors);
             return self::validationFailed($response, $errors);
@@ -191,6 +198,7 @@ final class ConsumersController
             'description' => $existing->description,
             'description' => $existing->description,
             'policy_id' => $existing->policyId,
             'policy_id' => $existing->policyId,
             'is_active' => $existing->isActive ? 1 : 0,
             'is_active' => $existing->isActive ? 1 : 0,
+            'audit_enabled' => $existing->auditEnabled ? 1 : 0,
         ];
         ];
 
 
         $this->consumers->update($id, $fields);
         $this->consumers->update($id, $fields);

+ 8 - 0
api/src/Application/Admin/ReportersController.php

@@ -175,6 +175,13 @@ final class ReportersController
                 $fields['is_active'] = $body['is_active'] ? 1 : 0;
                 $fields['is_active'] = $body['is_active'] ? 1 : 0;
             }
             }
         }
         }
+        if (array_key_exists('audit_enabled', $body)) {
+            if (!is_bool($body['audit_enabled'])) {
+                $errors['audit_enabled'] = 'must be boolean';
+            } else {
+                $fields['audit_enabled'] = $body['audit_enabled'] ? 1 : 0;
+            }
+        }
 
 
         if ($errors !== []) {
         if ($errors !== []) {
             return self::validationFailed($response, $errors);
             return self::validationFailed($response, $errors);
@@ -185,6 +192,7 @@ final class ReportersController
             'description' => $existing->description,
             'description' => $existing->description,
             'trust_weight' => number_format($existing->trustWeight, 2, '.', ''),
             'trust_weight' => number_format($existing->trustWeight, 2, '.', ''),
             'is_active' => $existing->isActive ? 1 : 0,
             'is_active' => $existing->isActive ? 1 : 0,
+            'audit_enabled' => $existing->auditEnabled ? 1 : 0,
         ];
         ];
 
 
         $this->reporters->update($id, $fields);
         $this->reporters->update($id, $fields);

+ 4 - 1
api/src/Application/Public/BlocklistController.php

@@ -88,7 +88,10 @@ final class BlocklistController
         $notModified = $ifNoneMatch !== null && self::etagMatches($ifNoneMatch, $etag);
         $notModified = $ifNoneMatch !== null && self::etagMatches($ifNoneMatch, $etag);
         $status = $notModified ? 304 : 200;
         $status = $notModified ? 304 : 200;
 
 
-        if ($this->settings->getBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, true)) {
+        if (
+            $consumer->auditEnabled
+            && $this->settings->getBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, true)
+        ) {
             $this->audit->emit(
             $this->audit->emit(
                 AuditAction::BLOCKLIST_REQUESTED,
                 AuditAction::BLOCKLIST_REQUESTED,
                 'blocklist',
                 'blocklist',

+ 4 - 1
api/src/Application/Public/ReportController.php

@@ -144,7 +144,10 @@ final class ReportController
             recomputedAt: $now,
             recomputedAt: $now,
         );
         );
 
 
-        if ($this->settings->getBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, true)) {
+        if (
+            $reporter->auditEnabled
+            && $this->settings->getBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, true)
+        ) {
             $this->audit->emit(
             $this->audit->emit(
                 AuditAction::REPORT_RECEIVED,
                 AuditAction::REPORT_RECEIVED,
                 'report',
                 'report',

+ 2 - 0
api/src/Domain/Consumer/Consumer.php

@@ -20,6 +20,7 @@ final class Consumer
         public readonly ?string $description,
         public readonly ?string $description,
         public readonly int $policyId,
         public readonly int $policyId,
         public readonly bool $isActive,
         public readonly bool $isActive,
+        public readonly bool $auditEnabled,
         public readonly ?int $createdByUserId,
         public readonly ?int $createdByUserId,
         public readonly DateTimeImmutable $createdAt,
         public readonly DateTimeImmutable $createdAt,
         public readonly ?DateTimeImmutable $lastPulledAt,
         public readonly ?DateTimeImmutable $lastPulledAt,
@@ -37,6 +38,7 @@ final class Consumer
             'description' => $this->description,
             'description' => $this->description,
             'policy_id' => $this->policyId,
             'policy_id' => $this->policyId,
             'is_active' => $this->isActive,
             'is_active' => $this->isActive,
+            'audit_enabled' => $this->auditEnabled,
             'created_by_user_id' => $this->createdByUserId,
             'created_by_user_id' => $this->createdByUserId,
             'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
             'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
             'last_pulled_at' => $this->lastPulledAt?->format('Y-m-d\TH:i:s\Z'),
             'last_pulled_at' => $this->lastPulledAt?->format('Y-m-d\TH:i:s\Z'),

+ 2 - 0
api/src/Domain/Reporter/Reporter.php

@@ -19,6 +19,7 @@ final class Reporter
         public readonly ?string $description,
         public readonly ?string $description,
         public readonly float $trustWeight,
         public readonly float $trustWeight,
         public readonly bool $isActive,
         public readonly bool $isActive,
+        public readonly bool $auditEnabled,
         public readonly ?int $createdByUserId,
         public readonly ?int $createdByUserId,
         public readonly DateTimeImmutable $createdAt,
         public readonly DateTimeImmutable $createdAt,
     ) {
     ) {
@@ -35,6 +36,7 @@ final class Reporter
             'description' => $this->description,
             'description' => $this->description,
             'trust_weight' => $this->trustWeight,
             'trust_weight' => $this->trustWeight,
             'is_active' => $this->isActive,
             'is_active' => $this->isActive,
+            'audit_enabled' => $this->auditEnabled,
             'created_by_user_id' => $this->createdByUserId,
             'created_by_user_id' => $this->createdByUserId,
             'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
             'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
         ];
         ];

+ 5 - 3
api/src/Infrastructure/Consumer/ConsumerRepository.php

@@ -26,7 +26,7 @@ final class ConsumerRepository
     {
     {
         /** @var array<string, mixed>|false $row */
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
         $row = $this->connection->fetchAssociative(
-            'SELECT id, name, description, policy_id, is_active, created_by_user_id, created_at, last_pulled_at '
+            'SELECT id, name, description, policy_id, is_active, audit_enabled, created_by_user_id, created_at, last_pulled_at '
             . 'FROM consumers WHERE id = :id',
             . 'FROM consumers WHERE id = :id',
             ['id' => $id]
             ['id' => $id]
         );
         );
@@ -38,7 +38,7 @@ final class ConsumerRepository
     {
     {
         /** @var array<string, mixed>|false $row */
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
         $row = $this->connection->fetchAssociative(
-            'SELECT id, name, description, policy_id, is_active, created_by_user_id, created_at, last_pulled_at '
+            'SELECT id, name, description, policy_id, is_active, audit_enabled, created_by_user_id, created_at, last_pulled_at '
             . 'FROM consumers WHERE name = :name',
             . 'FROM consumers WHERE name = :name',
             ['name' => $name]
             ['name' => $name]
         );
         );
@@ -53,7 +53,7 @@ final class ConsumerRepository
     {
     {
         /** @var list<array<string, mixed>> $rows */
         /** @var list<array<string, mixed>> $rows */
         $rows = $this->connection->fetchAllAssociative(
         $rows = $this->connection->fetchAllAssociative(
-            'SELECT id, name, description, policy_id, is_active, created_by_user_id, created_at, last_pulled_at '
+            'SELECT id, name, description, policy_id, is_active, audit_enabled, created_by_user_id, created_at, last_pulled_at '
             . 'FROM consumers ORDER BY id DESC LIMIT :limit OFFSET :offset',
             . 'FROM consumers ORDER BY id DESC LIMIT :limit OFFSET :offset',
             ['limit' => $limit, 'offset' => $offset]
             ['limit' => $limit, 'offset' => $offset]
         );
         );
@@ -73,6 +73,7 @@ final class ConsumerRepository
             'description' => $description,
             'description' => $description,
             'policy_id' => $policyId,
             'policy_id' => $policyId,
             'is_active' => 1,
             'is_active' => 1,
+            'audit_enabled' => 1,
             'created_by_user_id' => $createdByUserId,
             'created_by_user_id' => $createdByUserId,
         ]);
         ]);
 
 
@@ -121,6 +122,7 @@ final class ConsumerRepository
             description: $row['description'] !== null ? (string) $row['description'] : null,
             description: $row['description'] !== null ? (string) $row['description'] : null,
             policyId: (int) $row['policy_id'],
             policyId: (int) $row['policy_id'],
             isActive: (bool) $row['is_active'],
             isActive: (bool) $row['is_active'],
+            auditEnabled: (bool) ($row['audit_enabled'] ?? true),
             createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
             createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
             createdAt: $createdAt,
             createdAt: $createdAt,
             lastPulledAt: $lastPulled,
             lastPulledAt: $lastPulled,

+ 5 - 3
api/src/Infrastructure/Reporter/ReporterRepository.php

@@ -26,7 +26,7 @@ final class ReporterRepository
     {
     {
         /** @var array<string, mixed>|false $row */
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
         $row = $this->connection->fetchAssociative(
-            'SELECT id, name, description, trust_weight, is_active, created_by_user_id, created_at '
+            'SELECT id, name, description, trust_weight, is_active, audit_enabled, created_by_user_id, created_at '
             . 'FROM reporters WHERE id = :id',
             . 'FROM reporters WHERE id = :id',
             ['id' => $id]
             ['id' => $id]
         );
         );
@@ -38,7 +38,7 @@ final class ReporterRepository
     {
     {
         /** @var array<string, mixed>|false $row */
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
         $row = $this->connection->fetchAssociative(
-            'SELECT id, name, description, trust_weight, is_active, created_by_user_id, created_at '
+            'SELECT id, name, description, trust_weight, is_active, audit_enabled, created_by_user_id, created_at '
             . 'FROM reporters WHERE name = :name',
             . 'FROM reporters WHERE name = :name',
             ['name' => $name]
             ['name' => $name]
         );
         );
@@ -53,7 +53,7 @@ final class ReporterRepository
     {
     {
         /** @var list<array<string, mixed>> $rows */
         /** @var list<array<string, mixed>> $rows */
         $rows = $this->connection->fetchAllAssociative(
         $rows = $this->connection->fetchAllAssociative(
-            'SELECT id, name, description, trust_weight, is_active, created_by_user_id, created_at '
+            'SELECT id, name, description, trust_weight, is_active, audit_enabled, created_by_user_id, created_at '
             . 'FROM reporters ORDER BY id DESC LIMIT :limit OFFSET :offset',
             . 'FROM reporters ORDER BY id DESC LIMIT :limit OFFSET :offset',
             ['limit' => $limit, 'offset' => $offset]
             ['limit' => $limit, 'offset' => $offset]
         );
         );
@@ -73,6 +73,7 @@ final class ReporterRepository
             'description' => $description,
             'description' => $description,
             'trust_weight' => number_format($trustWeight, 2, '.', ''),
             'trust_weight' => number_format($trustWeight, 2, '.', ''),
             'is_active' => 1,
             'is_active' => 1,
+            'audit_enabled' => 1,
             'created_by_user_id' => $createdByUserId,
             'created_by_user_id' => $createdByUserId,
         ]);
         ]);
 
 
@@ -118,6 +119,7 @@ final class ReporterRepository
             description: $row['description'] !== null ? (string) $row['description'] : null,
             description: $row['description'] !== null ? (string) $row['description'] : null,
             trustWeight: (float) $row['trust_weight'],
             trustWeight: (float) $row['trust_weight'],
             isActive: (bool) $row['is_active'],
             isActive: (bool) $row['is_active'],
+            auditEnabled: (bool) ($row['audit_enabled'] ?? true),
             createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
             createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
             createdAt: $createdAt,
             createdAt: $createdAt,
         );
         );

+ 26 - 0
api/tests/Integration/Admin/ConsumersControllerTest.php

@@ -47,4 +47,30 @@ final class ConsumersControllerTest extends AppTestCase
         self::assertSame(400, $resp->getStatusCode());
         self::assertSame(400, $resp->getStatusCode());
         self::assertArrayHasKey('policy_id', $this->decode($resp)['details']);
         self::assertArrayHasKey('policy_id', $this->decode($resp)['details']);
     }
     }
+
+    public function testPatchTogglesAuditEnabled(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :name', ['name' => 'moderate']);
+
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/consumers',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'fw-audit-toggle', 'policy_id' => $policyId]) ?: null,
+        );
+        self::assertSame(201, $created->getStatusCode());
+        $body = $this->decode($created);
+        self::assertTrue($body['audit_enabled']);
+        $consumerId = (int) $body['id'];
+
+        $patch = $this->request(
+            'PATCH',
+            "/api/v1/admin/consumers/{$consumerId}",
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['audit_enabled' => false]) ?: null,
+        );
+        self::assertSame(200, $patch->getStatusCode());
+        self::assertFalse($this->decode($patch)['audit_enabled']);
+    }
 }
 }

+ 21 - 0
api/tests/Integration/Admin/ReportersControllerTest.php

@@ -100,6 +100,27 @@ final class ReportersControllerTest extends AppTestCase
         self::assertFalse($body['is_active']);
         self::assertFalse($body['is_active']);
     }
     }
 
 
+    public function testPatchTogglesAuditEnabled(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-audit-toggle');
+
+        // Default is true on create.
+        $detail = $this->request('GET', "/api/v1/admin/reporters/{$reporterId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertTrue($this->decode($detail)['audit_enabled']);
+
+        $patch = $this->request(
+            'PATCH',
+            "/api/v1/admin/reporters/{$reporterId}",
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['audit_enabled' => false]) ?: null,
+        );
+        self::assertSame(200, $patch->getStatusCode());
+        self::assertFalse($this->decode($patch)['audit_enabled']);
+    }
+
     public function testDeleteWithoutReportsSoftDeletes(): void
     public function testDeleteWithoutReportsSoftDeletes(): void
     {
     {
         $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
         $token = $this->createToken(TokenKind::Admin, role: Role::Admin);

+ 36 - 0
api/tests/Integration/Audit/PublicEndpointAuditTest.php

@@ -136,6 +136,42 @@ final class PublicEndpointAuditTest extends AppTestCase
         self::assertSame(0, $count);
         self::assertSame(0, $count);
     }
     }
 
 
+    public function testReportSuppressedWhenReporterAuditDisabled(): void
+    {
+        $reporterId = $this->createReporter('rep-silent');
+        $this->db->update('reporters', ['audit_enabled' => 0], ['id' => $reporterId]);
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode(['ip' => '203.0.113.99', 'category' => 'spam']),
+        );
+        self::assertSame(202, $resp->getStatusCode());
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
+        );
+        self::assertSame(0, $count);
+    }
+
+    public function testBlocklistSuppressedWhenConsumerAuditDisabled(): void
+    {
+        $token = $this->setupConsumerToken('moderate', 'fw-silent');
+        $this->db->update('consumers', ['audit_enabled' => 0], ['name' => 'fw-silent']);
+
+        $resp = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action = 'blocklist.requested'"
+        );
+        self::assertSame(0, $count);
+    }
+
     public function testFailedReportDoesNotEmitAudit(): void
     public function testFailedReportDoesNotEmitAudit(): void
     {
     {
         $reporterId = $this->createReporter('rep-bad');
         $reporterId = $this->createReporter('rep-bad');

+ 1 - 0
ui/CHANGELOG.md

@@ -18,6 +18,7 @@ with the api's tags in this monorepo.
 
 
 ### Added
 ### Added
 - Settings page now shows two **Audit toggles** for switching off the public-endpoint audit emissions (reporter `POST /report` and consumer `GET /blocklist`) without restarting the api. Posts to a new `/app/settings/audit-toggles` BFF route that PATCHes `/api/v1/admin/app-settings`.
 - Settings page now shows two **Audit toggles** for switching off the public-endpoint audit emissions (reporter `POST /report` and consumer `GET /blocklist`) without restarting the api. Posts to a new `/app/settings/audit-toggles` BFF route that PATCHes `/api/v1/admin/app-settings`.
+- Per-entity audit-log toggle on the reporter and consumer edit pages. Combined with the global Settings toggle via AND so either side is sufficient to silence the audit row.
 
 
 ## [1.0.0] — 2026-05-01
 ## [1.0.0] — 2026-05-01
 
 

+ 12 - 0
ui/resources/views/pages/consumers/edit.twig

@@ -35,6 +35,18 @@
                     active
                     active
                 </label>
                 </label>
             </div>
             </div>
+            <div class="md:col-span-2">
+                {# Hidden 0 sent first so an unchecked box still posts a value; PHP's parser keeps the last occurrence. #}
+                <input type="hidden" name="audit_enabled" value="0">
+                <label class="flex items-start gap-2 text-xs text-slate-600 dark:text-slate-400">
+                    <input type="checkbox" name="audit_enabled" value="1" class="mt-0.5"
+                           {% if consumer.audit_enabled %}checked{% endif %} {% if not can_write %}disabled{% endif %}>
+                    <span>
+                        <span class="font-medium text-slate-700 dark:text-slate-200">Audit log: write a <code>blocklist.requested</code> entry per pull</span>
+                        <span class="block text-[0.7rem] text-slate-500 dark:text-slate-400">Off silences this consumer even when the global toggle is on (Settings → Audit toggles).</span>
+                    </span>
+                </label>
+            </div>
         </div>
         </div>
         {% if can_write %}
         {% if can_write %}
             <div class="mt-4 flex justify-end">
             <div class="mt-4 flex justify-end">

+ 12 - 0
ui/resources/views/pages/reporters/edit.twig

@@ -32,6 +32,18 @@
                     active
                     active
                 </label>
                 </label>
             </div>
             </div>
+            <div class="md:col-span-2">
+                {# Hidden 0 sent first so an unchecked box still posts a value; PHP's parser keeps the last occurrence. #}
+                <input type="hidden" name="audit_enabled" value="0">
+                <label class="flex items-start gap-2 text-xs text-slate-600 dark:text-slate-400">
+                    <input type="checkbox" name="audit_enabled" value="1" class="mt-0.5"
+                           {% if reporter.audit_enabled %}checked{% endif %} {% if not can_write %}disabled{% endif %}>
+                    <span>
+                        <span class="font-medium text-slate-700 dark:text-slate-200">Audit log: write a <code>report.received</code> entry per ingest</span>
+                        <span class="block text-[0.7rem] text-slate-500 dark:text-slate-400">Off silences this reporter even when the global toggle is on (Settings → Audit toggles).</span>
+                    </span>
+                </label>
+            </div>
         </div>
         </div>
         {% if can_write %}
         {% if can_write %}
             <div class="mt-4 flex justify-end">
             <div class="mt-4 flex justify-end">

+ 3 - 0
ui/src/Controllers/ConsumersController.php

@@ -182,6 +182,9 @@ final class ConsumersController
         if (array_key_exists('is_active', $body)) {
         if (array_key_exists('is_active', $body)) {
             $payload['is_active'] = $this->formBool($body['is_active']);
             $payload['is_active'] = $this->formBool($body['is_active']);
         }
         }
+        if (array_key_exists('audit_enabled', $body)) {
+            $payload['audit_enabled'] = $this->formBool($body['audit_enabled']);
+        }
 
 
         try {
         try {
             $this->admin->updateConsumer($user->userId, $id, $payload);
             $this->admin->updateConsumer($user->userId, $id, $payload);

+ 3 - 0
ui/src/Controllers/ReportersController.php

@@ -175,6 +175,9 @@ final class ReportersController
         if (array_key_exists('is_active', $body)) {
         if (array_key_exists('is_active', $body)) {
             $payload['is_active'] = $this->formBool($body['is_active']);
             $payload['is_active'] = $this->formBool($body['is_active']);
         }
         }
+        if (array_key_exists('audit_enabled', $body)) {
+            $payload['audit_enabled'] = $this->formBool($body['audit_enabled']);
+        }
 
 
         try {
         try {
             $this->admin->updateReporter($user->userId, $id, $payload);
             $this->admin->updateReporter($user->userId, $id, $payload);