feat(firewall): add safeguarded rule creation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Controllers;
|
namespace KTXC\Controllers;
|
||||||
|
|
||||||
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\Service\FirewallRuleConflictException;
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
use KTXC\Service\SystemFirewallLogService;
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
use KTXC\Service\SystemFirewallStatusService;
|
use KTXC\Service\SystemFirewallStatusService;
|
||||||
@@ -106,6 +108,58 @@ final class FirewallController extends ControllerAbstract
|
|||||||
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
|
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[AuthenticatedRoute(
|
||||||
|
'/firewall/rules',
|
||||||
|
name: 'firewall.tenant.rules.create',
|
||||||
|
methods: ['POST'],
|
||||||
|
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
||||||
|
)]
|
||||||
|
public function createTenantRule(
|
||||||
|
Request $request,
|
||||||
|
string $type,
|
||||||
|
string $action,
|
||||||
|
string $value,
|
||||||
|
string $reason,
|
||||||
|
?int $durationSeconds = null,
|
||||||
|
bool $confirmCurrentIp = false
|
||||||
|
): JsonResponse {
|
||||||
|
return $this->mutationResponse(fn() => $this->tenantRules->createRule(
|
||||||
|
$type,
|
||||||
|
$action,
|
||||||
|
$value,
|
||||||
|
$reason,
|
||||||
|
$durationSeconds,
|
||||||
|
$request->getClientIp(),
|
||||||
|
$confirmCurrentIp
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AuthenticatedRoute(
|
||||||
|
'/firewall/system/rules',
|
||||||
|
name: 'firewall.system.rules.create',
|
||||||
|
methods: ['POST'],
|
||||||
|
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
||||||
|
)]
|
||||||
|
public function createSystemRule(
|
||||||
|
Request $request,
|
||||||
|
string $type,
|
||||||
|
string $action,
|
||||||
|
string $value,
|
||||||
|
string $reason,
|
||||||
|
?int $durationSeconds = null,
|
||||||
|
bool $confirmCurrentIp = false
|
||||||
|
): JsonResponse {
|
||||||
|
return $this->mutationResponse(fn() => $this->systemRules->createRule(
|
||||||
|
$type,
|
||||||
|
$action,
|
||||||
|
$value,
|
||||||
|
$reason,
|
||||||
|
$durationSeconds,
|
||||||
|
$request->getClientIp(),
|
||||||
|
$confirmCurrentIp
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
#[AuthenticatedRoute(
|
||||||
'/firewall/logs',
|
'/firewall/logs',
|
||||||
name: 'firewall.tenant.logs.list',
|
name: 'firewall.tenant.logs.list',
|
||||||
@@ -231,4 +285,21 @@ final class FirewallController extends ControllerAbstract
|
|||||||
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
|
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mutationResponse(callable $mutation): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED);
|
||||||
|
} catch (FirewallRuleConflictException $error) {
|
||||||
|
return new JsonResponse(['error' => [
|
||||||
|
'code' => $error->conflictCode,
|
||||||
|
'message' => $error->getMessage(),
|
||||||
|
]], JsonResponse::HTTP_CONFLICT);
|
||||||
|
} catch (\InvalidArgumentException $error) {
|
||||||
|
return new JsonResponse(['error' => [
|
||||||
|
'code' => 'invalid_firewall_rule',
|
||||||
|
'message' => $error->getMessage(),
|
||||||
|
]], JsonResponse::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Service;
|
||||||
|
|
||||||
|
final class FirewallRuleConflictException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $conflictCode,
|
||||||
|
string $message
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use KTXC\Models\Firewall\FirewallRuleObject;
|
|||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\Event\SecurityEvent;
|
use KTXF\Event\SecurityEvent;
|
||||||
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
final class FirewallRuleManager
|
final class FirewallRuleManager
|
||||||
{
|
{
|
||||||
@@ -84,6 +85,66 @@ final class FirewallRuleManager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createManualRule(
|
||||||
|
FirewallRuleScope $scope,
|
||||||
|
string $type,
|
||||||
|
string $action,
|
||||||
|
string $value,
|
||||||
|
string $reason,
|
||||||
|
?string $createdBy,
|
||||||
|
?int $durationSeconds = null,
|
||||||
|
?string $currentIp = null,
|
||||||
|
bool $confirmCurrentIp = false
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$reason = trim($reason);
|
||||||
|
if ($reason === '' || strlen($reason) > 1000) {
|
||||||
|
throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.');
|
||||||
|
}
|
||||||
|
if ($currentIp !== null) {
|
||||||
|
$currentIp = FirewallRuleValidator::ipAddress($currentIp);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!$confirmCurrentIp
|
||||||
|
&& $currentIp !== null
|
||||||
|
&& $action === FirewallRuleObject::ACTION_BLOCK
|
||||||
|
&& $this->matchesIp($type, $value, $currentIp)
|
||||||
|
) {
|
||||||
|
throw new FirewallRuleConflictException(
|
||||||
|
'current_ip_confirmation_required',
|
||||||
|
'This rule would block your current IP address. Explicit confirmation is required.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ([$type, $action]) {
|
||||||
|
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] =>
|
||||||
|
$this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds),
|
||||||
|
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] =>
|
||||||
|
$durationSeconds === null
|
||||||
|
? $this->allowIp($scope, $value, $reason, $createdBy)
|
||||||
|
: throw new \InvalidArgumentException('Temporary allow rules are not supported.'),
|
||||||
|
[FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] =>
|
||||||
|
$durationSeconds === null
|
||||||
|
? $this->blockIpRange($scope, $value, $reason, $createdBy)
|
||||||
|
: throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'),
|
||||||
|
[FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] =>
|
||||||
|
$this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds),
|
||||||
|
default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesIp(string $type, string $value, string $currentIp): bool
|
||||||
|
{
|
||||||
|
if ($type === FirewallRuleObject::TYPE_IP) {
|
||||||
|
$value = FirewallRuleValidator::ipAddress($value);
|
||||||
|
return inet_pton($value) === inet_pton($currentIp);
|
||||||
|
}
|
||||||
|
if ($type === FirewallRuleObject::TYPE_IP_RANGE) {
|
||||||
|
return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public function blockIp(
|
public function blockIp(
|
||||||
FirewallRuleScope $scope,
|
FirewallRuleScope $scope,
|
||||||
string $ipAddress,
|
string $ipAddress,
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ final class SystemFirewallRuleService
|
|||||||
return $this->rules->fetch(FirewallRuleScope::system(), $ruleId);
|
return $this->rules->fetch(FirewallRuleScope::system(), $ruleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createRule(
|
||||||
|
string $type,
|
||||||
|
string $action,
|
||||||
|
string $value,
|
||||||
|
string $reason,
|
||||||
|
?int $durationSeconds = null,
|
||||||
|
?string $currentIp = null,
|
||||||
|
bool $confirmCurrentIp = false
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->createManualRule(
|
||||||
|
FirewallRuleScope::system(),
|
||||||
|
$type,
|
||||||
|
$action,
|
||||||
|
$value,
|
||||||
|
$reason,
|
||||||
|
$this->identity->identifier(),
|
||||||
|
$durationSeconds,
|
||||||
|
$currentIp,
|
||||||
|
$confirmCurrentIp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
||||||
{
|
{
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
|||||||
@@ -43,6 +43,29 @@ final class TenantFirewallRuleService
|
|||||||
return $this->rules->fetch($this->scope(), $ruleId);
|
return $this->rules->fetch($this->scope(), $ruleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createRule(
|
||||||
|
string $type,
|
||||||
|
string $action,
|
||||||
|
string $value,
|
||||||
|
string $reason,
|
||||||
|
?int $durationSeconds = null,
|
||||||
|
?string $currentIp = null,
|
||||||
|
bool $confirmCurrentIp = false
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->createManualRule(
|
||||||
|
$this->scope(),
|
||||||
|
$type,
|
||||||
|
$action,
|
||||||
|
$value,
|
||||||
|
$reason,
|
||||||
|
$this->identity->identifier(),
|
||||||
|
$durationSeconds,
|
||||||
|
$currentIp,
|
||||||
|
$confirmCurrentIp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function effectivePolicy(): array
|
public function effectivePolicy(): array
|
||||||
{
|
{
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
$this->requirePermission(self::PERMISSION_READ);
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class IpUtils
|
|||||||
if ($netmask < 0 || $netmask > 32) {
|
if ($netmask < 0 || $netmask > 32) {
|
||||||
return self::setCacheResult($cacheKey, false);
|
return self::setCacheResult($cacheKey, false);
|
||||||
}
|
}
|
||||||
|
$netmask = (int)$netmask;
|
||||||
} else {
|
} else {
|
||||||
$address = $ip;
|
$address = $ip;
|
||||||
$netmask = 32;
|
$netmask = 32;
|
||||||
@@ -149,6 +150,7 @@ class IpUtils
|
|||||||
if ($netmask < 1 || $netmask > 128) {
|
if ($netmask < 1 || $netmask > 128) {
|
||||||
return self::setCacheResult($cacheKey, false);
|
return self::setCacheResult($cacheKey, false);
|
||||||
}
|
}
|
||||||
|
$netmask = (int)$netmask;
|
||||||
} else {
|
} else {
|
||||||
if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
||||||
return self::setCacheResult($cacheKey, false);
|
return self::setCacheResult($cacheKey, false);
|
||||||
|
|||||||
@@ -488,6 +488,41 @@ class FirewallStoreTest extends TestCase
|
|||||||
self::assertSame('tenant-disabled', $disabled['items'][0]->getReason());
|
self::assertSame('tenant-disabled', $disabled['items'][0]->getReason());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Administrative creation persists tenant and system ownership')]
|
||||||
|
public function testAdministrativeRuleCreation(): void
|
||||||
|
{
|
||||||
|
$manager = new FirewallRuleManager(
|
||||||
|
$this->store,
|
||||||
|
new FirewallRuleCache($this->store),
|
||||||
|
$this->createStub(EventDispatcherInterface::class)
|
||||||
|
);
|
||||||
|
$tenantRule = $manager->createManualRule(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
FirewallRuleObject::TYPE_DEVICE,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
'device-123',
|
||||||
|
'Compromised device',
|
||||||
|
'admin-a',
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
$systemRule = $manager->createManualRule(
|
||||||
|
FirewallRuleScope::system(),
|
||||||
|
FirewallRuleObject::TYPE_IP_RANGE,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
'198.51.100.0/24',
|
||||||
|
'Malicious network',
|
||||||
|
'system-admin',
|
||||||
|
currentIp: '203.0.113.10'
|
||||||
|
);
|
||||||
|
|
||||||
|
$persistedTenant = $this->store->fetchRule($tenantRule->getId());
|
||||||
|
$persistedSystem = $this->store->fetchRule($systemRule->getId());
|
||||||
|
self::assertSame('tenant-a', $persistedTenant->getTenantId());
|
||||||
|
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $persistedSystem->getScope());
|
||||||
|
self::assertNull($persistedSystem->getTenantId());
|
||||||
|
self::assertSame('system-admin', $persistedSystem->getCreatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
#[TestDox('Administrative log queries enforce tenant scope and supported filters')]
|
#[TestDox('Administrative log queries enforce tenant scope and supported filters')]
|
||||||
public function testAdministrativeLogQuery(): void
|
public function testAdministrativeLogQuery(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace KTXT\Unit\Controllers;
|
|||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\Context\IdentityContextInterface;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXC\Controllers\FirewallController;
|
use KTXC\Controllers\FirewallController;
|
||||||
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Service\FirewallRuleCache;
|
use KTXC\Service\FirewallRuleCache;
|
||||||
use KTXC\Service\FirewallRuleManager;
|
use KTXC\Service\FirewallRuleManager;
|
||||||
use KTXC\Service\FirewallStatusService;
|
use KTXC\Service\FirewallStatusService;
|
||||||
@@ -89,6 +90,39 @@ final class FirewallControllerTest extends TestCase
|
|||||||
self::assertSame(400, $this->controller->systemMetrics(since: 'not-a-date')->getStatusCode());
|
self::assertSame(400, $this->controller->systemMetrics(since: 'not-a-date')->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Current-IP blocks return a structured confirmation conflict')]
|
||||||
|
public function testCurrentIpConflict(): void
|
||||||
|
{
|
||||||
|
$this->store->expects(self::never())->method('depositRule');
|
||||||
|
$response = $this->controller->createTenantRule(
|
||||||
|
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
|
||||||
|
'ip',
|
||||||
|
'block',
|
||||||
|
'203.0.113.10',
|
||||||
|
'Suspected abuse'
|
||||||
|
);
|
||||||
|
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertSame(409, $response->getStatusCode());
|
||||||
|
self::assertSame('current_ip_confirmation_required', $data['error']['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Invalid manual rules return a stable validation response')]
|
||||||
|
public function testMutationValidation(): void
|
||||||
|
{
|
||||||
|
$response = $this->controller->createSystemRule(
|
||||||
|
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
|
||||||
|
'device',
|
||||||
|
'allow',
|
||||||
|
'device-123',
|
||||||
|
'Trusted device'
|
||||||
|
);
|
||||||
|
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertSame(400, $response->getStatusCode());
|
||||||
|
self::assertSame('invalid_firewall_rule', $data['error']['code']);
|
||||||
|
}
|
||||||
|
|
||||||
#[TestDox('Every rule endpoint declares its scope-specific read permission')]
|
#[TestDox('Every rule endpoint declares its scope-specific read permission')]
|
||||||
public function testRoutePermissions(): void
|
public function testRoutePermissions(): void
|
||||||
{
|
{
|
||||||
@@ -104,6 +138,8 @@ final class FirewallControllerTest extends TestCase
|
|||||||
'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ,
|
'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ,
|
||||||
'systemMetrics' => SystemFirewallLogService::PERMISSION_READ,
|
'systemMetrics' => SystemFirewallLogService::PERMISSION_READ,
|
||||||
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
|
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
|
||||||
|
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
||||||
|
'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($expected as $method => $permission) {
|
foreach ($expected as $method => $permission) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace KTXT\Unit\Service;
|
|||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Service\FirewallRuleCache;
|
use KTXC\Service\FirewallRuleCache;
|
||||||
|
use KTXC\Service\FirewallRuleConflictException;
|
||||||
use KTXC\Service\FirewallRuleManager;
|
use KTXC\Service\FirewallRuleManager;
|
||||||
use KTXC\Service\FirewallRuleScope;
|
use KTXC\Service\FirewallRuleScope;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
@@ -77,6 +78,96 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Manual rule creation requires a reason before persistence')]
|
||||||
|
public function testManualReason(): void
|
||||||
|
{
|
||||||
|
$this->store->expects(self::never())->method('depositRule');
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$this->manager->createManualRule(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
FirewallRuleObject::TYPE_IP,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
'203.0.113.10',
|
||||||
|
' ',
|
||||||
|
'admin-a'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Blocking the current exact IP requires explicit confirmation')]
|
||||||
|
public function testCurrentIpSafeguard(): void
|
||||||
|
{
|
||||||
|
$this->store->expects(self::never())->method('depositRule');
|
||||||
|
try {
|
||||||
|
$this->manager->createManualRule(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
FirewallRuleObject::TYPE_IP,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
'2001:db8::1',
|
||||||
|
'Confirmed abuse',
|
||||||
|
'admin-a',
|
||||||
|
currentIp: '2001:0db8:0:0:0:0:0:1'
|
||||||
|
);
|
||||||
|
self::fail('Current IP block should require confirmation.');
|
||||||
|
} catch (FirewallRuleConflictException $error) {
|
||||||
|
self::assertSame('current_ip_confirmation_required', $error->conflictCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('CIDR rules covering the current IP require explicit confirmation')]
|
||||||
|
public function testCurrentIpCidrSafeguard(): void
|
||||||
|
{
|
||||||
|
$this->store->expects(self::never())->method('depositRule');
|
||||||
|
$this->expectException(FirewallRuleConflictException::class);
|
||||||
|
|
||||||
|
$this->manager->createManualRule(
|
||||||
|
FirewallRuleScope::system(),
|
||||||
|
FirewallRuleObject::TYPE_IP_RANGE,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
'203.0.113.0/24',
|
||||||
|
'Network abuse',
|
||||||
|
'admin-a',
|
||||||
|
currentIp: '203.0.113.10'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Confirmed current-IP blocks retain manual audit context')]
|
||||||
|
public function testConfirmedCurrentIpBlock(): void
|
||||||
|
{
|
||||||
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
|
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
|
||||||
|
|
||||||
|
$rule = $this->manager->createManualRule(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
FirewallRuleObject::TYPE_IP,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
'203.0.113.10',
|
||||||
|
'Emergency lockout',
|
||||||
|
'admin-a',
|
||||||
|
currentIp: '203.0.113.10',
|
||||||
|
confirmCurrentIp: true
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Emergency lockout', $rule->getReason());
|
||||||
|
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $rule->getMetadata()['origin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Manual creation rejects unsupported type and action combinations')]
|
||||||
|
public function testUnsupportedManualRule(): void
|
||||||
|
{
|
||||||
|
$this->store->expects(self::never())->method('depositRule');
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$this->manager->createManualRule(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
FirewallRuleObject::TYPE_DEVICE,
|
||||||
|
FirewallRuleObject::ACTION_ALLOW,
|
||||||
|
'device-123',
|
||||||
|
'Trusted device',
|
||||||
|
'admin-a'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[TestDox('Rule lifecycle operations cannot cross scope ownership')]
|
#[TestDox('Rule lifecycle operations cannot cross scope ownership')]
|
||||||
public function testOwnership(): void
|
public function testOwnership(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -122,6 +122,23 @@ class FirewallRuleServicesTest extends TestCase
|
|||||||
$this->systemService()->blockIp('203.0.113.10');
|
$this->systemService()->blockIp('203.0.113.10');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Generic creation remains behind tenant and system management permissions')]
|
||||||
|
public function testGenericCreationPermissions(): void
|
||||||
|
{
|
||||||
|
$this->identity->method('hasPermission')->willReturn(false);
|
||||||
|
$this->store->expects(self::never())->method('depositRule');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->tenantService()->createRule('ip', 'block', '203.0.113.10', 'Abuse');
|
||||||
|
self::fail('Tenant creation should have been rejected.');
|
||||||
|
} catch (\RuntimeException $error) {
|
||||||
|
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||||
|
$this->systemService()->createRule('ip', 'block', '203.0.113.10', 'Abuse');
|
||||||
|
}
|
||||||
|
|
||||||
private function allow(string $permission): void
|
private function allow(string $permission): void
|
||||||
{
|
{
|
||||||
$this->identity->method('hasPermission')->with($permission)->willReturn(true);
|
$this->identity->method('hasPermission')->with($permission)->willReturn(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user