feat: workspace settings slices (backup, drift, operations)

This commit is contained in:
Ahmed Darrazi 2026-02-16 04:16:37 +01:00
parent d5d8d60017
commit 4e2d7e70af
15 changed files with 1202 additions and 90 deletions

View File

@ -10,17 +10,21 @@
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities; 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\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
@ -40,6 +44,17 @@ class WorkspaceSettings extends Page
protected static ?int $navigationSort = 20; protected static ?int $navigationSort = 20;
/**
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
*/
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'],
'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'],
];
public Workspace $workspace; public Workspace $workspace;
/** /**
@ -47,6 +62,16 @@ class WorkspaceSettings extends Page
*/ */
public array $data = []; public array $data = [];
/**
* @var array<string, mixed>
*/
public array $workspaceOverrides = [];
/**
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
*/
public array $resolvedSettings = [];
/** /**
* @return array<Action> * @return array<Action>
*/ */
@ -62,24 +87,13 @@ protected function getHeaderActions(): array
->tooltip(fn (): ?string => $this->currentUserCanManage() ->tooltip(fn (): ?string => $this->currentUserCanManage()
? null ? null
: 'You do not have permission to manage workspace settings.'), : 'You do not have permission to manage workspace settings.'),
Action::make('reset')
->label('Reset to default')
->color('danger')
->requiresConfirmation()
->action(function (): void {
$this->resetSetting();
})
->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 public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide save and reset controls for the settings form.') ->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::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::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::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
@ -125,12 +139,59 @@ public function content(Schema $schema): Schema
->schema([ ->schema([
TextInput::make('backup_retention_keep_last_default') TextInput::make('backup_retention_keep_last_default')
->label('Default retention keep-last') ->label('Default retention keep-last')
->placeholder('Unset (uses default)')
->numeric() ->numeric()
->integer() ->integer()
->minValue(1) ->minValue(1)
->required() ->maxValue(365)
->disabled(fn (): bool => ! $this->currentUserCanManage()) ->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText('Fallback value for backup schedule retention when retention_keep_last is empty.'), ->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)')
->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('Map finding types to severities as JSON.')
->schema([
Textarea::make('drift_severity_mapping')
->label('Severity mapping (JSON object)')
->rows(8)
->placeholder("{\n \"drift\": \"critical\"\n}")
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
->hintAction($this->makeResetAction('drift_severity_mapping')),
]),
Section::make('Operations settings')
->description('Workspace controls for operations retention and thresholds.')
->schema([
TextInput::make('operations_operation_run_retention_days')
->label('Operation run retention (days)')
->placeholder('Unset (uses default)')
->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 (minutes)')
->placeholder('Unset (uses default)')
->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')),
]), ]),
]); ]);
} }
@ -145,35 +206,60 @@ public function save(): void
$this->authorizeWorkspaceManage($user); $this->authorizeWorkspaceManage($user);
try { [$normalizedValues, $validationErrors] = $this->normalizedInputValues();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: 'backup',
key: 'retention_keep_last_default',
value: $this->data['backup_retention_keep_last_default'] ?? null,
);
} catch (ValidationException $exception) {
$errors = $exception->errors();
if (isset($errors['value'])) { if ($validationErrors !== []) {
throw ValidationException::withMessages([ throw ValidationException::withMessages($validationErrors);
'data.backup_retention_keep_last_default' => $errors['value'],
]);
} }
throw $exception; $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(); $this->loadFormState();
Notification::make() Notification::make()
->title('Workspace settings saved') ->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
->success() ->success()
->send(); ->send();
} }
public function resetSetting(): void public function resetSetting(string $field): void
{ {
$user = auth()->user(); $user = auth()->user();
@ -183,11 +269,22 @@ public function resetSetting(): void
$this->authorizeWorkspaceManage($user); $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( app(SettingsWriter::class)->resetWorkspaceSetting(
actor: $user, actor: $user,
workspace: $this->workspace, workspace: $this->workspace,
domain: 'backup', domain: $setting['domain'],
key: 'retention_keep_last_default', key: $setting['key'],
); );
$this->loadFormState(); $this->loadFormState();
@ -200,15 +297,295 @@ public function resetSetting(): void
private function loadFormState(): void private function loadFormState(): void
{ {
$resolvedValue = app(SettingsResolver::class)->resolveValue( $resolver = app(SettingsResolver::class);
$data = [];
$workspaceOverrides = [];
$resolvedSettings = [];
foreach (self::SETTING_FIELDS as $field => $setting) {
$resolved = $resolver->resolveDetailed(
workspace: $this->workspace, workspace: $this->workspace,
domain: 'backup', domain: $setting['domain'],
key: 'retention_keep_last_default', key: $setting['key'],
); );
$this->data = [ $workspaceValue = $resolved['workspace_value'];
'backup_retention_keep_last_default' => is_numeric($resolvedValue) ? (int) $resolvedValue : 30,
$workspaceOverrides[$field] = $workspaceValue;
$resolvedSettings[$field] = [
'source' => $resolved['source'],
'value' => $resolved['value'],
'system_default' => $resolved['system_default'],
]; ];
$data[$field] = $workspaceValue === null
? null
: $this->formatValueForInput($field, $workspaceValue);
}
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
}
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);
}
/**
* @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;
}
}
$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 ($setting['type'] === 'json') {
$value = $this->normalizeJsonInput($value);
}
$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']);
}
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;
}
$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
{
$setting = $this->settingForField($field);
$resolved = app(SettingsResolver::class)->resolveDetailed(
workspace: $this->workspace,
domain: $setting['domain'],
key: $setting['key'],
);
return $resolved['workspace_value'];
} }
private function currentUserCanManage(): bool private function currentUserCanManage(): bool

View File

@ -46,17 +46,31 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
]); ]);
$keepLast = $schedule->retention_keep_last; $keepLast = $schedule->retention_keep_last;
$retentionFloor = 1;
if ($schedule->tenant->workspace instanceof \App\Models\Workspace) {
$resolvedFloor = $settingsResolver->resolveValue(
workspace: $schedule->tenant->workspace,
domain: 'backup',
key: 'retention_min_floor',
tenant: $schedule->tenant,
);
if (is_numeric($resolvedFloor)) {
$retentionFloor = max(1, (int) $resolvedFloor);
}
}
if ($keepLast === null && $schedule->tenant->workspace instanceof \App\Models\Workspace) { if ($keepLast === null && $schedule->tenant->workspace instanceof \App\Models\Workspace) {
$resolved = $settingsResolver->resolveValue( $resolvedDefault = $settingsResolver->resolveValue(
workspace: $schedule->tenant->workspace, workspace: $schedule->tenant->workspace,
domain: 'backup', domain: 'backup',
key: 'retention_keep_last_default', key: 'retention_keep_last_default',
tenant: $schedule->tenant, tenant: $schedule->tenant,
); );
if (is_numeric($resolved)) { if (is_numeric($resolvedDefault)) {
$keepLast = (int) $resolved; $keepLast = (int) $resolvedDefault;
} }
} }
@ -66,8 +80,8 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
$keepLast = (int) $keepLast; $keepLast = (int) $keepLast;
if ($keepLast < 1) { if ($keepLast < $retentionFloor) {
$keepLast = 1; $keepLast = $retentionFloor;
} }
/** @var Collection<int, int> $keepBackupSetIds */ /** @var Collection<int, int> $keepBackupSetIds */
@ -140,6 +154,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
context: [ context: [
'metadata' => [ 'metadata' => [
'keep_last' => $keepLast, 'keep_last' => $keepLast,
'retention_floor' => $retentionFloor,
'deleted_backup_sets' => $deletedCount, 'deleted_backup_sets' => $deletedCount,
'operation_run_id' => (int) $operationRun->getKey(), 'operation_run_id' => (int) $operationRun->getKey(),
], ],

View File

@ -3,6 +3,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Settings\SettingsResolver;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -13,19 +15,50 @@ class PruneOldOperationRunsJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $retentionDays = 90
) {}
/** /**
* Execute the job. * Execute the job.
*/ */
public function handle(): void public function handle(SettingsResolver $settingsResolver): void
{ {
OperationRun::where('created_at', '<', now()->subDays($this->retentionDays)) $workspaceIds = OperationRun::query()
->whereNotNull('workspace_id')
->distinct()
->orderBy('workspace_id')
->pluck('workspace_id')
->filter(fn ($workspaceId): bool => is_numeric($workspaceId))
->map(fn ($workspaceId): int => (int) $workspaceId)
->values();
if ($workspaceIds->isEmpty()) {
return;
}
$workspaces = Workspace::query()
->whereIn('id', $workspaceIds->all())
->get()
->keyBy(fn (Workspace $workspace): int => (int) $workspace->getKey());
foreach ($workspaceIds as $workspaceId) {
$workspace = $workspaces->get($workspaceId);
if (! $workspace instanceof Workspace) {
continue;
}
$resolvedRetentionDays = $settingsResolver->resolveValue(
workspace: $workspace,
domain: 'operations',
key: 'operation_run_retention_days',
);
$retentionDays = is_numeric($resolvedRetentionDays)
? max(7, min(3650, (int) $resolvedRetentionDays))
: 90;
OperationRun::query()
->where('workspace_id', $workspaceId)
->where('created_at', '<', now()->subDays($retentionDays))
->delete(); ->delete();
} }
}
} }

View File

@ -22,6 +22,8 @@ class Finding extends Model
public const string SEVERITY_HIGH = 'high'; public const string SEVERITY_HIGH = 'high';
public const string SEVERITY_CRITICAL = 'critical';
public const string STATUS_NEW = 'new'; public const string STATUS_NEW = 'new';
public const string STATUS_ACKNOWLEDGED = 'acknowledged'; public const string STATUS_ACKNOWLEDGED = 'acknowledged';

View File

@ -16,6 +16,10 @@ class TenantSetting extends Model
protected $guarded = []; protected $guarded = [];
protected $casts = [
'value' => 'array',
];
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
return $this->belongsTo(Workspace::class); return $this->belongsTo(Workspace::class);

View File

@ -14,6 +14,10 @@ class WorkspaceSetting extends Model
protected $guarded = []; protected $guarded = [];
protected $casts = [
'value' => 'array',
];
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
return $this->belongsTo(Workspace::class); return $this->belongsTo(Workspace::class);

View File

@ -7,8 +7,10 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Settings\SettingsResolver;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use RuntimeException; use RuntimeException;
@ -19,6 +21,7 @@ public function __construct(
private readonly DriftEvidence $evidence, private readonly DriftEvidence $evidence,
private readonly SettingsNormalizer $settingsNormalizer, private readonly SettingsNormalizer $settingsNormalizer,
private readonly ScopeTagsNormalizer $scopeTagsNormalizer, private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
private readonly SettingsResolver $settingsResolver,
) {} ) {}
public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
@ -38,12 +41,13 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes))); $policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
$created = 0; $created = 0;
$resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT);
Policy::query() Policy::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->whereIn('policy_type', $policyTypes) ->whereIn('policy_type', $policyTypes)
->orderBy('id') ->orderBy('id')
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$created): void { ->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, &$created): void {
foreach ($policies as $policy) { foreach ($policies as $policy) {
if (! $policy instanceof Policy) { if (! $policy instanceof Policy) {
continue; continue;
@ -118,7 +122,7 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c
'current_operation_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
'subject_external_id' => (string) $policy->external_id, 'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM, 'severity' => $resolvedSeverity,
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
]); ]);
@ -191,7 +195,7 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c
'current_operation_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'assignment', 'subject_type' => 'assignment',
'subject_external_id' => (string) $policy->external_id, 'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM, 'severity' => $resolvedSeverity,
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
]); ]);
@ -266,7 +270,7 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c
'current_operation_run_id' => $current->getKey(), 'current_operation_run_id' => $current->getKey(),
'subject_type' => 'scope_tag', 'subject_type' => 'scope_tag',
'subject_external_id' => (string) $policy->external_id, 'subject_external_id' => (string) $policy->external_id,
'severity' => Finding::SEVERITY_MEDIUM, 'severity' => $resolvedSeverity,
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
]); ]);
@ -302,4 +306,61 @@ private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersio
->latest('captured_at') ->latest('captured_at')
->first(); ->first();
} }
private function resolveSeverityForFindingType(Tenant $tenant, string $findingType): string
{
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace && is_numeric($tenant->workspace_id)) {
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
}
if (! $workspace instanceof Workspace) {
return Finding::SEVERITY_MEDIUM;
}
$resolved = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: 'drift',
key: 'severity_mapping',
tenant: $tenant,
);
if (! is_array($resolved)) {
return Finding::SEVERITY_MEDIUM;
}
foreach ($resolved as $mappedFindingType => $mappedSeverity) {
if (! is_string($mappedFindingType) || ! is_string($mappedSeverity)) {
continue;
}
if ($mappedFindingType !== $findingType) {
continue;
}
$normalizedSeverity = strtolower($mappedSeverity);
if (in_array($normalizedSeverity, $this->supportedSeverities(), true)) {
return $normalizedSeverity;
}
break;
}
return Finding::SEVERITY_MEDIUM;
}
/**
* @return array<int, string>
*/
private function supportedSeverities(): array
{
return [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
];
}
} }

View File

@ -17,6 +17,7 @@ public function spec(mixed $value): BadgeSpec
Finding::SEVERITY_LOW => new BadgeSpec('Low', 'gray', 'heroicon-m-minus-circle'), Finding::SEVERITY_LOW => new BadgeSpec('Low', 'gray', 'heroicon-m-minus-circle'),
Finding::SEVERITY_MEDIUM => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'), Finding::SEVERITY_MEDIUM => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'),
Finding::SEVERITY_HIGH => new BadgeSpec('High', 'danger', 'heroicon-m-x-circle'), Finding::SEVERITY_HIGH => new BadgeSpec('High', 'danger', 'heroicon-m-x-circle'),
Finding::SEVERITY_CRITICAL => new BadgeSpec('Critical', 'danger', 'heroicon-m-fire'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; };
} }

View File

@ -4,6 +4,8 @@
namespace App\Support\Settings; namespace App\Support\Settings;
use App\Models\Finding;
final class SettingsRegistry final class SettingsRegistry
{ {
/** /**
@ -20,7 +22,79 @@ public function __construct()
key: 'retention_keep_last_default', key: 'retention_keep_last_default',
type: 'int', type: 'int',
systemDefault: 30, systemDefault: 30,
rules: ['required', 'integer', 'min:1', 'max:3650'], rules: ['required', 'integer', 'min:1', 'max:365'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'backup',
key: 'retention_min_floor',
type: 'int',
systemDefault: 1,
rules: ['required', 'integer', 'min:1', 'max:365'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'drift',
key: 'severity_mapping',
type: 'json',
systemDefault: [],
rules: [
'required',
'array',
static function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_array($value)) {
$fail('The severity mapping must be a JSON object.');
return;
}
foreach ($value as $findingType => $severity) {
if (! is_string($findingType) || trim($findingType) === '') {
$fail('Each severity mapping key must be a non-empty string.');
return;
}
if (! is_string($severity)) {
$fail(sprintf('Severity for "%s" must be a string.', $findingType));
return;
}
$normalizedSeverity = strtolower($severity);
if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) {
$fail(sprintf(
'Severity for "%s" must be one of: %s.',
$findingType,
implode(', ', self::supportedFindingSeverities()),
));
return;
}
}
},
],
normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value),
));
$this->register(new SettingDefinition(
domain: 'operations',
key: 'operation_run_retention_days',
type: 'int',
systemDefault: 90,
rules: ['required', 'integer', 'min:7', 'max:3650'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'operations',
key: 'stuck_run_threshold_minutes',
type: 'int',
systemDefault: 0,
rules: ['required', 'integer', 'min:0', 'max:10080'],
normalizer: static fn (mixed $value): int => (int) $value, normalizer: static fn (mixed $value): int => (int) $value,
)); ));
} }
@ -58,4 +132,41 @@ private function cacheKey(string $domain, string $key): string
{ {
return $domain.'.'.$key; return $domain.'.'.$key;
} }
/**
* @return array<int, string>
*/
private static function supportedFindingSeverities(): array
{
return [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
];
}
/**
* @return array<string, string>
*/
private static function normalizeSeverityMapping(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $findingType => $severity) {
if (! is_string($findingType) || trim($findingType) === '' || ! is_string($severity)) {
continue;
}
$normalized[$findingType] = strtolower($severity);
}
ksort($normalized);
return $normalized;
}
} }

View File

@ -2,8 +2,11 @@
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule; use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Jobs\ApplyBackupScheduleRetentionJob;
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\WorkspaceSetting;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@ -204,3 +207,137 @@ function makeBackupScheduleForLifecycle(\App\Models\Tenant $tenant, array $attri
expect((bool) $active->fresh()->trashed())->toBeFalse(); expect((bool) $active->fresh()->trashed())->toBeFalse();
expect((bool) BackupSchedule::withTrashed()->findOrFail($archived->id)->trashed())->toBeTrue(); expect((bool) BackupSchedule::withTrashed()->findOrFail($archived->id)->trashed())->toBeTrue();
}); });
it('clamps resolved workspace retention default to workspace retention floor', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
WorkspaceSetting::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'domain' => 'backup',
'key' => 'retention_keep_last_default',
'value' => 2,
'updated_by_user_id' => (int) $user->getKey(),
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'domain' => 'backup',
'key' => 'retention_min_floor',
'value' => 4,
'updated_by_user_id' => (int) $user->getKey(),
]);
$schedule = makeBackupScheduleForLifecycle($tenant, [
'name' => 'Floor clamp default',
'retention_keep_last' => null,
]);
$sets = collect(range(1, 6))->map(function (int $index) use ($tenant): BackupSet {
return BackupSet::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Clamp Default '.$index,
'status' => 'completed',
'item_count' => 0,
'completed_at' => now()->subMinutes(12 - $index),
]);
});
$completedAt = now('UTC')->startOfMinute()->subMinutes(8);
foreach ($sets as $set) {
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_run',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', 'lifecycle-floor-default:'.$schedule->id.':'.$set->id),
'summary_counts' => [],
'failure_summary' => [],
'context' => [
'backup_schedule_id' => (int) $schedule->id,
'backup_set_id' => (int) $set->id,
],
'started_at' => $completedAt,
'completed_at' => $completedAt,
]);
$completedAt = $completedAt->addMinute();
}
ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->id);
$keptIds = BackupSet::query()
->where('tenant_id', (int) $tenant->getKey())
->whereNull('deleted_at')
->orderBy('id')
->pluck('id')
->all();
expect($keptIds)->toHaveCount(4);
});
it('clamps schedule retention override when override is below workspace retention floor', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
WorkspaceSetting::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'domain' => 'backup',
'key' => 'retention_min_floor',
'value' => 3,
'updated_by_user_id' => (int) $user->getKey(),
]);
$schedule = makeBackupScheduleForLifecycle($tenant, [
'name' => 'Floor clamp override',
'retention_keep_last' => 1,
]);
$sets = collect(range(1, 5))->map(function (int $index) use ($tenant): BackupSet {
return BackupSet::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Clamp Override '.$index,
'status' => 'completed',
'item_count' => 0,
'completed_at' => now()->subMinutes(10 - $index),
]);
});
$completedAt = now('UTC')->startOfMinute()->subMinutes(6);
foreach ($sets as $set) {
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'System',
'type' => 'backup_schedule_run',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', 'lifecycle-floor-override:'.$schedule->id.':'.$set->id),
'summary_counts' => [],
'failure_summary' => [],
'context' => [
'backup_schedule_id' => (int) $schedule->id,
'backup_set_id' => (int) $set->id,
],
'started_at' => $completedAt,
'completed_at' => $completedAt,
]);
$completedAt = $completedAt->addMinute();
}
ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->id);
$keptIds = BackupSet::query()
->where('tenant_id', (int) $tenant->getKey())
->whereNull('deleted_at')
->orderBy('id')
->pluck('id')
->all();
expect($keptIds)->toHaveCount(3);
});

View File

@ -3,12 +3,13 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\WorkspaceSetting;
use App\Services\Drift\DriftFindingGenerator; use App\Services\Drift\DriftFindingGenerator;
test('it creates a drift finding when policy snapshot changes', function () { test('uses medium severity for drift findings when no severity mapping exists', function () {
[, $tenant] = createUserWithTenant(role: 'manager'); [, $tenant] = createUserWithTenant(role: 'manager');
$scopeKey = hash('sha256', 'scope-policy-snapshot'); $scopeKey = hash('sha256', 'scope-policy-snapshot-default-severity');
$baseline = createInventorySyncOperationRun($tenant, [ $baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey, 'selection_hash' => $scopeKey,
@ -60,6 +61,7 @@
->first(); ->first();
expect($finding)->not->toBeNull(); expect($finding)->not->toBeNull();
expect($finding->severity)->toBe(Finding::SEVERITY_MEDIUM);
expect($finding->subject_external_id)->toBe($policy->external_id); expect($finding->subject_external_id)->toBe($policy->external_id);
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified'); expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
expect($finding->evidence_jsonb) expect($finding->evidence_jsonb)
@ -70,3 +72,69 @@
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash') ->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash')
->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash'); ->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash');
}); });
test('applies workspace drift severity mapping when configured', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
WorkspaceSetting::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'domain' => 'drift',
'key' => 'severity_mapping',
'value' => ['drift' => 'critical'],
'updated_by_user_id' => (int) $user->getKey(),
]);
$scopeKey = hash('sha256', 'scope-policy-snapshot-mapped-severity');
$baseline = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
'status' => 'success',
'finished_at' => now()->subDays(2),
]);
$current = createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey,
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
'status' => 'success',
'finished_at' => now()->subDay(),
]);
$policy = Policy::factory()->for($tenant)->create([
'policy_type' => 'deviceConfiguration',
'platform' => 'windows10',
]);
PolicyVersion::factory()->for($tenant)->for($policy)->create([
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $baseline->finished_at->copy()->subMinute(),
'snapshot' => ['customSettingFoo' => 'Old value'],
'assignments' => [],
]);
PolicyVersion::factory()->for($tenant)->for($policy)->create([
'version_number' => 2,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $current->finished_at->copy()->subMinute(),
'snapshot' => ['customSettingFoo' => 'New value'],
'assignments' => [],
]);
$generator = app(DriftFindingGenerator::class);
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
expect($created)->toBe(1);
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('scope_key', $scopeKey)
->where('subject_type', 'policy')
->first();
expect($finding)->not->toBeNull();
expect($finding->severity)->toBe(Finding::SEVERITY_CRITICAL);
});

View File

@ -1,6 +1,9 @@
<?php <?php
use App\Jobs\PruneOldOperationRunsJob; use App\Jobs\PruneOldOperationRunsJob;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
it('schedules pruning job daily without overlapping', function () { it('schedules pruning job daily without overlapping', function () {
@ -13,3 +16,58 @@
expect($event)->not->toBeNull(); expect($event)->not->toBeNull();
expect($event->withoutOverlapping)->toBeTrue(); expect($event->withoutOverlapping)->toBeTrue();
}); });
it('prunes operation runs using per-workspace retention settings', function () {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'domain' => 'operations',
'key' => 'operation_run_retention_days',
'value' => 30,
'updated_by_user_id' => null,
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'domain' => 'operations',
'key' => 'operation_run_retention_days',
'value' => 120,
'updated_by_user_id' => null,
]);
$createRun = function (Workspace $workspace, int $ageDays, string $identitySuffix): OperationRun {
return OperationRun::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => null,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'maintenance',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => hash('sha256', 'prune-workspace-'.$workspace->getKey().'-'.$identitySuffix),
'summary_counts' => [],
'failure_summary' => [],
'context' => [],
'started_at' => now()->subDays($ageDays),
'completed_at' => now()->subDays($ageDays),
'created_at' => now()->subDays($ageDays),
'updated_at' => now()->subDays($ageDays),
]);
};
$workspaceAOldRun = $createRun($workspaceA, 45, 'old');
$workspaceANewRun = $createRun($workspaceA, 10, 'new');
$workspaceBOldRun = $createRun($workspaceB, 140, 'old');
$workspaceBRecentRun = $createRun($workspaceB, 100, 'recent');
PruneOldOperationRunsJob::dispatchSync();
expect(OperationRun::query()->whereKey((int) $workspaceAOldRun->getKey())->exists())->toBeFalse();
expect(OperationRun::query()->whereKey((int) $workspaceANewRun->getKey())->exists())->toBeTrue();
expect(OperationRun::query()->whereKey((int) $workspaceBOldRun->getKey())->exists())->toBeFalse();
expect(OperationRun::query()->whereKey((int) $workspaceBRecentRun->getKey())->exists())->toBeTrue();
});

View File

@ -10,11 +10,16 @@
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Livewire\Livewire; use Livewire\Livewire;
it('allows workspace managers to save and reset the workspace retention default', function (): void { /**
* @return array{0: Workspace, 1: User}
*/
function workspaceManagerUser(): array
{
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -26,17 +31,34 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
it('allows workspace managers to save and reset workspace slice settings', function (): void {
[$workspace, $user] = workspaceManagerUser();
$this->actingAs($user) $this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin')) ->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful(); ->assertSuccessful();
$component = Livewire::actingAs($user) $component = Livewire::actingAs($user)
->test(WorkspaceSettings::class) ->test(WorkspaceSettings::class)
->assertSet('data.backup_retention_keep_last_default', 30) ->assertSet('data.backup_retention_keep_last_default', null)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', null)
->assertSet('data.operations_operation_run_retention_days', null)
->assertSet('data.operations_stuck_run_threshold_minutes', null)
->set('data.backup_retention_keep_last_default', 55) ->set('data.backup_retention_keep_last_default', 55)
->set('data.backup_retention_min_floor', 12)
->set('data.drift_severity_mapping', '{"drift":"critical"}')
->set('data.operations_operation_run_retention_days', 120)
->set('data.operations_stuck_run_threshold_minutes', 60)
->callAction('save') ->callAction('save')
->assertHasNoErrors() ->assertHasNoErrors()
->assertSet('data.backup_retention_keep_last_default', 55); ->assertSet('data.backup_retention_keep_last_default', 55)
->assertSet('data.backup_retention_min_floor', 12)
->assertSet('data.operations_operation_run_retention_days', 120)
->assertSet('data.operations_stuck_run_threshold_minutes', 60);
expect(WorkspaceSetting::query() expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey()) ->where('workspace_id', (int) $workspace->getKey())
@ -47,10 +69,23 @@
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
->toBe(55); ->toBe(55);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
->toBe(12);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping'))
->toBe(['drift' => 'critical']);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'operation_run_retention_days'))
->toBe(120);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'stuck_run_threshold_minutes'))
->toBe(60);
$component $component
->callAction('reset') ->set('data.backup_retention_keep_last_default', '')
->callAction('save')
->assertHasNoErrors() ->assertHasNoErrors()
->assertSet('data.backup_retention_keep_last_default', 30); ->assertSet('data.backup_retention_keep_last_default', null);
expect(WorkspaceSetting::query() expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey()) ->where('workspace_id', (int) $workspace->getKey())
@ -58,19 +93,21 @@
->where('key', 'retention_keep_last_default') ->where('key', 'retention_keep_last_default')
->exists())->toBeFalse(); ->exists())->toBeFalse();
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) $component
->toBe(30); ->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.operations_operation_run_retention_days', null);
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'operations')
->where('key', 'operation_run_retention_days')
->exists())->toBeFalse();
}); });
it('rejects unknown setting keys and does not persist or audit changes', function (): void { it('rejects unknown setting keys and does not persist or audit changes', function (): void {
$workspace = Workspace::factory()->create(); [$workspace, $user] = workspaceManagerUser();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
$writer = app(SettingsWriter::class); $writer = app(SettingsWriter::class);
@ -81,15 +118,8 @@
expect(AuditLog::query()->count())->toBe(0); expect(AuditLog::query()->count())->toBe(0);
}); });
it('rejects invalid setting values and does not persist or audit changes', function (): void { it('rejects invalid backup settings bounds and does not persist or audit changes', function (): void {
$workspace = Workspace::factory()->create(); [$workspace, $user] = workspaceManagerUser();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
$writer = app(SettingsWriter::class); $writer = app(SettingsWriter::class);
@ -99,6 +129,201 @@
expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 0)) expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 0))
->toThrow(ValidationException::class); ->toThrow(ValidationException::class);
expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 366))
->toThrow(ValidationException::class);
expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_min_floor', 0))
->toThrow(ValidationException::class);
expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_min_floor', 366))
->toThrow(ValidationException::class);
expect(WorkspaceSetting::query()->count())->toBe(0); expect(WorkspaceSetting::query()->count())->toBe(0);
expect(AuditLog::query()->count())->toBe(0); expect(AuditLog::query()->count())->toBe(0);
}); });
it('rejects malformed drift severity mapping JSON on save', function (): void {
[$workspace, $user] = workspaceManagerUser();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.drift_severity_mapping', '{invalid-json}')
->callAction('save')
->assertHasErrors(['data.drift_severity_mapping']);
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'drift')
->where('key', 'severity_mapping')
->exists())->toBeFalse();
});
it('rejects invalid drift severity mapping shape and values', function (): void {
[$workspace, $user] = workspaceManagerUser();
$writer = app(SettingsWriter::class);
expect(fn () => $writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'drift',
key: 'severity_mapping',
value: [123 => 'low'],
))->toThrow(ValidationException::class);
expect(fn () => $writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'drift',
key: 'severity_mapping',
value: ['drift' => 'urgent'],
))->toThrow(ValidationException::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'drift',
key: 'severity_mapping',
value: ['drift' => 'CRITICAL'],
);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping'))
->toBe(['drift' => 'critical']);
});
it('saves and resets operations settings keys', function (): void {
[$workspace, $user] = workspaceManagerUser();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.operations_operation_run_retention_days', 365)
->set('data.operations_stuck_run_threshold_minutes', 45)
->callAction('save')
->assertHasNoErrors()
->assertSet('data.operations_operation_run_retention_days', 365)
->assertSet('data.operations_stuck_run_threshold_minutes', 45)
->mountFormComponentAction('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.operations_stuck_run_threshold_minutes', null);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'operation_run_retention_days'))
->toBe(365);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'stuck_run_threshold_minutes'))
->toBe(0);
});
it('requires confirmation for each per-setting reset action', function (): void {
[$workspace, $user] = workspaceManagerUser();
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'backup',
'key' => 'retention_keep_last_default',
'value' => 40,
'updated_by_user_id' => (int) $user->getKey(),
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'backup',
'key' => 'retention_min_floor',
'value' => 5,
'updated_by_user_id' => (int) $user->getKey(),
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'drift',
'key' => 'severity_mapping',
'value' => ['drift' => 'low'],
'updated_by_user_id' => (int) $user->getKey(),
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'operations',
'key' => 'operation_run_retention_days',
'value' => 120,
'updated_by_user_id' => (int) $user->getKey(),
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'operations',
'key' => 'stuck_run_threshold_minutes',
'value' => 30,
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)->test(WorkspaceSettings::class);
$component
->mountFormComponentAction('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content');
expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue();
$component->unmountFormComponentAction();
$component
->mountFormComponentAction('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content');
expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue();
$component->unmountFormComponentAction();
$component
->mountFormComponentAction('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content');
expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue();
$component->unmountFormComponentAction();
$component
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content');
expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue();
$component->unmountFormComponentAction();
$component
->mountFormComponentAction('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content');
expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue();
$component->unmountFormComponentAction();
});
it('emits one audit entry per key changed when saving multiple settings at once', function (): void {
[$workspace, $user] = workspaceManagerUser();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.backup_retention_keep_last_default', 50)
->set('data.backup_retention_min_floor', 10)
->set('data.operations_operation_run_retention_days', 120)
->callAction('save')
->assertHasNoErrors();
$updatedEvents = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->get();
expect($updatedEvents)->toHaveCount(3);
$keys = $updatedEvents
->map(fn (AuditLog $auditLog): ?string => data_get($auditLog->metadata, 'key'))
->filter(fn (?string $key): bool => is_string($key))
->values()
->all();
expect($keys)->toEqualCanonicalizing([
'retention_keep_last_default',
'retention_min_floor',
'operation_run_retention_days',
]);
});

View File

@ -11,7 +11,7 @@
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire; use Livewire\Livewire;
it('allows view-only members to view workspace settings but forbids save and reset mutations', function (): void { it('allows view-only members to view workspace settings but forbids save and per-setting reset mutations', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -38,16 +38,28 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(WorkspaceSettings::class) ->test(WorkspaceSettings::class)
->assertSet('data.backup_retention_keep_last_default', 27) ->assertSet('data.backup_retention_keep_last_default', 27)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', null)
->assertSet('data.operations_operation_run_retention_days', null)
->assertSet('data.operations_stuck_run_threshold_minutes', null)
->assertActionVisible('save') ->assertActionVisible('save')
->assertActionDisabled('save') ->assertActionDisabled('save')
->assertActionVisible('reset') ->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
->assertActionDisabled('reset') ->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
->assertFormComponentActionDisabled('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
->assertFormComponentActionVisible('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
->assertFormComponentActionDisabled('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
->assertFormComponentActionVisible('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content')
->assertFormComponentActionDisabled('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content')
->call('save') ->call('save')
->assertStatus(403); ->assertStatus(403);
Livewire::actingAs($user) Livewire::actingAs($user)
->test(WorkspaceSettings::class) ->test(WorkspaceSettings::class)
->call('resetSetting') ->call('resetSetting', 'backup_retention_keep_last_default')
->assertStatus(403); ->assertStatus(403);
expect(AuditLog::query()->count())->toBe(0); expect(AuditLog::query()->count())->toBe(0);

View File

@ -17,6 +17,10 @@
$high = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'high'); $high = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'high');
expect($high->label)->toBe('High'); expect($high->label)->toBe('High');
expect($high->color)->toBe('danger'); expect($high->color)->toBe('danger');
$critical = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'critical');
expect($critical->label)->toBe('Critical');
expect($critical->color)->toBe('danger');
}); });
it('maps finding status values to canonical badge semantics', function (): void { it('maps finding status values to canonical badge semantics', function (): void {