Kontext / Ziel Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk). Links zur Spec: spec.md plan.md quickstart.md Was ist drin Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions) Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext) Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns) CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**: verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort) RBAC-UX Semantik (konsequent & testbar) Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards. Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects). Member mit Capability: Action enabled und ausführbar. Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)). Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes. Sicherheit / Scope Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP). Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership). Kein Asset-Setup erforderlich; keine neuen Filament Assets. Compliance Notes (Repo-Regeln) Filament v5 / Livewire v4.0+ kompatibel. Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert). Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR. Tests / Qualität Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard. Guard-Test: “No ad-hoc Filament auth patterns”. Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped. Checklist: requirements.md vollständig (16/16). Review-Fokus API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply() Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #81
1130 lines
54 KiB
PHP
1130 lines
54 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\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 BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkAction;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Forms;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Infolists\Components\ViewEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Tabs;
|
|
use Filament\Schemas\Components\Tabs\Tab;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use UnitEnum;
|
|
|
|
class PolicyResource extends Resource
|
|
{
|
|
protected static ?string $model = Policy::class;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Policy Details')
|
|
->schema([
|
|
TextEntry::make('display_name')->label('Policy'),
|
|
TextEntry::make('policy_type')->label('Type'),
|
|
TextEntry::make('platform'),
|
|
TextEntry::make('external_id')->label('External ID'),
|
|
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
|
TextEntry::make('created_at')->since(),
|
|
TextEntry::make('latest_snapshot_mode')
|
|
->label('Snapshot')
|
|
->badge()
|
|
->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(),
|
|
])->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(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$tenantId = Tenant::current()->getKey();
|
|
|
|
return parent::getEloquentQuery()
|
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
|
->withCount('versions')
|
|
->with([
|
|
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
|
]);
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
VersionsRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListPolicies::route('/'),
|
|
'view' => Pages\ViewPolicy::route('/{record}'),
|
|
];
|
|
}
|
|
|
|
private static function latestSnapshot(Policy $record): array
|
|
{
|
|
$snapshot = $record->relationLoaded('versions')
|
|
? $record->versions->first()?->snapshot
|
|
: $record->versions()->orderByDesc('captured_at')->value('snapshot');
|
|
|
|
if (is_string($snapshot)) {
|
|
$decoded = json_decode($snapshot, true);
|
|
$snapshot = $decoded ?? [];
|
|
}
|
|
|
|
if (is_array($snapshot)) {
|
|
return $snapshot;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private static function latestVersionMetadata(Policy $record): array
|
|
{
|
|
$metadata = $record->relationLoaded('versions')
|
|
? $record->versions->first()?->metadata
|
|
: $record->versions()->orderByDesc('captured_at')->value('metadata');
|
|
|
|
if (is_string($metadata)) {
|
|
$decoded = json_decode($metadata, true);
|
|
$metadata = $decoded ?? [];
|
|
}
|
|
|
|
return is_array($metadata) ? $metadata : [];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function normalizedPolicyState(Policy $record): array
|
|
{
|
|
$cacheKey = 'tenantpilot.normalizedPolicyState.'.(string) $record->getKey();
|
|
$request = request();
|
|
|
|
if ($request->attributes->has($cacheKey)) {
|
|
$cached = $request->attributes->get($cacheKey);
|
|
|
|
if (is_array($cached)) {
|
|
return $cached;
|
|
}
|
|
}
|
|
|
|
$snapshot = static::latestSnapshot($record);
|
|
|
|
$normalized = app(PolicyNormalizer::class)->normalize(
|
|
$snapshot,
|
|
$record->policy_type,
|
|
$record->platform
|
|
);
|
|
|
|
$normalized['context'] = 'policy';
|
|
$normalized['record_id'] = (string) $record->getKey();
|
|
$normalized['policy_type'] = $record->policy_type;
|
|
|
|
$request->attributes->set($cacheKey, $normalized);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array{settings?: array<int, array<string, mixed>>} $normalized
|
|
* @return array{normalized: array<string, mixed>, general: ?array<string, mixed>}
|
|
*/
|
|
private static function splitGeneralBlock(array $normalized): array
|
|
{
|
|
$general = null;
|
|
$filtered = [];
|
|
|
|
foreach ($normalized['settings'] ?? [] as $block) {
|
|
if (! is_array($block)) {
|
|
continue;
|
|
}
|
|
|
|
$title = $block['title'] ?? null;
|
|
|
|
if (is_string($title) && strtolower($title) === 'general') {
|
|
$general = $block;
|
|
|
|
continue;
|
|
}
|
|
|
|
$filtered[] = $block;
|
|
}
|
|
|
|
$normalized['settings'] = $filtered;
|
|
|
|
return [
|
|
'normalized' => $normalized,
|
|
'general' => $general,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,string>|array<string,mixed>
|
|
*/
|
|
private static function typeMeta(?string $type): array
|
|
{
|
|
if ($type === null) {
|
|
return [];
|
|
}
|
|
|
|
return collect(config('tenantpilot.supported_policy_types', []))
|
|
->firstWhere('type', $type) ?? [];
|
|
}
|
|
|
|
private static function usesTabbedLayout(Policy $record): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
private static function hasSettingsTable(Policy $record): bool
|
|
{
|
|
$normalized = static::normalizedPolicyState($record);
|
|
$rows = $normalized['settings_table']['rows'] ?? [];
|
|
|
|
return is_array($rows) && $rows !== [];
|
|
}
|
|
|
|
/**
|
|
* @return array{entries: array<int, array{key: string, value: mixed}>}
|
|
*/
|
|
private static function generalOverviewState(Policy $record): array
|
|
{
|
|
$snapshot = static::latestSnapshot($record);
|
|
$entries = [];
|
|
|
|
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
|
if (is_string($name) && $name !== '') {
|
|
$entries[] = ['key' => 'Name', 'value' => $name];
|
|
}
|
|
|
|
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
|
if (is_string($platforms) && $platforms !== '') {
|
|
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
|
} elseif (is_array($platforms) && $platforms !== []) {
|
|
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
|
}
|
|
|
|
$technologies = $snapshot['technologies'] ?? null;
|
|
if (is_string($technologies) && $technologies !== '') {
|
|
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
|
} elseif (is_array($technologies) && $technologies !== []) {
|
|
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
|
}
|
|
|
|
if (array_key_exists('templateReference', $snapshot)) {
|
|
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
|
|
}
|
|
|
|
$settingCount = $snapshot['settingCount']
|
|
?? $snapshot['settingsCount']
|
|
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
|
|
|
if (is_int($settingCount) || is_numeric($settingCount)) {
|
|
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
|
|
}
|
|
|
|
$version = $snapshot['version'] ?? null;
|
|
if (is_string($version) && $version !== '') {
|
|
$entries[] = ['key' => 'Version', 'value' => $version];
|
|
} elseif (is_numeric($version)) {
|
|
$entries[] = ['key' => 'Version', 'value' => $version];
|
|
}
|
|
|
|
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
|
if (is_string($lastModified) && $lastModified !== '') {
|
|
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
|
|
}
|
|
|
|
$createdAt = $snapshot['createdDateTime'] ?? null;
|
|
if (is_string($createdAt) && $createdAt !== '') {
|
|
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
|
}
|
|
|
|
$description = $snapshot['description'] ?? null;
|
|
if (is_string($description) && $description !== '') {
|
|
$entries[] = ['key' => 'Description', 'value' => $description];
|
|
}
|
|
|
|
return [
|
|
'entries' => $entries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function settingsTabState(Policy $record): array
|
|
{
|
|
$normalized = static::normalizedPolicyState($record);
|
|
$rows = $normalized['settings_table']['rows'] ?? [];
|
|
$hasSettingsTable = is_array($rows) && $rows !== [];
|
|
|
|
if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) {
|
|
$split = static::splitGeneralBlock($normalized);
|
|
|
|
return $split['normalized'];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
}
|