*/ private array $definitions; public function __construct() { $this->definitions = []; $this->register(new SettingDefinition( domain: 'backup', key: 'retention_keep_last_default', type: 'int', systemDefault: 30, rules: ['required', 'integer', 'min:1', 'max:365'], normalizer: static fn (mixed $value): int => (int) $value, )); $this->register(new SettingDefinition( domain: 'backup', key: 'retention_min_floor', type: 'int', systemDefault: 1, rules: ['required', 'integer', 'min:1', 'max:365'], normalizer: static fn (mixed $value): int => (int) $value, )); $this->register(new SettingDefinition( domain: 'drift', key: 'severity_mapping', type: 'json', systemDefault: [], rules: [ 'required', 'array', static function (string $attribute, mixed $value, \Closure $fail): void { if (! is_array($value)) { $fail('The severity mapping must be a JSON object.'); return; } foreach ($value as $findingType => $severity) { if (! is_string($findingType) || trim($findingType) === '') { $fail('Each severity mapping key must be a non-empty string.'); return; } if (! is_string($severity)) { $fail(sprintf('Severity for "%s" must be a string.', $findingType)); return; } $normalizedSeverity = strtolower($severity); if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) { $fail(sprintf( 'Severity for "%s" must be one of: %s.', $findingType, implode(', ', self::supportedFindingSeverities()), )); return; } } }, ], normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value), )); $this->register(new SettingDefinition( domain: 'findings', key: 'sla_days', type: 'json', systemDefault: self::defaultFindingsSlaDays(), rules: [ 'required', 'array', static function (string $attribute, mixed $value, \Closure $fail): void { if (! is_array($value)) { $fail('The findings SLA days setting must be a JSON object.'); return; } $supportedSeverities = self::supportedFindingSeverities(); $supportedMap = array_fill_keys($supportedSeverities, true); foreach ($value as $severity => $days) { if (! is_string($severity)) { $fail('Each findings SLA key must be a severity string.'); return; } $normalizedSeverity = strtolower($severity); if (! isset($supportedMap[$normalizedSeverity])) { $fail(sprintf( 'Unsupported findings SLA severity "%s". Expected only: %s.', $severity, implode(', ', $supportedSeverities), )); return; } $normalizedDays = filter_var($days, FILTER_VALIDATE_INT); if ($normalizedDays === false || $normalizedDays < 1 || $normalizedDays > 3650) { $fail(sprintf( 'Findings SLA days for "%s" must be an integer between 1 and 3650.', $normalizedSeverity, )); return; } } }, ], normalizer: static fn (mixed $value): array => self::normalizeFindingsSlaDays($value), )); $this->register(new SettingDefinition( domain: 'operations', key: 'operation_run_retention_days', type: 'int', systemDefault: 90, rules: ['required', 'integer', 'min:7', 'max:3650'], normalizer: static fn (mixed $value): int => (int) $value, )); $this->register(new SettingDefinition( domain: 'operations', key: 'stuck_run_threshold_minutes', type: 'int', systemDefault: 0, rules: ['required', 'integer', 'min:0', 'max:10080'], normalizer: static fn (mixed $value): int => (int) $value, )); } /** * @return array */ public function all(): array { return $this->definitions; } public function find(string $domain, string $key): ?SettingDefinition { return $this->definitions[$this->cacheKey($domain, $key)] ?? null; } public function require(string $domain, string $key): SettingDefinition { $definition = $this->find($domain, $key); if ($definition instanceof SettingDefinition) { return $definition; } throw new \InvalidArgumentException(sprintf('Unknown setting key: %s.%s', $domain, $key)); } private function register(SettingDefinition $definition): void { $this->definitions[$this->cacheKey($definition->domain, $definition->key)] = $definition; } private function cacheKey(string $domain, string $key): string { return $domain.'.'.$key; } /** * @return array */ private static function supportedFindingSeverities(): array { return [ Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL, ]; } /** * @return array */ private static function normalizeSeverityMapping(mixed $value): array { if (! is_array($value)) { return []; } $normalized = []; foreach ($value as $findingType => $severity) { if (! is_string($findingType) || trim($findingType) === '' || ! is_string($severity)) { continue; } $normalized[$findingType] = strtolower($severity); } ksort($normalized); return $normalized; } /** * @return array */ private static function defaultFindingsSlaDays(): array { return [ Finding::SEVERITY_CRITICAL => 3, Finding::SEVERITY_HIGH => 7, Finding::SEVERITY_MEDIUM => 14, Finding::SEVERITY_LOW => 30, ]; } /** * @return array */ private static function normalizeFindingsSlaDays(mixed $value): array { if (! is_array($value)) { return self::defaultFindingsSlaDays(); } $normalized = []; foreach ($value as $severity => $days) { if (! is_string($severity)) { continue; } $normalizedSeverity = strtolower($severity); if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) { continue; } $normalizedDays = filter_var($days, FILTER_VALIDATE_INT); if ($normalizedDays === false) { continue; } $normalized[$normalizedSeverity] = (int) $normalizedDays; } $ordered = []; foreach (self::defaultFindingsSlaDays() as $severity => $_default) { if (array_key_exists($severity, $normalized)) { $ordered[$severity] = $normalized[$severity]; } } return $ordered; } }