*/ 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'], 'baseline_severity_mapping' => ['domain' => 'baseline', 'key' => 'severity_mapping', 'type' => 'json'], 'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'], 'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'], 'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'], 'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'], 'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'], 'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'], 'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'], 'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'], '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'], ]; /** * @var array */ private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [ 'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason', 'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason', ]; /** * 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', ]; /** * Baseline severity mapping is edited as explicit fields per change type. * * @var array */ private const BASELINE_MAPPING_SUB_FIELDS = [ 'baseline_severity_missing_policy' => 'missing_policy', 'baseline_severity_different_version' => 'different_version', 'baseline_severity_unexpected_policy' => 'unexpected_policy', ]; public Workspace $workspace; /** * @var array */ public array $data = []; /** * @var array */ public array $workspaceOverrides = []; /** * @var array */ public array $resolvedSettings = []; /** * @var array{ * plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}, * decisions?: array> * } */ public array $entitlementSummary = []; /** * 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('Workspace entitlements') ->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.')) ->columns(2) ->schema([ Select::make('entitlements_plan_profile') ->label('Plan profile') ->options(app(WorkspacePlanProfileCatalog::class)->optionLabels()) ->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label'])) ->native(false) ->columnSpanFull() ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->planProfileFieldHelperText()), TextInput::make('entitlements_managed_tenant_limit_override_value') ->label('Managed tenant activation limit override') ->placeholder('Unset (uses plan profile default)') ->suffix('tenants') ->hint('0 or greater') ->numeric() ->integer() ->minValue(0) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->managedTenantLimitHelperText()) ->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')), Textarea::make('entitlements_managed_tenant_limit_override_reason') ->label('Managed tenant activation override reason') ->rows(3) ->maxLength(500) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()), Select::make('entitlements_review_pack_generation_override_value') ->label('Review pack generation override') ->options(self::booleanOptions()) ->placeholder('Unset (uses plan profile default)') ->native(false) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->reviewPackGenerationHelperText()) ->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')), Textarea::make('entitlements_review_pack_generation_override_reason') ->label('Review pack generation override reason') ->rows(3) ->maxLength(500) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()), ]), 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('Baseline settings') ->key('baseline_section') ->description($this->sectionDescription('baseline', 'Tune baseline drift severity mapping, alert threshold, and stale-finding auto-close behavior.')) ->columns(2) ->afterHeader([ $this->makeResetAction('baseline_severity_mapping')->label('Reset mapping')->size('sm'), $this->makeResetAction('baseline_alert_min_severity')->label('Reset threshold')->size('sm'), $this->makeResetAction('baseline_auto_close_enabled')->label('Reset auto-close')->size('sm'), ]) ->schema([ Select::make('baseline_severity_missing_policy') ->label('Missing policy severity') ->options(self::severityOptions()) ->placeholder('Unset (uses default)') ->native(false) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->baselineSeverityFieldHelperText('missing_policy')), Select::make('baseline_severity_different_version') ->label('Different version severity') ->options(self::severityOptions()) ->placeholder('Unset (uses default)') ->native(false) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->baselineSeverityFieldHelperText('different_version')), Select::make('baseline_severity_unexpected_policy') ->label('Unexpected policy severity') ->options(self::severityOptions()) ->placeholder('Unset (uses default)') ->native(false) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->baselineSeverityFieldHelperText('unexpected_policy')), Select::make('baseline_alert_min_severity') ->label('Minimum alert severity') ->options(self::severityOptions()) ->placeholder('Unset (uses default)') ->native(false) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('baseline_alert_min_severity')), Select::make('baseline_auto_close_enabled') ->label('Auto-close stale drift') ->options(self::booleanOptions()) ->placeholder('Unset (uses default)') ->native(false) ->disabled(fn (): bool => ! $this->currentUserCanManage()) ->helperText(fn (): string => $this->helperTextFor('baseline_auto_close_enabled')), ]), 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->composeBaselineSeveritySubFieldsIntoData(); $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 resetEntitlementOverridePair(string $field): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceManage($user); if (! $this->hasEntitlementOverridePair($field)) { Notification::make() ->title('Entitlement already uses plan profile default') ->success() ->send(); return; } $writer = app(SettingsWriter::class); $valueSetting = $this->settingForField($field); $reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field]; $reasonSetting = $this->settingForField($reasonField); if ($this->workspaceOverrideForField($field) !== null) { $writer->resetWorkspaceSetting( actor: $user, workspace: $this->workspace, domain: $valueSetting['domain'], key: $valueSetting['key'], ); } if ($this->workspaceOverrideForField($reasonField) !== null) { $writer->resetWorkspaceSetting( actor: $user, workspace: $this->workspace, domain: $reasonSetting['domain'], key: $reasonSetting['key'], ); } $this->loadFormState(); Notification::make() ->title('Workspace entitlement override reset') ->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->decomposeBaselineSeveritySubFields($data, $workspaceOverrides); $this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings); $this->data = $data; $this->workspaceOverrides = $workspaceOverrides; $this->resolvedSettings = $resolvedSettings; $this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace); $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 { if ($this->isEntitlementOverrideValueField($field)) { $this->resetEntitlementOverridePair($field); return; } $this->resetSetting($field); }) ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field)) ->tooltip(function () use ($field): ?string { if (! $this->currentUserCanManage()) { return 'You do not have permission to manage workspace settings.'; } if (! $this->canResetField($field)) { if ($this->isEntitlementOverrideValueField($field)) { return 'No workspace override to reset.'; } return 'No workspace override to reset.'; } return null; }); } private function canResetField(string $field): bool { if ($this->isEntitlementOverrideValueField($field)) { return $this->hasEntitlementOverridePair($field); } return $this->hasWorkspaceOverride($field); } private function isEntitlementOverrideValueField(string $field): bool { return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS); } private function hasEntitlementOverridePair(string $field): bool { if (! $this->isEntitlementOverrideValueField($field)) { return false; } $reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field]; return $this->workspaceOverrideForField($field) !== null || $this->workspaceOverrideForField($reasonField) !== null; } private function planProfileFieldHelperText(): string { $profile = $this->resolvedPlanProfile(); $selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile'); if (! is_string($selectedProfile) || $selectedProfile === '') { return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']); } return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']); } private function managedTenantLimitHelperText(): string { $decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT); $effectiveValue = (int) ($decision['effective_value'] ?? 0); $currentUsage = (int) ($decision['current_usage'] ?? 0); $remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0); $capacityText = $remainingCapacity < 0 ? sprintf('Over limit by %d.', abs($remainingCapacity)) : sprintf('%d remaining.', $remainingCapacity); return sprintf( 'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.', $effectiveValue, $currentUsage, $capacityText, $this->entitlementSourceLabel($decision), ); } private function managedTenantLimitReasonHelperText(): string { return $this->entitlementReasonHelperText( valueField: 'entitlements_managed_tenant_limit_override_value', key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, ); } private function reviewPackGenerationHelperText(): string { $decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED); return sprintf( 'Effective state: %s. Source: %s.', (bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled', $this->entitlementSourceLabel($decision), ); } private function reviewPackGenerationReasonHelperText(): string { return $this->entitlementReasonHelperText( valueField: 'entitlements_review_pack_generation_override_value', key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, ); } private function entitlementReasonHelperText(string $valueField, string $key): string { $decision = $this->entitlementDecision($key); $rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null; if ($this->workspaceOverrideForField($valueField) === null) { return 'Required when an explicit override value is set.'; } if ($rationale === null || $rationale === '') { return 'Required when an explicit override value is set.'; } return sprintf('Current rationale: %s', $rationale); } /** * @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} */ private function resolvedPlanProfile(): array { $profile = $this->entitlementSummary['plan_profile'] ?? null; if (is_array($profile)) { return $profile; } return app(WorkspacePlanProfileCatalog::class)->default(); } /** * @return array */ private function entitlementDecision(string $key): array { $decision = $this->entitlementSummary['decisions'][$key] ?? null; return is_array($decision) ? $decision : []; } /** * @param array $decision */ private function entitlementSourceLabel(array $decision): string { if (($decision['source'] ?? null) === 'workspace_override') { return 'workspace override'; } $planProfileLabel = $decision['plan_profile_label'] ?? null; if (is_string($planProfileLabel) && $planProfileLabel !== '') { return sprintf('%s plan profile', $planProfileLabel); } return 'plan profile default'; } 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); } private function baselineSeverityFieldHelperText(string $changeType): string { $resolved = $this->resolvedSettings['baseline_severity_mapping'] ?? null; if (! is_array($resolved)) { return ''; } $effectiveValue = is_array($resolved['value'] ?? null) ? (string) ($resolved['value'][$changeType] ?? '') : ''; $systemDefault = is_array($resolved['system_default'] ?? null) ? (string) ($resolved['system_default'][$changeType] ?? '') : ''; if (! $this->hasWorkspaceOverride('baseline_severity_mapping')) { return sprintf('Default: %s.', $systemDefault); } if ( is_array($this->workspaceOverrideForField('baseline_severity_mapping')) && array_key_exists($changeType, $this->workspaceOverrideForField('baseline_severity_mapping')) ) { return sprintf('Effective: %s.', $effectiveValue); } return sprintf('Unset. Effective value: %s (system default).', $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; } if ($field === 'baseline_severity_mapping') { foreach (array_keys(self::BASELINE_MAPPING_SUB_FIELDS) as $subField) { $validationErrors['data.'.$subField] = $messages !== [] ? $messages : ['Invalid value.']; } continue; } $validationErrors['data.'.$field] = $messages !== [] ? $messages : ['Invalid value.']; } } foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) { if (($normalizedValues[$valueField] ?? null) === null) { $normalizedValues[$reasonField] = null; continue; } if (($normalizedValues[$reasonField] ?? null) !== null) { continue; } $message = match ($valueField) { 'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.', 'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.', default => 'Override reason is required when an explicit override is set.', }; $validationErrors['data.'.$reasonField] ??= []; $validationErrors['data.'.$reasonField][] = $message; } 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; } if ($setting['type'] === 'string') { return is_string($value) ? $value : null; } if ($setting['type'] === 'bool') { if (is_bool($value)) { return $value ? '1' : '0'; } return is_numeric($value) ? (string) (int) $value : 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 : '{}'; } if ($setting['type'] === 'string') { return is_string($value) && $value !== '' ? $value : 'null'; } if ($setting['type'] === 'bool') { if (is_bool($value)) { return $value ? 'enabled' : 'disabled'; } return 'null'; } 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'|'string'|'bool'} */ 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 baseline severity mapping JSON setting into explicit change-type sub-fields. * * @param array $data * @param array $workspaceOverrides */ private function decomposeBaselineSeveritySubFields(array &$data, array &$workspaceOverrides): void { $mappingOverride = $workspaceOverrides['baseline_severity_mapping'] ?? null; foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) { $data[$subField] = is_array($mappingOverride) && isset($mappingOverride[$changeType]) ? (string) $mappingOverride[$changeType] : 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 baseline severity mapping sub-fields back into the baseline_severity_mapping data key before save. */ private function composeBaselineSeveritySubFieldsIntoData(): void { $values = []; foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) { $value = $this->data[$subField] ?? null; if (! is_string($value) || trim($value) === '') { continue; } $values[$changeType] = strtolower(trim($value)); } $this->data['baseline_severity_mapping'] = $values !== [] ? $values : 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; } /** * @return array */ private static function severityOptions(): array { return [ 'low' => 'Low', 'medium' => 'Medium', 'high' => 'High', 'critical' => 'Critical', ]; } /** * @return array */ private static function booleanOptions(): array { return [ '1' => 'Enabled', '0' => 'Disabled', ]; } 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); } } }