feat(firewall): add audited rule lifecycle management

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-03 22:35:45 -04:00
parent b06c18d38e
commit d5ca89e160
13 changed files with 457 additions and 43 deletions
+1
View File
@@ -21,6 +21,7 @@ final class FirewallLogService
FirewallLogObject::EVENT_ACCESS_CHECK,
FirewallLogObject::EVENT_RULE_CREATED,
FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallLogObject::EVENT_RULE_ENABLED,
FirewallLogObject::EVENT_RULE_DISABLED,
FirewallLogObject::EVENT_RULE_REMOVED,
];
+133 -15
View File
@@ -261,33 +261,149 @@ final class FirewallRuleManager
return $rule;
}
public function disable(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
public function disableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return false;
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]
);
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_DISABLED, $rule, $actorId);
return true;
return $rule;
}
public function remove(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
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 false;
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);
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_REMOVED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
return true;
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(
@@ -391,7 +507,8 @@ final class FirewallRuleManager
private function publishLifecycleEvent(
string $name,
FirewallRuleObject $rule,
?string $actorId = null
?string $actorId = null,
array $change = []
): void
{
$event = new SecurityEvent($name, [
@@ -404,6 +521,7 @@ final class FirewallRuleManager
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
...$change,
]);
$event->setTenantId($rule->getTenantId())
->setIdentityId($actorId ?? $rule->getCreatedBy());
+2
View File
@@ -269,6 +269,7 @@ class FirewallService
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_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
@@ -285,6 +286,7 @@ class FirewallService
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
+30 -9
View File
@@ -103,23 +103,44 @@ final class SystemFirewallRuleService
);
}
public function disableRule(string $ruleId): bool
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disable(
FirewallRuleScope::system(),
$ruleId,
$this->identity->identifier()
return $this->rules->disableManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId): bool
{
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->remove(
return $this->rules->enableManual(
FirewallRuleScope::system(),
$ruleId,
$this->identity->identifier()
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->extendManual(
FirewallRuleScope::system(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
+29 -4
View File
@@ -103,16 +103,41 @@ final class TenantFirewallRuleService
);
}
public function disableRule(string $ruleId): bool
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disable($this->scope(), $ruleId, $this->identity->identifier());
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
public function removeRule(string $ruleId): bool
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->enableManual(
$this->scope(),
$ruleId,
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->remove($this->scope(), $ruleId, $this->identity->identifier());
return $this->rules->extendManual(
$this->scope(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
private function scope(): FirewallRuleScope