AllowlistController.php 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Admin;
  4. use App\Domain\Allowlist\AllowlistEntry;
  5. use App\Domain\Audit\AuditAction;
  6. use App\Domain\Audit\AuditEmitter;
  7. use App\Domain\Ip\Cidr;
  8. use App\Domain\Ip\InvalidCidrException;
  9. use App\Domain\Ip\InvalidIpException;
  10. use App\Domain\Ip\IpAddress;
  11. use App\Infrastructure\Allowlist\AllowlistRepository;
  12. use App\Infrastructure\Reputation\BlocklistCache;
  13. use App\Infrastructure\Reputation\CidrEvaluatorFactory;
  14. use Psr\Http\Message\ResponseInterface;
  15. use Psr\Http\Message\ServerRequestInterface;
  16. /**
  17. * Admin CRUD over `allowlist`. Mirrors ManualBlocksController minus
  18. * `expires_at` — allowlist entries don't expire on a clock.
  19. *
  20. * SPEC §6 RBAC: Operator may create/delete; Viewer may list/get.
  21. */
  22. final class AllowlistController
  23. {
  24. use AdminControllerSupport;
  25. public function __construct(
  26. private readonly AllowlistRepository $allowlist,
  27. private readonly CidrEvaluatorFactory $evaluator,
  28. private readonly BlocklistCache $blocklistCache,
  29. private readonly AuditEmitter $audit,
  30. ) {
  31. }
  32. public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  33. {
  34. $page = self::pagination($request);
  35. $params = $request->getQueryParams();
  36. $kind = null;
  37. if (isset($params['kind']) && is_string($params['kind']) && in_array($params['kind'], [AllowlistEntry::KIND_IP, AllowlistEntry::KIND_SUBNET], true)) {
  38. $kind = $params['kind'];
  39. }
  40. $rows = $this->allowlist->list($page['limit'], $page['offset'], ['kind' => $kind]);
  41. $total = $this->allowlist->count($kind);
  42. return self::json($response, 200, [
  43. 'items' => array_map(static fn (AllowlistEntry $e) => $e->toArray(), $rows),
  44. 'page' => $page['page'],
  45. 'limit' => $page['limit'],
  46. 'total' => $total,
  47. ]);
  48. }
  49. /**
  50. * @param array{id: string} $args
  51. */
  52. public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  53. {
  54. $id = self::parseId($args['id']);
  55. if ($id === null) {
  56. return self::error($response, 404, 'not_found');
  57. }
  58. $entry = $this->allowlist->findById($id);
  59. if ($entry === null) {
  60. return self::error($response, 404, 'not_found');
  61. }
  62. return self::json($response, 200, $entry->toArray());
  63. }
  64. public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
  65. {
  66. $body = self::jsonBody($request);
  67. $errors = [];
  68. $kind = $body['kind'] ?? null;
  69. if (!in_array($kind, [AllowlistEntry::KIND_IP, AllowlistEntry::KIND_SUBNET], true)) {
  70. return self::validationFailed($response, ['kind' => 'required, "ip" or "subnet"']);
  71. }
  72. $reason = null;
  73. if (array_key_exists('reason', $body)) {
  74. if ($body['reason'] !== null && !is_string($body['reason'])) {
  75. $errors['reason'] = 'must be string or null';
  76. } else {
  77. $reason = $body['reason'];
  78. }
  79. }
  80. if ($kind === AllowlistEntry::KIND_IP) {
  81. if (!isset($body['ip']) || !is_string($body['ip']) || $body['ip'] === '') {
  82. $errors['ip'] = 'required for kind=ip';
  83. }
  84. if (isset($body['cidr'])) {
  85. $errors['cidr'] = 'forbidden for kind=ip';
  86. }
  87. if ($errors !== []) {
  88. return self::validationFailed($response, $errors);
  89. }
  90. try {
  91. /** @var string $ipText */
  92. $ipText = $body['ip'];
  93. $ip = IpAddress::fromString($ipText);
  94. } catch (InvalidIpException $e) {
  95. return self::validationFailed($response, ['ip' => $e->getMessage()]);
  96. }
  97. $id = $this->allowlist->createIp($ip, $reason, self::actingUserId($request));
  98. $this->evaluator->invalidate();
  99. $this->blocklistCache->invalidateAll();
  100. // Eager rebuild so an allowlist/manual-block overlap surfaces
  101. // as a WARNING log entry inside this request — matches the
  102. // ManualBlocksController behaviour for symmetry.
  103. $this->evaluator->get();
  104. $created = $this->allowlist->findById($id);
  105. if ($created === null) {
  106. return self::error($response, 500, 'create_failed');
  107. }
  108. $this->audit->emit(
  109. AuditAction::ALLOWLIST_CREATED,
  110. 'allowlist',
  111. $id,
  112. ['kind' => 'ip', 'ip' => $ip->text(), 'reason' => $reason],
  113. self::auditContext($request),
  114. );
  115. return self::json($response, 201, $created->toArray());
  116. }
  117. // kind=subnet
  118. if (!isset($body['cidr']) || !is_string($body['cidr']) || $body['cidr'] === '') {
  119. $errors['cidr'] = 'required for kind=subnet';
  120. }
  121. if (isset($body['ip'])) {
  122. $errors['ip'] = 'forbidden for kind=subnet';
  123. }
  124. if ($errors !== []) {
  125. return self::validationFailed($response, $errors);
  126. }
  127. try {
  128. /** @var string $cidrInput */
  129. $cidrInput = $body['cidr'];
  130. $cidr = Cidr::fromString($cidrInput);
  131. } catch (InvalidCidrException $e) {
  132. return self::validationFailed($response, ['cidr' => $e->getMessage()]);
  133. }
  134. $id = $this->allowlist->createSubnet($cidr, $reason, self::actingUserId($request));
  135. $this->evaluator->invalidate();
  136. $this->blocklistCache->invalidateAll();
  137. $this->evaluator->get();
  138. $created = $this->allowlist->findById($id);
  139. if ($created === null) {
  140. return self::error($response, 500, 'create_failed');
  141. }
  142. $payload = $created->toArray();
  143. if ($cidrInput !== $cidr->text()) {
  144. $payload['normalized_from'] = $cidrInput;
  145. }
  146. $this->audit->emit(
  147. AuditAction::ALLOWLIST_CREATED,
  148. 'allowlist',
  149. $id,
  150. ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason],
  151. self::auditContext($request),
  152. );
  153. return self::json($response, 201, $payload);
  154. }
  155. /**
  156. * @param array{id: string} $args
  157. */
  158. public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
  159. {
  160. $id = self::parseId($args['id']);
  161. if ($id === null) {
  162. return self::error($response, 404, 'not_found');
  163. }
  164. $existing = $this->allowlist->findById($id);
  165. if ($existing === null) {
  166. return self::error($response, 404, 'not_found');
  167. }
  168. $this->allowlist->delete($id);
  169. $this->evaluator->invalidate();
  170. $this->blocklistCache->invalidateAll();
  171. $this->audit->emit(
  172. AuditAction::ALLOWLIST_DELETED,
  173. 'allowlist',
  174. $id,
  175. ['kind' => $existing->kind, 'reason' => $existing->reason],
  176. self::auditContext($request),
  177. );
  178. return $response->withStatus(204);
  179. }
  180. private static function parseId(string $raw): ?int
  181. {
  182. return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
  183. }
  184. }