scope === FirewallRuleObject::SCOPE_SYSTEM ? $this->store->listSystemRules($activeOnly) : $this->store->listRules($scope->tenantId, $activeOnly); } public function query( FirewallRuleScope $scope, string $status = 'active', ?string $type = null, ?string $action = null, int $limit = 50, int $offset = 0 ): array { if (!in_array($status, self::QUERY_STATUSES, true)) { throw new \InvalidArgumentException('Invalid rule status filter.'); } if ($type !== null && !in_array($type, [ FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::TYPE_DEVICE, ], true)) { throw new \InvalidArgumentException('Invalid rule type filter.'); } if ($action !== null && !in_array($action, [ FirewallRuleObject::ACTION_ALLOW, FirewallRuleObject::ACTION_BLOCK, ], true)) { throw new \InvalidArgumentException('Invalid rule action filter.'); } if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) { throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.'); } return $this->store->queryRules( $scope->scope, $scope->tenantId, $status, $type, $action, $limit, $offset ); } public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject { return $this->ownedRule($scope, $ruleId); } /** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */ public function effectivePolicy(string $tenantId): array { return [ 'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'], 'system' => $this->store->listSystemRules(), 'tenant' => $this->store->listRules($tenantId), ]; } public function createManualRule( FirewallRuleScope $scope, string $type, string $action, string $value, string $reason, ?string $createdBy, ?int $durationSeconds = null, ?string $currentIp = null, bool $confirmCurrentIp = false ): FirewallRuleObject { $reason = trim($reason); if ($reason === '' || strlen($reason) > 1000) { throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.'); } if ($currentIp !== null) { $currentIp = FirewallRuleValidator::ipAddress($currentIp); } if ( !$confirmCurrentIp && $currentIp !== null && $action === FirewallRuleObject::ACTION_BLOCK && $this->matchesIp($type, $value, $currentIp) ) { throw new FirewallRuleConflictException( 'current_ip_confirmation_required', 'This rule would block your current IP address. Explicit confirmation is required.' ); } return match ([$type, $action]) { [FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] => $this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds), [FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] => $durationSeconds === null ? $this->allowIp($scope, $value, $reason, $createdBy) : throw new \InvalidArgumentException('Temporary allow rules are not supported.'), [FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] => $durationSeconds === null ? $this->blockIpRange($scope, $value, $reason, $createdBy) : throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'), [FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] => $this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds), default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'), }; } private function matchesIp(string $type, string $value, string $currentIp): bool { if ($type === FirewallRuleObject::TYPE_IP) { $value = FirewallRuleValidator::ipAddress($value); return inet_pton($value) === inet_pton($currentIp); } if ($type === FirewallRuleObject::TYPE_IP_RANGE) { return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value)); } return false; } public function blockIp( FirewallRuleScope $scope, string $ipAddress, ?string $reason, ?string $createdBy, ?int $durationSeconds = null, string $origin = self::ORIGIN_MANUAL, array $metadata = [] ): FirewallRuleObject { $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); FirewallRuleValidator::duration($durationSeconds); $existing = $this->store->findExactIpRule( $scope->tenantId, $ipAddress, FirewallRuleObject::ACTION_BLOCK, $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; } $rule = $this->create( $scope, FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK, $ipAddress, $reason ?? 'Blocked by administrator', $createdBy, $durationSeconds, $origin, $metadata ); $this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason); return $rule; } public function allowIp( FirewallRuleScope $scope, string $ipAddress, ?string $reason, ?string $createdBy, string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); $rule = $this->create( $scope, FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW, $ipAddress, $reason ?? 'Allowed by administrator', $createdBy, null, $origin ); $this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason); return $rule; } public function blockIpRange( FirewallRuleScope $scope, string $cidr, ?string $reason, ?string $createdBy, string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { return $this->create( $scope, FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK, FirewallRuleValidator::cidr($cidr), $reason ?? 'Range blocked by administrator', $createdBy, null, $origin ); } public function blockDevice( FirewallRuleScope $scope, string $fingerprint, ?string $reason, ?string $createdBy, ?int $durationSeconds = null, string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { FirewallRuleValidator::duration($durationSeconds); $fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint); $rule = $this->create( $scope, FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK, $fingerprint, $reason ?? 'Device blocked by administrator', $createdBy, $durationSeconds, $origin ); $event = new SecurityEvent( SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason], tenantId: $scope->tenantId, deviceFingerprint: $fingerprint, reason: $reason, ); $this->events->dispatch($event); return $rule; } public function disableManual( FirewallRuleScope $scope, string $ruleId, string $reason, ?string $actorId ): ?FirewallRuleObject { $reason = self::manualReason($reason); $rule = $this->ownedRule($scope, $ruleId); if (!$rule) { 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] ); } return $rule; } 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 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, ['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL] ); 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( FirewallRuleScope $scope, string $type, string $action, string $value, string $reason, ?string $createdBy, ?int $durationSeconds = null, 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}"); } $rule = (new FirewallRuleObject()) ->setScope($scope->scope) ->setTenantId($scope->tenantId) ->setType($type) ->setAction($action) ->setValue($value) ->setReason($reason) ->setCreatedBy($createdBy) ->setCreatedAt(new \DateTimeImmutable()) ->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(); $this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule); 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); return $rule && $scope->owns($rule) ? $rule : null; } private function publishIpEvent( string $name, FirewallRuleScope $scope, string $ipAddress, ?string $reason ): void { $event = new SecurityEvent( $name, ['ip' => $ipAddress, 'reason' => $reason], tenantId: $scope->tenantId, ipAddress: $ipAddress, reason: $reason, ); $this->events->dispatch($event); } private function publishLifecycleEvent( string $name, FirewallRuleObject $rule, ?string $actorId = null, array $change = [] ): void { $event = new SecurityEvent( $name, [ 'ruleId' => $rule->getId(), 'ruleScope' => $rule->getScope(), 'ruleType' => $rule->getType(), 'ruleAction' => $rule->getAction(), 'ruleValue' => $rule->getValue(), 'reason' => $rule->getReason(), 'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL, 'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM), ...($rule->getMetadata() ?? []), ...$change, ], tenantId: $rule->getTenantId(), identityId: $actorId ?? $rule->getCreatedBy(), ); $this->events->dispatch($event); } }