| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- <?php
- declare(strict_types=1);
- namespace App\Application\Admin;
- use App\Domain\Allowlist\AllowlistEntry;
- 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\Infrastructure\Allowlist\AllowlistRepository;
- use App\Infrastructure\Reputation\BlocklistCache;
- use App\Infrastructure\Reputation\CidrEvaluatorFactory;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- /**
- * Admin CRUD over `allowlist`. Mirrors ManualBlocksController minus
- * `expires_at` — allowlist entries don't expire on a clock.
- *
- * SPEC §6 RBAC: Operator may create/delete; Viewer may list/get.
- */
- final class AllowlistController
- {
- use AdminControllerSupport;
- public function __construct(
- private readonly AllowlistRepository $allowlist,
- private readonly CidrEvaluatorFactory $evaluator,
- private readonly BlocklistCache $blocklistCache,
- private readonly AuditEmitter $audit,
- ) {
- }
- 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'], [AllowlistEntry::KIND_IP, AllowlistEntry::KIND_SUBNET], true)) {
- $kind = $params['kind'];
- }
- $rows = $this->allowlist->list($page['limit'], $page['offset'], ['kind' => $kind]);
- $total = $this->allowlist->count($kind);
- return self::json($response, 200, [
- 'items' => array_map(static fn (AllowlistEntry $e) => $e->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->allowlist->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, [AllowlistEntry::KIND_IP, AllowlistEntry::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 {
- $reason = $body['reason'];
- }
- }
- if ($kind === AllowlistEntry::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()]);
- }
- $id = $this->allowlist->createIp($ip, $reason, self::actingUserId($request));
- $this->evaluator->invalidate();
- $this->blocklistCache->invalidateAll();
- // Eager rebuild so an allowlist/manual-block overlap surfaces
- // as a WARNING log entry inside this request — matches the
- // ManualBlocksController behaviour for symmetry.
- $this->evaluator->get();
- $created = $this->allowlist->findById($id);
- if ($created === null) {
- 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),
- );
- 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()]);
- }
- $id = $this->allowlist->createSubnet($cidr, $reason, self::actingUserId($request));
- $this->evaluator->invalidate();
- $this->blocklistCache->invalidateAll();
- $this->evaluator->get();
- $created = $this->allowlist->findById($id);
- if ($created === null) {
- return self::error($response, 500, 'create_failed');
- }
- $payload = $created->toArray();
- if ($cidrInput !== $cidr->text()) {
- $payload['normalized_from'] = $cidrInput;
- }
- $this->audit->emit(
- AuditAction::ALLOWLIST_CREATED,
- 'allowlist',
- $id,
- ['kind' => 'subnet', 'cidr' => $cidr->text(), 'reason' => $reason],
- self::auditContext($request),
- );
- 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->allowlist->findById($id);
- if ($existing === null) {
- return self::error($response, 404, 'not_found');
- }
- $this->allowlist->delete($id);
- $this->evaluator->invalidate();
- $this->blocklistCache->invalidateAll();
- $this->audit->emit(
- AuditAction::ALLOWLIST_DELETED,
- 'allowlist',
- $id,
- ['kind' => $existing->kind, 'reason' => $existing->reason],
- self::auditContext($request),
- );
- return $response->withStatus(204);
- }
- private static function parseId(string $raw): ?int
- {
- return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
- }
- }
|