Summary
Implements Spec 085 “Tenant Operate Hub” semantics so central Monitoring pages are context-aware when entered from a tenant, without changing canonical URLs or implicitly mutating tenant selection. Also fixes a UX leak where tenant-scoped Inventory/Policies/Backups surfaces could appear in Admin navigation / be reachable without a selected tenant.
Why
Reduce “where am I / lost tenant context” confusion when operators jump between tenant work and central Monitoring.
Preserve deny-as-not-found security semantics and avoid tenant identity leaks.
Keep tenant-scoped data surfaces strictly tenant-scoped (not workspace-scoped).
What changed
Context-aware Monitoring:
/admin/operations shows scope label + CTAs (“Back to <tenant>”, “Show all tenants”) when tenant context is active and entitled.
/admin/operations/{run} shows deterministic back affordances + optional escape hatch (“Show all operations”) when tenant context is active and entitled.
Canonical Monitoring GET routes do not mutate tenant context.
Stale tenant context (not entitled) falls back to workspace scope without leaking tenant identity.
Tenant navigation IA:
Tenant panel sidebar provides “Monitoring” shortcuts (Runs/Alerts/Audit Log) into the central Monitoring surfaces.
Tenant-scoped Admin surfaces guard:
Inventory/Policies/Policy Versions/Backup Sets no longer show up tenantless; direct access redirects to /admin/choose-tenant when no tenant is selected.
Tests
Added/updated Pest coverage for:
Spec 085 header affordances + stale-context behavior
deny-as-not-found regressions for non-members/non-entitled users
“DB-only render” (no outbound calls) for Monitoring pages
tenant-scoped admin surfaces redirect when no tenant selected
Compatibility / Constraints
Filament v5 + Livewire v4 compliant (no v3 APIs).
Panel providers remain registered via providers.php (Laravel 11+/12).
No new assets; no changes to filament:assets deployment requirements.
No global search changes.
Manual verification
From a tenant, click “Monitoring → Runs” and confirm:
Scope label shows tenant scope
“Show all tenants” clears tenant context and returns to workspace scope
Open a run detail and confirm “Back to <tenant>” behavior + “Show all operations”.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@ebc83aaa-d947-4a08-b88e-bd72ac9645f7.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #104
1166 lines
56 KiB
PHP
1166 lines
56 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\PolicyResource\Pages;
|
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
|
use App\Jobs\BulkPolicyDeleteJob;
|
|
use App\Jobs\BulkPolicyExportJob;
|
|
use App\Jobs\BulkPolicyUnignoreJob;
|
|
use App\Jobs\SyncPoliciesJob;
|
|
use App\Models\Policy;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Intune\PolicyNormalizer;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Operations\BulkSelectionIdentity;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Badges\TagBadgeDomain;
|
|
use App\Support\Badges\TagBadgeRenderer;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Forms;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Infolists\Components\ViewEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Tabs;
|
|
use Filament\Schemas\Components\Tabs\Tab;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use UnitEnum;
|
|
|
|
class PolicyResource extends Resource
|
|
{
|
|
protected static ?string $model = Policy::class;
|
|
|
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
/** @var CapabilityResolver $resolver */
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $tenant)
|
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Policy Details')
|
|
->schema([
|
|
TextEntry::make('display_name')->label('Policy'),
|
|
TextEntry::make('policy_type')->label('Type'),
|
|
TextEntry::make('platform'),
|
|
TextEntry::make('external_id')->label('External ID'),
|
|
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
|
TextEntry::make('created_at')->since(),
|
|
TextEntry::make('latest_snapshot_mode')
|
|
->label('Snapshot')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
|
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode))
|
|
->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata_only' : 'full')
|
|
->helperText(function (Policy $record): ?string {
|
|
$meta = static::latestVersionMetadata($record);
|
|
|
|
if (($meta['source'] ?? null) !== 'metadata_only') {
|
|
return null;
|
|
}
|
|
|
|
$status = $meta['original_status'] ?? null;
|
|
|
|
return sprintf(
|
|
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
|
$status ?? 'an error'
|
|
);
|
|
})
|
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
|
|
// Tabbed content (General / Settings / JSON)
|
|
Tabs::make('policy_content')
|
|
->activeTab(1)
|
|
->persistTabInQueryString()
|
|
->tabs([
|
|
Tab::make('General')
|
|
->id('general')
|
|
->schema([
|
|
ViewEntry::make('policy_general')
|
|
->label('')
|
|
->view('filament.infolists.entries.policy-general')
|
|
->state(function (Policy $record) {
|
|
return static::generalOverviewState($record);
|
|
}),
|
|
])
|
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
|
Tab::make('Settings')
|
|
->id('settings')
|
|
->schema([
|
|
ViewEntry::make('settings_catalog')
|
|
->label('')
|
|
->view('filament.infolists.entries.normalized-settings')
|
|
->state(function (Policy $record) {
|
|
return static::settingsTabState($record);
|
|
})
|
|
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
|
|
$record->versions()->exists()
|
|
),
|
|
|
|
ViewEntry::make('settings_standard')
|
|
->label('')
|
|
->view('filament.infolists.entries.policy-settings-standard')
|
|
->state(function (Policy $record) {
|
|
return static::settingsTabState($record);
|
|
})
|
|
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
|
|
$record->versions()->exists()
|
|
),
|
|
|
|
TextEntry::make('no_settings_available')
|
|
->label('Settings')
|
|
->state('No policy snapshot available yet.')
|
|
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
|
|
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
|
]),
|
|
Tab::make('JSON')
|
|
->id('json')
|
|
->schema([
|
|
ViewEntry::make('snapshot_json')
|
|
->view('filament.infolists.entries.snapshot-json')
|
|
->state(fn (Policy $record) => static::latestSnapshot($record))
|
|
->columnSpanFull(),
|
|
TextEntry::make('snapshot_size')
|
|
->label('Payload Size')
|
|
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
|
->formatStateUsing(function ($state) {
|
|
if ($state > 512000) {
|
|
return '<span class="inline-flex items-center gap-1 text-warning-600 dark:text-warning-400 font-semibold">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
</svg>
|
|
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
|
</span>';
|
|
}
|
|
|
|
return number_format($state / 1024, 1).' KB';
|
|
})
|
|
->html()
|
|
->visible(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000),
|
|
])
|
|
->visible(fn (Policy $record) => $record->versions()->exists()),
|
|
])
|
|
->columnSpanFull()
|
|
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
|
|
|
// Legacy layout (kept for fallback if tabs are disabled)
|
|
Section::make('Settings')
|
|
->schema([
|
|
ViewEntry::make('settings')
|
|
->label('')
|
|
->view('filament.infolists.entries.normalized-settings')
|
|
->state(function (Policy $record) {
|
|
$normalized = app(PolicyNormalizer::class)->normalize(
|
|
static::latestSnapshot($record),
|
|
$record->policy_type ?? '',
|
|
$record->platform
|
|
);
|
|
|
|
$normalized['context'] = 'policy';
|
|
$normalized['record_id'] = (string) $record->getKey();
|
|
|
|
return $normalized;
|
|
}),
|
|
])
|
|
->columnSpanFull()
|
|
->visible(function (Policy $record) {
|
|
return ! static::usesTabbedLayout($record);
|
|
}),
|
|
|
|
Section::make('Policy Snapshot (JSON)')
|
|
->schema([
|
|
ViewEntry::make('snapshot_json')
|
|
->view('filament.infolists.entries.snapshot-json')
|
|
->state(fn (Policy $record) => static::latestSnapshot($record))
|
|
->columnSpanFull(),
|
|
|
|
TextEntry::make('snapshot_size')
|
|
->label('Payload Size')
|
|
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
|
->formatStateUsing(function ($state) {
|
|
if ($state > 512000) {
|
|
return '<span class="inline-flex items-center gap-1 text-warning-600 dark:text-warning-400 font-semibold">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
</svg>
|
|
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
|
</span>';
|
|
}
|
|
|
|
return number_format($state / 1024, 1).' KB';
|
|
})
|
|
->html()
|
|
->visible(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000),
|
|
])
|
|
->collapsible()
|
|
->collapsed(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])) > 512000)
|
|
->description('Raw JSON configuration from Microsoft Graph API')
|
|
->columnSpanFull()
|
|
->visible(function (Policy $record) {
|
|
return ! static::usesTabbedLayout($record);
|
|
}),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->modifyQueryUsing(function (Builder $query) {
|
|
// Quick-Workaround: Hide policies not synced in last 7 days
|
|
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
|
$query->where('last_synced_at', '>', now()->subDays(7));
|
|
})
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('display_name')
|
|
->label('Policy')
|
|
->searchable(),
|
|
Tables\Columns\TextColumn::make('policy_type')
|
|
->label('Type')
|
|
->badge()
|
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
|
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
|
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
|
Tables\Columns\TextColumn::make('category')
|
|
->label('Category')
|
|
->badge()
|
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
|
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
|
Tables\Columns\TextColumn::make('restore_mode')
|
|
->label('Restore')
|
|
->badge()
|
|
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
|
Tables\Columns\TextColumn::make('platform')
|
|
->badge()
|
|
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
|
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('settings_status')
|
|
->label('Settings')
|
|
->badge()
|
|
->state(function (Policy $record) {
|
|
$latest = $record->versions->first();
|
|
$snapshot = $latest?->snapshot ?? [];
|
|
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
|
|
|
return $hasSettings ? 'Available' : 'Missing';
|
|
})
|
|
->color(function (Policy $record) {
|
|
$latest = $record->versions->first();
|
|
$snapshot = $latest?->snapshot ?? [];
|
|
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
|
|
|
return $hasSettings ? 'success' : 'gray';
|
|
}),
|
|
Tables\Columns\TextColumn::make('external_id')
|
|
->label('External ID')
|
|
->copyable()
|
|
->limit(32),
|
|
Tables\Columns\TextColumn::make('last_synced_at')
|
|
->label('Last synced')
|
|
->dateTime()
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('created_at')
|
|
->dateTime()
|
|
->since()
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters([
|
|
Tables\Filters\SelectFilter::make('visibility')
|
|
->label('Visibility')
|
|
->options([
|
|
'active' => 'Active',
|
|
'ignored' => 'Ignored',
|
|
])
|
|
->default('active')
|
|
->query(function (Builder $query, array $data) {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if (blank($value)) {
|
|
return;
|
|
}
|
|
|
|
if ($value === 'active') {
|
|
$query->whereNull('ignored_at');
|
|
|
|
return;
|
|
}
|
|
|
|
if ($value === 'ignored') {
|
|
$query->whereNotNull('ignored_at');
|
|
}
|
|
}),
|
|
Tables\Filters\SelectFilter::make('policy_type')
|
|
->options(function () {
|
|
return collect(config('tenantpilot.supported_policy_types', []))
|
|
->pluck('label', 'type')
|
|
->map(fn ($label, $type) => $label ?? $type)
|
|
->all();
|
|
}),
|
|
Tables\Filters\SelectFilter::make('category')
|
|
->options(function () {
|
|
return collect(config('tenantpilot.supported_policy_types', []))
|
|
->pluck('category', 'category')
|
|
->filter()
|
|
->unique()
|
|
->sort()
|
|
->all();
|
|
})
|
|
->query(function (Builder $query, array $data) {
|
|
$category = $data['value'] ?? null;
|
|
if (! $category) {
|
|
return;
|
|
}
|
|
|
|
$types = collect(config('tenantpilot.supported_policy_types', []))
|
|
->where('category', $category)
|
|
->pluck('type')
|
|
->all();
|
|
|
|
$query->whereIn('policy_type', $types);
|
|
}),
|
|
Tables\Filters\SelectFilter::make('platform')
|
|
->options(fn () => Policy::query()
|
|
->distinct()
|
|
->pluck('platform', 'platform')
|
|
->filter()
|
|
->reject(fn ($platform) => is_string($platform) && strtolower($platform) === 'all')
|
|
->all()),
|
|
])
|
|
->actions([
|
|
Actions\ViewAction::make(),
|
|
ActionGroup::make([
|
|
UiEnforcement::forTableAction(
|
|
Actions\Action::make('ignore')
|
|
->label('Ignore')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
->action(function (Policy $record): void {
|
|
$record->ignore();
|
|
|
|
Notification::make()
|
|
->title('Policy ignored')
|
|
->success()
|
|
->send();
|
|
}),
|
|
fn () => Tenant::current(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->tooltip('You do not have permission to ignore policies.')
|
|
->preserveVisibility()
|
|
->apply(),
|
|
UiEnforcement::forTableAction(
|
|
Actions\Action::make('restore')
|
|
->label('Restore')
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
|
->action(function (Policy $record): void {
|
|
$record->unignore();
|
|
|
|
Notification::make()
|
|
->title('Policy restored')
|
|
->success()
|
|
->send();
|
|
}),
|
|
fn () => Tenant::current(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->tooltip('You do not have permission to restore policies.')
|
|
->preserveVisibility()
|
|
->apply(),
|
|
UiEnforcement::forTableAction(
|
|
Actions\Action::make('sync')
|
|
->label('Sync')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
->action(function (Policy $record, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
/** @var OperationRunService $opService */
|
|
$opService = app(OperationRunService::class);
|
|
$opRun = $opService->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'policy.sync_one',
|
|
inputs: [
|
|
'scope' => 'one',
|
|
'policy_id' => (int) $record->getKey(),
|
|
],
|
|
initiator: $user
|
|
);
|
|
|
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
Notification::make()
|
|
->title('Policy sync already active')
|
|
->body('This operation is already queued or running.')
|
|
->warning()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
}),
|
|
fn () => Tenant::current(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_SYNC)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
UiEnforcement::forTableAction(
|
|
Actions\Action::make('export')
|
|
->label('Export to Backup')
|
|
->icon('heroicon-o-archive-box-arrow-down')
|
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
->form([
|
|
Forms\Components\TextInput::make('backup_name')
|
|
->label('Backup Name')
|
|
->required()
|
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
])
|
|
->action(function (Policy $record, array $data): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$ids = [(int) $record->getKey()];
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'policy.export',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
|
|
BulkPolicyExportJob::dispatchSync(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
policyIds: $ids,
|
|
backupName: (string) $data['backup_name'],
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'backup_name' => (string) $data['backup_name'],
|
|
'policy_count' => 1,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
}),
|
|
fn () => Tenant::current(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
])
|
|
->label('More')
|
|
->icon('heroicon-o-ellipsis-vertical'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
UiEnforcement::forBulkAction(
|
|
BulkAction::make('bulk_delete')
|
|
->label('Ignore Policies')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
$value = $visibilityFilterState['value'] ?? null;
|
|
|
|
return $value === 'ignored';
|
|
})
|
|
->form(function (Collection $records) {
|
|
if ($records->count() >= 20) {
|
|
return [
|
|
Forms\Components\TextInput::make('confirmation')
|
|
->label('Type DELETE to confirm')
|
|
->required()
|
|
->in(['DELETE'])
|
|
->validationMessages([
|
|
'in' => 'Please type DELETE to confirm.',
|
|
]),
|
|
];
|
|
}
|
|
|
|
return [];
|
|
})
|
|
->action(function (Collection $records): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'policy.delete',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
|
|
BulkPolicyDeleteJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
policyIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'policy_count' => $count,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Policy delete queued')
|
|
->body("Queued deletion for {$count} policies.")
|
|
->icon('heroicon-o-arrow-path')
|
|
->iconColor('warning')
|
|
->info()
|
|
->actions([
|
|
\Filament\Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->duration(8000)
|
|
->sendToDatabase($user)
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->apply(),
|
|
|
|
UiEnforcement::forBulkAction(
|
|
BulkAction::make('bulk_restore')
|
|
->label('Restore Policies')
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
$value = $visibilityFilterState['value'] ?? null;
|
|
|
|
return ! in_array($value, [null, 'ignored'], true);
|
|
})
|
|
->action(function (Collection $records, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'policy.unignore',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
|
if ($count >= 20) {
|
|
BulkPolicyUnignoreJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
policyIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
BulkPolicyUnignoreJob::dispatchSync(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
policyIds: $ids,
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'policy_count' => $count,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
if ($count >= 20) {
|
|
Notification::make()
|
|
->title('Bulk restore started')
|
|
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
|
->icon('heroicon-o-arrow-path')
|
|
->iconColor('warning')
|
|
->info()
|
|
->duration(8000)
|
|
->sendToDatabase($user)
|
|
->send();
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
|
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->apply(),
|
|
|
|
UiEnforcement::forBulkAction(
|
|
BulkAction::make('bulk_sync')
|
|
->label('Sync Policies')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
$value = $visibilityFilterState['value'] ?? null;
|
|
|
|
return $value === 'ignored';
|
|
})
|
|
->action(function (Collection $records, HasTable $livewire): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$ids = $records
|
|
->pluck('id')
|
|
->map(static fn ($id): int => (int) $id)
|
|
->unique()
|
|
->sort()
|
|
->values()
|
|
->all();
|
|
|
|
/** @var OperationRunService $opService */
|
|
$opService = app(OperationRunService::class);
|
|
$opRun = $opService->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'policy.sync',
|
|
inputs: [
|
|
'scope' => 'subset',
|
|
'policy_ids' => $ids,
|
|
],
|
|
initiator: $user
|
|
);
|
|
|
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
Notification::make()
|
|
->title('Policy sync already active')
|
|
->body('This operation is already queued or running.')
|
|
->warning()
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_SYNC)
|
|
->apply(),
|
|
|
|
UiEnforcement::forBulkAction(
|
|
BulkAction::make('bulk_export')
|
|
->label('Export to Backup')
|
|
->icon('heroicon-o-archive-box-arrow-down')
|
|
->form([
|
|
Forms\Components\TextInput::make('backup_name')
|
|
->label('Backup Name')
|
|
->required()
|
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
])
|
|
->action(function (Collection $records, array $data): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
/** @var BulkSelectionIdentity $selection */
|
|
$selection = app(BulkSelectionIdentity::class);
|
|
|
|
$selectionIdentity = $selection->fromIds($ids);
|
|
|
|
/** @var OperationRunService $runs */
|
|
$runs = app(OperationRunService::class);
|
|
|
|
$opRun = $runs->enqueueBulkOperation(
|
|
tenant: $tenant,
|
|
type: 'policy.export',
|
|
targetScope: [
|
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
|
],
|
|
selectionIdentity: $selectionIdentity,
|
|
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
|
if ($count >= 20) {
|
|
BulkPolicyExportJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
policyIds: $ids,
|
|
backupName: (string) $data['backup_name'],
|
|
operationRun: $operationRun,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
BulkPolicyExportJob::dispatchSync(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
policyIds: $ids,
|
|
backupName: (string) $data['backup_name'],
|
|
operationRun: $operationRun,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'backup_name' => (string) $data['backup_name'],
|
|
'policy_count' => $count,
|
|
],
|
|
emitQueuedNotification: false,
|
|
);
|
|
|
|
if ($count >= 20) {
|
|
Notification::make()
|
|
->title('Bulk export started')
|
|
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
|
|
->icon('heroicon-o-arrow-path')
|
|
->iconColor('warning')
|
|
->info()
|
|
->duration(8000)
|
|
->sendToDatabase($user)
|
|
->send();
|
|
}
|
|
|
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
])
|
|
->send();
|
|
})
|
|
->deselectRecordsAfterCompletion(),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->apply(),
|
|
])->label('More'),
|
|
]);
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$tenantId = Tenant::currentOrFail()->getKey();
|
|
|
|
return parent::getEloquentQuery()
|
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
->withCount('versions')
|
|
->with([
|
|
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
|
]);
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
VersionsRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListPolicies::route('/'),
|
|
'view' => Pages\ViewPolicy::route('/{record}'),
|
|
];
|
|
}
|
|
|
|
private static function latestSnapshot(Policy $record): array
|
|
{
|
|
$snapshot = $record->relationLoaded('versions')
|
|
? $record->versions->first()?->snapshot
|
|
: $record->versions()->orderByDesc('captured_at')->value('snapshot');
|
|
|
|
if (is_string($snapshot)) {
|
|
$decoded = json_decode($snapshot, true);
|
|
$snapshot = $decoded ?? [];
|
|
}
|
|
|
|
if (is_array($snapshot)) {
|
|
return $snapshot;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private static function latestVersionMetadata(Policy $record): array
|
|
{
|
|
$metadata = $record->relationLoaded('versions')
|
|
? $record->versions->first()?->metadata
|
|
: $record->versions()->orderByDesc('captured_at')->value('metadata');
|
|
|
|
if (is_string($metadata)) {
|
|
$decoded = json_decode($metadata, true);
|
|
$metadata = $decoded ?? [];
|
|
}
|
|
|
|
return is_array($metadata) ? $metadata : [];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function normalizedPolicyState(Policy $record): array
|
|
{
|
|
$cacheKey = 'tenantpilot.normalizedPolicyState.'.(string) $record->getKey();
|
|
$request = request();
|
|
|
|
if ($request->attributes->has($cacheKey)) {
|
|
$cached = $request->attributes->get($cacheKey);
|
|
|
|
if (is_array($cached)) {
|
|
return $cached;
|
|
}
|
|
}
|
|
|
|
$snapshot = static::latestSnapshot($record);
|
|
|
|
$normalized = app(PolicyNormalizer::class)->normalize(
|
|
$snapshot,
|
|
$record->policy_type,
|
|
$record->platform
|
|
);
|
|
|
|
$normalized['context'] = 'policy';
|
|
$normalized['record_id'] = (string) $record->getKey();
|
|
$normalized['policy_type'] = $record->policy_type;
|
|
|
|
$request->attributes->set($cacheKey, $normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array{settings?: array<int, array<string, mixed>>} $normalized
|
|
* @return array{normalized: array<string, mixed>, general: ?array<string, mixed>}
|
|
*/
|
|
private static function splitGeneralBlock(array $normalized): array
|
|
{
|
|
$general = null;
|
|
$filtered = [];
|
|
|
|
foreach ($normalized['settings'] ?? [] as $block) {
|
|
if (! is_array($block)) {
|
|
continue;
|
|
}
|
|
|
|
$title = $block['title'] ?? null;
|
|
|
|
if (is_string($title) && strtolower($title) === 'general') {
|
|
$general = $block;
|
|
|
|
continue;
|
|
}
|
|
|
|
$filtered[] = $block;
|
|
}
|
|
|
|
$normalized['settings'] = $filtered;
|
|
|
|
return [
|
|
'normalized' => $normalized,
|
|
'general' => $general,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,string>|array<string,mixed>
|
|
*/
|
|
private static function typeMeta(?string $type): array
|
|
{
|
|
if ($type === null) {
|
|
return [];
|
|
}
|
|
|
|
return collect(config('tenantpilot.supported_policy_types', []))
|
|
->firstWhere('type', $type) ?? [];
|
|
}
|
|
|
|
private static function usesTabbedLayout(Policy $record): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
private static function hasSettingsTable(Policy $record): bool
|
|
{
|
|
$normalized = static::normalizedPolicyState($record);
|
|
$rows = $normalized['settings_table']['rows'] ?? [];
|
|
|
|
return is_array($rows) && $rows !== [];
|
|
}
|
|
|
|
/**
|
|
* @return array{entries: array<int, array{key: string, value: mixed}>}
|
|
*/
|
|
private static function generalOverviewState(Policy $record): array
|
|
{
|
|
$snapshot = static::latestSnapshot($record);
|
|
$entries = [];
|
|
|
|
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
|
if (is_string($name) && $name !== '') {
|
|
$entries[] = ['key' => 'Name', 'value' => $name];
|
|
}
|
|
|
|
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
|
if (is_string($platforms) && $platforms !== '') {
|
|
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
|
} elseif (is_array($platforms) && $platforms !== []) {
|
|
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
|
}
|
|
|
|
$technologies = $snapshot['technologies'] ?? null;
|
|
if (is_string($technologies) && $technologies !== '') {
|
|
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
|
} elseif (is_array($technologies) && $technologies !== []) {
|
|
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
|
}
|
|
|
|
if (array_key_exists('templateReference', $snapshot)) {
|
|
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
|
|
}
|
|
|
|
$settingCount = $snapshot['settingCount']
|
|
?? $snapshot['settingsCount']
|
|
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
|
|
|
if (is_int($settingCount) || is_numeric($settingCount)) {
|
|
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
|
|
}
|
|
|
|
$version = $snapshot['version'] ?? null;
|
|
if (is_string($version) && $version !== '') {
|
|
$entries[] = ['key' => 'Version', 'value' => $version];
|
|
} elseif (is_numeric($version)) {
|
|
$entries[] = ['key' => 'Version', 'value' => $version];
|
|
}
|
|
|
|
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
|
if (is_string($lastModified) && $lastModified !== '') {
|
|
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
|
|
}
|
|
|
|
$createdAt = $snapshot['createdDateTime'] ?? null;
|
|
if (is_string($createdAt) && $createdAt !== '') {
|
|
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
|
}
|
|
|
|
$description = $snapshot['description'] ?? null;
|
|
if (is_string($description) && $description !== '') {
|
|
$entries[] = ['key' => 'Description', 'value' => $description];
|
|
}
|
|
|
|
return [
|
|
'entries' => $entries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function settingsTabState(Policy $record): array
|
|
{
|
|
$normalized = static::normalizedPolicyState($record);
|
|
$rows = $normalized['settings_table']['rows'] ?? [];
|
|
$hasSettingsTable = is_array($rows) && $rows !== [];
|
|
|
|
if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
|
|
$split = static::splitGeneralBlock($normalized);
|
|
|
|
return $split['normalized'];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
}
|