feat(firewall): complete operational reliability phase
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Firewall;
|
||||
|
||||
use KTXC\Service\FirewallService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
|
||||
final class FirewallMaintenanceCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly FirewallService $firewall)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$result = $this->firewall->cleanup();
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Firewall maintenance failed: '.$error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
|
||||
$result['expiredRules'],
|
||||
$result['oldLogs'],
|
||||
$result['expiredBruteForceClaims']
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Firewall;
|
||||
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
|
||||
final class FirewallSetupCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$indexes = $this->store->ensureIndexes();
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Firewall database setup failed: '.$error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,6 @@ class ObjectId
|
||||
*/
|
||||
public static function isValid(string $id): bool
|
||||
{
|
||||
return MongoObjectId::isValid($id);
|
||||
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
public const EVENT_RULE_MATCH = 'rule_match';
|
||||
public const EVENT_ACCESS_CHECK = 'access_check';
|
||||
public const EVENT_RULE_CREATED = 'rule_created';
|
||||
public const EVENT_RULE_EXTENDED = 'rule_extended';
|
||||
public const EVENT_RULE_DISABLED = 'rule_disabled';
|
||||
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
@@ -41,6 +43,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||
] as $event) {
|
||||
@@ -158,6 +161,8 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
public function registerCI(): array
|
||||
{
|
||||
return [
|
||||
FirewallSetupCommand::class,
|
||||
FirewallMaintenanceCommand::class,
|
||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||
\KTXC\Console\Module\ModuleListCommand::class,
|
||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\ObjectId;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
@@ -17,6 +18,7 @@ class FirewallStore
|
||||
protected const RULES_COLLECTION = 'firewall_rules';
|
||||
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
||||
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
@@ -180,7 +182,7 @@ class FirewallStore
|
||||
*/
|
||||
public function fetchRule(string $id): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
@@ -259,7 +261,7 @@ class FirewallStore
|
||||
unset($data['id']);
|
||||
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||
['_id' => $id],
|
||||
self::ruleIdFilter($id),
|
||||
['$set' => $data]
|
||||
);
|
||||
return $rule;
|
||||
@@ -274,7 +276,7 @@ class FirewallStore
|
||||
if (!$id) {
|
||||
return;
|
||||
}
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,6 +478,33 @@ class FirewallStore
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
public function recordMaintenanceStatus(
|
||||
\DateTimeImmutable $startedAt,
|
||||
\DateTimeImmutable $completedAt,
|
||||
string $status,
|
||||
array $result,
|
||||
?string $error = null
|
||||
): void {
|
||||
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
|
||||
['_id' => 'cleanup'],
|
||||
['$set' => [
|
||||
'startedAt' => self::bsonDate($startedAt),
|
||||
'completedAt' => self::bsonDate($completedAt),
|
||||
'status' => $status,
|
||||
'result' => $result,
|
||||
'error' => $error,
|
||||
]],
|
||||
['upsert' => true]
|
||||
);
|
||||
}
|
||||
|
||||
public function maintenanceStatus(): ?array
|
||||
{
|
||||
return $this->dataStore
|
||||
->selectCollection(self::MAINTENANCE_COLLECTION)
|
||||
->findOne(['_id' => 'cleanup']);
|
||||
}
|
||||
|
||||
private static function ruleDocument(FirewallRuleObject $rule): array
|
||||
{
|
||||
$data = $rule->jsonSerialize();
|
||||
@@ -502,4 +531,9 @@ class FirewallStore
|
||||
{
|
||||
return UTCDateTime::fromDateTime($date);
|
||||
}
|
||||
|
||||
private static function ruleIdFilter(string $id): array
|
||||
{
|
||||
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user