Sfoglia il codice sorgente

feat(settings): add demo data + database purge actions

Adds two admin-only maintenance actions to the Settings page so an
admin can either populate the system with sample data for demos and
documentation screenshots, or wipe operational data to start clean.

API
  POST /api/v1/admin/maintenance/seed-demo
    Inserts demo reporters, consumers, IPs across multiple countries,
    reports spread across the past 30 days, manual blocks, allowlist
    entries, and synthetic GeoIP, then triggers a full score
    recompute. Idempotent: returns 409 when demo data already exists.

  POST /api/v1/admin/maintenance/purge
    Wipes reports, scores, enrichment, manual blocks, allowlist,
    audit log, job history, reporters, consumers, policies, and
    non-service tokens. Preserves users, OIDC role mappings, abuse
    categories, and the service token. Requires confirm: "PURGE" in
    the body.

UI
  Adds a "Demo & maintenance" section to /app/settings with a
  one-click load-demo modal and a separate purge modal that requires
  the operator to type "PURGE" before the submit button enables.

Audit, OpenAPI, and integration tests included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 settimana fa
parent
commit
4eaf3d9d4a

+ 28 - 0
api/openapi.php

@@ -556,6 +556,34 @@ $paths = [
     '/api/v1/admin/config' => [
         'get' => ['tags' => ['Admin'], 'summary' => 'Effective config (secrets masked)', 'description' => 'Admin only.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Config sections', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['sections' => ['type' => 'object', 'additionalProperties' => ['type' => 'object']]]]]]]]],
     ],
+    '/api/v1/admin/maintenance/purge' => [
+        'post' => [
+            'tags' => ['Admin'],
+            'summary' => 'Wipe operational data (Admin)',
+            'description' => "Deletes reports, scores, enrichment, manual blocks, allowlist, audit log, job history, reporters, consumers, policies, and non-service tokens. Preserves users, OIDC role mappings, abuse categories, and the `service`-kind token.\n\nRequires `confirm: \"PURGE\"` in the body — any other value returns 400.",
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['PURGE']]]]]]],
+            'responses' => [
+                '200' => ['description' => 'Purge succeeded', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'purged'], 'deleted' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]]]]]],
+                '400' => ['description' => 'Missing or wrong `confirm` value'],
+            ],
+        ],
+    ],
+    '/api/v1/admin/maintenance/seed-demo' => [
+        'post' => [
+            'tags' => ['Admin'],
+            'summary' => 'Load demo dataset (Admin)',
+            'description' => "Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.",
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'responses' => [
+                '200' => ['description' => 'Demo data inserted', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'seeded'], 'summary' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], 'recompute' => ['$ref' => '#/components/schemas/JobOutcome']]]]]],
+                '409' => ['description' => 'Demo data already present'],
+                '412' => ['description' => 'No categories configured'],
+            ],
+        ],
+    ],
 
     // ---------- Auth (UI BFF only) ----------
     '/api/v1/auth/users/upsert-oidc' => [

+ 74 - 0
api/public/openapi.yaml

@@ -1086,6 +1086,80 @@ paths:
                     type: object
                     additionalProperties:
                       type: object
+  '/api/v1/admin/maintenance/purge':
+    post:
+      tags:
+        - Admin
+      summary: Wipe operational data (Admin)
+      description: |
+        Deletes reports, scores, enrichment, manual blocks, allowlist, audit log, job history, reporters, consumers, policies, and non-service tokens. Preserves users, OIDC role mappings, abuse categories, and the `service`-kind token.
+
+        Requires `confirm: "PURGE"` in the body — any other value returns 400.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              required:
+                - confirm
+              properties:
+                confirm:
+                  type: string
+                  enum:
+                    - PURGE
+      responses:
+        '200':
+          description: Purge succeeded
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    example: purged
+                  deleted:
+                    type: object
+                    additionalProperties:
+                      type: integer
+        '400':
+          description: Missing or wrong `confirm` value
+  '/api/v1/admin/maintenance/seed-demo':
+    post:
+      tags:
+        - Admin
+      summary: Load demo dataset (Admin)
+      description: 'Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.'
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: Demo data inserted
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  status:
+                    type: string
+                    example: seeded
+                  summary:
+                    type: object
+                    additionalProperties:
+                      type: integer
+                  recompute:
+                    '$ref': '#/components/schemas/JobOutcome'
+        '409':
+          description: Demo data already present
+        '412':
+          description: No categories configured
   '/api/v1/auth/users/upsert-oidc':
     post:
       tags:

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

@@ -11,6 +11,7 @@ use App\Application\Admin\ConfigController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\IpsController;
 use App\Application\Admin\JobsAdminController;
+use App\Application\Admin\MaintenanceController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\PoliciesController;
@@ -291,6 +292,17 @@ final class AppFactory
             $admin->get('/config', [$config, 'show'])
                 ->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
+            // "PURGE" string.
+            /** @var MaintenanceController $maintenance */
+            $maintenance = $container->get(MaintenanceController::class);
+            $admin->post('/maintenance/purge', [$maintenance, 'purge'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+            $admin->post('/maintenance/seed-demo', [$maintenance, 'seedDemo'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+
             // Policies: list/show/preview = Viewer; write = Admin.
             /** @var PoliciesController $policies */
             $policies = $container->get(PoliciesController::class);

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

@@ -11,6 +11,7 @@ use App\Application\Admin\ConfigController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\IpsController;
 use App\Application\Admin\JobsAdminController;
+use App\Application\Admin\MaintenanceController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\PoliciesController;
@@ -429,6 +430,7 @@ final class Container
             CategoriesController::class => autowire(),
             AuditController::class => autowire(),
             JobsAdminController::class => autowire(),
+            MaintenanceController::class => autowire(),
             ConfigController::class => factory(static function (ContainerInterface $c): ConfigController {
                 /** @var array<string, mixed> $settings */
                 $settings = $c->get('settings');

+ 375 - 0
api/src/Application/Admin/MaintenanceController.php

@@ -0,0 +1,375 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Application\Jobs\RecomputeScoresJob;
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Enrichment\EnrichmentResult;
+use App\Domain\Ip\Cidr;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Consumer\ConsumerRepository;
+use App\Infrastructure\Jobs\JobRunner;
+use App\Infrastructure\ManualBlock\ManualBlockRepository;
+use App\Infrastructure\Policy\PolicyRepository;
+use App\Infrastructure\Reporter\ReporterRepository;
+use App\Infrastructure\Reputation\IpEnrichmentRepository;
+use App\Infrastructure\Reputation\ReportRepository;
+use Doctrine\DBAL\Connection;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin-only "wipe everything" + "load demo dataset" surface.
+ *
+ *  - `POST /api/v1/admin/maintenance/purge` — wipes operational data
+ *    (reports, scores, blocks, audit, etc.) plus reporters/consumers/
+ *    tokens/policies. Preserves users, OIDC role mappings, categories,
+ *    and the `service`-kind token. Body must include `confirm: "PURGE"`
+ *    to execute.
+ *  - `POST /api/v1/admin/maintenance/seed-demo` — populates the database
+ *    with realistic-looking sample reporters, consumers, IPs, reports,
+ *    manual blocks, allowlist entries, and synthetic GeoIP. Triggers a
+ *    full score recompute on completion. Rejects with 409 if demo data
+ *    is already present.
+ */
+final class MaintenanceController
+{
+    use AdminControllerSupport;
+
+    private const DEMO_REPORTERS = [
+        ['name' => 'demo-web-edge', 'description' => 'Demo: edge webserver', 'trust_weight' => 1.0],
+        ['name' => 'demo-fail2ban', 'description' => 'Demo: fail2ban agents', 'trust_weight' => 1.2],
+        ['name' => 'demo-honeypot', 'description' => 'Demo: research honeypot', 'trust_weight' => 1.5],
+    ];
+
+    private const DEMO_CONSUMERS = [
+        ['name' => 'demo-firewall-prod', 'description' => 'Demo: production edge firewall', 'policy' => 'moderate'],
+        ['name' => 'demo-proxy-staging', 'description' => 'Demo: staging proxy', 'policy' => 'strict'],
+    ];
+
+    private const DEMO_MANUAL_BLOCKS = [
+        ['kind' => 'ip', 'value' => '198.51.100.42', 'reason' => 'Demo: known malicious actor'],
+        ['kind' => 'subnet', 'value' => '198.51.100.0/24', 'reason' => 'Demo: blanket block on test net'],
+    ];
+
+    private const DEMO_ALLOWLIST = [
+        ['kind' => 'ip', 'value' => '203.0.113.10', 'reason' => 'Demo: trusted partner'],
+        ['kind' => 'subnet', 'value' => '203.0.113.0/28', 'reason' => 'Demo: office NAT range'],
+    ];
+
+    private const DEMO_IPS = [
+        ['ip' => '192.0.2.5',    'country' => 'US', 'asn' => 13335, 'org' => 'Cloudflare, Inc.'],
+        ['ip' => '192.0.2.17',   'country' => 'DE', 'asn' => 24940, 'org' => 'Hetzner Online GmbH'],
+        ['ip' => '192.0.2.42',   'country' => 'RU', 'asn' => 12389, 'org' => 'Rostelecom'],
+        ['ip' => '192.0.2.99',   'country' => 'CN', 'asn' => 4134,  'org' => 'China Telecom'],
+        ['ip' => '192.0.2.130',  'country' => 'BR', 'asn' => 8167,  'org' => 'V tal'],
+        ['ip' => '192.0.2.180',  'country' => 'FR', 'asn' => 16276, 'org' => 'OVH SAS'],
+        ['ip' => '192.0.2.211',  'country' => 'NL', 'asn' => 60781, 'org' => 'LeaseWeb Netherlands'],
+        ['ip' => '198.51.100.7', 'country' => 'IN', 'asn' => 9498,  'org' => 'BHARTI Airtel'],
+        ['ip' => '198.51.100.55','country' => 'GB', 'asn' => 5089,  'org' => 'Virgin Media'],
+        ['ip' => '198.51.100.88','country' => 'JP', 'asn' => 4713,  'org' => 'NTT Communications'],
+        ['ip' => '198.51.100.140','country' => 'KR','asn' => 4766,  'org' => 'Korea Telecom'],
+        ['ip' => '198.51.100.200','country' => 'TR','asn' => 9121,  'org' => 'Turk Telekom'],
+        ['ip' => '203.0.113.50', 'country' => 'CA', 'asn' => 577,   'org' => 'Bell Canada'],
+        ['ip' => '203.0.113.77', 'country' => 'AU', 'asn' => 7545,  'org' => 'TPG Telecom'],
+        ['ip' => '203.0.113.120','country' => 'IT', 'asn' => 12874, 'org' => 'Fastweb'],
+        ['ip' => '203.0.113.150','country' => 'ES', 'asn' => 3352,  'org' => 'Telefonica de Espana'],
+        ['ip' => '203.0.113.190','country' => 'PL', 'asn' => 5617,  'org' => 'Orange Polska'],
+        ['ip' => '203.0.113.230','country' => 'UA', 'asn' => 13188, 'org' => 'Content Delivery Network'],
+        ['ip' => '192.0.2.244',  'country' => 'VN', 'asn' => 7552,  'org' => 'Viettel Group'],
+        ['ip' => '198.51.100.250','country' => 'ID','asn' => 17974, 'org' => 'PT Telekomunikasi Indonesia'],
+        ['ip' => '2001:db8::1',  'country' => 'US', 'asn' => 15169, 'org' => 'Google LLC'],
+        ['ip' => '2001:db8::abcd','country' => 'IE','asn' => 16509, 'org' => 'Amazon.com, Inc.'],
+    ];
+
+    public function __construct(
+        private readonly Connection $connection,
+        private readonly ReporterRepository $reporters,
+        private readonly ConsumerRepository $consumers,
+        private readonly PolicyRepository $policies,
+        private readonly CategoryRepository $categories,
+        private readonly ManualBlockRepository $manualBlocks,
+        private readonly AllowlistRepository $allowlist,
+        private readonly ReportRepository $reports,
+        private readonly IpEnrichmentRepository $enrichment,
+        private readonly RecomputeScoresJob $recomputeJob,
+        private readonly JobRunner $jobRunner,
+        private readonly Clock $clock,
+        private readonly AuditEmitter $audit,
+    ) {
+    }
+
+    /**
+     * Wipe operational data. Preserves users, OIDC role mappings, categories,
+     * and the `service`-kind token (the UI's own credential).
+     */
+    public function purge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $confirm = isset($body['confirm']) && is_string($body['confirm']) ? $body['confirm'] : '';
+        if ($confirm !== 'PURGE') {
+            return self::validationFailed($response, [
+                'confirm' => 'must be the literal string "PURGE" to authorize the wipe',
+            ]);
+        }
+
+        $deleted = $this->connection->transactional(function (Connection $conn): array {
+            $counts = [];
+            // Order: child tables first, then parents. RESTRICT FKs:
+            //   reports.reporter_id, reports.category_id, consumers.policy_id.
+            // Categories are preserved, so reports must be wiped first to
+            // free reporters; consumers must go before policies.
+            $tables = [
+                'reports',
+                'ip_scores',
+                'ip_enrichment',
+                'manual_blocks',
+                'allowlist',
+                'audit_log',
+                'job_runs',
+                'job_locks',
+            ];
+            foreach ($tables as $table) {
+                $counts[$table] = (int) $conn->executeStatement('DELETE FROM ' . $table);
+            }
+
+            // Tokens: keep the service token; everything else goes.
+            $counts['api_tokens'] = (int) $conn->executeStatement(
+                'DELETE FROM api_tokens WHERE kind != :svc',
+                ['svc' => TokenKind::Service->value],
+            );
+
+            $counts['consumers'] = (int) $conn->executeStatement('DELETE FROM consumers');
+            $counts['policy_category_thresholds'] = (int) $conn->executeStatement(
+                'DELETE FROM policy_category_thresholds',
+            );
+            $counts['policies'] = (int) $conn->executeStatement('DELETE FROM policies');
+            $counts['reporters'] = (int) $conn->executeStatement('DELETE FROM reporters');
+
+            return $counts;
+        });
+
+        $this->audit->emit(
+            AuditAction::MAINTENANCE_PURGED,
+            'maintenance',
+            null,
+            ['deleted' => $deleted],
+            self::auditContext($request),
+        );
+
+        return self::json($response, 200, [
+            'status' => 'purged',
+            'deleted' => $deleted,
+        ]);
+    }
+
+    /**
+     * Populate the database with a demo dataset. Idempotent: returns 409 if
+     * the marker reporter ("demo-web-edge") already exists.
+     */
+    public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        if ($this->reporters->findByName(self::DEMO_REPORTERS[0]['name']) !== null) {
+            return self::json($response, 409, [
+                'error' => 'already_seeded',
+                'message' => 'Demo data already present. Purge first to re-seed.',
+            ]);
+        }
+
+        $categoryIds = [];
+        foreach ($this->categories->listAll() as $category) {
+            $categoryIds[$category->slug] = $category->id;
+        }
+        if ($categoryIds === []) {
+            return self::error($response, 412, 'no_categories_configured');
+        }
+
+        $now = $this->clock->now();
+        $actingUserId = self::actingUserId($request);
+
+        $summary = [
+            'reporters' => 0,
+            'consumers' => 0,
+            'policies' => 0,
+            'reports' => 0,
+            'ips' => 0,
+            'manual_blocks' => 0,
+            'allowlist' => 0,
+            'enrichment' => 0,
+        ];
+
+        $reporterIds = [];
+        foreach (self::DEMO_REPORTERS as $r) {
+            $reporterIds[$r['name']] = $this->reporters->create(
+                $r['name'],
+                $r['description'],
+                $r['trust_weight'],
+                $actingUserId,
+            );
+            ++$summary['reporters'];
+        }
+
+        $policyIds = $this->ensureDefaultPolicies($categoryIds, $summary);
+
+        foreach (self::DEMO_CONSUMERS as $c) {
+            $policyId = $policyIds[$c['policy']] ?? reset($policyIds);
+            if ($policyId === false) {
+                continue;
+            }
+            $this->consumers->create($c['name'], $c['description'], (int) $policyId, $actingUserId);
+            ++$summary['consumers'];
+        }
+
+        foreach (self::DEMO_IPS as $i => $row) {
+            try {
+                $ip = IpAddress::fromString($row['ip']);
+            } catch (\Throwable) {
+                continue;
+            }
+
+            $this->enrichment->upsert($ip->binary(), new EnrichmentResult(
+                countryCode: $row['country'],
+                asn: $row['asn'],
+                asOrg: $row['org'],
+                enrichedAt: $now,
+            ));
+            ++$summary['enrichment'];
+            ++$summary['ips'];
+
+            // Stagger reports across the past 30 days so decay produces
+            // visually distinct scores in the dashboard.
+            $reportCount = 2 + ($i % 6) * 3;
+            for ($n = 0; $n < $reportCount; ++$n) {
+                $slugIdx = ($i + $n) % count($categoryIds);
+                $slug = array_keys($categoryIds)[$slugIdx];
+                $catId = $categoryIds[$slug];
+                $reporterIdx = $n % count(self::DEMO_REPORTERS);
+                $reporterName = self::DEMO_REPORTERS[$reporterIdx]['name'];
+                $reporterId = $reporterIds[$reporterName];
+                $weight = self::DEMO_REPORTERS[$reporterIdx]['trust_weight'];
+
+                $ageHours = (int) (($n * 17 + $i * 23) % (30 * 24));
+                $receivedAt = $now->modify('-' . $ageHours . ' hours');
+
+                $this->reports->insert(
+                    $ip->binary(),
+                    $ip->text(),
+                    $catId,
+                    $reporterId,
+                    $weight,
+                    json_encode([
+                        'demo' => true,
+                        'context' => $slug . ' attempt #' . ($n + 1),
+                    ]) ?: null,
+                    $receivedAt,
+                );
+                ++$summary['reports'];
+            }
+        }
+
+        foreach (self::DEMO_MANUAL_BLOCKS as $mb) {
+            try {
+                if ($mb['kind'] === 'ip') {
+                    $this->manualBlocks->createIp(
+                        IpAddress::fromString($mb['value']),
+                        $mb['reason'],
+                        null,
+                        $actingUserId,
+                    );
+                } else {
+                    $this->manualBlocks->createSubnet(
+                        Cidr::fromString($mb['value']),
+                        $mb['reason'],
+                        null,
+                        $actingUserId,
+                    );
+                }
+                ++$summary['manual_blocks'];
+            } catch (\Throwable) {
+                // Ignore — demo data shouldn't block setup on a single bad row.
+            }
+        }
+
+        foreach (self::DEMO_ALLOWLIST as $al) {
+            try {
+                if ($al['kind'] === 'ip') {
+                    $this->allowlist->createIp(
+                        IpAddress::fromString($al['value']),
+                        $al['reason'],
+                        $actingUserId,
+                    );
+                } else {
+                    $this->allowlist->createSubnet(
+                        Cidr::fromString($al['value']),
+                        $al['reason'],
+                        $actingUserId,
+                    );
+                }
+                ++$summary['allowlist'];
+            } catch (\Throwable) {
+                // Ignore.
+            }
+        }
+
+        // Recompute so dashboards show the seeded scores immediately.
+        $outcome = $this->jobRunner->run($this->recomputeJob, ['full' => true], 'manual');
+
+        $this->audit->emit(
+            AuditAction::MAINTENANCE_SEEDED,
+            'maintenance',
+            null,
+            ['summary' => $summary],
+            self::auditContext($request),
+        );
+
+        return self::json($response, 200, [
+            'status' => 'seeded',
+            'summary' => $summary,
+            'recompute' => $outcome->toArray(),
+        ]);
+    }
+
+    /**
+     * If no policies exist after a purge, recreate the three defaults so the
+     * blocklist endpoint and policies page aren't broken. Returns
+     * `name => id` for every policy currently in the table.
+     *
+     * @param array<string, int> $categoryIds slug -> id
+     * @param array<string, int> $summary     incremented in place when a row is created
+     * @return array<string, int> name -> id
+     */
+    private function ensureDefaultPolicies(array $categoryIds, array &$summary): array
+    {
+        $existing = $this->policies->listAll();
+        $byName = [];
+        foreach ($existing as $p) {
+            $byName[$p->name] = $p->id;
+        }
+        if ($byName !== []) {
+            return $byName;
+        }
+
+        $defaults = [
+            'strict' => ['desc' => 'Conservative: high-confidence blocks only.', 'threshold' => 2.5],
+            'moderate' => ['desc' => 'Balanced: moderate accumulated abuse signal.', 'threshold' => 1.0],
+            'paranoid' => ['desc' => 'Aggressive: block on faint signal.', 'threshold' => 0.3],
+        ];
+        foreach ($defaults as $name => $spec) {
+            $thresholds = [];
+            foreach ($categoryIds as $catId) {
+                $thresholds[$catId] = $spec['threshold'];
+            }
+            $byName[$name] = $this->policies->create($name, $spec['desc'], true, $thresholds);
+            ++$summary['policies'];
+        }
+
+        return $byName;
+    }
+}

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

@@ -49,6 +49,9 @@ final class AuditAction
 
     public const JOB_TRIGGERED = 'job.triggered';
 
+    public const MAINTENANCE_PURGED = 'maintenance.purged';
+    public const MAINTENANCE_SEEDED = 'maintenance.seeded';
+
     public static function entityTypeFor(string $action): string
     {
         $dot = strpos($action, '.');

+ 131 - 0
api/tests/Integration/Admin/MaintenanceControllerTest.php

@@ -0,0 +1,131 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * `/api/v1/admin/maintenance/{purge,seed-demo}` — admin-only one-shot
+ * data tools used by the Settings page for demos and resets.
+ */
+final class MaintenanceControllerTest extends AppTestCase
+{
+    public function testPurgeRequiresAdmin(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Operator);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/purge',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "PURGE"}',
+        );
+        self::assertSame(403, $resp->getStatusCode());
+    }
+
+    public function testPurgeRequiresLiteralConfirmString(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/purge',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "yes"}',
+        );
+        self::assertSame(400, $resp->getStatusCode());
+        self::assertSame('validation_failed', $this->decode($resp)['error']);
+    }
+
+    public function testPurgeWipesOperationalDataButPreservesServiceToken(): void
+    {
+        // Seed a service token (bypassing the controller; the API protects it).
+        $this->db->insert('api_tokens', [
+            'kind' => TokenKind::Service->value,
+            'token_hash' => str_repeat('a', 64),
+            'token_prefix' => 'irdb_svc',
+        ]);
+        // And one operational token, plus a reporter and a stray report.
+        $reporterId = $this->createReporter('purge-victim');
+        $catRow = $this->db->fetchAssociative('SELECT id FROM categories LIMIT 1');
+        self::assertNotFalse($catRow);
+        $this->db->insert('reports', [
+            'ip_bin' => str_repeat("\x00", 12) . "\x00\x00\x00\x01",
+            'ip_text' => '0.0.0.1',
+            'category_id' => (int) $catRow['id'],
+            'reporter_id' => $reporterId,
+            'weight_at_report' => '1.00',
+        ]);
+
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/purge',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "PURGE"}',
+        );
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame('purged', $body['status']);
+        self::assertGreaterThanOrEqual(1, (int) $body['deleted']['reports']);
+
+        // Service token survives, the admin token does not.
+        $svcCount = (int) $this->db->fetchOne(
+            'SELECT COUNT(*) FROM api_tokens WHERE kind = :k',
+            ['k' => TokenKind::Service->value],
+        );
+        self::assertSame(1, $svcCount);
+        $allTokens = (int) $this->db->fetchOne('SELECT COUNT(*) FROM api_tokens');
+        self::assertSame(1, $allTokens, 'only the service token should remain');
+
+        // Reporters / reports gone.
+        self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters'));
+        self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports'));
+
+        // Categories preserved.
+        self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories'));
+    }
+
+    public function testSeedDemoPopulatesDataAndIsIdempotent(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+
+        $first = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/seed-demo',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(200, $first->getStatusCode());
+        $body = $this->decode($first);
+        self::assertSame('seeded', $body['status']);
+        self::assertGreaterThan(0, (int) $body['summary']['reporters']);
+        self::assertGreaterThan(0, (int) $body['summary']['ips']);
+        self::assertGreaterThan(0, (int) $body['summary']['reports']);
+
+        // Real rows landed.
+        self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters'));
+        self::assertGreaterThan(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports'));
+
+        // Second call short-circuits with 409.
+        $second = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/seed-demo',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(409, $second->getStatusCode());
+        self::assertSame('already_seeded', $this->decode($second)['error']);
+    }
+
+    public function testSeedDemoForbiddenForViewer(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/seed-demo',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(403, $resp->getStatusCode());
+    }
+}

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

@@ -110,6 +110,92 @@
         </section>
     {% endif %}
 
+    {# -------------------- Demo & maintenance -------------------- #}
+    <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">Demo &amp; maintenance</h2>
+        <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Populate the database with sample data for screenshots and demos, or wipe operational data to start clean. Both actions are admin-only and audited.</p>
+
+        <div class="mt-4 grid gap-4 md:grid-cols-2">
+            <div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-900 dark:bg-emerald-950/40"
+                 x-data="{ open: false, submitting: false }">
+                <h3 class="text-sm font-semibold text-emerald-800 dark:text-emerald-200">Load demo data</h3>
+                <p class="mt-1 text-xs text-emerald-900/80 dark:text-emerald-200/80">
+                    Inserts demo reporters, consumers, IPs, reports, manual blocks, allowlist entries, and synthetic GeoIP — then triggers a full score recompute. Returns "already seeded" if demo data is present.
+                </p>
+                <div class="mt-3">
+                    <button type="button" x-on:click="open = true"
+                            class="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500">
+                        Load demo data…
+                    </button>
+                </div>
+
+                <div x-show="open" x-cloak
+                     class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4">
+                    <div class="w-full max-w-md rounded-2xl border border-emerald-300 bg-white p-6 shadow-2xl dark:border-emerald-700 dark:bg-slate-900">
+                        <h3 class="text-lg font-semibold text-emerald-700 dark:text-emerald-300">Load demo data?</h3>
+                        <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
+                            This will add sample reporters, consumers, IPs, and reports to the database, then run a full recompute. Existing real data is left untouched.
+                        </p>
+                        <form method="post" action="/app/settings/maintenance/seed-demo" class="mt-4 flex justify-end gap-2"
+                              x-on:submit="submitting = true">
+                            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                            <button type="button" x-on:click="open = false"
+                                    class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                            <button type="submit" x-bind:disabled="submitting"
+                                    class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50">
+                                <span x-show="!submitting">Load demo data</span>
+                                <span x-show="submitting" x-cloak>Loading…</span>
+                            </button>
+                        </form>
+                    </div>
+                </div>
+            </div>
+
+            <div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40"
+                 x-data="{ open: false, confirm: '', submitting: false }">
+                <h3 class="text-sm font-semibold text-red-800 dark:text-red-200">Purge operational data</h3>
+                <p class="mt-1 text-xs text-red-900/80 dark:text-red-200/80">
+                    Deletes all reports, scores, manual blocks, allowlist, audit log, reporters, consumers, and non-service tokens. Users, OIDC mappings, and categories are preserved.
+                </p>
+                <div class="mt-3">
+                    <button type="button" x-on:click="open = true"
+                            class="rounded-md border border-red-400 bg-white px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-slate-900 dark:text-red-300 dark:hover:bg-slate-800">
+                        Purge data…
+                    </button>
+                </div>
+
+                <div x-show="open" x-cloak
+                     class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/70 px-4">
+                    <div class="w-full max-w-md rounded-2xl border border-red-400 bg-white p-6 shadow-2xl dark:border-red-700 dark:bg-slate-900">
+                        <h3 class="text-lg font-semibold text-red-700 dark:text-red-300">Purge operational data?</h3>
+                        <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
+                            This will <strong class="text-red-700 dark:text-red-300">permanently delete</strong> reports, scores, blocks, allowlist, audit log, reporters, consumers, and tokens. The service token, your user account, OIDC mappings, and abuse categories are preserved.
+                        </p>
+                        <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
+                            Type <code class="rounded bg-slate-100 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-slate-800 dark:text-red-300">PURGE</code> to confirm:
+                        </p>
+                        <form method="post" action="/app/settings/maintenance/purge" class="mt-3"
+                              x-on:submit="submitting = true">
+                            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                            <input type="text" name="confirm" autocomplete="off" x-model="confirm"
+                                   class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-sm dark:border-slate-700 dark:bg-slate-950"
+                                   placeholder="PURGE">
+                            <div class="mt-4 flex justify-end gap-2">
+                                <button type="button" x-on:click="open = false; confirm = ''"
+                                        class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                                <button type="submit" x-bind:disabled="confirm !== 'PURGE' || submitting"
+                                        class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-40">
+                                    <span x-show="!submitting">Purge data</span>
+                                    <span x-show="submitting" x-cloak>Purging…</span>
+                                </button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </section>
+
     {# ------------------------------ GeoIP ----------------------------- #}
     {% if config and config.sections.geoip %}
         <section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">

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

@@ -397,4 +397,35 @@ final class AdminClient
     {
         return $this->api->request('GET', '/api/v1/admin/config', [], $actingUserId);
     }
+
+    /**
+     * Wipe operational data on the api side. The API requires
+     * `confirm: "PURGE"` in the body — anything else returns 400.
+     *
+     * @return array<string, mixed>
+     */
+    public function purgeData(int $actingUserId): array
+    {
+        return $this->api->request(
+            'POST',
+            '/api/v1/admin/maintenance/purge',
+            ['json' => ['confirm' => 'PURGE']],
+            $actingUserId,
+        );
+    }
+
+    /**
+     * Load the demo dataset. Returns 409 if data is already seeded.
+     *
+     * @return array<string, mixed>
+     */
+    public function seedDemo(int $actingUserId): array
+    {
+        return $this->api->request(
+            'POST',
+            '/api/v1/admin/maintenance/seed-demo',
+            [],
+            $actingUserId,
+        );
+    }
 }

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

@@ -196,6 +196,8 @@ final class AppFactory
             $settings = $container->get(SettingsController::class);
             $group->get('/settings', [$settings, 'index']);
             $group->post('/settings/jobs/trigger/{name}', [$settings, 'trigger']);
+            $group->post('/settings/maintenance/purge', [$settings, 'purge']);
+            $group->post('/settings/maintenance/seed-demo', [$settings, 'seedDemo']);
         })->add($authRequired);
 
         $app->map(

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

@@ -123,4 +123,80 @@ final class SettingsController
 
         return $response->withStatus(303)->withHeader('Location', '/app/settings');
     }
+
+    /**
+     * Wipe operational data. The API requires the literal `PURGE` string;
+     * we additionally require the user to have typed it in the form, both
+     * to avoid drive-by clicks and to keep curl-style misuse loud.
+     */
+    public function purge(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);
+        $confirm = isset($body['confirm']) && is_string($body['confirm']) ? trim($body['confirm']) : '';
+        if ($confirm !== 'PURGE') {
+            $this->sessions()->flash('error', 'Type PURGE exactly to confirm the wipe.');
+
+            return $response->withStatus(303)->withHeader('Location', '/app/settings');
+        }
+
+        try {
+            $result = $this->admin->purgeData($user->userId);
+            $deleted = is_array($result['deleted'] ?? null) ? $result['deleted'] : [];
+            $total = array_sum(array_map('intval', $deleted));
+            $this->sessions()->flash('success', sprintf(
+                'Database purged. %d rows deleted across %d tables.',
+                $total,
+                count($deleted),
+            ));
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/settings');
+    }
+
+    /**
+     * Load the demo dataset. Idempotent on the api side — repeats return
+     * 409 which we surface as an info-level flash, not an error.
+     */
+    public function seedDemo(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');
+        }
+
+        try {
+            $result = $this->admin->seedDemo($user->userId);
+            $summary = is_array($result['summary'] ?? null) ? $result['summary'] : [];
+            $this->sessions()->flash('success', sprintf(
+                'Demo data loaded — %d reporters, %d consumers, %d IPs, %d reports.',
+                (int) ($summary['reporters'] ?? 0),
+                (int) ($summary['consumers'] ?? 0),
+                (int) ($summary['ips'] ?? 0),
+                (int) ($summary['reports'] ?? 0),
+            ));
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/settings');
+    }
 }