TenantAtlas/apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php
ahmido e222845a36
Some checks failed
Main Confidence / confidence (push) Failing after 53s
247: plans entitlements billing readiness (#287)
Automated commit and PR created by Copilot per user request.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #287
2026-04-27 17:35:04 +00:00

1460 lines
53 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Settings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class WorkspaceSettings extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'settings/workspace';
protected static ?string $title = 'Workspace settings';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?int $navigationSort = 20;
/**
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/
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<string, string>
*/
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<int, string>
*/
private const KEYVALUE_FIELDS = [
'drift_severity_mapping',
];
/**
* Findings SLA days are decomposed into individual form fields per severity.
*
* @var array<string, string>
*/
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<string, string>
*/
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<string, mixed>
*/
public array $data = [];
/**
* @var array<string, mixed>
*/
public array $workspaceOverrides = [];
/**
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
*/
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<string, array<string, mixed>>
* }
*/
public array $entitlementSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
* @var array<string, array{user_name: string, updated_at: Carbon}>
*/
public array $domainLastModified = [];
/**
* @return array<Action>
*/
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<string, mixed>
*/
private function entitlementDecision(string $key): array
{
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
return is_array($decision) ? $decision : [];
}
/**
* @param array<string, mixed> $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<string, mixed>, 1: array<string, array<int, string>>}
*/
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 "(?<severity>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<mixed> $value
* @return array<string, mixed>
*/
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<mixed> $value
* @return array<mixed>
*/
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<string, mixed> $data
* @param array<string, mixed> $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<string, mixed> $data
* @param array<string, mixed> $workspaceOverrides
* @param array<string, array{source: string, value: mixed, system_default: mixed}> $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<string, string>
*/
private static function severityOptions(): array
{
return [
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
'critical' => 'Critical',
];
}
/**
* @return array<string, string>
*/
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);
}
}
}