|
|
@@ -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;
|
|
|
+ }
|
|
|
+}
|