Răsfoiți Sursa

fix: make admin audit emit transactional with mutation (SEC_REVIEW F4, F5)

F4: every admin write (ManualBlocks, Allowlist, Reporters, Consumers,
Categories, Policies, Tokens, AppSettings, Maintenance.purge/seedDemo,
Jobs.trigger) emitted audit_log AFTER its mutation and OUTSIDE a
transaction; DbAuditEmitter::emit swallowed Throwable. Any audit
insert failure (DB hiccup, lock timeout, JSON encoding error, process
kill) left state mutated with no audit row — integrity of the audit
log rested on a best-effort second write.

F5: /api/v1/auth/users/upsert-oidc and upsert-local created users and
assigned roles with no AuditEmitter call at all. Account creation and
privilege grants — primary SOC events — were invisible.

AuditEmitter now exposes emit() (best-effort, kept for high-volume
public paths in ReportController/BlocklistController where dropping
audit beats user-visible failure) and emitOrThrow() (strict,
propagates infra errors). Every admin write call site is rewritten as
Connection::transactional() { mutation; emitOrThrow(...) }; cache
invalidations move post-commit. AuthController emits user.created on
new-account paths and user.role_changed on OIDC role drift, all inside
the same transactional block. The auth route group now attaches
AuditContextMiddleware so the new rows carry source IP / request id.
DbAuditEmitter switches its json_encode to JSON_THROW_ON_ERROR so
payload encoding failures become typed exceptions instead of a silent
false reaching audit_repo->insert.

Regression tests:
- AuditRollbackTest drops audit_log to force emitOrThrow to fail and
  asserts manual_blocks / reporters / allowlist / categories / users
  rows are not created (5xx returned, target table count unchanged).
- AuthEndpointsTest::testFirstUpsertLocalEmitsUserCreatedAudit,
  testRotatingUsernamesEmitsOnlyOneUserCreatedAudit,
  testNewOidcLoginEmitsUserCreatedAudit,
  testOidcRoleDriftEmitsRoleChangedAudit cover F5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 5 zile în urmă
părinte
comite
8d948ae676

+ 6 - 2
api/src/App/AppFactory.php

@@ -152,7 +152,9 @@ final class AppFactory
 
         // Auth API: service-token-only. No impersonation — these endpoints
         // exist to *produce* user_ids the ui can later impersonate. The
-        // controller enforces kind=service on each call.
+        // controller enforces kind=service on each call. Audit context is
+        // attached so user.created / user.role_changed audit rows
+        // (SEC_REVIEW F5) carry source IP and request id.
         $app->group('/api/v1/auth', function (RouteCollectorProxy $auth) use ($container): void {
             /** @var AuthController $controller */
             $controller = $container->get(AuthController::class);
@@ -166,7 +168,9 @@ final class AppFactory
                 /** @var array{id: string} $args */
                 return $controller->getUser($request, $response, $args['id']);
             });
-        })->add($tokenAuth);
+        })
+            ->add($auditContext)
+            ->add($tokenAuth);
 
         // Public API: ingest endpoint. Auth → rate limit → controller. The
         // controller rejects non-reporter kinds itself (uniform 401 per

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

@@ -417,8 +417,12 @@ final class Container
                 $role = $c->get('settings.oidc_default_role');
                 /** @var UserRepository $users */
                 $users = $c->get(UserRepository::class);
+                /** @var AuditEmitter $audit */
+                $audit = $c->get(AuditEmitter::class);
+                /** @var Connection $conn */
+                $conn = $c->get(Connection::class);
 
-                return new AuthController($users, $role ?? Role::Viewer);
+                return new AuthController($users, $role ?? Role::Viewer, $audit, $conn);
             }),
             MeController::class => autowire(),
             ReportersController::class => autowire(),

+ 49 - 32
api/src/Application/Admin/AllowlistController.php

@@ -14,6 +14,7 @@ use App\Domain\Ip\IpAddress;
 use App\Infrastructure\Allowlist\AllowlistRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -32,6 +33,7 @@ final class AllowlistController
         private readonly CidrEvaluatorFactory $evaluator,
         private readonly BlocklistCache $blocklistCache,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -111,7 +113,22 @@ final class AllowlistController
                 return self::validationFailed($response, ['ip' => $e->getMessage()]);
             }
 
-            $id = $this->allowlist->createIp($ip, $reason, self::actingUserId($request));
+            $userId = self::actingUserId($request);
+            $auditCtx = self::auditContext($request);
+            $id = $this->connection->transactional(function () use ($ip, $reason, $userId, $auditCtx): int {
+                $id = $this->allowlist->createIp($ip, $reason, $userId);
+                $this->audit->emitOrThrow(
+                    AuditAction::ALLOWLIST_CREATED,
+                    'allowlist',
+                    $id,
+                    ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason],
+                    $auditCtx,
+                    $ip->text(),
+                );
+
+                return $id;
+            });
+
             $this->evaluator->invalidate();
             $this->blocklistCache->invalidateAll();
             // Eager rebuild so an allowlist/manual-block overlap surfaces
@@ -123,15 +140,6 @@ 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),
-                $ip->text(),
-            );
-
             return self::json($response, 201, $created->toArray());
         }
 
@@ -154,7 +162,22 @@ final class AllowlistController
             return self::validationFailed($response, ['cidr' => $e->getMessage()]);
         }
 
-        $id = $this->allowlist->createSubnet($cidr, $reason, self::actingUserId($request));
+        $userId = self::actingUserId($request);
+        $auditCtx = self::auditContext($request);
+        $id = $this->connection->transactional(function () use ($cidr, $reason, $userId, $auditCtx): int {
+            $id = $this->allowlist->createSubnet($cidr, $reason, $userId);
+            $this->audit->emitOrThrow(
+                AuditAction::ALLOWLIST_CREATED,
+                'allowlist',
+                $id,
+                ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason],
+                $auditCtx,
+                $cidr->text(),
+            );
+
+            return $id;
+        });
+
         $this->evaluator->invalidate();
         $this->blocklistCache->invalidateAll();
         $this->evaluator->get();
@@ -168,15 +191,6 @@ 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),
-            $cidr->text(),
-        );
-
         return self::json($response, 201, $payload);
     }
 
@@ -194,22 +208,25 @@ final class AllowlistController
             return self::error($response, 404, 'not_found');
         }
 
-        $this->allowlist->delete($id);
-        $this->evaluator->invalidate();
-        $this->blocklistCache->invalidateAll();
-
         $label = $existing->kind === AllowlistEntry::KIND_IP
             ? $existing->ip?->text()
             : $existing->cidr?->text();
+        $auditCtx = self::auditContext($request);
 
-        $this->audit->emit(
-            AuditAction::ALLOWLIST_DELETED,
-            'allowlist',
-            $id,
-            ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
-            self::auditContext($request),
-            $label,
-        );
+        $this->connection->transactional(function () use ($id, $existing, $label, $auditCtx): void {
+            $this->allowlist->delete($id);
+            $this->audit->emitOrThrow(
+                AuditAction::ALLOWLIST_DELETED,
+                'allowlist',
+                $id,
+                ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
+                $auditCtx,
+                $label,
+            );
+        });
+
+        $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
 
         return $response->withStatus(204);
     }

+ 16 - 12
api/src/Application/Admin/AppSettingsController.php

@@ -7,6 +7,7 @@ namespace App\Application\Admin;
 use App\Domain\Audit\AuditAction;
 use App\Domain\Audit\AuditEmitter;
 use App\Domain\Settings\AppSettings;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -32,6 +33,7 @@ final class AppSettingsController
     public function __construct(
         private readonly AppSettings $settings,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -66,19 +68,21 @@ final class AppSettingsController
             return self::validationFailed($response, $errors);
         }
 
-        foreach ($changes as $key => $diff) {
-            $this->settings->setBool($key, (bool) $diff['to']);
-        }
-
         if ($changes !== []) {
-            $this->audit->emit(
-                AuditAction::APP_SETTINGS_UPDATED,
-                'app_settings',
-                null,
-                ['changes' => $changes],
-                self::auditContext($request),
-                'audit-toggles',
-            );
+            $auditCtx = self::auditContext($request);
+            $this->connection->transactional(function () use ($changes, $auditCtx): void {
+                foreach ($changes as $key => $diff) {
+                    $this->settings->setBool($key, (bool) $diff['to']);
+                }
+                $this->audit->emitOrThrow(
+                    AuditAction::APP_SETTINGS_UPDATED,
+                    'app_settings',
+                    null,
+                    ['changes' => $changes],
+                    $auditCtx,
+                    'audit-toggles',
+                );
+            });
         }
 
         return self::json($response, 200, $this->snapshot());

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

@@ -8,6 +8,7 @@ use App\Domain\Audit\AuditAction;
 use App\Domain\Audit\AuditEmitter;
 use App\Domain\Reputation\DecayFunction;
 use App\Infrastructure\Category\CategoryRepository;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -33,6 +34,7 @@ final class CategoriesController
     public function __construct(
         private readonly CategoryRepository $categories,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -123,21 +125,26 @@ final class CategoriesController
 
         /** @var DecayFunction $decayFunction */
         /** @var float $decayParam */
-        $id = $this->categories->create($slug, $name, $description, $decayFunction, $decayParam, $isActive);
+        $auditCtx = self::auditContext($request);
+        $id = $this->connection->transactional(function () use ($slug, $name, $description, $decayFunction, $decayParam, $isActive, $auditCtx): int {
+            $id = $this->categories->create($slug, $name, $description, $decayFunction, $decayParam, $isActive);
+            $this->audit->emitOrThrow(
+                AuditAction::CATEGORY_CREATED,
+                'category',
+                $id,
+                ['slug' => $slug, 'name' => $name, 'decay_function' => $decayFunction->value, 'decay_param' => $decayParam],
+                $auditCtx,
+                $slug,
+            );
+
+            return $id;
+        });
+
         $created = $this->categories->findById($id);
         if ($created === null) {
             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),
-            $slug,
-        );
-
         return self::json($response, 201, $created->toArray());
     }
 
@@ -232,21 +239,25 @@ final class CategoriesController
             'is_active' => $existing->isActive ? 1 : 0,
         ];
 
-        $this->categories->update($id, $fields);
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $fields, $existing, $beforeSnapshot, $auditCtx): void {
+            $this->categories->update($id, $fields);
+            $updatedSlug = isset($fields['slug']) ? (string) $fields['slug'] : $existing->slug;
+            $this->audit->emitOrThrow(
+                AuditAction::CATEGORY_UPDATED,
+                'category',
+                $id,
+                ['slug' => $existing->slug, 'changes' => self::diffFields($beforeSnapshot, $fields)],
+                $auditCtx,
+                $updatedSlug,
+            );
+        });
+
         $updated = $this->categories->findById($id);
         if ($updated === null) {
             return self::error($response, 500, 'update_failed');
         }
 
-        $this->audit->emit(
-            AuditAction::CATEGORY_UPDATED,
-            'category',
-            $id,
-            ['slug' => $existing->slug, 'changes' => self::diffFields($beforeSnapshot, $fields)],
-            self::auditContext($request),
-            $updated->slug,
-        );
-
         return self::json($response, 200, $updated->toArray());
     }
 
@@ -277,16 +288,18 @@ final class CategoriesController
             ]);
         }
 
-        $this->categories->delete($id);
-
-        $this->audit->emit(
-            AuditAction::CATEGORY_DELETED,
-            'category',
-            $id,
-            ['slug' => $existing->slug, 'name' => $existing->name],
-            self::auditContext($request),
-            $existing->slug,
-        );
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
+            $this->categories->delete($id);
+            $this->audit->emitOrThrow(
+                AuditAction::CATEGORY_DELETED,
+                'category',
+                $id,
+                ['slug' => $existing->slug, 'name' => $existing->name],
+                $auditCtx,
+                $existing->slug,
+            );
+        });
 
         return $response->withStatus(204);
     }

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

@@ -7,6 +7,7 @@ namespace App\Application\Admin;
 use App\Domain\Audit\AuditAction;
 use App\Domain\Audit\AuditEmitter;
 use App\Infrastructure\Consumer\ConsumerRepository;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -24,6 +25,7 @@ final class ConsumersController
     public function __construct(
         private readonly ConsumerRepository $consumers,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -103,21 +105,27 @@ final class ConsumersController
         }
 
         /** @var int $policyId */
-        $id = $this->consumers->create($name, $description, $policyId, self::actingUserId($request));
+        $userId = self::actingUserId($request);
+        $auditCtx = self::auditContext($request);
+        $id = $this->connection->transactional(function () use ($name, $description, $policyId, $userId, $auditCtx): int {
+            $id = $this->consumers->create($name, $description, $policyId, $userId);
+            $this->audit->emitOrThrow(
+                AuditAction::CONSUMER_CREATED,
+                'consumer',
+                $id,
+                ['name' => $name, 'policy_id' => $policyId, 'description' => $description],
+                $auditCtx,
+                $name,
+            );
+
+            return $id;
+        });
+
         $created = $this->consumers->findById($id);
         if ($created === null) {
             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),
-            $name,
-        );
-
         return self::json($response, 201, $created->toArray());
     }
 
@@ -201,21 +209,25 @@ final class ConsumersController
             'audit_enabled' => $existing->auditEnabled ? 1 : 0,
         ];
 
-        $this->consumers->update($id, $fields);
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $fields, $existing, $beforeSnapshot, $auditCtx): void {
+            $this->consumers->update($id, $fields);
+            $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
+            $this->audit->emitOrThrow(
+                AuditAction::CONSUMER_UPDATED,
+                'consumer',
+                $id,
+                ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
+                $auditCtx,
+                $updatedName,
+            );
+        });
+
         $updated = $this->consumers->findById($id);
         if ($updated === null) {
             return self::error($response, 500, 'update_failed');
         }
 
-        $this->audit->emit(
-            AuditAction::CONSUMER_UPDATED,
-            'consumer',
-            $id,
-            ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
-            self::auditContext($request),
-            $updated->name,
-        );
-
         return self::json($response, 200, $updated->toArray());
     }
 
@@ -233,16 +245,18 @@ final class ConsumersController
             return self::error($response, 404, 'not_found');
         }
 
-        $this->consumers->softDelete($id);
-
-        $this->audit->emit(
-            AuditAction::CONSUMER_DELETED,
-            'consumer',
-            $id,
-            ['name' => $existing->name, 'soft' => true],
-            self::auditContext($request),
-            $existing->name,
-        );
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
+            $this->consumers->softDelete($id);
+            $this->audit->emitOrThrow(
+                AuditAction::CONSUMER_DELETED,
+                'consumer',
+                $id,
+                ['name' => $existing->name, 'soft' => true],
+                $auditCtx,
+                $existing->name,
+            );
+        });
 
         return $response->withStatus(204);
     }

+ 4 - 4
api/src/Application/Admin/JobsAdminController.php

@@ -117,10 +117,10 @@ final class JobsAdminController
         $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(
+        // record that it was invoked. SEC_REVIEW F4: emitOrThrow so a
+        // failed audit insert produces a 500 instead of silently running
+        // the job without a trigger row in audit_log.
+        $this->audit->emitOrThrow(
             AuditAction::JOB_TRIGGERED,
             'job',
             $name,

+ 28 - 19
api/src/Application/Admin/MaintenanceController.php

@@ -221,7 +221,8 @@ final class MaintenanceController
             ]);
         }
 
-        $deleted = $this->connection->transactional(function (Connection $conn): array {
+        $auditCtx = self::auditContext($request);
+        $deleted = $this->connection->transactional(function (Connection $conn) use ($auditCtx): array {
             $counts = [];
             // Order: child tables first, then parents. RESTRICT FKs:
             //   reports.reporter_id, reports.category_id, consumers.policy_id.
@@ -254,18 +255,23 @@ final class MaintenanceController
             $counts['policies'] = (int) $conn->executeStatement('DELETE FROM policies');
             $counts['reporters'] = (int) $conn->executeStatement('DELETE FROM reporters');
 
+            // SEC_REVIEW F4: emit inside the same transaction so that an
+            // audit insert failure rolls back the wipe instead of leaving
+            // an unattributed purge in the history. Note the audit_log
+            // table was just truncated above — this row is the first new
+            // entry in the post-purge log.
+            $this->audit->emitOrThrow(
+                AuditAction::MAINTENANCE_PURGED,
+                'maintenance',
+                null,
+                ['deleted' => $counts],
+                $auditCtx,
+                'purge',
+            );
+
             return $counts;
         });
 
-        $this->audit->emit(
-            AuditAction::MAINTENANCE_PURGED,
-            'maintenance',
-            null,
-            ['deleted' => $deleted],
-            self::auditContext($request),
-            'purge',
-        );
-
         return self::json($response, 200, [
             'status' => 'purged',
             'deleted' => $deleted,
@@ -476,6 +482,18 @@ final class MaintenanceController
             }
         }
 
+        // SEC_REVIEW F4: audit emit lives inside the seed transaction so a
+        // failed audit insert rolls back the bulk inserts above instead of
+        // leaving thousands of unattributed demo rows on disk.
+        $this->audit->emitOrThrow(
+            AuditAction::MAINTENANCE_SEEDED,
+            'maintenance',
+            null,
+            ['summary' => $summary],
+            self::auditContext($request),
+            'seed-demo',
+        );
+
         $this->connection->commit();
         } catch (\Throwable $e) {
             if ($this->connection->isTransactionActive()) {
@@ -487,15 +505,6 @@ final class MaintenanceController
         // Recompute so dashboards show the seeded scores immediately.
         $outcome = $this->jobRunner->run($this->recomputeJob, ['full' => true], 'manual');
 
-        $this->audit->emit(
-            AuditAction::MAINTENANCE_SEEDED,
-            'maintenance',
-            null,
-            ['summary' => $summary],
-            self::auditContext($request),
-            'seed-demo',
-        );
-
         return self::json($response, 200, [
             'status' => 'seeded',
             'summary' => $summary,

+ 49 - 32
api/src/Application/Admin/ManualBlocksController.php

@@ -16,6 +16,7 @@ use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\CidrEvaluatorFactory;
 use DateTimeImmutable;
 use DateTimeZone;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -42,6 +43,7 @@ final class ManualBlocksController
         private readonly CidrEvaluatorFactory $evaluator,
         private readonly BlocklistCache $blocklistCache,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -135,7 +137,22 @@ final class ManualBlocksController
                 return self::validationFailed($response, ['ip' => $e->getMessage()]);
             }
 
-            $id = $this->manualBlocks->createIp($ip, $reason, $expiresAt, self::actingUserId($request));
+            $userId = self::actingUserId($request);
+            $auditCtx = self::auditContext($request);
+            $id = $this->connection->transactional(function () use ($ip, $reason, $expiresAt, $userId, $auditCtx): int {
+                $id = $this->manualBlocks->createIp($ip, $reason, $expiresAt, $userId);
+                $this->audit->emitOrThrow(
+                    AuditAction::MANUAL_BLOCK_CREATED,
+                    'manual_block',
+                    $id,
+                    ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
+                    $auditCtx,
+                    $ip->text(),
+                );
+
+                return $id;
+            });
+
             $this->evaluator->invalidate();
             $this->blocklistCache->invalidateAll();
             // Eagerly rebuild so any overlap with the allowlist surfaces as
@@ -147,15 +164,6 @@ 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),
-                $ip->text(),
-            );
-
             return self::json($response, 201, $created->toArray());
         }
 
@@ -178,7 +186,22 @@ final class ManualBlocksController
             return self::validationFailed($response, ['cidr' => $e->getMessage()]);
         }
 
-        $id = $this->manualBlocks->createSubnet($cidr, $reason, $expiresAt, self::actingUserId($request));
+        $userId = self::actingUserId($request);
+        $auditCtx = self::auditContext($request);
+        $id = $this->connection->transactional(function () use ($cidr, $reason, $expiresAt, $userId, $auditCtx): int {
+            $id = $this->manualBlocks->createSubnet($cidr, $reason, $expiresAt, $userId);
+            $this->audit->emitOrThrow(
+                AuditAction::MANUAL_BLOCK_CREATED,
+                'manual_block',
+                $id,
+                ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason, 'expires_at' => $expiresAt?->format('c')],
+                $auditCtx,
+                $cidr->text(),
+            );
+
+            return $id;
+        });
+
         $this->evaluator->invalidate();
         $this->blocklistCache->invalidateAll();
         $this->evaluator->get();
@@ -192,15 +215,6 @@ 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),
-            $cidr->text(),
-        );
-
         return self::json($response, 201, $payload);
     }
 
@@ -218,22 +232,25 @@ final class ManualBlocksController
             return self::error($response, 404, 'not_found');
         }
 
-        $this->manualBlocks->delete($id);
-        $this->evaluator->invalidate();
-        $this->blocklistCache->invalidateAll();
-
         $label = $existing->kind === ManualBlock::KIND_IP
             ? $existing->ip?->text()
             : $existing->cidr?->text();
+        $auditCtx = self::auditContext($request);
 
-        $this->audit->emit(
-            AuditAction::MANUAL_BLOCK_DELETED,
-            'manual_block',
-            $id,
-            ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
-            self::auditContext($request),
-            $label,
-        );
+        $this->connection->transactional(function () use ($id, $existing, $label, $auditCtx): void {
+            $this->manualBlocks->delete($id);
+            $this->audit->emitOrThrow(
+                AuditAction::MANUAL_BLOCK_DELETED,
+                'manual_block',
+                $id,
+                ['kind' => $existing->kind, 'ip' => $existing->ip?->text(), 'cidr' => $existing->cidr?->text(), 'reason' => $existing->reason],
+                $auditCtx,
+                $label,
+            );
+        });
+
+        $this->evaluator->invalidate();
+        $this->blocklistCache->invalidateAll();
 
         return $response->withStatus(204);
     }

+ 54 - 40
api/src/Application/Admin/PoliciesController.php

@@ -17,6 +17,7 @@ use App\Infrastructure\Policy\PolicyRepository;
 use App\Infrastructure\Reputation\BlocklistCache;
 use App\Infrastructure\Reputation\IpScoreRepository;
 use DateTimeImmutable;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -44,6 +45,7 @@ final class PoliciesController
         private readonly ManualBlockRepository $manualBlocks,
         private readonly Clock $clock,
         private readonly IpScoreRepository $ipScores,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -125,22 +127,27 @@ final class PoliciesController
             return self::validationFailed($response, $errors);
         }
 
-        $id = $this->policies->create($name, $description, $includeManualBlocks, $thresholds);
+        $auditCtx = self::auditContext($request);
+        $id = $this->connection->transactional(function () use ($name, $description, $includeManualBlocks, $thresholds, $auditCtx): int {
+            $id = $this->policies->create($name, $description, $includeManualBlocks, $thresholds);
+            $this->audit->emitOrThrow(
+                AuditAction::POLICY_CREATED,
+                'policy',
+                $id,
+                ['name' => $name, 'include_manual_blocks' => $includeManualBlocks, 'threshold_count' => count($thresholds)],
+                $auditCtx,
+                $name,
+            );
+
+            return $id;
+        });
+
         $this->blocklistCache->invalidate($id);
         $created = $this->policies->findById($id);
         if ($created === null) {
             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),
-            $name,
-        );
-
         return self::json($response, 201, $created->toArray($this->slugByCategoryId()));
     }
 
@@ -218,18 +225,6 @@ final class PoliciesController
 
         $beforeThresholds = self::thresholdsBySlug($existing->thresholds, $slugByCategoryId);
 
-        if ($fields !== []) {
-            $this->policies->update($id, $fields);
-        }
-        if ($thresholds !== null) {
-            $this->policies->replaceThresholds($id, $thresholds);
-        }
-        $this->blocklistCache->invalidate($id);
-        $updated = $this->policies->findById($id);
-        if ($updated === null) {
-            return self::error($response, 500, 'update_failed');
-        }
-
         $changes = self::diffFields($beforeSnapshot, $fields);
         if ($thresholds !== null) {
             $afterThresholds = self::thresholdsBySlug($thresholds, $slugByCategoryId);
@@ -238,14 +233,30 @@ final class PoliciesController
             }
         }
 
-        $this->audit->emit(
-            AuditAction::POLICY_UPDATED,
-            'policy',
-            $id,
-            ['name' => $existing->name, 'changes' => $changes],
-            self::auditContext($request),
-            $updated->name,
-        );
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $fields, $thresholds, $existing, $changes, $auditCtx): void {
+            if ($fields !== []) {
+                $this->policies->update($id, $fields);
+            }
+            if ($thresholds !== null) {
+                $this->policies->replaceThresholds($id, $thresholds);
+            }
+            $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
+            $this->audit->emitOrThrow(
+                AuditAction::POLICY_UPDATED,
+                'policy',
+                $id,
+                ['name' => $existing->name, 'changes' => $changes],
+                $auditCtx,
+                $updatedName,
+            );
+        });
+
+        $this->blocklistCache->invalidate($id);
+        $updated = $this->policies->findById($id);
+        if ($updated === null) {
+            return self::error($response, 500, 'update_failed');
+        }
 
         return self::json($response, 200, $updated->toArray($slugByCategoryId));
     }
@@ -272,17 +283,20 @@ final class PoliciesController
             ]);
         }
 
-        $this->policies->delete($id);
-        $this->blocklistCache->invalidate($id);
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
+            $this->policies->delete($id);
+            $this->audit->emitOrThrow(
+                AuditAction::POLICY_DELETED,
+                'policy',
+                $id,
+                ['name' => $existing->name],
+                $auditCtx,
+                $existing->name,
+            );
+        });
 
-        $this->audit->emit(
-            AuditAction::POLICY_DELETED,
-            'policy',
-            $id,
-            ['name' => $existing->name],
-            self::auditContext($request),
-            $existing->name,
-        );
+        $this->blocklistCache->invalidate($id);
 
         return $response->withStatus(204);
     }

+ 56 - 38
api/src/Application/Admin/ReportersController.php

@@ -7,6 +7,7 @@ namespace App\Application\Admin;
 use App\Domain\Audit\AuditAction;
 use App\Domain\Audit\AuditEmitter;
 use App\Infrastructure\Reporter\ReporterRepository;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -26,6 +27,7 @@ final class ReportersController
     public function __construct(
         private readonly ReporterRepository $reporters,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -100,21 +102,27 @@ final class ReportersController
             return self::validationFailed($response, $errors);
         }
 
-        $id = $this->reporters->create($name, $description, $trustWeight, self::actingUserId($request));
+        $userId = self::actingUserId($request);
+        $auditCtx = self::auditContext($request);
+        $id = $this->connection->transactional(function () use ($name, $description, $trustWeight, $userId, $auditCtx): int {
+            $id = $this->reporters->create($name, $description, $trustWeight, $userId);
+            $this->audit->emitOrThrow(
+                AuditAction::REPORTER_CREATED,
+                'reporter',
+                $id,
+                ['name' => $name, 'trust_weight' => $trustWeight, 'description' => $description],
+                $auditCtx,
+                $name,
+            );
+
+            return $id;
+        });
+
         $created = $this->reporters->findById($id);
         if ($created === null) {
             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),
-            $name,
-        );
-
         return self::json($response, 201, $created->toArray());
     }
 
@@ -195,21 +203,25 @@ final class ReportersController
             'audit_enabled' => $existing->auditEnabled ? 1 : 0,
         ];
 
-        $this->reporters->update($id, $fields);
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $fields, $existing, $beforeSnapshot, $auditCtx): void {
+            $this->reporters->update($id, $fields);
+            $updatedName = isset($fields['name']) ? (string) $fields['name'] : $existing->name;
+            $this->audit->emitOrThrow(
+                AuditAction::REPORTER_UPDATED,
+                'reporter',
+                $id,
+                ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
+                $auditCtx,
+                $updatedName,
+            );
+        });
+
         $updated = $this->reporters->findById($id);
         if ($updated === null) {
             return self::error($response, 500, 'update_failed');
         }
 
-        $this->audit->emit(
-            AuditAction::REPORTER_UPDATED,
-            'reporter',
-            $id,
-            ['name' => $existing->name, 'changes' => self::diffFields($beforeSnapshot, $fields)],
-            self::auditContext($request),
-            $updated->name,
-        );
-
         return self::json($response, 200, $updated->toArray());
     }
 
@@ -227,17 +239,21 @@ final class ReportersController
             return self::error($response, 404, 'not_found');
         }
 
+        $auditCtx = self::auditContext($request);
+
         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),
-                $existing->name,
-            );
+            $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
+                $this->reporters->softDelete($id);
+                $this->audit->emitOrThrow(
+                    AuditAction::REPORTER_DELETED,
+                    'reporter',
+                    $id,
+                    ['name' => $existing->name, 'soft' => true, 'reason' => 'has_reports'],
+                    $auditCtx,
+                    $existing->name,
+                );
+            });
 
             return self::json($response, 409, [
                 'error' => 'has_reports',
@@ -245,15 +261,17 @@ final class ReportersController
             ]);
         }
 
-        $this->reporters->softDelete($id);
-        $this->audit->emit(
-            AuditAction::REPORTER_DELETED,
-            'reporter',
-            $id,
-            ['name' => $existing->name, 'soft' => true],
-            self::auditContext($request),
-            $existing->name,
-        );
+        $this->connection->transactional(function () use ($id, $existing, $auditCtx): void {
+            $this->reporters->softDelete($id);
+            $this->audit->emitOrThrow(
+                AuditAction::REPORTER_DELETED,
+                'reporter',
+                $id,
+                ['name' => $existing->name, 'soft' => true],
+                $auditCtx,
+                $existing->name,
+            );
+        });
 
         return $response->withStatus(204);
     }

+ 65 - 53
api/src/Application/Admin/TokensController.php

@@ -17,6 +17,7 @@ use App\Infrastructure\Consumer\ConsumerRepository;
 use App\Infrastructure\Reporter\ReporterRepository;
 use DateTimeImmutable;
 use DateTimeZone;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -46,6 +47,7 @@ final class TokensController
         private readonly ConsumerRepository $consumers,
         private readonly Clock $clock,
         private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -163,40 +165,45 @@ final class TokensController
         $hash = $this->hasher->hash($raw);
         $prefix = substr($raw, 0, 8);
 
-        $this->tokens->create(new TokenRecord(
-            id: null,
-            kind: $kind,
-            hash: $hash,
-            prefix: $prefix,
-            reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
-            consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
-            role: $kind === TokenKind::Admin ? $role : null,
-            expiresAt: $expiresAt,
-            revokedAt: null,
-            lastUsedAt: null,
-        ));
-
-        $created = $this->tokens->findByHashIncludingInvalid($hash);
-        if ($created === null) {
-            return self::error($response, 500, 'create_failed');
-        }
+        $auditCtx = self::auditContext($request);
+        $created = $this->connection->transactional(function () use ($kind, $hash, $prefix, $reporterId, $consumerId, $role, $expiresAt, $auditCtx): TokenRecord {
+            $this->tokens->create(new TokenRecord(
+                id: null,
+                kind: $kind,
+                hash: $hash,
+                prefix: $prefix,
+                reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
+                consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
+                role: $kind === TokenKind::Admin ? $role : null,
+                expiresAt: $expiresAt,
+                revokedAt: null,
+                lastUsedAt: null,
+            ));
+
+            $created = $this->tokens->findByHashIncludingInvalid($hash);
+            if ($created === null) {
+                throw new \RuntimeException('token not retrievable after insert');
+            }
 
-        // 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),
-            self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
-        );
+            // Audit payload deliberately excludes the raw token. Prefix is OK.
+            $this->audit->emitOrThrow(
+                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'),
+                ],
+                $auditCtx,
+                self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
+            );
+
+            return $created;
+        });
 
         return self::json($response, 201, [
             'id' => $created->id,
@@ -227,16 +234,19 @@ final class TokensController
             return self::error($response, 403, 'cannot revoke service tokens via API');
         }
 
-        $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),
-            self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
-        );
+        $auditCtx = self::auditContext($request);
+        $now = $this->clock->now();
+        $this->connection->transactional(function () use ($id, $token, $auditCtx, $now): void {
+            $this->tokens->revoke($id, $now);
+            $this->audit->emitOrThrow(
+                AuditAction::TOKEN_REVOKED,
+                'token',
+                $id,
+                ['kind' => $token->kind->value, 'prefix' => $token->prefix],
+                $auditCtx,
+                self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
+            );
+        });
 
         return $response->withStatus(204);
     }
@@ -268,16 +278,18 @@ final class TokensController
             ]);
         }
 
-        $this->tokens->deleteRow($id);
-
-        $this->audit->emit(
-            AuditAction::TOKEN_DELETED,
-            'token',
-            $id,
-            ['kind' => $token->kind->value, 'prefix' => $token->prefix],
-            self::auditContext($request),
-            self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
-        );
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $token, $auditCtx): void {
+            $this->tokens->deleteRow($id);
+            $this->audit->emitOrThrow(
+                AuditAction::TOKEN_DELETED,
+                'token',
+                $id,
+                ['kind' => $token->kind->value, 'prefix' => $token->prefix],
+                $auditCtx,
+                self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
+            );
+        });
 
         return $response->withStatus(204);
     }

+ 82 - 2
api/src/Application/Auth/AuthController.php

@@ -4,8 +4,13 @@ declare(strict_types=1);
 
 namespace App\Application\Auth;
 
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditContext;
+use App\Domain\Audit\AuditEmitter;
 use App\Domain\Auth\Role;
 use App\Infrastructure\Auth\UserRepository;
+use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
+use Doctrine\DBAL\Connection;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -23,6 +28,8 @@ final class AuthController
     public function __construct(
         private readonly UserRepository $users,
         private readonly Role $oidcDefaultRole,
+        private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
     ) {
     }
 
@@ -42,7 +49,50 @@ final class AuthController
             return self::error($response, 400, 'invalid request body');
         }
 
-        $user = $this->users->upsertOidc($subject, $email, $displayName, $groups, $this->oidcDefaultRole);
+        $auditCtx = self::auditContext($request);
+
+        $user = $this->connection->transactional(function () use ($subject, $email, $displayName, $groups, $auditCtx) {
+            $existing = $this->users->findBySubject($subject);
+            $user = $this->users->upsertOidc($subject, $email, $displayName, $groups, $this->oidcDefaultRole);
+
+            if ($existing === null) {
+                // SEC_REVIEW F5: account creation is a primary SOC event;
+                // emit a user.created row attributed to the service-token
+                // call (kind=system) with source IP from AuditContextMiddleware.
+                $this->audit->emitOrThrow(
+                    AuditAction::USER_CREATED,
+                    'user',
+                    $user->id,
+                    [
+                        'source' => 'oidc',
+                        'subject' => $subject,
+                        'email' => $email,
+                        'display_name' => $displayName,
+                        'role' => $user->role->value,
+                        'groups' => $groups,
+                    ],
+                    $auditCtx,
+                    $email,
+                );
+            } elseif ($existing->role !== $user->role) {
+                // Role drift on subsequent OIDC login (group membership change).
+                $this->audit->emitOrThrow(
+                    AuditAction::USER_ROLE_CHANGED,
+                    'user',
+                    $user->id,
+                    [
+                        'source' => 'oidc',
+                        'subject' => $subject,
+                        'changes' => ['role' => ['from' => $existing->role->value, 'to' => $user->role->value]],
+                        'groups' => $groups,
+                    ],
+                    $auditCtx,
+                    $email,
+                );
+            }
+
+            return $user;
+        });
 
         return self::json_response($response, 200, [
             'user_id' => $user->id,
@@ -65,7 +115,30 @@ final class AuthController
             return self::error($response, 400, 'invalid request body');
         }
 
-        $user = $this->users->upsertLocal($username);
+        $auditCtx = self::auditContext($request);
+
+        $user = $this->connection->transactional(function () use ($username, $auditCtx) {
+            $existing = $this->users->findLocal();
+            $user = $this->users->upsertLocal($username);
+
+            if ($existing === null) {
+                // SEC_REVIEW F5: first local-admin bootstrap.
+                $this->audit->emitOrThrow(
+                    AuditAction::USER_CREATED,
+                    'user',
+                    $user->id,
+                    [
+                        'source' => 'local',
+                        'display_name' => $username,
+                        'role' => $user->role->value,
+                    ],
+                    $auditCtx,
+                    $username,
+                );
+            }
+
+            return $user;
+        });
 
         return self::json_response($response, 200, [
             'user_id' => $user->id,
@@ -103,6 +176,13 @@ final class AuthController
         ]);
     }
 
+    private static function auditContext(ServerRequestInterface $request): AuditContext
+    {
+        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);
+
+        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
+    }
+
     private function requireServiceToken(
         ServerRequestInterface $request,
         ResponseInterface $response,

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

@@ -43,6 +43,7 @@ final class AuditAction
     public const ALLOWLIST_CREATED = 'allowlist.created';
     public const ALLOWLIST_DELETED = 'allowlist.deleted';
 
+    public const USER_CREATED = 'user.created';
     public const USER_ROLE_CHANGED = 'user.role_changed';
 
     public const OIDC_ROLE_MAPPING_CREATED = 'oidc_role_mapping.created';

+ 29 - 4
api/src/Domain/Audit/AuditEmitter.php

@@ -7,14 +7,24 @@ 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.
+ * Two semantics are exposed for the same insert:
+ *
+ *  - {@see emit()} swallows infra failures and logs them. Use it on
+ *    high-volume public paths (`report.received`, `blocklist.requested`)
+ *    where a missing audit row is preferable to a user-visible failure
+ *    on a request whose primary purpose is not audit.
+ *  - {@see emitOrThrow()} propagates infra failures. Use it from inside
+ *    `Connection::transactional()` together with the originating
+ *    mutation, so a failed audit insert rolls back the state change
+ *    that produced it (SEC_REVIEW F4). All admin/auth writes use this
+ *    path — without it, an attacker who can intentionally fail the
+ *    audit insert mutates state without trace.
  */
 interface AuditEmitter
 {
     /**
+     * Best-effort emit. Catches infra exceptions and logs `audit_emit_failed`.
+     *
      * @param array<string, mixed> $payload Free-form event payload, JSON-encoded into details_json.
      *     MUST NOT contain raw secrets (raw tokens, passwords).
      * @param string|null $entityLabel Human-readable identifier (name, slug, IP, CIDR, prefix).
@@ -29,4 +39,19 @@ interface AuditEmitter
         AuditContext $context,
         ?string $entityLabel = null,
     ): void;
+
+    /**
+     * Strict emit. Propagates any infra exception so the caller's enclosing
+     * transaction rolls back. Same parameter contract as {@see emit()}.
+     *
+     * @param array<string, mixed> $payload
+     */
+    public function emitOrThrow(
+        string $action,
+        ?string $entityType,
+        int|string|null $entityId,
+        array $payload,
+        AuditContext $context,
+        ?string $entityLabel = null,
+    ): void;
 }

+ 32 - 5
api/src/Infrastructure/Audit/DbAuditEmitter.php

@@ -11,10 +11,13 @@ 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.
+ * Persists audit rows via {@see AuditRepository}. Two emit modes:
+ *
+ *  - {@see emit()}: best-effort. Failures are logged and swallowed so a
+ *    DB hiccup on a high-volume public path doesn't 500 the user.
+ *  - {@see emitOrThrow()}: strict. Failures propagate so an enclosing
+ *    `Connection::transactional()` rolls back the originating mutation.
+ *    SEC_REVIEW F4 — admin writes must not commit without an audit row.
  */
 final class DbAuditEmitter implements AuditEmitter
 {
@@ -35,7 +38,7 @@ final class DbAuditEmitter implements AuditEmitter
         $type = $entityType ?? AuditAction::entityTypeFor($action);
 
         try {
-            $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+            $json = self::encodePayload($payload);
             $this->repo->insert($action, $type, $entityId, $entityLabel, $json, $context);
         } catch (Throwable $e) {
             $this->logger->error('audit_emit_failed', [
@@ -47,4 +50,28 @@ final class DbAuditEmitter implements AuditEmitter
             ]);
         }
     }
+
+    public function emitOrThrow(
+        string $action,
+        ?string $entityType,
+        int|string|null $entityId,
+        array $payload,
+        AuditContext $context,
+        ?string $entityLabel = null,
+    ): void {
+        $type = $entityType ?? AuditAction::entityTypeFor($action);
+        $json = self::encodePayload($payload);
+        $this->repo->insert($action, $type, $entityId, $entityLabel, $json, $context);
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private static function encodePayload(array $payload): string
+    {
+        return (string) json_encode(
+            $payload,
+            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
+        );
+    }
 }

+ 151 - 0
api/tests/Integration/Audit/AuditRollbackTest.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;
+
+/**
+ * SEC_REVIEW F4: every admin write must be transactional with its
+ * audit emit. If the audit insert fails for any reason (DB error,
+ * lock timeout, JSON encoding failure), the originating mutation
+ * MUST roll back so we can never end up with state changes that have
+ * no audit row attributing them.
+ *
+ * The forcing function here is dropping the `audit_log` table so that
+ * any subsequent `INSERT INTO audit_log` raises a SQL error. The
+ * emitter's `emitOrThrow()` propagates, the enclosing
+ * `Connection::transactional()` rolls back, and the target table
+ * sees zero new rows.
+ */
+final class AuditRollbackTest extends AppTestCase
+{
+    public function testManualBlockCreateRollsBackWhenAuditInsertFails(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+
+        // Force every emitOrThrow() to fail with a SQL error.
+        $this->db->executeStatement('DROP TABLE audit_log');
+
+        $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM manual_blocks');
+
+        $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.99', 'reason' => 'rollback-test']),
+        );
+
+        self::assertGreaterThanOrEqual(500, $resp->getStatusCode(), 'request should fail loudly');
+
+        $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM manual_blocks');
+        self::assertSame($before, $after, 'mutation must roll back when audit emit fails');
+    }
+
+    public function testReporterCreateRollsBackWhenAuditInsertFails(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $this->db->executeStatement('DROP TABLE audit_log');
+
+        $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters');
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/reporters',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['name' => 'rollback-test-rep']),
+        );
+
+        self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
+
+        $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters');
+        self::assertSame($before, $after, 'reporter row must not exist if audit emit fails');
+    }
+
+    public function testAllowlistCreateRollsBackWhenAuditInsertFails(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $this->db->executeStatement('DROP TABLE audit_log');
+
+        $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM allowlist');
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/allowlist',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.77', 'reason' => 'rollback-test']),
+        );
+
+        self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
+
+        $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM allowlist');
+        self::assertSame($before, $after);
+    }
+
+    public function testCategoryCreateRollsBackWhenAuditInsertFails(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+        $this->db->executeStatement('DROP TABLE audit_log');
+
+        $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories');
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/categories',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode([
+                'slug' => 'rollback_test',
+                'name' => 'Rollback Test',
+                'decay_function' => 'linear',
+                'decay_param' => 30,
+            ]),
+        );
+
+        self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
+
+        $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM categories');
+        self::assertSame($before, $after, 'category row must not exist if audit emit fails');
+    }
+
+    public function testUpsertLocalRollsBackWhenAuditInsertFails(): void
+    {
+        // SEC_REVIEW F5: user.created emit is transactional with the
+        // user row insert. Drop audit_log on a fresh DB (no local user
+        // exists yet) → first upsert-local fails → users table stays
+        // empty.
+        $token = $this->createToken(TokenKind::Service);
+        $this->db->executeStatement('DROP TABLE audit_log');
+
+        $before = (int) $this->db->fetchOne('SELECT COUNT(*) FROM users');
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-local',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['username' => 'admin']),
+        );
+
+        self::assertGreaterThanOrEqual(500, $resp->getStatusCode());
+
+        $after = (int) $this->db->fetchOne('SELECT COUNT(*) FROM users');
+        self::assertSame($before, $after, 'user.created must be transactional with the user insert');
+    }
+}

+ 129 - 0
api/tests/Integration/Auth/AuthEndpointsTest.php

@@ -86,6 +86,135 @@ final class AuthEndpointsTest extends AppTestCase
         );
     }
 
+    /**
+     * SEC_REVIEW F5: account creation must be audited. The first
+     * upsert-local emits a `user.created` row attributed to the
+     * service-token call (kind=system, no acting user yet) so SOC
+     * tooling can see when the local-admin row first comes into
+     * existence.
+     */
+    public function testFirstUpsertLocalEmitsUserCreatedAudit(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-local',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode(['username' => 'admin']) ?: null
+        );
+        self::assertSame(200, $response->getStatusCode());
+        $userId = $this->decode($response)['user_id'];
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, action, target_type, target_id, details_json FROM audit_log WHERE action = 'user.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row, 'user.created audit row must exist');
+        self::assertSame('system', $row['actor_kind']);
+        self::assertSame('user', $row['target_type']);
+        self::assertSame((string) $userId, $row['target_id']);
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame('local', $details['source']);
+        self::assertSame('admin', $details['display_name']);
+        self::assertSame('admin', $details['role']);
+    }
+
+    public function testRotatingUsernamesEmitsOnlyOneUserCreatedAudit(): void
+    {
+        // The first upsert mints the row + emits user.created.
+        // Subsequent renames update display_name but must NOT emit
+        // user.created again (no new account is created).
+        $token = $this->createToken(TokenKind::Service);
+        $headers = [
+            'Authorization' => 'Bearer ' . $token,
+            'Content-Type' => 'application/json',
+        ];
+        foreach (['admin', 'renamed-1', 'renamed-2'] as $name) {
+            $this->request('POST', '/api/v1/auth/users/upsert-local', $headers, json_encode(['username' => $name]) ?: null);
+        }
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action = 'user.created'"
+        );
+        self::assertSame(1, $count, 'only the bootstrap call should emit user.created');
+    }
+
+    public function testNewOidcLoginEmitsUserCreatedAudit(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-oidc',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode([
+                'subject' => 'sub-new',
+                'email' => 'newcomer@example.com',
+                'display_name' => 'Newcomer',
+                'groups' => [],
+            ]) ?: null
+        );
+        self::assertSame(200, $response->getStatusCode());
+        $userId = $this->decode($response)['user_id'];
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, action, target_type, target_id, target_label, details_json FROM audit_log WHERE action = 'user.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('user', $row['target_type']);
+        self::assertSame((string) $userId, $row['target_id']);
+        self::assertSame('newcomer@example.com', $row['target_label']);
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame('oidc', $details['source']);
+        self::assertSame('sub-new', $details['subject']);
+        self::assertSame('viewer', $details['role']);
+    }
+
+    public function testOidcRoleDriftEmitsRoleChangedAudit(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $headers = [
+            'Authorization' => 'Bearer ' . $token,
+            'Content-Type' => 'application/json',
+        ];
+        $this->db->insert('oidc_role_mappings', [
+            'group_id' => 'admin-grp',
+            'role' => Role::Admin->value,
+        ]);
+
+        // First login: admin role.
+        $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
+            'subject' => 'drift-sub',
+            'email' => 'drift@example.com',
+            'display_name' => 'Drift',
+            'groups' => ['admin-grp'],
+        ]) ?: null);
+
+        // Second login: no admin group → role drops to default viewer.
+        $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
+            'subject' => 'drift-sub',
+            'email' => 'drift@example.com',
+            'display_name' => 'Drift',
+            'groups' => [],
+        ]) ?: null);
+
+        $row = $this->db->fetchAssociative(
+            "SELECT details_json FROM audit_log WHERE action = 'user.role_changed' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row, 'role drift must emit user.role_changed');
+        $details = json_decode((string) $row['details_json'], true);
+        self::assertIsArray($details);
+        self::assertSame('admin', $details['changes']['role']['from']);
+        self::assertSame('viewer', $details['changes']['role']['to']);
+    }
+
     /**
      * Defense-in-depth: even if application code regresses, the partial
      * unique index added in 20260504100000_add_unique_local_user_index