52afd35d6f
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
544 lines
18 KiB
PHP
544 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXC\Service;
|
|
|
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
use KTXC\Stores\FirewallStore;
|
|
use KTXF\Event\EventDispatcherInterface;
|
|
use KTXC\Security\Event\SecurityEvent;
|
|
use KTXF\IpUtils;
|
|
|
|
final class FirewallRuleManager
|
|
{
|
|
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
|
|
public const MAX_QUERY_LIMIT = 100;
|
|
public const ORIGIN_MANUAL = 'manual';
|
|
public const ORIGIN_AUTOMATIC = 'automatic';
|
|
|
|
public function __construct(
|
|
private readonly FirewallStore $store,
|
|
private readonly FirewallRuleCache $cache,
|
|
private readonly EventDispatcherInterface $events,
|
|
) {
|
|
}
|
|
|
|
public function list(FirewallRuleScope $scope, bool $activeOnly = true): array
|
|
{
|
|
return $scope->scope === FirewallRuleObject::SCOPE_SYSTEM
|
|
? $this->store->listSystemRules($activeOnly)
|
|
: $this->store->listRules($scope->tenantId, $activeOnly);
|
|
}
|
|
|
|
public function query(
|
|
FirewallRuleScope $scope,
|
|
string $status = 'active',
|
|
?string $type = null,
|
|
?string $action = null,
|
|
int $limit = 50,
|
|
int $offset = 0
|
|
): array {
|
|
if (!in_array($status, self::QUERY_STATUSES, true)) {
|
|
throw new \InvalidArgumentException('Invalid rule status filter.');
|
|
}
|
|
if ($type !== null && !in_array($type, [
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::TYPE_IP_RANGE,
|
|
FirewallRuleObject::TYPE_DEVICE,
|
|
], true)) {
|
|
throw new \InvalidArgumentException('Invalid rule type filter.');
|
|
}
|
|
if ($action !== null && !in_array($action, [
|
|
FirewallRuleObject::ACTION_ALLOW,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
], true)) {
|
|
throw new \InvalidArgumentException('Invalid rule action filter.');
|
|
}
|
|
if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) {
|
|
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
|
|
}
|
|
|
|
return $this->store->queryRules(
|
|
$scope->scope,
|
|
$scope->tenantId,
|
|
$status,
|
|
$type,
|
|
$action,
|
|
$limit,
|
|
$offset
|
|
);
|
|
}
|
|
|
|
public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
|
{
|
|
return $this->ownedRule($scope, $ruleId);
|
|
}
|
|
|
|
/** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */
|
|
public function effectivePolicy(string $tenantId): array
|
|
{
|
|
return [
|
|
'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'],
|
|
'system' => $this->store->listSystemRules(),
|
|
'tenant' => $this->store->listRules($tenantId),
|
|
];
|
|
}
|
|
|
|
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(
|
|
FirewallRuleScope $scope,
|
|
string $ipAddress,
|
|
?string $reason,
|
|
?string $createdBy,
|
|
?int $durationSeconds = null,
|
|
string $origin = self::ORIGIN_MANUAL,
|
|
array $metadata = []
|
|
): FirewallRuleObject {
|
|
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
|
FirewallRuleValidator::duration($durationSeconds);
|
|
|
|
$existing = $this->store->findExactIpRule(
|
|
$scope->tenantId,
|
|
$ipAddress,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
$scope->scope
|
|
);
|
|
if ($existing) {
|
|
if (
|
|
$origin === self::ORIGIN_AUTOMATIC
|
|
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
|
|
&& $durationSeconds !== null
|
|
) {
|
|
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
|
|
}
|
|
|
|
return $existing;
|
|
}
|
|
|
|
$rule = $this->create(
|
|
$scope,
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
$ipAddress,
|
|
$reason ?? 'Blocked by administrator',
|
|
$createdBy,
|
|
$durationSeconds,
|
|
$origin,
|
|
$metadata
|
|
);
|
|
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
public function allowIp(
|
|
FirewallRuleScope $scope,
|
|
string $ipAddress,
|
|
?string $reason,
|
|
?string $createdBy,
|
|
string $origin = self::ORIGIN_MANUAL
|
|
): FirewallRuleObject {
|
|
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
|
$rule = $this->create(
|
|
$scope,
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_ALLOW,
|
|
$ipAddress,
|
|
$reason ?? 'Allowed by administrator',
|
|
$createdBy,
|
|
null,
|
|
$origin
|
|
);
|
|
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
public function blockIpRange(
|
|
FirewallRuleScope $scope,
|
|
string $cidr,
|
|
?string $reason,
|
|
?string $createdBy,
|
|
string $origin = self::ORIGIN_MANUAL
|
|
): FirewallRuleObject {
|
|
return $this->create(
|
|
$scope,
|
|
FirewallRuleObject::TYPE_IP_RANGE,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
FirewallRuleValidator::cidr($cidr),
|
|
$reason ?? 'Range blocked by administrator',
|
|
$createdBy,
|
|
null,
|
|
$origin
|
|
);
|
|
}
|
|
|
|
public function blockDevice(
|
|
FirewallRuleScope $scope,
|
|
string $fingerprint,
|
|
?string $reason,
|
|
?string $createdBy,
|
|
?int $durationSeconds = null,
|
|
string $origin = self::ORIGIN_MANUAL
|
|
): FirewallRuleObject {
|
|
FirewallRuleValidator::duration($durationSeconds);
|
|
$fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint);
|
|
$rule = $this->create(
|
|
$scope,
|
|
FirewallRuleObject::TYPE_DEVICE,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
$fingerprint,
|
|
$reason ?? 'Device blocked by administrator',
|
|
$createdBy,
|
|
$durationSeconds,
|
|
$origin
|
|
);
|
|
|
|
$event = new SecurityEvent(
|
|
SecurityEvent::DEVICE_BLOCKED,
|
|
['device' => $fingerprint, 'reason' => $reason],
|
|
tenantId: $scope->tenantId,
|
|
deviceFingerprint: $fingerprint,
|
|
reason: $reason,
|
|
);
|
|
$this->events->dispatch($event);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
public function disableManual(
|
|
FirewallRuleScope $scope,
|
|
string $ruleId,
|
|
string $reason,
|
|
?string $actorId
|
|
): ?FirewallRuleObject {
|
|
$reason = self::manualReason($reason);
|
|
$rule = $this->ownedRule($scope, $ruleId);
|
|
if (!$rule) {
|
|
return null;
|
|
}
|
|
if ($rule->isEnabled()) {
|
|
$rule->setEnabled(false);
|
|
$this->store->depositRule($rule);
|
|
$this->cache->invalidate();
|
|
$this->publishLifecycleEvent(
|
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
|
$rule,
|
|
$actorId,
|
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
|
);
|
|
}
|
|
|
|
return $rule;
|
|
}
|
|
|
|
public function enableManual(
|
|
FirewallRuleScope $scope,
|
|
string $ruleId,
|
|
string $reason,
|
|
?string $actorId,
|
|
?string $currentIp = null,
|
|
bool $confirmCurrentIp = false
|
|
): ?FirewallRuleObject {
|
|
$reason = self::manualReason($reason);
|
|
$rule = $this->ownedRule($scope, $ruleId);
|
|
if (!$rule) {
|
|
return null;
|
|
}
|
|
if (
|
|
!$confirmCurrentIp
|
|
&& $currentIp !== null
|
|
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
|
|
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
|
|
) {
|
|
throw new FirewallRuleConflictException(
|
|
'current_ip_confirmation_required',
|
|
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
|
|
);
|
|
}
|
|
if (!$rule->isEnabled()) {
|
|
$rule->setEnabled(true);
|
|
$this->store->depositRule($rule);
|
|
$this->cache->invalidate();
|
|
$this->publishLifecycleEvent(
|
|
SecurityEvent::FIREWALL_RULE_ENABLED,
|
|
$rule,
|
|
$actorId,
|
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
|
);
|
|
}
|
|
|
|
return $rule;
|
|
}
|
|
|
|
public function extendManual(
|
|
FirewallRuleScope $scope,
|
|
string $ruleId,
|
|
int $durationSeconds,
|
|
string $reason,
|
|
?string $actorId
|
|
): ?FirewallRuleObject {
|
|
$reason = self::manualReason($reason);
|
|
FirewallRuleValidator::duration($durationSeconds);
|
|
$rule = $this->ownedRule($scope, $ruleId);
|
|
if (!$rule) {
|
|
return null;
|
|
}
|
|
$previousExpiry = $rule->getExpiresAt();
|
|
if ($previousExpiry === null) {
|
|
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
|
|
}
|
|
$now = new \DateTimeImmutable();
|
|
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
|
|
->modify("+{$durationSeconds} seconds");
|
|
$metadata = $rule->getMetadata() ?? [];
|
|
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
|
|
$extensions[] = [
|
|
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
|
|
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
|
|
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
|
|
'origin' => self::ORIGIN_MANUAL,
|
|
'actorId' => $actorId,
|
|
'reason' => $reason,
|
|
];
|
|
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
|
|
$this->store->depositRule($rule);
|
|
$this->cache->invalidate();
|
|
$this->publishLifecycleEvent(
|
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
|
$rule,
|
|
$actorId,
|
|
[
|
|
'changeReason' => $reason,
|
|
'changeOrigin' => self::ORIGIN_MANUAL,
|
|
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
|
|
]
|
|
);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
public function removeManual(
|
|
FirewallRuleScope $scope,
|
|
string $ruleId,
|
|
string $reason,
|
|
?string $actorId
|
|
): ?FirewallRuleObject {
|
|
$reason = self::manualReason($reason);
|
|
$rule = $this->ownedRule($scope, $ruleId);
|
|
if (!$rule) {
|
|
return null;
|
|
}
|
|
$this->store->destroyRule($rule);
|
|
$this->cache->invalidate();
|
|
$this->publishLifecycleEvent(
|
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
|
$rule,
|
|
$actorId,
|
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
|
);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
private static function manualReason(string $reason): string
|
|
{
|
|
$reason = trim($reason);
|
|
if ($reason === '' || strlen($reason) > 1000) {
|
|
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
|
|
}
|
|
|
|
return $reason;
|
|
}
|
|
|
|
private function create(
|
|
FirewallRuleScope $scope,
|
|
string $type,
|
|
string $action,
|
|
string $value,
|
|
string $reason,
|
|
?string $createdBy,
|
|
?int $durationSeconds = null,
|
|
string $origin = self::ORIGIN_MANUAL,
|
|
array $metadata = []
|
|
): FirewallRuleObject {
|
|
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
|
|
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
|
|
}
|
|
|
|
$rule = (new FirewallRuleObject())
|
|
->setScope($scope->scope)
|
|
->setTenantId($scope->tenantId)
|
|
->setType($type)
|
|
->setAction($action)
|
|
->setValue($value)
|
|
->setReason($reason)
|
|
->setCreatedBy($createdBy)
|
|
->setCreatedAt(new \DateTimeImmutable())
|
|
->setEnabled(true);
|
|
|
|
if ($durationSeconds !== null) {
|
|
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
|
|
}
|
|
$metadata = [...$metadata, 'origin' => $origin];
|
|
if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) {
|
|
$metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM);
|
|
$metadata['extensions'] = [];
|
|
}
|
|
$rule->setMetadata($metadata);
|
|
|
|
$this->store->depositRule($rule);
|
|
$this->cache->invalidate();
|
|
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
private function extendAutomaticBlock(
|
|
FirewallRuleObject $rule,
|
|
int $durationSeconds,
|
|
array $policy
|
|
): FirewallRuleObject {
|
|
$now = new \DateTimeImmutable();
|
|
$previousExpiry = $rule->getExpiresAt();
|
|
$newExpiry = $now->modify("+{$durationSeconds} seconds");
|
|
if ($previousExpiry !== null && $newExpiry <= $previousExpiry) {
|
|
return $rule;
|
|
}
|
|
|
|
$metadata = $rule->getMetadata() ?? [];
|
|
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
|
|
$extensions[] = [
|
|
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
|
|
'previousExpiresAt' => $previousExpiry?->format(\DateTimeInterface::ATOM),
|
|
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
|
|
'failureCount' => $policy['lastFailureCount'] ?? null,
|
|
];
|
|
$rule->setExpiresAt($newExpiry)->setMetadata([
|
|
...$metadata,
|
|
...$policy,
|
|
'origin' => self::ORIGIN_AUTOMATIC,
|
|
'originalExpiresAt' => $metadata['originalExpiresAt']
|
|
?? $previousExpiry?->format(\DateTimeInterface::ATOM),
|
|
'extensions' => $extensions,
|
|
'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM),
|
|
]);
|
|
|
|
$this->store->depositRule($rule);
|
|
$this->cache->invalidate();
|
|
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule);
|
|
|
|
return $rule;
|
|
}
|
|
|
|
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
|
{
|
|
$rule = $this->store->fetchRule($ruleId);
|
|
|
|
return $rule && $scope->owns($rule) ? $rule : null;
|
|
}
|
|
|
|
private function publishIpEvent(
|
|
string $name,
|
|
FirewallRuleScope $scope,
|
|
string $ipAddress,
|
|
?string $reason
|
|
): void {
|
|
$event = new SecurityEvent(
|
|
$name,
|
|
['ip' => $ipAddress, 'reason' => $reason],
|
|
tenantId: $scope->tenantId,
|
|
ipAddress: $ipAddress,
|
|
reason: $reason,
|
|
);
|
|
$this->events->dispatch($event);
|
|
}
|
|
|
|
private function publishLifecycleEvent(
|
|
string $name,
|
|
FirewallRuleObject $rule,
|
|
?string $actorId = null,
|
|
array $change = []
|
|
): void
|
|
{
|
|
$event = new SecurityEvent(
|
|
$name,
|
|
[
|
|
'ruleId' => $rule->getId(),
|
|
'ruleScope' => $rule->getScope(),
|
|
'ruleType' => $rule->getType(),
|
|
'ruleAction' => $rule->getAction(),
|
|
'ruleValue' => $rule->getValue(),
|
|
'reason' => $rule->getReason(),
|
|
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
|
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
|
...($rule->getMetadata() ?? []),
|
|
...$change,
|
|
],
|
|
tenantId: $rule->getTenantId(),
|
|
identityId: $actorId ?? $rule->getCreatedBy(),
|
|
);
|
|
$this->events->dispatch($event);
|
|
}
|
|
}
|