1
0
Эх сурвалжийг харах

feat(M12): audit log emitter, filterable audit UI, settings page

- AuditEmitter wired into every write path
- service-token+impersonation audits attribute to user, not service token
- GET /api/v1/admin/audit-log with filters, pagination
- POST /api/v1/admin/jobs/trigger/{name} as admin wrapper around internal jobs
- GET /api/v1/admin/config (secrets masked) and jobs/status
- UI Audit and Settings pages

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 долоо хоног өмнө
parent
commit
a5898683d1
36 өөрчлөгдсөн 2458 нэмэгдсэн , 8 устгасан
  1. 97 0
      PROGRESS.md
  2. 27 0
      api/db/migrations/20260429140000_add_audit_action_index.php
  3. 27 0
      api/src/App/AppFactory.php
  4. 18 0
      api/src/App/Container.php
  5. 14 0
      api/src/Application/Admin/AdminControllerSupport.php
  6. 27 0
      api/src/Application/Admin/AllowlistController.php
  7. 118 0
      api/src/Application/Admin/AuditController.php
  8. 30 2
      api/src/Application/Admin/CategoriesController.php
  9. 135 0
      api/src/Application/Admin/ConfigController.php
  10. 30 2
      api/src/Application/Admin/ConsumersController.php
  11. 172 0
      api/src/Application/Admin/JobsAdminController.php
  12. 27 0
      api/src/Application/Admin/ManualBlocksController.php
  13. 31 0
      api/src/Application/Admin/PoliciesController.php
  14. 36 2
      api/src/Application/Admin/ReportersController.php
  15. 27 0
      api/src/Application/Admin/TokensController.php
  16. 58 0
      api/src/Domain/Audit/AuditAction.php
  17. 38 0
      api/src/Domain/Audit/AuditContext.php
  18. 28 0
      api/src/Domain/Audit/AuditEmitter.php
  19. 152 0
      api/src/Infrastructure/Audit/AuditRepository.php
  20. 48 0
      api/src/Infrastructure/Audit/DbAuditEmitter.php
  21. 94 0
      api/src/Infrastructure/Http/Middleware/AuditContextMiddleware.php
  22. 83 0
      api/tests/Integration/Admin/AuditLogControllerTest.php
  23. 98 0
      api/tests/Integration/Admin/ConfigControllerTest.php
  24. 117 0
      api/tests/Integration/Admin/JobsAdminControllerTest.php
  25. 151 0
      api/tests/Integration/Audit/AuditEmissionTest.php
  26. 24 0
      api/tests/Unit/Audit/AuditActionTest.php
  27. 153 0
      ui/resources/views/pages/audit/index.twig
  28. 133 0
      ui/resources/views/pages/settings/index.twig
  29. 2 2
      ui/resources/views/partials/sidebar.twig
  30. 48 0
      ui/src/ApiClient/AdminClient.php
  31. 11 0
      ui/src/App/AppFactory.php
  32. 4 0
      ui/src/App/Container.php
  33. 108 0
      ui/src/Controllers/AuditController.php
  34. 126 0
      ui/src/Controllers/SettingsController.php
  35. 79 0
      ui/tests/Integration/Audit/AuditPageTest.php
  36. 87 0
      ui/tests/Integration/Settings/SettingsPageTest.php

+ 97 - 0
PROGRESS.md

@@ -368,3 +368,100 @@ vendored from `maxmind/MaxMind-DB` (Apache-2.0). Cover IP `81.2.69.142` (GB)
 plus a small IPv6 set. Schema is MaxMind-shape so `MaxMindRecordAdapter`
 drives them; the IPinfo adapter is exercised via direct unit tests since
 no public IPinfo-shape MMDB fixture is available.
+
+## M12 — Audit & settings (done)
+
+**Built:** audit emission across every state-changing admin endpoint;
+filterable audit list endpoint + UI; admin-side jobs status + manual
+trigger endpoints + UI Settings page; effective-config endpoint with
+secrets masked.
+
+**Notes for next milestone:**
+- Audit failures are logged (`audit_emit_failed` Monolog event) but
+  never propagate — `DbAuditEmitter` swallows on insert error.
+- The actor-resolution invariant: service-token + impersonation always
+  records `actor_kind=user` with the impersonated `user_id`; raw admin
+  tokens record `actor_kind=admin-token` with the **token id** as
+  `actor_id`. Reporter / consumer tokens are recorded with their FK id.
+- Failed validation paths (4xx) **don't** emit audit. Only successful
+  state changes do.
+- `POST /api/v1/admin/jobs/trigger/{name}` is the only path the UI
+  uses to invoke jobs; `/internal/jobs/*` remains scheduler-only and
+  network-restricted to RFC1918. The admin endpoint emits one
+  `job.triggered` audit row before invoking the runner with
+  `triggered_by="manual"`.
+- Manual trigger short-circuits 412 for `refresh-geoip` when an opt-in
+  provider's credential is unset — same envelope the internal handler
+  uses, so the UI flash message reads identically.
+- Whitelisted job-trigger params: `full`, `max_rows`, `reenrich`.
+  Anything else in the request body is dropped to avoid a malicious
+  admin smuggling config-shaped values into the runner.
+- Token creation NEVER puts the raw token in the audit payload —
+  prefix only. Verified by an integration test that asserts the raw
+  token doesn't appear in `details_json`.
+- `GET /admin/config` masks `INTERNAL_JOB_TOKEN`,
+  `MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`,
+  `APP_SECRET` to `***`; `UI_SERVICE_TOKEN` shows the first 8 chars
+  + `...`. Plain values for everything else (DB driver, log level,
+  cadences, GeoIP paths). Empty values stay empty so misconfiguration
+  is visible instead of being hidden behind `***`.
+
+**Schema:**
+- `idx_audit_action` — index on `audit_log(action)` for the audit
+  page's filter-by-action common case. Country/actor/entity-id indexes
+  were already in M02. Migration `20260429140000_add_audit_action_index.php`.
+
+**Test surface added (api):** Unit: `AuditActionTest`. Integration:
+`AuditEmissionTest` (5 tests covering admin-token attribution,
+service-token impersonation attribution, raw-token-not-in-payload,
+no-emit-on-validation-failure, full create/update/delete cycle for
+categories), `AuditLogControllerTest` (4 tests: empty, filtered,
+invalid-actor-kind 400, RBAC), `JobsAdminControllerTest` (5 tests:
+viewer-readable status, operator forbidden trigger, unknown job 404,
+manual trigger end-to-end with audit + `triggered_by=manual`,
+refresh-geoip 412 under MaxMind without key), `ConfigControllerTest`
+(viewer 403, sections shape, masking with secrets set). Total: **336
+tests / 973 assertions**, 0 deprecations.
+
+**Test surface added (ui):** `AuditPageTest` (4 tests: list, empty,
+filter round-trip, anonymous redirect), `SettingsPageTest` (3 tests:
+admin renders config + jobs, viewer 303 to /no-access, anonymous to
+/login). Total: **78 tests / 199 assertions**.
+
+**Acceptance:** `composer cs && composer stan && composer test` clean
+on both subprojects. The full Block A/B/C bash acceptance script in
+the M12 brief is gated on a fresh `docker compose` boot, which the
+development environment in this session can't run end-to-end (no
+Docker daemon at the host level during the milestone implementation
+phase). The unit + integration tests cover every controller and audit
+code path that the bash acceptance script exercises; the bash script
+is preserved verbatim in the milestone doc for the next operator to
+run against a clean compose stack.
+
+**RBAC summary applied this milestone:**
+- Audit list: Viewer (every signed-in user can browse audit).
+- Jobs status: Viewer (cosmetic Settings rendering still gates on Admin).
+- Job trigger: Admin only.
+- Config endpoint: Admin only.
+- All emission middleware runs once per admin request (after
+  TokenAuth + Impersonation, before RBAC) so the actor is always
+  resolved regardless of which middleware short-circuits.
+
+**Deviations from SPEC:**
+- The SPEC §M12.6 user management UI was deferred from M10 and
+  remains out-of-scope here (the focus this milestone was the audit
+  trail itself). API endpoints for users / oidc-role-mappings exist
+  from M03; their dedicated UI list/edit pages will land in M13/M14.
+  Sidebar still hides those links until they ship.
+- `audit_log.target_type` and `target_id` are the SPEC §4 column
+  names; the API and UI surface them under the brief's vocabulary
+  `entity_type`/`entity_id` for clarity. The repository translates.
+  This is documented inline; no schema change.
+- `JobsAdminController` is a separate class from the internal
+  `JobsController`; the brief implied a single shared class but a
+  dedicated admin controller keeps the audit + RBAC concerns
+  cleanly out of the internal-only handler.
+
+**Added dependencies:** none.
+
+**Added env vars:** none.

+ 27 - 0
api/db/migrations/20260429140000_add_audit_action_index.php

@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * SPEC §M12: the audit page filters heavily by `action`. M02's audit_log
+ * migration covered created_at, (actor_kind, actor_id), and
+ * (target_type, target_id) but not action — fix here.
+ */
+final class AddAuditActionIndex extends BaseMigration
+{
+    public function up(): void
+    {
+        $this->table('audit_log')
+            ->addIndex(['action'], ['name' => 'idx_audit_action'])
+            ->update();
+    }
+
+    public function down(): void
+    {
+        $this->table('audit_log')
+            ->removeIndexByName('idx_audit_action')
+            ->update();
+    }
+}

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

@@ -5,9 +5,12 @@ declare(strict_types=1);
 namespace App\App;
 
 use App\Application\Admin\AllowlistController;
+use App\Application\Admin\AuditController;
 use App\Application\Admin\CategoriesController;
+use App\Application\Admin\ConfigController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\IpsController;
+use App\Application\Admin\JobsAdminController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\PoliciesController;
@@ -20,6 +23,7 @@ use App\Application\Public\BlocklistController;
 use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Infrastructure\Http\JsonErrorHandler;
+use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
 use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
 use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
 use App\Infrastructure\Http\Middleware\InternalTokenMiddleware;
@@ -77,6 +81,8 @@ final class AppFactory
         $tokenAuth = $container->get(TokenAuthenticationMiddleware::class);
         /** @var ImpersonationMiddleware $impersonation */
         $impersonation = $container->get(ImpersonationMiddleware::class);
+        /** @var AuditContextMiddleware $auditContext */
+        $auditContext = $container->get(AuditContextMiddleware::class);
         /** @var RateLimitMiddleware $rateLimit */
         $rateLimit = $container->get(RateLimitMiddleware::class);
         /** @var InternalNetworkMiddleware $internalNetwork */
@@ -257,6 +263,26 @@ final class AppFactory
             $admin->delete('/categories/{id}', [$categories, 'delete'])
                 ->add(RbacMiddleware::require($rf, Role::Admin));
 
+            // Audit log: Viewer.
+            /** @var AuditController $audit */
+            $audit = $container->get(AuditController::class);
+            $admin->get('/audit-log', [$audit, 'list'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+
+            // Jobs admin (Viewer for status, Admin for trigger).
+            /** @var JobsAdminController $jobsAdmin */
+            $jobsAdmin = $container->get(JobsAdminController::class);
+            $admin->get('/jobs/status', [$jobsAdmin, 'status'])
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+            $admin->post('/jobs/trigger/{name}', [$jobsAdmin, 'trigger'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+
+            // Effective config (secrets masked) — Admin only.
+            /** @var ConfigController $config */
+            $config = $container->get(ConfigController::class);
+            $admin->get('/config', [$config, 'show'])
+                ->add(RbacMiddleware::require($rf, Role::Admin));
+
             // Policies: list/show/preview = Viewer; write = Admin.
             /** @var PoliciesController $policies */
             $policies = $container->get(PoliciesController::class);
@@ -273,6 +299,7 @@ final class AppFactory
             $admin->delete('/policies/{id}', [$policies, 'delete'])
                 ->add(RbacMiddleware::require($rf, Role::Admin));
         })
+            ->add($auditContext)
             ->add($impersonation)
             ->add($tokenAuth);
 

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

@@ -5,9 +5,12 @@ declare(strict_types=1);
 namespace App\App;
 
 use App\Application\Admin\AllowlistController;
+use App\Application\Admin\AuditController;
 use App\Application\Admin\CategoriesController;
+use App\Application\Admin\ConfigController;
 use App\Application\Admin\ConsumersController;
 use App\Application\Admin\IpsController;
+use App\Application\Admin\JobsAdminController;
 use App\Application\Admin\ManualBlocksController;
 use App\Application\Admin\MeController;
 use App\Application\Admin\PoliciesController;
@@ -23,6 +26,7 @@ use App\Application\Jobs\RefreshGeoipJob;
 use App\Application\Jobs\TickJob;
 use App\Application\Public\BlocklistController;
 use App\Application\Public\ReportController;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Auth\Role;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenIssuer;
@@ -33,6 +37,8 @@ use App\Domain\Reputation\PairScorer;
 use App\Domain\Time\Clock;
 use App\Domain\Time\SystemClock;
 use App\Infrastructure\Allowlist\AllowlistRepository;
+use App\Infrastructure\Audit\AuditRepository;
+use App\Infrastructure\Audit\DbAuditEmitter;
 use App\Infrastructure\Auth\RoleMappingRepository;
 use App\Infrastructure\Auth\ServiceTokenBootstrap;
 use App\Infrastructure\Auth\TokenRepository;
@@ -49,6 +55,7 @@ use App\Infrastructure\Enrichment\MaxMindRecordAdapter;
 use App\Infrastructure\Enrichment\MmdbEnrichmentService;
 use App\Infrastructure\Enrichment\RecordAdapter;
 use App\Infrastructure\Http\JsonErrorHandler;
+use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
 use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
 use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
 use App\Infrastructure\Http\Middleware\InternalTokenMiddleware;
@@ -199,6 +206,9 @@ final class Container
             ServiceTokenBootstrap::class => autowire(),
             TokenAuthenticationMiddleware::class => autowire(),
             ImpersonationMiddleware::class => autowire(),
+            AuditContextMiddleware::class => autowire(),
+            AuditRepository::class => autowire(),
+            AuditEmitter::class => autowire(DbAuditEmitter::class),
             PairScorer::class => factory(static function (ContainerInterface $c): PairScorer {
                 /** @var ReportRepository $reports */
                 $reports = $c->get(ReportRepository::class);
@@ -410,6 +420,14 @@ final class Container
             IpsController::class => autowire(),
             StatsController::class => autowire(),
             CategoriesController::class => autowire(),
+            AuditController::class => autowire(),
+            JobsAdminController::class => autowire(),
+            ConfigController::class => factory(static function (ContainerInterface $c): ConfigController {
+                /** @var array<string, mixed> $settings */
+                $settings = $c->get('settings');
+
+                return new ConfigController($settings);
+            }),
         ]);
 
         return $builder->build();

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

@@ -4,7 +4,9 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditContext;
 use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
 use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -80,4 +82,16 @@ trait AdminControllerSupport
 
         return $principal instanceof AuthenticatedPrincipal ? $principal->userId : null;
     }
+
+    /**
+     * Pull the request-scoped AuditContext (set by AuditContextMiddleware).
+     * Falls back to a minimal system context if the middleware didn't run
+     * (test setups that bypass the middleware stack).
+     */
+    private static function auditContext(ServerRequestInterface $request): AuditContext
+    {
+        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);
+
+        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
+    }
 }

+ 27 - 0
api/src/Application/Admin/AllowlistController.php

@@ -5,6 +5,8 @@ declare(strict_types=1);
 namespace App\Application\Admin;
 
 use App\Domain\Allowlist\AllowlistEntry;
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Ip\Cidr;
 use App\Domain\Ip\InvalidCidrException;
 use App\Domain\Ip\InvalidIpException;
@@ -29,6 +31,7 @@ final class AllowlistController
         private readonly AllowlistRepository $allowlist,
         private readonly CidrEvaluatorFactory $evaluator,
         private readonly BlocklistCache $blocklistCache,
+        private readonly AuditEmitter $audit,
     ) {
     }
 
@@ -120,6 +123,14 @@ final class AllowlistController
                 return self::error($response, 500, 'create_failed');
             }
 
+            $this->audit->emit(
+                AuditAction::ALLOWLIST_CREATED,
+                'allowlist',
+                $id,
+                ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason],
+                self::auditContext($request),
+            );
+
             return self::json($response, 201, $created->toArray());
         }
 
@@ -156,6 +167,14 @@ final class AllowlistController
             $payload['normalized_from'] = $cidrInput;
         }
 
+        $this->audit->emit(
+            AuditAction::ALLOWLIST_CREATED,
+            'allowlist',
+            $id,
+            ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, $payload);
     }
 
@@ -177,6 +196,14 @@ final class AllowlistController
         $this->evaluator->invalidate();
         $this->blocklistCache->invalidateAll();
 
+        $this->audit->emit(
+            AuditAction::ALLOWLIST_DELETED,
+            'allowlist',
+            $id,
+            ['kind' => $existing->kind, 'reason' => $existing->reason],
+            self::auditContext($request),
+        );
+
         return $response->withStatus(204);
     }
 

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

@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Infrastructure\Audit\AuditRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `GET /api/v1/admin/audit-log` — paginated, filterable audit list.
+ *
+ * Filters: actor_kind, actor_id, action, entity_type, entity_id, from, to.
+ * Pagination: page (1-indexed), page_size (default 50, max 200).
+ *
+ * RBAC: Viewer — every signed-in user can see audit. Per SPEC §7 RBAC matrix.
+ */
+final class AuditController
+{
+    use AdminControllerSupport;
+
+    private const ALLOWED_ACTOR_KINDS = ['user', 'admin-token', 'reporter', 'consumer', 'system'];
+
+    public function __construct(private readonly AuditRepository $audit)
+    {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $params = $request->getQueryParams();
+        $errors = [];
+
+        $pageSize = isset($params['page_size']) && ctype_digit((string) $params['page_size'])
+            ? (int) $params['page_size']
+            : 50;
+        $pageSize = max(1, min(200, $pageSize));
+        $page = isset($params['page']) && ctype_digit((string) $params['page']) ? max(1, (int) $params['page']) : 1;
+
+        $filters = [];
+
+        if (isset($params['actor_kind']) && is_string($params['actor_kind']) && $params['actor_kind'] !== '') {
+            if (!in_array($params['actor_kind'], self::ALLOWED_ACTOR_KINDS, true)) {
+                $errors['actor_kind'] = 'must be one of: ' . implode(', ', self::ALLOWED_ACTOR_KINDS);
+            } else {
+                $filters['actor_kind'] = $params['actor_kind'];
+            }
+        }
+
+        if (isset($params['actor_id']) && (string) $params['actor_id'] !== '') {
+            if (!ctype_digit((string) $params['actor_id'])) {
+                $errors['actor_id'] = 'must be a positive integer';
+            } else {
+                $filters['actor_id'] = (int) $params['actor_id'];
+            }
+        }
+
+        if (isset($params['action']) && is_string($params['action']) && $params['action'] !== '') {
+            $filters['action'] = $params['action'];
+        }
+
+        if (isset($params['entity_type']) && is_string($params['entity_type']) && $params['entity_type'] !== '') {
+            $filters['entity_type'] = $params['entity_type'];
+        }
+
+        if (isset($params['entity_id']) && is_string($params['entity_id']) && $params['entity_id'] !== '') {
+            $filters['entity_id'] = $params['entity_id'];
+        }
+
+        if (isset($params['from']) && is_string($params['from']) && $params['from'] !== '') {
+            $normalized = self::normalizeIsoTimestamp($params['from']);
+            if ($normalized === null) {
+                $errors['from'] = 'must be ISO-8601 (e.g. 2026-04-01T00:00:00Z)';
+            } else {
+                $filters['from'] = $normalized;
+            }
+        }
+
+        if (isset($params['to']) && is_string($params['to']) && $params['to'] !== '') {
+            $normalized = self::normalizeIsoTimestamp($params['to']);
+            if ($normalized === null) {
+                $errors['to'] = 'must be ISO-8601 (e.g. 2026-04-30T23:59:59Z)';
+            } else {
+                $filters['to'] = $normalized;
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $offset = ($page - 1) * $pageSize;
+        $result = $this->audit->search($filters, $pageSize, $offset);
+
+        return self::json($response, 200, [
+            'items' => $result['items'],
+            'page' => $page,
+            'page_size' => $pageSize,
+            'total' => $result['total'],
+        ]);
+    }
+
+    /**
+     * Convert an ISO-8601 input into the `Y-m-d H:i:s` form used by the
+     * audit table. Tolerates `Z`, `+00:00`, fractional seconds, or no TZ
+     * (assumed UTC).
+     */
+    private static function normalizeIsoTimestamp(string $value): ?string
+    {
+        try {
+            $dt = new \DateTimeImmutable($value, new \DateTimeZone('UTC'));
+        } catch (\Exception) {
+            return null;
+        }
+
+        return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
+    }
+}

+ 30 - 2
api/src/Application/Admin/CategoriesController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Reputation\DecayFunction;
 use App\Infrastructure\Category\CategoryRepository;
 use Psr\Http\Message\ResponseInterface;
@@ -28,8 +30,10 @@ final class CategoriesController
 
     private const SLUG_PATTERN = '/^[a-z][a-z0-9_]{0,63}$/';
 
-    public function __construct(private readonly CategoryRepository $categories)
-    {
+    public function __construct(
+        private readonly CategoryRepository $categories,
+        private readonly AuditEmitter $audit,
+    ) {
     }
 
     public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
@@ -125,6 +129,14 @@ final class CategoriesController
             return self::error($response, 500, 'create_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::CATEGORY_CREATED,
+            'category',
+            $id,
+            ['slug' => $slug, 'name' => $name, 'decay_function' => $decayFunction->value, 'decay_param' => $decayParam],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, $created->toArray());
     }
 
@@ -216,6 +228,14 @@ final class CategoriesController
             return self::error($response, 500, 'update_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::CATEGORY_UPDATED,
+            'category',
+            $id,
+            ['slug' => $existing->slug, 'changed' => array_keys($fields)],
+            self::auditContext($request),
+        );
+
         return self::json($response, 200, $updated->toArray());
     }
 
@@ -248,6 +268,14 @@ final class CategoriesController
 
         $this->categories->delete($id);
 
+        $this->audit->emit(
+            AuditAction::CATEGORY_DELETED,
+            'category',
+            $id,
+            ['slug' => $existing->slug, 'name' => $existing->name],
+            self::auditContext($request),
+        );
+
         return $response->withStatus(204);
     }
 

+ 135 - 0
api/src/Application/Admin/ConfigController.php

@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `GET /api/v1/admin/config` — effective config the api is using,
+ * with secrets masked.
+ *
+ * Masking rules per SPEC §M12.5:
+ *  - `***`               for: INTERNAL_JOB_TOKEN, MAXMIND_LICENSE_KEY,
+ *                            IPINFO_TOKEN, DB_MYSQL_PASSWORD, APP_SECRET
+ *  - first 8 + `...`     for: UI_SERVICE_TOKEN
+ *  - plain values        for everything else
+ *
+ * Returns config grouped by section so the UI can render it without
+ * inventing categorisation. RBAC: Admin only — viewers and operators
+ * see no_access.
+ */
+final class ConfigController
+{
+    use AdminControllerSupport;
+
+    /**
+     * @param array<string, mixed> $settings Effective settings array (the same array that built the container).
+     */
+    public function __construct(private readonly array $settings)
+    {
+    }
+
+    public function show(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        return self::json($response, 200, [
+            'sections' => $this->sections(),
+        ]);
+    }
+
+    /**
+     * @return array<string, array<string, mixed>>
+     */
+    private function sections(): array
+    {
+        $db = $this->settings['db'] ?? [];
+        $geoip = $this->settings['geoip'] ?? [];
+
+        return [
+            'app' => [
+                'APP_ENV' => $this->settings['app_env'] ?? null,
+                'LOG_LEVEL' => $this->levelName(),
+                'APP_SECRET' => self::mask((string) ($this->settings['app_secret'] ?? '')),
+                'UI_ORIGIN' => $this->settings['ui_origin'] ?? null,
+            ],
+            'database' => [
+                'DB_DRIVER' => $db['driver'] ?? null,
+                'DB_SQLITE_PATH' => $db['sqlite_path'] ?? null,
+                'DB_MYSQL_HOST' => $db['mysql_host'] ?? null,
+                'DB_MYSQL_PORT' => $db['mysql_port'] ?? null,
+                'DB_MYSQL_DATABASE' => $db['mysql_database'] ?? null,
+                'DB_MYSQL_USERNAME' => $db['mysql_username'] ?? null,
+                'DB_MYSQL_PASSWORD' => self::mask((string) ($db['mysql_password'] ?? '')),
+            ],
+            'auth' => [
+                'UI_SERVICE_TOKEN' => self::previewToken((string) ($this->settings['ui_service_token'] ?? '')),
+                'INTERNAL_JOB_TOKEN' => self::mask((string) ($this->settings['internal_job_token'] ?? '')),
+                'OIDC_DEFAULT_ROLE' => $this->oidcDefaultRoleName(),
+            ],
+            'reputation' => [
+                'SCORE_REPORT_HARD_CUTOFF_DAYS' => $this->settings['score_hard_cutoff_days'] ?? null,
+                'SCORE_RECOMPUTE_INTERVAL_SECONDS' => $this->settings['score_recompute_interval_seconds'] ?? null,
+                'API_RATE_LIMIT_PER_SECOND' => $this->settings['rate_limit_per_second'] ?? null,
+                'CIDR_EVALUATOR_TTL_SECONDS' => $this->settings['cidr_evaluator_ttl_seconds'] ?? null,
+                'BLOCKLIST_CACHE_TTL_SECONDS' => $this->settings['blocklist_cache_ttl_seconds'] ?? null,
+            ],
+            'jobs' => [
+                'JOB_RECOMPUTE_MAX_RUNTIME_SECONDS' => $this->settings['job_recompute_max_runtime_seconds'] ?? null,
+                'JOB_RECOMPUTE_MAX_ROWS_PER_TICK' => $this->settings['job_recompute_max_rows_per_tick'] ?? null,
+                'JOB_AUDIT_RETENTION_DAYS' => $this->settings['job_audit_retention_days'] ?? null,
+                'JOB_GEOIP_REFRESH_INTERVAL_DAYS' => $geoip['refresh_interval_days'] ?? null,
+            ],
+            'geoip' => [
+                'GEOIP_ENABLED' => $geoip['enabled'] ?? null,
+                'GEOIP_PROVIDER' => $geoip['provider'] ?? null,
+                'GEOIP_COUNTRY_DB' => $geoip['country_db'] ?? null,
+                'GEOIP_ASN_DB' => $geoip['asn_db'] ?? null,
+                'MAXMIND_LICENSE_KEY' => self::mask((string) ($geoip['maxmind_license_key'] ?? '')),
+                'IPINFO_TOKEN' => self::mask((string) ($geoip['ipinfo_token'] ?? '')),
+            ],
+        ];
+    }
+
+    private static function mask(string $value): string
+    {
+        return $value === '' ? '' : '***';
+    }
+
+    /**
+     * Token preview: empty stays empty so misconfiguration is visible;
+     * present values show first 8 + ellipsis.
+     */
+    private static function previewToken(string $value): string
+    {
+        if ($value === '') {
+            return '';
+        }
+
+        return substr($value, 0, 8) . '...';
+    }
+
+    private function levelName(): ?string
+    {
+        $level = $this->settings['log_level'] ?? null;
+        if ($level instanceof \Monolog\Level) {
+            return $level->getName();
+        }
+
+        return is_string($level) ? $level : null;
+    }
+
+    private function oidcDefaultRoleName(): ?string
+    {
+        $role = $this->settings['oidc_default_role'] ?? null;
+        if ($role === null) {
+            return 'none';
+        }
+        if ($role instanceof \App\Domain\Auth\Role) {
+            return $role->value;
+        }
+
+        return is_string($role) ? $role : null;
+    }
+}

+ 30 - 2
api/src/Application/Admin/ConsumersController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Infrastructure\Consumer\ConsumerRepository;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -19,8 +21,10 @@ final class ConsumersController
 {
     use AdminControllerSupport;
 
-    public function __construct(private readonly ConsumerRepository $consumers)
-    {
+    public function __construct(
+        private readonly ConsumerRepository $consumers,
+        private readonly AuditEmitter $audit,
+    ) {
     }
 
     public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
@@ -105,6 +109,14 @@ final class ConsumersController
             return self::error($response, 500, 'create_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::CONSUMER_CREATED,
+            'consumer',
+            $id,
+            ['name' => $name, 'policy_id' => $policyId, 'description' => $description],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, $created->toArray());
     }
 
@@ -179,6 +191,14 @@ final class ConsumersController
             return self::error($response, 500, 'update_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::CONSUMER_UPDATED,
+            'consumer',
+            $id,
+            ['changed' => array_keys($fields)],
+            self::auditContext($request),
+        );
+
         return self::json($response, 200, $updated->toArray());
     }
 
@@ -198,6 +218,14 @@ final class ConsumersController
 
         $this->consumers->softDelete($id);
 
+        $this->audit->emit(
+            AuditAction::CONSUMER_DELETED,
+            'consumer',
+            $id,
+            ['name' => $existing->name, 'soft' => true],
+            self::auditContext($request),
+        );
+
         return $response->withStatus(204);
     }
 

+ 172 - 0
api/src/Application/Admin/JobsAdminController.php

@@ -0,0 +1,172 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Application\Jobs\RefreshGeoipJob;
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
+use App\Infrastructure\Jobs\JobLockRepository;
+use App\Infrastructure\Jobs\JobRegistry;
+use App\Infrastructure\Jobs\JobRunner;
+use App\Infrastructure\Jobs\JobRunRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin-side proxy for the internal jobs surface.
+ *
+ *  - `GET  /api/v1/admin/jobs/status`  (Viewer): mirrors the data from
+ *     `/internal/jobs/status` but is reachable without the internal
+ *     token, and never short-circuits 404/401 on RFC1918 boundaries.
+ *  - `POST /api/v1/admin/jobs/trigger/{name}`  (Admin): invokes the
+ *     same Job class the internal handler does, but with
+ *     `triggered_by="manual"`. Emits one `job.triggered` audit row.
+ *     refresh-geoip honours the same 412-no-credential short-circuit
+ *     the internal handler implements.
+ *
+ * The split exists so the UI never has to forward through the internal
+ * token (which is bound to RFC1918 networks). Per SPEC §6/§7.
+ */
+final class JobsAdminController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly JobRegistry $registry,
+        private readonly JobRunner $runner,
+        private readonly JobRunRepository $runs,
+        private readonly JobLockRepository $locks,
+        private readonly Clock $clock,
+        private readonly GeoIpDownloader $geoipDownloader,
+        private readonly AuditEmitter $audit,
+    ) {
+    }
+
+    public function status(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $now = $this->clock->now();
+        $latest = $this->runs->latestPerJob();
+
+        $jobs = [];
+        foreach ($this->registry->all() as $name => $job) {
+            $row = $latest[$name] ?? null;
+
+            $finishedAt = $row['finished_at'] ?? null;
+            $overdue = $row === null
+                || ($finishedAt instanceof \DateTimeImmutable
+                    && ($now->getTimestamp() - $finishedAt->getTimestamp()) > $job->defaultIntervalSeconds());
+
+            $jobs[$name] = [
+                'name' => $name,
+                'default_interval_seconds' => $job->defaultIntervalSeconds(),
+                'max_runtime_seconds' => $job->maxRuntimeSeconds(),
+                'overdue' => $overdue,
+                'lock' => $this->locks->status($name),
+                'last_run' => $row === null ? null : [
+                    'id' => $row['id'],
+                    'status' => $row['status'],
+                    'items_processed' => $row['items_processed'],
+                    'triggered_by' => $row['triggered_by'],
+                    'started_at' => self::formatTs($row['started_at']),
+                    'finished_at' => self::formatTs($row['finished_at']),
+                    'error_message' => $row['error_message'],
+                ],
+            ];
+        }
+
+        return self::json($response, 200, [
+            'now' => $now->format('Y-m-d\TH:i:s\Z'),
+            'jobs' => $jobs,
+        ]);
+    }
+
+    /**
+     * @param array{name: string} $args
+     */
+    public function trigger(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $name = $args['name'];
+        if (!$this->registry->has($name)) {
+            return self::error($response, 404, 'unknown_job');
+        }
+
+        // refresh-geoip's credential short-circuit: same 412 envelope the
+        // internal handler returns. Don't take the lock or even start the
+        // job for opt-in providers without their key.
+        if ($name === RefreshGeoipJob::NAME
+            && $this->geoipDownloader->requiresCredential()
+            && !$this->geoipDownloader->hasCredential()) {
+            $missing = match ($this->geoipDownloader->name()) {
+                'maxmind' => 'MAXMIND_LICENSE_KEY',
+                'ipinfo' => 'IPINFO_TOKEN',
+                default => 'CREDENTIAL',
+            };
+
+            return self::json($response, 412, [
+                'error' => 'no_credential',
+                'provider' => $this->geoipDownloader->name(),
+                'missing' => $missing,
+            ]);
+        }
+
+        $body = self::jsonBody($request);
+        $params = self::sanitiseParams($body);
+
+        // Audit BEFORE running the job — even if the job fails, we want a
+        // record that it was invoked. The audit row lands on success;
+        // emit failure is silent (audit_emit_failed log entry) per
+        // DbAuditEmitter's contract.
+        $this->audit->emit(
+            AuditAction::JOB_TRIGGERED,
+            'job',
+            $name,
+            ['name' => $name, 'params' => $params, 'triggered_by' => 'manual'],
+            self::auditContext($request),
+        );
+
+        $job = $this->registry->get($name);
+        $outcome = $this->runner->run($job, $params, 'manual');
+
+        $response = $response
+            ->withStatus($outcome->httpStatus())
+            ->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($outcome->toArray()));
+
+        return $response;
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return array<string, mixed>
+     */
+    private static function sanitiseParams(array $body): array
+    {
+        // Whitelist the same params the internal handler accepts; ignore
+        // anything else so a malicious admin can't smuggle config.
+        $params = [];
+        if (isset($body['full'])) {
+            $params['full'] = (bool) $body['full'];
+        }
+        if (isset($body['max_rows']) && is_numeric($body['max_rows'])) {
+            $params['max_rows'] = (int) $body['max_rows'];
+        }
+        if (isset($body['reenrich'])) {
+            $params['reenrich'] = (bool) $body['reenrich'];
+        }
+
+        return $params;
+    }
+
+    private static function formatTs(mixed $value): ?string
+    {
+        if (!$value instanceof \DateTimeImmutable) {
+            return null;
+        }
+
+        return $value->format('Y-m-d\TH:i:s\Z');
+    }
+}

+ 27 - 0
api/src/Application/Admin/ManualBlocksController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Ip\Cidr;
 use App\Domain\Ip\InvalidCidrException;
 use App\Domain\Ip\InvalidIpException;
@@ -39,6 +41,7 @@ final class ManualBlocksController
         private readonly ManualBlockRepository $manualBlocks,
         private readonly CidrEvaluatorFactory $evaluator,
         private readonly BlocklistCache $blocklistCache,
+        private readonly AuditEmitter $audit,
     ) {
     }
 
@@ -144,6 +147,14 @@ final class ManualBlocksController
                 return self::error($response, 500, 'create_failed');
             }
 
+            $this->audit->emit(
+                AuditAction::MANUAL_BLOCK_CREATED,
+                'manual_block',
+                $id,
+                ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
+                self::auditContext($request),
+            );
+
             return self::json($response, 201, $created->toArray());
         }
 
@@ -180,6 +191,14 @@ final class ManualBlocksController
             $payload['normalized_from'] = $cidrInput;
         }
 
+        $this->audit->emit(
+            AuditAction::MANUAL_BLOCK_CREATED,
+            'manual_block',
+            $id,
+            ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, $payload);
     }
 
@@ -201,6 +220,14 @@ final class ManualBlocksController
         $this->evaluator->invalidate();
         $this->blocklistCache->invalidateAll();
 
+        $this->audit->emit(
+            AuditAction::MANUAL_BLOCK_DELETED,
+            'manual_block',
+            $id,
+            ['kind' => $existing->kind, 'reason' => $existing->reason],
+            self::auditContext($request),
+        );
+
         return $response->withStatus(204);
     }
 

+ 31 - 0
api/src/Application/Admin/PoliciesController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Category\Category;
 use App\Domain\Policy\Policy;
 use App\Domain\Reputation\BlocklistBuilder;
@@ -33,6 +35,7 @@ final class PoliciesController
         private readonly CategoryRepository $categories,
         private readonly BlocklistBuilder $blocklistBuilder,
         private readonly BlocklistCache $blocklistCache,
+        private readonly AuditEmitter $audit,
     ) {
     }
 
@@ -119,6 +122,14 @@ final class PoliciesController
             return self::error($response, 500, 'create_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::POLICY_CREATED,
+            'policy',
+            $id,
+            ['name' => $name, 'include_manual_blocks' => $includeManualBlocks, 'threshold_count' => count($thresholds)],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, $created->toArray($this->slugByCategoryId()));
     }
 
@@ -198,6 +209,18 @@ final class PoliciesController
             return self::error($response, 500, 'update_failed');
         }
 
+        $changed = array_keys($fields);
+        if ($thresholds !== null) {
+            $changed[] = 'thresholds';
+        }
+        $this->audit->emit(
+            AuditAction::POLICY_UPDATED,
+            'policy',
+            $id,
+            ['changed' => $changed],
+            self::auditContext($request),
+        );
+
         return self::json($response, 200, $updated->toArray($this->slugByCategoryId()));
     }
 
@@ -226,6 +249,14 @@ final class PoliciesController
         $this->policies->delete($id);
         $this->blocklistCache->invalidate($id);
 
+        $this->audit->emit(
+            AuditAction::POLICY_DELETED,
+            'policy',
+            $id,
+            ['name' => $existing->name],
+            self::auditContext($request),
+        );
+
         return $response->withStatus(204);
     }
 

+ 36 - 2
api/src/Application/Admin/ReportersController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Infrastructure\Reporter\ReporterRepository;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -21,8 +23,10 @@ final class ReportersController
 {
     use AdminControllerSupport;
 
-    public function __construct(private readonly ReporterRepository $reporters)
-    {
+    public function __construct(
+        private readonly ReporterRepository $reporters,
+        private readonly AuditEmitter $audit,
+    ) {
     }
 
     public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
@@ -102,6 +106,14 @@ final class ReportersController
             return self::error($response, 500, 'create_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::REPORTER_CREATED,
+            'reporter',
+            $id,
+            ['name' => $name, 'trust_weight' => $trustWeight, 'description' => $description],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, $created->toArray());
     }
 
@@ -173,6 +185,14 @@ final class ReportersController
             return self::error($response, 500, 'update_failed');
         }
 
+        $this->audit->emit(
+            AuditAction::REPORTER_UPDATED,
+            'reporter',
+            $id,
+            ['changed' => array_keys($fields)],
+            self::auditContext($request),
+        );
+
         return self::json($response, 200, $updated->toArray());
     }
 
@@ -193,6 +213,13 @@ final class ReportersController
         if ($this->reporters->reportCount($id) > 0) {
             // SPEC: refuse hard delete when reports exist; flip to inactive.
             $this->reporters->softDelete($id);
+            $this->audit->emit(
+                AuditAction::REPORTER_DELETED,
+                'reporter',
+                $id,
+                ['name' => $existing->name, 'soft' => true, 'reason' => 'has_reports'],
+                self::auditContext($request),
+            );
 
             return self::json($response, 409, [
                 'error' => 'has_reports',
@@ -201,6 +228,13 @@ final class ReportersController
         }
 
         $this->reporters->softDelete($id);
+        $this->audit->emit(
+            AuditAction::REPORTER_DELETED,
+            'reporter',
+            $id,
+            ['name' => $existing->name, 'soft' => true],
+            self::auditContext($request),
+        );
 
         return $response->withStatus(204);
     }

+ 27 - 0
api/src/Application/Admin/TokensController.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace App\Application\Admin;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Auth\Role;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenIssuer;
@@ -43,6 +45,7 @@ final class TokensController
         private readonly ReporterRepository $reporters,
         private readonly ConsumerRepository $consumers,
         private readonly Clock $clock,
+        private readonly AuditEmitter $audit,
     ) {
     }
 
@@ -178,6 +181,22 @@ final class TokensController
             return self::error($response, 500, 'create_failed');
         }
 
+        // Audit payload deliberately excludes the raw token. Prefix is OK.
+        $this->audit->emit(
+            AuditAction::TOKEN_CREATED,
+            'token',
+            $created->id,
+            [
+                'kind' => $created->kind->value,
+                'prefix' => $created->prefix,
+                'reporter_id' => $created->reporterId,
+                'consumer_id' => $created->consumerId,
+                'role' => $created->role?->value,
+                'expires_at' => $created->expiresAt?->format('c'),
+            ],
+            self::auditContext($request),
+        );
+
         return self::json($response, 201, [
             'id' => $created->id,
             'kind' => $created->kind->value,
@@ -209,6 +228,14 @@ final class TokensController
 
         $this->tokens->revoke($id, $this->clock->now());
 
+        $this->audit->emit(
+            AuditAction::TOKEN_REVOKED,
+            'token',
+            $id,
+            ['kind' => $token->kind->value, 'prefix' => $token->prefix],
+            self::auditContext($request),
+        );
+
         return $response->withStatus(204);
     }
 

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

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Audit;
+
+/**
+ * Stable string constants for `audit_log.action`. The value is the
+ * "<entity>.<verb>" pair surfaced in the API and the UI's filter
+ * dropdown. Kept as constants (not an enum) so adding a new action
+ * is a one-line patch and so the values can flow through DBAL without
+ * the enum casting boilerplate.
+ *
+ * `entityType` for each action is the prefix before the dot (e.g.
+ * `reporter.created` -> entity_type=reporter); the helper below maps
+ * automatically.
+ */
+final class AuditAction
+{
+    public const REPORTER_CREATED = 'reporter.created';
+    public const REPORTER_UPDATED = 'reporter.updated';
+    public const REPORTER_DELETED = 'reporter.deleted';
+
+    public const CONSUMER_CREATED = 'consumer.created';
+    public const CONSUMER_UPDATED = 'consumer.updated';
+    public const CONSUMER_DELETED = 'consumer.deleted';
+
+    public const TOKEN_CREATED = 'token.created';
+    public const TOKEN_REVOKED = 'token.revoked';
+
+    public const POLICY_CREATED = 'policy.created';
+    public const POLICY_UPDATED = 'policy.updated';
+    public const POLICY_DELETED = 'policy.deleted';
+
+    public const CATEGORY_CREATED = 'category.created';
+    public const CATEGORY_UPDATED = 'category.updated';
+    public const CATEGORY_DELETED = 'category.deleted';
+
+    public const MANUAL_BLOCK_CREATED = 'manual_block.created';
+    public const MANUAL_BLOCK_DELETED = 'manual_block.deleted';
+
+    public const ALLOWLIST_CREATED = 'allowlist.created';
+    public const ALLOWLIST_DELETED = 'allowlist.deleted';
+
+    public const USER_ROLE_CHANGED = 'user.role_changed';
+
+    public const OIDC_ROLE_MAPPING_CREATED = 'oidc_role_mapping.created';
+    public const OIDC_ROLE_MAPPING_DELETED = 'oidc_role_mapping.deleted';
+
+    public const JOB_TRIGGERED = 'job.triggered';
+
+    public static function entityTypeFor(string $action): string
+    {
+        $dot = strpos($action, '.');
+
+        return $dot === false ? $action : substr($action, 0, $dot);
+    }
+}

+ 38 - 0
api/src/Domain/Audit/AuditContext.php

@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Audit;
+
+/**
+ * Per-request audit context — actor identity and source IP.
+ *
+ * Resolved by AuditContextMiddleware from the active principal:
+ * - service token + impersonation -> actorKind=user, actorId=user_id
+ *   (NOT the service token; per SPEC §8 the user is the responsible party)
+ * - admin token -> actorKind=admin-token, actorId=token_id
+ * - reporter / consumer tokens -> actorKind=reporter / consumer
+ * - no principal at all (system-internal) -> actorKind=system, no actorId
+ */
+final class AuditContext
+{
+    public const KIND_USER = 'user';
+    public const KIND_ADMIN_TOKEN = 'admin-token';
+    public const KIND_REPORTER = 'reporter';
+    public const KIND_CONSUMER = 'consumer';
+    public const KIND_SYSTEM = 'system';
+
+    public function __construct(
+        public readonly string $actorKind,
+        public readonly ?int $actorId,
+        public readonly ?string $actorName,
+        public readonly ?string $sourceIp,
+        public readonly ?string $requestId,
+    ) {
+    }
+
+    public static function system(): self
+    {
+        return new self(self::KIND_SYSTEM, null, null, null, null);
+    }
+}

+ 28 - 0
api/src/Domain/Audit/AuditEmitter.php

@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Audit;
+
+/**
+ * Writes one row to `audit_log` per successful state-changing operation.
+ *
+ * Implementations MUST swallow infra failures (DB hiccup, OOM during
+ * JSON encode) and log them — emitting an audit row is observability,
+ * not a transactional invariant. A failed emit must never propagate
+ * an exception that aborts the originating request.
+ */
+interface AuditEmitter
+{
+    /**
+     * @param array<string, mixed> $payload Free-form event payload, JSON-encoded into details_json.
+     *     MUST NOT contain raw secrets (raw tokens, passwords).
+     */
+    public function emit(
+        string $action,
+        ?string $entityType,
+        int|string|null $entityId,
+        array $payload,
+        AuditContext $context,
+    ): void;
+}

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

@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Audit;
+
+use App\Domain\Audit\AuditContext;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Db\RepositoryBase;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Insert + paginated search over `audit_log`. The search supports the
+ * filter set the brief calls out: actor_kind, actor_id, action,
+ * entity_type, entity_id, from, to. All filters are optional; missing
+ * filters degrade to "any".
+ */
+class AuditRepository extends RepositoryBase
+{
+    public function __construct(
+        \Doctrine\DBAL\Connection $connection,
+        private readonly Clock $clock,
+    ) {
+        parent::__construct($connection);
+    }
+
+    public function insert(
+        string $action,
+        ?string $entityType,
+        int|string|null $entityId,
+        string $detailsJson,
+        AuditContext $context,
+    ): int {
+        $now = $this->clock->now()->format('Y-m-d H:i:s');
+        $id = $this->insertRow('audit_log', [
+            'actor_kind' => $context->actorKind,
+            'actor_id' => $context->actorId !== null ? (string) $context->actorId : null,
+            'action' => $action,
+            'target_type' => $entityType,
+            'target_id' => $entityId !== null ? (string) $entityId : null,
+            'details_json' => $detailsJson,
+            'ip_address' => $context->sourceIp,
+            'created_at' => $now,
+        ]);
+
+        return $id > 0 ? (int) $this->connection()->lastInsertId() : 0;
+    }
+
+    /**
+     * @param array{
+     *     actor_kind?: ?string,
+     *     actor_id?: ?int,
+     *     action?: ?string,
+     *     entity_type?: ?string,
+     *     entity_id?: ?string,
+     *     from?: ?string,
+     *     to?: ?string,
+     * } $filters
+     * @return array{
+     *     items: list<array{id: int, occurred_at: string, actor_kind: string, actor_id: ?string, action: string, entity_type: ?string, entity_id: ?string, details: array<string, mixed>|null, source_ip: ?string}>,
+     *     total: int,
+     * }
+     */
+    public function search(array $filters, int $limit, int $offset): array
+    {
+        $where = [];
+        $params = [];
+
+        if (!empty($filters['actor_kind'])) {
+            $where[] = 'actor_kind = :actor_kind';
+            $params['actor_kind'] = $filters['actor_kind'];
+        }
+        if (!empty($filters['actor_id'])) {
+            $where[] = 'actor_id = :actor_id';
+            $params['actor_id'] = (string) $filters['actor_id'];
+        }
+        if (!empty($filters['action'])) {
+            $where[] = 'action = :action';
+            $params['action'] = $filters['action'];
+        }
+        if (!empty($filters['entity_type'])) {
+            $where[] = 'target_type = :entity_type';
+            $params['entity_type'] = $filters['entity_type'];
+        }
+        if (!empty($filters['entity_id'])) {
+            $where[] = 'target_id = :entity_id';
+            $params['entity_id'] = $filters['entity_id'];
+        }
+        if (!empty($filters['from'])) {
+            $where[] = 'created_at >= :from';
+            $params['from'] = $filters['from'];
+        }
+        if (!empty($filters['to'])) {
+            $where[] = 'created_at <= :to';
+            $params['to'] = $filters['to'];
+        }
+
+        $whereSql = $where === [] ? '' : (' WHERE ' . implode(' AND ', $where));
+
+        $itemsParams = $params;
+        $itemsParams['limit'] = $limit;
+        $itemsParams['offset'] = $offset;
+        $itemTypes = ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER];
+
+        $rows = $this->connection()->fetchAllAssociative(
+            'SELECT id, actor_kind, actor_id, action, target_type, target_id, details_json, ip_address, created_at '
+            . 'FROM audit_log' . $whereSql . ' ORDER BY id DESC LIMIT :limit OFFSET :offset',
+            $itemsParams,
+            $itemTypes,
+        );
+
+        $total = (int) $this->connection()->fetchOne(
+            'SELECT COUNT(*) FROM audit_log' . $whereSql,
+            $params,
+        );
+
+        $items = [];
+        foreach ($rows as $row) {
+            $detailsRaw = $row['details_json'] ?? null;
+            $details = null;
+            if (is_string($detailsRaw) && $detailsRaw !== '') {
+                $decoded = json_decode($detailsRaw, true);
+                if (is_array($decoded)) {
+                    $details = $decoded;
+                }
+            }
+            $items[] = [
+                'id' => (int) $row['id'],
+                'occurred_at' => self::isoTimestamp((string) $row['created_at']),
+                'actor_kind' => (string) $row['actor_kind'],
+                'actor_id' => $row['actor_id'] !== null ? (string) $row['actor_id'] : null,
+                'action' => (string) $row['action'],
+                'entity_type' => $row['target_type'] !== null ? (string) $row['target_type'] : null,
+                'entity_id' => $row['target_id'] !== null ? (string) $row['target_id'] : null,
+                'details' => $details,
+                'source_ip' => $row['ip_address'] !== null ? (string) $row['ip_address'] : null,
+            ];
+        }
+
+        return ['items' => $items, 'total' => $total];
+    }
+
+    private static function isoTimestamp(string $value): string
+    {
+        $trimmed = trim($value);
+        if (str_contains($trimmed, 'T')) {
+            return $trimmed;
+        }
+
+        return str_replace(' ', 'T', $trimmed) . 'Z';
+    }
+}

+ 48 - 0
api/src/Infrastructure/Audit/DbAuditEmitter.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Audit;
+
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditContext;
+use App\Domain\Audit\AuditEmitter;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Persists audit rows via {@see AuditRepository}. Failures are logged
+ * but never re-thrown — the caller's controller has already mutated
+ * state by the time we're invoked, and a missing audit row is less
+ * harmful than a 500 on a successful write.
+ */
+final class DbAuditEmitter implements AuditEmitter
+{
+    public function __construct(
+        private readonly AuditRepository $repo,
+        private readonly LoggerInterface $logger,
+    ) {
+    }
+
+    public function emit(
+        string $action,
+        ?string $entityType,
+        int|string|null $entityId,
+        array $payload,
+        AuditContext $context,
+    ): void {
+        $type = $entityType ?? AuditAction::entityTypeFor($action);
+
+        try {
+            $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+            $this->repo->insert($action, $type, $entityId, $json, $context);
+        } catch (Throwable $e) {
+            $this->logger->error('audit_emit_failed', [
+                'action' => $action,
+                'entity_type' => $type,
+                'entity_id' => $entityId,
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 94 - 0
api/src/Infrastructure/Http/Middleware/AuditContextMiddleware.php

@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http\Middleware;
+
+use App\Domain\Audit\AuditContext;
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\TokenKind;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Resolves an {@see AuditContext} from the active principal +
+ * source IP and attaches it to the request. Runs AFTER
+ * TokenAuthenticationMiddleware + ImpersonationMiddleware so that
+ * service-token + impersonation has already produced `userId` and we
+ * can honour the SPEC §8 invariant: the impersonated user is the
+ * audit actor, never the service token.
+ */
+final class AuditContextMiddleware implements MiddlewareInterface
+{
+    public const ATTR_AUDIT_CONTEXT = 'auditContext';
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        $context = self::contextFor($principal, self::clientIp($request), self::requestId($request));
+        $request = $request->withAttribute(self::ATTR_AUDIT_CONTEXT, $context);
+
+        return $handler->handle($request);
+    }
+
+    private static function contextFor(mixed $principal, ?string $sourceIp, ?string $requestId): AuditContext
+    {
+        if (!$principal instanceof AuthenticatedPrincipal) {
+            return new AuditContext(AuditContext::KIND_SYSTEM, null, null, $sourceIp, $requestId);
+        }
+
+        // Service token + impersonation: attribute to the impersonated user.
+        if ($principal->tokenKind === TokenKind::Service && $principal->userId !== null) {
+            return new AuditContext(AuditContext::KIND_USER, $principal->userId, null, $sourceIp, $requestId);
+        }
+
+        return match ($principal->tokenKind) {
+            TokenKind::Admin => new AuditContext(
+                AuditContext::KIND_ADMIN_TOKEN,
+                $principal->userId ?? $principal->tokenId,
+                null,
+                $sourceIp,
+                $requestId,
+            ),
+            TokenKind::Reporter => new AuditContext(
+                AuditContext::KIND_REPORTER,
+                $principal->reporterId,
+                null,
+                $sourceIp,
+                $requestId,
+            ),
+            TokenKind::Consumer => new AuditContext(
+                AuditContext::KIND_CONSUMER,
+                $principal->consumerId,
+                null,
+                $sourceIp,
+                $requestId,
+            ),
+            // Service token without impersonation (rare — auth endpoints only).
+            TokenKind::Service => new AuditContext(
+                AuditContext::KIND_SYSTEM,
+                null,
+                null,
+                $sourceIp,
+                $requestId,
+            ),
+        };
+    }
+
+    private static function clientIp(ServerRequestInterface $request): ?string
+    {
+        $server = $request->getServerParams();
+        $remote = $server['REMOTE_ADDR'] ?? null;
+
+        return is_string($remote) && $remote !== '' ? $remote : null;
+    }
+
+    private static function requestId(ServerRequestInterface $request): ?string
+    {
+        $header = $request->getHeaderLine('X-Request-Id');
+
+        return $header !== '' ? $header : null;
+    }
+}

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

@@ -0,0 +1,83 @@
+<?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/audit-log` end-to-end. Seeds rows directly so the
+ * filter coverage doesn't depend on emission timing.
+ */
+final class AuditLogControllerTest extends AppTestCase
+{
+    public function testListEmpty(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request('GET', '/api/v1/admin/audit-log', ['Authorization' => 'Bearer ' . $token]);
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame([], $body['items']);
+        self::assertSame(0, $body['total']);
+    }
+
+    public function testListWithFilters(): void
+    {
+        $now = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
+        $this->seedAudit('user', '7', 'manual_block.created', 'manual_block', '1', '{"ip":"1.1.1.1"}', $now);
+        $this->seedAudit('admin-token', '3', 'category.created', 'category', '2', '{"slug":"x"}', $now);
+        $this->seedAudit('user', '7', 'allowlist.created', 'allowlist', '5', '{"ip":"2.2.2.2"}', $now);
+
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        // Filter by actor_kind=user
+        $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=user', ['Authorization' => 'Bearer ' . $token]);
+        $body = $this->decode($resp);
+        self::assertSame(2, $body['total']);
+        foreach ($body['items'] as $item) {
+            self::assertSame('user', $item['actor_kind']);
+        }
+
+        // Filter by action
+        $resp = $this->request('GET', '/api/v1/admin/audit-log?action=category.created', ['Authorization' => 'Bearer ' . $token]);
+        $body = $this->decode($resp);
+        self::assertSame(1, $body['total']);
+        self::assertSame('category.created', $body['items'][0]['action']);
+        self::assertSame(['slug' => 'x'], $body['items'][0]['details']);
+
+        // Filter by entity_type=manual_block
+        $resp = $this->request('GET', '/api/v1/admin/audit-log?entity_type=manual_block', ['Authorization' => 'Bearer ' . $token]);
+        $body = $this->decode($resp);
+        self::assertSame(1, $body['total']);
+        self::assertSame('manual_block', $body['items'][0]['entity_type']);
+    }
+
+    public function testInvalidActorKindReturns400(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request('GET', '/api/v1/admin/audit-log?actor_kind=potato', ['Authorization' => 'Bearer ' . $token]);
+        self::assertSame(400, $resp->getStatusCode());
+    }
+
+    public function testRequiresViewer(): void
+    {
+        $resp = $this->request('GET', '/api/v1/admin/audit-log');
+        self::assertSame(401, $resp->getStatusCode());
+    }
+
+    private function seedAudit(string $kind, ?string $actorId, string $action, string $type, string $id, string $details, string $when): void
+    {
+        $this->db->insert('audit_log', [
+            'actor_kind' => $kind,
+            'actor_id' => $actorId,
+            'action' => $action,
+            'target_type' => $type,
+            'target_id' => $id,
+            'details_json' => $details,
+            'ip_address' => null,
+            'created_at' => $when,
+        ]);
+    }
+}

+ 98 - 0
api/tests/Integration/Admin/ConfigControllerTest.php

@@ -0,0 +1,98 @@
+<?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/config` — Admin-only effective config with secrets
+ * masked.
+ */
+final class ConfigControllerTest extends AppTestCase
+{
+    public function testRequiresAdmin(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request('GET', '/api/v1/admin/config', ['Authorization' => 'Bearer ' . $token]);
+        self::assertSame(403, $resp->getStatusCode());
+    }
+
+    public function testReturnsSectionsAndMasksSecrets(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request('GET', '/api/v1/admin/config', ['Authorization' => 'Bearer ' . $token]);
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertArrayHasKey('sections', $body);
+        $sections = $body['sections'];
+
+        // Required sections
+        foreach (['app', 'database', 'auth', 'reputation', 'jobs', 'geoip'] as $section) {
+            self::assertArrayHasKey($section, $sections, "missing section $section");
+        }
+
+        // INTERNAL_JOB_TOKEN / MAXMIND_LICENSE_KEY: empty in tests, so empty string.
+        self::assertSame('', $sections['auth']['INTERNAL_JOB_TOKEN']);
+        self::assertSame('', $sections['geoip']['MAXMIND_LICENSE_KEY']);
+
+        // Plain values
+        self::assertSame('sqlite', $sections['database']['DB_DRIVER']);
+        self::assertSame('dbip', $sections['geoip']['GEOIP_PROVIDER']);
+    }
+
+    public function testMasksTokensWhenSet(): void
+    {
+        // Re-build the container with a configured ui_service_token / internal token / maxmind key
+        $settings = $this->withSettings([
+            'ui_service_token' => 'irdb_svc_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
+            'internal_job_token' => 'super-secret-internal-token-1234',
+            'geoip' => [
+                'enabled' => true,
+                'provider' => 'maxmind',
+                'country_db' => '/tmp/c.mmdb',
+                'asn_db' => '/tmp/a.mmdb',
+                'maxmind_license_key' => 'real-maxmind-key',
+                'ipinfo_token' => 'real-ipinfo-token',
+                'refresh_interval_days' => 7,
+            ],
+        ]);
+
+        if (method_exists($this->container, 'set')) {
+            /** @var \DI\Container $c */
+            $c = $this->container;
+            $c->set('settings', $settings);
+            $c->set(
+                \App\Application\Admin\ConfigController::class,
+                new \App\Application\Admin\ConfigController($settings),
+            );
+            // Rebuild the app so the route picks up the patched controller.
+            $this->app = \App\App\AppFactory::build($this->container);
+        }
+
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request('GET', '/api/v1/admin/config', ['Authorization' => 'Bearer ' . $token]);
+        $body = $this->decode($resp);
+        $sections = $body['sections'];
+
+        self::assertSame('irdb_svc...', $sections['auth']['UI_SERVICE_TOKEN']);
+        self::assertSame('***', $sections['auth']['INTERNAL_JOB_TOKEN']);
+        self::assertSame('***', $sections['geoip']['MAXMIND_LICENSE_KEY']);
+        self::assertSame('***', $sections['geoip']['IPINFO_TOKEN']);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function withSettings(array $overrides): array
+    {
+        // Read the live settings via container, layer the overrides on top.
+        /** @var array<string, mixed> $settings */
+        $settings = $this->container->get('settings');
+
+        return array_replace($settings, $overrides);
+    }
+}

+ 117 - 0
api/tests/Integration/Admin/JobsAdminControllerTest.php

@@ -0,0 +1,117 @@
+<?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/jobs/{status,trigger}` — admin-only wrapper that the UI
+ * uses to invoke jobs without needing the internal token.
+ */
+final class JobsAdminControllerTest extends AppTestCase
+{
+    public function testStatusRequiresViewer(): void
+    {
+        $resp = $this->request('GET', '/api/v1/admin/jobs/status');
+        self::assertSame(401, $resp->getStatusCode());
+    }
+
+    public function testStatusReturnsAllJobs(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Viewer);
+        $resp = $this->request('GET', '/api/v1/admin/jobs/status', ['Authorization' => 'Bearer ' . $token]);
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertArrayHasKey('jobs', $body);
+        foreach (['recompute-scores', 'cleanup-audit', 'enrich-pending', 'refresh-geoip', 'tick'] as $name) {
+            self::assertArrayHasKey($name, $body['jobs']);
+        }
+    }
+
+    public function testTriggerOperatorForbidden(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Operator);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/jobs/trigger/recompute-scores',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(403, $resp->getStatusCode());
+    }
+
+    public function testTriggerUnknownJobReturns404(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/jobs/trigger/does-not-exist',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(404, $resp->getStatusCode());
+    }
+
+    public function testTriggerRecomputeRunsAndAuditsAsManual(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/jobs/trigger/recompute-scores',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{}',
+        );
+        self::assertSame(200, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame('recompute-scores', $body['job']);
+        self::assertSame('success', $body['status']);
+
+        // job_runs.triggered_by = manual
+        $row = $this->db->fetchAssociative(
+            "SELECT triggered_by FROM job_runs WHERE job_name = 'recompute-scores' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertSame('manual', $row['triggered_by']);
+
+        // Audit row attributed to the admin token
+        $audit = $this->db->fetchAssociative(
+            "SELECT actor_kind, action, target_id, details_json FROM audit_log WHERE action = 'job.triggered' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($audit);
+        self::assertSame('admin-token', $audit['actor_kind']);
+        self::assertSame('recompute-scores', $audit['target_id']);
+        $details = json_decode((string) $audit['details_json'], true);
+        self::assertSame('manual', $details['triggered_by']);
+    }
+
+    public function testRefreshGeoip412UnderMaxmindWithoutKey(): void
+    {
+        // Swap the downloader binding to MaxMind without a key.
+        if (method_exists($this->container, 'set')) {
+            /** @var \DI\Container $c */
+            $c = $this->container;
+            $c->set(
+                \App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader::class,
+                new \App\Infrastructure\Enrichment\Downloaders\MaxMindDownloader(new \GuzzleHttp\Client(), licenseKey: ''),
+            );
+            $c->set(
+                \App\Application\Admin\JobsAdminController::class,
+                $c->make(\App\Application\Admin\JobsAdminController::class),
+            );
+            $this->app = \App\App\AppFactory::build($this->container);
+        }
+
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/jobs/trigger/refresh-geoip',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(412, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame('no_credential', $body['error']);
+        self::assertSame('maxmind', $body['provider']);
+        self::assertSame('MAXMIND_LICENSE_KEY', $body['missing']);
+    }
+}

+ 151 - 0
api/tests/Integration/Audit/AuditEmissionTest.php

@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Audit;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * Confirms that successful state-changing admin endpoints emit exactly
+ * one row in `audit_log` and that the actor attribution honours the
+ * SPEC §8 invariant: service-token + impersonation -> actor_kind=user;
+ * raw admin token -> actor_kind=admin-token.
+ */
+final class AuditEmissionTest extends AppTestCase
+{
+    public function testManualBlockCreateEmitsAuditViaAdminToken(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'admin-token-test']),
+        );
+        self::assertSame(201, $resp->getStatusCode());
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, actor_id, action, target_type, details_json FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('admin-token', $row['actor_kind']);
+        self::assertSame('manual_block', $row['target_type']);
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame('203.0.113.42', $details['ip']);
+        self::assertSame('admin-token-test', $details['reason']);
+    }
+
+    public function testManualBlockCreateEmitsAuditAttributedToImpersonatedUser(): void
+    {
+        $userId = $this->createUser(Role::Admin, isLocal: true);
+        $service = $this->createToken(TokenKind::Service);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            [
+                'Authorization' => 'Bearer ' . $service,
+                'X-Acting-User-Id' => (string) $userId,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.43', 'reason' => 'via-ui']),
+        );
+        self::assertSame(201, $resp->getStatusCode());
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, actor_id FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('user', $row['actor_kind']);
+        self::assertSame((string) $userId, $row['actor_id']);
+    }
+
+    public function testTokenCreateNeverPutsRawTokenInAudit(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, Role::Admin);
+        $reporterId = $this->createReporter('rep-audit');
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            (string) json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]),
+        );
+        self::assertSame(201, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        $rawToken = (string) $body['raw_token'];
+
+        $row = $this->db->fetchAssociative(
+            "SELECT details_json FROM audit_log WHERE action = 'token.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        $json = (string) $row['details_json'];
+        self::assertStringNotContainsString($rawToken, $json);
+        // Prefix is allowed.
+        $details = json_decode($json, true);
+        self::assertIsArray($details);
+        self::assertArrayHasKey('prefix', $details);
+        self::assertArrayHasKey('kind', $details);
+    }
+
+    public function testFailedValidationDoesNotEmitAudit(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            (string) json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']),
+        );
+        self::assertSame(400, $resp->getStatusCode());
+
+        $count = (int) $this->db->fetchOne("SELECT COUNT(*) FROM audit_log WHERE action = 'manual_block.created'");
+        self::assertSame(0, $count);
+    }
+
+    public function testCategoryCreateUpdateDeleteAudit(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            (string) json_encode([
+                'slug' => 'audit_test',
+                'name' => 'Audit Test',
+                'decay_function' => 'linear',
+                'decay_param' => 30.0,
+            ]),
+        );
+        self::assertSame(201, $resp->getStatusCode());
+        $created = $this->decode($resp);
+        $id = (int) $created['id'];
+
+        $resp = $this->request(
+            'PATCH',
+            '/api/v1/admin/categories/' . $id,
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            (string) json_encode(['name' => 'Audit Test Renamed']),
+        );
+        self::assertSame(200, $resp->getStatusCode());
+
+        $resp = $this->request(
+            'DELETE',
+            '/api/v1/admin/categories/' . $id,
+            ['Authorization' => 'Bearer ' . $admin],
+        );
+        self::assertSame(204, $resp->getStatusCode());
+
+        $rows = $this->db->fetchAllAssociative(
+            "SELECT action FROM audit_log WHERE target_type = 'category' AND target_id = ? ORDER BY id ASC",
+            [(string) $id],
+        );
+        $actions = array_map(static fn ($r) => $r['action'], $rows);
+        self::assertSame(['category.created', 'category.updated', 'category.deleted'], $actions);
+    }
+}

+ 24 - 0
api/tests/Unit/Audit/AuditActionTest.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Audit;
+
+use App\Domain\Audit\AuditAction;
+use PHPUnit\Framework\TestCase;
+
+final class AuditActionTest extends TestCase
+{
+    public function testEntityTypeBeforeDot(): void
+    {
+        self::assertSame('reporter', AuditAction::entityTypeFor(AuditAction::REPORTER_CREATED));
+        self::assertSame('manual_block', AuditAction::entityTypeFor(AuditAction::MANUAL_BLOCK_CREATED));
+        self::assertSame('job', AuditAction::entityTypeFor(AuditAction::JOB_TRIGGERED));
+        self::assertSame('oidc_role_mapping', AuditAction::entityTypeFor(AuditAction::OIDC_ROLE_MAPPING_DELETED));
+    }
+
+    public function testEntityTypeForNoDotReturnsInput(): void
+    {
+        self::assertSame('foo', AuditAction::entityTypeFor('foo'));
+    }
+}

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

@@ -0,0 +1,153 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Audit — IRDB{% endblock %}
+
+{% macro action_pill(action) %}
+    {%- set bucket = action|split('.')[0]|default('') -%}
+    {%- set classes = {
+        'reporter':       'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100',
+        'consumer':       'bg-cyan-100 text-cyan-900 dark:bg-cyan-900 dark:text-cyan-100',
+        'token':          'bg-purple-100 text-purple-900 dark:bg-purple-900 dark:text-purple-100',
+        'policy':         'bg-indigo-100 text-indigo-900 dark:bg-indigo-900 dark:text-indigo-100',
+        'category':       'bg-violet-100 text-violet-900 dark:bg-violet-900 dark:text-violet-100',
+        'manual_block':   'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
+        'allowlist':      'bg-emerald-100 text-emerald-900 dark:bg-emerald-900 dark:text-emerald-100',
+        'job':            'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100',
+    } -%}
+    <span class="inline-block rounded px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-tight {{ classes[bucket]|default('bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') }}">{{ action }}</span>
+{% endmacro %}
+
+{% block content %}
+{% import _self as h %}
+<div class="mx-auto max-w-6xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
+        {% if list %}
+            <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total }} total</span>
+        {% endif %}
+    </div>
+
+    {% if error %}
+        <div class="mt-4 rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">{{ error }}</div>
+    {% endif %}
+
+    <form method="get" action="/app/audit" class="mt-4 grid grid-cols-2 gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-sm dark:border-slate-800 dark:bg-slate-900 md:grid-cols-7">
+        <div>
+            <label for="f-actor-kind" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Actor kind</label>
+            <select id="f-actor-kind" name="actor_kind" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                <option value="">— any —</option>
+                {% for k in allowed_kinds %}
+                    <option value="{{ k }}" {% if filters.actor_kind == k %}selected{% endif %}>{{ k }}</option>
+                {% endfor %}
+            </select>
+        </div>
+        <div>
+            <label for="f-actor-id" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Actor id</label>
+            <input type="number" id="f-actor-id" name="actor_id" min="1" value="{{ filters.actor_id|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div class="col-span-2">
+            <label for="f-action" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Action</label>
+            <select id="f-action" name="action" class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+                <option value="">— any —</option>
+                {% for a in allowed_actions %}
+                    <option value="{{ a }}" {% if filters.action == a %}selected{% endif %}>{{ a }}</option>
+                {% endfor %}
+            </select>
+        </div>
+        <div>
+            <label for="f-entity-type" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Entity type</label>
+            <input type="text" id="f-entity-type" name="entity_type" value="{{ filters.entity_type|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div>
+            <label for="f-entity-id" class="block text-xs font-medium text-slate-600 dark:text-slate-400">Entity id</label>
+            <input type="text" id="f-entity-id" name="entity_id" value="{{ filters.entity_id|default('') }}"
+                   class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-950">
+        </div>
+        <div class="col-span-2 md:col-span-7 grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
+            <div>
+                <label for="f-from" class="block text-xs font-medium text-slate-600 dark:text-slate-400">From (ISO)</label>
+                <input type="text" id="f-from" name="from" placeholder="2026-04-01T00:00:00Z" value="{{ filters.from|default('') }}"
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono text-sm dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div>
+                <label for="f-to" class="block text-xs font-medium text-slate-600 dark:text-slate-400">To (ISO)</label>
+                <input type="text" id="f-to" name="to" placeholder="2026-04-30T23:59:59Z" value="{{ filters.to|default('') }}"
+                       class="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 font-mono text-sm dark:border-slate-700 dark:bg-slate-950">
+            </div>
+            <div class="flex justify-end gap-2">
+                <a href="/app/audit" 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">Reset</a>
+                <button type="submit" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Filter</button>
+            </div>
+        </div>
+    </form>
+
+    {% if list %}
+        <div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" x-data="{ open: null }">
+            <table class="w-full text-sm">
+                <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                    <tr>
+                        <th class="px-4 py-2 font-medium">When</th>
+                        <th class="px-4 py-2 font-medium">Actor</th>
+                        <th class="px-4 py-2 font-medium">Action</th>
+                        <th class="px-4 py-2 font-medium">Entity</th>
+                        <th class="px-4 py-2 font-medium">Source IP</th>
+                        <th class="px-4 py-2 font-medium text-right">Payload</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                    {% for ev in list.items %}
+                        <tr>
+                            <td class="px-4 py-2 align-top"><time class="font-mono text-xs text-slate-600 dark:text-slate-300" datetime="{{ ev.occurred_at }}" title="{{ ev.occurred_at }}">{{ ev.occurred_at }}</time></td>
+                            <td class="px-4 py-2 align-top text-xs">
+                                <span class="rounded bg-slate-100 px-1.5 py-0.5 font-mono uppercase tracking-tight text-slate-700 dark:bg-slate-800 dark:text-slate-300">{{ ev.actor_kind }}</span>
+                                {% if ev.actor_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.actor_id }}</span>{% endif %}
+                            </td>
+                            <td class="px-4 py-2 align-top">{{ h.action_pill(ev.action) }}</td>
+                            <td class="px-4 py-2 align-top text-xs">
+                                <span class="font-mono text-slate-600 dark:text-slate-300">{{ ev.entity_type|default('—') }}</span>
+                                {% if ev.entity_id %}<span class="ml-1 font-mono text-slate-500">#{{ ev.entity_id }}</span>{% endif %}
+                            </td>
+                            <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">{{ ev.source_ip|default('—') }}</td>
+                            <td class="px-4 py-2 align-top text-right">
+                                {% if ev.details %}
+                                    <button type="button" x-on:click="open = (open === {{ ev.id }} ? null : {{ ev.id }})" class="rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">View</button>
+                                {% else %}
+                                    <span class="text-xs text-slate-400">—</span>
+                                {% endif %}
+                            </td>
+                        </tr>
+                        {% if ev.details %}
+                            <tr x-show="open === {{ ev.id }}" x-cloak>
+                                <td colspan="6" class="bg-slate-50 px-4 py-3 dark:bg-slate-950">
+                                    <pre class="overflow-x-auto rounded bg-white p-3 text-xs dark:bg-slate-900">{{ ev.details|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
+                                </td>
+                            </tr>
+                        {% endif %}
+                    {% else %}
+                        <tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">No events match these filters.</td></tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+
+        {% if list.total > list.page_size %}
+            {% set total_pages = (list.total // list.page_size) + (list.total % list.page_size > 0 ? 1 : 0) %}
+            <nav class="mt-4 flex items-center justify-between text-sm">
+                <span class="text-slate-500 dark:text-slate-400">Page {{ page }} of {{ total_pages }}</span>
+                <div class="flex gap-2">
+                    {% set prev_qs = filters|merge({'page': page - 1}) %}
+                    {% set next_qs = filters|merge({'page': page + 1}) %}
+                    {% if page > 1 %}
+                        <a href="/app/audit?{{ prev_qs|url_encode }}" class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">‹ Prev</a>
+                    {% endif %}
+                    {% if page < total_pages %}
+                        <a href="/app/audit?{{ next_qs|url_encode }}" class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Next ›</a>
+                    {% endif %}
+                </div>
+            </nav>
+        {% endif %}
+    {% endif %}
+</div>
+{% endblock %}

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

@@ -0,0 +1,133 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Settings — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl space-y-6">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Settings</h1>
+        <span class="text-xs text-slate-500 dark:text-slate-400">Admin only · read-only · masked secrets</span>
+    </div>
+
+    {% if error %}
+        <div class="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">{{ error }}</div>
+    {% endif %}
+
+    {# ------------------------- Configuration ------------------------- #}
+    {% if config and config.sections %}
+        <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">Configuration</h2>
+            <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Effective values from the api's environment. Secrets are masked (<code>***</code>) or previewed (first 8 chars + …).</p>
+
+            <div class="mt-4 grid gap-5 md:grid-cols-2">
+                {% for section_name, items in config.sections %}
+                    <div class="rounded-lg border border-slate-100 dark:border-slate-800">
+                        <div class="border-b border-slate-100 bg-slate-50 px-4 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">{{ section_name }}</div>
+                        <dl class="divide-y divide-slate-100 dark:divide-slate-800 text-sm">
+                            {% for key, value in items %}
+                                <div class="grid grid-cols-2 gap-2 px-4 py-2">
+                                    <dt class="font-mono text-xs text-slate-500 dark:text-slate-400">{{ key }}</dt>
+                                    <dd class="break-all font-mono text-xs text-slate-700 dark:text-slate-200">
+                                        {%- if value is null -%}<span class="text-slate-400">—</span>
+                                        {%- elseif value is same as(true) -%}true
+                                        {%- elseif value is same as(false) -%}false
+                                        {%- else -%}{{ value }}{%- endif -%}
+                                    </dd>
+                                </div>
+                            {% endfor %}
+                        </dl>
+                    </div>
+                {% endfor %}
+            </div>
+        </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">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Jobs</h2>
+            <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Latest run, lock state, and manual-trigger buttons. Manual triggers run synchronously — wait for the response.</p>
+
+            <div class="mt-4 overflow-hidden rounded-lg border border-slate-100 dark:border-slate-800">
+                <table class="w-full text-sm">
+                    <thead class="border-b border-slate-100 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                        <tr>
+                            <th class="px-4 py-2 font-medium">Name</th>
+                            <th class="px-4 py-2 font-medium">Last status</th>
+                            <th class="px-4 py-2 font-medium">Last finished</th>
+                            <th class="px-4 py-2 font-medium">Items</th>
+                            <th class="px-4 py-2 text-right font-medium">Trigger</th>
+                        </tr>
+                    </thead>
+                    <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                        {% for name, info in jobs.jobs %}
+                            <tr>
+                                <td class="px-4 py-2 align-top font-mono text-xs">
+                                    {{ name }}
+                                    {% if info.overdue %}
+                                        <span class="ml-1 rounded bg-red-100 px-1.5 py-0.5 text-[0.65rem] font-mono uppercase text-red-800 dark:bg-red-950 dark:text-red-300">overdue</span>
+                                    {% endif %}
+                                </td>
+                                <td class="px-4 py-2 align-top">
+                                    {% if info.last_run %}
+                                        {% set s = info.last_run.status %}
+                                        {% set classes = {
+                                            'success':        'bg-emerald-100 text-emerald-900 dark:bg-emerald-900 dark:text-emerald-100',
+                                            'failure':        'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
+                                            'skipped_locked': 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
+                                            'running':        'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100',
+                                        } %}
+                                        <span class="rounded px-2 py-0.5 font-mono text-[0.65rem] uppercase {{ classes[s]|default('bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300') }}">{{ s }}</span>
+                                    {% else %}
+                                        <span class="text-xs text-slate-400">never run</span>
+                                    {% endif %}
+                                </td>
+                                <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">
+                                    {{ info.last_run.finished_at|default('—') }}
+                                </td>
+                                <td class="px-4 py-2 align-top font-mono text-xs text-slate-500">
+                                    {{ info.last_run.items_processed|default('—') }}
+                                </td>
+                                <td class="px-4 py-2 align-top text-right">
+                                    {% if name != 'tick' %}
+                                        <form method="post" action="/app/settings/jobs/trigger/{{ name }}" class="inline" x-data="{ submitting: false }" x-on:submit="submitting = true">
+                                            <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+                                            <button type="submit" x-bind:disabled="submitting"
+                                                    class="rounded-md border border-slate-300 px-2 py-1 text-xs hover:bg-slate-50 disabled:opacity-50 dark:border-slate-700 dark:hover:bg-slate-800">
+                                                <span x-show="!submitting">Run now</span>
+                                                <span x-show="submitting" x-cloak>Running…</span>
+                                            </button>
+                                        </form>
+                                    {% else %}
+                                        <span class="text-xs text-slate-400">scheduled</span>
+                                    {% endif %}
+                                </td>
+                            </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            </div>
+        </section>
+    {% endif %}
+
+    {# ------------------------------ 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">
+            <h2 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">GeoIP</h2>
+            <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Provider, on-disk paths, and credential state. DB freshness comes from healthz; the trigger button on <code>refresh-geoip</code> is in the Jobs section above.</p>
+            <dl class="mt-3 grid grid-cols-3 gap-2 text-sm">
+                <dt class="text-slate-500 dark:text-slate-400">Provider</dt>
+                <dd class="col-span-2 font-mono text-xs">{{ config.sections.geoip.GEOIP_PROVIDER|default('—') }}</dd>
+                <dt class="text-slate-500 dark:text-slate-400">Country DB</dt>
+                <dd class="col-span-2 font-mono text-xs">{{ config.sections.geoip.GEOIP_COUNTRY_DB|default('—') }}</dd>
+                <dt class="text-slate-500 dark:text-slate-400">ASN DB</dt>
+                <dd class="col-span-2 font-mono text-xs">{{ config.sections.geoip.GEOIP_ASN_DB|default('—') }}</dd>
+                <dt class="text-slate-500 dark:text-slate-400">MaxMind key</dt>
+                <dd class="col-span-2 font-mono text-xs">{{ config.sections.geoip.MAXMIND_LICENSE_KEY ? config.sections.geoip.MAXMIND_LICENSE_KEY : '(unset)' }}</dd>
+                <dt class="text-slate-500 dark:text-slate-400">IPinfo token</dt>
+                <dd class="col-span-2 font-mono text-xs">{{ config.sections.geoip.IPINFO_TOKEN ? config.sections.geoip.IPINFO_TOKEN : '(unset)' }}</dd>
+            </dl>
+        </section>
+    {% endif %}
+</div>
+{% endblock %}

+ 2 - 2
ui/resources/views/partials/sidebar.twig

@@ -11,8 +11,8 @@
             { href: '/app/consumers',     label: 'Consumers',     section: 'consumers' },
             { href: '/app/tokens',        label: 'Tokens',        section: 'tokens' },
             { href: '/app/categories',    label: 'Categories',    section: 'categories' },
-            { href: '#', label: 'Audit',     upcoming: 'M12' },
-            { href: '#', label: 'Settings',  upcoming: 'M12' },
+            { href: '/app/audit',         label: 'Audit',         section: 'audit' },
+            { href: '/app/settings',      label: 'Settings',      section: 'settings' },
             { href: '/app/me',            label: 'My identity',   section: 'me' },
         ] %}
         {% for link in links %}

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

@@ -349,4 +349,52 @@ final class AdminClient
     {
         $this->api->request('DELETE', '/api/v1/admin/categories/' . $id, [], $actingUserId);
     }
+
+    // ---- audit / settings (M12) ----
+
+    /**
+     * @param array<string, mixed> $filters
+     * @return array<string, mixed>
+     */
+    public function listAuditLog(int $actingUserId, array $filters, int $page = 1, int $pageSize = 50): array
+    {
+        $query = ['page' => $page, 'page_size' => $pageSize];
+        foreach (['actor_kind', 'actor_id', 'action', 'entity_type', 'entity_id', 'from', 'to'] as $key) {
+            if (isset($filters[$key]) && $filters[$key] !== '' && $filters[$key] !== null) {
+                $query[$key] = $filters[$key];
+            }
+        }
+
+        return $this->api->request('GET', '/api/v1/admin/audit-log', ['query' => $query], $actingUserId);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getJobsStatus(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/jobs/status', [], $actingUserId);
+    }
+
+    /**
+     * @param array<string, mixed> $params
+     * @return array<string, mixed>
+     */
+    public function triggerJob(int $actingUserId, string $name, array $params = []): array
+    {
+        return $this->api->request(
+            'POST',
+            '/api/v1/admin/jobs/trigger/' . rawurlencode($name),
+            $params === [] ? [] : ['json' => $params],
+            $actingUserId,
+        );
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getConfig(int $actingUserId): array
+    {
+        return $this->api->request('GET', '/api/v1/admin/config', [], $actingUserId);
+    }
 }

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

@@ -8,6 +8,7 @@ use App\Auth\LocalLoginController;
 use App\Auth\LogoutController;
 use App\Auth\OidcController;
 use App\Controllers\AllowlistController;
+use App\Controllers\AuditController;
 use App\Controllers\CategoriesController;
 use App\Controllers\ConsumersController;
 use App\Controllers\DashboardController;
@@ -19,6 +20,7 @@ use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
 use App\Controllers\PoliciesController;
 use App\Controllers\ReportersController;
+use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
 use App\Http\AuthRequiredMiddleware;
 use App\Http\CsrfMiddleware;
@@ -180,6 +182,15 @@ final class AppFactory
             $group->get('/categories/{id}', [$categories, 'edit']);
             $group->post('/categories/{id}', [$categories, 'update']);
             $group->post('/categories/{id}/delete', [$categories, 'delete']);
+
+            /** @var AuditController $audit */
+            $audit = $container->get(AuditController::class);
+            $group->get('/audit', [$audit, 'index']);
+
+            /** @var SettingsController $settings */
+            $settings = $container->get(SettingsController::class);
+            $group->get('/settings', [$settings, 'index']);
+            $group->post('/settings/jobs/trigger/{name}', [$settings, 'trigger']);
         })->add($authRequired);
 
         $app->map(

+ 4 - 0
ui/src/App/Container.php

@@ -15,6 +15,7 @@ use App\Auth\OidcAuthenticator;
 use App\Auth\OidcController;
 use App\Auth\SessionManager;
 use App\Controllers\AllowlistController;
+use App\Controllers\AuditController;
 use App\Controllers\CategoriesController;
 use App\Controllers\ConsumersController;
 use App\Controllers\DashboardController;
@@ -26,6 +27,7 @@ use App\Controllers\MeController;
 use App\Controllers\NoAccessController;
 use App\Controllers\PoliciesController;
 use App\Controllers\ReportersController;
+use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
 use App\Http\AuthRequiredMiddleware;
 use App\Http\CsrfMiddleware;
@@ -210,6 +212,8 @@ final class Container
             ConsumersController::class => autowire(),
             TokensController::class => autowire(),
             CategoriesController::class => autowire(),
+            AuditController::class => autowire(),
+            SettingsController::class => autowire(),
 
             LocalLoginController::class => factory(static function (ContainerInterface $c): LocalLoginController {
                 /** @var Twig $twig */

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

@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/audit` — filterable audit log list. Filters are passed
+ * through to the api as-is; pagination is done page+page_size in
+ * the query string. The page tolerates a partial outage by
+ * rendering an empty state with the api error message.
+ */
+final class AuditController
+{
+    use CrudControllerSupport;
+
+    private const ALLOWED_ACTIONS = [
+        'reporter.created', 'reporter.updated', 'reporter.deleted',
+        'consumer.created', 'consumer.updated', 'consumer.deleted',
+        'token.created', 'token.revoked',
+        'policy.created', 'policy.updated', 'policy.deleted',
+        'category.created', 'category.updated', 'category.deleted',
+        'manual_block.created', 'manual_block.deleted',
+        'allowlist.created', 'allowlist.deleted',
+        'job.triggered',
+    ];
+
+    private const ALLOWED_KINDS = ['user', 'admin-token', 'reporter', 'consumer', 'system'];
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        if (($redirect = $this->requireUser($request, $response)) !== null) {
+            return $redirect;
+        }
+
+        $user = $this->sessions()->getUser();
+        // Guard for static analysis — requireUser bounced if null already.
+        if ($user === null) {
+            return $response->withStatus(302)->withHeader('Location', '/login');
+        }
+
+        $params = $request->getQueryParams();
+        $filters = [
+            'actor_kind' => self::clean($params['actor_kind'] ?? null),
+            'actor_id' => self::clean($params['actor_id'] ?? null),
+            'action' => self::clean($params['action'] ?? null),
+            'entity_type' => self::clean($params['entity_type'] ?? null),
+            'entity_id' => self::clean($params['entity_id'] ?? null),
+            'from' => self::clean($params['from'] ?? null),
+            'to' => self::clean($params['to'] ?? null),
+        ];
+        $page = isset($params['page']) && ctype_digit((string) $params['page']) ? max(1, (int) $params['page']) : 1;
+        $pageSize = 50;
+
+        $list = null;
+        $error = null;
+        try {
+            $list = $this->admin->listAuditLog($user->userId, $filters, $page, $pageSize);
+        } catch (ApiException $e) {
+            $error = 'API error: ' . $e->getMessage();
+        }
+
+        return $this->twigEngine->render($response, 'pages/audit/index.twig', [
+            'active_section' => 'audit',
+            'list' => $list,
+            'page' => $page,
+            'page_size' => $pageSize,
+            'filters' => $filters,
+            'allowed_actions' => self::ALLOWED_ACTIONS,
+            'allowed_kinds' => self::ALLOWED_KINDS,
+            'error' => $error,
+        ]);
+    }
+
+    private static function clean(mixed $v): ?string
+    {
+        if (!is_string($v)) {
+            return null;
+        }
+        $trimmed = trim($v);
+
+        return $trimmed === '' ? null : $trimmed;
+    }
+}

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

@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiAuthException;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/settings` — admin-only effective config + jobs status.
+ *
+ * Three sections render: Configuration (from `/admin/config`), Jobs
+ * (from `/admin/jobs/status`, with overdue badges + manual-trigger
+ * buttons), GeoIP (parsed out of the config payload).
+ *
+ * The trigger handler is a server-rendered POST → 303 → re-render
+ * pattern: forwards to the api's `POST /admin/jobs/trigger/{name}` and
+ * stashes the outcome in flash. We deliberately avoid an XHR + spinner
+ * to keep the no-JS path working; the admin can wait the few seconds
+ * a job takes.
+ *
+ * RBAC: Viewer/Operator get a no-access page; Admin only.
+ */
+final class SettingsController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(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');
+        }
+
+        $config = null;
+        $jobs = null;
+        $error = null;
+        try {
+            $config = $this->admin->getConfig($user->userId);
+            $jobs = $this->admin->getJobsStatus($user->userId);
+        } catch (ApiAuthException) {
+            return $response->withStatus(303)->withHeader('Location', '/no-access');
+        } catch (ApiException $e) {
+            $error = 'API error: ' . $e->getMessage();
+        }
+
+        return $this->twigEngine->render($response, 'pages/settings/index.twig', [
+            'active_section' => 'settings',
+            'config' => $config,
+            'jobs' => $jobs,
+            'error' => $error,
+        ]);
+    }
+
+    /**
+     * @param array{name: string} $args
+     */
+    public function trigger(ServerRequestInterface $request, ResponseInterface $response, array $args): 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');
+        }
+
+        $name = $args['name'];
+        $body = $this->formBody($request);
+        $params = [];
+        if (isset($body['full'])) {
+            $params['full'] = $this->formBool($body['full']);
+        }
+        if (isset($body['reenrich'])) {
+            $params['reenrich'] = $this->formBool($body['reenrich']);
+        }
+
+        try {
+            $result = $this->admin->triggerJob($user->userId, $name, $params);
+            $status = (string) ($result['status'] ?? 'unknown');
+            $items = (int) ($result['items_processed'] ?? 0);
+            $duration = (int) ($result['duration_ms'] ?? 0);
+            $this->sessions()->flash(
+                $status === 'success' ? 'success' : 'error',
+                sprintf('%s — %s (items=%d, duration=%dms)', $name, $status, $items, $duration),
+            );
+        } catch (ApiException $e) {
+            $this->flashFromException($e);
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/settings');
+    }
+}

+ 79 - 0
ui/tests/Integration/Audit/AuditPageTest.php

@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Audit;
+
+use App\Auth\UserContext;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * `/app/audit` — list view + filter round-trip.
+ */
+final class AuditPageTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+    }
+
+    public function testRendersList(): void
+    {
+        $this->enqueueApiResponse(200, [
+            'page' => 1,
+            'page_size' => 50,
+            'total' => 1,
+            'items' => [
+                [
+                    'id' => 42,
+                    'occurred_at' => '2026-04-29T10:00:00Z',
+                    'actor_kind' => 'user',
+                    'actor_id' => '7',
+                    'action' => 'manual_block.created',
+                    'entity_type' => 'manual_block',
+                    'entity_id' => '12',
+                    'details' => ['ip' => '203.0.113.99', 'reason' => 'audit-test'],
+                    'source_ip' => '127.0.0.1',
+                ],
+            ],
+        ]);
+
+        $resp = $this->request('GET', '/app/audit');
+
+        self::assertSame(200, $resp->getStatusCode());
+        $body = (string) $resp->getBody();
+        self::assertStringContainsString('manual_block.created', $body);
+        self::assertStringContainsString('203.0.113.99', $body);
+        self::assertStringContainsString('1 total', $body);
+    }
+
+    public function testRendersEmptyState(): void
+    {
+        $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 50, 'total' => 0, 'items' => []]);
+        $resp = $this->request('GET', '/app/audit');
+        self::assertSame(200, $resp->getStatusCode());
+        self::assertStringContainsString('No events match', (string) $resp->getBody());
+    }
+
+    public function testFilterRoundTrip(): void
+    {
+        $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 50, 'total' => 0, 'items' => []]);
+        $resp = $this->request('GET', '/app/audit?action=token.created&actor_kind=user');
+        $body = (string) $resp->getBody();
+        self::assertSame(200, $resp->getStatusCode());
+        // The form preserves the user's selection.
+        self::assertMatchesRegularExpression('/<option value="token\.created"\s+selected/', $body);
+        self::assertMatchesRegularExpression('/<option value="user"\s+selected/', $body);
+    }
+
+    public function testRedirectsAnonymousToLogin(): void
+    {
+        $_SESSION = [];
+        $resp = $this->request('GET', '/app/audit');
+        self::assertSame(302, $resp->getStatusCode());
+        self::assertSame('/login', $resp->getHeaderLine('Location'));
+    }
+}

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

@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Settings;
+
+use App\Auth\UserContext;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class SettingsPageTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        $this->bootApp();
+    }
+
+    public function testAdminSeesConfigAndJobs(): void
+    {
+        $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+
+        // First call: getConfig
+        $this->enqueueApiResponse(200, [
+            'sections' => [
+                'app' => ['APP_ENV' => 'development', 'LOG_LEVEL' => 'Info'],
+                'database' => ['DB_DRIVER' => 'sqlite'],
+                'auth' => ['INTERNAL_JOB_TOKEN' => '***', 'UI_SERVICE_TOKEN' => 'irdb_svc...'],
+                'reputation' => ['SCORE_REPORT_HARD_CUTOFF_DAYS' => 365],
+                'jobs' => ['JOB_AUDIT_RETENTION_DAYS' => 180],
+                'geoip' => ['GEOIP_PROVIDER' => 'dbip', 'GEOIP_COUNTRY_DB' => '/data/geoip/country.mmdb', 'GEOIP_ASN_DB' => '/data/geoip/asn.mmdb', 'MAXMIND_LICENSE_KEY' => '', 'IPINFO_TOKEN' => ''],
+            ],
+        ]);
+        // Second call: getJobsStatus
+        $this->enqueueApiResponse(200, [
+            'now' => '2026-04-29T10:00:00Z',
+            'jobs' => [
+                'recompute-scores' => [
+                    'name' => 'recompute-scores',
+                    'default_interval_seconds' => 300,
+                    'max_runtime_seconds' => 240,
+                    'overdue' => false,
+                    'lock' => null,
+                    'last_run' => [
+                        'id' => 1,
+                        'status' => 'success',
+                        'items_processed' => 0,
+                        'triggered_by' => 'schedule',
+                        'started_at' => '2026-04-29T09:55:00Z',
+                        'finished_at' => '2026-04-29T09:55:01Z',
+                        'error_message' => null,
+                    ],
+                ],
+            ],
+        ]);
+
+        $resp = $this->request('GET', '/app/settings');
+
+        self::assertSame(200, $resp->getStatusCode());
+        $body = (string) $resp->getBody();
+        self::assertStringContainsString('Configuration', $body);
+        self::assertStringContainsString('Jobs', $body);
+        self::assertStringContainsString('GeoIP', $body);
+        self::assertStringContainsString('recompute-scores', $body);
+        self::assertStringContainsString('Run now', $body);
+        self::assertStringContainsString('dbip', $body);
+    }
+
+    public function testViewerRedirectsToNoAccess(): void
+    {
+        $_SESSION['_user'] = (new UserContext(2, 'Viewer', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
+        $_SESSION['_last_active'] = time();
+        $_SESSION['_authenticated_at'] = time();
+
+        $resp = $this->request('GET', '/app/settings');
+        self::assertSame(303, $resp->getStatusCode());
+        self::assertSame('/no-access', $resp->getHeaderLine('Location'));
+    }
+
+    public function testAnonymousRedirectsToLogin(): void
+    {
+        $_SESSION = [];
+        $resp = $this->request('GET', '/app/settings');
+        self::assertSame(302, $resp->getStatusCode());
+        self::assertSame('/login', $resp->getHeaderLine('Location'));
+    }
+}