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; } }