feat(firewall): add audited rule lifecycle management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user