PR Body Implements Spec 065 “Tenant RBAC v1” with capabilities-first RBAC, tenant membership scoping (Option 3), and consistent Filament action semantics. Key decisions / rules Tenancy Option 3: tenant switching is tenantless (ChooseTenant), tenant-scoped routes stay scoped, non-members get 404 (not 403). RBAC model: canonical capability registry + role→capability map + Gates for each capability (no role-string checks in UI logic). UX policy: for tenant members lacking permission → actions are visible but disabled + tooltip (avoid click→403). Security still enforced server-side. What’s included Capabilities foundation: Central capability registry (Capabilities::*) Role→capability mapping (RoleCapabilityMap) Gate registration + resolver/manager updates to support tenant-scoped authorization Filament enforcement hardening across the app: Tenant registration & tenant CRUD properly gated Backup/restore/policy flows aligned to “visible-but-disabled” where applicable Provider operations (health check / inventory sync / compliance snapshot) guarded and normalized Directory groups + inventory sync start surfaces normalized Policy version maintenance actions (archive/restore/prune/force delete) gated SpecKit artifacts for 065: spec.md, plan/tasks updates, checklists, enforcement hitlist Security guarantees Non-member → 404 via tenant scoping/membership guards. Member without capability → 403 on execution, even if UI is disabled. No destructive actions execute without proper authorization checks. Tests Adds/updates Pest coverage for: Tenant scoping & membership denial behavior Role matrix expectations (owner/manager/operator/readonly) Filament surface checks (visible/disabled actions, no side effects) Provider/Inventory/Groups run-start authorization Verified locally with targeted vendor/bin/sail artisan test --compact … Deployment / ops notes No new services required. Safe change: behavior is authorization + UI semantics; no breaking route changes intended. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #79
1168 lines
54 KiB
PHP
1168 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 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 Illuminate\Support\Facades\Gate;
|
|
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([
|
|
Actions\Action::make('ignore')
|
|
->label('Ignore')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
->action(function (Policy $record, HasTable $livewire) {
|
|
$tenant = Tenant::current();
|
|
|
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
|
|
$record->ignore();
|
|
|
|
Notification::make()
|
|
->title('Policy ignored')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Actions\Action::make('restore')
|
|
->label('Restore')
|
|
->icon('heroicon-o-arrow-uturn-left')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
->action(function (Policy $record) {
|
|
$tenant = Tenant::current();
|
|
|
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
|
|
$record->unignore();
|
|
|
|
Notification::make()
|
|
->title('Policy restored')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Actions\Action::make('sync')
|
|
->label('Sync')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->visible(function (Policy $record): bool {
|
|
if ($record->ignored_at !== null) {
|
|
return false;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
if (! $tenant instanceof Tenant) {
|
|
return false;
|
|
}
|
|
|
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
|
})
|
|
->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);
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
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();
|
|
}),
|
|
Actions\Action::make('export')
|
|
->label('Export to Backup')
|
|
->icon('heroicon-o-archive-box-arrow-down')
|
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
|
->form([
|
|
Forms\Components\TextInput::make('backup_name')
|
|
->label('Backup Name')
|
|
->required()
|
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
])
|
|
->action(function (Policy $record, array $data) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 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();
|
|
}),
|
|
])->icon('heroicon-o-ellipsis-vertical'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([
|
|
BulkAction::make('bulk_delete')
|
|
->label('Ignore Policies')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
|
$value = $visibilityFilterState['value'] ?? null;
|
|
|
|
return $value === 'ignored';
|
|
})
|
|
->disabled(function (): bool {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return true;
|
|
}
|
|
|
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
})
|
|
->form(function (Collection $records) {
|
|
if ($records->count() >= 20) {
|
|
return [
|
|
Forms\Components\TextInput::make('confirmation')
|
|
->label('Type DELETE to confirm')
|
|
->required()
|
|
->in(['DELETE'])
|
|
->validationMessages([
|
|
'in' => 'Please type DELETE to confirm.',
|
|
]),
|
|
];
|
|
}
|
|
|
|
return [];
|
|
})
|
|
->action(function (Collection $records, array $data) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
if (! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 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.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(),
|
|
|
|
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);
|
|
})
|
|
->disabled(function (): bool {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return true;
|
|
}
|
|
|
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
})
|
|
->action(function (Collection $records, HasTable $livewire) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 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(),
|
|
|
|
BulkAction::make('bulk_sync')
|
|
->label('Sync Policies')
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('primary')
|
|
->requiresConfirmation()
|
|
->hidden(function (HasTable $livewire): bool {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return true;
|
|
}
|
|
|
|
$tenant = Tenant::current();
|
|
if (! $tenant instanceof Tenant) {
|
|
return true;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
return true;
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
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(),
|
|
|
|
BulkAction::make('bulk_export')
|
|
->label('Export to Backup')
|
|
->icon('heroicon-o-archive-box-arrow-down')
|
|
->disabled(function (): bool {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return true;
|
|
}
|
|
|
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
|
})
|
|
->form([
|
|
Forms\Components\TextInput::make('backup_name')
|
|
->label('Backup Name')
|
|
->required()
|
|
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
|
])
|
|
->action(function (Collection $records, array $data) {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
$count = $records->count();
|
|
$ids = $records->pluck('id')->toArray();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 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(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|