AllowlistController.php 8.6 KB

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