TenantAtlas/app/Support/Settings/SettingsRegistry.php
2026-02-25 02:45:20 +01:00

281 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Settings;
use App\Models\Finding;
final class SettingsRegistry
{
/**
* @var array<string, SettingDefinition>
*/
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<string, SettingDefinition>
*/
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<int, string>
*/
private static function supportedFindingSeverities(): array
{
return [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
];
}
/**
* @return array<string, string>
*/
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<string, int>
*/
private static function defaultFindingsSlaDays(): array
{
return [
Finding::SEVERITY_CRITICAL => 3,
Finding::SEVERITY_HIGH => 7,
Finding::SEVERITY_MEDIUM => 14,
Finding::SEVERITY_LOW => 30,
];
}
/**
* @return array<string, int>
*/
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;
}
}