TenantAtlas/app/Filament/Resources/PolicyResource.php
ahmido 412dd7ad66 feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
2026-01-03 02:06:35 +00:00

810 lines
36 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob;
use App\Jobs\BulkPolicyExportJob;
use App\Jobs\BulkPolicySyncJob;
use App\Jobs\BulkPolicyUnignoreJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\PolicyNormalizer;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum;
class PolicyResource extends Resource
{
protected static ?string $model = Policy::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Policy Details')
->schema([
TextEntry::make('display_name')->label('Policy'),
TextEntry::make('policy_type')->label('Type'),
TextEntry::make('platform'),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
TextEntry::make('created_at')->since(),
TextEntry::make('latest_snapshot_mode')
->label('Snapshot')
->badge()
->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success')
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full')
->helperText(function (Policy $record): ?string {
$meta = static::latestVersionMetadata($record);
if (($meta['source'] ?? null) !== 'metadata_only') {
return null;
}
$status = $meta['original_status'] ?? null;
return sprintf(
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
$status ?? 'an error'
);
})
->visible(fn (Policy $record) => $record->versions()->exists()),
])
->columns(2)
->columnSpanFull(),
// Tabbed content (General / Settings / JSON)
Tabs::make('policy_content')
->activeTab(1)
->persistTabInQueryString()
->tabs([
Tab::make('General')
->id('general')
->schema([
ViewEntry::make('policy_general')
->label('')
->view('filament.infolists.entries.policy-general')
->state(function (Policy $record) {
return static::generalOverviewState($record);
}),
])
->visible(fn (Policy $record) => $record->versions()->exists()),
Tab::make('Settings')
->id('settings')
->schema([
ViewEntry::make('settings_catalog')
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
$record->versions()->exists()
),
ViewEntry::make('settings_standard')
->label('')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (Policy $record) {
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
$record->versions()->exists()
),
TextEntry::make('no_settings_available')
->label('Settings')
->state('No policy snapshot available yet.')
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
->visible(fn (Policy $record) => ! $record->versions()->exists()),
]),
Tab::make('JSON')
->id('json')
->schema([
ViewEntry::make('snapshot_json')
->view('filament.infolists.entries.snapshot-json')
->state(fn (Policy $record) => static::latestSnapshot($record))
->columnSpanFull(),
TextEntry::make('snapshot_size')
->label('Payload Size')
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
->formatStateUsing(function ($state) {
if ($state > 512000) {
return '<span class="inline-flex items-center gap-1 text-warning-600 dark:text-warning-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
</span>';
}
return number_format($state / 1024, 1).' KB';
})
->html()
->visible(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000),
])
->visible(fn (Policy $record) => $record->versions()->exists()),
])
->columnSpanFull()
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
// Legacy layout (kept for fallback if tabs are disabled)
Section::make('Settings')
->schema([
ViewEntry::make('settings')
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
static::latestSnapshot($record),
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
}),
])
->columnSpanFull()
->visible(function (Policy $record) {
return ! static::usesTabbedLayout($record);
}),
Section::make('Policy Snapshot (JSON)')
->schema([
ViewEntry::make('snapshot_json')
->view('filament.infolists.entries.snapshot-json')
->state(fn (Policy $record) => static::latestSnapshot($record))
->columnSpanFull(),
TextEntry::make('snapshot_size')
->label('Payload Size')
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
->formatStateUsing(function ($state) {
if ($state > 512000) {
return '<span class="inline-flex items-center gap-1 text-warning-600 dark:text-warning-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
</span>';
}
return number_format($state / 1024, 1).' KB';
})
->html()
->visible(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000),
])
->collapsible()
->collapsed(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000)
->description('Raw JSON configuration from Microsoft Graph API')
->columnSpanFull()
->visible(function (Policy $record) {
return ! static::usesTabbedLayout($record);
}),
]);
}
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(function (Builder $query) {
// Quick-Workaround: Hide policies not synced in last 7 days
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
$query->where('last_synced_at', '>', now()->subDays(7));
})
->columns([
Tables\Columns\TextColumn::make('display_name')
->label('Policy')
->searchable(),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state),
Tables\Columns\TextColumn::make('category')
->label('Category')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
Tables\Columns\TextColumn::make('platform')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('settings_status')
->label('Settings')
->badge()
->state(function (Policy $record) {
$latest = $record->versions->first();
$snapshot = $latest?->snapshot ?? [];
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
return $hasSettings ? 'Available' : 'Missing';
})
->color(function (Policy $record) {
$latest = $record->versions->first();
$snapshot = $latest?->snapshot ?? [];
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
return $hasSettings ? 'success' : 'gray';
}),
Tables\Columns\TextColumn::make('external_id')
->label('External ID')
->copyable()
->limit(32),
Tables\Columns\TextColumn::make('last_synced_at')
->label('Last synced')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->since()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('visibility')
->label('Visibility')
->options([
'active' => 'Active',
'ignored' => 'Ignored',
])
->default('active')
->query(function (Builder $query, array $data) {
$value = $data['value'] ?? null;
if (blank($value)) {
return;
}
if ($value === 'active') {
$query->whereNull('ignored_at');
return;
}
if ($value === 'ignored') {
$query->whereNotNull('ignored_at');
}
}),
Tables\Filters\SelectFilter::make('policy_type')
->options(function () {
return collect(config('tenantpilot.supported_policy_types', []))
->pluck('label', 'type')
->map(fn ($label, $type) => $label ?? $type)
->all();
}),
Tables\Filters\SelectFilter::make('category')
->options(function () {
return collect(config('tenantpilot.supported_policy_types', []))
->pluck('category', 'category')
->filter()
->unique()
->sort()
->all();
})
->query(function (Builder $query, array $data) {
$category = $data['value'] ?? null;
if (! $category) {
return;
}
$types = collect(config('tenantpilot.supported_policy_types', []))
->where('category', $category)
->pluck('type')
->all();
$query->whereIn('policy_type', $types);
}),
Tables\Filters\SelectFilter::make('platform')
->options(fn () => Policy::query()
->distinct()
->pluck('platform', 'platform')
->filter()
->reject(fn ($platform) => is_string($platform) && strtolower($platform) === 'all')
->all()),
])
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
Actions\Action::make('ignore')
->label('Ignore')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at === null)
->action(function (Policy $record) {
$record->ignore();
Notification::make()
->title('Policy ignored')
->success()
->send();
}),
Actions\Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at !== null)
->action(function (Policy $record) {
$record->unignore();
Notification::make()
->title('Policy restored')
->success()
->send();
}),
Actions\Action::make('sync')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(fn (Policy $record) => $record->ignored_at === null)
->action(function (Policy $record) {
$tenant = Tenant::current();
$user = auth()->user();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1);
BulkPolicySyncJob::dispatchSync($run->id);
}),
Actions\Action::make('export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->visible(fn (Policy $record) => $record->ignored_at === null)
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Policy $record, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_delete')
->label('Ignore Policies')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return $value === 'ignored';
})
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk delete started')
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyDeleteJob::dispatch($run->id);
} else {
BulkPolicyDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_restore')
->label('Restore Policies')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return ! in_array($value, [null, 'ignored'], true);
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyUnignoreJob::dispatch($run->id);
} else {
BulkPolicyUnignoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_sync')
->label('Sync Policies')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
$value = $visibilityFilterState['value'] ?? null;
return $value === 'ignored';
})
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk sync started')
->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicySyncJob::dispatch($run->id);
} else {
BulkPolicySyncJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_export')
->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down')
->form([
Forms\Components\TextInput::make('backup_name')
->label('Backup Name')
->required()
->default(fn () => 'Backup '.now()->toDateTimeString()),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk export started')
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
} else {
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
}
})
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->withCount('versions')
->with([
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
]);
}
public static function getRelations(): array
{
return [
VersionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPolicies::route('/'),
'view' => Pages\ViewPolicy::route('/{record}'),
];
}
private static function latestSnapshot(Policy $record): array
{
$snapshot = $record->relationLoaded('versions')
? $record->versions->first()?->snapshot
: $record->versions()->orderByDesc('captured_at')->value('snapshot');
if (is_string($snapshot)) {
$decoded = json_decode($snapshot, true);
$snapshot = $decoded ?? [];
}
if (is_array($snapshot)) {
return $snapshot;
}
return [];
}
private static function latestVersionMetadata(Policy $record): array
{
$metadata = $record->relationLoaded('versions')
? $record->versions->first()?->metadata
: $record->versions()->orderByDesc('captured_at')->value('metadata');
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
$metadata = $decoded ?? [];
}
return is_array($metadata) ? $metadata : [];
}
/**
* @return array<string, mixed>
*/
private static function normalizedPolicyState(Policy $record): array
{
$cacheKey = 'tenantpilot.normalizedPolicyState.'.(string) $record->getKey();
$request = request();
if ($request->attributes->has($cacheKey)) {
$cached = $request->attributes->get($cacheKey);
if (is_array($cached)) {
return $cached;
}
}
$snapshot = static::latestSnapshot($record);
$normalized = app(PolicyNormalizer::class)->normalize(
$snapshot,
$record->policy_type,
$record->platform
);
$normalized['context'] = 'policy';
$normalized['record_id'] = (string) $record->getKey();
$normalized['policy_type'] = $record->policy_type;
$request->attributes->set($cacheKey, $normalized);
return $normalized;
}
/**
* @param array{settings?: array<int, array<string, mixed>>} $normalized
* @return array{normalized: array<string, mixed>, general: ?array<string, mixed>}
*/
private static function splitGeneralBlock(array $normalized): array
{
$general = null;
$filtered = [];
foreach ($normalized['settings'] ?? [] as $block) {
if (! is_array($block)) {
continue;
}
$title = $block['title'] ?? null;
if (is_string($title) && strtolower($title) === 'general') {
$general = $block;
continue;
}
$filtered[] = $block;
}
$normalized['settings'] = $filtered;
return [
'normalized' => $normalized,
'general' => $general,
];
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,string>|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
private static function usesTabbedLayout(Policy $record): bool
{
return true;
}
private static function hasSettingsTable(Policy $record): bool
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
return is_array($rows) && $rows !== [];
}
/**
* @return array{entries: array<int, array{key: string, value: mixed}>}
*/
private static function generalOverviewState(Policy $record): array
{
$snapshot = static::latestSnapshot($record);
$entries = [];
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
if (is_string($name) && $name !== '') {
$entries[] = ['key' => 'Name', 'value' => $name];
}
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
if (is_string($platforms) && $platforms !== '') {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
} elseif (is_array($platforms) && $platforms !== []) {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
}
$technologies = $snapshot['technologies'] ?? null;
if (is_string($technologies) && $technologies !== '') {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
} elseif (is_array($technologies) && $technologies !== []) {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
}
if (array_key_exists('templateReference', $snapshot)) {
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
}
$settingCount = $snapshot['settingCount']
?? $snapshot['settingsCount']
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
if (is_int($settingCount) || is_numeric($settingCount)) {
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
}
$version = $snapshot['version'] ?? null;
if (is_string($version) && $version !== '') {
$entries[] = ['key' => 'Version', 'value' => $version];
} elseif (is_numeric($version)) {
$entries[] = ['key' => 'Version', 'value' => $version];
}
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
if (is_string($lastModified) && $lastModified !== '') {
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
}
$createdAt = $snapshot['createdDateTime'] ?? null;
if (is_string($createdAt) && $createdAt !== '') {
$entries[] = ['key' => 'Created', 'value' => $createdAt];
}
$description = $snapshot['description'] ?? null;
if (is_string($description) && $description !== '') {
$entries[] = ['key' => 'Description', 'value' => $description];
}
return [
'entries' => $entries,
];
}
/**
* @return array<string, mixed>
*/
private static function settingsTabState(Policy $record): array
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
$hasSettingsTable = is_array($rows) && $rows !== [];
if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
}
return $normalized;
}
}