Selaa lähdekoodia

feat: audit reporter ingest and consumer blocklist pulls with toggles

Adds two new audit-log entries — `report.received` on `POST /api/v1/report`
and `blocklist.requested` on `GET /api/v1/blocklist` (including 304s) —
attributed to the reporter or consumer principal. Each is gated by a
runtime feature flag so admins can silence the high-volume rows without
a container restart.

A small `app_settings` key/value table backs the toggles, exposed via
`GET/PATCH /api/v1/admin/app-settings` and surfaced as two switches in
the UI Settings page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 viikko sitten
vanhempi
sitoutus
61a26affe1

+ 7 - 0
api/CHANGELOG.md

@@ -13,6 +13,13 @@ change to that contract that consumers must adapt to.
 Tags use the `api-v<MAJOR>.<MINOR>.<PATCH>` form so they don't collide
 with the UI's tags in this monorepo.
 
+## [Unreleased]
+
+### 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.
+- `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.
+- New audit actions: `report.received`, `blocklist.requested`, `app_settings.updated`.
+
 ## [1.0.0] — 2026-05-01
 
 First stable release. Implements every milestone of `SPEC.md` from the

+ 34 - 0
api/db/migrations/20260501130000_create_app_settings.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * Runtime-mutable feature flags. SPEC §M12.5 settings page is otherwise
+ * read-only of env config; a small key/value table here lets admins
+ * toggle feature flags (e.g. audit emission for high-volume public
+ * endpoints) without restarting the api container.
+ *
+ * Seeds the two audit-emission flags as enabled. Defaults at the
+ * application layer mirror these so a missing row is treated as enabled.
+ */
+final class CreateAppSettings extends BaseMigration
+{
+    public function change(): void
+    {
+        $table = $this->table('app_settings', ['id' => false, 'primary_key' => ['key']]);
+        $table
+            ->addColumn('key', 'string', ['limit' => 64, 'null' => false])
+            ->addColumn('value', 'text', ['null' => true]);
+
+        $this->addTimestampColumn($table, 'updated_at');
+
+        $table->create();
+
+        $this->table('app_settings')->insert([
+            ['key' => 'audit_report_received_enabled', 'value' => '1'],
+            ['key' => 'audit_blocklist_request_enabled', 'value' => '1'],
+        ])->saveData();
+    }
+}

+ 55 - 0
api/public/openapi.yaml

@@ -1086,6 +1086,61 @@ paths:
                     type: object
                     additionalProperties:
                       type: object
+  '/api/v1/admin/app-settings':
+    get:
+      tags:
+        - Admin
+      summary: Runtime feature flags (Admin)
+      description: |
+        Returns the runtime-mutable feature flags. Currently exposes the
+        audit-emission toggles for high-volume public endpoints
+        (`audit_report_received_enabled`, `audit_blocklist_request_enabled`).
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: Current toggle state
+          content:
+            'application/json':
+              schema:
+                type: object
+                additionalProperties:
+                  type: boolean
+    patch:
+      tags:
+        - Admin
+      summary: Update runtime feature flags (Admin)
+      description: |
+        Updates one or more runtime feature flags. Body keys not listed are left
+        untouched. Returns the post-update snapshot.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                audit_report_received_enabled:
+                  type: boolean
+                audit_blocklist_request_enabled:
+                  type: boolean
+      responses:
+        '200':
+          description: Updated snapshot
+          content:
+            'application/json':
+              schema:
+                type: object
+                additionalProperties:
+                  type: boolean
+        '400':
+          description: Validation error
   '/api/v1/admin/maintenance/purge':
     post:
       tags:

+ 10 - 0
api/src/App/AppFactory.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\App;
 
 use App\Application\Admin\AllowlistController;
+use App\Application\Admin\AppSettingsController;
 use App\Application\Admin\AuditController;
 use App\Application\Admin\CategoriesController;
 use App\Application\Admin\ConfigController;
@@ -180,6 +181,7 @@ final class AppFactory
             $public->get('/blocklist', $blocklist);
         })
             ->add($rateLimit)
+            ->add($auditContext)
             ->add($tokenAuth);
 
         // Admin API: token auth → impersonation → role check.
@@ -292,6 +294,14 @@ final class AppFactory
             $admin->get('/config', [$config, 'show'])
                 ->add(RbacMiddleware::require($rf, Role::Admin));
 
+            // Runtime feature flags (audit-emission toggles). Admin only.
+            /** @var AppSettingsController $appSettings */
+            $appSettings = $container->get(AppSettingsController::class);
+            $admin->get('/app-settings', [$appSettings, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+            $admin->patch('/app-settings', [$appSettings, 'update'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+
             // Demo / maintenance — Admin only. Both wipe and seed are
             // destructive in different directions; the UI guards each with a
             // confirmation modal and the purge body must include the literal

+ 5 - 0
api/src/App/Container.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\App;
 
 use App\Application\Admin\AllowlistController;
+use App\Application\Admin\AppSettingsController;
 use App\Application\Admin\AuditController;
 use App\Application\Admin\CategoriesController;
 use App\Application\Admin\ConfigController;
@@ -37,6 +38,7 @@ use App\Domain\Enrichment\EnrichmentService;
 use App\Domain\Reputation\BlocklistBuilder;
 use App\Domain\Reputation\EffectiveStatusService;
 use App\Domain\Reputation\PairScorer;
+use App\Domain\Settings\AppSettings;
 use App\Domain\Time\Clock;
 use App\Domain\Time\SystemClock;
 use App\Infrastructure\Allowlist\AllowlistRepository;
@@ -79,6 +81,7 @@ use App\Infrastructure\Reputation\IpEnrichmentRepository;
 use App\Infrastructure\Reputation\IpHistoryRepository;
 use App\Infrastructure\Reputation\IpScoreRepository;
 use App\Infrastructure\Reputation\ReportRepository;
+use App\Infrastructure\Settings\DbAppSettings;
 
 use function DI\autowire;
 
@@ -213,6 +216,7 @@ final class Container
             AuditContextMiddleware::class => autowire(),
             AuditRepository::class => autowire(),
             AuditEmitter::class => autowire(DbAuditEmitter::class),
+            AppSettings::class => autowire(DbAppSettings::class),
             PairScorer::class => factory(static function (ContainerInterface $c): PairScorer {
                 /** @var ReportRepository $reports */
                 $reports = $c->get(ReportRepository::class);
@@ -431,6 +435,7 @@ final class Container
             AuditController::class => autowire(),
             JobsAdminController::class => autowire(),
             MaintenanceController::class => autowire(),
+            AppSettingsController::class => autowire(),
             ConfigController::class => factory(static function (ContainerInterface $c): ConfigController {
                 /** @var array<string, mixed> $settings */
                 $settings = $c->get('settings');

+ 103 - 0
api/src/Application/Admin/AppSettingsController.php

@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
+use App\Domain\Settings\AppSettings;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `GET/PATCH /api/v1/admin/app-settings` — runtime-mutable feature flags.
+ *
+ * Currently exposes the two audit-emission toggles (report.received and
+ * blocklist.requested) which are surfaced as switches on the settings
+ * page so an admin can silence the high-volume audit rows without
+ * restarting the api container.
+ *
+ * RBAC: Admin only — same trust level as the rest of the settings page.
+ */
+final class AppSettingsController
+{
+    use AdminControllerSupport;
+
+    private const KEYS = [
+        AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED,
+        AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED,
+    ];
+
+    public function __construct(
+        private readonly AppSettings $settings,
+        private readonly AuditEmitter $audit,
+    ) {
+    }
+
+    public function show(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        return self::json($response, 200, $this->snapshot());
+    }
+
+    public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+        $changes = [];
+
+        foreach (self::KEYS as $key) {
+            if (!array_key_exists($key, $body)) {
+                continue;
+            }
+            $raw = $body[$key];
+            if (!is_bool($raw) && !in_array($raw, [0, 1, '0', '1', 'true', 'false'], true)) {
+                $errors[$key] = 'must be a boolean';
+                continue;
+            }
+            $value = is_bool($raw) ? $raw : in_array($raw, [1, '1', 'true'], true);
+            $current = $this->settings->getBool($key, true);
+            if ($current !== $value) {
+                $changes[$key] = ['from' => $current, 'to' => $value];
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        foreach ($changes as $key => $diff) {
+            $this->settings->setBool($key, (bool) $diff['to']);
+        }
+
+        if ($changes !== []) {
+            $this->audit->emit(
+                AuditAction::APP_SETTINGS_UPDATED,
+                'app_settings',
+                null,
+                ['changes' => $changes],
+                self::auditContext($request),
+                'audit-toggles',
+            );
+        }
+
+        return self::json($response, 200, $this->snapshot());
+    }
+
+    /**
+     * @return array<string, bool>
+     */
+    private function snapshot(): array
+    {
+        return [
+            AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED => $this->settings->getBool(
+                AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED,
+                true,
+            ),
+            AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED => $this->settings->getBool(
+                AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED,
+                true,
+            ),
+        ];
+    }
+}

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

@@ -4,11 +4,16 @@ declare(strict_types=1);
 
 namespace App\Application\Public;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditContext;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Auth\AuthenticatedPrincipal;
 use App\Domain\Auth\TokenKind;
 use App\Domain\Reputation\Blocklist;
 use App\Domain\Reputation\BlocklistEntry;
+use App\Domain\Settings\AppSettings;
 use App\Infrastructure\Consumer\ConsumerRepository;
+use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
 use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
 use App\Infrastructure\Policy\PolicyRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
@@ -43,6 +48,8 @@ final class BlocklistController
         private readonly ConsumerRepository $consumers,
         private readonly PolicyRepository $policies,
         private readonly BlocklistCache $blocklistCache,
+        private readonly AuditEmitter $audit,
+        private readonly AppSettings $settings,
     ) {
     }
 
@@ -78,7 +85,29 @@ final class BlocklistController
         $etag = '"' . hash('sha256', $body) . '"';
 
         $ifNoneMatch = self::extractIfNoneMatch($request);
-        if ($ifNoneMatch !== null && self::etagMatches($ifNoneMatch, $etag)) {
+        $notModified = $ifNoneMatch !== null && self::etagMatches($ifNoneMatch, $etag);
+        $status = $notModified ? 304 : 200;
+
+        if ($this->settings->getBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, true)) {
+            $this->audit->emit(
+                AuditAction::BLOCKLIST_REQUESTED,
+                'blocklist',
+                $policy->id,
+                [
+                    'consumer_id' => $consumer->id,
+                    'consumer_name' => $consumer->name,
+                    'policy_id' => $policy->id,
+                    'policy_name' => $policy->name,
+                    'entries' => $blocklist->count(),
+                    'format' => $isJson ? 'json' : 'text',
+                    'status' => $status,
+                ],
+                self::auditContext($request),
+                $policy->name,
+            );
+        }
+
+        if ($notModified) {
             return $response
                 ->withStatus(304)
                 ->withHeader('ETag', $etag)
@@ -98,6 +127,13 @@ final class BlocklistController
             ->withHeader('X-Blocklist-Policy', $blocklist->policyName);
     }
 
+    private static function auditContext(ServerRequestInterface $request): AuditContext
+    {
+        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);
+
+        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
+    }
+
     private static function renderText(Blocklist $blocklist): string
     {
         if ($blocklist->entries === []) {

+ 31 - 0
api/src/Application/Public/ReportController.php

@@ -4,13 +4,18 @@ declare(strict_types=1);
 
 namespace App\Application\Public;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditContext;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Auth\AuthenticatedPrincipal;
 use App\Domain\Auth\TokenKind;
 use App\Domain\Ip\InvalidIpException;
 use App\Domain\Ip\IpAddress;
 use App\Domain\Reputation\PairScorer;
+use App\Domain\Settings\AppSettings;
 use App\Domain\Time\Clock;
 use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
 use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
 use App\Infrastructure\Reporter\ReporterRepository;
 use App\Infrastructure\Reputation\IpScoreRepository;
@@ -45,6 +50,8 @@ final class ReportController
         private readonly IpScoreRepository $ipScores,
         private readonly PairScorer $scorer,
         private readonly Clock $clock,
+        private readonly AuditEmitter $audit,
+        private readonly AppSettings $settings,
     ) {
     }
 
@@ -137,6 +144,23 @@ final class ReportController
             recomputedAt: $now,
         );
 
+        if ($this->settings->getBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, true)) {
+            $this->audit->emit(
+                AuditAction::REPORT_RECEIVED,
+                'report',
+                $reportId,
+                [
+                    'ip' => $ip->text(),
+                    'category' => $category->slug,
+                    'reporter_id' => $reporter->id,
+                    'reporter_name' => $reporter->name,
+                    'has_metadata' => $metadataJson !== null,
+                ],
+                self::auditContext($request),
+                $ip->text(),
+            );
+        }
+
         return self::json($response, 202, [
             'report_id' => $reportId,
             'ip' => $ip->text(),
@@ -144,6 +168,13 @@ final class ReportController
         ]);
     }
 
+    private static function auditContext(ServerRequestInterface $request): AuditContext
+    {
+        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);
+
+        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
+    }
+
     /**
      * @return array<string, mixed>
      */

+ 5 - 0
api/src/Domain/Audit/AuditAction.php

@@ -52,6 +52,11 @@ final class AuditAction
     public const MAINTENANCE_PURGED = 'maintenance.purged';
     public const MAINTENANCE_SEEDED = 'maintenance.seeded';
 
+    public const REPORT_RECEIVED = 'report.received';
+    public const BLOCKLIST_REQUESTED = 'blocklist.requested';
+
+    public const APP_SETTINGS_UPDATED = 'app_settings.updated';
+
     public static function entityTypeFor(string $action): string
     {
         $dot = strpos($action, '.');

+ 27 - 0
api/src/Domain/Settings/AppSettings.php

@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Settings;
+
+/**
+ * Runtime-mutable feature flags backed by the `app_settings` table.
+ *
+ * Keys not stored in the table fall back to the caller-supplied default
+ * so a fresh database (or a stripped row) is treated as enabled rather
+ * than silently turning a feature off.
+ */
+interface AppSettings
+{
+    public const KEY_AUDIT_REPORT_RECEIVED_ENABLED = 'audit_report_received_enabled';
+    public const KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED = 'audit_blocklist_request_enabled';
+
+    public function getBool(string $key, bool $default): bool;
+
+    public function setBool(string $key, bool $value): void;
+
+    /**
+     * @return array<string, string>
+     */
+    public function all(): array;
+}

+ 74 - 0
api/src/Infrastructure/Settings/DbAppSettings.php

@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Settings;
+
+use App\Domain\Settings\AppSettings;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Db\RepositoryBase;
+use Doctrine\DBAL\Connection;
+
+/**
+ * `app_settings` is a tiny key/value table. Booleans are stored as
+ * `'1'` / `'0'` strings to keep the column type uniform across drivers.
+ */
+final class DbAppSettings extends RepositoryBase implements AppSettings
+{
+    public function __construct(
+        Connection $connection,
+        private readonly Clock $clock,
+    ) {
+        parent::__construct($connection);
+    }
+
+    public function getBool(string $key, bool $default): bool
+    {
+        $value = $this->connection()->fetchOne(
+            'SELECT value FROM app_settings WHERE key = :key',
+            ['key' => $key],
+        );
+        if ($value === false || $value === null) {
+            return $default;
+        }
+
+        return (string) $value === '1';
+    }
+
+    public function setBool(string $key, bool $value): void
+    {
+        $now = $this->clock->now()->format('Y-m-d H:i:s');
+        $stored = $value ? '1' : '0';
+
+        $exists = (int) $this->connection()->fetchOne(
+            'SELECT COUNT(*) FROM app_settings WHERE key = :key',
+            ['key' => $key],
+        );
+        if ($exists > 0) {
+            $this->connection()->executeStatement(
+                'UPDATE app_settings SET value = :value, updated_at = :updated_at WHERE key = :key',
+                ['value' => $stored, 'updated_at' => $now, 'key' => $key],
+            );
+
+            return;
+        }
+
+        $this->connection()->executeStatement(
+            'INSERT INTO app_settings (key, value, updated_at) VALUES (:key, :value, :updated_at)',
+            ['key' => $key, 'value' => $stored, 'updated_at' => $now],
+        );
+    }
+
+    public function all(): array
+    {
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT key, value FROM app_settings',
+        );
+        $out = [];
+        foreach ($rows as $row) {
+            $out[(string) $row['key']] = $row['value'] === null ? '' : (string) $row['value'];
+        }
+
+        return $out;
+    }
+}

+ 97 - 0
api/tests/Integration/Admin/AppSettingsControllerTest.php

@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Settings\AppSettings;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class AppSettingsControllerTest extends AppTestCase
+{
+    public function testGetReturnsCurrentSnapshotAsBooleans(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $resp = $this->request('GET', '/api/v1/admin/app-settings', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertTrue($body['audit_report_received_enabled']);
+        self::assertTrue($body['audit_blocklist_request_enabled']);
+    }
+
+    public function testNonAdminRoleIsRejected(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+
+        $resp = $this->request('GET', '/api/v1/admin/app-settings', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(403, $resp->getStatusCode());
+    }
+
+    public function testPatchPersistsToggleAndEmitsAudit(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $resp = $this->request(
+            'PATCH',
+            '/api/v1/admin/app-settings',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode(['audit_report_received_enabled' => false]),
+        );
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertFalse($body['audit_report_received_enabled']);
+        self::assertTrue($body['audit_blocklist_request_enabled']);
+
+        /** @var AppSettings $settings */
+        $settings = $this->container->get(AppSettings::class);
+        self::assertFalse($settings->getBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, true));
+
+        $row = $this->db->fetchAssociative(
+            "SELECT details_json FROM audit_log WHERE action = 'app_settings.updated' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertArrayHasKey('audit_report_received_enabled', $details['changes']);
+        self::assertSame(true, $details['changes']['audit_report_received_enabled']['from']);
+        self::assertSame(false, $details['changes']['audit_report_received_enabled']['to']);
+    }
+
+    public function testPatchWithNoChangesDoesNotEmitAudit(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $resp = $this->request(
+            'PATCH',
+            '/api/v1/admin/app-settings',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode(['audit_report_received_enabled' => true]),
+        );
+        self::assertSame(200, $resp->getStatusCode());
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action = 'app_settings.updated'"
+        );
+        self::assertSame(0, $count);
+    }
+
+    public function testPatchValidatesBooleanShape(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $resp = $this->request(
+            'PATCH',
+            '/api/v1/admin/app-settings',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            (string) json_encode(['audit_report_received_enabled' => 'maybe']),
+        );
+        self::assertSame(400, $resp->getStatusCode());
+    }
+}

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

@@ -0,0 +1,173 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Audit;
+
+use App\Domain\Auth\TokenKind;
+use App\Domain\Settings\AppSettings;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * SPEC §M12 covers admin-side audit emission. C20 adds two public-endpoint
+ * audit entries — `report.received` and `blocklist.requested` — gated by
+ * runtime feature flags so the high-volume rows can be silenced without
+ * restarting the api.
+ */
+final class PublicEndpointAuditTest extends AppTestCase
+{
+    public function testReportReceivedEmitsAuditWithReporterActor(): void
+    {
+        $reporterId = $this->createReporter('rep-audit');
+        $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.42', 'category' => 'brute_force', 'metadata' => ['ua' => 'curl']]),
+        );
+        self::assertSame(202, $resp->getStatusCode());
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, actor_id, action, target_type, target_label, details_json FROM audit_log WHERE action = 'report.received' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('reporter', $row['actor_kind']);
+        self::assertSame((string) $reporterId, $row['actor_id']);
+        self::assertSame('report', $row['target_type']);
+        self::assertSame('203.0.113.42', $row['target_label']);
+
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame('203.0.113.42', $details['ip']);
+        self::assertSame('brute_force', $details['category']);
+        self::assertSame($reporterId, $details['reporter_id']);
+        self::assertSame('rep-audit', $details['reporter_name']);
+        self::assertTrue($details['has_metadata']);
+    }
+
+    public function testReportReceivedSuppressedWhenToggleDisabled(): void
+    {
+        /** @var AppSettings $settings */
+        $settings = $this->container->get(AppSettings::class);
+        $settings->setBool(AppSettings::KEY_AUDIT_REPORT_RECEIVED_ENABLED, false);
+
+        $reporterId = $this->createReporter('rep-quiet');
+        $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' => '198.51.100.7', 'category' => 'scanner']),
+        );
+        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 testBlocklistRequestedEmitsAuditWithConsumerActor(): void
+    {
+        $token = $this->setupConsumerToken('moderate', 'fw-audit');
+        $resp = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $resp->getStatusCode());
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, actor_id, action, target_type, target_label, details_json FROM audit_log WHERE action = 'blocklist.requested' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('consumer', $row['actor_kind']);
+        self::assertNotNull($row['actor_id']);
+        self::assertSame('blocklist', $row['target_type']);
+        self::assertSame('moderate', $row['target_label']);
+
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame('moderate', $details['policy_name']);
+        self::assertSame('text', $details['format']);
+        self::assertSame(200, $details['status']);
+        self::assertArrayHasKey('entries', $details);
+    }
+
+    public function testBlocklist304StillEmitsAuditWithStatus304(): void
+    {
+        $token = $this->setupConsumerToken('moderate', 'fw-etag');
+        $first = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        $etag = $first->getHeaderLine('ETag');
+
+        $second = $this->request('GET', '/api/v1/blocklist', [
+            'Authorization' => 'Bearer ' . $token,
+            'If-None-Match' => $etag,
+        ]);
+        self::assertSame(304, $second->getStatusCode());
+
+        $statuses = $this->db->fetchAllAssociative(
+            "SELECT details_json FROM audit_log WHERE action = 'blocklist.requested' ORDER BY id ASC"
+        );
+        self::assertCount(2, $statuses);
+        $second = json_decode((string) $statuses[1]['details_json'], true);
+        self::assertIsArray($second);
+        self::assertSame(304, $second['status']);
+    }
+
+    public function testBlocklistRequestedSuppressedWhenToggleDisabled(): void
+    {
+        /** @var AppSettings $settings */
+        $settings = $this->container->get(AppSettings::class);
+        $settings->setBool(AppSettings::KEY_AUDIT_BLOCKLIST_REQUEST_ENABLED, false);
+
+        $token = $this->setupConsumerToken('moderate', 'fw-quiet');
+        $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
+    {
+        $reporterId = $this->createReporter('rep-bad');
+        $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' => 'not-an-ip', 'category' => 'spam']),
+        );
+        self::assertSame(400, $resp->getStatusCode());
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action = 'report.received'"
+        );
+        self::assertSame(0, $count);
+    }
+
+    private function setupConsumerToken(string $policyName, string $consumerName): string
+    {
+        $policyId = (int) $this->db->fetchOne(
+            'SELECT id FROM policies WHERE name = :n',
+            ['n' => $policyName],
+        );
+        $this->db->insert('consumers', [
+            'name' => $consumerName,
+            'policy_id' => $policyId,
+            'is_active' => 1,
+        ]);
+        $consumerId = (int) $this->db->lastInsertId();
+
+        return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
+    }
+}

+ 5 - 0
ui/CHANGELOG.md

@@ -14,6 +14,11 @@ the api is owned by the `api` container's changelog.
 Tags use the `ui-v<MAJOR>.<MINOR>.<PATCH>` form so they don't collide
 with the api's tags in this monorepo.
 
+## [Unreleased]
+
+### 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`.
+
 ## [1.0.0] — 2026-05-01
 
 First stable release. Implements every milestone of `SPEC.md` from the

+ 39 - 0
ui/resources/views/pages/settings/index.twig

@@ -43,6 +43,45 @@
         </section>
     {% endif %}
 
+    {# ------------------------- Audit toggles ------------------------- #}
+    {% if app_settings is not null %}
+        <section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Audit toggles</h2>
+            <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">High-volume public endpoints can be excluded from the audit log to keep the table compact. Each switch is independent; changes take effect immediately.</p>
+
+            <form method="post" action="/app/settings/audit-toggles" class="mt-4 space-y-3">
+                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+
+                <label class="flex items-start gap-3 rounded-lg border border-slate-100 px-3 py-2 dark:border-slate-800">
+                    <input type="checkbox" name="audit_report_received_enabled" value="1"
+                           class="mt-0.5 h-4 w-4 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
+                           {% if app_settings.audit_report_received_enabled %}checked{% endif %}>
+                    <span class="text-sm">
+                        <span class="font-medium text-slate-700 dark:text-slate-200">Log when a reporter submits an IP</span>
+                        <span class="block text-xs text-slate-500 dark:text-slate-400">Each <code>POST /api/v1/report</code> writes a <code>report.received</code> entry.</span>
+                    </span>
+                </label>
+
+                <label class="flex items-start gap-3 rounded-lg border border-slate-100 px-3 py-2 dark:border-slate-800">
+                    <input type="checkbox" name="audit_blocklist_request_enabled" value="1"
+                           class="mt-0.5 h-4 w-4 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
+                           {% if app_settings.audit_blocklist_request_enabled %}checked{% endif %}>
+                    <span class="text-sm">
+                        <span class="font-medium text-slate-700 dark:text-slate-200">Log when a consumer requests the ban list</span>
+                        <span class="block text-xs text-slate-500 dark:text-slate-400">Each <code>GET /api/v1/blocklist</code> writes a <code>blocklist.requested</code> entry (including 304s).</span>
+                    </span>
+                </label>
+
+                <div class="flex justify-end">
+                    <button type="submit"
+                            class="rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-600 dark:bg-slate-200 dark:text-slate-900 dark:hover:bg-white">
+                        Save
+                    </button>
+                </div>
+            </form>
+        </section>
+    {% endif %}
+
     {# ------------------------------ Jobs ----------------------------- #}
     {% if jobs and jobs.jobs %}
         <section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">

+ 36 - 0
ui/src/ApiClient/AdminClient.php

@@ -406,6 +406,42 @@ final class AdminClient
         return $this->api->request('GET', '/api/v1/admin/config', [], $actingUserId);
     }
 
+    /**
+     * Runtime feature flags (audit-emission toggles).
+     *
+     * @return array<string, bool>
+     */
+    public function getAppSettings(int $actingUserId): array
+    {
+        $payload = $this->api->request('GET', '/api/v1/admin/app-settings', [], $actingUserId);
+        $out = [];
+        foreach ($payload as $k => $v) {
+            $out[(string) $k] = (bool) $v;
+        }
+
+        return $out;
+    }
+
+    /**
+     * @param array<string, bool> $values
+     * @return array<string, bool>
+     */
+    public function updateAppSettings(int $actingUserId, array $values): array
+    {
+        $payload = $this->api->request(
+            'PATCH',
+            '/api/v1/admin/app-settings',
+            ['json' => $values],
+            $actingUserId,
+        );
+        $out = [];
+        foreach ($payload as $k => $v) {
+            $out[(string) $k] = (bool) $v;
+        }
+
+        return $out;
+    }
+
     /**
      * Wipe operational data on the api side. The API requires
      * `confirm: "PURGE"` in the body — anything else returns 400.

+ 1 - 0
ui/src/App/AppFactory.php

@@ -199,6 +199,7 @@ final class AppFactory
             $group->post('/settings/jobs/trigger/{name}', [$settings, 'trigger']);
             $group->post('/settings/maintenance/purge', [$settings, 'purge']);
             $group->post('/settings/maintenance/seed-demo', [$settings, 'seedDemo']);
+            $group->post('/settings/audit-toggles', [$settings, 'updateAuditToggles']);
         })->add($authRequired);
 
         $app->map(

+ 37 - 0
ui/src/Controllers/SettingsController.php

@@ -64,10 +64,12 @@ final class SettingsController
 
         $config = null;
         $jobs = null;
+        $appSettings = null;
         $error = null;
         try {
             $config = $this->admin->getConfig($user->userId);
             $jobs = $this->admin->getJobsStatus($user->userId);
+            $appSettings = $this->admin->getAppSettings($user->userId);
         } catch (ApiAuthException) {
             return $response->withStatus(303)->withHeader('Location', '/no-access');
         } catch (ApiException $e) {
@@ -78,10 +80,45 @@ final class SettingsController
             'active_section' => 'settings',
             'config' => $config,
             'jobs' => $jobs,
+            'app_settings' => $appSettings,
             'error' => $error,
         ]);
     }
 
+    /**
+     * Persist the audit-emission toggles. Unchecked checkboxes don't
+     * appear in the form body, so we explicitly map every known key to
+     * a boolean and PATCH the snapshot.
+     */
+    public function updateAuditToggles(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        if (($redirect = $this->requireUser($request, $response)) !== null) {
+            return $redirect;
+        }
+        $user = $this->sessions()->getUser();
+        if ($user === null) {
+            return $response->withStatus(302)->withHeader('Location', '/login');
+        }
+        if (!$this->userIs($user, 'admin')) {
+            return $response->withStatus(303)->withHeader('Location', '/no-access');
+        }
+
+        $body = $this->formBody($request);
+        $payload = [
+            'audit_report_received_enabled' => isset($body['audit_report_received_enabled']),
+            'audit_blocklist_request_enabled' => isset($body['audit_blocklist_request_enabled']),
+        ];
+
+        try {
+            $this->admin->updateAppSettings($user->userId, $payload);
+            $this->sessions()->flash('success', 'Audit toggles updated.');
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/settings');
+    }
+
     /**
      * @param array{name: string} $args
      */

+ 5 - 0
ui/tests/Integration/Settings/SettingsPageTest.php

@@ -53,6 +53,11 @@ final class SettingsPageTest extends AppTestCase
                 ],
             ],
         ]);
+        // Third call: getAppSettings (audit toggles)
+        $this->enqueueApiResponse(200, [
+            'audit_report_received_enabled' => true,
+            'audit_blocklist_request_enabled' => true,
+        ]);
 
         $resp = $this->request('GET', '/app/settings');