| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- <?php
- declare(strict_types=1);
- namespace App\Application\Jobs;
- use App\Domain\Audit\AuditAction;
- use App\Domain\Audit\AuditContext;
- use App\Domain\Audit\AuditEmitter;
- use App\Domain\Jobs\Job;
- use App\Domain\Jobs\JobContext;
- use App\Domain\Jobs\JobResult;
- use App\Domain\ManualBlock\ManualBlock;
- use App\Infrastructure\ManualBlock\ManualBlockRepository;
- use App\Infrastructure\Reputation\BlocklistCache;
- use App\Infrastructure\Reputation\CidrEvaluatorFactory;
- /**
- * Daily prune of expired `manual_blocks` rows.
- *
- * Read-time filtering (M14.5) already hides expired rows from every list,
- * lookup, and the CidrEvaluator's snapshot — so correctness doesn't depend
- * on this job. The job exists for tidiness: keeps the table from growing
- * unbounded with old expired entries.
- *
- * For each row deleted, emits one `manual_block.deleted` audit row with
- * `actor_kind=system` so the audit trail records who removed the block.
- * Invalidates the CidrEvaluator + BlocklistCache so any in-flight reads on
- * this replica observe the prune immediately.
- */
- final class CleanupExpiredManualBlocksJob implements Job
- {
- public const NAME = 'cleanup-expired-manual-blocks';
- public function __construct(
- private readonly ManualBlockRepository $manualBlocks,
- private readonly AuditEmitter $audit,
- private readonly CidrEvaluatorFactory $evaluator,
- private readonly BlocklistCache $blocklistCache,
- ) {
- }
- public function name(): string
- {
- return self::NAME;
- }
- public function defaultIntervalSeconds(): int
- {
- return 86400;
- }
- public function maxRuntimeSeconds(): int
- {
- return 60;
- }
- public function run(JobContext $context): JobResult
- {
- $now = $context->clock->now();
- $expiredIds = $this->manualBlocks->findExpired($now);
- if ($expiredIds === []) {
- return new JobResult(itemsProcessed: 0);
- }
- // Snapshot labels before the rows disappear so the audit row carries
- // the IP/CIDR text rather than just an opaque numeric id.
- $labels = [];
- foreach ($expiredIds as $id) {
- $row = $this->manualBlocks->findById($id);
- $labels[$id] = $row === null
- ? null
- : ($row->kind === ManualBlock::KIND_IP
- ? $row->ip?->text()
- : $row->cidr?->text());
- }
- $deleted = $this->manualBlocks->deleteExpired($now);
- $auditCtx = AuditContext::system();
- foreach ($expiredIds as $id) {
- $this->audit->emit(
- AuditAction::MANUAL_BLOCK_DELETED,
- 'manual_block',
- (string) $id,
- ['reason' => 'expired', 'job' => self::NAME, 'target' => $labels[$id] ?? null],
- $auditCtx,
- $labels[$id] ?? null,
- );
- }
- // Heavy-handed but correct: any in-process snapshot is invalidated
- // so subsequent reads on this replica don't keep an expired row in
- // their cached view.
- $this->evaluator->invalidate();
- $this->blocklistCache->invalidateAll();
- return new JobResult(
- itemsProcessed: $deleted,
- details: ['expired_ids' => $expiredIds],
- );
- }
- }
|