| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- <?php
- 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;
- use App\Domain\Ip\IpAddress;
- use App\Domain\ManualBlock\ManualBlock;
- use App\Infrastructure\ManualBlock\ManualBlockRepository;
- 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;
- /**
- * Admin CRUD over `manual_blocks`. SPEC §6 RBAC:
- * - Operator: create + delete
- * - Viewer: list + get
- *
- * Per-route role enforcement happens in `AppFactory` via `RbacMiddleware::require`;
- * this controller only sees authorized requests.
- *
- * CIDR canonicalization (recommendation (c) in M06.md): non-canonical input
- * such as `203.0.113.55/24` is silently normalized to `203.0.113.0/24` and
- * the response body echoes both `cidr` and `normalized_from`. Canonical
- * input omits the field. This is the behaviour the M06 acceptance script
- * tests for.
- */
- final class ManualBlocksController
- {
- use AdminControllerSupport;
- public function __construct(
- private readonly ManualBlockRepository $manualBlocks,
- private readonly CidrEvaluatorFactory $evaluator,
- private readonly BlocklistCache $blocklistCache,
- private readonly AuditEmitter $audit,
- private readonly Connection $connection,
- ) {
- }
- public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $page = self::pagination($request);
- $params = $request->getQueryParams();
- $kind = null;
- if (isset($params['kind']) && is_string($params['kind']) && in_array($params['kind'], [ManualBlock::KIND_IP, ManualBlock::KIND_SUBNET], true)) {
- $kind = $params['kind'];
- }
- $rows = $this->manualBlocks->list($page['limit'], $page['offset'], ['kind' => $kind]);
- $total = $this->manualBlocks->count($kind);
- return self::json($response, 200, [
- 'items' => array_map(static fn (ManualBlock $b) => $b->toArray(), $rows),
- 'page' => $page['page'],
- 'limit' => $page['limit'],
- 'total' => $total,
- ]);
- }
- /**
- * @param array{id: string} $args
- */
- public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
- {
- $id = self::parseId($args['id']);
- if ($id === null) {
- return self::error($response, 404, 'not_found');
- }
- $entry = $this->manualBlocks->findById($id);
- if ($entry === null) {
- return self::error($response, 404, 'not_found');
- }
- return self::json($response, 200, $entry->toArray());
- }
- public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
- {
- $body = self::jsonBody($request);
- $errors = [];
- $kind = $body['kind'] ?? null;
- if (!in_array($kind, [ManualBlock::KIND_IP, ManualBlock::KIND_SUBNET], true)) {
- return self::validationFailed($response, ['kind' => 'required, "ip" or "subnet"']);
- }
- $reason = null;
- if (array_key_exists('reason', $body)) {
- if ($body['reason'] !== null && !is_string($body['reason'])) {
- $errors['reason'] = 'must be string or null';
- } else {
- // SEC_REVIEW F52: strip C0/C1 control characters before
- // the reason lands in `audit_log.target_label` /
- // `details_json` to defeat log-injection and terminal-
- // escape attacks on log viewers.
- $reason = is_string($body['reason'])
- ? self::stripControlChars($body['reason'])
- : null;
- }
- }
- $expiresAt = null;
- if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
- if (!is_string($body['expires_at'])) {
- $errors['expires_at'] = 'must be ISO-8601 string or null';
- } else {
- $parsed = self::parseTimestamp($body['expires_at']);
- if ($parsed === null) {
- $errors['expires_at'] = 'must be ISO-8601 (e.g. 2026-12-31T23:59:59Z)';
- } else {
- $expiresAt = $parsed;
- }
- }
- }
- if ($kind === ManualBlock::KIND_IP) {
- if (!isset($body['ip']) || !is_string($body['ip']) || $body['ip'] === '') {
- $errors['ip'] = 'required for kind=ip';
- }
- if (isset($body['cidr'])) {
- $errors['cidr'] = 'forbidden for kind=ip';
- }
- if ($errors !== []) {
- return self::validationFailed($response, $errors);
- }
- try {
- /** @var string $ipText */
- $ipText = $body['ip'];
- $ip = IpAddress::fromString($ipText);
- } catch (InvalidIpException $e) {
- return self::validationFailed($response, ['ip' => $e->getMessage()]);
- }
- $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
- // a WARNING log entry while we're still in this request — gives
- // admins immediate feedback on a suspicious configuration.
- $this->evaluator->get();
- $created = $this->manualBlocks->findById($id);
- if ($created === null) {
- return self::error($response, 500, 'create_failed');
- }
- return self::json($response, 201, $created->toArray());
- }
- // kind=subnet
- if (!isset($body['cidr']) || !is_string($body['cidr']) || $body['cidr'] === '') {
- $errors['cidr'] = 'required for kind=subnet';
- }
- if (isset($body['ip'])) {
- $errors['ip'] = 'forbidden for kind=subnet';
- }
- if ($errors !== []) {
- return self::validationFailed($response, $errors);
- }
- try {
- /** @var string $cidrInput */
- $cidrInput = $body['cidr'];
- $cidr = Cidr::fromString($cidrInput);
- } catch (InvalidCidrException $e) {
- return self::validationFailed($response, ['cidr' => $e->getMessage()]);
- }
- $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();
- $created = $this->manualBlocks->findById($id);
- if ($created === null) {
- return self::error($response, 500, 'create_failed');
- }
- $payload = $created->toArray();
- if ($cidrInput !== $cidr->text()) {
- $payload['normalized_from'] = $cidrInput;
- }
- return self::json($response, 201, $payload);
- }
- /**
- * @param array{id: string} $args
- */
- public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
- {
- $id = self::parseId($args['id']);
- if ($id === null) {
- return self::error($response, 404, 'not_found');
- }
- $existing = $this->manualBlocks->findById($id);
- if ($existing === null) {
- return self::error($response, 404, 'not_found');
- }
- $label = $existing->kind === ManualBlock::KIND_IP
- ? $existing->ip?->text()
- : $existing->cidr?->text();
- $auditCtx = self::auditContext($request);
- $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);
- }
- private static function parseId(string $raw): ?int
- {
- return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
- }
- private static function parseTimestamp(string $iso): ?DateTimeImmutable
- {
- try {
- return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
- } catch (\Exception) {
- return null;
- }
- }
- }
|