refactor(firewall): separate enforcement from rule management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -32,7 +32,6 @@ class FirewallService
|
||||
private const MAX_AUTH_FAILURES = 1000;
|
||||
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
|
||||
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
|
||||
private const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
|
||||
|
||||
// Configuration keys
|
||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||
@@ -40,13 +39,12 @@ class FirewallService
|
||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||
|
||||
/** @var array<string, FirewallRuleObject[]> */
|
||||
private array $rulesCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly FirewallRuleCache $ruleCache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -192,8 +190,8 @@ class FirewallService
|
||||
self::MAX_AUTO_BLOCK_DURATION
|
||||
);
|
||||
|
||||
$this->blockIpForTenant(
|
||||
$tenantId,
|
||||
$this->rules->blockIp(
|
||||
FirewallRuleScope::tenant($tenantId),
|
||||
$ipAddress,
|
||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||
null, // System-created
|
||||
@@ -273,297 +271,6 @@ class FirewallService
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Rule Management
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
*/
|
||||
public function blockIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
return $this->blockIpForTenant(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
$reason,
|
||||
$createdBy,
|
||||
$durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private function blockIpForTenant(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = $this->validateIpAddress($ipAddress);
|
||||
$this->validateDuration($durationSeconds);
|
||||
|
||||
// Check if already blocked
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow an IP address (whitelist)
|
||||
*/
|
||||
public function allowIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = $this->validateIpAddress($ipAddress);
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_ALLOW)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Allowed by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP range (CIDR notation)
|
||||
*/
|
||||
public function blockIpRange(
|
||||
string $cidr,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$cidr = $this->validateCidr($cidr);
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP_RANGE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($cidr)
|
||||
->setReason($reason ?? 'Range blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a device fingerprint
|
||||
*/
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$fingerprint = trim($fingerprint);
|
||||
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
|
||||
);
|
||||
}
|
||||
$this->validateDuration($durationSeconds);
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_DEVICE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($fingerprint)
|
||||
->setReason($reason ?? 'Device blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
|
||||
$event->setDeviceFingerprint($fingerprint)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a rule by ID
|
||||
*/
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store->destroyRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a rule (soft delete)
|
||||
*/
|
||||
public function disableRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule->setEnabled(false);
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules for current tenant
|
||||
*/
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listRules($tenantId, $activeOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get firewall logs for current tenant
|
||||
*/
|
||||
public function getLogs(
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
int $limit = 100
|
||||
): array {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count
|
||||
*/
|
||||
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||
{
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->store->countBlockedRequests($tenantId, $since);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Check if firewall is enabled for current tenant
|
||||
*/
|
||||
@@ -603,43 +310,6 @@ class FirewallService
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function validateIpAddress(string $ipAddress): string
|
||||
{
|
||||
$ipAddress = trim($ipAddress);
|
||||
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
|
||||
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
private function validateCidr(string $cidr): string
|
||||
{
|
||||
$cidr = trim($cidr);
|
||||
if (substr_count($cidr, '/') !== 1) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
[$address, $prefix] = explode('/', $cidr, 2);
|
||||
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
|
||||
if ((int)$prefix > $maximumPrefix) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
return $cidr;
|
||||
}
|
||||
|
||||
private function validateDuration(?int $durationSeconds): void
|
||||
{
|
||||
if ($durationSeconds !== null && $durationSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active rules (cached)
|
||||
* @return FirewallRuleObject[]
|
||||
@@ -651,19 +321,7 @@ class FirewallService
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!array_key_exists($tenantId, $this->rulesCache)) {
|
||||
$this->rulesCache[$tenantId] = $this->store->listApplicableRules($tenantId);
|
||||
}
|
||||
|
||||
return $this->rulesCache[$tenantId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rules cache
|
||||
*/
|
||||
private function clearRulesCache(): void
|
||||
{
|
||||
$this->rulesCache = [];
|
||||
return $this->ruleCache->applicable($tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user