feat(firewall): complete operational reliability phase

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-30 23:39:37 -04:00
parent 90d847ffae
commit 4ee91a7918
15 changed files with 574 additions and 22 deletions
+58 -4
View File
@@ -34,7 +34,8 @@ final class FirewallRuleManager
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
FirewallRuleValidator::duration($durationSeconds);
@@ -46,6 +47,14 @@ final class FirewallRuleManager
$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;
}
@@ -57,7 +66,8 @@ final class FirewallRuleManager
$reason ?? 'Blocked by administrator',
$createdBy,
$durationSeconds,
$origin
$origin,
$metadata
);
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
@@ -171,7 +181,8 @@ final class FirewallRuleManager
string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL
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}");
@@ -186,12 +197,17 @@ final class FirewallRuleManager
->setReason($reason)
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setMetadata(['origin' => $origin])
->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();
@@ -200,6 +216,43 @@ final class FirewallRuleManager
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);
@@ -233,6 +286,7 @@ final class FirewallRuleManager
'reason' => $rule->getReason(),
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
]);
$event->setTenantId($rule->getTenantId())
->setIdentityId($actorId ?? $rule->getCreatedBy());
+39 -10
View File
@@ -169,7 +169,8 @@ class FirewallService
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $blockDuration)) {
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
return;
}
@@ -204,7 +205,17 @@ class FirewallService
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
null, // System-created
$blockDuration,
FirewallRuleManager::ORIGIN_AUTOMATIC
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => $this->getBoundedIntegerConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
),
'failureWindowSeconds' => $windowSeconds,
'lastFailureCount' => $failureCount,
'blockDurationSeconds' => $blockDuration,
]
);
}
@@ -257,6 +268,7 @@ class FirewallService
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
@@ -272,6 +284,7 @@ class FirewallService
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
@@ -341,15 +354,31 @@ class FirewallService
*/
public function cleanup(): array
{
$expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
$expiredClaims = $this->store->cleanupExpiredBruteForceClaims();
$startedAt = new \DateTimeImmutable();
return [
'expiredRules' => $expiredRules,
'oldLogs' => $oldLogs,
'expiredBruteForceClaims' => $expiredClaims,
];
try {
$result = [
'expiredRules' => $this->store->cleanupExpiredRules(),
'oldLogs' => $this->store->cleanupOldLogs(30),
'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(),
];
$this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result);
return $result;
} catch (\Throwable $error) {
try {
$this->store->recordMaintenanceStatus(
$startedAt,
new \DateTimeImmutable(),
'failed',
[],
$error->getMessage()
);
} catch (\Throwable) {
// Preserve the cleanup failure when the status store is also unavailable.
}
throw $error;
}
}
}