*/ private const SETTING_FIELDS = [ 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], 'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'], 'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'], 'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'], ]; /** * Fields rendered as Filament KeyValue components (array state, not JSON string). * * @var array */ private const KEYVALUE_FIELDS = [ 'drift_severity_mapping', ]; /** * Findings SLA days are decomposed into individual form fields per severity. * * @var array */ private const SLA_SUB_FIELDS = [ 'findings_sla_critical' => 'critical', 'findings_sla_high' => 'high', 'findings_sla_medium' => 'medium', 'findings_sla_low' => 'low', ]; public Workspace $workspace; /** * @var array */ public array $data = []; /** * @var array */ public array $workspaceOverrides = []; /** * @var array */ public array $resolvedSettings = []; /** * Per-domain "last modified" metadata: domain => {user_name, updated_at}. * * @var array */ public array $domainLastModified = []; /** * @return array */ protected function getHeaderActions(): array { return [ Action::make('save') ->label('Save') ->action(function (): void { $this->save(); }) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->tooltip(fn (): ?string => $this->currentUserCanManage() ? null : 'You do not have permission to manage workspace settings.'), ]; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.') ->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.'); } public function mount(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if ($workspaceId === null) { $this->redirect('/admin/choose-workspace'); return; } $workspace = Workspace::query()->whereKey($workspaceId)->first(); if (! $workspace instanceof Workspace) { abort(404); } $this->workspace = $workspace; $this->authorizeWorkspaceView($user); $this->loadFormState(); } public function content(Schema $schema): Schema { return $schema ->statePath('data') ->schema([ Section::make('Backup settings') ->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.')) ->schema([ TextInput::make('backup_retention_keep_last_default') ->label('Default retention keep-last') ->placeholder('Unset (uses default)') ->suffix('versions') ->hint('1 – 365') ->numeric() ->integer() ->minValue(1) ->maxValue(365) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default')) ->hintAction($this->makeResetAction('backup_retention_keep_last_default')), TextInput::make('backup_retention_min_floor') ->label('Minimum retention floor') ->placeholder('Unset (uses default)') ->suffix('versions') ->hint('1 – 365') ->numeric() ->integer() ->minValue(1) ->maxValue(365) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor')) ->hintAction($this->makeResetAction('backup_retention_min_floor')), ]), Section::make('Drift settings') ->description($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.')) ->schema([ KeyValue::make('drift_severity_mapping') ->label('Severity mapping') ->keyLabel('Finding type') ->valueLabel('Severity') ->keyPlaceholder('e.g. drift') ->valuePlaceholder('critical, high, medium, or low') ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping')) ->hintAction($this->makeResetAction('drift_severity_mapping')), ]), Section::make('Findings settings') ->key('findings_section') ->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.')) ->columns(2) ->afterHeader([ $this->makeResetAction('findings_sla_days')->label('Reset all SLA')->size('sm'), ]) ->schema([ TextInput::make('findings_sla_critical') ->label('Critical severity') ->placeholder('Unset (uses default)') ->suffix('days') ->hint('1 – 3,650') ->numeric() ->integer() ->minValue(1) ->maxValue(3650) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->slaFieldHelperText('critical')), TextInput::make('findings_sla_high') ->label('High severity') ->placeholder('Unset (uses default)') ->suffix('days') ->hint('1 – 3,650') ->numeric() ->integer() ->minValue(1) ->maxValue(3650) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->slaFieldHelperText('high')), TextInput::make('findings_sla_medium') ->label('Medium severity') ->placeholder('Unset (uses default)') ->suffix('days') ->hint('1 – 3,650') ->numeric() ->integer() ->minValue(1) ->maxValue(3650) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->slaFieldHelperText('medium')), TextInput::make('findings_sla_low') ->label('Low severity') ->placeholder('Unset (uses default)') ->suffix('days') ->hint('1 – 3,650') ->numeric() ->integer() ->minValue(1) ->maxValue(3650) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->slaFieldHelperText('low')), ]), Section::make('Operations settings') ->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.')) ->schema([ TextInput::make('operations_operation_run_retention_days') ->label('Operation run retention') ->placeholder('Unset (uses default)') ->suffix('days') ->hint('7 – 3,650') ->numeric() ->integer() ->minValue(7) ->maxValue(3650) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days')) ->hintAction($this->makeResetAction('operations_operation_run_retention_days')), TextInput::make('operations_stuck_run_threshold_minutes') ->label('Stuck run threshold') ->placeholder('Unset (uses default)') ->suffix('minutes') ->hint('0 – 10,080') ->numeric() ->integer() ->minValue(0) ->maxValue(10080) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes')) ->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')), ]), ]); } public function save(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceManage($user); $this->resetValidation(); $this->composeSlaSubFieldsIntoData(); [$normalizedValues, $validationErrors] = $this->normalizedInputValues(); if ($validationErrors !== []) { throw ValidationException::withMessages($validationErrors); } $writer = app(SettingsWriter::class); $changedSettingsCount = 0; foreach (self::SETTING_FIELDS as $field => $setting) { $incomingValue = $normalizedValues[$field] ?? null; $currentOverride = $this->workspaceOverrideForField($field); if ($incomingValue === null) { if ($currentOverride === null) { continue; } $writer->resetWorkspaceSetting( actor: $user, workspace: $this->workspace, domain: $setting['domain'], key: $setting['key'], ); $changedSettingsCount++; continue; } if ($this->valuesEqual($incomingValue, $currentOverride)) { continue; } $writer->updateWorkspaceSetting( actor: $user, workspace: $this->workspace, domain: $setting['domain'], key: $setting['key'], value: $incomingValue, ); $changedSettingsCount++; } $this->loadFormState(); Notification::make() ->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') ->success() ->send(); } public function resetSetting(string $field): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceManage($user); $setting = $this->settingForField($field); if ($this->workspaceOverrideForField($field) === null) { Notification::make() ->title('Setting already uses default') ->success() ->send(); return; } app(SettingsWriter::class)->resetWorkspaceSetting( actor: $user, workspace: $this->workspace, domain: $setting['domain'], key: $setting['key'], ); $this->loadFormState(); Notification::make() ->title('Workspace setting reset to default') ->success() ->send(); } private function loadFormState(): void { $resolver = app(SettingsResolver::class); $data = []; $workspaceOverrides = []; $resolvedSettings = []; foreach (self::SETTING_FIELDS as $field => $setting) { $resolved = $resolver->resolveDetailed( workspace: $this->workspace, domain: $setting['domain'], key: $setting['key'], ); $workspaceValue = $resolved['workspace_value']; $workspaceOverrides[$field] = $workspaceValue; $resolvedSettings[$field] = [ 'source' => $resolved['source'], 'value' => $resolved['value'], 'system_default' => $resolved['system_default'], ]; $data[$field] = $workspaceValue === null ? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null) : $this->formatValueForInput($field, $workspaceValue); } $this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings); $this->data = $data; $this->workspaceOverrides = $workspaceOverrides; $this->resolvedSettings = $resolvedSettings; $this->loadDomainLastModified(); } /** * Load per-domain "last modified" metadata from workspace_settings. */ private function loadDomainLastModified(): void { $domains = array_unique(array_column(self::SETTING_FIELDS, 'domain')); $records = WorkspaceSetting::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereIn('domain', $domains) ->whereNotNull('updated_by_user_id') ->with('updatedByUser:id,name') ->get(); $domainInfo = []; foreach ($records as $record) { /** @var WorkspaceSetting $record */ $domain = $record->domain; $updatedAt = $record->updated_at; if (! $updatedAt instanceof Carbon) { continue; } if (isset($domainInfo[$domain]) && $domainInfo[$domain]['updated_at']->gte($updatedAt)) { continue; } $user = $record->updatedByUser; $domainInfo[$domain] = [ 'user_name' => $user instanceof User ? $user->name : 'Unknown', 'updated_at' => $updatedAt, ]; } $this->domainLastModified = $domainInfo; } /** * Build a section description that appends "last modified" info when available. */ private function sectionDescription(string $domain, string $baseDescription): string { $meta = $this->domainLastModified[$domain] ?? null; if (! is_array($meta)) { return $baseDescription; } /** @var Carbon $updatedAt */ $updatedAt = $meta['updated_at']; return sprintf( '%s — Last modified by %s, %s.', $baseDescription, $meta['user_name'], $updatedAt->diffForHumans(), ); } private function makeResetAction(string $field): Action { return Action::make('reset_'.$field) ->label('Reset') ->color('danger') ->requiresConfirmation() ->action(function () use ($field): void { $this->resetSetting($field); }) ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field)) ->tooltip(function () use ($field): ?string { if (! $this->currentUserCanManage()) { return 'You do not have permission to manage workspace settings.'; } if (! $this->hasWorkspaceOverride($field)) { return 'No workspace override to reset.'; } return null; }); } private function helperTextFor(string $field): string { $resolved = $this->resolvedSettings[$field] ?? null; if (! is_array($resolved)) { return ''; } $effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null); if (! $this->hasWorkspaceOverride($field)) { return sprintf( 'Unset. Effective value: %s (%s).', $effectiveValue, $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')), ); } return sprintf('Effective value: %s.', $effectiveValue); } private function slaFieldHelperText(string $severity): string { $resolved = $this->resolvedSettings['findings_sla_days'] ?? null; if (! is_array($resolved)) { return ''; } $effectiveValue = is_array($resolved['value'] ?? null) ? (int) ($resolved['value'][$severity] ?? 0) : 0; $systemDefault = is_array($resolved['system_default'] ?? null) ? (int) ($resolved['system_default'][$severity] ?? 0) : 0; if (! $this->hasWorkspaceOverride('findings_sla_days')) { return sprintf('Default: %d days.', $systemDefault); } return sprintf('Effective: %d days.', $effectiveValue); } /** * @return array{0: array, 1: array>} */ private function normalizedInputValues(): array { $normalizedValues = []; $validationErrors = []; foreach (self::SETTING_FIELDS as $field => $_setting) { try { $normalizedValues[$field] = $this->normalizeFieldInput( field: $field, value: $this->data[$field] ?? null, ); } catch (ValidationException $exception) { $messages = []; foreach ($exception->errors() as $errorMessages) { foreach ((array) $errorMessages as $message) { $messages[] = (string) $message; } } if ($field === 'findings_sla_days') { $severityToField = array_flip(self::SLA_SUB_FIELDS); $targeted = false; foreach ($messages as $message) { if (preg_match('/include "(?critical|high|medium|low)"/i', $message, $matches) === 1) { $severity = strtolower((string) $matches['severity']); $subField = $severityToField[$severity] ?? null; if (is_string($subField)) { $validationErrors['data.'.$subField] ??= []; $validationErrors['data.'.$subField][] = $message; $targeted = true; } } } if (! $targeted) { foreach (self::SLA_SUB_FIELDS as $subField => $_severity) { $validationErrors['data.'.$subField] = $messages !== [] ? $messages : ['Invalid value.']; } } continue; } $validationErrors['data.'.$field] = $messages !== [] ? $messages : ['Invalid value.']; } } return [$normalizedValues, $validationErrors]; } private function normalizeFieldInput(string $field, mixed $value): mixed { $setting = $this->settingForField($field); if ($value === null) { return null; } if (is_string($value) && trim($value) === '') { return null; } if (is_array($value) && $value === []) { return null; } if ($setting['type'] === 'json') { $value = $this->normalizeJsonInput($value); if (in_array($field, self::KEYVALUE_FIELDS, true)) { $value = $this->normalizeKeyValueInput($value); if ($value === []) { return null; } } } $definition = $this->settingDefinition($field); $validator = Validator::make( data: ['value' => $value], rules: ['value' => $definition->rules], ); if ($validator->fails()) { throw ValidationException::withMessages($validator->errors()->toArray()); } return $definition->normalize($validator->validated()['value']); } /** * Normalize KeyValue component state. * * Filament's KeyValue UI keeps an empty row by default, which can submit as * ['' => ''] and would otherwise fail validation. We treat empty rows as unset. * * @param array $value * @return array */ private function normalizeKeyValueInput(array $value): array { $normalized = []; foreach ($value as $key => $item) { if (is_array($item) && array_key_exists('key', $item)) { $rowKey = $item['key']; $rowValue = $item['value'] ?? null; if (! is_string($rowKey)) { continue; } $trimmedKey = trim($rowKey); if ($trimmedKey === '') { continue; } if (is_string($rowValue)) { $trimmedValue = trim($rowValue); if ($trimmedValue === '') { continue; } $normalized[$trimmedKey] = $trimmedValue; continue; } if ($rowValue === null) { continue; } $normalized[$trimmedKey] = $rowValue; continue; } if (! is_string($key)) { continue; } $trimmedKey = trim($key); if ($trimmedKey === '') { continue; } if (is_string($item)) { $trimmedValue = trim($item); if ($trimmedValue === '') { continue; } $normalized[$trimmedKey] = $trimmedValue; continue; } if ($item === null) { continue; } $normalized[$trimmedKey] = $item; } return $normalized; } private function normalizeJsonInput(mixed $value): array { if (is_array($value)) { return $value; } if (! is_string($value)) { throw ValidationException::withMessages([ 'value' => ['The value must be valid JSON.'], ]); } $decoded = json_decode($value, true); if (json_last_error() !== JSON_ERROR_NONE) { throw ValidationException::withMessages([ 'value' => ['The value must be valid JSON.'], ]); } if (! is_array($decoded)) { throw ValidationException::withMessages([ 'value' => ['The value must be a JSON object.'], ]); } return $decoded; } private function valuesEqual(mixed $left, mixed $right): bool { if ($left === null || $right === null) { return $left === $right; } if (is_array($left) && is_array($right)) { return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right); } if (is_numeric($left) && is_numeric($right)) { return (int) $left === (int) $right; } return $left === $right; } private function encodeCanonicalArray(array $value): string { $encoded = json_encode($this->sortNestedArray($value)); return is_string($encoded) ? $encoded : ''; } /** * @param array $value * @return array */ private function sortNestedArray(array $value): array { foreach ($value as $key => $item) { if (! is_array($item)) { continue; } $value[$key] = $this->sortNestedArray($item); } ksort($value); return $value; } private function formatValueForInput(string $field, mixed $value): mixed { $setting = $this->settingForField($field); if ($setting['type'] === 'json') { if (! is_array($value)) { return null; } if (in_array($field, self::KEYVALUE_FIELDS, true)) { return $value; } $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : null; } return is_numeric($value) ? (int) $value : null; } private function formatValueForDisplay(string $field, mixed $value): string { $setting = $this->settingForField($field); if ($setting['type'] === 'json') { if (! is_array($value) || $value === []) { return '{}'; } $encoded = json_encode($value, JSON_UNESCAPED_SLASHES); return is_string($encoded) ? $encoded : '{}'; } return is_numeric($value) ? (string) (int) $value : 'null'; } private function sourceLabel(string $source): string { return match ($source) { 'workspace_override' => 'workspace override', 'tenant_override' => 'tenant override', default => 'system default', }; } /** * @return array{domain: string, key: string, type: 'int'|'json'} */ private function settingForField(string $field): array { if (! isset(self::SETTING_FIELDS[$field])) { throw ValidationException::withMessages([ 'data' => [sprintf('Unknown settings field: %s', $field)], ]); } return self::SETTING_FIELDS[$field]; } private function settingDefinition(string $field): SettingDefinition { $setting = $this->settingForField($field); return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']); } private function hasWorkspaceOverride(string $field): bool { return $this->workspaceOverrideForField($field) !== null; } private function workspaceOverrideForField(string $field): mixed { return $this->workspaceOverrides[$field] ?? null; } /** * Decompose the findings_sla_days JSON setting into individual SLA sub-fields. * * @param array $data * @param array $workspaceOverrides * @param array $resolvedSettings */ private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, array &$resolvedSettings): void { $slaOverride = $workspaceOverrides['findings_sla_days'] ?? null; $slaResolved = $resolvedSettings['findings_sla_days'] ?? null; foreach (self::SLA_SUB_FIELDS as $subField => $severity) { $data[$subField] = is_array($slaOverride) && isset($slaOverride[$severity]) ? (int) $slaOverride[$severity] : null; } } /** * Re-compose individual SLA sub-fields back into the findings_sla_days data key before save. */ private function composeSlaSubFieldsIntoData(): void { $values = []; $hasAnyValue = false; foreach (self::SLA_SUB_FIELDS as $subField => $severity) { $val = $this->data[$subField] ?? null; if ($val !== null && (is_string($val) ? trim($val) !== '' : true)) { $values[$severity] = (int) $val; $hasAnyValue = true; } } $this->data['findings_sla_days'] = $hasAnyValue ? $values : null; } private function currentUserCanManage(): bool { $user = auth()->user(); if (! $user instanceof User || ! $this->workspace instanceof Workspace) { return false; } /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); return $resolver->isMember($user, $this->workspace) && $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE); } private function authorizeWorkspaceView(User $user): void { /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $this->workspace)) { abort(404); } if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) { abort(403); } } private function authorizeWorkspaceManage(User $user): void { /** @var WorkspaceCapabilityResolver $resolver */ $resolver = app(WorkspaceCapabilityResolver::class); if (! $resolver->isMember($user, $this->workspace)) { abort(404); } if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) { abort(403); } } }