feat(specs/261): add provider missing policy visibility
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m40s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m40s
This commit is contained in:
parent
bcabb14480
commit
91f327a7c2
2
.github/skills/giteaflow/SKILL.md
vendored
2
.github/skills/giteaflow/SKILL.md
vendored
@ -5,4 +5,4 @@
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||
comit all changes, push to remote, and create a pull request against platform-dev with gitea mcp
|
||||
@ -50,7 +50,7 @@ public function handle(): int
|
||||
|
||||
$changedVersions = 0;
|
||||
$changedPolicies = 0;
|
||||
$ignoredPolicies = 0;
|
||||
$providerMissingPolicies = 0;
|
||||
|
||||
foreach ($candidates as $policy) {
|
||||
$latestVersion = $policy->versions()->latest('version_number')->first();
|
||||
@ -86,14 +86,15 @@ public function handle(): int
|
||||
->first();
|
||||
|
||||
if ($existingTarget) {
|
||||
$policy->forceFill(['ignored_at' => now()])->save();
|
||||
$ignoredPolicies++;
|
||||
$policy->forceFill(['missing_from_provider_at' => now()])->save();
|
||||
$providerMissingPolicies++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policy->forceFill([
|
||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
$changedPolicies++;
|
||||
|
||||
@ -106,7 +107,7 @@ public function handle(): int
|
||||
$this->info('Done.');
|
||||
$this->info('PolicyVersions changed: '.$changedVersions);
|
||||
$this->info('Policies changed: '.$changedPolicies);
|
||||
$this->info('Policies ignored: '.$ignoredPolicies);
|
||||
$this->info('Policies marked provider-missing: '.$providerMissingPolicies);
|
||||
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@ -72,6 +72,16 @@ class PolicyResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return static::text('common.policy');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return static::text('common.policies');
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
@ -100,7 +110,7 @@ public static function canViewAny(): bool
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync policies.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
@ -112,12 +122,12 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label('Sync from Intune')
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Sync policies from Intune')
|
||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||
->modalHeading(static::text('resource.sync_modal_heading'))
|
||||
->modalDescription(static::text('resource.sync_modal_description').' '.static::text('common.source_microsoft_intune'))
|
||||
->action(function (Pages\ListPolicies $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -150,7 +160,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -165,14 +175,14 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync policies.')
|
||||
->tooltip(static::text('resource.sync_permission_tooltip'))
|
||||
->apply();
|
||||
}
|
||||
|
||||
@ -185,16 +195,31 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Policy Details')
|
||||
Section::make(static::text('resource.details_section'))
|
||||
->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('display_name')->label(static::text('common.policy')),
|
||||
TextEntry::make('policy_type')->label(static::text('common.type')),
|
||||
TextEntry::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
TextEntry::make('visibility_state')
|
||||
->label(static::text('common.visibility'))
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->helperText(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||
? static::text('resource.visibility_source_unavailable_backup_items')
|
||||
: null),
|
||||
TextEntry::make('external_id')->label(static::text('common.external_id')),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label(static::text('common.last_synced')),
|
||||
TextEntry::make('created_at')->since()->label(static::text('common.created')),
|
||||
TextEntry::make('latest_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
@ -211,8 +236,8 @@ public static function infolist(Schema $schema): Schema
|
||||
$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'
|
||||
static::text('resource.snapshot_metadata_only_helper'),
|
||||
$status ?? static::text('resource.graph_error_fallback')
|
||||
);
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
@ -225,7 +250,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->activeTab(1)
|
||||
->persistTabInQueryString()
|
||||
->tabs([
|
||||
Tab::make('General')
|
||||
Tab::make(static::text('resource.tab_general'))
|
||||
->id('general')
|
||||
->schema([
|
||||
ViewEntry::make('policy_general')
|
||||
@ -236,7 +261,7 @@ public static function infolist(Schema $schema): Schema
|
||||
}),
|
||||
])
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
Tab::make('Settings')
|
||||
Tab::make(static::text('common.settings'))
|
||||
->id('settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
@ -248,12 +273,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $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.')
|
||||
->label(static::text('common.settings'))
|
||||
->state(static::text('resource.settings_empty_state'))
|
||||
->helperText(static::text('resource.settings_empty_state_helper'))
|
||||
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
||||
]),
|
||||
Tab::make('JSON')
|
||||
Tab::make(static::text('resource.tab_json'))
|
||||
->id('json')
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
@ -261,7 +286,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(fn (Policy $record) => static::latestSnapshot($record))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('snapshot_size')
|
||||
->label('Payload Size')
|
||||
->label(static::text('resource.payload_size'))
|
||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||
->formatStateUsing(function ($state) {
|
||||
if ($state > 512000) {
|
||||
@ -269,7 +294,7 @@ public static function infolist(Schema $schema): Schema
|
||||
<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
|
||||
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||
</span>';
|
||||
}
|
||||
|
||||
@ -284,7 +309,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
||||
|
||||
// Legacy layout (kept for fallback if tabs are disabled)
|
||||
Section::make('Settings')
|
||||
Section::make(static::text('common.settings'))
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
->label('')
|
||||
@ -298,7 +323,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return ! static::usesTabbedLayout($record);
|
||||
}),
|
||||
|
||||
Section::make('Policy Snapshot (JSON)')
|
||||
Section::make(static::text('resource.snapshot_json_section'))
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
@ -306,7 +331,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
|
||||
TextEntry::make('snapshot_size')
|
||||
->label('Payload Size')
|
||||
->label(static::text('resource.payload_size'))
|
||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||
->formatStateUsing(function ($state) {
|
||||
if ($state > 512000) {
|
||||
@ -314,7 +339,7 @@ public static function infolist(Schema $schema): Schema
|
||||
<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
|
||||
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||
</span>';
|
||||
}
|
||||
|
||||
@ -336,11 +361,6 @@ public static function infolist(Schema $schema): Schema
|
||||
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));
|
||||
})
|
||||
->defaultSort('display_name')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
@ -349,24 +369,36 @@ public static function table(Table $table): Table
|
||||
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('visibility_state')
|
||||
->label(static::text('common.visibility'))
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->description(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||
? static::text('resource.visibility_source_unavailable_description')
|
||||
: null),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label('Category')
|
||||
->label(static::text('common.category'))
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('restore_mode')
|
||||
->label('Restore')
|
||||
->label(static::text('common.restore'))
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||
@ -374,19 +406,22 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('settings_status')
|
||||
->label('Settings')
|
||||
->label(static::text('common.settings'))
|
||||
->badge()
|
||||
->state(function (Policy $record) {
|
||||
$latest = $record->versions->first();
|
||||
$snapshot = $latest?->snapshot ?? [];
|
||||
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
||||
|
||||
return $hasSettings ? 'Available' : 'Missing';
|
||||
return $hasSettings
|
||||
? static::text('resource.settings_available')
|
||||
: static::text('resource.settings_missing');
|
||||
})
|
||||
->color(function (Policy $record) {
|
||||
$latest = $record->versions->first();
|
||||
@ -396,12 +431,12 @@ public static function table(Table $table): Table
|
||||
return $hasSettings ? 'success' : 'gray';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
->label(static::text('common.external_id'))
|
||||
->copyable()
|
||||
->limit(32)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('last_synced_at')
|
||||
->label('Last synced')
|
||||
->label(static::text('common.last_synced'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
@ -411,27 +446,35 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('visibility')
|
||||
->label('Visibility')
|
||||
->label(static::text('common.visibility'))
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'ignored' => 'Ignored',
|
||||
'active' => static::text('resource.filter_active'),
|
||||
'ignored' => static::text('resource.filter_ignored'),
|
||||
'provider_missing' => static::text('resource.filter_source_unavailable'),
|
||||
'all' => static::text('resource.filter_all'),
|
||||
])
|
||||
->default('active')
|
||||
->query(function (Builder $query, array $data) {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (blank($value)) {
|
||||
if (blank($value) || $value === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'active') {
|
||||
$query->whereNull('ignored_at');
|
||||
$query->active();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'ignored') {
|
||||
$query->whereNotNull('ignored_at');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'provider_missing') {
|
||||
$query->whereNotNull('missing_from_provider_at');
|
||||
}
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
@ -475,14 +518,16 @@ public static function table(Table $table): Table
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export')
|
||||
->label('Export to Backup')
|
||||
->label(static::text('resource.export_to_backup'))
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->disabled(fn (Policy $record): bool => ! $record->isCurrentBackupEligible())
|
||||
->tooltip(fn (Policy $record): ?string => $record->currentBackupBlockedReasonLabel())
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->label(static::text('common.backup_name'))
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Policy $record, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -496,6 +541,16 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $record->isCurrentBackupEligible()) {
|
||||
Notification::make()
|
||||
->title(static::text('resource.current_backup_unavailable'))
|
||||
->body($record->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = [(int) $record->getKey()];
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -533,7 +588,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -541,11 +596,12 @@ public static function table(Table $table): Table
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveDisabled()
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync')
|
||||
->label(static::text('resource.sync_action_secondary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
@ -579,7 +635,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -592,7 +648,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -604,7 +660,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->label(static::text('resource.restore_action'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -613,19 +669,19 @@ public static function table(Table $table): Table
|
||||
$record->unignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy restored')
|
||||
->title(static::text('resource.policy_restored'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
->tooltip(static::text('resource.restore_permission_tooltip'))
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('ignore')
|
||||
->label('Ignore')
|
||||
->label(static::text('resource.ignore_action'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -634,31 +690,31 @@ public static function table(Table $table): Table
|
||||
$record->ignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy ignored')
|
||||
->title(static::text('resource.policy_ignored'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
->tooltip(static::text('resource.ignore_permission_tooltip'))
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->label(static::text('common.more'))
|
||||
->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_export')
|
||||
->label('Export to Backup')
|
||||
->label(static::text('resource.export_to_backup'))
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->label(static::text('common.backup_name'))
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -674,6 +730,20 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$blocked = $records->first(
|
||||
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||
);
|
||||
|
||||
if ($blocked instanceof Policy) {
|
||||
Notification::make()
|
||||
->title(static::text('resource.current_backup_unavailable'))
|
||||
->body($blocked->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
@ -721,7 +791,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -732,7 +802,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_sync')
|
||||
->label('Sync Policies')
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
@ -779,7 +849,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -792,7 +862,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -803,7 +873,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Policies')
|
||||
->label(static::text('resource.restore_bulk_action'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -873,7 +943,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -884,7 +954,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Ignore Policies')
|
||||
->label(static::text('resource.ignore_bulk_action'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -898,11 +968,11 @@ public static function table(Table $table): Table
|
||||
if ($records->count() >= 20) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@ -955,10 +1025,10 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -967,10 +1037,10 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -979,10 +1049,10 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])->label(static::text('common.more')),
|
||||
])
|
||||
->emptyStateHeading('No policies synced yet')
|
||||
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
|
||||
->emptyStateHeading(static::text('resource.empty_state_heading'))
|
||||
->emptyStateDescription(static::text('resource.empty_state_description'))
|
||||
->emptyStateIcon('heroicon-o-arrow-path')
|
||||
->emptyStateActions([
|
||||
static::makeSyncAction(),
|
||||
@ -1159,25 +1229,25 @@ private static function generalOverviewState(Policy $record): array
|
||||
|
||||
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
||||
if (is_string($name) && $name !== '') {
|
||||
$entries[] = ['key' => 'Name', 'value' => $name];
|
||||
$entries[] = ['key' => static::text('resource.general_field_name'), 'value' => $name];
|
||||
}
|
||||
|
||||
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
||||
if (is_string($platforms) && $platforms !== '') {
|
||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
||||
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||
} elseif (is_array($platforms) && $platforms !== []) {
|
||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
||||
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||
}
|
||||
|
||||
$technologies = $snapshot['technologies'] ?? null;
|
||||
if (is_string($technologies) && $technologies !== '') {
|
||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
||||
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||
} elseif (is_array($technologies) && $technologies !== []) {
|
||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
||||
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||
}
|
||||
|
||||
if (array_key_exists('templateReference', $snapshot)) {
|
||||
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
|
||||
$entries[] = ['key' => static::text('resource.general_field_template_reference'), 'value' => $snapshot['templateReference']];
|
||||
}
|
||||
|
||||
$settingCount = $snapshot['settingCount']
|
||||
@ -1185,29 +1255,29 @@ private static function generalOverviewState(Policy $record): array
|
||||
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
||||
|
||||
if (is_int($settingCount) || is_numeric($settingCount)) {
|
||||
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
|
||||
$entries[] = ['key' => static::text('resource.general_field_setting_count'), 'value' => $settingCount];
|
||||
}
|
||||
|
||||
$version = $snapshot['version'] ?? null;
|
||||
if (is_string($version) && $version !== '') {
|
||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
||||
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||
} elseif (is_numeric($version)) {
|
||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
||||
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||
}
|
||||
|
||||
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
||||
if (is_string($lastModified) && $lastModified !== '') {
|
||||
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
|
||||
$entries[] = ['key' => static::text('resource.general_field_last_modified'), 'value' => $lastModified];
|
||||
}
|
||||
|
||||
$createdAt = $snapshot['createdDateTime'] ?? null;
|
||||
if (is_string($createdAt) && $createdAt !== '') {
|
||||
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
||||
$entries[] = ['key' => static::text('resource.general_field_created'), 'value' => $createdAt];
|
||||
}
|
||||
|
||||
$description = $snapshot['description'] ?? null;
|
||||
if (is_string($description) && $description !== '') {
|
||||
$entries[] = ['key' => 'Description', 'value' => $description];
|
||||
$entries[] = ['key' => static::text('resource.general_field_description'), 'value' => $description];
|
||||
}
|
||||
|
||||
return [
|
||||
@ -1232,4 +1302,9 @@ private static function settingsTabState(Policy $record): array
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Jobs\CapturePolicySnapshotJob;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
@ -39,23 +40,37 @@ private function makeCaptureSnapshotAction(): Action
|
||||
{
|
||||
$action = UiEnforcement::forAction(
|
||||
Action::make('capture_snapshot')
|
||||
->label('Capture snapshot')
|
||||
->label($this->text('resource.capture_snapshot_action'))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Capture snapshot now')
|
||||
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
||||
->modalHeading($this->text('resource.capture_snapshot_modal_heading'))
|
||||
->modalSubheading($this->text('resource.capture_snapshot_modal_subheading').' '.$this->text('common.source_microsoft_intune'))
|
||||
->disabled(fn (): bool => $this->record instanceof Policy && $this->record->isProviderMissing())
|
||||
->tooltip(fn (): ?string => $this->record instanceof Policy && $this->record->isProviderMissing()
|
||||
? $this->record->currentBackupBlockedReasonLabel()
|
||||
: null)
|
||||
->form([
|
||||
Forms\Components\Checkbox::make('include_assignments')
|
||||
->label('Include assignments')
|
||||
->label($this->text('resource.capture_snapshot_include_assignments'))
|
||||
->default(true)
|
||||
->helperText('Captures assignment include/exclude targeting and filters.'),
|
||||
->helperText($this->text('resource.capture_snapshot_include_assignments_helper')),
|
||||
Forms\Components\Checkbox::make('include_scope_tags')
|
||||
->label('Include scope tags')
|
||||
->label($this->text('resource.capture_snapshot_include_scope_tags'))
|
||||
->default(true)
|
||||
->helperText('Captures policy scope tag IDs.'),
|
||||
->helperText($this->text('resource.capture_snapshot_include_scope_tags_helper')),
|
||||
])
|
||||
->action(function (array $data, AuditLogger $auditLogger) {
|
||||
$policy = $this->record;
|
||||
|
||||
if ($policy instanceof Policy && $policy->isProviderMissing()) {
|
||||
Notification::make()
|
||||
->title($this->text('resource.capture_snapshot_unavailable_title'))
|
||||
->body($policy->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $policy->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
@ -108,11 +123,11 @@ private function makeCaptureSnapshotAction(): Action
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Snapshot already in progress')
|
||||
->body('An active run already exists for this policy. Opening run details.')
|
||||
->title($this->text('resource.capture_snapshot_in_progress_title'))
|
||||
->body($this->text('resource.capture_snapshot_in_progress_body'))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label($this->text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
@ -145,7 +160,7 @@ private function makeCaptureSnapshotAction(): Action
|
||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label($this->text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -155,7 +170,8 @@ private function makeCaptureSnapshotAction(): Action
|
||||
->color('primary')
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to capture policy snapshots.')
|
||||
->tooltip($this->text('resource.capture_snapshot_permission_tooltip'))
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
if (! $action instanceof Action) {
|
||||
@ -164,4 +180,9 @@ private function makeCaptureSnapshotAction(): Action
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,15 +59,15 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||
->label('Restore to Intune')
|
||||
->label($this->text('relation.restore_to_microsoft_intune'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
->modalHeading(fn (PolicyVersion $record): string => $this->text('relation.restore_heading', ['version' => $record->version_number]))
|
||||
->modalSubheading($this->text('relation.restore_subheading'))
|
||||
->form([
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->label($this->text('common.preview_only_dry_run'))
|
||||
->default(true),
|
||||
])
|
||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||
@ -77,7 +77,7 @@ public function table(Table $table): Table
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Missing tenant or user context.')
|
||||
->title($this->text('relation.missing_context_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -86,7 +86,7 @@ public function table(Table $table): Table
|
||||
|
||||
if ($record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->title($this->text('versions.different_tenant_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -103,7 +103,7 @@ public function table(Table $table): Table
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Restore run failed to start')
|
||||
->title($this->text('relation.restore_run_failed_title'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
@ -112,7 +112,7 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Restore run started')
|
||||
->title($this->text('relation.restore_run_started_title'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
@ -146,7 +146,7 @@ public function table(Table $table): Table
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
return $this->text('versions.metadata_only_tooltip');
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -171,10 +171,11 @@ public function table(Table $table): Table
|
||||
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('version_number')->label($this->text('common.version'))->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->label($this->text('common.captured'))->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label($this->text('common.actor')),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label($this->text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
@ -189,8 +190,8 @@ public function table(Table $table): Table
|
||||
$restoreToIntune,
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No versions captured')
|
||||
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
||||
->emptyStateHeading($this->text('relation.no_versions_captured'))
|
||||
->emptyStateDescription($this->text('relation.no_versions_captured_description'));
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
||||
@ -214,4 +215,9 @@ private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record):
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,23 +121,25 @@ public static function infolist(Schema $schema): Schema
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
||||
Infolists\Components\TextEntry::make('version_number')->label(static::text('common.version')),
|
||||
Infolists\Components\TextEntry::make('policy_type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Infolists\Components\TextEntry::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||
Section::make('Backup quality')
|
||||
Infolists\Components\TextEntry::make('created_by')->label(static::text('common.actor')),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime()->label(static::text('common.captured')),
|
||||
Section::make(static::text('versions.backup_quality_section'))
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('quality_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
@ -145,27 +147,27 @@ public static function infolist(Schema $schema): Schema
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Infolists\Components\TextEntry::make('quality_summary')
|
||||
->label('Backup quality')
|
||||
->label(static::text('versions.backup_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
|
||||
Infolists\Components\TextEntry::make('quality_assignment_signal')
|
||||
->label('Assignment quality')
|
||||
->label(static::text('versions.assignment_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
|
||||
Infolists\Components\TextEntry::make('quality_next_action')
|
||||
->label('Next action')
|
||||
->label(static::text('versions.next_action'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
|
||||
Infolists\Components\TextEntry::make('quality_integrity_warning')
|
||||
->label('Integrity note')
|
||||
->label(static::text('versions.integrity_note'))
|
||||
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
|
||||
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('quality_boundary')
|
||||
->label('Boundary')
|
||||
->label(static::text('versions.boundary'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
Section::make(static::text('versions.related_context_section'))
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
->label('')
|
||||
@ -179,7 +181,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->persistTabInQueryString('tab')
|
||||
->columnSpanFull()
|
||||
->tabs([
|
||||
Tab::make('Normalized settings')
|
||||
Tab::make(static::text('common.settings'))
|
||||
->id('normalized-settings')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||
@ -198,14 +200,14 @@ public static function infolist(Schema $schema): Schema
|
||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
||||
}),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
Tab::make(static::text('resource.tab_json'))
|
||||
->id('raw-json')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('snapshot_pretty')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
|
||||
]),
|
||||
Tab::make('Diff')
|
||||
Tab::make(static::text('versions.diff_tab'))
|
||||
->id('diff')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_diff')
|
||||
@ -226,7 +228,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
||||
}),
|
||||
Infolists\Components\ViewEntry::make('diff_json')
|
||||
->label('Raw diff (advanced)')
|
||||
->label(static::text('versions.raw_diff_advanced'))
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$previous = $record->previous();
|
||||
@ -275,11 +277,11 @@ public static function infolist(Schema $schema): Schema
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||
->label('Prune Versions')
|
||||
->label(static::text('versions.prune_versions'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||
->modalDescription(static::text('versions.prune_modal_description'))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -291,8 +293,8 @@ public static function table(Table $table): Table
|
||||
->form(function (Collection $records) {
|
||||
$fields = [
|
||||
Forms\Components\TextInput::make('retention_days')
|
||||
->label('Retention Days')
|
||||
->helperText('Versions captured within the last N days will be skipped.')
|
||||
->label(static::text('versions.retention_days'))
|
||||
->helperText(static::text('versions.retention_days_helper'))
|
||||
->numeric()
|
||||
->required()
|
||||
->default(90)
|
||||
@ -301,11 +303,11 @@ public static function table(Table $table): Table
|
||||
|
||||
if ($records->count() >= 20) {
|
||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -363,7 +365,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -372,11 +374,11 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||
->label('Restore Versions')
|
||||
->label(static::text('versions.restore_versions'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -388,8 +390,8 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||
->modalHeading(fn (Collection $records) => static::text('versions.restore_versions_modal_heading', ['count' => $records->count()]))
|
||||
->modalDescription(static::text('versions.restore_versions_modal_description'))
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -438,7 +440,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -447,11 +449,11 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||
->label('Force Delete Versions')
|
||||
->label(static::text('versions.force_delete_versions'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -463,15 +465,15 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||
->modalHeading(fn (Collection $records) => static::text('versions.force_delete_versions_modal_heading', ['count' => $records->count()]))
|
||||
->modalDescription(static::text('versions.force_delete_versions_modal_description'))
|
||||
->form([
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
@ -522,7 +524,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -531,7 +533,7 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
@ -542,13 +544,15 @@ public static function table(Table $table): Table
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('version_number')
|
||||
->label(static::text('common.version'))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
@ -556,30 +560,33 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->label(static::text('versions.backup_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
|
||||
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label(static::text('common.actor'))->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->label(static::text('common.captured'))->dateTime()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
->label('Type')
|
||||
->label(static::text('common.type'))
|
||||
->options(FilterOptionCatalog::policyTypes())
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
FilterPresets::dateRange('captured_at', static::text('common.captured'), 'captured_at'),
|
||||
FilterPresets::archived(),
|
||||
])
|
||||
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
||||
@ -590,12 +597,12 @@ public static function table(Table $table): Table
|
||||
Actions\ActionGroup::make([
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore_via_wizard')
|
||||
->label('Restore via Wizard')
|
||||
->label(static::text('versions.restore_via_wizard'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||
->modalHeading(fn (PolicyVersion $record): string => static::text('versions.restore_via_wizard_modal_heading', ['version' => $record->version_number]))
|
||||
->modalSubheading(static::text('versions.restore_via_wizard_modal_subheading'))
|
||||
->visible(function (): bool {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -646,11 +653,11 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You do not have permission to create restore runs.';
|
||||
return static::text('versions.restore_run_permission_tooltip');
|
||||
}
|
||||
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
return static::text('versions.metadata_only_tooltip');
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -676,8 +683,8 @@ public static function table(Table $table): Table
|
||||
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
Notification::make()
|
||||
->title('Restore disabled for metadata-only snapshot')
|
||||
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||
->title(static::text('versions.restore_disabled_metadata_title'))
|
||||
->body(static::text('versions.restore_disabled_metadata_body'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -686,7 +693,7 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->title(static::text('versions.different_tenant_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -697,7 +704,7 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $policy) {
|
||||
Notification::make()
|
||||
->title('Policy could not be found for this version')
|
||||
->title(static::text('versions.missing_policy_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -706,11 +713,10 @@ public static function table(Table $table): Table
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => sprintf(
|
||||
'Policy Version Restore • %s • v%d',
|
||||
$policy->display_name,
|
||||
$record->version_number
|
||||
),
|
||||
'name' => static::text('versions.backup_set_name', [
|
||||
'policy' => $policy->display_name,
|
||||
'version' => $record->version_number,
|
||||
]),
|
||||
'created_by' => $user?->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
@ -788,7 +794,7 @@ public static function table(Table $table): Table
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->label(static::text('versions.archive'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
@ -815,7 +821,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version archived')
|
||||
->title(static::text('versions.archived_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -823,14 +829,14 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->label(static::text('versions.force_delete'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
@ -857,7 +863,7 @@ public static function table(Table $table): Table
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version permanently deleted')
|
||||
->title(static::text('versions.force_deleted_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -865,7 +871,7 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
@ -873,7 +879,7 @@ public static function table(Table $table): Table
|
||||
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->label(static::text('common.restore'))
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
@ -900,7 +906,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version restored')
|
||||
->title(static::text('versions.restored_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -908,13 +914,13 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
])
|
||||
->label('More')
|
||||
->label(static::text('common.more'))
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
])
|
||||
@ -923,14 +929,14 @@ public static function table(Table $table): Table
|
||||
$bulkPruneVersions,
|
||||
$bulkRestoreVersions,
|
||||
$bulkForceDeleteVersions,
|
||||
])->label('More'),
|
||||
])->label(static::text('common.more')),
|
||||
])
|
||||
->emptyStateHeading('No policy versions')
|
||||
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
|
||||
->emptyStateHeading(static::text('versions.empty_state_heading'))
|
||||
->emptyStateDescription(static::text('versions.empty_state_description'))
|
||||
->emptyStateIcon('heroicon-o-clock')
|
||||
->emptyStateActions([
|
||||
Actions\Action::make('open_backup_sets')
|
||||
->label('Open backup sets')
|
||||
->label(static::text('versions.open_backup_sets'))
|
||||
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->color('gray'),
|
||||
]);
|
||||
@ -1016,7 +1022,7 @@ public static function relatedContextEntries(PolicyVersion $record): array
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? static::text('versions.related_record_fallback'))
|
||||
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
@ -1032,10 +1038,10 @@ private static function policyVersionAssignmentQualityLabel(PolicyVersion $recor
|
||||
$summary = static::policyVersionQualitySummary($record);
|
||||
|
||||
return match (true) {
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
|
||||
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
|
||||
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
|
||||
default => 'No assignment issues were detected from captured metadata.',
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => static::text('versions.assignment_fetch_failed_orphaned'),
|
||||
$summary->hasAssignmentIssues => static::text('versions.assignment_fetch_failed'),
|
||||
$summary->hasOrphanedAssignments => static::text('versions.assignment_orphaned'),
|
||||
default => static::text('versions.assignment_no_issues'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1065,6 +1071,11 @@ private static function resolvedDisplayName(PolicyVersion $record): string
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
return sprintf('Version %d', (int) $record->version_number);
|
||||
return static::text('versions.fallback_display_name', ['version' => (int) $record->version_number]);
|
||||
}
|
||||
|
||||
private static function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1473,9 +1473,13 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
->orWhereHas('policy', function ($policyQuery): void {
|
||||
$policyQuery
|
||||
->whereNull('ignored_at')
|
||||
->orWhereNotNull('missing_from_provider_at');
|
||||
});
|
||||
})
|
||||
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
||||
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -1499,6 +1503,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$displayName = $item->resolvedDisplayName();
|
||||
$identifier = $item->policy_identifier ?? null;
|
||||
$versionNumber = $item->policyVersion?->version_number;
|
||||
$providerMissingNote = $item->policy?->missing_from_provider_at
|
||||
? 'current state: provider missing; historical restore available'
|
||||
: null;
|
||||
|
||||
$options[$item->id] = $displayName;
|
||||
|
||||
@ -1508,6 +1515,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$platform,
|
||||
'quality: '.$qualitySummary->compactSummary,
|
||||
"restore: {$restore}",
|
||||
$providerMissingNote,
|
||||
$versionNumber ? "version: {$versionNumber}" : null,
|
||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||
@ -1540,9 +1548,13 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
->orWhereHas('policy', function ($policyQuery): void {
|
||||
$policyQuery
|
||||
->whereNull('ignored_at')
|
||||
->orWhereNotNull('missing_from_provider_at');
|
||||
});
|
||||
})
|
||||
->with(['policy:id,display_name'])
|
||||
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -1659,6 +1671,7 @@ private static function restoreItemSelectionLabel(BackupItem $item): string
|
||||
return implode(' • ', array_filter([
|
||||
$item->resolvedDisplayName(),
|
||||
$summary->compactSummary,
|
||||
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@ -242,12 +242,33 @@ public function handle(
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($policy->ignored_at) {
|
||||
if (! $policy->isCurrentBackupEligible()) {
|
||||
$reasonCode = match ($policy->currentBackupBlockedReason()) {
|
||||
Policy::VISIBILITY_PROVIDER_MISSING => 'policy_provider_missing',
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy_ignored_locally',
|
||||
default => 'policy_not_current_backup_eligible',
|
||||
};
|
||||
$reason = $policy->currentBackupBlockedReasonLabel()
|
||||
?? 'Policy is not eligible for current backup capture.';
|
||||
|
||||
$newBackupFailures[] = [
|
||||
'policy_id' => $policyId,
|
||||
'reason' => RunFailureSanitizer::sanitizeMessage($reason),
|
||||
'status' => null,
|
||||
'reason_code' => $reasonCode,
|
||||
];
|
||||
$didMutateBackupSet = true;
|
||||
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => str_replace('_', '.', $reasonCode),
|
||||
'message' => RunFailureSanitizer::sanitizeMessage($reason),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -120,6 +120,49 @@ public function handle(OperationRunService $operationRunService): void
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $policy->isCurrentBackupEligible()) {
|
||||
$failed++;
|
||||
$reasonCode = match ($policy->currentBackupBlockedReason()) {
|
||||
Policy::VISIBILITY_PROVIDER_MISSING => 'policy.provider_missing',
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy.ignored_locally',
|
||||
default => 'policy.not_current_backup_eligible',
|
||||
};
|
||||
$failures[] = [
|
||||
'code' => $reasonCode,
|
||||
'message' => $policy->currentBackupBlockedReasonLabel()
|
||||
?? "Policy {$policyId} is not eligible for current backup capture.",
|
||||
];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$backupSet->update([
|
||||
'status' => 'failed',
|
||||
'item_count' => $succeeded,
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'created' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get latest version for snapshot
|
||||
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
||||
|
||||
@ -215,6 +258,15 @@ public function handle(OperationRunService $operationRunService): void
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
$backupSet->update([
|
||||
'status' => match ($outcome) {
|
||||
OperationRunOutcome::Failed->value => 'failed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => 'partial',
|
||||
default => 'completed',
|
||||
},
|
||||
'item_count' => $succeeded,
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@ -98,6 +97,17 @@ public function table(Table $table): Table
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
TextColumn::make('visibility_state')
|
||||
->label('Visibility')
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->description(fn (Policy $record): ?string => $record->isCurrentBackupEligible()
|
||||
? null
|
||||
: $record->currentBackupBlockedReasonLabel()),
|
||||
TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
|
||||
@ -146,7 +156,7 @@ public function table(Table $table): Table
|
||||
'90' => 'Within 90 days',
|
||||
'any' => 'Any time',
|
||||
])
|
||||
->default('7')
|
||||
->default('any')
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = (string) ($data['value'] ?? '7');
|
||||
|
||||
@ -158,14 +168,28 @@ public function table(Table $table): Table
|
||||
|
||||
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
|
||||
}),
|
||||
TernaryFilter::make('ignored')
|
||||
->label('Ignored')
|
||||
->nullable()
|
||||
->queries(
|
||||
true: fn (Builder $query) => $query->whereNotNull('ignored_at'),
|
||||
false: fn (Builder $query) => $query->whereNull('ignored_at'),
|
||||
)
|
||||
->default(false),
|
||||
SelectFilter::make('visibility')
|
||||
->label('Visibility')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'ignored' => 'Ignored locally',
|
||||
'provider_missing' => 'Provider missing',
|
||||
'all' => 'All',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (blank($value) || $value === 'all') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
'active' => $query->active(),
|
||||
'ignored' => $query->whereNotNull('ignored_at'),
|
||||
'provider_missing' => $query->whereNotNull('missing_from_provider_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
SelectFilter::make('has_versions')
|
||||
->label('Has versions')
|
||||
->options([
|
||||
@ -188,6 +212,7 @@ public function table(Table $table): Table
|
||||
])
|
||||
->emptyStateHeading('No matching policies available')
|
||||
->emptyStateDescription('Adjust the current filters or sync additional policies before adding them to this backup set.')
|
||||
->checkIfRecordIsSelectableUsing(fn (Policy $record): bool => $record->isCurrentBackupEligible())
|
||||
->bulkActions([
|
||||
BulkAction::make('add_selected_to_backup_set')
|
||||
->label('Add selected')
|
||||
@ -285,6 +310,20 @@ public function table(Table $table): Table
|
||||
|
||||
sort($policyIds);
|
||||
|
||||
$blocked = $records->first(
|
||||
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||
);
|
||||
|
||||
if ($blocked instanceof Policy) {
|
||||
Notification::make()
|
||||
->title('Current backup unavailable')
|
||||
->body($blocked->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($policyIds);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Concerns\InteractsWithODataTypes;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -15,12 +16,21 @@ class Policy extends Model
|
||||
use HasFactory;
|
||||
use InteractsWithODataTypes;
|
||||
|
||||
public const VISIBILITY_ACTIVE = 'active';
|
||||
|
||||
public const VISIBILITY_IGNORED_LOCALLY = 'ignored_locally';
|
||||
|
||||
public const VISIBILITY_PROVIDER_MISSING = 'provider_missing';
|
||||
|
||||
public const VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING = 'ignored_locally_provider_missing';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'last_synced_at' => 'datetime',
|
||||
'ignored_at' => 'datetime',
|
||||
'missing_from_provider_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
@ -38,16 +48,77 @@ public function backupItems(): HasMany
|
||||
return $this->hasMany(BackupItem::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('ignored_at');
|
||||
return $query
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at');
|
||||
}
|
||||
|
||||
public function scopeIgnored($query)
|
||||
public function scopeIgnored(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('ignored_at');
|
||||
}
|
||||
|
||||
public function scopeProviderMissing(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('missing_from_provider_at');
|
||||
}
|
||||
|
||||
public function scopeCurrentBackupEligible(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at');
|
||||
}
|
||||
|
||||
public function isIgnoredLocally(): bool
|
||||
{
|
||||
return $this->ignored_at !== null;
|
||||
}
|
||||
|
||||
public function isProviderMissing(): bool
|
||||
{
|
||||
return $this->missing_from_provider_at !== null;
|
||||
}
|
||||
|
||||
public function visibilityState(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isIgnoredLocally() && $this->isProviderMissing() => self::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING,
|
||||
$this->isIgnoredLocally() => self::VISIBILITY_IGNORED_LOCALLY,
|
||||
$this->isProviderMissing() => self::VISIBILITY_PROVIDER_MISSING,
|
||||
default => self::VISIBILITY_ACTIVE,
|
||||
};
|
||||
}
|
||||
|
||||
public function isCurrentBackupEligible(): bool
|
||||
{
|
||||
return ! $this->isIgnoredLocally() && ! $this->isProviderMissing();
|
||||
}
|
||||
|
||||
public function currentBackupBlockedReason(): ?string
|
||||
{
|
||||
if ($this->isProviderMissing()) {
|
||||
return self::VISIBILITY_PROVIDER_MISSING;
|
||||
}
|
||||
|
||||
if ($this->isIgnoredLocally()) {
|
||||
return self::VISIBILITY_IGNORED_LOCALLY;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function currentBackupBlockedReasonLabel(): ?string
|
||||
{
|
||||
return match ($this->currentBackupBlockedReason()) {
|
||||
self::VISIBILITY_PROVIDER_MISSING => 'Provider missing - current provider-backed capture is unavailable.',
|
||||
self::VISIBILITY_IGNORED_LOCALLY => 'Ignored locally - restore local visibility before fresh capture.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function ignore(): void
|
||||
{
|
||||
$this->update(['ignored_at' => now()]);
|
||||
|
||||
@ -43,7 +43,7 @@ public function createBackupSet(
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $policyIds)
|
||||
->whereNull('ignored_at')
|
||||
->currentBackupEligible()
|
||||
->get();
|
||||
|
||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
|
||||
@ -184,7 +184,7 @@ public function addPoliciesToSet(
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $policyIds)
|
||||
->whereNull('ignored_at')
|
||||
->currentBackupEligible()
|
||||
->get();
|
||||
|
||||
$metadata = $backupSet->metadata ?? [];
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Arr;
|
||||
@ -25,6 +26,7 @@ public function __construct(
|
||||
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||
private readonly ?ProviderGateway $providerGateway = null,
|
||||
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
|
||||
private readonly ?AuditLogger $auditLogger = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -54,6 +56,8 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
||||
$synced = [];
|
||||
$failures = [];
|
||||
$successfulPolicyTypes = [];
|
||||
$observedExternalIdsByPolicyType = [];
|
||||
|
||||
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
|
||||
|
||||
@ -110,6 +114,9 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
continue;
|
||||
}
|
||||
|
||||
$successfulPolicyTypes[$policyType] = true;
|
||||
$observedExternalIdsByPolicyType[$policyType] ??= [];
|
||||
|
||||
foreach ($response->data as $policyData) {
|
||||
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
|
||||
|
||||
@ -117,6 +124,8 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalId = (string) $externalId;
|
||||
|
||||
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
|
||||
|
||||
if ($canonicalPolicyType !== $policyType) {
|
||||
@ -127,52 +136,60 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||
|
||||
if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$observedExternalIdsByPolicyType[$policyType][] = $externalId;
|
||||
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
|
||||
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
||||
|
||||
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$this->reclassifyConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$policy = Policy::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => $policyType,
|
||||
],
|
||||
[
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'display_name' => $displayName,
|
||||
'platform' => $policyPlatform,
|
||||
'last_synced_at' => now(),
|
||||
'ignored_at' => null,
|
||||
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||
]
|
||||
);
|
||||
$policy = Policy::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => $policyType,
|
||||
]);
|
||||
$wasProviderMissing = $policy->exists && $policy->missing_from_provider_at !== null;
|
||||
|
||||
$policy->forceFill([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'display_name' => $displayName,
|
||||
'platform' => $policyPlatform,
|
||||
'last_synced_at' => now(),
|
||||
'missing_from_provider_at' => null,
|
||||
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
|
||||
$synced[] = $policy->id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->markProviderMissingPolicies(
|
||||
tenant: $tenant,
|
||||
policyTypes: array_keys($successfulPolicyTypes),
|
||||
observedExternalIdsByPolicyType: $observedExternalIdsByPolicyType,
|
||||
);
|
||||
|
||||
return [
|
||||
'synced' => $synced,
|
||||
'failures' => $failures,
|
||||
@ -338,7 +355,7 @@ private function isEnrollmentNotificationItem(array $policyData): bool
|
||||
], true);
|
||||
}
|
||||
|
||||
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
|
||||
{
|
||||
$enrollmentTypes = [
|
||||
'enrollmentRestriction',
|
||||
@ -353,45 +370,54 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId
|
||||
}
|
||||
|
||||
$existingCorrect = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->first();
|
||||
|
||||
if ($existingCorrect) {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $enrollmentTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
$this->markSiblingPoliciesProviderMissing(
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyTypes: $enrollmentTypes,
|
||||
exceptPolicyType: $policyType,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingWrong = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $enrollmentTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->first();
|
||||
|
||||
if (! $existingWrong) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
|
||||
|
||||
$existingWrong->forceFill([
|
||||
'policy_type' => $policyType,
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $existingWrong,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
|
||||
PolicyVersion::query()
|
||||
->where('policy_id', $existingWrong->id)
|
||||
->update(['policy_type' => $policyType]);
|
||||
}
|
||||
|
||||
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||
private function reclassifyConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
|
||||
{
|
||||
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
|
||||
|
||||
@ -400,44 +426,154 @@ private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $
|
||||
}
|
||||
|
||||
$existingCorrect = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->first();
|
||||
|
||||
if ($existingCorrect) {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $configurationTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
$this->markSiblingPoliciesProviderMissing(
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyTypes: $configurationTypes,
|
||||
exceptPolicyType: $policyType,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingWrong = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $configurationTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->first();
|
||||
|
||||
if (! $existingWrong) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
|
||||
|
||||
$existingWrong->forceFill([
|
||||
'policy_type' => $policyType,
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $existingWrong,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
|
||||
PolicyVersion::query()
|
||||
->where('policy_id', $existingWrong->id)
|
||||
->update(['policy_type' => $policyType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $policyTypes
|
||||
*/
|
||||
private function markSiblingPoliciesProviderMissing(Tenant $tenant, string $externalId, array $policyTypes, string $exceptPolicyType): void
|
||||
{
|
||||
$timestamp = now();
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->where('policy_type', '!=', $exceptPolicyType)
|
||||
->whereNull('missing_from_provider_at')
|
||||
->get()
|
||||
->each(function (Policy $policy) use ($tenant, $timestamp): void {
|
||||
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
|
||||
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingDetected,
|
||||
transitionAt: $timestamp,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $policyTypes
|
||||
* @param array<string, array<int, string>> $observedExternalIdsByPolicyType
|
||||
*/
|
||||
private function markProviderMissingPolicies(Tenant $tenant, array $policyTypes, array $observedExternalIdsByPolicyType): void
|
||||
{
|
||||
foreach ($policyTypes as $policyType) {
|
||||
if (! is_string($policyType) || $policyType === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$observedExternalIds = array_values(array_unique(array_filter(
|
||||
array_map('strval', $observedExternalIdsByPolicyType[$policyType] ?? []),
|
||||
static fn (string $externalId): bool => $externalId !== '',
|
||||
)));
|
||||
|
||||
$query = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', $policyType)
|
||||
->whereNull('missing_from_provider_at');
|
||||
|
||||
if ($observedExternalIds !== []) {
|
||||
$query->whereNotIn('external_id', $observedExternalIds);
|
||||
}
|
||||
|
||||
$timestamp = now();
|
||||
|
||||
$query->get()
|
||||
->each(function (Policy $policy) use ($tenant, $timestamp): void {
|
||||
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
|
||||
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingDetected,
|
||||
transitionAt: $timestamp,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function auditProviderPresenceTransition(
|
||||
Tenant $tenant,
|
||||
Policy $policy,
|
||||
AuditActionId $action,
|
||||
mixed $transitionAt = null,
|
||||
): void {
|
||||
$transitionAt ??= now();
|
||||
|
||||
$this->auditLogger()->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'transition_at' => method_exists($transitionAt, 'toIso8601String')
|
||||
? $transitionAt->toIso8601String()
|
||||
: (string) $transitionAt,
|
||||
'source' => 'policy_sync',
|
||||
],
|
||||
],
|
||||
resourceType: 'policy',
|
||||
resourceId: (string) $policy->getKey(),
|
||||
targetLabel: (string) $policy->display_name,
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
private function auditLogger(): AuditLogger
|
||||
{
|
||||
return $this->auditLogger ?? app(AuditLogger::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single policy from Graph and update local metadata.
|
||||
*/
|
||||
@ -506,13 +642,23 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
|
||||
|
||||
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
|
||||
$platform = $payload['platform'] ?? $policy->platform;
|
||||
$wasProviderMissing = $policy->missing_from_provider_at !== null;
|
||||
|
||||
$policy->forceFill([
|
||||
'display_name' => $displayName,
|
||||
'platform' => $platform,
|
||||
'last_synced_at' => now(),
|
||||
'missing_from_provider_at' => null,
|
||||
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -27,6 +27,9 @@ enum AuditActionId: string
|
||||
// Diagnostics / repair actions.
|
||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||
|
||||
case PolicyProviderMissingDetected = 'policy.provider_missing_detected';
|
||||
case PolicyProviderMissingCleared = 'policy.provider_missing_cleared';
|
||||
|
||||
// Managed tenant onboarding wizard.
|
||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||
@ -184,6 +187,8 @@ private static function labels(): array
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role change',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removal',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
|
||||
self::PolicyProviderMissingDetected->value => 'Policy provider missing detected',
|
||||
self::PolicyProviderMissingCleared->value => 'Policy provider missing cleared',
|
||||
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
||||
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
|
||||
self::ManagedTenantOnboardingDraftSelected->value => 'Managed tenant onboarding draft selected',
|
||||
@ -312,6 +317,8 @@ private static function summaries(): array
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removed',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection triggered',
|
||||
self::PolicyProviderMissingDetected->value => 'Policy marked provider missing',
|
||||
self::PolicyProviderMissingCleared->value => 'Policy provider presence restored',
|
||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
|
||||
@ -205,8 +205,8 @@ public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySum
|
||||
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
||||
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
||||
nextAction: $degradationFamilies === []
|
||||
? 'Open the version detail if you need raw settings or diff context.'
|
||||
: 'Prefer a stronger version or inspect the version detail before restore.',
|
||||
? $this->text('next_action_open_version_detail')
|
||||
: $this->text('next_action_prefer_stronger_version'),
|
||||
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
||||
);
|
||||
}
|
||||
@ -295,25 +295,25 @@ private function singleRecordHighlights(
|
||||
$highlights = [];
|
||||
|
||||
if ($snapshotMode === 'metadata_only') {
|
||||
$highlights[] = 'Metadata only';
|
||||
$highlights[] = $this->text('quality_highlight_metadata_only');
|
||||
}
|
||||
|
||||
if ($hasAssignmentIssues) {
|
||||
$highlights[] = 'Assignment fetch failed';
|
||||
$highlights[] = $this->text('quality_highlight_assignment_fetch_failed');
|
||||
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
|
||||
$highlights[] = 'Assignments captured separately';
|
||||
$highlights[] = $this->text('quality_highlight_assignments_captured_separately');
|
||||
}
|
||||
|
||||
if ($hasOrphanedAssignments) {
|
||||
$highlights[] = 'Orphaned assignments';
|
||||
$highlights[] = $this->text('quality_highlight_orphaned_assignments');
|
||||
}
|
||||
|
||||
if ($integrityWarning !== null) {
|
||||
$highlights[] = 'Integrity warning';
|
||||
$highlights[] = $this->text('quality_highlight_integrity_warning');
|
||||
}
|
||||
|
||||
if ($snapshotMode === 'unknown' && $highlights === []) {
|
||||
$highlights[] = 'Unknown quality';
|
||||
$highlights[] = $this->text('quality_highlight_unknown_quality');
|
||||
}
|
||||
|
||||
return array_values(array_unique($highlights));
|
||||
@ -326,9 +326,9 @@ private function compactSummaryFromHighlights(array $qualityHighlights, string $
|
||||
}
|
||||
|
||||
return match ($snapshotMode) {
|
||||
'full' => 'Full payload',
|
||||
'unknown' => 'Unknown quality',
|
||||
default => 'No degradations detected',
|
||||
'full' => $this->text('compact_summary_full_payload'),
|
||||
'unknown' => $this->text('compact_summary_unknown_quality'),
|
||||
default => $this->text('compact_summary_no_degradations_detected'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -336,15 +336,20 @@ private function singleRecordSummaryMessage(array $qualityHighlights, string $sn
|
||||
{
|
||||
if ($qualityHighlights === []) {
|
||||
return match ($snapshotMode) {
|
||||
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
||||
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
||||
default => 'No degradations were detected.',
|
||||
'full' => $this->text('summary_full_no_degradations'),
|
||||
'unknown' => $this->text('summary_unknown_quality'),
|
||||
default => $this->text('summary_no_degradations'),
|
||||
};
|
||||
}
|
||||
|
||||
return implode(' • ', $qualityHighlights).'.';
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.versions.'.$key, $replace);
|
||||
}
|
||||
|
||||
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
|
||||
{
|
||||
if ($totalItems === 0) {
|
||||
|
||||
@ -43,6 +43,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
|
||||
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
|
||||
BadgeDomain::PolicyProviderPresence->value => Domains\PolicyProviderPresenceBadge::class,
|
||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
|
||||
@ -34,6 +34,7 @@ enum BadgeDomain: string
|
||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||
case PolicyRestoreMode = 'policy_restore_mode';
|
||||
case PolicyRisk = 'policy_risk';
|
||||
case PolicyProviderPresence = 'policy_provider_presence';
|
||||
case IgnoredAt = 'ignored_at';
|
||||
case RestorePreviewDecision = 'restore_preview_decision';
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class PolicyProviderPresenceBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return match (BadgeCatalog::normalizeState($value)) {
|
||||
Policy::VISIBILITY_ACTIVE => new BadgeSpec(__('localization.policy.badges.active'), 'success', 'heroicon-m-check-circle'),
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY => new BadgeSpec(__('localization.policy.badges.ignored_locally'), 'warning', 'heroicon-m-eye-slash'),
|
||||
Policy::VISIBILITY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.source_unavailable'), 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.ignored_source_unavailable'), 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -13,8 +13,8 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'full' => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
||||
'metadata_only' => new BadgeSpec('Metadata only', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'full' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_full'), 'success', 'heroicon-m-check-circle'),
|
||||
'metadata_only' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_metadata_only'), 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -120,12 +120,12 @@ private static function platform(mixed $value): TagBadgeSpec
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'windows' => 'Windows',
|
||||
'android' => 'Android',
|
||||
'ios' => 'iOS',
|
||||
'macos' => 'macOS',
|
||||
'all' => 'All',
|
||||
'mobile' => 'Mobile',
|
||||
'windows' => __('localization.policy.common.platform_label_windows'),
|
||||
'android' => __('localization.policy.common.platform_label_android'),
|
||||
'ios' => __('localization.policy.common.platform_label_ios'),
|
||||
'macos' => __('localization.policy.common.platform_label_macos'),
|
||||
'all' => __('localization.policy.common.platform_label_all'),
|
||||
'mobile' => __('localization.policy.common.platform_label_mobile'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@ -141,7 +141,7 @@ public function supportsFilters(string $domainKey, string $subjectClass): bool
|
||||
public function groupLabel(string $domainKey, string $subjectClass): string
|
||||
{
|
||||
return match ([trim($domainKey), trim($subjectClass)]) {
|
||||
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => 'Intune policies',
|
||||
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => __('localization.policy.taxonomy.policies'),
|
||||
[GovernanceDomainKey::PlatformFoundation->value, GovernanceSubjectClass::ConfigurationResource->value] => 'Platform foundation configuration resources',
|
||||
[GovernanceDomainKey::Entra->value, GovernanceSubjectClass::Control->value] => 'Entra controls',
|
||||
default => trim($domainKey).' / '.trim($subjectClass),
|
||||
|
||||
@ -49,6 +49,18 @@ public function entryLabel(string $relationKey): string
|
||||
return OperationRunLinks::singularLabel();
|
||||
}
|
||||
|
||||
if ($relationKey === 'current_policy_version') {
|
||||
return __('localization.policy.versions.related_entry_current_policy_version');
|
||||
}
|
||||
|
||||
if ($relationKey === 'parent_policy') {
|
||||
return __('localization.policy.versions.related_entry_policy');
|
||||
}
|
||||
|
||||
if ($relationKey === 'policy_version') {
|
||||
return __('localization.policy.versions.related_entry_policy_version');
|
||||
}
|
||||
|
||||
return self::ENTRY_LABELS[$relationKey] ?? Str::headline(str_replace('_', ' ', $relationKey));
|
||||
}
|
||||
|
||||
@ -62,6 +74,14 @@ public function actionLabel(string $relationKey): string
|
||||
return OperationRunLinks::openLabel();
|
||||
}
|
||||
|
||||
if ($relationKey === 'parent_policy') {
|
||||
return __('localization.policy.versions.related_action_view_policy');
|
||||
}
|
||||
|
||||
if (in_array($relationKey, ['current_policy_version', 'policy_version'], true)) {
|
||||
return __('localization.policy.versions.related_action_view_policy_version');
|
||||
}
|
||||
|
||||
return self::ACTION_LABELS[$relationKey] ?? 'Open '.Str::headline(str_replace('_', ' ', $relationKey));
|
||||
}
|
||||
|
||||
|
||||
@ -54,12 +54,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
|
||||
return $this->resolved(
|
||||
descriptor: $descriptor,
|
||||
primaryLabel: (string) ($policy->display_name ?: 'Policy'),
|
||||
secondaryLabel: 'Policy #'.$policy->getKey(),
|
||||
primaryLabel: (string) ($policy->display_name ?: __('localization.policy.versions.related_entry_policy')),
|
||||
secondaryLabel: __('localization.policy.versions.reference_policy_number', ['id' => $policy->getKey()]),
|
||||
linkTarget: new ReferenceLinkTarget(
|
||||
targetKind: ReferenceClass::Policy->value,
|
||||
url: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant),
|
||||
actionLabel: 'View policy',
|
||||
actionLabel: __('localization.policy.versions.related_action_view_policy'),
|
||||
contextBadge: 'Tenant',
|
||||
),
|
||||
);
|
||||
|
||||
@ -53,7 +53,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
}
|
||||
|
||||
$policyName = $version->policy?->display_name;
|
||||
$secondary = 'Version '.(string) $version->version_number;
|
||||
$secondary = __('localization.policy.versions.reference_version_number', ['version' => (string) $version->version_number]);
|
||||
|
||||
if (is_string($version->capture_purpose?->value) && $version->capture_purpose->value !== '') {
|
||||
$secondary .= ' · '.str_replace('_', ' ', $version->capture_purpose->value);
|
||||
@ -61,12 +61,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
|
||||
return $this->resolved(
|
||||
descriptor: $descriptor,
|
||||
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : 'Policy version',
|
||||
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : __('localization.policy.versions.related_entry_policy_version'),
|
||||
secondaryLabel: $secondary,
|
||||
linkTarget: new ReferenceLinkTarget(
|
||||
targetKind: ReferenceClass::PolicyVersion->value,
|
||||
url: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant),
|
||||
actionLabel: 'View policy version',
|
||||
actionLabel: __('localization.policy.versions.related_action_view_policy_version'),
|
||||
contextBadge: 'Tenant',
|
||||
),
|
||||
);
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('policies') || Schema::hasColumn('policies', 'missing_from_provider_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('policies', function (Blueprint $table): void {
|
||||
$table->timestamp('missing_from_provider_at')->nullable()->after('ignored_at');
|
||||
$table->index('missing_from_provider_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('policies') || ! Schema::hasColumn('policies', 'missing_from_provider_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('policies', function (Blueprint $table): void {
|
||||
$table->dropIndex(['missing_from_provider_at']);
|
||||
$table->dropColumn('missing_from_provider_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -325,6 +325,188 @@
|
||||
'actions' => 'Aktionen',
|
||||
'open_approval_queue' => 'Freigabewarteschlange öffnen',
|
||||
],
|
||||
'policy' => [
|
||||
'common' => [
|
||||
'policy' => 'Richtlinie',
|
||||
'policies' => 'Richtlinien',
|
||||
'type' => 'Typ',
|
||||
'visibility' => 'Sichtbarkeit',
|
||||
'category' => 'Kategorie',
|
||||
'restore' => 'Wiederherstellen',
|
||||
'platform' => 'Plattform',
|
||||
'settings' => 'Einstellungen',
|
||||
'external_id' => 'Externe ID',
|
||||
'last_synced' => 'Zuletzt synchronisiert',
|
||||
'snapshot' => 'Snapshot',
|
||||
'version' => 'Version',
|
||||
'actor' => 'Akteur',
|
||||
'created' => 'Erstellt',
|
||||
'captured' => 'Erfasst',
|
||||
'platform_label_windows' => 'Windows',
|
||||
'platform_label_android' => 'Android',
|
||||
'platform_label_ios' => 'iOS',
|
||||
'platform_label_macos' => 'macOS',
|
||||
'platform_label_all' => 'Alle',
|
||||
'platform_label_mobile' => 'Mobil',
|
||||
'open_operation' => 'Operation öffnen',
|
||||
'more' => 'Mehr',
|
||||
'backup_name' => 'Backup-Name',
|
||||
'backup_name_default_prefix' => 'Backup',
|
||||
'source_microsoft_intune' => 'Quelle: Microsoft Intune',
|
||||
'type_delete_to_confirm' => 'Zur Bestätigung DELETE eingeben',
|
||||
'type_delete_to_confirm_validation' => 'Bitte DELETE zur Bestätigung eingeben.',
|
||||
'preview_only_dry_run' => 'Nur Vorschau (Dry-Run)',
|
||||
],
|
||||
'resource' => [
|
||||
'sync_action_primary' => 'Richtlinien synchronisieren',
|
||||
'sync_action_secondary' => 'Synchronisieren',
|
||||
'sync_modal_heading' => 'Richtlinien-Inventar synchronisieren',
|
||||
'sync_modal_description' => 'Diese Aktion reiht eine Hintergrundssynchronisierung für unterstützte Richtlinientypen im aktuellen Tenant ein.',
|
||||
'sync_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu synchronisieren.',
|
||||
'capture_snapshot_action' => 'Snapshot erfassen',
|
||||
'capture_snapshot_modal_heading' => 'Snapshot jetzt erfassen',
|
||||
'capture_snapshot_modal_subheading' => 'Diese Aktion reiht einen Hintergrundjob ein, der die aktuelle Konfiguration aus Microsoft Graph abruft und eine neue Richtlinienversion speichert.',
|
||||
'capture_snapshot_include_assignments' => 'Zuweisungen einschließen',
|
||||
'capture_snapshot_include_assignments_helper' => 'Erfasst Include-/Exclude-Ziele und Filter für Zuweisungen.',
|
||||
'capture_snapshot_include_scope_tags' => 'Scope-Tags einschließen',
|
||||
'capture_snapshot_include_scope_tags_helper' => 'Erfasst die Scope-Tag-IDs der Richtlinie.',
|
||||
'capture_snapshot_unavailable_title' => 'Snapshot-Erfassung nicht verfügbar',
|
||||
'capture_snapshot_in_progress_title' => 'Snapshot bereits in Arbeit',
|
||||
'capture_snapshot_in_progress_body' => 'Für diese Richtlinie existiert bereits ein aktiver Lauf. Laufdetails werden geöffnet.',
|
||||
'capture_snapshot_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien-Snapshots zu erfassen.',
|
||||
'visibility_source_unavailable_description' => 'Die verbundene Quelle hat diese Richtlinie nicht geliefert oder ist aktuell nicht verfügbar. Die historische Wiederherstellung bleibt verfügbar.',
|
||||
'visibility_source_unavailable_backup_items' => 'Die verbundene Quelle hat diese Richtlinie nicht geliefert oder ist aktuell nicht verfügbar. Historische Backup-Items bleiben für die Wiederherstellungsauswahl verfügbar.',
|
||||
'details_section' => 'Richtliniendetails',
|
||||
'tab_general' => 'Allgemein',
|
||||
'tab_json' => 'JSON',
|
||||
'general_field_name' => 'Name',
|
||||
'general_field_platforms' => 'Plattformen',
|
||||
'general_field_technologies' => 'Technologien',
|
||||
'general_field_template_reference' => 'Vorlagenreferenz',
|
||||
'general_field_setting_count' => 'Anzahl Einstellungen',
|
||||
'general_field_version' => 'Version',
|
||||
'general_field_last_modified' => 'Zuletzt geändert',
|
||||
'general_field_created' => 'Erstellt',
|
||||
'general_field_description' => 'Beschreibung',
|
||||
'general_empty_state' => 'Keine allgemeinen Metadaten verfügbar.',
|
||||
'general_fallback_field' => 'Feld',
|
||||
'template_fallback' => 'Vorlage',
|
||||
'settings_empty_state' => 'Noch kein Richtlinien-Snapshot verfügbar.',
|
||||
'settings_empty_state_helper' => 'Diese Richtlinie wurde inventarisiert, aber es wurde noch kein Konfigurations-Snapshot erfasst.',
|
||||
'snapshot_metadata_only_helper' => 'Graph lieferte für diesen Richtlinientyp :status zurück. Es wurden nur lokale Metadaten gespeichert; Einstellungen und Wiederherstellung sind erst verfügbar, wenn Graph wieder erfolgreich antwortet.',
|
||||
'graph_error_fallback' => 'einen Fehler',
|
||||
'snapshot_json_section' => 'Richtlinien-Snapshot (JSON)',
|
||||
'payload_size' => 'Payload-Größe',
|
||||
'large_payload_warning' => 'Großer Payload (:size KB) - kann die Performance beeinträchtigen',
|
||||
'settings_available' => 'Verfügbar',
|
||||
'settings_missing' => 'Fehlt',
|
||||
'filter_active' => 'Aktiv',
|
||||
'filter_ignored' => 'Lokal ignoriert',
|
||||
'filter_source_unavailable' => 'Quelle nicht verfügbar',
|
||||
'filter_all' => 'Alle',
|
||||
'export_to_backup' => 'Ins Backup exportieren',
|
||||
'current_backup_unavailable' => 'Aktuelles Backup nicht verfügbar',
|
||||
'restore_action' => 'Wiederherstellen',
|
||||
'restore_bulk_action' => 'Richtlinien wiederherstellen',
|
||||
'restore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien wiederherzustellen.',
|
||||
'policy_restored' => 'Richtlinie wiederhergestellt',
|
||||
'ignore_action' => 'Ignorieren',
|
||||
'ignore_bulk_action' => 'Richtlinien ignorieren',
|
||||
'ignore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu ignorieren.',
|
||||
'policy_ignored' => 'Richtlinie ignoriert',
|
||||
'empty_state_heading' => 'Noch keine Richtlinien im Inventory',
|
||||
'empty_state_description' => 'Starte eine Synchronisierung, um das Richtlinien-Inventar dieses Tenants mit Versionen, Wiederherstellbarkeit und Governance-Evidence aufzubauen.',
|
||||
'delete_queued_body' => 'Löschung für :count Richtlinien eingeplant.',
|
||||
],
|
||||
'versions' => [
|
||||
'backup_quality_section' => 'Backup-Qualität',
|
||||
'related_context_section' => 'Zugehöriger Kontext',
|
||||
'diff_tab' => 'Diff',
|
||||
'backup_quality' => 'Backup-Qualität',
|
||||
'snapshot_mode_full' => 'Vollständig',
|
||||
'snapshot_mode_metadata_only' => 'Nur Metadaten',
|
||||
'assignment_quality' => 'Zuweisungsqualität',
|
||||
'next_action' => 'Nächste Aktion',
|
||||
'integrity_note' => 'Integritätshinweis',
|
||||
'boundary' => 'Abgrenzung',
|
||||
'quality_highlight_metadata_only' => 'Nur Metadaten',
|
||||
'quality_highlight_assignment_fetch_failed' => 'Abruf der Zuweisungen fehlgeschlagen',
|
||||
'quality_highlight_assignments_captured_separately' => 'Zuweisungen separat erfasst',
|
||||
'quality_highlight_orphaned_assignments' => 'Verwaiste Zuweisungen erkannt',
|
||||
'quality_highlight_integrity_warning' => 'Integritätswarnung',
|
||||
'quality_highlight_unknown_quality' => 'Unbekannte Qualität',
|
||||
'compact_summary_full_payload' => 'Vollständige Nutzlast',
|
||||
'compact_summary_unknown_quality' => 'Unbekannte Qualität',
|
||||
'compact_summary_no_degradations_detected' => 'Keine Degradationen erkannt',
|
||||
'summary_full_no_degradations' => 'In Snapshot und Zuweisungsmetadaten wurden keine Degradationen erkannt.',
|
||||
'summary_unknown_quality' => 'Die Qualität ist unbekannt, weil diesem Datensatz ausreichende Vollständigkeitsmetadaten für eine stärkere Aussage fehlen.',
|
||||
'summary_no_degradations' => 'Es wurden keine Degradationen erkannt.',
|
||||
'next_action_open_version_detail' => 'Öffne die Versionsdetails, wenn du Roh-Einstellungen oder Diff-Kontext brauchst.',
|
||||
'next_action_prefer_stronger_version' => 'Bevorzuge eine stärkere Version oder prüfe die Versionsdetails vor der Wiederherstellung.',
|
||||
'raw_diff_advanced' => 'Rohdiff (erweitert)',
|
||||
'prune_versions' => 'Versionen bereinigen',
|
||||
'prune_modal_description' => 'Nur Versionen, die älter als das angegebene Aufbewahrungsfenster in Tagen sind, kommen infrage. Neuere Versionen werden übersprungen.',
|
||||
'retention_days' => 'Aufbewahrungstage',
|
||||
'retention_days_helper' => 'Versionen aus den letzten N Tagen werden übersprungen.',
|
||||
'manage_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinienversionen zu verwalten.',
|
||||
'restore_versions' => 'Versionen wiederherstellen',
|
||||
'restore_versions_modal_heading' => ':count Richtlinienversionen wiederherstellen?',
|
||||
'restore_versions_modal_description' => 'Archivierte Versionen werden in die aktive Liste zurückgeführt. Aktive Versionen werden übersprungen.',
|
||||
'force_delete_versions' => 'Versionen endgültig löschen',
|
||||
'force_delete_versions_modal_heading' => ':count Richtlinienversionen endgültig löschen?',
|
||||
'force_delete_versions_modal_description' => 'Dies ist endgültig. Nur archivierte Versionen werden dauerhaft gelöscht; aktive Versionen werden übersprungen.',
|
||||
'restore_via_wizard' => 'Über Assistent wiederherstellen',
|
||||
'restore_via_wizard_modal_heading' => 'Version :version über Assistent wiederherstellen?',
|
||||
'restore_via_wizard_modal_subheading' => 'Erstellt aus diesem Snapshot ein Backup-Set mit einem Element und öffnet den Wiederherstellungsassistenten vorausgefüllt.',
|
||||
'restore_run_permission_tooltip' => 'Sie haben keine Berechtigung, Wiederherstellungsläufe zu erstellen.',
|
||||
'metadata_only_tooltip' => 'Für reine Metadaten-Snapshots deaktiviert (Graph hat keine Richtlinieneinstellungen geliefert).',
|
||||
'restore_disabled_metadata_title' => 'Wiederherstellung für reinen Metadaten-Snapshot deaktiviert',
|
||||
'restore_disabled_metadata_body' => 'Dieser Snapshot enthält nur Metadaten; Graph hat keine Richtlinieneinstellungen für eine Wiederherstellung geliefert.',
|
||||
'different_tenant_title' => 'Richtlinienversion gehört zu einem anderen Tenant',
|
||||
'missing_policy_title' => 'Richtlinie für diese Version konnte nicht gefunden werden',
|
||||
'backup_set_name' => 'Richtlinienversions-Wiederherstellung - :policy - v:version',
|
||||
'archive' => 'Archivieren',
|
||||
'archived_title' => 'Richtlinienversion archiviert',
|
||||
'force_delete' => 'Endgültig löschen',
|
||||
'force_deleted_title' => 'Richtlinienversion dauerhaft gelöscht',
|
||||
'restored_title' => 'Richtlinienversion wiederhergestellt',
|
||||
'empty_state_heading' => 'Noch keine Richtlinienversionen',
|
||||
'empty_state_description' => 'Erfasse oder synchronisiere Richtlinien-Snapshots, um eine Versionshistorie aufzubauen.',
|
||||
'open_backup_sets' => 'Backup-Sets öffnen',
|
||||
'related_entry_current_policy_version' => 'Aktuelle Richtlinienversion',
|
||||
'related_entry_policy' => 'Richtlinie',
|
||||
'related_entry_policy_version' => 'Richtlinienversion',
|
||||
'related_action_view_policy' => 'Richtlinie anzeigen',
|
||||
'related_action_view_policy_version' => 'Richtlinienversion anzeigen',
|
||||
'reference_policy_number' => 'Richtlinie #:id',
|
||||
'reference_version_number' => 'Version :version',
|
||||
'related_record_fallback' => 'Zugehörigen Datensatz öffnen',
|
||||
'assignment_fetch_failed_orphaned' => 'Das Abrufen der Zuweisungen ist fehlgeschlagen und verwaiste Ziele wurden erkannt.',
|
||||
'assignment_fetch_failed' => 'Das Abrufen der Zuweisungen ist während der Erfassung fehlgeschlagen.',
|
||||
'assignment_orphaned' => 'Verwaiste Zuweisungsziele wurden erkannt.',
|
||||
'assignment_no_issues' => 'Aus den erfassten Metadaten wurden keine Zuweisungsprobleme erkannt.',
|
||||
'fallback_display_name' => 'Version :version',
|
||||
],
|
||||
'relation' => [
|
||||
'restore_to_microsoft_intune' => 'In Microsoft Intune wiederherstellen',
|
||||
'restore_heading' => 'Version :version in Microsoft Intune wiederherstellen?',
|
||||
'restore_subheading' => 'Erstellt einen Wiederherstellungslauf mit diesem Richtlinienversions-Snapshot.',
|
||||
'missing_context_title' => 'Tenant- oder Benutzerkontext fehlt.',
|
||||
'restore_run_failed_title' => 'Wiederherstellungslauf konnte nicht gestartet werden',
|
||||
'restore_run_started_title' => 'Wiederherstellungslauf gestartet',
|
||||
'no_versions_captured' => 'Noch keine Versionen erfasst',
|
||||
'no_versions_captured_description' => 'Erfasse oder synchronisiere diese Richtlinie erneut, um Versionshistorieneinträge zu erzeugen.',
|
||||
],
|
||||
'badges' => [
|
||||
'active' => 'Aktiv',
|
||||
'ignored_locally' => 'Lokal ignoriert',
|
||||
'source_unavailable' => 'Quelle nicht verfügbar',
|
||||
'ignored_source_unavailable' => 'Ignoriert + Quelle nicht verfügbar',
|
||||
],
|
||||
'taxonomy' => [
|
||||
'policies' => 'Richtlinien',
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
|
||||
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
|
||||
|
||||
@ -325,6 +325,188 @@
|
||||
'actions' => 'Actions',
|
||||
'open_approval_queue' => 'Open approval queue',
|
||||
],
|
||||
'policy' => [
|
||||
'common' => [
|
||||
'policy' => 'Policy',
|
||||
'policies' => 'Policies',
|
||||
'type' => 'Type',
|
||||
'visibility' => 'Visibility',
|
||||
'category' => 'Category',
|
||||
'restore' => 'Restore',
|
||||
'platform' => 'Platform',
|
||||
'settings' => 'Settings',
|
||||
'external_id' => 'External ID',
|
||||
'last_synced' => 'Last synced',
|
||||
'snapshot' => 'Snapshot',
|
||||
'version' => 'Version',
|
||||
'actor' => 'Actor',
|
||||
'created' => 'Created',
|
||||
'captured' => 'Captured',
|
||||
'platform_label_windows' => 'Windows',
|
||||
'platform_label_android' => 'Android',
|
||||
'platform_label_ios' => 'iOS',
|
||||
'platform_label_macos' => 'macOS',
|
||||
'platform_label_all' => 'All',
|
||||
'platform_label_mobile' => 'Mobile',
|
||||
'open_operation' => 'Open operation',
|
||||
'more' => 'More',
|
||||
'backup_name' => 'Backup name',
|
||||
'backup_name_default_prefix' => 'Backup',
|
||||
'source_microsoft_intune' => 'Source: Microsoft Intune',
|
||||
'type_delete_to_confirm' => 'Type DELETE to confirm',
|
||||
'type_delete_to_confirm_validation' => 'Please type DELETE to confirm.',
|
||||
'preview_only_dry_run' => 'Preview only (dry-run)',
|
||||
],
|
||||
'resource' => [
|
||||
'sync_action_primary' => 'Sync policies',
|
||||
'sync_action_secondary' => 'Sync',
|
||||
'sync_modal_heading' => 'Sync policy inventory',
|
||||
'sync_modal_description' => 'This queues a background sync operation for supported policy types in the current tenant.',
|
||||
'sync_permission_tooltip' => 'You do not have permission to sync policies.',
|
||||
'capture_snapshot_action' => 'Capture snapshot',
|
||||
'capture_snapshot_modal_heading' => 'Capture snapshot now',
|
||||
'capture_snapshot_modal_subheading' => 'This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.',
|
||||
'capture_snapshot_include_assignments' => 'Include assignments',
|
||||
'capture_snapshot_include_assignments_helper' => 'Captures assignment include/exclude targeting and filters.',
|
||||
'capture_snapshot_include_scope_tags' => 'Include scope tags',
|
||||
'capture_snapshot_include_scope_tags_helper' => 'Captures policy scope tag IDs.',
|
||||
'capture_snapshot_unavailable_title' => 'Snapshot capture unavailable',
|
||||
'capture_snapshot_in_progress_title' => 'Snapshot already in progress',
|
||||
'capture_snapshot_in_progress_body' => 'An active run already exists for this policy. Opening run details.',
|
||||
'capture_snapshot_permission_tooltip' => 'You do not have permission to capture policy snapshots.',
|
||||
'visibility_source_unavailable_description' => 'The connected source did not return this policy or is currently unavailable. Historical restore remains available.',
|
||||
'visibility_source_unavailable_backup_items' => 'The connected source did not return this policy or is currently unavailable. Historical backup items remain available for restore selection.',
|
||||
'details_section' => 'Policy details',
|
||||
'tab_general' => 'General',
|
||||
'tab_json' => 'JSON',
|
||||
'general_field_name' => 'Name',
|
||||
'general_field_platforms' => 'Platforms',
|
||||
'general_field_technologies' => 'Technologies',
|
||||
'general_field_template_reference' => 'Template reference',
|
||||
'general_field_setting_count' => 'Setting count',
|
||||
'general_field_version' => 'Version',
|
||||
'general_field_last_modified' => 'Last modified',
|
||||
'general_field_created' => 'Created',
|
||||
'general_field_description' => 'Description',
|
||||
'general_empty_state' => 'No general metadata available.',
|
||||
'general_fallback_field' => 'Field',
|
||||
'template_fallback' => 'Template',
|
||||
'settings_empty_state' => 'No policy snapshot available yet.',
|
||||
'settings_empty_state_helper' => 'This policy has been inventoried but no configuration snapshot has been captured yet.',
|
||||
'snapshot_metadata_only_helper' => 'Graph returned :status for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
||||
'graph_error_fallback' => 'an error',
|
||||
'snapshot_json_section' => 'Policy snapshot (JSON)',
|
||||
'payload_size' => 'Payload size',
|
||||
'large_payload_warning' => 'Large payload (:size KB) - may impact performance',
|
||||
'settings_available' => 'Available',
|
||||
'settings_missing' => 'Missing',
|
||||
'filter_active' => 'Active',
|
||||
'filter_ignored' => 'Ignored locally',
|
||||
'filter_source_unavailable' => 'Source unavailable',
|
||||
'filter_all' => 'All',
|
||||
'export_to_backup' => 'Export to backup',
|
||||
'current_backup_unavailable' => 'Current backup unavailable',
|
||||
'restore_action' => 'Restore',
|
||||
'restore_bulk_action' => 'Restore policies',
|
||||
'restore_permission_tooltip' => 'You do not have permission to restore policies.',
|
||||
'policy_restored' => 'Policy restored',
|
||||
'ignore_action' => 'Ignore',
|
||||
'ignore_bulk_action' => 'Ignore policies',
|
||||
'ignore_permission_tooltip' => 'You do not have permission to ignore policies.',
|
||||
'policy_ignored' => 'Policy ignored',
|
||||
'empty_state_heading' => 'No policies in inventory yet',
|
||||
'empty_state_description' => 'Run a sync to build this tenant\'s policy inventory, including versions, restore readiness, and governance evidence.',
|
||||
'delete_queued_body' => 'Queued deletion for :count policies.',
|
||||
],
|
||||
'versions' => [
|
||||
'backup_quality_section' => 'Backup quality',
|
||||
'related_context_section' => 'Related context',
|
||||
'diff_tab' => 'Diff',
|
||||
'backup_quality' => 'Backup quality',
|
||||
'snapshot_mode_full' => 'Full',
|
||||
'snapshot_mode_metadata_only' => 'Metadata only',
|
||||
'assignment_quality' => 'Assignment quality',
|
||||
'next_action' => 'Next action',
|
||||
'integrity_note' => 'Integrity note',
|
||||
'boundary' => 'Boundary',
|
||||
'quality_highlight_metadata_only' => 'Metadata only',
|
||||
'quality_highlight_assignment_fetch_failed' => 'Assignment fetch failed',
|
||||
'quality_highlight_assignments_captured_separately' => 'Assignments captured separately',
|
||||
'quality_highlight_orphaned_assignments' => 'Orphaned assignments',
|
||||
'quality_highlight_integrity_warning' => 'Integrity warning',
|
||||
'quality_highlight_unknown_quality' => 'Unknown quality',
|
||||
'compact_summary_full_payload' => 'Full payload',
|
||||
'compact_summary_unknown_quality' => 'Unknown quality',
|
||||
'compact_summary_no_degradations_detected' => 'No degradations detected',
|
||||
'summary_full_no_degradations' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
||||
'summary_unknown_quality' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
||||
'summary_no_degradations' => 'No degradations were detected.',
|
||||
'next_action_open_version_detail' => 'Open the version detail if you need raw settings or diff context.',
|
||||
'next_action_prefer_stronger_version' => 'Prefer a stronger version or inspect the version detail before restore.',
|
||||
'raw_diff_advanced' => 'Raw diff (advanced)',
|
||||
'prune_versions' => 'Prune versions',
|
||||
'prune_modal_description' => 'Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.',
|
||||
'retention_days' => 'Retention days',
|
||||
'retention_days_helper' => 'Versions captured within the last N days will be skipped.',
|
||||
'manage_permission_tooltip' => 'You do not have permission to manage policy versions.',
|
||||
'restore_versions' => 'Restore versions',
|
||||
'restore_versions_modal_heading' => 'Restore :count policy versions?',
|
||||
'restore_versions_modal_description' => 'Archived versions will be restored back to the active list. Active versions will be skipped.',
|
||||
'force_delete_versions' => 'Force delete versions',
|
||||
'force_delete_versions_modal_heading' => 'Force delete :count policy versions?',
|
||||
'force_delete_versions_modal_description' => 'This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.',
|
||||
'restore_via_wizard' => 'Restore via wizard',
|
||||
'restore_via_wizard_modal_heading' => 'Restore version :version via wizard?',
|
||||
'restore_via_wizard_modal_subheading' => 'Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.',
|
||||
'restore_run_permission_tooltip' => 'You do not have permission to create restore runs.',
|
||||
'metadata_only_tooltip' => 'Disabled for metadata-only snapshots (Graph did not provide policy settings).',
|
||||
'restore_disabled_metadata_title' => 'Restore disabled for metadata-only snapshot',
|
||||
'restore_disabled_metadata_body' => 'This snapshot only contains metadata; Graph did not provide policy settings to restore.',
|
||||
'different_tenant_title' => 'Policy version belongs to a different tenant',
|
||||
'missing_policy_title' => 'Policy could not be found for this version',
|
||||
'backup_set_name' => 'Policy version restore - :policy - v:version',
|
||||
'archive' => 'Archive',
|
||||
'archived_title' => 'Policy version archived',
|
||||
'force_delete' => 'Force delete',
|
||||
'force_deleted_title' => 'Policy version permanently deleted',
|
||||
'restored_title' => 'Policy version restored',
|
||||
'empty_state_heading' => 'No policy versions',
|
||||
'empty_state_description' => 'Capture or sync policy snapshots to build a version history.',
|
||||
'open_backup_sets' => 'Open backup sets',
|
||||
'related_entry_current_policy_version' => 'Current policy version',
|
||||
'related_entry_policy' => 'Policy',
|
||||
'related_entry_policy_version' => 'Policy version',
|
||||
'related_action_view_policy' => 'View policy',
|
||||
'related_action_view_policy_version' => 'View policy version',
|
||||
'reference_policy_number' => 'Policy #:id',
|
||||
'reference_version_number' => 'Version :version',
|
||||
'related_record_fallback' => 'Open related record',
|
||||
'assignment_fetch_failed_orphaned' => 'Assignment fetch failed and orphaned targets were detected.',
|
||||
'assignment_fetch_failed' => 'Assignment fetch failed during capture.',
|
||||
'assignment_orphaned' => 'Orphaned assignment targets were detected.',
|
||||
'assignment_no_issues' => 'No assignment issues were detected from captured metadata.',
|
||||
'fallback_display_name' => 'Version :version',
|
||||
],
|
||||
'relation' => [
|
||||
'restore_to_microsoft_intune' => 'Restore to Microsoft Intune',
|
||||
'restore_heading' => 'Restore version :version to Microsoft Intune?',
|
||||
'restore_subheading' => 'Creates a restore run using this policy version snapshot.',
|
||||
'missing_context_title' => 'Missing tenant or user context.',
|
||||
'restore_run_failed_title' => 'Restore run failed to start',
|
||||
'restore_run_started_title' => 'Restore run started',
|
||||
'no_versions_captured' => 'No versions captured',
|
||||
'no_versions_captured_description' => 'Capture or sync this policy again to create version history entries.',
|
||||
],
|
||||
'badges' => [
|
||||
'active' => 'Active',
|
||||
'ignored_locally' => 'Ignored locally',
|
||||
'source_unavailable' => 'Source unavailable',
|
||||
'ignored_source_unavailable' => 'Ignored + source unavailable',
|
||||
],
|
||||
'taxonomy' => [
|
||||
'policies' => 'Policies',
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Language override applied.',
|
||||
'locale_override_cleared' => 'Language override cleared.',
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($key) && $key !== '' ? $key : 'Field';
|
||||
$label = is_string($key) && $key !== '' ? $key : __('localization.policy.resource.general_fallback_field');
|
||||
|
||||
$cards[] = [
|
||||
'key' => $label,
|
||||
@ -92,23 +92,23 @@
|
||||
@endphp
|
||||
|
||||
@if (empty($cards))
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">No general metadata available.</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ __('localization.policy.resource.general_empty_state') }}</p>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($cards as $entry)
|
||||
@php
|
||||
$keyLower = $entry['key_lower'] ?? '';
|
||||
$value = $entry['value'] ?? null;
|
||||
$isPlatform = str_contains($keyLower, 'platform');
|
||||
$isPlatform = str_contains($keyLower, 'platform') || str_contains($keyLower, 'plattform');
|
||||
$isTechnologies = str_contains($keyLower, 'technolog');
|
||||
$isTemplateReference = str_contains($keyLower, 'template');
|
||||
$isTemplateReference = str_contains($keyLower, 'template') || str_contains($keyLower, 'vorlage');
|
||||
$isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null;
|
||||
$toneKey = match (true) {
|
||||
str_contains($keyLower, 'name') => 'name',
|
||||
str_contains($keyLower, 'platform') => 'platform',
|
||||
str_contains($keyLower, 'setting') => 'settings',
|
||||
str_contains($keyLower, 'template') => 'template',
|
||||
str_contains($keyLower, 'technology') => 'technology',
|
||||
str_contains($keyLower, 'platform') || str_contains($keyLower, 'plattform') => 'platform',
|
||||
str_contains($keyLower, 'setting') || str_contains($keyLower, 'einstellung') => 'settings',
|
||||
str_contains($keyLower, 'template') || str_contains($keyLower, 'vorlage') => 'template',
|
||||
str_contains($keyLower, 'technology') || str_contains($keyLower, 'technolog') => 'technology',
|
||||
default => 'default',
|
||||
};
|
||||
$tone = $toneMap[$toneKey] ?? $toneMap['default'];
|
||||
@ -152,7 +152,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }}
|
||||
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : __('localization.policy.resource.template_fallback') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
@ -58,3 +59,58 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('bulk export blocks provider-missing policies before creating items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
PolicyVersion::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'version_number' => 1,
|
||||
'snapshot' => ['test' => 'data'],
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$opRun = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'policy.export',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'policy-export-missing-test',
|
||||
'context' => [
|
||||
'policy_ids' => [$policy->id],
|
||||
'backup_name' => 'Missing Backup',
|
||||
],
|
||||
]);
|
||||
|
||||
$job = new BulkPolicyExportJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: [$policy->id],
|
||||
backupName: 'Missing Backup',
|
||||
backupDescription: null,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
$job->handle(app(OperationRunService::class));
|
||||
|
||||
$opRun->refresh();
|
||||
|
||||
expect($opRun->status)->toBe('completed')
|
||||
->and($opRun->outcome)->toBe('failed')
|
||||
->and($opRun->failure_summary[0]['code'] ?? null)->toBe('policy.provider_missing');
|
||||
|
||||
$backupSet = BackupSet::query()->where('name', 'Missing Backup')->firstOrFail();
|
||||
|
||||
expect($backupSet->status)->toBe('failed')
|
||||
->and((int) $backupSet->item_count)->toBe(0)
|
||||
->and($backupSet->items()->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -146,7 +146,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
]);
|
||||
});
|
||||
|
||||
test('backup service skips ignored policies', function () {
|
||||
test('backup service skips ignored and provider-missing policies', function () {
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
@ -194,14 +194,36 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
|
||||
$policyC = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-3',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Policy C',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
$policyD = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-4',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Policy D',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'ignored_at' => now(),
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\Intune\BackupService::class);
|
||||
$backupSet = $service->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [$policyA->id, $policyB->id],
|
||||
policyIds: [$policyA->id, $policyB->id, $policyC->id, $policyD->id],
|
||||
actorEmail: 'tester@example.com',
|
||||
actorName: 'Tester',
|
||||
);
|
||||
|
||||
expect($backupSet->item_count)->toBe(1);
|
||||
expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]);
|
||||
expect($policyD->currentBackupBlockedReason())->toBe(Policy::VISIBILITY_PROVIDER_MISSING);
|
||||
});
|
||||
|
||||
@ -81,6 +81,46 @@
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup set update queued');
|
||||
});
|
||||
|
||||
test('policy picker keeps provider-missing policies visible but blocks add run creation', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test backup',
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'display_name' => 'Provider missing policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
'last_synced_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetPolicyPickerTable::class, [
|
||||
'backupSetId' => $backupSet->id,
|
||||
])
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertCanSeeTableRecords([$policy])
|
||||
->assertSee('Provider missing')
|
||||
->callTableBulkAction('add_selected_to_backup_set', [$policy])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_set.update')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('policy picker table reuses an active run on double click (idempotency)', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
@ -144,6 +145,8 @@
|
||||
});
|
||||
|
||||
it('summarizes governed subjects, readiness, and save-forward feedback for current selector payloads', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
|
||||
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
|
||||
@ -158,7 +161,7 @@
|
||||
];
|
||||
|
||||
expect(BaselineProfileResource::scopeSummaryText($payload))
|
||||
->toBe('Intune policies: Device Configuration; Platform foundation configuration resources: Assignment Filter')
|
||||
->toBe(__('localization.policy.taxonomy.policies').': Device Configuration; Platform foundation configuration resources: Assignment Filter')
|
||||
->and(BaselineProfileResource::scopeSupportReadinessText($payload))
|
||||
->toBe('Capture: ready. Compare: ready.')
|
||||
->and(BaselineProfileResource::scopeSelectionFeedbackText($payload))
|
||||
@ -166,6 +169,8 @@
|
||||
});
|
||||
|
||||
it('shows normalization lineage on the baseline profile detail surface before a legacy row is saved forward', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
|
||||
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
|
||||
@ -194,7 +199,7 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profileId])
|
||||
->assertSee('Governed subject summary')
|
||||
->assertSee('Intune policies: Device Configuration')
|
||||
->assertSee(__('localization.policy.taxonomy.policies').': Device Configuration')
|
||||
->assertSee('Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.');
|
||||
});
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -67,6 +68,8 @@ function makeWorkspaceListComponent(string $role = 'owner'): Testable
|
||||
}
|
||||
|
||||
it('defines the policies empty state contract and keeps the sync CTA outcome intact', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
Queue::fake();
|
||||
bindFailHardGraphClient();
|
||||
|
||||
@ -78,19 +81,19 @@ function makeWorkspaceListComponent(string $role = 'owner'): Testable
|
||||
|
||||
$component = Livewire::test(ListPolicies::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['sync'])
|
||||
->assertSee('No policies synced yet')
|
||||
->assertSee('Sync your first tenant to see Intune policies here.');
|
||||
->assertSee(__('localization.policy.resource.empty_state_heading'))
|
||||
->assertSee(__('localization.policy.resource.empty_state_description'));
|
||||
|
||||
$table = getFeature122EmptyStateTable($component);
|
||||
|
||||
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
|
||||
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
|
||||
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.resource.empty_state_description'));
|
||||
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path');
|
||||
|
||||
$action = getFeature122EmptyStateAction($component, 'sync');
|
||||
|
||||
expect($action)->not->toBeNull();
|
||||
expect($action?->getLabel())->toBe('Sync from Intune');
|
||||
expect($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'));
|
||||
|
||||
$component
|
||||
->mountAction('sync')
|
||||
|
||||
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function getPolicyInventoryEmptyStateAction(Testable $component, string $name): ?Action
|
||||
{
|
||||
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
||||
if ($action instanceof Action && $action->getName() === $name) {
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('renders policy inventory list copy from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ListPolicies::class)
|
||||
->assertSee(__('localization.policy.common.policies'))
|
||||
->assertSee(__('localization.policy.resource.empty_state_heading'))
|
||||
->assertSee(__('localization.policy.resource.empty_state_description'));
|
||||
|
||||
$action = getPolicyInventoryEmptyStateAction($component, 'sync');
|
||||
|
||||
expect($action)->not->toBeNull()
|
||||
->and($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'));
|
||||
});
|
||||
|
||||
it('renders source-unavailable policy labels from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'German Source Unavailable Policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Livewire::test(ListPolicies::class)
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertSee(__('localization.policy.badges.source_unavailable'));
|
||||
|
||||
$badge = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'provider_missing');
|
||||
|
||||
expect($badge->label)->toBe(__('localization.policy.badges.source_unavailable'));
|
||||
});
|
||||
|
||||
it('renders policy version list copy from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListPolicyVersions::class)
|
||||
->assertSee(__('localization.policy.versions.empty_state_heading'))
|
||||
->assertSee(__('localization.policy.versions.empty_state_description'))
|
||||
->assertSee(__('localization.policy.versions.open_backup_sets'));
|
||||
});
|
||||
|
||||
it('renders the restore-to-Microsoft-Intune action from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(VersionsRelationManager::class, [
|
||||
'ownerRecord' => $policy,
|
||||
'pageClass' => ViewPolicy::class,
|
||||
])->assertSee(__('localization.policy.relation.restore_to_microsoft_intune'));
|
||||
});
|
||||
|
||||
it('renders policy version quality and related labels from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Windows Policy',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'platform' => 'all',
|
||||
'snapshot' => ['id' => 'policy-1'],
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(ListPolicyVersions::class)
|
||||
->assertSee(__('localization.policy.common.captured'))
|
||||
->assertSee(__('localization.policy.versions.snapshot_mode_full'))
|
||||
->assertSee(__('localization.policy.versions.compact_summary_full_payload'))
|
||||
->assertSee(__('localization.policy.versions.next_action_open_version_detail'))
|
||||
->assertSee(__('localization.policy.versions.related_action_view_policy'))
|
||||
->assertSee(__('localization.policy.common.platform_label_all'));
|
||||
});
|
||||
|
||||
it('renders policy detail and capture-snapshot copy from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Enrollment Notifications',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Enrollment Notifications',
|
||||
'platforms' => 'all',
|
||||
'lastModifiedDateTime' => '2026-01-04T11:22:52Z',
|
||||
'createdDateTime' => '2026-01-04T11:22:52Z',
|
||||
],
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['tab' => 'general::tab'])
|
||||
->test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
|
||||
->assertSee(__('localization.policy.resource.capture_snapshot_action'))
|
||||
->assertSee(__('localization.policy.resource.details_section'))
|
||||
->assertSee(__('localization.policy.resource.tab_general'))
|
||||
->assertSee(__('localization.policy.resource.general_field_platforms'))
|
||||
->assertSee(__('localization.policy.common.platform_label_all'))
|
||||
->assertSee(__('localization.policy.resource.general_field_last_modified'))
|
||||
->assertSee(__('localization.policy.resource.general_field_created'));
|
||||
|
||||
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
|
||||
->assertActionExists('capture_snapshot', function (Action $action): bool {
|
||||
return $action->getLabel() === __('localization.policy.resource.capture_snapshot_action')
|
||||
&& $action->isConfirmationRequired()
|
||||
&& (string) $action->getModalHeading() === __('localization.policy.resource.capture_snapshot_modal_heading')
|
||||
&& str_contains((string) $action->getModalDescription(), __('localization.policy.resource.capture_snapshot_modal_subheading'))
|
||||
&& str_contains((string) $action->getModalDescription(), __('localization.policy.common.source_microsoft_intune'));
|
||||
});
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
@ -46,6 +47,8 @@
|
||||
});
|
||||
|
||||
test('policy list keeps the standard table defaults and persists state in-session', function () {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -62,7 +65,7 @@
|
||||
$table = $component->instance()->getTable();
|
||||
|
||||
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
|
||||
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Models\Policy;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('filters active, ignored, and provider-missing policy states distinctly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$active = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Active policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
]);
|
||||
|
||||
$missing = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Provider missing policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$combined = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Ignored missing policy',
|
||||
'ignored_at' => now()->subDay(),
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicies::class)
|
||||
->assertCanSeeTableRecords([$active])
|
||||
->assertCanNotSeeTableRecords([$missing, $combined])
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertCanSeeTableRecords([$missing, $combined])
|
||||
->assertCanNotSeeTableRecords([$active])
|
||||
->set('tableFilters.visibility.value', 'ignored')
|
||||
->assertCanSeeTableRecords([$combined])
|
||||
->assertCanNotSeeTableRecords([$active, $missing]);
|
||||
});
|
||||
|
||||
it('keeps provider-missing sync retry available and current export disabled', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Provider missing policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicies::class)
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertCanSeeTableRecords([$policy])
|
||||
->assertSee(__('localization.policy.badges.source_unavailable'))
|
||||
->assertTableActionEnabled('sync', $policy)
|
||||
->assertTableActionDisabled('export', $policy);
|
||||
});
|
||||
@ -11,8 +11,11 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
it('shows parent policy and snapshot evidence links for policy versions', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
@ -57,6 +60,6 @@
|
||||
|
||||
$this->get(PolicyVersionResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('View policy')
|
||||
->assertSee(__('localization.policy.versions.related_action_view_policy'))
|
||||
->assertSee(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant), false);
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('restore selection options are grouped and filter ignored policies', function () {
|
||||
test('restore selection options are grouped and preserve provider-missing continuity', function () {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
@ -39,9 +39,26 @@
|
||||
'platform' => 'windows',
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
$providerMissingPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-provider-missing',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Provider Missing Policy',
|
||||
'platform' => 'windows',
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
$combinedPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-ignored-provider-missing',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Ignored Provider Missing Policy',
|
||||
'platform' => 'windows',
|
||||
'ignored_at' => now(),
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'item_count' => 4,
|
||||
'item_count' => 6,
|
||||
]);
|
||||
|
||||
$policyItem = BackupItem::factory()
|
||||
@ -68,6 +85,30 @@
|
||||
])
|
||||
->create();
|
||||
|
||||
$providerMissingItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => $providerMissingPolicy->id,
|
||||
'policy_identifier' => $providerMissingPolicy->external_id,
|
||||
'policy_type' => $providerMissingPolicy->policy_type,
|
||||
'platform' => $providerMissingPolicy->platform,
|
||||
'payload' => ['id' => $providerMissingPolicy->external_id],
|
||||
])
|
||||
->create();
|
||||
|
||||
$combinedItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => $combinedPolicy->id,
|
||||
'policy_identifier' => $combinedPolicy->external_id,
|
||||
'policy_type' => $combinedPolicy->policy_type,
|
||||
'platform' => $combinedPolicy->platform,
|
||||
'payload' => ['id' => $combinedPolicy->external_id],
|
||||
])
|
||||
->create();
|
||||
|
||||
$scopeTagItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
@ -134,6 +175,14 @@
|
||||
|
||||
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
|
||||
|
||||
expect($flattenedOptions)->toHaveKey($providerMissingItem->id);
|
||||
expect($flattenedOptions[$providerMissingItem->id])->toContain('Provider Missing Policy')
|
||||
->and($flattenedOptions[$providerMissingItem->id])->toContain('provider missing now');
|
||||
|
||||
expect($flattenedOptions)->toHaveKey($combinedItem->id);
|
||||
expect($flattenedOptions[$combinedItem->id])->toContain('Ignored Provider Missing Policy')
|
||||
->and($flattenedOptions[$combinedItem->id])->toContain('provider missing now');
|
||||
|
||||
expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
|
||||
expect($flattenedOptions[$scopeTagItem->id])->toContain('Scope Tag Alpha');
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -40,6 +41,8 @@ function spec125BaselineTenantContext(): array
|
||||
}
|
||||
|
||||
it('keeps the policy resource list as the baseline resource-standard example', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user] = spec125BaselineTenantContext();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ListPolicies::class)
|
||||
@ -53,8 +56,8 @@ function spec125BaselineTenantContext(): array
|
||||
expect($table->persistsSearchInSession())->toBeTrue();
|
||||
expect($table->persistsSortInSession())->toBeTrue();
|
||||
expect($table->persistsFiltersInSession())->toBeTrue();
|
||||
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
|
||||
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
|
||||
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.resource.empty_state_description'));
|
||||
expect(array_keys($table->getVisibleColumns()))->toContain('display_name', 'policy_type', 'platform', 'last_synced_at');
|
||||
|
||||
$displayName = $table->getColumn('display_name');
|
||||
@ -70,6 +73,8 @@ function spec125BaselineTenantContext(): array
|
||||
});
|
||||
|
||||
it('keeps the policy versions relation manager on the standard relation-manager contract', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = spec125BaselineTenantContext();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
@ -86,8 +91,8 @@ function spec125BaselineTenantContext(): array
|
||||
expect($table->getDefaultSortColumn())->toBe('version_number');
|
||||
expect($table->getDefaultSortDirection())->toBe('desc');
|
||||
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::relationManager());
|
||||
expect($table->getEmptyStateHeading())->toBe('No versions captured');
|
||||
expect($table->getEmptyStateDescription())->toBe('Capture or sync this policy again to create version history entries.');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.relation.no_versions_captured'));
|
||||
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.relation.no_versions_captured_description'));
|
||||
expect($table->getColumn('version_number')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('captured_at')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('policy_type')?->isToggleable())->toBeTrue();
|
||||
|
||||
@ -96,7 +96,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
->where('external_id', 'config-1')
|
||||
->firstOrFail();
|
||||
|
||||
expect($existingConfig->ignored_at)->not->toBeNull();
|
||||
expect($existingConfig->ignored_at)->toBeNull();
|
||||
expect($existingConfig->missing_from_provider_at)->not->toBeNull();
|
||||
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'config-skip')->exists())->toBeFalse();
|
||||
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->exists())->toBeTrue();
|
||||
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->whereNull('missing_from_provider_at')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
@ -47,7 +47,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
}
|
||||
}
|
||||
|
||||
test('sync revives ignored policies when they exist in Intune', function () {
|
||||
test('sync preserves local ignore when policies still exist in Intune', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'test-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
@ -88,13 +88,14 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
// Refresh the policy
|
||||
$policy->refresh();
|
||||
|
||||
// Policy should no longer be ignored
|
||||
expect($policy->ignored_at)->toBeNull();
|
||||
// Provider reappearance updates local metadata, but only a user action clears local ignore.
|
||||
expect($policy->ignored_at)->not->toBeNull();
|
||||
expect($policy->missing_from_provider_at)->toBeNull();
|
||||
expect($policy->display_name)->toBe('Test Policy (Updated)');
|
||||
expect($policy->last_synced_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('sync creates new policies even if ignored ones exist with same external_id', function () {
|
||||
test('sync updates ignored policies without reviving them', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'test-tenant-2',
|
||||
'name' => 'Test Tenant 2',
|
||||
@ -149,15 +150,17 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
// Sync policies
|
||||
app(PolicySyncService::class)->syncPolicies($tenant);
|
||||
|
||||
// All policies should now be active
|
||||
expect(Policy::active()->count())->toBe(2);
|
||||
expect(Policy::ignored()->count())->toBe(0);
|
||||
// Both provider-visible policies remain locally ignored until explicitly restored.
|
||||
expect(Policy::active()->count())->toBe(0);
|
||||
expect(Policy::ignored()->count())->toBe(2);
|
||||
|
||||
$policyAbc = Policy::where('external_id', 'policy-abc')->first();
|
||||
expect($policyAbc->display_name)->toBe('Restored Policy ABC');
|
||||
expect($policyAbc->ignored_at)->toBeNull();
|
||||
expect($policyAbc->ignored_at)->not->toBeNull();
|
||||
expect($policyAbc->missing_from_provider_at)->toBeNull();
|
||||
|
||||
$policyDef = Policy::where('external_id', 'policy-def')->first();
|
||||
expect($policyDef->display_name)->toBe('Restored Policy DEF');
|
||||
expect($policyDef->ignored_at)->toBeNull();
|
||||
expect($policyDef->ignored_at)->not->toBeNull();
|
||||
expect($policyDef->missing_from_provider_at)->toBeNull();
|
||||
});
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Policy;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function tenantWithDefaultMicrosoftConnectionForProviderMissing(array $attributes = []): Tenant
|
||||
{
|
||||
$tenant = Tenant::factory()->create($attributes + [
|
||||
'status' => 'active',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
'consent_status' => 'granted',
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'type' => 'client_secret',
|
||||
'payload' => [
|
||||
'client_id' => 'provider-client-'.$tenant->getKey(),
|
||||
'client_secret' => 'provider-secret-'.$tenant->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
it('marks previously observed policies missing when provider list omits them', function (): void {
|
||||
$tenant = tenantWithDefaultMicrosoftConnectionForProviderMissing();
|
||||
|
||||
$present = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-present',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Old present',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
]);
|
||||
|
||||
$missing = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-missing',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Missing from provider',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
]);
|
||||
|
||||
mock(GraphLogger::class)
|
||||
->shouldReceive('logRequest', 'logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->once()
|
||||
->with('deviceConfiguration', mockery::type('array'))
|
||||
->andReturn(new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'policy-present',
|
||||
'displayName' => 'Provider present',
|
||||
'platform' => 'windows',
|
||||
],
|
||||
],
|
||||
));
|
||||
|
||||
app(PolicySyncService::class)->syncPolicies($tenant, [
|
||||
['type' => 'deviceConfiguration', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
$present->refresh();
|
||||
$missing->refresh();
|
||||
|
||||
expect($present->display_name)->toBe('Provider present')
|
||||
->and($present->ignored_at)->toBeNull()
|
||||
->and($present->missing_from_provider_at)->toBeNull()
|
||||
->and($missing->ignored_at)->toBeNull()
|
||||
->and($missing->missing_from_provider_at)->not->toBeNull()
|
||||
->and($missing->visibilityState())->toBe(Policy::VISIBILITY_PROVIDER_MISSING);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('action', AuditActionId::PolicyProviderMissingDetected->value)
|
||||
->where('resource_id', (string) $missing->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('clears provider missing on reappearance without clearing local ignore', function (): void {
|
||||
$tenant = tenantWithDefaultMicrosoftConnectionForProviderMissing();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-returned',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Returned policy',
|
||||
'ignored_at' => now()->subDay(),
|
||||
'missing_from_provider_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
mock(GraphLogger::class)
|
||||
->shouldReceive('logRequest', 'logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->once()
|
||||
->with('deviceConfiguration', mockery::type('array'))
|
||||
->andReturn(new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'policy-returned',
|
||||
'displayName' => 'Returned from provider',
|
||||
'platform' => 'windows',
|
||||
],
|
||||
],
|
||||
));
|
||||
|
||||
app(PolicySyncService::class)->syncPolicies($tenant, [
|
||||
['type' => 'deviceConfiguration', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
$policy->refresh();
|
||||
|
||||
expect($policy->display_name)->toBe('Returned from provider')
|
||||
->and($policy->ignored_at)->not->toBeNull()
|
||||
->and($policy->missing_from_provider_at)->toBeNull()
|
||||
->and($policy->visibilityState())->toBe(Policy::VISIBILITY_IGNORED_LOCALLY);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('action', AuditActionId::PolicyProviderMissingCleared->value)
|
||||
->where('resource_id', (string) $policy->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
@ -40,7 +40,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
it('marks targeted managed app configurations as ignored during sync', function () {
|
||||
it('marks targeted managed app configurations as provider missing during sync', function () {
|
||||
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
@ -82,7 +82,8 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
|
||||
$policy->refresh();
|
||||
|
||||
expect($policy->ignored_at)->not->toBeNull();
|
||||
expect($policy->ignored_at)->toBeNull();
|
||||
expect($policy->missing_from_provider_at)->not->toBeNull();
|
||||
expect($synced)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
@ -338,6 +339,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(Policy::query()
|
||||
@ -345,13 +347,14 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
->where('external_id', 'esp-1')
|
||||
->where('policy_type', 'endpointSecurityPolicy')
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->where('policy_type', 'settingsCatalogPolicy')
|
||||
->whereNull('ignored_at')
|
||||
->whereNotNull('missing_from_provider_at')
|
||||
->count())->toBe(0);
|
||||
|
||||
$version->refresh();
|
||||
|
||||
@ -61,6 +61,7 @@
|
||||
|
||||
expect($version->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||
expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||
expect($policy->missing_from_provider_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('reclassify command can detect ESP even when a policy has no versions', function () {
|
||||
@ -103,4 +104,56 @@
|
||||
|
||||
$policy->refresh();
|
||||
expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage');
|
||||
expect($policy->missing_from_provider_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('reclassify command marks duplicate wrong rows provider missing instead of ignored', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-reclassify-duplicate',
|
||||
'name' => 'Tenant Reclassify Duplicate',
|
||||
'metadata' => [],
|
||||
'is_current' => true,
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$wrong = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'esp-duplicate',
|
||||
'policy_type' => 'enrollmentRestriction',
|
||||
'display_name' => 'ESP Duplicate Wrong',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'esp-duplicate',
|
||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||
'display_name' => 'ESP Duplicate Correct',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
PolicyVersion::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $wrong->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => 'enrollmentRestriction',
|
||||
'platform' => 'all',
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => CarbonImmutable::now(),
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration',
|
||||
'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration',
|
||||
'displayName' => 'ESP Duplicate Wrong',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$wrong->refresh();
|
||||
|
||||
expect($wrong->policy_type)->toBe('enrollmentRestriction')
|
||||
->and($wrong->ignored_at)->toBeNull()
|
||||
->and($wrong->missing_from_provider_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -5,14 +5,37 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
it('maps policy provider presence values to canonical badge semantics', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
$active = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'active');
|
||||
expect($active->label)->toBe(__('localization.policy.badges.active'));
|
||||
expect($active->color)->toBe('success');
|
||||
|
||||
$ignored = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'ignored_locally');
|
||||
expect($ignored->label)->toBe(__('localization.policy.badges.ignored_locally'));
|
||||
expect($ignored->color)->toBe('warning');
|
||||
|
||||
$missing = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'provider_missing');
|
||||
expect($missing->label)->toBe(__('localization.policy.badges.source_unavailable'));
|
||||
expect($missing->color)->toBe('warning');
|
||||
|
||||
$combined = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'ignored_locally_provider_missing');
|
||||
expect($combined->label)->toBe(__('localization.policy.badges.ignored_source_unavailable'));
|
||||
expect($combined->color)->toBe('danger');
|
||||
});
|
||||
|
||||
it('maps policy snapshot mode values to canonical badge semantics', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
$full = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'full');
|
||||
expect($full->label)->toBe('Full');
|
||||
expect($full->label)->toBe(__('localization.policy.versions.snapshot_mode_full'));
|
||||
expect($full->color)->toBe('success');
|
||||
|
||||
$metadataOnly = BadgeCatalog::spec(BadgeDomain::PolicySnapshotMode, 'metadata_only');
|
||||
expect($metadataOnly->label)->toBe('Metadata only');
|
||||
expect($metadataOnly->label)->toBe(__('localization.policy.versions.snapshot_mode_metadata_only'));
|
||||
expect($metadataOnly->color)->toBe('warning');
|
||||
});
|
||||
|
||||
|
||||
@ -10,8 +10,6 @@ # Discoveries
|
||||
|
||||
Items that are already tracked in [spec-candidates.md](spec-candidates.md) or [roadmap.md](roadmap.md) should not remain here.
|
||||
|
||||
**Last reviewed**: 2026-04-30
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 — 2026-03-15 architecture hardening cluster moved out of discoveries
|
||||
|
||||
@ -4,6 +4,7 @@ # TenantPilot Implementation Ledger
|
||||
> **Last reviewed:** 2026-04-30
|
||||
> **Use for:** Repo-based implementation status and product-surface maturity assessment
|
||||
> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch
|
||||
> **Scoped maintenance:** 2026-05-01 provider-missing / Spec 261 alignment and doc hygiene only; no full repo-wide maturity re-audit was performed.
|
||||
|
||||
## Purpose
|
||||
|
||||
@ -57,7 +58,7 @@ ## Roadmap Coverage Summary
|
||||
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
|
||||
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real, aber die Customer-Review-Consumption ist noch nicht voll productized. |
|
||||
| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. |
|
||||
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
|
||||
| Provider-missing policy visibility follow-up | specified | weak | no | no | no | Spec 261 ist als schmaler policy-only Follow-up vorbereitet; die breitere Lifecycle-Taxonomie bleibt strategisch und unimplementiert. |
|
||||
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
|
||||
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
|
||||
| Private AI Execution Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
|
||||
@ -129,7 +130,7 @@ ## Planned But Not Implemented
|
||||
- Standardization & Policy Quality / Intune Linting
|
||||
- PSA / Ticketing Handoff
|
||||
- Cross-Tenant Compare and Promotion v1
|
||||
- Policy Lifecycle / Ghost Policies
|
||||
- Provider-Missing Policy Visibility & Restore Continuity v1 (Spec 261, specified only)
|
||||
- Later compliance overlays beyond the current control/evidence foundation
|
||||
|
||||
## Release Readiness
|
||||
@ -302,4 +303,4 @@ ## Evidence Sources
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-04-29 on branch `platform-dev`
|
||||
2026-05-01 on branch `261-provider-missing-policy-visibility` (scoped provider-missing/docs alignment only)
|
||||
|
||||
@ -8,8 +8,6 @@ # Operator Semantic Taxonomy
|
||||
> Canonical operator-facing state reference for the first implementation slice.
|
||||
> Downstream specs and badge mappings must reuse this vocabulary instead of inventing local synonyms.
|
||||
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
@ -8,8 +8,6 @@ # Product Principles
|
||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||
> New specs must align with these. If a principle needs to change, update this file first.
|
||||
|
||||
**Last reviewed**: 2026-04-09
|
||||
|
||||
---
|
||||
|
||||
## Identity & Isolation
|
||||
|
||||
@ -4,14 +4,13 @@ # Product Roadmap
|
||||
> **Last reviewed:** 2026-04-30
|
||||
> **Use for:** Current product roadmap, release themes, and prioritization context
|
||||
> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification
|
||||
> **Scoped maintenance:** 2026-05-01 lifecycle/provider-missing wording alignment only; no full roadmap re-review was performed.
|
||||
>
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
>
|
||||
> Queue boundary: the active candidate queue lives in `spec-candidates.md`; older audit-derived candidate packages are historical inputs only.
|
||||
|
||||
**Last updated**: 2026-04-30
|
||||
|
||||
---
|
||||
|
||||
## Release History
|
||||
@ -173,6 +172,8 @@ ### Workspace, Tenant & Managed Object Lifecycle Governance
|
||||
|
||||
**Important boundary**: Do not implement a narrow policy-only ghost lifecycle patch, Laravel `SoftDeletes` rollout, workspace deletion flow, tenant deletion flow, purge engine, or retention framework before this lifecycle taxonomy is agreed.
|
||||
|
||||
**Approved narrow exception**: Spec 261 (`provider-missing-policy-visibility`) now captures the bounded policy-only provider-missing truth correction. Keep future lifecycle, deletion, retention, and purge work taxonomy-first; do not generalize Spec 261 into the broader lifecycle model.
|
||||
|
||||
**Spec candidate**: `Workspace, Tenant & Managed Object Lifecycle Governance v1` in `docs/product/spec-candidates.md`.
|
||||
|
||||
### Platform Operations Maturity
|
||||
|
||||
@ -4,12 +4,12 @@ # Spec Candidates
|
||||
> **Last reviewed:** 2026-04-30
|
||||
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
|
||||
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
|
||||
> **Scoped maintenance:** 2026-05-01 provider-missing / Spec 261 alignment only; the active queue was not fully re-audited end-to-end.
|
||||
>
|
||||
> Repo-based next-spec queue for TenantPilot.
|
||||
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
|
||||
|
||||
> **Last reviewed**: 2026-04-30
|
||||
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
|
||||
**Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
|
||||
|
||||
---
|
||||
|
||||
@ -420,7 +420,7 @@ ### Workspace, Tenant & Managed Object Lifecycle Governance v1
|
||||
4. `Retention & Purge Governance v1` — retention periods, legal hold, purge eligibility, irreversible deletion confirmation, and audit trail.
|
||||
5. `Restoreability Expiry & Evidence Retention v1` — distinguish restorable backup payloads from retained evidence/audit metadata and define when restore is no longer possible but evidence remains retained.
|
||||
- **Roadmap fit**: This is not a P0 sales feature. It is a P2 enterprise trust and compliance hardening candidate that becomes important before serious production customer offboarding, destructive data operations, or regulated retention commitments. It must not block Customer Review Workspace Productization, Governance Decision Surface Convergence, or Cross-Tenant Compare & Promotion.
|
||||
- **Candidate decision**: Keep as strategic candidate. Do not implement a narrow Ghost Policy spec until the lifecycle taxonomy is agreed. If provider-missing policy behavior becomes an immediate product bug, create a smaller follow-up spec named `Provider-Missing Policy Visibility & Restore Continuity v1`; that smaller spec must use `provider_deleted_at`, `missing_from_provider_at`, or an equivalent provider-presence field and must not use Laravel `SoftDeletes` or local deletion semantics.
|
||||
- **Candidate decision**: Keep as strategic candidate. Do not let near-term policy fixes expand into a general ghost-policy, deletion, or retention model before the lifecycle taxonomy is agreed. The bounded policy-only exception is now Spec 261 (`provider-missing-policy-visibility`); keep that spec isolated to provider-missing truth and restore continuity rather than treating it as partial completion of this broader taxonomy.
|
||||
|
||||
- `Workspace-level PII override for review packs`: bounded deferred follow-up from Spec 109.
|
||||
- `CSV export for filtered run metadata`: valid system-console follow-up, but not near the top of the queue.
|
||||
@ -443,6 +443,7 @@ ## Promoted to Spec
|
||||
- Private AI Execution & Policy Foundation -> Spec 248 (`private-ai-policy-foundation`)
|
||||
- Customer Review Workspace v1 -> Spec 249 (`customer-review-workspace`)
|
||||
- Decision-Based Governance Inbox v1 -> Spec 250 (`decision-governance-inbox`)
|
||||
- Provider-Missing Policy Visibility & Restore Continuity v1 -> Spec 261 (`provider-missing-policy-visibility`)
|
||||
- Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`)
|
||||
- Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`)
|
||||
- Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`)
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
# Specification Quality Checklist: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
**Purpose**: Validate the spec package before implementation planning and task execution
|
||||
**Created**: 2026-05-01
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] The spec stays on one bounded policy-only provider-presence correction and does not drift into workspace/tenant lifecycle governance
|
||||
- [x] Mandatory repo sections are completed, including candidate check, scope fields, cross-cutting reuse, provider boundary, guardrails, proportionality, and testing impact
|
||||
- [x] Candidate selection is grounded in repo reality as well as product docs, including the already-prepared Specs 251-260 and the documented narrower lifecycle follow-up
|
||||
- [x] No implementation diff leaks into the feature contract beyond concrete repo surfaces needed for local fit
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
||||
- [x] Functional requirements are testable and bounded
|
||||
- [x] Success criteria are measurable and behavior-focused
|
||||
- [x] Acceptance scenarios cover current-state policy truth, backup eligibility, restore continuity, and reappearance behavior
|
||||
- [x] Edge cases cover ignored-plus-missing overlap, supported-type reclassification, and historical backup continuity
|
||||
- [x] Dependencies, assumptions, and risks are explicit
|
||||
|
||||
## Guardrail & Surface Fit
|
||||
|
||||
- [x] The spec, plan, and package keep the same native Filament classification, shared-family relevance, and surface-role hierarchy for policy, backup, and restore screens
|
||||
- [x] `ignored_at` is explicitly reserved for local suppression and `missing_from_provider_at` is the only new provider-presence truth proposed
|
||||
- [x] Combined-state filter membership and blocked-reason precedence are explicit across the spec, data model, plan, and conceptual contract
|
||||
- [x] Decision-first obligations are explicit for changed surfaces: one dominant next action, diagnostics-secondary ordering, hidden/capability-gated support detail, and no duplicate visible decision summary
|
||||
- [x] No new page, panel, provider, asset strategy, lifecycle engine, or SoftDeletes path is introduced
|
||||
- [x] OperationRun impact is explicitly bounded to existing sync/backup start surfaces with no new run type
|
||||
- [x] Provider-specific semantics stay inside sync interpretation rather than leaking into platform-core operator vocabulary
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned validation stays in focused `fast-feedback` and `confidence` lanes only
|
||||
- [x] The package reuses existing policy, backup, restore, and badge test families instead of introducing browser or heavy-governance proof
|
||||
- [x] The US2 proving suite is aligned across spec, plan, quickstart, and tasks
|
||||
- [x] Reviewer handoff and narrow Sail commands are explicit in the plan and quickstart artifacts
|
||||
|
||||
## Notes
|
||||
|
||||
- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `.specify/memory/constitution.md`, `apps/platform/app/Models/Policy.php`, `apps/platform/app/Services/Intune/PolicySyncService.php`, `apps/platform/app/Services/Intune/BackupService.php`, `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/RestoreRunResource.php`, and the related sync/backup/restore tests on 2026-05-01.
|
||||
- No application implementation was performed while preparing this spec package.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- **Outcome class**: `acceptable-special-case`
|
||||
- **Outcome**: `keep`
|
||||
- **Reason**: The package intentionally pulls one documented narrow follow-up ahead of the full lifecycle taxonomy, but it stays policy-only, uses one aligned proving suite, makes the combined-state contract explicit, reuses existing shared seams, and fixes a repo-visible truth bug without importing the broader lifecycle framework.
|
||||
- **Workflow result**: Ready for implementation planning and task execution
|
||||
@ -0,0 +1,300 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Provider-Missing Policy Visibility & Restore Continuity v1 (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual contract for the Provider-Missing Policy Visibility & Restore
|
||||
Continuity v1 planning package.
|
||||
|
||||
These paths describe existing Filament admin and tenant-scoped routes reused by
|
||||
implementation. The schemas document the derived policy-presence, current
|
||||
backup-eligibility, and historical restore-continuity contract expected over
|
||||
existing policy, backup, and restore truth; they do not define a new public
|
||||
REST API.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/admin/t/{tenant}/policies:
|
||||
get:
|
||||
summary: View tenant policies with distinct local-ignore and provider-missing states
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: visibility
|
||||
required: false
|
||||
description: |
|
||||
`ignored` includes both `ignored_locally` and
|
||||
`ignored_locally_provider_missing`. `provider_missing` includes both
|
||||
`provider_missing` and `ignored_locally_provider_missing`.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- active
|
||||
- ignored
|
||||
- provider_missing
|
||||
- all
|
||||
responses:
|
||||
'200':
|
||||
description: Policy list rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PolicyIndexPageModel'
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the existing policy capability
|
||||
'404':
|
||||
description: Not found for non-members, tenant mismatches, or out-of-scope targets
|
||||
|
||||
/admin/t/{tenant}/policies/{policy}:
|
||||
get:
|
||||
summary: View one tenant policy with provider-presence continuity context
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: path
|
||||
name: policy
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Policy detail rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PolicyDetailModel'
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the existing policy capability
|
||||
'404':
|
||||
description: Not found for non-members, tenant mismatches, or inaccessible policy targets
|
||||
|
||||
/admin/t/{tenant}/backup-sets/create:
|
||||
get:
|
||||
summary: Open current backup selection with provider-missing eligibility rules
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Backup-set creation surface rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSelectionModel'
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the existing backup capability
|
||||
'404':
|
||||
description: Not found for non-members, tenant mismatches, or out-of-scope targets
|
||||
|
||||
/admin/t/{tenant}/restore-runs/create:
|
||||
get:
|
||||
summary: Open restore item selection with historical continuity for provider-missing policies
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenant
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: backup_set_id
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Restore-run creation surface rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RestoreSelectionModel'
|
||||
'403':
|
||||
description: Forbidden for an in-scope actor missing the existing restore capability
|
||||
'404':
|
||||
description: Not found for non-members, tenant mismatches, or inaccessible backup targets
|
||||
|
||||
components:
|
||||
schemas:
|
||||
PolicyVisibilityState:
|
||||
type: string
|
||||
description: |
|
||||
Combined state remains explicit as `ignored_locally_provider_missing`.
|
||||
List filters continue to expose it through both the `ignored` and
|
||||
`provider_missing` filter tokens.
|
||||
enum:
|
||||
- active
|
||||
- ignored_locally
|
||||
- provider_missing
|
||||
- ignored_locally_provider_missing
|
||||
|
||||
PolicyActionEligibility:
|
||||
type: object
|
||||
required:
|
||||
- can_sync
|
||||
- can_export_current
|
||||
- can_restore_history
|
||||
properties:
|
||||
can_sync:
|
||||
type: boolean
|
||||
can_export_current:
|
||||
type: boolean
|
||||
can_restore_history:
|
||||
type: boolean
|
||||
blocked_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Primary current-state action blocker. When both `ignored_at` and
|
||||
`missing_from_provider_at` are present, this resolves to
|
||||
`provider_missing` because fresh provider-backed capture is not
|
||||
possible; local ignore remains secondary context.
|
||||
enum:
|
||||
- ignored_locally
|
||||
- provider_missing
|
||||
|
||||
PolicyListRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- display_name
|
||||
- policy_type
|
||||
- visibility_state
|
||||
- action_eligibility
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
display_name:
|
||||
type: string
|
||||
policy_type:
|
||||
type: string
|
||||
visibility_state:
|
||||
$ref: '#/components/schemas/PolicyVisibilityState'
|
||||
ignored_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
missing_from_provider_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_synced_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
action_eligibility:
|
||||
$ref: '#/components/schemas/PolicyActionEligibility'
|
||||
|
||||
PolicyIndexPageModel:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PolicyListRow'
|
||||
|
||||
PolicyDetailModel:
|
||||
type: object
|
||||
required:
|
||||
- policy
|
||||
- historical_restore_available
|
||||
properties:
|
||||
policy:
|
||||
$ref: '#/components/schemas/PolicyListRow'
|
||||
historical_restore_available:
|
||||
type: boolean
|
||||
continuity_message:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
BackupSelectionRow:
|
||||
type: object
|
||||
required:
|
||||
- policy_id
|
||||
- label
|
||||
- eligible
|
||||
properties:
|
||||
policy_id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
eligible:
|
||||
type: boolean
|
||||
blocked_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Primary current backup blocker. When a policy is both locally ignored
|
||||
and provider-missing, this remains `provider_missing` because current
|
||||
capture cannot proceed.
|
||||
enum:
|
||||
- ignored_locally
|
||||
- provider_missing
|
||||
secondary_local_ignore:
|
||||
type: boolean
|
||||
description: |
|
||||
True when the row is also locally ignored. This remains secondary
|
||||
context when `blocked_reason` resolves to `provider_missing`.
|
||||
historical_continuity_available:
|
||||
type: boolean
|
||||
|
||||
BackupSelectionModel:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupSelectionRow'
|
||||
|
||||
RestoreSelectionRow:
|
||||
type: object
|
||||
required:
|
||||
- backup_item_id
|
||||
- label
|
||||
- selectable
|
||||
properties:
|
||||
backup_item_id:
|
||||
type: integer
|
||||
label:
|
||||
type: string
|
||||
selectable:
|
||||
type: boolean
|
||||
provider_missing_notice:
|
||||
type: boolean
|
||||
continuity_message:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
RestoreSelectionModel:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RestoreSelectionRow'
|
||||
112
specs/261-provider-missing-policy-visibility/data-model.md
Normal file
112
specs/261-provider-missing-policy-visibility/data-model.md
Normal file
@ -0,0 +1,112 @@
|
||||
# Data Model: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
## Overview
|
||||
|
||||
This slice changes one existing persisted entity and introduces only derived projections elsewhere. No new table, registry, or artifact family is planned.
|
||||
|
||||
## Entity: Policy (existing, modified)
|
||||
|
||||
**Table**: `policies`
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Source | Notes |
|
||||
|---|---|---|---|
|
||||
| `id` | bigint | existing | Primary key |
|
||||
| `workspace_id` | bigint | existing | Required ownership anchor |
|
||||
| `tenant_id` | bigint | existing | Required ownership anchor |
|
||||
| `external_id` | string | existing | Provider-facing stable key |
|
||||
| `policy_type` | string | existing | Canonical local policy type |
|
||||
| `ignored_at` | timestamp nullable | existing | Explicit local suppression only after this slice |
|
||||
| `missing_from_provider_at` | timestamp nullable | new | Provider-missing observation in the current supported provider-backed result set |
|
||||
| `last_synced_at` | timestamp nullable | existing | Last successful provider observation/update on this row |
|
||||
|
||||
### Invariants
|
||||
|
||||
- `workspace_id` and `tenant_id` remain required and non-null.
|
||||
- `ignored_at` may only be set or cleared by explicit local suppression flows.
|
||||
- `missing_from_provider_at` may only be set or cleared by sync/provider-observation logic.
|
||||
- A policy row remains persisted and viewable even when `missing_from_provider_at` is set.
|
||||
|
||||
### Derived Visibility States
|
||||
|
||||
| Derived state | `ignored_at` | `missing_from_provider_at` | Meaning |
|
||||
|---|---|---|---|
|
||||
| `active` | null | null | Current policy is locally visible and currently observed in the provider-backed result set |
|
||||
| `ignored_locally` | set | null | Current policy is intentionally hidden/suppressed locally |
|
||||
| `provider_missing` | null | set | Current policy remains local truth but is not currently observed in the supported provider-backed result set |
|
||||
| `ignored_locally_provider_missing` | set | set | Policy is locally suppressed and also currently missing from the provider-backed result set |
|
||||
|
||||
### Filter Membership And Precedence
|
||||
|
||||
- `active` filter returns only `active` rows.
|
||||
- `ignored` filter returns `ignored_locally` and `ignored_locally_provider_missing` rows.
|
||||
- `provider_missing` filter returns `provider_missing` and `ignored_locally_provider_missing` rows.
|
||||
- `all` returns the complete set.
|
||||
- For current backup/export, `provider_missing` wins as the primary `blocked_reason` when both timestamps are present because fresh provider-backed capture is impossible even if the row is also locally ignored.
|
||||
|
||||
### State Transition Rules
|
||||
|
||||
| Event | From | To | Notes |
|
||||
|---|---|---|---|
|
||||
| Local ignore | `active` or `provider_missing` | `ignored_locally` or `ignored_locally_provider_missing` | Explicit operator intent |
|
||||
| Local restore/unignore | `ignored_locally` or `ignored_locally_provider_missing` | `active` or `provider_missing` | Clears only `ignored_at` |
|
||||
| Sync marks missing | `active` or `ignored_locally` | `provider_missing` or `ignored_locally_provider_missing` | Sets only `missing_from_provider_at` |
|
||||
| Sync reappears | `provider_missing` or `ignored_locally_provider_missing` | `active` or `ignored_locally` | Clears only `missing_from_provider_at` |
|
||||
| Sync reclassifies to supported type | any provider-present state | provider-present state | Updates `policy_type` without using ignore semantics |
|
||||
|
||||
## Derived Projection: Current Backup Eligibility
|
||||
|
||||
**Persistence**: none; computed from `Policy`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `policy_id` | bigint | Policy being evaluated for current backup/export |
|
||||
| `eligible` | boolean | True only when the policy is neither locally ignored nor provider-missing for current-state capture |
|
||||
| `blocked_reason` | string nullable | `ignored_locally` or `provider_missing` when not eligible; `provider_missing` wins when both timestamps are present |
|
||||
| `historical_continuity_available` | boolean | Indicates whether backup history already exists even if current capture is blocked |
|
||||
|
||||
### Rules
|
||||
|
||||
- Fresh provider-backed capture requires `ignored_at = null` and `missing_from_provider_at = null`.
|
||||
- When both timestamps are set, current backup/export MUST return `blocked_reason = provider_missing` and MUST retain local ignore as secondary UI context.
|
||||
- Historical backup existence does not make a provider-missing policy eligible for fresh capture.
|
||||
|
||||
## Derived Projection: Restore Continuity
|
||||
|
||||
**Persistence**: none; computed from `BackupItem` plus optional linked `Policy`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `backup_item_id` | bigint | Historical item being offered for restore |
|
||||
| `policy_id` | bigint nullable | Linked current policy row when present |
|
||||
| `selectable` | boolean | Restore eligibility of the historical item |
|
||||
| `provider_missing_notice` | boolean | Shows that the current live policy is missing while the historical item remains valid |
|
||||
| `continuity_message` | string nullable | Calm explanation shown in restore selection |
|
||||
|
||||
### Rules
|
||||
|
||||
- Historical restore selection continues to follow `BackupItem` truth.
|
||||
- Provider-missing status on the live policy is descriptive unless an independent restore rule blocks the historical item.
|
||||
|
||||
## Audit Payload (existing infrastructure, new event meanings)
|
||||
|
||||
**Persistence**: existing `audit_logs`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `action_id` | string | `policy.provider_missing_detected` or `policy.provider_missing_cleared` or equivalent existing action ids if reused |
|
||||
| `workspace_id` | bigint | Existing scope anchor |
|
||||
| `tenant_id` | bigint | Existing scope anchor |
|
||||
| `subject_type` | string | `policy` |
|
||||
| `subject_id` | bigint | Policy id |
|
||||
| `metadata.external_id` | string | Provider-facing stable key |
|
||||
| `metadata.policy_type` | string | Canonical local policy type |
|
||||
| `metadata.transition_at` | timestamp | When the presence transition was observed |
|
||||
|
||||
## Out of Scope Data Shapes
|
||||
|
||||
- No `provider_deleted_at`
|
||||
- No lifecycle history table
|
||||
- No materialized `policy_state` enum column
|
||||
- No new recovery artifact or backup continuity table
|
||||
252
specs/261-provider-missing-policy-visibility/plan.md
Normal file
252
specs/261-provider-missing-policy-visibility/plan.md
Normal file
@ -0,0 +1,252 @@
|
||||
# Implementation Plan: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
**Branch**: `261-provider-missing-policy-visibility` | **Date**: 2026-05-01 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Prepare one bounded policy-truth correction that separates provider-missing presence from intentional local suppression without opening the broader workspace or tenant lifecycle program. The narrow implementation path is to add `missing_from_provider_at` on `policies`, reserve `ignored_at` for explicit user suppression only, stop sync and type-filter logic from writing `ignored_at` for provider absence, keep missing policies visible as historical local truth, block current-state backup/export actions for missing policies, and preserve restore continuity for historical `BackupItem` records.
|
||||
|
||||
Repo truth already exposes the exact seams this slice needs: [../../apps/platform/app/Models/Policy.php](../../apps/platform/app/Models/Policy.php) has `ignored_at` but no provider-presence field; [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) currently clears `ignored_at` on `updateOrCreate()` and marks reclassified or filtered records ignored; [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) only distinguishes active versus ignored; [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) selects backup candidates with `whereNull('ignored_at')`; [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) filters restore-option continuity through the same ignored check; and current tests such as [../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php](../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php) codify the conflated behavior that this spec intentionally corrects.
|
||||
|
||||
V1 therefore stays narrow: no new panel or provider, no global-search change, no new asset registration, no `SoftDeletes`, no lifecycle engine, no provider-deleted distinction, no purge flow, no multi-object rollout, and no cross-tenant workflow. The work is a policy-only truth correction that reuses the existing policy, backup, restore, audit, and sync seams.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `PolicySyncService`, `BackupService`, `PolicyResource`, `RestoreRunResource`, policy bulk jobs, audit infrastructure, and policy badge helpers
|
||||
**Storage**: PostgreSQL via existing `policies`, `policy_versions`, `backup_sets`, `backup_items`, `restore_runs`, `operation_runs`, and `audit_logs`; one nullable timestamp is planned on `policies`
|
||||
**Testing**: Pest v4 feature and focused unit coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Laravel monolith in `apps/platform`, existing admin and tenant-scoped Filament surfaces only
|
||||
**Project Type**: Web application (Laravel monolith with Filament resources and pages)
|
||||
**Performance Goals**: keep provider-missing detection inside existing sync result processing, avoid extra Graph calls, keep policy list and restore option queries eager-load safe, and avoid widening queue or asset cost
|
||||
**Constraints**: no `SoftDeletes`, no `provider_deleted_at`, no new lifecycle registry/service, no panel/provider changes, no asset strategy change, no new globally searchable resource, and no customer-facing portal work
|
||||
**Scale/Scope**: 1 model/migration, 1 sync service cluster, 1 policy resource, 1 backup service seam, 1 restore selection seam, bounded audit updates, and targeted policy/backup/restore tests
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- [../../apps/platform/app/Models/Policy.php](../../apps/platform/app/Models/Policy.php) for `missing_from_provider_at`, casts, scopes, and derived provider-presence helpers.
|
||||
- [../../apps/platform/database/migrations](../../apps/platform/database/migrations) for one migration adding `missing_from_provider_at` to `policies`.
|
||||
- [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) for provider-missing detection, reappearance clearing, subtype filtering, and reclassification semantics.
|
||||
- [../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php](../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php) and existing unignore flows as the retained local-suppression path that must stay on `ignored_at` only.
|
||||
- [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) plus its `Pages/ListPolicies.php` and `Pages/ViewPolicy.php` surfaces for badges, filters, helper copy, and action availability.
|
||||
- [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) and current backup/export actions that choose policies from current local truth.
|
||||
- [../../apps/platform/app/Filament/Resources/BackupSetResource.php](../../apps/platform/app/Filament/Resources/BackupSetResource.php) or related picker helpers if the current backup-set policy picker needs provider-missing reason text.
|
||||
- [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) for restore item option continuity and provider-missing messaging.
|
||||
- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) and the existing audit logger path for provider-missing and reappeared events if no existing audit action is sufficient.
|
||||
- [../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php](../../apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php), [../../apps/platform/tests/Feature/PolicySyncServiceTest.php](../../apps/platform/tests/Feature/PolicySyncServiceTest.php), [../../apps/platform/tests/Feature/BulkDeletePoliciesTest.php](../../apps/platform/tests/Feature/BulkDeletePoliciesTest.php), [../../apps/platform/tests/Feature/BulkUnignorePoliciesTest.php](../../apps/platform/tests/Feature/BulkUnignorePoliciesTest.php), [../../apps/platform/tests/Feature/BulkExportToBackupTest.php](../../apps/platform/tests/Feature/BulkExportToBackupTest.php), [../../apps/platform/tests/Feature/Filament/BackupCreationTest.php](../../apps/platform/tests/Feature/Filament/BackupCreationTest.php), [../../apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php](../../apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php), [../../apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php](../../apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php), [../../apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php](../../apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php), and [../../apps/platform/tests/Unit/Badges/PolicyBadgesTest.php](../../apps/platform/tests/Unit/Badges/PolicyBadgesTest.php) for bounded regression proof.
|
||||
|
||||
## Policy Presence / Sync Fit
|
||||
|
||||
- Treat provider presence as a property of the local policy row, not a second entity or lifecycle engine. The current narrow truth is `ignored_at` plus `missing_from_provider_at`.
|
||||
- The derived state family stays bounded:
|
||||
- active: `ignored_at = null` and `missing_from_provider_at = null`
|
||||
- ignored locally: `ignored_at != null` and `missing_from_provider_at = null`
|
||||
- provider missing: `ignored_at = null` and `missing_from_provider_at != null`
|
||||
- ignored locally + provider missing: both timestamps present, with local suppression as the primary local-control meaning
|
||||
- Policy list filtering stays deterministic: the combined state belongs to both the `ignored` and `provider_missing` filter views so operators can reach it from either workflow.
|
||||
- [../../apps/platform/app/Services/Intune/PolicySyncService.php](../../apps/platform/app/Services/Intune/PolicySyncService.php) must stop blindly resetting `ignored_at` on `updateOrCreate()`.
|
||||
- Any sync path that currently uses `ignored_at` for subtype filtering or reclassification must instead either reclassify the row to a supported canonical type or mark `missing_from_provider_at` when the object falls out of the supported provider-backed result set.
|
||||
- Reappearance must clear `missing_from_provider_at` without automatically clearing `ignored_at`.
|
||||
- Existing local delete/restore semantics in `ignore()` and `unignore()` remain unchanged and stay rooted in `ignored_at`.
|
||||
|
||||
## Backup / Restore Continuity Fit
|
||||
|
||||
- [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) and any current backup/export picker that selects policies for fresh capture must keep provider-missing policies visible but blocked from current-state capture.
|
||||
- When both `ignored_at` and `missing_from_provider_at` are present, current backup/export uses `provider_missing` as the primary blocked reason because the product cannot truthfully claim fresh provider-backed capture is possible.
|
||||
- Existing historical `BackupItem` truth remains authoritative for restore continuity. The system should not require a live provider-present policy row to keep a historical restore item selectable.
|
||||
- [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) should keep continuity messaging secondary and truthful: the backup item is historical and selectable, while the live policy is currently provider-missing.
|
||||
- Current-state backup blocking should prefer calm explanatory copy and zero new run creation. Historical restore remains a separate allowed path.
|
||||
|
||||
## UI / Filament & Livewire Fit
|
||||
|
||||
- Filament remains v5 on Livewire v4. No new panel or provider is planned, and provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php).
|
||||
- [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) already exposes a list and a view page and explicitly sets `$isGloballySearchable = false`; this feature does not change global-search posture.
|
||||
- Keep the existing policy resource as the primary current-state decision surface. Do not add a new ghost-policy page, special diagnostics page, or alternate provider-missing registry.
|
||||
- Any destructive action that survives this slice remains the existing local ignore/restore family and must continue to use confirmation and server-side authorization. This feature does not introduce a new destructive action.
|
||||
- Asset strategy remains unchanged. No new Filament assets or deployment `filament:assets` steps are expected beyond the existing platform process.
|
||||
- If status badges or helper copy expand, reuse the existing badge and policy presentation seam instead of adding local ad-hoc mappings.
|
||||
|
||||
## RBAC / Authorization Fit
|
||||
|
||||
- Workspace membership and tenant entitlement remain the first boundary. Nothing in provider-missing semantics changes `404` versus `403` scope handling.
|
||||
- Existing policy, backup, and restore capabilities remain authoritative. No new capability string or role check is justified.
|
||||
- Backup blocking for provider-missing policies is business-state gating, not authorization. In-scope operators still see the policy and the historical restore path; out-of-scope actors still receive deny-as-not-found.
|
||||
- Any reused detail or action route must keep current server-side authorization checks and confirmation behavior.
|
||||
|
||||
## Audit / Logging Fit
|
||||
|
||||
- Reuse the existing audit infrastructure; do not create a second lifecycle-log family.
|
||||
- The narrow expectation is one explicit audit event when a previously observed policy becomes provider-missing and one when it later reappears.
|
||||
- Audit payload should stay tenant-safe and minimal: policy id, policy external id, canonical policy type, transition timestamp, and sync source context are sufficient.
|
||||
- No new `OperationRun` type is planned. Current sync actions may continue to use the existing run UX only when a sync actually starts.
|
||||
|
||||
## Data & Query Fit
|
||||
|
||||
- Add one nullable timestamp column: `policies.missing_from_provider_at`.
|
||||
- Keep `workspace_id` and `tenant_id` as required anchors on policy truth; this slice does not weaken tenant ownership.
|
||||
- Prefer deriving provider-presence state via model helpers or narrow query scopes instead of storing a second status enum.
|
||||
- Update current policy-eligibility queries to use both `ignored_at` and `missing_from_provider_at` where current-state capture must stay live-only.
|
||||
- Combined-state filter membership is derived, not stored: ignored views include `ignored_locally_provider_missing`, provider-missing views include `ignored_locally_provider_missing`, and current backup/export blocked-reason precedence resolves to `provider_missing` when both timestamps are present.
|
||||
- Keep restore queries historical-first: if a `BackupItem` remains eligible, provider-missing context is descriptive rather than disqualifying unless a separate restore rule already blocks it.
|
||||
- Avoid adding speculative `provider_state_reason` or lifecycle-history tables in v1.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament
|
||||
- **Shared-family relevance**: status messaging, badges, row/detail actions, backup eligibility messaging, restore continuity descriptions, and audit labels
|
||||
- **State layers in scope**: page, detail, action modal, picker description, query/model state
|
||||
- **Audience modes in scope**: operator-MSP, support-platform
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first current policy meaning, diagnostics-second sync/history context, raw/support third and only where existing surfaces already expose it
|
||||
- **Raw/support gating plan**: no new raw payload disclosure; any provider detail remains secondary on existing policy/version evidence surfaces only
|
||||
- **One-primary-action / duplicate-truth control**: policy list/detail own current-state meaning; backup flows own current capture eligibility; restore flows own historical continuity; no surface should restate another surface's primary truth as an equal-priority summary
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory because this slice changes a shared state vocabulary across policy, backup, and restore seams
|
||||
- **Special surface test profiles**: standard-native-filament, shared-detail-family
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none planned; any attempt to add a new provider-missing page, new lifecycle registry, or new panel becomes exception-required drift
|
||||
- **Active feature PR close-out entry**: Guardrail / State Vocabulary
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `Policy`, `PolicySyncService`, `PolicyResource`, `BackupService`, `BackupSetResource` picker helpers if needed, `RestoreRunResource`, audit action ids, and the policy badge/presentation seam
|
||||
- **Shared abstractions reused**: existing policy model truth, shared policy filters/actions, existing backup selection path, existing restore-item option builders, existing audit logger, and existing badge rendering tests
|
||||
- **New abstraction introduced? why?**: none planned. If implementation needs a tiny provider-presence helper, keep it on `Policy` or an existing policy-presenter seam instead of creating a new lifecycle framework.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: current shared seams already own the right surfaces. They are simply fed by the wrong state vocabulary today.
|
||||
- **Bounded deviation / spread control**: none planned. Any proposal for a separate ghost-policy registry, dedicated diagnostics page, or generic managed-object lifecycle layer should be deferred to the broader lifecycle taxonomy follow-up.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, by narrowing when current backup/export actions may start and by leaving sync-retry behavior on the existing shared path
|
||||
- **Central contract reused**: existing policy sync and backup/export start UX
|
||||
- **Delegated UX behaviors**: allowed sync actions keep the current queued toast/link behavior; blocked provider-missing current backup/export actions stop before run creation and explain why locally
|
||||
- **Surface-owned behavior kept local**: policy, backup, and restore surfaces own provider-missing messaging only; they do not create a new run type or monitoring surface
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Provider-owned seams**: Microsoft Graph list results, subtype inclusion/exclusion, and canonical-type mapping remain provider-owned evidence in sync
|
||||
- **Platform-core seams**: local suppression truth, provider-missing truth, current backup eligibility, historical restore continuity, and operator-facing policy vocabulary
|
||||
- **Neutral platform terms / contracts preserved**: `provider missing`, `ignored locally`, `current backup eligibility`, and `restore continuity`
|
||||
- **Retained provider-specific semantics and why**: subtype filtering remains provider-specific inside sync because it already reflects supported endpoint scope
|
||||
- **Bounded extraction or follow-up path**: `document-in-feature` for the narrow provider-presence wording now; `follow-up-spec` later if the broader lifecycle taxonomy or explicit provider-deleted distinction is approved
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first / snapshot truth: PASS. The slice keeps historical `PolicyVersion` and `BackupItem` truth authoritative and does not invent a new shadow registry.
|
||||
- Read/write separation: PASS. No new destructive flow, purge path, or customer-facing mutation surface is introduced.
|
||||
- Graph contract path: PASS. The slice reuses the existing provider sync boundary and adds no new Graph family.
|
||||
- Deterministic capabilities: PASS. Existing capability registries remain canonical.
|
||||
- Workspace and tenant isolation: PASS. `workspace_id` and `tenant_id` remain required anchors on policy truth.
|
||||
- RBAC-UX plane separation: PASS. The slice stays inside existing admin and tenant-scoped policy, backup, and restore surfaces.
|
||||
- Destructive confirmation standard: PASS. Existing local ignore/restore destructive actions remain the only destructive family and keep confirmation plus server-side authorization.
|
||||
- Global search safety: PASS. `PolicyResource` is already globally disabled and remains so.
|
||||
- OperationRun / Ops-UX: PASS. No new run type is introduced; blocked backup/export states stop before run creation.
|
||||
- Data minimization: PASS. Provider-missing copy stays calm and decision-first; no new raw payload exposure is added.
|
||||
- Test governance (TEST-GOV-001): PASS. Planned proof stays inside focused feature and unit lanes.
|
||||
- Proportionality / no premature abstraction: PASS. One timestamp and derived state family are the narrowest defensible shape.
|
||||
- Persisted truth (PERSIST-001): PASS. One field is added to existing policy truth; no new table or artifact family is created.
|
||||
- Behavioral state (STATE-001): PASS. The state family is derived from existing and new timestamps rather than adding a new enum/persistence layer.
|
||||
- Provider boundary (PROV-001): PASS. Provider-specific result interpretation stays in sync; operator-facing language stays platform-neutral.
|
||||
- Filament / Laravel planning contract: PASS. Filament remains v5 on Livewire v4, provider registration remains in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no panel change is planned, `PolicyResource` global search remains disabled, and no assets are expected.
|
||||
|
||||
**Gate evaluation**: PASS.
|
||||
|
||||
- The narrow path is defensible if implementation keeps `ignored_at` user-owned and `missing_from_provider_at` provider-observation-only.
|
||||
- The plan fails the gate if it drifts into `SoftDeletes`, a new lifecycle service, a provider-deleted taxonomy, or multi-object rollout.
|
||||
|
||||
**Post-design re-check**: PASS. [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), and [contracts/provider-missing-policy-visibility.openapi.yaml](contracts/provider-missing-policy-visibility.openapi.yaml) are present and aligned with the spec package.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature for sync behavior, policy UI, backup eligibility, restore continuity, and authorization continuity; Unit for policy badge/state or narrow derived helper behavior if one is introduced
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the behavior is server-side and already anchored in existing sync, policy, backup, and restore tests. Focused feature coverage plus one small unit seam is enough without browser cost.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php tests/Feature/PolicySyncServiceTest.php tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php tests/Feature/PolicySyncStartSurfaceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyProviderMissingUiTest.php tests/Feature/PolicyGeneralViewTest.php tests/Feature/BulkDeletePoliciesTest.php tests/Feature/BulkUnignorePoliciesTest.php tests/Unit/Badges/PolicyBadgesTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BulkExportToBackupTest.php tests/Feature/Filament/BackupCreationTest.php tests/Feature/Filament/BackupSetPolicyPickerTableTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing tenant, policy, backup-set, backup-item, restore-run, and policy-version fixtures
|
||||
- **Expensive defaults or shared helper growth introduced?**: none expected; any new helper should stay policy-local and explicit
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief for policy and backup surfaces; shared-detail-family proof for restore continuity
|
||||
- **Closing validation and reviewer handoff**: rerun the commands above, verify sync never writes `ignored_at` for provider absence, verify missing policies remain visible, verify current backup/export blocks without starting a run, verify historical restore continuity remains selectable, and verify non-members or out-of-scope actors still resolve as `404`
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond small feature-local test additions
|
||||
- **Review-stop questions**: hidden `ignored_at` writes left in sync, blocked backup/export starting a run anyway, restore continuity silently filtered, or provider-specific wording leaking into platform truth
|
||||
- **Escalation path**: `document-in-feature` for bounded wording or audit metadata notes; `follow-up-spec` for broader lifecycle rollout; `reject-or-split` for SoftDeletes or new framework drift
|
||||
- **Active feature PR close-out entry**: Guardrail / State Vocabulary
|
||||
|
||||
## Rollout & Risk Controls
|
||||
|
||||
- Keep policy truth anchored to the existing `policies` row; do not create a side table or ghost-policy registry.
|
||||
- Keep local ignore and provider-missing semantics orthogonal. Sync may clear provider-missing on reappearance, but only an explicit user action clears local ignore.
|
||||
- Keep current-state capture conservative. If the provider cannot currently supply live state for a policy, current backup/export should explain that and stop.
|
||||
- Keep historical restore continuity permissive where `BackupItem` truth already exists.
|
||||
- Keep global search posture, panel registration, and asset strategy unchanged.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/261-provider-missing-policy-visibility/
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── provider-missing-policy-visibility.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ └── Resources/
|
||||
│ │ ├── BackupSetResource.php
|
||||
│ │ ├── PolicyResource.php
|
||||
│ │ └── RestoreRunResource.php
|
||||
│ ├── Jobs/
|
||||
│ │ └── Operations/PolicyBulkDeleteWorkerJob.php
|
||||
│ ├── Models/
|
||||
│ │ └── Policy.php
|
||||
│ ├── Services/
|
||||
│ │ ├── Audit/
|
||||
│ │ └── Intune/
|
||||
│ │ ├── BackupService.php
|
||||
│ │ └── PolicySyncService.php
|
||||
│ └── Support/
|
||||
│ ├── Audit/AuditActionId.php
|
||||
│ └── Ui/
|
||||
├── database/
|
||||
│ └── migrations/
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ ├── Jobs/
|
||||
│ └── ...
|
||||
└── Unit/
|
||||
└── Badges/
|
||||
```
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
- **New persistence**: 1 nullable timestamp on `policies`
|
||||
- **New derived state family**: 1 provider-presence family layered onto the existing local ignore semantics
|
||||
- **New routes/pages/panels/providers**: none
|
||||
- **New assets**: none
|
||||
- **New queues or long-running operations**: none
|
||||
- **Expected implementation risk**: moderate, because multiple existing tests currently encode the conflated semantics and must be corrected together
|
||||
69
specs/261-provider-missing-policy-visibility/quickstart.md
Normal file
69
specs/261-provider-missing-policy-visibility/quickstart.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Quickstart: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
## Preconditions
|
||||
|
||||
1. Start the platform runtime if it is not already running:
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Keep verification inside the existing policy, backup, and restore seams. No browser smoke or heavy-governance lane is required for this slice.
|
||||
|
||||
## Scenario 1: Provider-missing is distinct from local ignore
|
||||
|
||||
1. Create or reuse a tenant policy fixture that sync normally into the local `policies` table.
|
||||
2. Mark a second policy as locally ignored through the existing ignore flow.
|
||||
3. Run sync with a provider payload that omits the first policy but still returns the second policy.
|
||||
4. Verify:
|
||||
- the omitted policy receives `missing_from_provider_at` and remains visible as provider-missing
|
||||
- the locally ignored policy keeps `ignored_at`
|
||||
- sync does not clear `ignored_at` for the ignored policy
|
||||
|
||||
## Scenario 2: Current backup/export blocks provider-missing policies
|
||||
|
||||
1. Reuse a policy marked provider-missing from Scenario 1 and apply the existing local ignore flow so the policy reaches the combined ignored-plus-missing state.
|
||||
2. Attempt the current backup/export path from the existing policy or backup selection surface.
|
||||
3. Verify:
|
||||
- the combined-state policy remains visible but blocked from fresh capture
|
||||
- the combined ignored-plus-missing policy is discoverable from both the `ignored` and `provider_missing` filter views
|
||||
- the primary blocked reason is provider-missing
|
||||
- local ignore remains visible only as secondary context, not the dominant blocker
|
||||
- no new backup/export run is created for a blocked policy
|
||||
|
||||
## Scenario 3: Historical restore continuity remains available
|
||||
|
||||
1. Create or reuse a `BackupItem` for a policy that is now provider-missing.
|
||||
2. Open the existing restore-run creation flow for the owning backup set.
|
||||
3. Verify:
|
||||
- the historical item remains selectable when otherwise eligible
|
||||
- the selection UI shows a provider-missing continuity note rather than filtering the item out
|
||||
|
||||
## Scenario 4: Reappearance clears only provider-missing state
|
||||
|
||||
1. Re-run sync with the previously missing policy present again.
|
||||
2. Verify:
|
||||
- `missing_from_provider_at` clears
|
||||
- `ignored_at` remains unchanged if the policy is locally ignored
|
||||
- an audit event records the reappearance transition
|
||||
|
||||
## Focused Validation Commands
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php tests/Feature/PolicySyncServiceTest.php tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php tests/Feature/PolicySyncStartSurfaceTest.php
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyProviderMissingUiTest.php tests/Feature/PolicyGeneralViewTest.php tests/Feature/BulkDeletePoliciesTest.php tests/Feature/BulkUnignorePoliciesTest.php tests/Unit/Badges/PolicyBadgesTest.php
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BulkExportToBackupTest.php tests/Feature/Filament/BackupCreationTest.php tests/Feature/Filament/BackupSetPolicyPickerTableTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php
|
||||
```
|
||||
|
||||
## Finish
|
||||
|
||||
1. Run Pint on touched PHP files:
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
2. Confirm there are no remaining sync-driven `ignored_at` writes in policy-sync code paths and that the slice did not introduce `SoftDeletes`, a purge path, or a new lifecycle framework before closing the feature.
|
||||
53
specs/261-provider-missing-policy-visibility/research.md
Normal file
53
specs/261-provider-missing-policy-visibility/research.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Research: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
## Decision 1: Persist provider-missing observation on `policies` via `missing_from_provider_at`
|
||||
|
||||
- **Decision**: Add one nullable timestamp, `missing_from_provider_at`, to the existing `policies` table.
|
||||
- **Rationale**: The current ambiguity is persisted on the policy row itself. The narrowest truthful fix is therefore another field on that same row, not a second table or lifecycle engine.
|
||||
- **Why this is enough**: The slice only needs to know whether a previously observed policy is currently absent from the supported provider-backed result set. A timestamp captures that fact while preserving historical local truth.
|
||||
- **Alternatives considered**:
|
||||
- Reuse `ignored_at`: rejected because it keeps local suppression and provider absence conflated.
|
||||
- Add `SoftDeletes`: rejected because provider absence is not local deletion.
|
||||
- Add a lifecycle history table: rejected as premature for this policy-only follow-up.
|
||||
|
||||
## Decision 2: Keep `ignored_at` user-owned and stop sync from reviving ignored policies
|
||||
|
||||
- **Decision**: Treat `ignored_at` as explicit local suppression only. Sync may clear `missing_from_provider_at` on reappearance, but it must not clear `ignored_at` automatically.
|
||||
- **Rationale**: Existing bulk delete and restore flows already use `ignore()` and `unignore()` as user intent. A later provider sync should not silently reverse that operator choice.
|
||||
- **Repo anchor**: [../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php](../../apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php) currently treats ignore as local deletion, so sync revival is the inconsistent behavior, not the bulk action.
|
||||
- **Alternatives considered**:
|
||||
- Preserve `PolicySyncIgnoredRevivalTest` semantics: rejected because it keeps local suppression non-deterministic.
|
||||
- Auto-unignore when a provider object reappears: rejected because it makes provider observation override explicit local intent.
|
||||
|
||||
## Decision 3: Current backup/export requires provider-present policy truth, but historical restore continuity remains available
|
||||
|
||||
- **Decision**: Current backup/export flows keep provider-missing policies visible but blocked, while restore creation continues to offer historical `BackupItem` continuity when otherwise eligible.
|
||||
- **Rationale**: A fresh snapshot depends on current provider-backed state. Historical restore depends on already captured backup truth. Those are different claims and should not share one eligibility gate.
|
||||
- **Repo anchor**: [../../apps/platform/app/Services/Intune/BackupService.php](../../apps/platform/app/Services/Intune/BackupService.php) currently only checks `ignored_at`; [../../apps/platform/app/Filament/Resources/RestoreRunResource.php](../../apps/platform/app/Filament/Resources/RestoreRunResource.php) already has a historical-item grouping seam that can carry provider-missing continuity messaging.
|
||||
- **Alternatives considered**:
|
||||
- Keep current backup/export behavior for missing policies: rejected because it pretends a fresh provider-backed snapshot is still possible.
|
||||
- Hide missing policies from restore flows too: rejected because it throws away existing backup value and breaks recovery continuity.
|
||||
|
||||
## Decision 4: Audit provider-missing and reappeared transitions through the existing audit path
|
||||
|
||||
- **Decision**: Reuse the current audit infrastructure for two explicit transition events: provider-missing detected and provider-missing cleared.
|
||||
- **Rationale**: Reviewers need to understand why a policy is missing now and when it returned later. The current audit path is already the bounded place for that truth.
|
||||
- **Why no new `OperationRun`**: The transition is a side effect of existing sync work, not a new long-running workflow or operator-owned run family.
|
||||
- **Alternatives considered**:
|
||||
- No audit changes: rejected because the state would be hard to explain later.
|
||||
- New lifecycle event subsystem: rejected as broader than the slice needs.
|
||||
|
||||
## Decision 5: Keep the existing policy resource as the primary current-state surface
|
||||
|
||||
- **Decision**: Extend the existing `PolicyResource` list/view surfaces instead of adding a ghost-policy page or lifecycle registry.
|
||||
- **Rationale**: Operators already decide what a policy means from the current policy surface. Adding another page would duplicate vocabulary and spread the bug fix across more UI.
|
||||
- **Repo anchor**: [../../apps/platform/app/Filament/Resources/PolicyResource.php](../../apps/platform/app/Filament/Resources/PolicyResource.php) already owns list/view routes, action availability, and visibility filters.
|
||||
- **Alternatives considered**:
|
||||
- New diagnostics page for provider-missing policies: rejected as unnecessary surface expansion.
|
||||
- Backup or restore pages owning current-state explanation: rejected because those are secondary flows.
|
||||
|
||||
## Open Follow-Up Questions Deferred Intentionally
|
||||
|
||||
- Distinguishing `provider_deleted_at` from generic provider absence remains a later follow-up.
|
||||
- Rolling provider-presence semantics out to other managed objects remains a later follow-up.
|
||||
- Full lifecycle taxonomy and retention or purge policy remain blocked on the broader lifecycle spec.
|
||||
313
specs/261-provider-missing-policy-visibility/spec.md
Normal file
313
specs/261-provider-missing-policy-visibility/spec.md
Normal file
@ -0,0 +1,313 @@
|
||||
# Feature Specification: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
**Feature Branch**: `261-provider-missing-policy-visibility`
|
||||
**Created**: 2026-05-01
|
||||
**Status**: Ready
|
||||
**Input**: User description: "Prepare the next repo-based follow-up under the lifecycle governance candidate: separate provider-missing policy presence from local ignore/delete semantics, keep restore continuity truthful, and avoid SoftDeletes, purge, or broad lifecycle-taxonomy work."
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot currently collapses at least two different truths into `policies.ignored_at`: intentional local suppression and sync-driven provider absence or scope loss. That makes policy visibility, backup eligibility, and restore continuity hard to trust.
|
||||
- **Today's failure**: Operators can see a policy as `Ignored` even when it was not intentionally suppressed, sync can clear `ignored_at` simply because the provider returns the object again, and backup or restore surfaces cannot distinguish a locally ignored policy from a policy that is only missing from the current supported provider result set.
|
||||
- **User-visible improvement**: Operators can tell whether a policy is intentionally ignored locally or missing from the current supported provider-backed view, and historical backup/restore continuity remains available without masquerading as local deletion.
|
||||
- **Smallest enterprise-capable version**: Add one bounded provider-presence field to policy truth, reserve `ignored_at` for explicit local suppression only, surface the derived provider-missing state on policy and restore-adjacent surfaces, block current-state backup/export actions for missing policies, and clear provider-missing state when the provider object reappears.
|
||||
- **Explicit non-goals**: No full workspace/tenant lifecycle taxonomy, no `SoftDeletes` rollout for policies, no purge or retention engine, no new policy hard-delete flow, no multi-object lifecycle framework, no cross-tenant workflow, no customer-facing portal, no billing or workspace-closure work, and no broad managed-object lifecycle engine.
|
||||
- **Permanent complexity imported**: One new tenant-owned provider-presence timestamp on `policies`, one derived provider-missing state, focused audit semantics for missing/reappeared transitions, and narrow feature/unit coverage across sync, policy, backup, and restore seams.
|
||||
- **Why now**: The active P0-P2 prep queue is already represented by full spec packages through Specs 251-260. The broader `Workspace, Tenant & Managed Object Lifecycle Governance v1` candidate is explicitly strategic and not ready, but that same candidate documents `Provider-Missing Managed Object Truth v1` and the narrower fallback `Provider-Missing Policy Visibility & Restore Continuity v1` if provider-missing policy behavior becomes an immediate product bug. Current repo truth already shows that bug: `ignored_at` is reused by local policy deletion, sync reclassification, provider result filtering, backup eligibility, and sync revival tests.
|
||||
- **Why not local**: A page-local badge or one-off restore exemption would still leave the canonical truth split across `Policy`, `PolicySyncService`, policy actions, backup selection, restore selection, and audit behavior. This slice has to converge those shared seams or the product will keep saying two incompatible things about the same policy.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: New lifecycle-like field, cross-surface semantics, and a smaller follow-up chosen ahead of the full lifecycle taxonomy. Defense: the slice stays strictly policy-only, adds only one provider-presence field instead of a general lifecycle framework, keeps workspace/tenant closure and retention out of scope, and is justified by a current runtime truth bug rather than future-proofing.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/policies`
|
||||
- existing tenant-scoped policy detail surfaces reached from the policies resource
|
||||
- existing tenant-scoped backup creation and backup-set policy-picker surfaces that choose current policies for snapshotting
|
||||
- existing tenant-scoped restore-run creation surfaces that select historical backup items for restore continuity
|
||||
- **Data Ownership**:
|
||||
- `Policy` remains tenant-owned truth and keeps required `workspace_id` and `tenant_id` anchors.
|
||||
- This slice adds one tenant-owned provider-observation field, `missing_from_provider_at`, on `policies`. It records that the policy is no longer observed in the current supported provider-backed result set for its canonical policy type.
|
||||
- `ignored_at` remains tenant-owned local suppression truth only.
|
||||
- `PolicyVersion`, `BackupSet`, `BackupItem`, and existing restore-run truth remain unchanged and continue to own historical snapshot and recovery evidence.
|
||||
- **RBAC**:
|
||||
- Existing workspace membership and tenant entitlement remain the first isolation boundary.
|
||||
- Existing policy, backup, and restore capabilities remain authoritative; this slice adds no new role or capability family.
|
||||
- Non-members and out-of-scope tenant access remain deny-as-not-found.
|
||||
- Members missing the existing manage, backup, or restore capability continue to receive forbidden semantics on the affected actions.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, table filters and badges, row/header actions, backup eligibility messaging, restore-item selection descriptions, audit labels
|
||||
- **Systems touched**: `Policy`, `PolicySyncService`, policy list/detail actions, current backup creation and backup-set item selection, restore-item selection, and the existing audit logger path
|
||||
- **Existing pattern(s) to extend**: existing policy visibility filter/action family, existing restore-item selection/description builders, existing backup eligibility rules, and existing audit logging
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `PolicyResource`, `RestoreRunResource` restore-item option builders, `BackupService`, and the current workspace/tenant audit logger path remain the shared seams to extend
|
||||
- **Why the existing shared path is sufficient or insufficient**: the existing policy, backup, and restore helpers already centralize the relevant queries and action affordances. What is missing is one truthful provider-presence distinction inside those shared seams, not a new ghost-policy service or lifecycle framework.
|
||||
- **Allowed deviation and why**: none
|
||||
- **Consistency impact**: `Ignored`, `Provider missing`, backup blocked messaging, restore continuity notes, and reappearance wording must mean the same thing across policy list, policy detail, backup selection, restore selection, and sync-driven audit entries.
|
||||
- **Review focus**: reviewers must verify that sync and provider reclassification stop writing `ignored_at`, that missing policies remain visible and historically restorable, and that no surface keeps using `ignored_at` as a catch-all proxy for provider absence.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: existing policy `Sync` and policy export/backup queued-start UX remain authoritative when an action is still allowed
|
||||
- **Delegated start/completion UX behaviors**: when a provider-missing policy still allows an explicit sync retry, the current queued toast and `Open operation` link behavior remain unchanged. When a missing policy is not eligible for current-state backup/export, the local action is blocked before any run is created.
|
||||
- **Local surface-owned behavior that remains**: policy, backup, and restore surfaces only explain provider-missing continuity and action eligibility. They do not create a new run type or a new recovery workflow.
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged for existing sync and backup runs
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed
|
||||
- **Seams affected**: policy persistence truth, sync result interpretation, provider-presence vocabulary, restore continuity wording, and local suppression semantics
|
||||
- **Neutral platform terms preserved or introduced**: provider missing, local ignore, current provider-backed state, historical restore continuity, supported provider result set
|
||||
- **Provider-specific semantics retained and why**: Microsoft Graph list results and provider-specific subtype filtering remain provider-owned evidence inside the sync layer. They only determine whether the policy is currently observed in the supported provider result set.
|
||||
- **Why this does not deepen provider coupling accidentally**: the persisted field records neutral provider-presence truth on the local policy record. Provider-specific endpoint or OData logic stays inside the existing sync layer and does not leak into operator-facing labels beyond the neutral `Provider missing` meaning.
|
||||
- **Follow-up path**: the broader lifecycle taxonomy and any future provider-deleted distinction remain separate follow-up specs
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant policies list and detail | yes | Native Filament + shared policy primitives | filters, badges, row actions, detail messaging | table, row action, detail, helper copy | no | Keeps current policy resource as the single current-state policy surface |
|
||||
| Tenant backup creation and backup-set policy selection | yes | Native Filament + shared backup primitives | action gating, policy picker descriptions | action modal, picker, validation state | no | Missing policies stop pretending they can produce a fresh provider-backed snapshot |
|
||||
| Tenant restore-run item selection | yes | Native Filament + shared restore primitives | restore continuity messaging, item descriptions | wizard, grouped options, descriptions | no | Historical backup items remain visible without being conflated with ignored policies |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant policies list and detail | Primary Decision Surface | Decide whether the policy is actively managed, intentionally ignored, or missing from the current provider-backed view | current visibility state, display name, type, last sync, and dominant next action | historical versions, backup history, and related restore context after opening detail | Primary because operators decide what to do with a policy here before entering backup or restore flows | Keeps current-state policy truth on the policy surface instead of outsourcing it to restore screens | Removes guessing whether `Ignored` means local suppression or provider absence |
|
||||
| Tenant backup creation and backup-set policy selection | Secondary Context Surface | Decide whether a current policy can still be snapshotted now | eligible versus blocked policies and the provider-missing reason when blocked | existing backup history and related policy detail | Secondary because backup selection follows the current policy decision rather than replacing it | Keeps backup selection focused on current provider-backed capture eligibility | Prevents avoidable backup failures and manual policy-by-policy checking |
|
||||
| Tenant restore-run item selection | Secondary Recovery Surface | Decide whether historical backup content should be restored even when the current provider object is missing | grouped historical items, provider-missing continuity note, and restore mode | linked policy detail, backup metadata, and later restore preview | Secondary because it is only relevant once the operator is already in a recovery flow | Aligns restore continuity with historical backup truth rather than current provider presence | Removes the false impression that a missing policy cannot be restored from history |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant policies list and detail | operator-MSP, support-platform | provider-missing versus ignored state, last observed sync timing, and safe next action | provider result-set interpretation, subtype mismatch context, and historical versions | raw provider payloads and low-level sync traces remain secondary | `Sync` or `Restore local visibility` depending on state | raw provider details and support diagnostics remain hidden/gated | Policy surfaces own current-state meaning; restore surfaces only add historical continuity |
|
||||
| Tenant backup creation and backup-set policy selection | operator-MSP | current capture eligibility and the provider-missing reason when blocked | last observed timing and existing backup availability | raw provider request detail remains hidden | `Export to Backup` when eligible | detailed provider diagnostics stay off picker rows | Backup surfaces state only capture eligibility; they do not restate full policy lifecycle semantics |
|
||||
| Tenant restore-run item selection | operator-MSP | historical restore eligibility plus provider-missing continuity note | backup quality, version metadata, and restore mode | raw payloads stay secondary in later restore detail | `Continue restore` | low-level provider diagnostics stay hidden | Restore surfaces own historical continuity only and do not replace current-state policy truth |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant policies list and detail | List / Table / Detail | CRUD / List-first Resource | Open a policy and decide whether to sync, ignore, or restore local visibility | full-row click to policy detail | required | grouped under `More` where already used | existing confirmation-protected ignore/restore actions only | `/admin/t/{tenant}/policies` | existing tenant-scoped policy detail route | tenant context, visibility state, policy type, last sync | Policies / Policy | whether the policy is active, ignored locally, or missing from provider | none |
|
||||
| Tenant backup creation and backup-set policy selection | Contextual action family | Action modal / picker | Create or extend a backup with eligible current policies | explicit action modal and picker | forbidden | existing helper text and picker descriptions only | no new destructive action | existing tenant backup set and backup creation routes | existing backup-set detail route | tenant context, provider-backed eligibility, backup quality | Backup selection / Policy snapshot | whether a policy can still be snapshotted now | none |
|
||||
| Tenant restore-run item selection | Wizard / Selection | Recovery selector | Select historical items to restore | grouped checkbox or selection list | forbidden | descriptions and later preview only | no destructive action on the selection step | existing tenant restore-run collection route | existing restore-run creation and preview routes | tenant context, backup set, restore mode, historical continuity | Restore items / Backup item | whether a historical item remains selectable even when current provider presence is missing | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant policies list and detail | Tenant operator | Decide what the current policy state means and what action is still safe | List/detail | Is this policy intentionally ignored, currently missing from provider, or still active? | visibility state, type, platform, last sync, and safe next action | subtype mismatch context, history, and provider-backed evidence | local suppression, provider presence, last observed state | TenantPilot only until an allowed sync is started | Sync, Ignore, Restore | Ignore |
|
||||
| Tenant backup creation and backup-set policy selection | Tenant operator | Decide whether a current policy can be added to a fresh snapshot | Action modal/picker | Can I still create a current backup of this policy right now? | eligible/blocked state, provider-missing reason, and existing backup availability | deeper history and provider diagnostics only when needed | provider presence, backup quality, restore mode | TenantPilot only until current backup export begins | Export to Backup, Add to Backup Set | none |
|
||||
| Tenant restore-run item selection | Tenant operator | Decide whether historical backup data should be restored despite current provider absence | Wizard selector | Can I still restore this historical policy item even if the live object is missing now? | grouped item labels, backup quality, provider-missing continuity note | version metadata and later restore preview | historical continuity, restore mode, provider presence | TenantPilot only in later restore flow | Continue restore | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: yes - one new tenant-owned provider-presence timestamp on `policies`
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: yes - one derived provider-presence state family built from `ignored_at` plus `missing_from_provider_at`
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: current policy, backup, and restore workflows cannot truthfully say whether a policy was intentionally suppressed or simply dropped out of the supported provider result set.
|
||||
- **Existing structure is insufficient because**: `ignored_at` already carries local suppression and sync-driven absence semantics, so any local fix would keep the shared query and action seams contradictory.
|
||||
- **Narrowest correct implementation**: add exactly one provider-presence field on policy truth, reserve `ignored_at` for user suppression, and update the existing shared sync, policy, backup, and restore seams to consume those two truths consistently.
|
||||
- **Ownership cost**: one new migration, small query/copy adjustments, audit entries for missing/reappeared transitions, and focused tests.
|
||||
- **Alternative intentionally rejected**: the full lifecycle taxonomy was rejected as too broad for this current bug. A local badge-only workaround was rejected because it would not fix sync, backup, restore, and audit semantics together.
|
||||
- **Release truth**: current-release truth driven by a repo-visible runtime ambiguity, not future-release platform preparation.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature, Unit
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: the business truth is server-side and sits in policy sync, policy UI, backup eligibility, and restore continuity. Focused feature coverage plus a few narrow unit assertions on derived provider-presence semantics are enough to prove the slice.
|
||||
- **New or expanded test families**: expand the existing policy sync, policy action, backup selection, and restore item selection families; keep one small unit family for provider-presence semantics if the implementation adds a bounded helper
|
||||
- **Fixture / helper cost impact**: low to moderate. Reuse existing tenant, policy, backup set, backup item, and restore-run fixtures. Avoid provider-wide browser harnesses or new heavy support fixtures.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament, shared-detail-family
|
||||
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for policy, backup, and restore surfaces. No browser proof is required.
|
||||
- **Reviewer handoff**: reviewers must confirm that `ignored_at` is no longer written/cleared by sync-driven provider absence logic, that missing policies stay historically restorable, that current backup actions do not pretend missing policies can snapshot live state, and that tenant isolation plus `404` versus `403` semantics stay unchanged.
|
||||
- **Budget / baseline / trend impact**: low feature-local increase only
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php tests/Feature/PolicySyncServiceTest.php tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php tests/Feature/PolicySyncStartSurfaceTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyProviderMissingUiTest.php tests/Feature/PolicyGeneralViewTest.php tests/Feature/BulkDeletePoliciesTest.php tests/Feature/BulkUnignorePoliciesTest.php tests/Unit/Badges/PolicyBadgesTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BulkExportToBackupTest.php tests/Feature/Filament/BackupCreationTest.php tests/Feature/Filament/BackupSetPolicyPickerTableTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- one new `missing_from_provider_at` field on `policies`
|
||||
- reserving `ignored_at` for explicit local suppression only
|
||||
- sync semantics for policies that disappear from the supported provider-backed result set for their canonical policy type
|
||||
- policy list/detail visibility that distinguishes local ignore from provider-missing state
|
||||
- current backup eligibility changes so provider-missing policies are not treated as live snapshot candidates
|
||||
- restore continuity messaging and selection behavior for historical backup items tied to provider-missing policies
|
||||
- explicit auditability for provider-missing and provider-reappeared transitions
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- full managed-object lifecycle taxonomy
|
||||
- workspace or tenant suspension, closure, deletion, retention, or purge
|
||||
- `SoftDeletes` or local deletion semantics for policies
|
||||
- a new `PolicyLifecycleService`, `GhostPolicyRegistry`, or other broad framework
|
||||
- multi-object rollout of provider-presence semantics beyond policies
|
||||
- explicit `provider_deleted_at` semantics when the provider cannot yet prove hard deletion distinctly from absence in the supported result set
|
||||
- customer-facing read-only surfaces, cross-tenant portfolio workflows, or billing-state overlays
|
||||
|
||||
## Dependencies
|
||||
|
||||
- existing policy sync and canonical policy-type reclassification logic in `PolicySyncService`
|
||||
- existing policy list/detail action surfaces in `PolicyResource`
|
||||
- existing backup selection and backup-set creation flows in `BackupService` and related Filament surfaces
|
||||
- existing restore-item option builders in `RestoreRunResource`
|
||||
- existing audit logging infrastructure
|
||||
- explicit sequencing awareness with Spec 251, which owns workspace commercial lifecycle and must remain separate from this policy-level provider-presence slice
|
||||
|
||||
## Assumptions
|
||||
|
||||
- the current provider integration can truthfully detect when a previously seen policy is no longer present in the supported provider-backed result set for its canonical type, even if it cannot always distinguish hard deletion from provider-side scope change
|
||||
- `last_synced_at` continues to mean last successful provider observation for the current local policy row; it does not become a generic heartbeat field for missing policies
|
||||
- local policy suppression must remain explicit and reversible by operator action rather than being cleared automatically by a later sync result
|
||||
- historical `PolicyVersion` and `BackupItem` records already provide enough restore continuity evidence that no new recovery persistence family is needed
|
||||
- Filament v5 on Livewire v4 remains the UI substrate, and no panel/provider registration change is required
|
||||
|
||||
## Risks
|
||||
|
||||
- repo code and tests currently assume sync can revive ignored policies, so the change may reveal hidden dependencies on the old conflated meaning
|
||||
- some provider subtype or canonical-type transitions may still need careful wording so `Provider missing` does not overstate certainty about hard deletion
|
||||
- backup and restore surfaces could drift again if one seam continues to query only `ignored_at` after the new provider-presence field lands
|
||||
- policy actions could become misleading if current-state backup is blocked without also surfacing the historical restore path clearly
|
||||
|
||||
## Candidate Selection Rationale
|
||||
|
||||
- **Selected candidate**: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md` under `Workspace, Tenant & Managed Object Lifecycle Governance v1`, especially the documented follow-up `Provider-Missing Managed Object Truth v1` and the explicit narrower fallback `Provider-Missing Policy Visibility & Restore Continuity v1`
|
||||
- `docs/product/roadmap.md` lifecycle-governance boundary that rejects a broad ghost-policy patch before the full taxonomy is agreed
|
||||
- current repo truth in `apps/platform/app/Models/Policy.php`, `apps/platform/app/Services/Intune/PolicySyncService.php`, `apps/platform/app/Services/Intune/BackupService.php`, `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/RestoreRunResource.php`, and the related policy sync/restore tests
|
||||
- **Why selected**: the active prep queue from P0 through P2 is already represented by Specs 251-260. The broader lifecycle-governance candidate remains too strategic for immediate prep, but the repo already documents a narrower provider-missing follow-up and current code clearly shows a live `ignored_at` conflation bug that affects policy, backup, and restore truth.
|
||||
- **Why this is the smallest viable implementation slice**: it touches only policy provider-presence truth and the directly dependent backup/restore seams, introduces one bounded field, and keeps workspace lifecycle, purge, retention, and multi-object rollout deferred.
|
||||
- **Intentional narrowing from source candidate**: this slice does not define a general lifecycle taxonomy. It only separates provider-missing policy presence from local suppression and preserves restore continuity where historical backups already exist.
|
||||
- **Why close alternatives are deferred**:
|
||||
- the full `Workspace, Tenant & Managed Object Lifecycle Governance v1` candidate remains too broad and explicitly strategic
|
||||
- `Workspace & Tenant Closure Lifecycle v1` is a later follow-up once the broader lifecycle taxonomy is ready
|
||||
- `Cross-Tenant Compare and Promotion v1` already has refreshed spec work in Spec 043 and is not the next open prep target here
|
||||
- Spec 251 already owns workspace commercial state and would be a duplicate vehicle for broader lifecycle semantics
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
- the full `Workspace, Tenant & Managed Object Lifecycle Governance v1` taxonomy
|
||||
- explicit `provider_deleted_at` versus `missing_from_provider_at` distinction if the provider surface later proves hard deletion truth cleanly
|
||||
- multi-object rollout of provider-presence semantics beyond policies
|
||||
- export-before-delete and retention governance after the shared lifecycle taxonomy is agreed
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Tell provider missing apart from local ignore (Priority: P1)
|
||||
|
||||
As a tenant operator, I want policy surfaces to show whether a policy is intentionally ignored locally or simply missing from the current supported provider-backed result set so I can choose the right next action.
|
||||
|
||||
**Why this priority**: This is the core trust fix. If operators still have to guess what `Ignored` means, the slice has failed.
|
||||
|
||||
**Independent Test**: Can be fully tested by syncing policies into missing and locally ignored states, then opening the existing policy resource and verifying the two states render differently and keep their own actions.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a previously observed policy is no longer returned in the supported provider-backed result set, **When** an entitled operator opens the policy list/detail, **Then** the policy renders as provider-missing rather than locally ignored.
|
||||
2. **Given** a policy was intentionally ignored by the operator, **When** a later sync still sees the same provider object, **Then** the sync does not clear the local ignore state automatically.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Keep restore continuity honest (Priority: P1)
|
||||
|
||||
As a tenant operator, I want missing policies to remain historically restorable from existing backups without being treated as active live policies for new snapshots.
|
||||
|
||||
**Why this priority**: The product already has backup and restore truth. The slice must preserve that value while stopping misleading current-state actions.
|
||||
|
||||
**Independent Test**: Can be fully tested by marking a policy provider-missing, then verifying that current backup selection blocks it while historical backup items for that policy remain selectable in restore creation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a policy is provider-missing but has existing backup history, **When** the operator enters the restore-item selection flow, **Then** the historical backup items remain selectable with explicit provider-missing continuity messaging.
|
||||
2. **Given** a policy is provider-missing, **When** the operator attempts a current-state backup/export flow, **Then** the product keeps that policy visible but blocked with an explicit provider-missing reason instead of pretending it is a normal active policy.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Record and clear provider-missing transitions predictably (Priority: P2)
|
||||
|
||||
As an operator or support reviewer, I want provider-missing and reappeared transitions to be auditable so later policy history makes sense without new lifecycle frameworks.
|
||||
|
||||
**Why this priority**: The state is only trustworthy if later reviewers can see why it changed and when it returned.
|
||||
|
||||
**Independent Test**: Can be fully tested by running sync once with the policy missing and once with the policy reappearing, then checking the local policy state and audit evidence.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a previously observed policy disappears from the supported provider result set, **When** sync completes, **Then** the policy remains local, receives `missing_from_provider_at`, and records an audit entry for the provider-missing transition.
|
||||
2. **Given** a provider-missing policy appears again in a later sync, **When** sync completes, **Then** `missing_from_provider_at` clears, local ignore state remains untouched if it exists, and an audit entry records the reappearance.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A policy may be locally ignored and later also become provider-missing. Local suppression remains the primary local control, while provider-missing stays secondary continuity context.
|
||||
- A combined-state policy appears in both the `ignored` and `provider_missing` filter views so operators can reach it from either workflow.
|
||||
- A policy may move between supported canonical policy types. Where the canonical type can still be resolved, the record should be reclassified instead of marked provider-missing.
|
||||
- A provider subtype can fall out of the current supported result set without proving hard deletion. The slice must not overstate that as local deletion.
|
||||
- A backup item may reference a policy row that no longer exists locally. Historical restore continuity must keep following existing `BackupItem` truth rather than depending on a current policy row.
|
||||
- Wrong-tenant access or missing capability must keep the current `404` versus `403` semantics even when provider-missing copy is added.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature touches existing sync and backup behavior but introduces no new provider call family and no new `OperationRun` type. It must keep tenant isolation, current sync observability, and auditability intact while avoiding any new purge or deletion behavior.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice introduces one new persisted provider-presence field and one derived state family because the current product truth is already wrong without it. A narrower UI-only workaround is insufficient because shared sync, backup, restore, and audit seams would stay contradictory. No new registry, resolver, strategy system, or lifecycle engine is justified.
|
||||
|
||||
**Constitution alignment (XCUT-001):** Policy visibility, backup eligibility, restore continuity, and audit copy are cross-cutting interaction classes already served by shared paths. This slice must land in those shared seams rather than through page-local conditionals.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Membership and tenant entitlement remain the isolation boundary. Non-members stay `404`, members lacking surviving manage/restore capability stay `403`, and provider-missing business-state messaging must not be used as an authorization substitute.
|
||||
|
||||
**Constitution alignment (PROV-001):** Provider-specific detection stays inside the provider-backed sync layer. The persisted and operator-facing truth remains neutral provider-presence language rather than Microsoft endpoint semantics.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature and unit lanes. No browser or heavy-governance family is needed for this slice.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-261-001**: The system MUST reserve `policies.ignored_at` for explicit local suppression only and MUST NOT set or clear it as a side effect of provider absence or provider result-set loss.
|
||||
- **FR-261-002**: The system MUST persist provider-missing observation on the policy record through `missing_from_provider_at` when a previously observed policy is no longer present in the current supported provider-backed result set for its canonical policy type.
|
||||
- **FR-261-003**: A provider-missing policy MUST remain visible in tenant policy surfaces as a local historical record and MUST NOT be treated as hard-deleted or soft-deleted.
|
||||
- **FR-261-004**: Policy list/detail surfaces MUST present provider-missing state distinctly from local ignore state, with truthful action guidance for each state.
|
||||
- **FR-261-005**: Current backup/export flows that depend on live provider-backed policy state MUST keep provider-missing policies visible but blocked with explicit reason text instead of treating them as normal active policies.
|
||||
- **FR-261-006**: Existing restore flows backed by `BackupItem` history MUST continue to offer historical restore continuity for provider-missing policies when the historical item is otherwise eligible.
|
||||
- **FR-261-007**: When sync later observes a provider-missing policy again, the system MUST clear `missing_from_provider_at` and keep any local ignore state unchanged.
|
||||
- **FR-261-008**: Where sync can reclassify the same external object into another supported canonical policy type, it MUST reclassify the local row rather than marking the policy ignored.
|
||||
- **FR-261-009**: The system MUST emit explicit audit evidence when a policy first becomes provider-missing and when it later reappears.
|
||||
- **FR-261-010**: The slice MUST NOT introduce `SoftDeletes`, a new deletion action, a purge path, or a general managed-object lifecycle framework.
|
||||
- **FR-261-011**: When both `ignored_at` and `missing_from_provider_at` are set, the policy MUST remain discoverable from both ignored and provider-missing filter views, and current backup/export surfaces MUST use `provider_missing` as the primary blocked reason while retaining local ignore as secondary context.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Zero sync paths continue to write or clear `ignored_at` for provider-missing semantics after the slice lands.
|
||||
- **SC-002**: Policy surfaces and visibility filters show distinct provider-missing versus locally ignored states, including the combined-state rule, for 100% of covered validation scenarios.
|
||||
- **SC-003**: Provider-missing policies are excluded from current backup eligibility while historical restore continuity remains available in 100% of covered restore-selection scenarios.
|
||||
- **SC-004**: Reappearance of a provider-missing policy clears the provider-missing flag without automatically restoring local ignore state in 100% of covered sync scenarios.
|
||||
218
specs/261-provider-missing-policy-visibility/tasks.md
Normal file
218
specs/261-provider-missing-policy-visibility/tasks.md
Normal file
@ -0,0 +1,218 @@
|
||||
# Tasks: Provider-Missing Policy Visibility & Restore Continuity v1
|
||||
|
||||
**Input**: Design documents from `/specs/261-provider-missing-policy-visibility/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-missing-policy-visibility.openapi.yaml`
|
||||
|
||||
**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` feature/unit lanes already named in `specs/261-provider-missing-policy-visibility/plan.md` and `specs/261-provider-missing-policy-visibility/quickstart.md`. Prefer focused coverage in `apps/platform/tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php`, `apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php`, `apps/platform/tests/Feature/PolicySyncServiceTest.php`, `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/PolicyProviderMissingUiTest.php`, `apps/platform/tests/Feature/BulkDeletePoliciesTest.php`, `apps/platform/tests/Feature/BulkUnignorePoliciesTest.php`, `apps/platform/tests/Feature/BulkExportToBackupTest.php`, `apps/platform/tests/Feature/Filament/BackupCreationTest.php`, `apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php`, `apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php`, and `apps/platform/tests/Unit/Badges/PolicyBadgesTest.php`. Do not add browser or heavy-governance coverage for this slice.
|
||||
**Operations**: No new `OperationRun` family is introduced. Existing policy sync and current backup/export surfaces keep their shared start UX only when an action is still allowed. Provider-missing current backup/export blocks must stop before run creation and explain why locally.
|
||||
**RBAC**: Preserve current workspace/tenant deny-as-not-found `404` boundaries, retain `403` for in-scope capability failures on existing policy, backup, and restore actions, and do not introduce new capability strings or role checks.
|
||||
**UI / Surface Guardrails**: This is `review-mandatory` work across native Filament policy, backup, and restore surfaces. Keep `standard-native-filament` relief for the policy and backup screens and `shared-detail-family` proof for restore continuity. Explicitly prove one dominant next action per changed surface, diagnostics-secondary ordering, hidden or capability-gated support detail, and no duplicate visible decision summary. Do not add a new page, diagnostics shell, panel, or asset.
|
||||
**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, or provider work is introduced. `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/BackupSetResource.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource.php` remain the only affected operator-facing surfaces.
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` -> `US2` -> `US3` -> final polish and validation because provider-presence truth must exist before backup/restore and audit behavior can be finished safely.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in focused `Feature` and `Unit` files only; no browser or heavy-governance family is added.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, and support defaults remain cheap by default.
|
||||
- [x] Planned validation commands stay limited to the targeted Sail test commands already captured in `specs/261-provider-missing-policy-visibility/plan.md` and `specs/261-provider-missing-policy-visibility/quickstart.md`.
|
||||
- [x] The declared surface test profile stays `standard-native-filament` plus `shared-detail-family` where restore continuity needs explicit proof.
|
||||
- [x] Any follow-up or wording note resolves in this feature as `document-in-feature` or `follow-up-spec`, not as implicit scope creep.
|
||||
|
||||
## Phase 1: Setup (Shared Inventory)
|
||||
|
||||
**Purpose**: Lock the concrete source seams and narrow proof commands before implementation starts.
|
||||
|
||||
- [x] T001 [P] Verify the source-truth inventory across `apps/platform/app/Models/Policy.php`, `apps/platform/app/Services/Intune/PolicySyncService.php`, `apps/platform/app/Services/Intune/BackupService.php`, `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/BackupSetResource.php`, `apps/platform/app/Filament/Resources/RestoreRunResource.php`, and `apps/platform/app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php`
|
||||
- [x] T002 [P] Verify the focused validation commands and proof lanes in `specs/261-provider-missing-policy-visibility/plan.md` and `specs/261-provider-missing-policy-visibility/quickstart.md`
|
||||
- [x] T003 [P] Verify the badge and audit seams across `apps/platform/tests/Unit/Badges/PolicyBadgesTest.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and the current policy sync test family
|
||||
|
||||
**Checkpoint**: The narrow seam inventory and proving commands are locked before runtime code changes begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Proof Anchors)
|
||||
|
||||
**Purpose**: Make the failing proof and shared state vocabulary explicit before touching shared runtime truth.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [x] T004 [P] Lock sync-state proof across `apps/platform/tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php`, `apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php`, `apps/platform/tests/Feature/PolicySyncServiceTest.php`, `apps/platform/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php`, and `apps/platform/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php`
|
||||
- [x] T005 [P] Lock policy-surface and badge proof across `apps/platform/tests/Feature/Filament/PolicyProviderMissingUiTest.php`, `apps/platform/tests/Feature/PolicyGeneralViewTest.php`, and `apps/platform/tests/Unit/Badges/PolicyBadgesTest.php`, including one dominant next action, diagnostics-secondary ordering, and no duplicate visible decision summary
|
||||
- [x] T006 [P] Lock current-backup and restore-continuity proof across `apps/platform/tests/Feature/BulkExportToBackupTest.php`, `apps/platform/tests/Feature/Filament/BackupCreationTest.php`, `apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php`, `apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php`, and `apps/platform/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
|
||||
|
||||
**Checkpoint**: Failing proof files and state-vocabulary expectations are explicit and ready for bounded implementation work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Distinguish provider missing from local ignore (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make policy sync and policy surfaces tell the truth about provider-missing versus locally ignored state.
|
||||
|
||||
**Independent Test**: Sync a previously observed policy out of the supported provider result set while keeping another policy locally ignored, then verify policy list/detail surfaces show distinct states and sync no longer auto-clears local ignore.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T007 [P] [US1] Add sync-state coverage for provider-missing detection, reappearance, and no-auto-revive behavior in `apps/platform/tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php`, `apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php`, and `apps/platform/tests/Feature/PolicySyncServiceTest.php`
|
||||
- [x] T008 [P] [US1] Add policy UI and badge coverage for provider-missing versus ignored states in `apps/platform/tests/Feature/Filament/PolicyProviderMissingUiTest.php`, `apps/platform/tests/Feature/PolicyGeneralViewTest.php`, and `apps/platform/tests/Unit/Badges/PolicyBadgesTest.php`, including dominant-action, diagnostics-secondary, and duplicate-summary assertions
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T009 [US1] Add `missing_from_provider_at` persistence and provider-presence helpers in `apps/platform/database/migrations/*_add_missing_from_provider_at_to_policies_table.php` and `apps/platform/app/Models/Policy.php`
|
||||
- [x] T010 [US1] Update sync and type-filter semantics in `apps/platform/app/Services/Intune/PolicySyncService.php` so provider absence sets/clears `missing_from_provider_at`, supported-type reclassification stops using `ignored_at`, and local ignore survives reappearance
|
||||
- [x] T011 [US1] Update policy filters, badges, helper copy, and action availability in `apps/platform/app/Filament/Resources/PolicyResource.php` and any related policy page helpers so provider-missing state is distinct from local ignore
|
||||
- [x] T012 [US1] Reconcile local ignore regression expectations in `apps/platform/tests/Feature/BulkDeletePoliciesTest.php`, `apps/platform/tests/Feature/BulkUnignorePoliciesTest.php`, `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php`, `apps/platform/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php`, and `apps/platform/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional and policy surfaces no longer collapse provider absence into local ignore.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Keep current backup/export honest while preserving restore continuity (Priority: P1)
|
||||
|
||||
**Goal**: Stop treating provider-missing policies as current snapshot candidates while keeping historical restore paths available.
|
||||
|
||||
**Independent Test**: Mark a policy provider-missing, verify current backup/export selection blocks or excludes it, then verify historical restore selection still offers the related backup item with continuity messaging.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T013 [P] [US2] Add current backup/export eligibility coverage in `apps/platform/tests/Feature/BulkExportToBackupTest.php`, `apps/platform/tests/Feature/Filament/BackupCreationTest.php`, and `apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php`, including the combined ignored-plus-missing case where `provider_missing` is primary and local ignore is secondary context
|
||||
- [x] T014 [P] [US2] Add restore continuity coverage for provider-missing policies in `apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php` and any required restore safety regression assertions in `apps/platform/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`, including calm continuity messaging without duplicating current-state blocker summaries
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T015 [US2] Update current backup/export policy selection in `apps/platform/app/Services/Intune/BackupService.php` so provider-missing policies remain visible but blocked from fresh capture
|
||||
- [x] T016 [US2] Update backup selection/picker messaging in `apps/platform/app/Filament/Resources/BackupSetResource.php` and related backup creation helpers so provider-missing policies explain why fresh capture is unavailable without masquerading as ignored, while the combined state keeps local ignore as secondary context only
|
||||
- [x] T017 [US2] Update historical restore option builders and descriptions in `apps/platform/app/Filament/Resources/RestoreRunResource.php` so provider-missing policies remain selectable from backup history with continuity messaging
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional and backup versus restore truth is no longer conflated.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Audit transitions and preserve safe recovery paths (Priority: P2)
|
||||
|
||||
**Goal**: Make provider-missing and reappeared transitions auditable while keeping existing safe sync-retry behavior on the shared action surface.
|
||||
|
||||
**Independent Test**: Run sync through missing and reappeared transitions, verify audit evidence is emitted, and confirm existing sync-retry surfaces remain truthful without creating new run behavior.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T018 [P] [US3] Add audit-transition coverage in `apps/platform/tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php` and any existing audit assertions that cover policy sync transitions
|
||||
- [x] T019 [P] [US3] Add sync-action and authorization continuity assertions in `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/PolicyProviderMissingUiTest.php`, `apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php`, and `apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T020 [US3] Add or reuse audit action identifiers and emit provider-missing/reappeared events in `apps/platform/app/Support/Audit/AuditActionId.php` plus the existing audit logger call sites reached from policy sync
|
||||
- [x] T021 [US3] Reconcile sync-retry/export action truth in `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php`, and related policy action tests so blocked current backup/export does not create a run and allowed sync retry still uses the shared path
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional and provider-presence transitions are auditable without new lifecycle or run infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: Clean shared wording and run the narrow validation workflow.
|
||||
|
||||
- [x] T022 [P] Reconcile any remaining provider-missing wording, badge labels, helper copy, dominant-action hierarchy, diagnostics-secondary ordering, raw/support gating, and duplicate-summary removal across `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/BackupSetResource.php`, `apps/platform/app/Filament/Resources/RestoreRunResource.php`, and any touched localization or UI helper files
|
||||
- [x] T023 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php tests/Feature/PolicySyncServiceTest.php tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php tests/Feature/PolicySyncStartSurfaceTest.php`
|
||||
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyProviderMissingUiTest.php tests/Feature/PolicyGeneralViewTest.php tests/Feature/BulkDeletePoliciesTest.php tests/Feature/BulkUnignorePoliciesTest.php tests/Unit/Badges/PolicyBadgesTest.php`
|
||||
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BulkExportToBackupTest.php tests/Feature/Filament/BackupCreationTest.php tests/Feature/Filament/BackupSetPolicyPickerTableTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php`
|
||||
- [x] T027 Run final residue searches for sync-driven `ignored_at` writes, expected `missing_from_provider_at` query usage, and the absence of `SoftDeletes`, purge-flow additions, new deletion actions, or lifecycle-framework drift across `apps/platform/app/` and `apps/platform/tests/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and locks the seam inventory plus validation commands.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks story work until failing proof and state vocabulary are explicit.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational and is the MVP entry point because provider-presence truth is the base behavior.
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because backup and restore behavior consume the new provider-presence truth.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and should land after User Story 2 so audit and action continuity match the finished current-state and historical-state behavior.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete so wording and residue checks run against the final shape.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: Depends on US1.
|
||||
- **US3**: Depends on US1 and should follow US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Add or update story tests first and confirm they fail before implementation is considered complete.
|
||||
- Keep `ignored_at` and `missing_from_provider_at` orthogonal rather than introducing a combined stored enum.
|
||||
- Reuse existing policy, backup, and restore surfaces instead of adding a new provider-missing registry or diagnostics page.
|
||||
- Keep provider-deleted taxonomy, multi-object rollout, and broader lifecycle work out of scope.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001`, `T002`, and `T003` can run in parallel during Setup.
|
||||
- `T004`, `T005`, and `T006` can run in parallel during Foundational work.
|
||||
- `T007` and `T008` can run in parallel for User Story 1 before `T009` through `T012`.
|
||||
- `T013` and `T014` can run in parallel for User Story 2 before `T015` through `T017`.
|
||||
- `T018` and `T019` can run in parallel for User Story 3 before `T020` and `T021`.
|
||||
- `T024`, `T025`, and `T026` can run in parallel during final validation.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# User Story 1 tests in parallel
|
||||
T007 apps/platform/tests/Feature/Jobs/PolicySyncProviderMissingSemanticsTest.php + apps/platform/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php + apps/platform/tests/Feature/PolicySyncServiceTest.php
|
||||
T008 apps/platform/tests/Feature/Filament/PolicyProviderMissingUiTest.php + apps/platform/tests/Feature/PolicyGeneralViewTest.php + apps/platform/tests/Unit/Badges/PolicyBadgesTest.php
|
||||
|
||||
# User Story 1 implementation after the tests are in place
|
||||
T009 apps/platform/database/migrations/*_add_missing_from_provider_at_to_policies_table.php + apps/platform/app/Models/Policy.php
|
||||
T010 apps/platform/app/Services/Intune/PolicySyncService.php
|
||||
T011 apps/platform/app/Filament/Resources/PolicyResource.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# User Story 2 tests in parallel
|
||||
T013 apps/platform/tests/Feature/BulkExportToBackupTest.php + apps/platform/tests/Feature/Filament/BackupCreationTest.php + apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php
|
||||
T014 apps/platform/tests/Feature/Filament/RestoreItemSelectionTest.php + apps/platform/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php
|
||||
|
||||
# User Story 2 implementation after the tests are in place
|
||||
T015 apps/platform/app/Services/Intune/BackupService.php
|
||||
T016 apps/platform/app/Filament/Resources/BackupSetResource.php
|
||||
T017 apps/platform/app/Filament/Resources/RestoreRunResource.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Stories 1 and 2)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Complete Phase 4: User Story 2.
|
||||
5. Run `T023`, `T024`, `T025`, and `T026` before widening into audit and action-continuity cleanup.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Introduce provider-presence truth on `Policy` and stop sync from mutating `ignored_at` for provider absence.
|
||||
2. Update policy UI to reflect the corrected state vocabulary.
|
||||
3. Update backup selection so fresh capture uses provider-present truth only.
|
||||
4. Update restore selection so historical backup continuity remains available.
|
||||
5. Add audit continuity and finish with narrow validation commands.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor can own the sync/model/policy surface slice (`US1`) while another prepares backup/restore tests (`US2`) after Phase 2.
|
||||
2. After `US1` lands, backup and restore implementation can proceed without reopening state vocabulary.
|
||||
3. A final pass can add audit continuity plus residue cleanup and run the targeted Sail commands.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Suggested MVP scope: Phase 1 through Phase 4 only. Current backup/export truth is not honest until both US1 and US2 land together.
|
||||
- Explicit non-goals remain: `SoftDeletes`, provider-deleted taxonomy, ghost-policy registry, multi-object lifecycle rollout, workspace/tenant lifecycle semantics, and new browser or heavy-governance proof.
|
||||
- Follow-up candidates remain the same as the prepared spec: the broader lifecycle taxonomy and an explicit provider-deleted distinction.
|
||||
- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths.
|
||||
Loading…
Reference in New Issue
Block a user