feat(044): drift findings UI + bulk acknowledge
This commit is contained in:
parent
4a894891a0
commit
66b4695581
@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Drift\DriftRunSelector;
|
use App\Services\Drift\DriftRunSelector;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -23,6 +28,30 @@ class DriftLanding extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.drift-landing';
|
protected string $view = 'filament.pages.drift-landing';
|
||||||
|
|
||||||
|
public ?string $state = null;
|
||||||
|
|
||||||
|
public ?string $message = null;
|
||||||
|
|
||||||
|
public ?string $scopeKey = null;
|
||||||
|
|
||||||
|
public ?int $baselineRunId = null;
|
||||||
|
|
||||||
|
public ?int $currentRunId = null;
|
||||||
|
|
||||||
|
public ?string $baselineFinishedAt = null;
|
||||||
|
|
||||||
|
public ?string $currentFinishedAt = null;
|
||||||
|
|
||||||
|
public ?int $bulkOperationRunId = null;
|
||||||
|
|
||||||
|
/** @var array<string, int>|null */
|
||||||
|
public ?array $statusCounts = null;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return FindingResource::canAccess();
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
@ -40,21 +69,45 @@ public function mount(): void
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
if (! $latestSuccessful instanceof InventorySyncRun) {
|
||||||
|
$this->state = 'blocked';
|
||||||
|
$this->message = 'No successful inventory runs found yet.';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||||
|
$this->scopeKey = $scopeKey;
|
||||||
|
|
||||||
$selector = app(DriftRunSelector::class);
|
$selector = app(DriftRunSelector::class);
|
||||||
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||||
|
|
||||||
if ($comparison === null) {
|
if ($comparison === null) {
|
||||||
|
$this->state = 'blocked';
|
||||||
|
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseline = $comparison['baseline'];
|
$baseline = $comparison['baseline'];
|
||||||
$current = $comparison['current'];
|
$current = $comparison['current'];
|
||||||
|
|
||||||
|
$this->baselineRunId = (int) $baseline->getKey();
|
||||||
|
$this->currentRunId = (int) $current->getKey();
|
||||||
|
|
||||||
|
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
||||||
|
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
$exists = Finding::query()
|
$exists = Finding::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
@ -64,15 +117,87 @@ public function mount(): void
|
|||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($exists) {
|
if ($exists) {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$newCount = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('baseline_run_id', $baseline->getKey())
|
||||||
|
->where('current_run_id', $current->getKey())
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$latestRun = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
if ($activeRun instanceof BulkOperationRun) {
|
||||||
|
$this->state = 'generating';
|
||||||
|
$this->bulkOperationRunId = (int) $activeRun->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||||
|
|
||||||
|
$newCount = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('baseline_run_id', $baseline->getKey())
|
||||||
|
->where('current_run_id', $current->getKey())
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||||
|
|
||||||
|
if ($newCount === 0) {
|
||||||
|
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof BulkOperationRun && in_array($latestRun->status, ['failed', 'aborted'], true)) {
|
||||||
|
$this->state = 'error';
|
||||||
|
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||||
|
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkOperationService = app(BulkOperationService::class);
|
||||||
|
$run = $bulkOperationService->createRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
resource: 'drift',
|
||||||
|
action: 'generate',
|
||||||
|
itemIds: [$scopeKey],
|
||||||
|
totalItems: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->update(['idempotency_key' => $idempotencyKey]);
|
||||||
|
|
||||||
|
$this->state = 'generating';
|
||||||
|
$this->bulkOperationRunId = (int) $run->getKey();
|
||||||
|
|
||||||
GenerateDriftFindingsJob::dispatch(
|
GenerateDriftFindingsJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) $user->getKey(),
|
userId: (int) $user->getKey(),
|
||||||
baselineRunId: (int) $baseline->getKey(),
|
baselineRunId: (int) $baseline->getKey(),
|
||||||
currentRunId: (int) $current->getKey(),
|
currentRunId: (int) $current->getKey(),
|
||||||
scopeKey: $scopeKey,
|
scopeKey: $scopeKey,
|
||||||
|
bulkOperationRunId: (int) $run->getKey(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,4 +205,31 @@ public function getFindingsUrl(): string
|
|||||||
{
|
{
|
||||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBaselineRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->baselineRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->currentRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBulkRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->bulkOperationRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BulkOperationRunResource::getUrl('view', ['record' => $this->bulkOperationRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,28 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
use App\Filament\Resources\FindingResource\Pages;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class FindingResource extends Resource
|
class FindingResource extends Resource
|
||||||
@ -29,22 +43,326 @@ public static function form(Schema $schema): Schema
|
|||||||
return $schema;
|
return $schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Finding')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||||
|
TextEntry::make('status')->badge(),
|
||||||
|
TextEntry::make('severity')->badge(),
|
||||||
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
||||||
|
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
||||||
|
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||||
|
TextEntry::make('subject_type')->label('Subject type'),
|
||||||
|
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||||
|
TextEntry::make('baseline_run_id')
|
||||||
|
->label('Baseline run')
|
||||||
|
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
||||||
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('current_run_id')
|
||||||
|
->label('Current run')
|
||||||
|
->url(fn (Finding $record): ?string => $record->current_run_id
|
||||||
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
||||||
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Diff')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('settings_diff')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.normalized-diff')
|
||||||
|
->state(function (Finding $record): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
|
$baselineVersion = is_numeric($baselineId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$currentVersion = is_numeric($currentId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||||
|
|
||||||
|
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
|
||||||
|
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
|
||||||
|
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
|
||||||
|
|
||||||
|
if (($addedCount + $removedCount + $changedCount) === 0) {
|
||||||
|
Arr::set(
|
||||||
|
$diff,
|
||||||
|
'summary.message',
|
||||||
|
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $diff;
|
||||||
|
})
|
||||||
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
ViewEntry::make('scope_tags_diff')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.scope-tags-diff')
|
||||||
|
->state(function (Finding $record): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
|
$baselineVersion = is_numeric($baselineId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$currentVersion = is_numeric($currentId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
||||||
|
})
|
||||||
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
ViewEntry::make('assignments_diff')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.assignments-diff')
|
||||||
|
->state(function (Finding $record): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
|
$baselineVersion = is_numeric($baselineId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$currentVersion = is_numeric($currentId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
||||||
|
})
|
||||||
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->collapsed()
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Evidence (Sanitized)')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('evidence_jsonb')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
||||||
Tables\Columns\TextColumn::make('status')->badge(),
|
Tables\Columns\TextColumn::make('status')->badge(),
|
||||||
Tables\Columns\TextColumn::make('severity')->badge(),
|
Tables\Columns\TextColumn::make('severity')->badge(),
|
||||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject')->searchable(),
|
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||||
])
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options([
|
||||||
|
Finding::STATUS_NEW => 'New',
|
||||||
|
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
||||||
|
])
|
||||||
|
->default(Finding::STATUS_NEW),
|
||||||
|
Tables\Filters\SelectFilter::make('finding_type')
|
||||||
|
->options([
|
||||||
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||||
|
])
|
||||||
|
->default(Finding::FINDING_TYPE_DRIFT),
|
||||||
|
Tables\Filters\Filter::make('scope_key')
|
||||||
|
->form([
|
||||||
|
TextInput::make('scope_key')
|
||||||
|
->label('Scope key')
|
||||||
|
->placeholder('Inventory selection hash')
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$scopeKey = $data['scope_key'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($scopeKey) || $scopeKey === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('scope_key', $scopeKey);
|
||||||
|
}),
|
||||||
|
Tables\Filters\Filter::make('run_ids')
|
||||||
|
->label('Run IDs')
|
||||||
|
->form([
|
||||||
|
TextInput::make('baseline_run_id')
|
||||||
|
->label('Baseline run id')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('current_run_id')
|
||||||
|
->label('Current run id')
|
||||||
|
->numeric(),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$baselineRunId = $data['baseline_run_id'] ?? null;
|
||||||
|
if (is_numeric($baselineRunId)) {
|
||||||
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRunId = $data['current_run_id'] ?? null;
|
||||||
|
if (is_numeric($currentRunId)) {
|
||||||
|
$query->where('current_run_id', (int) $currentRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}),
|
||||||
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('acknowledge')
|
||||||
|
->label('Acknowledge')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
||||||
|
->authorize(function (Finding $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('update', $record);
|
||||||
|
})
|
||||||
|
->action(function (Finding $record): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Finding belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->acknowledge($user);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Finding acknowledged')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
BulkAction::make('acknowledge_selected')
|
||||||
|
->label('Acknowledge selected')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->authorize(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||||
|
|
||||||
|
return $user->can('update', $probe);
|
||||||
|
})
|
||||||
|
->authorizeIndividualRecords('update')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Collection $records): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstRecord = $records->first();
|
||||||
|
if ($firstRecord instanceof Finding) {
|
||||||
|
Gate::authorize('update', $firstRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
$acknowledgedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== Finding::STATUS_NEW) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->acknowledge($user);
|
||||||
|
$acknowledgedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk acknowledge completed')
|
||||||
|
->body($body)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -52,6 +370,13 @@ public static function getEloquentQuery(): Builder
|
|||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::current()->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
|
->addSelect([
|
||||||
|
'subject_display_name' => InventoryItem::query()
|
||||||
|
->select('display_name')
|
||||||
|
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
||||||
|
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
||||||
|
->limit(1),
|
||||||
|
])
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,169 @@
|
|||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ListFindings extends ListRecords
|
class ListFindings extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingResource::class;
|
protected static string $resource = FindingResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('acknowledge_all_matching')
|
||||||
|
->label('Acknowledge all matching')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->authorize(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||||
|
|
||||||
|
return $user->can('update', $probe);
|
||||||
|
})
|
||||||
|
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
||||||
|
->modalDescription(function (): string {
|
||||||
|
$count = $this->getAllMatchingCount();
|
||||||
|
|
||||||
|
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
||||||
|
})
|
||||||
|
->form(function (): array {
|
||||||
|
$count = $this->getAllMatchingCount();
|
||||||
|
|
||||||
|
if ($count <= 100) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
TextInput::make('confirmation')
|
||||||
|
->label('Type ACKNOWLEDGE to confirm')
|
||||||
|
->required()
|
||||||
|
->in(['ACKNOWLEDGE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->buildAllMatchingQuery();
|
||||||
|
$count = (clone $query)->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
Notification::make()
|
||||||
|
->title('No matching findings')
|
||||||
|
->body('There are no new findings matching the current filters.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstRecord = (clone $query)->first();
|
||||||
|
if ($firstRecord instanceof Finding) {
|
||||||
|
Gate::authorize('update', $firstRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $query->update([
|
||||||
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
'acknowledged_at' => now(),
|
||||||
|
'acknowledged_by_user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->deselectAllTableRecords();
|
||||||
|
$this->resetPage();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk acknowledge completed')
|
||||||
|
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildAllMatchingQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$query = Finding::query();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where('tenant_id', $tenant->getKey());
|
||||||
|
|
||||||
|
$query->where('status', Finding::STATUS_NEW);
|
||||||
|
|
||||||
|
$findingType = $this->getFindingTypeFilterValue();
|
||||||
|
if (is_string($findingType) && $findingType !== '') {
|
||||||
|
$query->where('finding_type', $findingType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
||||||
|
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
||||||
|
if (is_string($scopeKey) && $scopeKey !== '') {
|
||||||
|
$query->where('scope_key', $scopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
||||||
|
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
||||||
|
if (is_numeric($baselineRunId)) {
|
||||||
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
||||||
|
if (is_numeric($currentRunId)) {
|
||||||
|
$query->where('current_run_id', (int) $currentRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAllMatchingCount(): int
|
||||||
|
{
|
||||||
|
return (int) $this->buildAllMatchingQuery()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getStatusFilterValue(): string
|
||||||
|
{
|
||||||
|
$state = $this->getTableFilterState('status') ?? [];
|
||||||
|
$value = Arr::get($state, 'value');
|
||||||
|
|
||||||
|
return is_string($value) && $value !== ''
|
||||||
|
? $value
|
||||||
|
: Finding::STATUS_NEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFindingTypeFilterValue(): string
|
||||||
|
{
|
||||||
|
$state = $this->getTableFilterState('finding_type') ?? [];
|
||||||
|
$value = Arr::get($state, 'value');
|
||||||
|
|
||||||
|
return is_string($value) && $value !== ''
|
||||||
|
? $value
|
||||||
|
: Finding::FINDING_TYPE_DRIFT;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Drift\DriftFindingGenerator;
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class GenerateDriftFindingsJob implements ShouldQueue
|
class GenerateDriftFindingsJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@ -22,13 +26,22 @@ public function __construct(
|
|||||||
public int $baselineRunId,
|
public int $baselineRunId,
|
||||||
public int $currentRunId,
|
public int $currentRunId,
|
||||||
public string $scopeKey,
|
public string $scopeKey,
|
||||||
|
public int $bulkOperationRunId,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the job.
|
* Execute the job.
|
||||||
*/
|
*/
|
||||||
public function handle(DriftFindingGenerator $generator): void
|
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
|
||||||
{
|
{
|
||||||
|
Log::info('GenerateDriftFindingsJob: started', [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
|
'current_run_id' => $this->currentRunId,
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||||
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
throw new RuntimeException('Tenant not found.');
|
throw new RuntimeException('Tenant not found.');
|
||||||
@ -44,11 +57,48 @@ public function handle(DriftFindingGenerator $generator): void
|
|||||||
throw new RuntimeException('Current run not found.');
|
throw new RuntimeException('Current run not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$generator->generate(
|
$run = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->bulkOperationRunId);
|
||||||
|
|
||||||
|
if (! $run instanceof BulkOperationRun) {
|
||||||
|
throw new RuntimeException('Bulk operation run not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkOperationService->start($run);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$created = $generator->generate(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
baseline: $baseline,
|
baseline: $baseline,
|
||||||
current: $current,
|
current: $current,
|
||||||
scopeKey: $this->scopeKey,
|
scopeKey: $this->scopeKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Log::info('GenerateDriftFindingsJob: completed', [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
|
'current_run_id' => $this->currentRunId,
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||||
|
'created_findings_count' => $created,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bulkOperationService->recordSuccess($run);
|
||||||
|
$bulkOperationService->complete($run);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('GenerateDriftFindingsJob: failed', [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
|
'current_run_id' => $this->currentRunId,
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bulkOperationService->fail($run, $e->getMessage());
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,5 +61,7 @@ public function acknowledge(User $user): void
|
|||||||
'acknowledged_at' => now(),
|
'acknowledged_at' => now(),
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
'acknowledged_by_user_id' => $user->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
304
app/Services/Drift/DriftFindingDiffBuilder.php
Normal file
304
app/Services/Drift/DriftFindingDiffBuilder.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
|
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||||
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||||
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||||
|
use App\Services\Intune\VersionDiff;
|
||||||
|
|
||||||
|
class DriftFindingDiffBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettingsNormalizer $settingsNormalizer,
|
||||||
|
private readonly AssignmentsNormalizer $assignmentsNormalizer,
|
||||||
|
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||||
|
private readonly VersionDiff $versionDiff,
|
||||||
|
private readonly EntraGroupLabelResolver $groupLabelResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildSettingsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array
|
||||||
|
{
|
||||||
|
$policyType = $currentVersion?->policy_type ?? $baselineVersion?->policy_type ?? '';
|
||||||
|
$platform = $currentVersion?->platform ?? $baselineVersion?->platform;
|
||||||
|
|
||||||
|
$from = $baselineVersion
|
||||||
|
? $this->settingsNormalizer->normalizeForDiff(is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [], (string) $policyType, $platform)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$to = $currentVersion
|
||||||
|
? $this->settingsNormalizer->normalizeForDiff(is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [], (string) $policyType, $platform)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$result = $this->versionDiff->compare($from, $to);
|
||||||
|
$result['policy_type'] = $policyType;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildAssignmentsDiff(Tenant $tenant, ?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, int $limit = 200): array
|
||||||
|
{
|
||||||
|
$baseline = $baselineVersion ? $this->assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments) : [];
|
||||||
|
$current = $currentVersion ? $this->assignmentsNormalizer->normalizeForDiff($currentVersion->assignments) : [];
|
||||||
|
|
||||||
|
$baselineMap = [];
|
||||||
|
foreach ($baseline as $row) {
|
||||||
|
$baselineMap[$row['key']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentMap = [];
|
||||||
|
foreach ($current as $row) {
|
||||||
|
$currentMap[$row['key']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allKeys = array_values(array_unique(array_merge(array_keys($baselineMap), array_keys($currentMap))));
|
||||||
|
sort($allKeys);
|
||||||
|
|
||||||
|
$added = [];
|
||||||
|
$removed = [];
|
||||||
|
$changed = [];
|
||||||
|
|
||||||
|
foreach ($allKeys as $key) {
|
||||||
|
$from = $baselineMap[$key] ?? null;
|
||||||
|
$to = $currentMap[$key] ?? null;
|
||||||
|
|
||||||
|
if ($from === null && is_array($to)) {
|
||||||
|
$added[] = $to;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($to === null && is_array($from)) {
|
||||||
|
$removed[] = $from;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($from) || ! is_array($to)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diffFields = [
|
||||||
|
'filter_type',
|
||||||
|
'filter_id',
|
||||||
|
'intent',
|
||||||
|
'mode',
|
||||||
|
];
|
||||||
|
|
||||||
|
$fieldChanges = [];
|
||||||
|
|
||||||
|
foreach ($diffFields as $field) {
|
||||||
|
$fromValue = $from[$field] ?? null;
|
||||||
|
$toValue = $to[$field] ?? null;
|
||||||
|
|
||||||
|
if ($fromValue !== $toValue) {
|
||||||
|
$fieldChanges[$field] = [
|
||||||
|
'from' => $fromValue,
|
||||||
|
'to' => $toValue,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fieldChanges !== []) {
|
||||||
|
$changed[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'include_exclude' => $to['include_exclude'],
|
||||||
|
'target_type' => $to['target_type'],
|
||||||
|
'target_id' => $to['target_id'],
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'changes' => $fieldChanges,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated = false;
|
||||||
|
|
||||||
|
$total = count($added) + count($removed) + count($changed);
|
||||||
|
if ($total > $limit) {
|
||||||
|
$truncated = true;
|
||||||
|
|
||||||
|
$budget = $limit;
|
||||||
|
|
||||||
|
$changed = array_slice($changed, 0, min(count($changed), $budget));
|
||||||
|
$budget -= count($changed);
|
||||||
|
|
||||||
|
$added = array_slice($added, 0, min(count($added), $budget));
|
||||||
|
$budget -= count($added);
|
||||||
|
|
||||||
|
$removed = array_slice($removed, 0, min(count($removed), $budget));
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = $this->groupLabelsForDiff($tenant, $added, $removed, $changed);
|
||||||
|
|
||||||
|
$decorateAssignment = function (array $row) use ($labels): array {
|
||||||
|
$row['target_label'] = $this->targetLabel($row, $labels);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
|
||||||
|
$decorateChanged = function (array $row) use ($decorateAssignment): array {
|
||||||
|
$row['from'] = is_array($row['from'] ?? null) ? $decorateAssignment($row['from']) : $row['from'];
|
||||||
|
$row['to'] = is_array($row['to'] ?? null) ? $decorateAssignment($row['to']) : $row['to'];
|
||||||
|
$row['target_label'] = is_array($row['to'] ?? null) ? ($row['to']['target_label'] ?? null) : null;
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'added' => count($added),
|
||||||
|
'removed' => count($removed),
|
||||||
|
'changed' => count($changed),
|
||||||
|
'message' => sprintf('%d added, %d removed, %d changed', count($added), count($removed), count($changed)),
|
||||||
|
'truncated' => $truncated,
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'added' => array_map($decorateAssignment, $added),
|
||||||
|
'removed' => array_map($decorateAssignment, $removed),
|
||||||
|
'changed' => array_map($decorateChanged, $changed),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildScopeTagsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array
|
||||||
|
{
|
||||||
|
$baselineIds = $baselineVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags) ?? []) : [];
|
||||||
|
$currentIds = $currentVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags) ?? []) : [];
|
||||||
|
|
||||||
|
$baselineLabels = $baselineVersion ? $this->scopeTagsNormalizer->labelsById($baselineVersion->scope_tags) : [];
|
||||||
|
$currentLabels = $currentVersion ? $this->scopeTagsNormalizer->labelsById($currentVersion->scope_tags) : [];
|
||||||
|
|
||||||
|
$baselineSet = array_fill_keys($baselineIds, true);
|
||||||
|
$currentSet = array_fill_keys($currentIds, true);
|
||||||
|
|
||||||
|
$addedIds = array_values(array_diff($currentIds, $baselineIds));
|
||||||
|
$removedIds = array_values(array_diff($baselineIds, $currentIds));
|
||||||
|
|
||||||
|
sort($addedIds);
|
||||||
|
sort($removedIds);
|
||||||
|
|
||||||
|
$decorate = static function (array $ids, array $labels): array {
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (! is_string($id) || $id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $labels[$id] ?? ($id === '0' ? 'Default' : $id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'added' => count($addedIds),
|
||||||
|
'removed' => count($removedIds),
|
||||||
|
'changed' => 0,
|
||||||
|
'message' => sprintf('%d added, %d removed', count($addedIds), count($removedIds)),
|
||||||
|
'baseline_count' => count($baselineSet),
|
||||||
|
'current_count' => count($currentSet),
|
||||||
|
],
|
||||||
|
'added' => $decorate($addedIds, $currentLabels),
|
||||||
|
'removed' => $decorate($removedIds, $baselineLabels),
|
||||||
|
'baseline' => $decorate($baselineIds, $baselineLabels),
|
||||||
|
'current' => $decorate($currentIds, $currentLabels),
|
||||||
|
'changed' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $added
|
||||||
|
* @param array<int, array<string, mixed>> $removed
|
||||||
|
* @param array<int, array<string, mixed>> $changed
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function groupLabelsForDiff(Tenant $tenant, array $added, array $removed, array $changed): array
|
||||||
|
{
|
||||||
|
$groupIds = [];
|
||||||
|
|
||||||
|
foreach ([$added, $removed] as $items) {
|
||||||
|
foreach ($items as $row) {
|
||||||
|
$targetType = $row['target_type'] ?? null;
|
||||||
|
$targetId = $row['target_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($targetType, 'groupassignmenttarget')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds[] = $targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($changed as $row) {
|
||||||
|
$targetType = $row['target_type'] ?? null;
|
||||||
|
$targetId = $row['target_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($targetType, 'groupassignmenttarget')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds[] = $targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = array_values(array_unique($groupIds));
|
||||||
|
|
||||||
|
if ($groupIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->groupLabelResolver->resolveMany($tenant, $groupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $assignment
|
||||||
|
* @param array<string, string> $groupLabels
|
||||||
|
*/
|
||||||
|
private function targetLabel(array $assignment, array $groupLabels): string
|
||||||
|
{
|
||||||
|
$targetType = $assignment['target_type'] ?? null;
|
||||||
|
$targetId = $assignment['target_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||||
|
return 'Unknown target';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
|
||||||
|
return 'All devices';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'allusersassignmenttarget')) {
|
||||||
|
return 'All users';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'groupassignmenttarget')) {
|
||||||
|
return $groupLabels[$targetId] ?? EntraGroupLabelResolver::formatLabel(null, $targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s (%s)', $targetType, $targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,8 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||||
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@ -15,6 +17,8 @@ class DriftFindingGenerator
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DriftHasher $hasher,
|
private readonly DriftHasher $hasher,
|
||||||
private readonly DriftEvidence $evidence,
|
private readonly DriftEvidence $evidence,
|
||||||
|
private readonly SettingsNormalizer $settingsNormalizer,
|
||||||
|
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
||||||
@ -48,17 +52,103 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
|||||||
$baselineVersion = $this->versionForRun($policy, $baseline);
|
$baselineVersion = $this->versionForRun($policy, $baseline);
|
||||||
$currentVersion = $this->versionForRun($policy, $current);
|
$currentVersion = $this->versionForRun($policy, $current);
|
||||||
|
|
||||||
|
if ($baselineVersion instanceof PolicyVersion || $currentVersion instanceof PolicyVersion) {
|
||||||
|
$policyType = (string) ($policy->policy_type ?? '');
|
||||||
|
$platform = is_string($policy->platform ?? null) ? $policy->platform : null;
|
||||||
|
|
||||||
|
$baselineSnapshot = $baselineVersion instanceof PolicyVersion && is_array($baselineVersion->snapshot)
|
||||||
|
? $baselineVersion->snapshot
|
||||||
|
: [];
|
||||||
|
$currentSnapshot = $currentVersion instanceof PolicyVersion && is_array($currentVersion->snapshot)
|
||||||
|
? $currentVersion->snapshot
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform);
|
||||||
|
$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform);
|
||||||
|
|
||||||
|
$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized);
|
||||||
|
$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized);
|
||||||
|
|
||||||
|
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
||||||
|
$changeType = match (true) {
|
||||||
|
$baselineVersion instanceof PolicyVersion && ! $currentVersion instanceof PolicyVersion => 'removed',
|
||||||
|
! $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion => 'added',
|
||||||
|
default => 'modified',
|
||||||
|
};
|
||||||
|
|
||||||
|
$fingerprint = $this->hasher->fingerprint(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
scopeKey: $scopeKey,
|
||||||
|
subjectType: 'policy',
|
||||||
|
subjectExternalId: (string) $policy->external_id,
|
||||||
|
changeType: $changeType,
|
||||||
|
baselineHash: $baselineSnapshotHash,
|
||||||
|
currentHash: $currentSnapshotHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rawEvidence = [
|
||||||
|
'change_type' => $changeType,
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_snapshot',
|
||||||
|
'changed_fields' => ['snapshot_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion?->getKey(),
|
||||||
|
'snapshot_hash' => $baselineSnapshotHash,
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion?->getKey(),
|
||||||
|
'snapshot_hash' => $currentSnapshotHash,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$finding = Finding::query()->firstOrNew([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'fingerprint' => $fingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wasNew = ! $finding->exists;
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => (string) $policy->external_id,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($wasNew) {
|
||||||
|
$finding->forceFill([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'acknowledged_at' => null,
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->save();
|
||||||
|
|
||||||
|
if ($wasNew) {
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$baselineAssignmentsHash = $baselineVersion->assignments_hash ?? null;
|
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
||||||
$currentAssignmentsHash = $currentVersion->assignments_hash ?? null;
|
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
||||||
|
|
||||||
if ($baselineAssignmentsHash === $currentAssignmentsHash) {
|
$baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments);
|
||||||
continue;
|
$currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments);
|
||||||
}
|
|
||||||
|
|
||||||
|
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
||||||
$fingerprint = $this->hasher->fingerprint(
|
$fingerprint = $this->hasher->fingerprint(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
scopeKey: $scopeKey,
|
scopeKey: $scopeKey,
|
||||||
@ -71,7 +161,10 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
|||||||
|
|
||||||
$rawEvidence = [
|
$rawEvidence = [
|
||||||
'change_type' => 'modified',
|
'change_type' => 'modified',
|
||||||
'summary' => 'Policy assignments changed',
|
'summary' => [
|
||||||
|
'kind' => 'policy_assignments',
|
||||||
|
'changed_fields' => ['assignments_hash'],
|
||||||
|
],
|
||||||
'baseline' => [
|
'baseline' => [
|
||||||
'policy_id' => $policy->external_id,
|
'policy_id' => $policy->external_id,
|
||||||
'policy_version_id' => $baselineVersion->getKey(),
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
@ -84,12 +177,14 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
Finding::query()->updateOrCreate(
|
$finding = Finding::query()->firstOrNew([
|
||||||
[
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
'fingerprint' => $fingerprint,
|
'fingerprint' => $fingerprint,
|
||||||
],
|
]);
|
||||||
[
|
|
||||||
|
$wasNew = ! $finding->exists;
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
'scope_key' => $scopeKey,
|
'scope_key' => $scopeKey,
|
||||||
'baseline_run_id' => $baseline->getKey(),
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
@ -97,15 +192,98 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
|||||||
'subject_type' => 'assignment',
|
'subject_type' => 'assignment',
|
||||||
'subject_external_id' => (string) $policy->external_id,
|
'subject_external_id' => (string) $policy->external_id,
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($wasNew) {
|
||||||
|
$finding->forceFill([
|
||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
'acknowledged_at' => null,
|
'acknowledged_at' => null,
|
||||||
'acknowledged_by_user_id' => null,
|
'acknowledged_by_user_id' => null,
|
||||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
]);
|
||||||
],
|
}
|
||||||
|
|
||||||
|
$finding->save();
|
||||||
|
|
||||||
|
if ($wasNew) {
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
||||||
|
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
||||||
|
|
||||||
|
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineScopeTagsHash = $this->hasher->hashNormalized($baselineScopeTagIds);
|
||||||
|
$currentScopeTagsHash = $this->hasher->hashNormalized($currentScopeTagIds);
|
||||||
|
|
||||||
|
if ($baselineScopeTagsHash === $currentScopeTagsHash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fingerprint = $this->hasher->fingerprint(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
scopeKey: $scopeKey,
|
||||||
|
subjectType: 'scope_tag',
|
||||||
|
subjectExternalId: (string) $policy->external_id,
|
||||||
|
changeType: 'modified',
|
||||||
|
baselineHash: $baselineScopeTagsHash,
|
||||||
|
currentHash: $currentScopeTagsHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$rawEvidence = [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_scope_tags',
|
||||||
|
'changed_fields' => ['scope_tags_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
|
'scope_tags_hash' => $baselineScopeTagsHash,
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'scope_tags_hash' => $currentScopeTagsHash,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$finding = Finding::query()->firstOrNew([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'fingerprint' => $fingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wasNew = ! $finding->exists;
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'scope_tag',
|
||||||
|
'subject_external_id' => (string) $policy->external_id,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($wasNew) {
|
||||||
|
$finding->forceFill([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'acknowledged_at' => null,
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->save();
|
||||||
|
|
||||||
|
if ($wasNew) {
|
||||||
$created++;
|
$created++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return $created;
|
return $created;
|
||||||
|
|||||||
@ -4,6 +4,24 @@
|
|||||||
|
|
||||||
class DriftHasher
|
class DriftHasher
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $volatileKeys
|
||||||
|
*/
|
||||||
|
public function hashNormalized(mixed $value, array $volatileKeys = [
|
||||||
|
'@odata.context',
|
||||||
|
'@odata.etag',
|
||||||
|
'createdDateTime',
|
||||||
|
'lastModifiedDateTime',
|
||||||
|
'modifiedDateTime',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
]): string
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeValue($value, $volatileKeys);
|
||||||
|
|
||||||
|
return hash('sha256', json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
public function fingerprint(
|
public function fingerprint(
|
||||||
int $tenantId,
|
int $tenantId,
|
||||||
string $scopeKey,
|
string $scopeKey,
|
||||||
@ -30,4 +48,54 @@ private function normalize(string $value): string
|
|||||||
{
|
{
|
||||||
return trim(mb_strtolower($value));
|
return trim(mb_strtolower($value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $volatileKeys
|
||||||
|
*/
|
||||||
|
private function normalizeValue(mixed $value, array $volatileKeys): mixed
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
if ($this->isList($value)) {
|
||||||
|
$items = array_map(fn ($item) => $this->normalizeValue($item, $volatileKeys), $value);
|
||||||
|
|
||||||
|
usort($items, function ($a, $b): int {
|
||||||
|
return strcmp(
|
||||||
|
json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||||
|
json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (is_string($key) && in_array($key, $volatileKeys, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$key] = $this->normalizeValue($item, $volatileKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($result);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isList(array $value): bool
|
||||||
|
{
|
||||||
|
if ($value === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($value) === range(0, count($value) - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
app/Services/Drift/Normalizers/AssignmentsNormalizer.php
Normal file
113
app/Services/Drift/Normalizers/AssignmentsNormalizer.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift\Normalizers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class AssignmentsNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array{key:string,include_exclude:string,target_type:string,target_id:string,filter_type:string,filter_id:?string,intent:?string,mode:?string}>
|
||||||
|
*/
|
||||||
|
public function normalizeForDiff(mixed $assignments): array
|
||||||
|
{
|
||||||
|
if (! is_array($assignments)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $assignment['target'] ?? null;
|
||||||
|
if (! is_array($target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawType = $target['@odata.type'] ?? null;
|
||||||
|
$targetType = $this->normalizeOdataType(is_string($rawType) ? $rawType : '');
|
||||||
|
|
||||||
|
$includeExclude = str_contains($targetType, 'exclusion') ? 'exclude' : 'include';
|
||||||
|
$targetId = $this->extractTargetId($targetType, $target);
|
||||||
|
|
||||||
|
if ($targetId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||||
|
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none';
|
||||||
|
|
||||||
|
$intent = $assignment['intent'] ?? null;
|
||||||
|
$mode = $assignment['mode'] ?? null;
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'key' => implode('|', [
|
||||||
|
$includeExclude,
|
||||||
|
$targetType,
|
||||||
|
$targetId,
|
||||||
|
]),
|
||||||
|
'include_exclude' => $includeExclude,
|
||||||
|
'target_type' => $targetType,
|
||||||
|
'target_id' => $targetId,
|
||||||
|
'filter_type' => is_string($filterType) && $filterType !== '' ? strtolower(trim($filterType)) : 'none',
|
||||||
|
'filter_id' => is_string($filterId) && $filterId !== '' ? $filterId : null,
|
||||||
|
'intent' => is_string($intent) && $intent !== '' ? strtolower(trim($intent)) : null,
|
||||||
|
'mode' => is_string($mode) && $mode !== '' ? strtolower(trim($mode)) : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, function (array $a, array $b): int {
|
||||||
|
return strcmp($a['key'], $b['key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOdataType(string $odataType): string
|
||||||
|
{
|
||||||
|
$value = trim($odataType);
|
||||||
|
$value = ltrim($value, '#');
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($value, '.')) {
|
||||||
|
$value = (string) strrchr($value, '.');
|
||||||
|
$value = ltrim($value, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower(trim($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function extractTargetId(string $targetType, array $target): string
|
||||||
|
{
|
||||||
|
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
|
||||||
|
return 'all_devices';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'allusersassignmenttarget')) {
|
||||||
|
return 'all_users';
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupId = Arr::get($target, 'groupId');
|
||||||
|
if (is_string($groupId) && $groupId !== '') {
|
||||||
|
return $groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$collectionId = Arr::get($target, 'collectionId');
|
||||||
|
if (is_string($collectionId) && $collectionId !== '') {
|
||||||
|
return $collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Services/Drift/Normalizers/ScopeTagsNormalizer.php
Normal file
136
app/Services/Drift/Normalizers/ScopeTagsNormalizer.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift\Normalizers;
|
||||||
|
|
||||||
|
class ScopeTagsNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function normalizeIds(mixed $scopeTags): array
|
||||||
|
{
|
||||||
|
return $this->normalizeIdsForHash($scopeTags) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For drift hashing/comparison we need stable, reliable IDs.
|
||||||
|
*
|
||||||
|
* Legacy policy versions may have only `names` without `ids`. In that case we:
|
||||||
|
* - infer `Default` as id `0`
|
||||||
|
* - otherwise return null (unknown/unreliable; should not create drift)
|
||||||
|
*
|
||||||
|
* @return array<int, string>|null
|
||||||
|
*/
|
||||||
|
public function normalizeIdsForHash(mixed $scopeTags): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($scopeTags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $scopeTags['ids'] ?? null;
|
||||||
|
if (is_array($ids)) {
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (! is_string($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_values(array_unique($normalized));
|
||||||
|
sort($normalized);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = $scopeTags['names'] ?? null;
|
||||||
|
if (! is_array($names) || $names === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedNames = [];
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (! is_string($name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim($name);
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedNames[] = strtolower($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedNames = array_values(array_unique($normalizedNames));
|
||||||
|
sort($normalizedNames);
|
||||||
|
|
||||||
|
if ($normalizedNames === ['default']) {
|
||||||
|
return ['0'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function labelsById(mixed $scopeTags): array
|
||||||
|
{
|
||||||
|
if (! is_array($scopeTags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = is_array($scopeTags['ids'] ?? null) ? $scopeTags['ids'] : null;
|
||||||
|
$names = is_array($scopeTags['names'] ?? null) ? $scopeTags['names'] : [];
|
||||||
|
|
||||||
|
if (! is_array($ids)) {
|
||||||
|
$inferred = $this->normalizeIdsForHash($scopeTags);
|
||||||
|
|
||||||
|
if ($inferred === ['0'] && $names !== []) {
|
||||||
|
return ['0' => 'Default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
|
||||||
|
foreach ($ids as $index => $id) {
|
||||||
|
if (! is_string($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $names[$index] ?? '';
|
||||||
|
$name = is_string($name) ? trim($name) : '';
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
$name = $id === '0' ? 'Default' : $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists($id, $labels) || $labels[$id] === $id) {
|
||||||
|
$labels[$id] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($labels);
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Services/Drift/Normalizers/SettingsNormalizer.php
Normal file
19
app/Services/Drift/Normalizers/SettingsNormalizer.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift\Normalizers;
|
||||||
|
|
||||||
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
|
||||||
|
class SettingsNormalizer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PolicyNormalizer $policyNormalizer) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $snapshot
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function normalizeForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||||
|
{
|
||||||
|
return $this->policyNormalizer->flattenForDiff($snapshot ?? [], $policyType, $platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,13 +6,13 @@
|
|||||||
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Read and write Intune device configuration policies.',
|
'description' => 'Read and write Intune device configuration policies.',
|
||||||
'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization'],
|
'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization', 'drift'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'DeviceManagementConfiguration.Read.All',
|
'key' => 'DeviceManagementConfiguration.Read.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Read Intune device configuration policies (least-privilege for inventory).',
|
'description' => 'Read Intune device configuration policies (least-privilege for inventory).',
|
||||||
'features' => ['policy-sync', 'backup', 'settings-normalization'],
|
'features' => ['policy-sync', 'backup', 'settings-normalization', 'drift'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'DeviceManagementApps.ReadWrite.All',
|
'key' => 'DeviceManagementApps.ReadWrite.All',
|
||||||
@ -72,7 +72,7 @@
|
|||||||
'key' => 'Group.Read.All',
|
'key' => 'Group.Read.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.',
|
'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.',
|
||||||
'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache'],
|
'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache', 'drift'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'DeviceManagementScripts.ReadWrite.All',
|
'key' => 'DeviceManagementScripts.ReadWrite.All',
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
@php
|
||||||
|
$diff = $getState() ?? [];
|
||||||
|
$summary = $diff['summary'] ?? [];
|
||||||
|
|
||||||
|
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||||
|
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||||
|
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||||
|
|
||||||
|
$renderRow = static function (array $row): array {
|
||||||
|
return [
|
||||||
|
'include_exclude' => (string) ($row['include_exclude'] ?? 'include'),
|
||||||
|
'target_label' => (string) ($row['target_label'] ?? 'Unknown target'),
|
||||||
|
'filter_type' => (string) ($row['filter_type'] ?? 'none'),
|
||||||
|
'filter_id' => $row['filter_id'] ?? null,
|
||||||
|
'intent' => $row['intent'] ?? null,
|
||||||
|
'mode' => $row['mode'] ?? null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Assignments diff"
|
||||||
|
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
{{ (int) ($summary['added'] ?? 0) }} added
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="warning">
|
||||||
|
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (($summary['truncated'] ?? false) === true)
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Truncated to {{ (int) ($summary['limit'] ?? 0) }} items
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($changed !== [])
|
||||||
|
<x-filament::section heading="Changed" collapsible>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($changed as $row)
|
||||||
|
@php
|
||||||
|
$to = is_array($row['to'] ?? null) ? $renderRow($row['to']) : $renderRow([]);
|
||||||
|
$from = is_array($row['from'] ?? null) ? $renderRow($row['from']) : $renderRow([]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ $to['target_label'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 grid gap-2 text-sm md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">From</div>
|
||||||
|
<div class="mt-1 space-y-1">
|
||||||
|
<div>Type: {{ $from['include_exclude'] }}</div>
|
||||||
|
<div>Filter: {{ $from['filter_type'] }}@if($from['filter_id']) ({{ $from['filter_id'] }})@endif</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">To</div>
|
||||||
|
<div class="mt-1 space-y-1">
|
||||||
|
<div>Type: {{ $to['include_exclude'] }}</div>
|
||||||
|
<div>Filter: {{ $to['filter_type'] }}@if($to['filter_id']) ({{ $to['filter_id'] }})@endif</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($added !== [])
|
||||||
|
<x-filament::section heading="Added" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($added as $row)
|
||||||
|
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $row['target_label'] }}</div>
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($removed !== [])
|
||||||
|
<x-filament::section heading="Removed" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($removed as $row)
|
||||||
|
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $row['target_label'] }}</div>
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
@php
|
||||||
|
$diff = $getState() ?? [];
|
||||||
|
$summary = $diff['summary'] ?? [];
|
||||||
|
|
||||||
|
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||||
|
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||||
|
$baseline = is_array($diff['baseline'] ?? null) ? $diff['baseline'] : [];
|
||||||
|
$current = is_array($diff['current'] ?? null) ? $diff['current'] : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Scope tags diff"
|
||||||
|
:description="$summary['message'] ?? sprintf('%d added, %d removed', $summary['added'] ?? 0, $summary['removed'] ?? 0)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
{{ (int) ($summary['added'] ?? 0) }} added
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Baseline: {{ (int) ($summary['baseline_count'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Current: {{ (int) ($summary['current_count'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($added !== [])
|
||||||
|
<x-filament::section heading="Added" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($added as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($removed !== [])
|
||||||
|
<x-filament::section heading="Removed" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($removed as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($current !== [])
|
||||||
|
<x-filament::section heading="Current" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($current as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($baseline !== [])
|
||||||
|
<x-filament::section heading="Baseline" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($baseline as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@ -5,6 +5,98 @@
|
|||||||
Review new drift findings between the last two inventory sync runs for the current scope.
|
Review new drift findings between the last two inventory sync runs for the current scope.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (filled($scopeKey))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Scope: {{ $scopeKey }}
|
||||||
|
@if ($baselineRunId && $currentRunId)
|
||||||
|
· Baseline
|
||||||
|
@if ($this->getBaselineRunUrl())
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getBaselineRunUrl() }}">
|
||||||
|
#{{ $baselineRunId }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
#{{ $baselineRunId }}
|
||||||
|
@endif
|
||||||
|
@if (filled($baselineFinishedAt))
|
||||||
|
({{ $baselineFinishedAt }})
|
||||||
|
@endif
|
||||||
|
· Current
|
||||||
|
@if ($this->getCurrentRunUrl())
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getCurrentRunUrl() }}">
|
||||||
|
#{{ $currentRunId }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
#{{ $currentRunId }}
|
||||||
|
@endif
|
||||||
|
@if (filled($currentFinishedAt))
|
||||||
|
({{ $currentFinishedAt }})
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($state === 'blocked')
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Blocked
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'generating')
|
||||||
|
<x-filament::badge color="warning">
|
||||||
|
Generating
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Drift generation has been queued. Refresh this page once it finishes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($this->getBulkRunUrl())
|
||||||
|
<div class="text-sm">
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
||||||
|
View run #{{ $bulkOperationRunId }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'error')
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
Error
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($this->getBulkRunUrl())
|
||||||
|
<div class="text-sm">
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
||||||
|
View run #{{ $bulkOperationRunId }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'ready')
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
New: {{ (int) ($statusCounts['new'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Ready
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<x-filament::button tag="a" :href="$this->getFindingsUrl()">
|
<x-filament::button tag="a" :href="$this->getFindingsUrl()">
|
||||||
Findings
|
Findings
|
||||||
|
|||||||
@ -6,28 +6,28 @@ # Specification Quality Checklist: Drift MVP (044)
|
|||||||
|
|
||||||
## Content Quality
|
## Content Quality
|
||||||
|
|
||||||
- [ ] No implementation details (languages, frameworks, APIs) (T002)
|
- [x] No implementation details (languages, frameworks, APIs) (spec.md contains scenarios/rules/states/acceptance only)
|
||||||
- [x] Focused on user value and business needs (spec.md: Purpose, User Scenarios & Testing, Success Criteria)
|
- [x] Focused on user value and business needs (spec.md: Purpose, User Scenarios, Acceptance Criteria)
|
||||||
- [ ] Written for non-technical stakeholders (T002)
|
- [x] Written for non-technical stakeholders (spec.md uses plain language; avoids code/framework terms)
|
||||||
- [x] All mandatory sections completed (spec.md includes Purpose, Scenarios, FR/NFR, Success Criteria, Out of Scope)
|
- [x] All mandatory sections completed (spec.md includes Purpose, User Scenarios, Rules, Acceptance Criteria)
|
||||||
|
|
||||||
## Requirement Completeness
|
## Requirement Completeness
|
||||||
|
|
||||||
- [x] No [NEEDS CLARIFICATION] markers remain (spec.md: no "[NEEDS CLARIFICATION]" markers)
|
- [x] No [NEEDS CLARIFICATION] markers remain (spec.md: no "[NEEDS CLARIFICATION]" markers)
|
||||||
- [x] Requirements are testable and unambiguous (spec.md: FR1–FR4; tasks.md defines tests for key behaviors T015–T018, T024–T025, T029–T030, T035, T038)
|
- [x] Requirements are testable and unambiguous (spec.md: Rules + Acceptance Criteria)
|
||||||
- [x] Success criteria are measurable (spec.md: SC1 "under 3 minutes", SC2 deterministic consistency)
|
- [x] Success criteria are measurable (spec.md: Acceptance Criteria)
|
||||||
- [x] Success criteria are technology-agnostic (no implementation details) (spec.md: SC1–SC2)
|
- [x] Success criteria are technology-agnostic (no implementation details) (spec.md: Acceptance Criteria)
|
||||||
- [x] All acceptance scenarios are defined (spec.md: Scenario 1/2/3)
|
- [x] All acceptance scenarios are defined (spec.md: Scenario 1/2/3)
|
||||||
- [x] Edge cases are identified (spec.md: <2 runs blocked state; generation failure explicit error state; acknowledgement per comparison)
|
- [x] Edge cases are identified (spec.md: blocked state; error state; acknowledgement per comparison)
|
||||||
- [x] Scope is clearly bounded (spec.md: FR2b + Out of Scope)
|
- [x] Scope is clearly bounded (spec.md: Rules → Coverage (MVP))
|
||||||
- [x] Dependencies and assumptions identified (spec.md: Dependencies / Name Resolution; NFR2; "No render-time Graph calls")
|
- [x] Dependencies and assumptions identified (spec.md: Rules → UI states; Run tracking)
|
||||||
|
|
||||||
## Feature Readiness
|
## Feature Readiness
|
||||||
|
|
||||||
- [x] All functional requirements have clear acceptance criteria (spec.md: FR1–FR4 + Scenario 1/2/3)
|
- [x] All functional requirements have clear acceptance criteria (spec.md: Rules + Acceptance Criteria)
|
||||||
- [x] User scenarios cover primary flows (spec.md: Scenario 1/2/3)
|
- [x] User scenarios cover primary flows (spec.md: Scenario 1/2/3)
|
||||||
- [ ] Feature meets measurable outcomes defined in Success Criteria (T022, T023, T026, T027, T031, T033, T035)
|
- [x] Feature meets measurable outcomes defined in Success Criteria (spec.md: Acceptance Criteria are measurable and testable)
|
||||||
- [ ] No implementation details leak into specification (T002)
|
- [x] No implementation details leak into specification (spec.md avoids implementation and names a generic “persisted run record” only)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -102,9 +102,30 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Accepted
|
description: Accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/DriftGenerateAccepted'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
|
DriftGenerateAccepted:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
bulk_operation_run_id:
|
||||||
|
type: integer
|
||||||
|
description: Canonical async run record (status/errors/idempotency)
|
||||||
|
scope_key:
|
||||||
|
type: string
|
||||||
|
baseline_run_id:
|
||||||
|
type: integer
|
||||||
|
current_run_id:
|
||||||
|
type: integer
|
||||||
|
required: [bulk_operation_run_id, scope_key, baseline_run_id, current_run_id]
|
||||||
|
|
||||||
Finding:
|
Finding:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -11,6 +11,7 @@ ## Summary
|
|||||||
- Baseline run = previous successful run for the same scope; comparison run = latest successful run.
|
- Baseline run = previous successful run for the same scope; comparison run = latest successful run.
|
||||||
- Findings are persisted with deterministic fingerprints and support MVP triage (`new` → `acknowledged`).
|
- Findings are persisted with deterministic fingerprints and support MVP triage (`new` → `acknowledged`).
|
||||||
- UI is DB-only for label/name resolution (no render-time Graph calls).
|
- UI is DB-only for label/name resolution (no render-time Graph calls).
|
||||||
|
- Drift generation is tracked via `BulkOperationRun` for status/errors across refresh and idempotency.
|
||||||
|
|
||||||
## Technical Context
|
## Technical Context
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ ## Technical Context
|
|||||||
- Tenant isolation for all reads/writes
|
- Tenant isolation for all reads/writes
|
||||||
- No render-time Graph calls; labels resolved from DB caches
|
- No render-time Graph calls; labels resolved from DB caches
|
||||||
- Evidence minimization (sanitized allowlist; no raw payload dumps)
|
- Evidence minimization (sanitized allowlist; no raw payload dumps)
|
||||||
|
- Drift generation is job-only and uses `BulkOperationRun` (`resource=drift`, `action=generate`) as the canonical run record
|
||||||
**Scale/Scope**:
|
**Scale/Scope**:
|
||||||
- Tenants may have large inventories; findings must be indexed for typical filtering
|
- Tenants may have large inventories; findings must be indexed for typical filtering
|
||||||
|
|
||||||
|
|||||||
@ -15,14 +15,16 @@ ## Prepare data
|
|||||||
## Use Drift
|
## Use Drift
|
||||||
|
|
||||||
1. Navigate to the new Drift area.
|
1. Navigate to the new Drift area.
|
||||||
2. On first open, Drift will dispatch a background job to generate findings for:
|
2. On first open, Drift will queue background generation and record status in a persisted run record.
|
||||||
|
3. Generation produces findings for:
|
||||||
- baseline = previous successful run for the same `scope_key`
|
- baseline = previous successful run for the same `scope_key`
|
||||||
- current = latest successful run for the same `scope_key`
|
- current = latest successful run for the same `scope_key`
|
||||||
3. Refresh the page once the job finishes.
|
4. Refresh the page once generation finishes.
|
||||||
|
|
||||||
## Triage
|
## Triage
|
||||||
|
|
||||||
- Acknowledge a finding; it should move out of the “new” view but remain visible/auditable.
|
- Acknowledge a finding; it moves out of the default “new” view but remains visible/auditable.
|
||||||
|
- Use the status filter to include acknowledged findings.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -1,166 +1,81 @@
|
|||||||
# Feature Specification: Drift MVP
|
# Feature Specification: Drift MVP (044)
|
||||||
|
|
||||||
**Feature Branch**: `feat/044-drift-mvp`
|
|
||||||
**Created**: 2026-01-07
|
|
||||||
**Status**: Draft
|
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Detect and report drift between expected and observed states using inventory and run metadata.
|
Help admins quickly spot and triage configuration “drift”: what changed between two inventory snapshots.
|
||||||
|
|
||||||
This MVP focuses on reporting and triage, not automatic remediation.
|
This MVP is about visibility and acknowledgement (triage), not automatic fixes.
|
||||||
|
|
||||||
## Clarifications
|
## User Scenarios
|
||||||
|
|
||||||
### Session 2026-01-12
|
|
||||||
|
|
||||||
- Q: How should Drift pick the baseline run for a given tenant + scope? → A: Baseline = previous successful inventory run for the same scope; compare against the latest successful run.
|
|
||||||
- Q: Should Drift findings be persisted or computed on demand? → A: Persist findings in DB per comparison (baseline_run_id + current_run_id), including a deterministic fingerprint for stable identity + triage.
|
|
||||||
- Q: How define the fingerprint (Stable ID) for a drift finding? → A: `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)` (normalized; excludes volatile fields).
|
|
||||||
- Q: Which inventory entities/types are in scope for Drift MVP? → A: Policies + Assignments.
|
|
||||||
- Q: When should drift findings be generated? → A: On-demand when opening Drift: if findings for (baseline,current,scope) don’t exist yet, dispatch an async job to generate them.
|
|
||||||
|
|
||||||
### Session 2026-01-13
|
|
||||||
|
|
||||||
- Q: What should Drift do if there are fewer than two successful inventory runs for the same `scope_key`? → A: Show a blocked/empty state (“Need at least 2 successful runs for this scope to calculate drift”) and do not dispatch drift generation.
|
|
||||||
- Q: Should acknowledgement carry forward across comparisons? → A: No; acknowledgement is per comparison (`baseline_run_id` + `current_run_id` + `scope_key`). The same drift may re-appear as `new` in later comparisons.
|
|
||||||
- Q: Which `change_type` values are supported in Drift MVP? → A: `added`, `removed`, `modified` (assignment target/intent changes are covered under `modified`).
|
|
||||||
- Q: What is the default UI behavior for `new` vs `acknowledged` findings? → A: Default UI shows only `new`; `acknowledged` is accessible via an explicit filter.
|
|
||||||
- Q: What should the UI do if drift generation fails for a comparison? → A: Show an explicit error state (safe message + reference/run ids) and do not show findings for that comparison until a successful generation exists.
|
|
||||||
|
|
||||||
### Session 2026-01-14
|
|
||||||
|
|
||||||
- Q: How should Drift track generation status/errors/idempotency for a comparison? → A: Use `BulkOperationRun` as the canonical run container (status, failures, idempotency_key, and consistent UI/ops patterns).
|
|
||||||
|
|
||||||
## Pinned Decisions (MVP defaults)
|
|
||||||
|
|
||||||
- Drift is implemented as a generator that writes persisted Finding rows (not only an in-memory/on-demand diff).
|
|
||||||
- Baseline selection: baseline = previous successful inventory run for the same scope_key; comparison = latest successful inventory run for the same scope_key.
|
|
||||||
- Scope is first-class via `scope_key` and must be deterministic to support future pinned baselines and compare workflows.
|
|
||||||
- Fingerprints are deterministic and stable for triage/audit workflows.
|
|
||||||
- Drift MVP only uses `finding_type=drift` and `status` in {`new`, `acknowledged`}.
|
|
||||||
- Default severity: `medium` (until a rule engine exists).
|
|
||||||
- UI must not perform render-time Graph calls. Graph access (if any) is limited to background sync/jobs.
|
|
||||||
- Drift generation is tracked via `BulkOperationRun` to persist status/errors across refresh and to enforce idempotency per (tenant, scope_key, baseline_run_id, current_run_id).
|
|
||||||
|
|
||||||
## Key Entities / Generic Findings (Future-proof)
|
|
||||||
|
|
||||||
### Finding (generic)
|
|
||||||
|
|
||||||
We want Drift MVP to remain MVP-sized, while making it easy to add future generators (Security Suite Audits, Cross-tenant Compare) without inventing a new model.
|
|
||||||
|
|
||||||
Rationale:
|
|
||||||
- Drift = delta engine over runs.
|
|
||||||
- Audit = rule engine over inventory.
|
|
||||||
- Both write Findings with the same semantics: deterministic fingerprint + triage + minimized evidence.
|
|
||||||
|
|
||||||
- `finding_type` (enum): `drift` (MVP), later `audit`, `compare`
|
|
||||||
- `tenant_id`
|
|
||||||
- `scope_key` (string): deterministic scope identifier (see Scope Definition / FR1)
|
|
||||||
- `baseline_run_id` (nullable; e.g. audit/compare)
|
|
||||||
- `current_run_id` (nullable; e.g. audit)
|
|
||||||
- `fingerprint` (string): deterministic; unique per tenant+scope+subject+change
|
|
||||||
- `subject_type` (string): e.g. policy type (or other inventory entity type)
|
|
||||||
- `subject_external_id` (string): Graph external id
|
|
||||||
- `severity` (enum): `low` / `medium` / `high` (MVP default: `medium`)
|
|
||||||
- `status` (enum): `new` / `acknowledged` (later: `snoozed` / `assigned` / `commented`)
|
|
||||||
- `acknowledged_at` (nullable)
|
|
||||||
- `acknowledged_by_user_id` (nullable)
|
|
||||||
- `evidence_jsonb` (jsonb): sanitized, small, secrets-free (no raw payload dumps)
|
|
||||||
- Optional/nullable for later (prepared; out of MVP): `rule_id`, `control_id`, `expected_value`, `source`
|
|
||||||
|
|
||||||
MVP implementation scope: only `finding_type=drift`, statuses `new/acknowledged`, and no rule engine.
|
|
||||||
|
|
||||||
## User Scenarios & Testing
|
|
||||||
|
|
||||||
### Scenario 1: View drift summary
|
### Scenario 1: View drift summary
|
||||||
- Given inventory sync has run at least twice
|
|
||||||
- When the admin opens Drift
|
|
||||||
- Then they see a summary of changes since the last baseline
|
|
||||||
|
|
||||||
- If there are fewer than two successful runs for the same `scope_key`, Drift shows a blocked/empty state and does not start drift generation.
|
- Given the system has at least two successful inventory snapshots for the same selection/scope
|
||||||
|
- When an admin opens Drift
|
||||||
|
- Then they see a summary of what was added, removed, or changed since the previous snapshot
|
||||||
|
|
||||||
### Scenario 2: Drill into a drift finding
|
### Scenario 2: Drill into a drift finding
|
||||||
- Given a drift finding exists
|
|
||||||
- When the admin opens the finding
|
- Given drift findings exist for a comparison
|
||||||
- Then they see what changed, when, and which run observed it
|
- When an admin opens a specific finding
|
||||||
|
- Then they can see what changed and which two snapshots were compared
|
||||||
|
|
||||||
### Scenario 3: Acknowledge / triage
|
### Scenario 3: Acknowledge / triage
|
||||||
|
|
||||||
- Given a drift finding exists
|
- Given a drift finding exists
|
||||||
- When the admin marks it acknowledged
|
- When an admin acknowledges it
|
||||||
- Then it is hidden from “new” lists but remains auditable
|
- Then it no longer appears in “new” views, but remains available for audit/history
|
||||||
|
|
||||||
- Acknowledgement is per comparison; later comparisons may still surface the same drift as `new`.
|
## Rules
|
||||||
|
|
||||||
## Functional Requirements
|
### Coverage (MVP)
|
||||||
|
|
||||||
- FR1: Baseline + scope
|
- Drift findings cover **policies, their assignments, and scope tags** for the selected scope.
|
||||||
- Define `scope_key` as the deterministic Inventory selection identifier.
|
|
||||||
- MVP definition: `scope_key = InventorySyncRun.selection_hash`.
|
|
||||||
- Rationale: selection hashing already normalizes equivalent selections; reusing it keeps drift scope stable and consistent across the product.
|
|
||||||
- Baseline run (MVP) = previous successful inventory run for the same `scope_key`.
|
|
||||||
- Comparison run (MVP) = latest successful inventory run for the same `scope_key`.
|
|
||||||
|
|
||||||
- FR2: Finding generation (Drift MVP)
|
### Baseline and comparison selection
|
||||||
- Findings are persisted per (`baseline_run_id`, `current_run_id`, `scope_key`).
|
|
||||||
- Findings cover adds, removals, and changes for supported entities (Policies + Assignments).
|
|
||||||
- MVP `change_type` values: `added`, `removed`, `modified`.
|
|
||||||
- Findings are deterministic: same baseline/current + scope_key ⇒ same set of fingerprints.
|
|
||||||
- Drift generation must be tracked via `BulkOperationRun` with an idempotency key derived from (tenant_id, scope_key, baseline_run_id, current_run_id).
|
|
||||||
- If fewer than two successful inventory runs exist for a given `scope_key`, Drift does not generate findings and must surface a clear blocked/empty state in the UI.
|
|
||||||
|
|
||||||
- FR2a: Fingerprint definition (MVP)
|
- Drift always compares two successful inventory snapshots for the same selection/scope.
|
||||||
- Fingerprint = `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)`.
|
- The “current” snapshot is the latest successful snapshot for that scope.
|
||||||
- `baseline_hash` / `current_hash` are hashes over normalized, sanitized comparison data (exclude volatile fields like timestamps).
|
- The “baseline” snapshot is the previous successful snapshot for that scope.
|
||||||
- Goal: stable identity for triage + audit compatibility.
|
|
||||||
|
|
||||||
- FR2b: Drift MVP scope includes Policies and their Assignments.
|
### Change types
|
||||||
- Assignment drift includes target changes (e.g., groupId) and intent changes.
|
|
||||||
|
|
||||||
- FR3: Provide Drift UI with summary and details.
|
Each drift finding must be categorized as one of:
|
||||||
- Default lists and the Drift landing summary show only `status=new` by default.
|
|
||||||
- The UI must provide a filter to include `acknowledged` findings.
|
|
||||||
- If drift generation fails for a comparison, the UI must surface an explicit error state (no secrets), including reference identifiers (e.g., run ids and the `BulkOperationRun` id), and must not fall back to stale/previous results.
|
|
||||||
|
|
||||||
- FR4: Triage (MVP)
|
- **added**: the item exists in current but not in baseline
|
||||||
- Admin can acknowledge a finding; record `acknowledged_by_user_id` + `acknowledged_at`.
|
- **removed**: the item exists in baseline but not in current
|
||||||
- Acknowledgement does not carry forward across comparisons in the MVP.
|
- **modified**: the item exists in both but differs (including assignment target and/or intent changes)
|
||||||
- Findings are never deleted in the MVP.
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
### Acknowledgement
|
||||||
|
|
||||||
- NFR1: Drift generation must be deterministic for the same baseline and scope.
|
- Acknowledgement is **per comparison** (baseline + current within a scope).
|
||||||
- NFR2: Drift must remain tenant-scoped and safe to display.
|
- Acknowledgement does **not** carry forward to later comparisons.
|
||||||
- NFR3: Evidence minimization
|
|
||||||
- `evidence_jsonb` must be sanitized (no tokens/secrets) and kept small.
|
|
||||||
- MVP drift evidence should include only:
|
|
||||||
- `change_type`
|
|
||||||
- changed_fields / metadata summary (counts, field list)
|
|
||||||
- run refs (baseline_run_id/current_run_id, timestamps)
|
|
||||||
- No raw payload dumps.
|
|
||||||
|
|
||||||
## Dependencies / Name Resolution
|
### UI states
|
||||||
|
|
||||||
- Drift/Audit UI should resolve labels via Inventory + Foundations (047) + Groups Cache (051) where applicable.
|
- **blocked**: If fewer than two successful snapshots exist for the same scope, Drift shows a clear blocked state and does not attempt generation.
|
||||||
- No render-time Graph calls (Graph only in background sync/jobs, never in UI render).
|
- **error**: If drift generation fails for a comparison, Drift shows a clear error state with safe information and reference identifiers to the recorded run.
|
||||||
|
|
||||||
## Success Criteria
|
### Default views
|
||||||
|
|
||||||
- SC1: Admins can identify drift across supported types (Policies + Assignments) in under 3 minutes.
|
- Default Drift summary and default finding lists show **new** findings only.
|
||||||
- SC2: Drift results are consistent across repeated generation for the same baseline.
|
- Acknowledged findings are accessible via an explicit filter.
|
||||||
|
|
||||||
## Out of Scope
|
### Run tracking (status, errors, idempotency)
|
||||||
|
|
||||||
- Automatic revert/promotion.
|
- Drift generation status and errors must be recorded in a **persisted run record** so that progress/failure survives refresh and can be inspected later.
|
||||||
- Rule engine in MVP (Audit later), but the data model is prepared via `rule_id` / `control_id` / `expected_value`.
|
- Re-opening Drift for the same comparison must be idempotent (it should not create duplicate work for the same comparison).
|
||||||
|
|
||||||
## Future Work (non-MVP)
|
### Determinism and stable identity
|
||||||
|
|
||||||
- Security Suite Audits: add rule-based generators that write Findings (no new Finding model).
|
- For the same scope + baseline + current, Drift must produce the same set of findings.
|
||||||
- Cross-tenant Compare: may write Findings (`finding_type=compare`) or emit a compatible format that can be stored as Findings.
|
- Each finding must have a stable identifier (“fingerprint”) so triage actions can reliably reference the same drift item within a comparison.
|
||||||
|
|
||||||
## Related Specs
|
## Acceptance Criteria
|
||||||
|
|
||||||
- Program: `specs/039-inventory-program/spec.md`
|
- With two successful snapshots for the same scope, Drift shows a summary of **added/removed/modified** items for that comparison.
|
||||||
- Core: `specs/040-inventory-core/spec.md`
|
- With fewer than two successful snapshots for the same scope, Drift shows **blocked** and does not start generation.
|
||||||
- Compare: `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
- If generation fails, Drift shows **error** and provides reference identifiers to the persisted run record.
|
||||||
|
- Default views exclude acknowledged findings, and acknowledged findings remain available via filter.
|
||||||
|
- Acknowledging a finding records who/when acknowledged and hides it from “new” views.
|
||||||
|
- Re-running generation for the same comparison does not create duplicate work and produces consistent results.
|
||||||
|
|||||||
@ -25,10 +25,10 @@ ## Phase 1: Setup (Shared Infrastructure)
|
|||||||
|
|
||||||
**Purpose**: Project wiring for Drift MVP.
|
**Purpose**: Project wiring for Drift MVP.
|
||||||
|
|
||||||
- [ ] T001 Create/update constitution gate checklist in `specs/044-drift-mvp/checklists/requirements.md`
|
- [x] T001 Create/update constitution gate checklist in `specs/044-drift-mvp/checklists/requirements.md`
|
||||||
- [ ] T002 Confirm spec/plan artifacts are current in `specs/044-drift-mvp/{plan.md,spec.md,research.md,data-model.md,quickstart.md,contracts/admin-findings.openapi.yaml}`
|
- [x] T002 Confirm spec/plan artifacts are current in `specs/044-drift-mvp/{plan.md,spec.md,research.md,data-model.md,quickstart.md,contracts/admin-findings.openapi.yaml}`
|
||||||
- [ ] T003 Add Drift landing page shell in `app/Filament/Pages/DriftLanding.php`
|
- [x] T003 Add Drift landing page shell in `app/Filament/Pages/DriftLanding.php`
|
||||||
- [ ] T004 [P] Add Finding resource shells in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/`
|
- [x] T004 [P] Add Finding resource shells in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -38,17 +38,17 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
|||||||
|
|
||||||
**Checkpoint**: DB schema exists, tenant scoping enforced, and tests can create Finding rows.
|
**Checkpoint**: DB schema exists, tenant scoping enforced, and tests can create Finding rows.
|
||||||
|
|
||||||
- [ ] T005 Create `findings` migration in `database/migrations/*_create_findings_table.php` with Finding fields aligned to `specs/044-drift-mvp/spec.md`:
|
- [x] T005 Create `findings` migration in `database/migrations/*_create_findings_table.php` with Finding fields aligned to `specs/044-drift-mvp/spec.md`:
|
||||||
(tenant_id, finding_type, scope_key, baseline_run_id, current_run_id, subject_type, subject_external_id, severity, status, fingerprint unique, evidence_jsonb, acknowledged_at, acknowledged_by_user_id)
|
(tenant_id, finding_type, scope_key, baseline_run_id, current_run_id, subject_type, subject_external_id, severity, status, fingerprint unique, evidence_jsonb, acknowledged_at, acknowledged_by_user_id)
|
||||||
- [ ] T006 Create `Finding` model in `app/Models/Finding.php` (casts for `evidence_jsonb`, enums/constants for `finding_type`/`severity`/`status`, `acknowledged_at` handling)
|
- [x] T006 Create `Finding` model in `app/Models/Finding.php` (casts for `evidence_jsonb`, enums/constants for `finding_type`/`severity`/`status`, `acknowledged_at` handling)
|
||||||
- [ ] T007 [P] Add `FindingFactory` in `database/factories/FindingFactory.php`
|
- [x] T007 [P] Add `FindingFactory` in `database/factories/FindingFactory.php`
|
||||||
- [ ] T008 Ensure `InventorySyncRunFactory` exists (or add it) in `database/factories/InventorySyncRunFactory.php`
|
- [x] T008 Ensure `InventorySyncRunFactory` exists (or add it) in `database/factories/InventorySyncRunFactory.php`
|
||||||
- [ ] T009 Add authorization policy in `app/Policies/FindingPolicy.php` and wire it in `app/Providers/AuthServiceProvider.php` (or project equivalent)
|
- [x] T009 Add authorization policy in `app/Policies/FindingPolicy.php` and wire it in `app/Providers/AuthServiceProvider.php` (or project equivalent)
|
||||||
- [ ] T010 Add Drift permissions in `config/intune_permissions.php` (view + acknowledge) and wire them into Filament navigation/actions
|
- [x] T010 Add Drift permissions in `config/intune_permissions.php` (view + acknowledge) and wire them into Filament navigation/actions
|
||||||
- [ ] T011 Enforce tenant scoping for Finding queries in `app/Models/Finding.php` and/or `app/Filament/Resources/FindingResource.php`
|
- [x] T011 Enforce tenant scoping for Finding queries in `app/Models/Finding.php` and/or `app/Filament/Resources/FindingResource.php`
|
||||||
- [ ] T012 Implement fingerprint helper in `app/Services/Drift/DriftHasher.php` (sha256 per spec)
|
- [x] T012 Implement fingerprint helper in `app/Services/Drift/DriftHasher.php` (sha256 per spec)
|
||||||
- [ ] T013 Pin `scope_key = InventorySyncRun.selection_hash` in `app/Services/Drift/DriftScopeKey.php` (single canonical definition)
|
- [x] T013 Pin `scope_key = InventorySyncRun.selection_hash` in `app/Services/Drift/DriftScopeKey.php` (single canonical definition)
|
||||||
- [ ] T014 Implement evidence allowlist builder in `app/Services/Drift/DriftEvidence.php` (new file)
|
- [x] T014 Implement evidence allowlist builder in `app/Services/Drift/DriftEvidence.php` (new file)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -60,18 +60,18 @@ ## Phase 3: User Story 1 - View drift summary (Priority: P1) MVP
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T015 [P] [US1] Baseline selection tests in `tests/Feature/Drift/DriftBaselineSelectionTest.php`
|
- [x] T015 [P] [US1] Baseline selection tests in `tests/Feature/Drift/DriftBaselineSelectionTest.php`
|
||||||
- [ ] T016 [P] [US1] Generation dispatch tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
|
- [x] T016 [P] [US1] Generation dispatch tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
|
||||||
- [ ] T017 [P] [US1] Tenant isolation tests in `tests/Feature/Drift/DriftTenantIsolationTest.php`
|
- [x] T017 [P] [US1] Tenant isolation tests in `tests/Feature/Drift/DriftTenantIsolationTest.php`
|
||||||
- [ ] T018 [P] [US1] Assignment drift detection test (targets + intent changes per FR2b) in `tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php`
|
- [x] T018 [P] [US1] Assignment drift detection test (targets + intent changes per FR2b) in `tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T019 [US1] Implement run selection service in `app/Services/Drift/DriftRunSelector.php`
|
- [x] T019 [US1] Implement run selection service in `app/Services/Drift/DriftRunSelector.php`
|
||||||
- [ ] T020 [US1] Implement generator job in `app/Jobs/GenerateDriftFindingsJob.php` (dedupe/lock by tenant+scope+baseline+current)
|
- [x] T021 [US1] Implement generator service in `app/Services/Drift/DriftFindingGenerator.php` (idempotent)
|
||||||
- [ ] T021 [US1] Implement generator service in `app/Services/Drift/DriftFindingGenerator.php` (idempotent)
|
- [x] T020 [US1] Implement generator job in `app/Jobs/GenerateDriftFindingsJob.php` (dedupe/lock by tenant+scope+baseline+current)
|
||||||
- [ ] T022 [US1] Implement landing behavior (dispatch + status UI) in `app/Filament/Pages/DriftLanding.php`
|
- [x] T022 [US1] Implement landing behavior (dispatch + status UI) in `app/Filament/Pages/DriftLanding.php`
|
||||||
- [ ] T023 [US1] Implement summary queries/widgets in `app/Filament/Pages/DriftLanding.php`
|
- [x] T023 [US1] Implement summary queries/widgets in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -83,14 +83,17 @@ ## Phase 4: User Story 2 - Drill into a drift finding (Priority: P2)
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T024 [P] [US2] Finding detail test in `tests/Feature/Drift/DriftFindingDetailTest.php`
|
- [x] T024 [P] [US2] Finding detail test in `tests/Feature/Drift/DriftFindingDetailTest.php`
|
||||||
- [ ] T025 [P] [US2] Evidence minimization test in `tests/Feature/Drift/DriftEvidenceMinimizationTest.php`
|
- [x] T025 [P] [US2] Evidence minimization test in `tests/Feature/Drift/DriftEvidenceMinimizationTest.php`
|
||||||
|
- [x] T041 [P] [US2] Finding detail shows normalized settings diff (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php`
|
||||||
|
- [x] T042 [P] [US2] Finding detail shows assignments diff + cached group labels (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T026 [US2] Implement list UI in `app/Filament/Resources/FindingResource.php` (filters: status, scope_key, run)
|
- [x] T026 [US2] Implement list UI in `app/Filament/Resources/FindingResource.php` (filters: status, scope_key, run)
|
||||||
- [ ] T027 [US2] Implement detail UI in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
- [x] T027 [US2] Implement detail UI in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
- [ ] T028 [US2] Implement DB-only name resolution in `app/Filament/Resources/FindingResource.php` (inventory/foundations caches)
|
- [x] T028 [US2] Implement DB-only name resolution in `app/Filament/Resources/FindingResource.php` (inventory/foundations caches)
|
||||||
|
- [x] T043 [US2] Add real diffs to Finding detail (settings + assignments) in `app/Filament/Resources/FindingResource.php`, `app/Services/Drift/*`, and `resources/views/filament/infolists/entries/assignments-diff.blade.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -102,25 +105,57 @@ ## Phase 5: User Story 3 - Acknowledge/triage (Priority: P3)
|
|||||||
|
|
||||||
### Tests (write first)
|
### Tests (write first)
|
||||||
|
|
||||||
- [ ] T029 [P] [US3] Acknowledge action test in `tests/Feature/Drift/DriftAcknowledgeTest.php`
|
- [x] T029 [P] [US3] Acknowledge action test in `tests/Feature/Drift/DriftAcknowledgeTest.php`
|
||||||
- [ ] T030 [P] [US3] Acknowledge authorization test in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php`
|
- [x] T030 [P] [US3] Acknowledge authorization test in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php`
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
- [ ] T031 [US3] Add acknowledge actions in `app/Filament/Resources/FindingResource.php`
|
- [x] T031 [US3] Add acknowledge actions in `app/Filament/Resources/FindingResource.php`
|
||||||
- [ ] T032 [US3] Implement `acknowledge()` domain method in `app/Models/Finding.php`
|
- [x] T032 [US3] Implement `acknowledge()` domain method in `app/Models/Finding.php`
|
||||||
- [ ] T033 [US3] Ensure Drift summary excludes acknowledged by default in `app/Filament/Pages/DriftLanding.php`
|
- [x] T033 [US3] Ensure Drift summary excludes acknowledged by default in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
|
||||||
|
### Bulk triage (post-MVP UX)
|
||||||
|
|
||||||
|
**Goal**: Admin can acknowledge many findings safely and quickly.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T048 [P] [US3] Bulk acknowledge selected test in `tests/Feature/Drift/DriftBulkAcknowledgeTest.php`
|
||||||
|
- [x] T049 [P] [US3] Acknowledge all matching current filters test in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php`
|
||||||
|
- [x] T050 [P] [US3] Acknowledge all matching requires type-to-confirm when >100 in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php`
|
||||||
|
- [x] T051 [P] [US3] Bulk acknowledge authorization test in `tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T052 [US3] Add bulk triage actions to Findings list in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
- [ ] T034 Add DB indexes in `database/migrations/*_create_findings_table.php` (tenant_id+status, tenant_id+scope_key, tenant_id+baseline_run_id, tenant_id+current_run_id)
|
- [x] T034 Add DB indexes in `database/migrations/*_create_findings_table.php` (tenant_id+status, tenant_id+scope_key, tenant_id+baseline_run_id, tenant_id+current_run_id)
|
||||||
- [ ] T035 [P] Add determinism test in `tests/Feature/Drift/DriftGenerationDeterminismTest.php` (same baseline/current => same fingerprints)
|
- [x] T035 [P] Add determinism test in `tests/Feature/Drift/DriftGenerationDeterminismTest.php` (same baseline/current => same fingerprints)
|
||||||
- [ ] T036 Add job observability logs in `app/Jobs/GenerateDriftFindingsJob.php` (no secrets)
|
- [x] T036 Add job observability logs in `app/Jobs/GenerateDriftFindingsJob.php` (no secrets)
|
||||||
- [ ] T037 Add idempotency/upsert strategy in `app/Services/Drift/DriftFindingGenerator.php`
|
- [x] T037 Add idempotency/upsert strategy in `app/Services/Drift/DriftFindingGenerator.php`
|
||||||
- [ ] T038 Ensure volatile fields excluded from hashing in `app/Services/Drift/DriftHasher.php` and cover in `tests/Feature/Drift/DriftHasherTest.php`
|
- [x] T038 Ensure volatile fields excluded from hashing in `app/Services/Drift/DriftHasher.php` and cover in `tests/Feature/Drift/DriftHasherTest.php`
|
||||||
- [ ] T039 Validate and update `specs/044-drift-mvp/quickstart.md` after implementation
|
- [x] T039 Validate and update `specs/044-drift-mvp/quickstart.md` after implementation
|
||||||
|
- [x] T040 Fix drift evidence summary shape in `app/Services/Drift/DriftFindingGenerator.php` and cover in `tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php` (snapshot_hash vs assignments_hash)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Scope Tags Drift (Post-MVP Fix)
|
||||||
|
|
||||||
|
**Goal**: Detect and display drift caused by `roleScopeTagIds` changes on a policy version.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T044 [P] [US1] Scope-tag drift detection test in `tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php`
|
||||||
|
- [x] T045 [P] [US2] Finding detail shows scope-tags diff (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T046 [US1] Implement scope-tag drift detection in `app/Services/Drift/DriftFindingGenerator.php` (deterministic hashing; kind=`policy_scope_tags`)
|
||||||
|
- [x] T047 [US2] Implement scope-tags diff builder + UI in `app/Services/Drift/DriftFindingDiffBuilder.php`, `app/Filament/Resources/FindingResource.php`, and `resources/views/filament/infolists/entries/scope-tags-diff.blade.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php
Normal file
31
tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('readonly users cannot acknowledge findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableAction('acknowledge', $finding);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_NEW);
|
||||||
|
});
|
||||||
28
tests/Feature/Drift/DriftAcknowledgeTest.php
Normal file
28
tests/Feature/Drift/DriftAcknowledgeTest.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('a finding can be acknowledged via table action', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'acknowledged_at' => null,
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableAction('acknowledge', $finding);
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
expect($finding->acknowledged_at)->not->toBeNull();
|
||||||
|
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
@ -52,7 +52,6 @@
|
|||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
'assignments' => $baselineAssignments,
|
'assignments' => $baselineAssignments,
|
||||||
'assignments_hash' => hash('sha256', json_encode($baselineAssignments)),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
@ -60,7 +59,6 @@
|
|||||||
'policy_type' => $policy->policy_type,
|
'policy_type' => $policy->policy_type,
|
||||||
'captured_at' => $current->finished_at->copy()->subMinute(),
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
'assignments' => $currentAssignments,
|
'assignments' => $currentAssignments,
|
||||||
'assignments_hash' => hash('sha256', json_encode($currentAssignments)),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$generator = app(DriftFindingGenerator::class);
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('acknowledge all matching requires confirmation when acknowledging more than 100 findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(101)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->mountAction('acknowledge_all_matching')
|
||||||
|
->callMountedAction();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->mountAction('acknowledge_all_matching')
|
||||||
|
->setActionData(['confirmation' => 'ACKNOWLEDGE'])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED));
|
||||||
|
});
|
||||||
51
tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php
Normal file
51
tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('acknowledge all matching respects scope key filter', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeA = 'scope-a';
|
||||||
|
$scopeB = 'scope-b';
|
||||||
|
|
||||||
|
$matching = Finding::factory()
|
||||||
|
->count(2)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'scope_key' => $scopeA,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nonMatching = Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'scope_key' => $scopeB,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->set('tableFilters', [
|
||||||
|
'status' => ['value' => Finding::STATUS_NEW],
|
||||||
|
'finding_type' => ['value' => Finding::FINDING_TYPE_DRIFT],
|
||||||
|
'scope_key' => ['scope_key' => $scopeA],
|
||||||
|
])
|
||||||
|
->callAction('acknowledge_all_matching');
|
||||||
|
|
||||||
|
$matching->each(function (Finding $finding) use ($user): void {
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
$nonMatching->refresh();
|
||||||
|
expect($nonMatching->status)->toBe(Finding::STATUS_NEW);
|
||||||
|
expect($nonMatching->acknowledged_at)->toBeNull();
|
||||||
|
});
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('readonly users cannot bulk acknowledge selected findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(2)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableBulkAction('acknowledge_selected', $findings);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readonly users cannot acknowledge all matching findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(2)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callAction('acknowledge_all_matching');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
|
});
|
||||||
34
tests/Feature/Drift/DriftBulkAcknowledgeTest.php
Normal file
34
tests/Feature/Drift/DriftBulkAcknowledgeTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('findings can be acknowledged via table bulk action', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(3)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'acknowledged_at' => null,
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableBulkAction('acknowledge_selected', $findings)
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$findings->each(function (Finding $finding) use ($user): void {
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
expect($finding->acknowledged_at)->not->toBeNull();
|
||||||
|
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('opening Drift does not re-dispatch when the last run completed with zero findings', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-zero-findings');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
BulkOperationRun::factory()->for($tenant)->for($user)->create([
|
||||||
|
'resource' => 'drift',
|
||||||
|
'action' => 'generate',
|
||||||
|
'status' => 'completed',
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
|
'item_ids' => [$scopeKey],
|
||||||
|
'total_items' => 1,
|
||||||
|
'processed_items' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(DriftLanding::class)
|
||||||
|
->assertSet('state', 'ready')
|
||||||
|
->assertSet('scopeKey', $scopeKey);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
expect(BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(GenerateDriftFindingsJob::class);
|
||||||
|
});
|
||||||
24
tests/Feature/Drift/DriftEvidenceMinimizationTest.php
Normal file
24
tests/Feature/Drift/DriftEvidenceMinimizationTest.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Drift\DriftEvidence;
|
||||||
|
|
||||||
|
test('drift evidence sanitizer keeps only allowlisted keys', function () {
|
||||||
|
$payload = [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => ['changed_fields' => ['assignments_hash']],
|
||||||
|
'baseline' => ['hash' => 'a'],
|
||||||
|
'current' => ['hash' => 'b'],
|
||||||
|
'diff' => ['a' => 'b'],
|
||||||
|
'notes' => 'ok',
|
||||||
|
'access_token' => 'should-not-leak',
|
||||||
|
'client_secret' => 'should-not-leak',
|
||||||
|
'raw_payload' => ['big' => 'blob'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$safe = app(DriftEvidence::class)->sanitize($payload);
|
||||||
|
|
||||||
|
expect($safe)->toHaveKeys(['change_type', 'summary', 'baseline', 'current', 'diff', 'notes']);
|
||||||
|
expect($safe)->not->toHaveKey('access_token');
|
||||||
|
expect($safe)->not->toHaveKey('client_secret');
|
||||||
|
expect($safe)->not->toHaveKey('raw_payload');
|
||||||
|
});
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\EntraGroup;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
|
|
||||||
|
test('finding detail shows an assignments diff with DB-only group label resolution', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-assignments-diff'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'external_id' => 'policy-456',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$group1 = '76b787af-cae9-4a8e-89e9-b8cc67f81779';
|
||||||
|
$group2 = '6b0bc3d7-91f3-4e4b-8181-8236d908d2dd';
|
||||||
|
$group3 = 'cbd8d685-0d95-4de0-8fce-140a5cad8ddc';
|
||||||
|
|
||||||
|
EntraGroup::factory()->for($tenant)->create([
|
||||||
|
'entra_id' => strtolower($group1),
|
||||||
|
'display_name' => 'Group One',
|
||||||
|
]);
|
||||||
|
|
||||||
|
EntraGroup::factory()->for($tenant)->create([
|
||||||
|
'entra_id' => strtolower($group3),
|
||||||
|
'display_name' => 'Group Three',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => $group1,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||||
|
'groupId' => $group2,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => $group1,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => '62fb77d0-8f85-4ba0-a1c7-fd71d418521d',
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => $group3,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'assignment',
|
||||||
|
'subject_external_id' => $policy->external_id,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_assignments',
|
||||||
|
'changed_fields' => ['assignments_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
|
'assignments_hash' => 'baseline-assignments-hash',
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'assignments_hash' => 'current-assignments-hash',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy 456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expectedGroup1 = EntraGroupLabelResolver::formatLabel('Group One', $group1);
|
||||||
|
$expectedGroup2 = EntraGroupLabelResolver::formatLabel(null, $group2);
|
||||||
|
$expectedGroup3 = EntraGroupLabelResolver::formatLabel('Group Three', $group3);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Assignments diff')
|
||||||
|
->assertSee('1 added')
|
||||||
|
->assertSee('1 removed')
|
||||||
|
->assertSee('1 changed')
|
||||||
|
->assertSee($expectedGroup1)
|
||||||
|
->assertSee($expectedGroup2)
|
||||||
|
->assertSee($expectedGroup3)
|
||||||
|
->assertSee('include')
|
||||||
|
->assertSee('none');
|
||||||
|
});
|
||||||
100
tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php
Normal file
100
tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
|
||||||
|
test('finding detail shows a scope tags diff without Graph calls', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-scope-tags-diff'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'external_id' => 'policy-scope-tags-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 17,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0'],
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 18,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0', 'a1b2c3'],
|
||||||
|
'names' => ['Default', 'Verbund-1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'scope_tag',
|
||||||
|
'subject_external_id' => $policy->external_id,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_scope_tags',
|
||||||
|
'changed_fields' => ['scope_tags_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
|
'scope_tags_hash' => 'baseline-scope-tags-hash',
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'scope_tags_hash' => 'current-scope-tags-hash',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy Scope Tags',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Scope tags diff')
|
||||||
|
->assertSee('1 added')
|
||||||
|
->assertSee('0 removed')
|
||||||
|
->assertSee('Verbund-1')
|
||||||
|
->assertSee('Default');
|
||||||
|
});
|
||||||
100
tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php
Normal file
100
tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
|
||||||
|
test('finding detail shows a normalized settings diff for policy_snapshot evidence', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-settings-diff'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'external_id' => 'policy-123',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'Old description',
|
||||||
|
'customSettingFoo' => 'Old value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'New description',
|
||||||
|
'customSettingFoo' => 'New value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => $policy->external_id,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_snapshot',
|
||||||
|
'changed_fields' => ['snapshot_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
|
'snapshot_hash' => 'baseline-hash',
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'snapshot_hash' => 'current-hash',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy 123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Normalized diff')
|
||||||
|
->assertSee('1 changed')
|
||||||
|
->assertSee('Custom Setting Foo')
|
||||||
|
->assertSee('From')
|
||||||
|
->assertSee('To')
|
||||||
|
->assertSee('Old value')
|
||||||
|
->assertSee('New value');
|
||||||
|
});
|
||||||
48
tests/Feature/Drift/DriftFindingDetailTest.php
Normal file
48
tests/Feature/Drift/DriftFindingDetailTest.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
|
||||||
|
test('finding detail renders without Graph calls', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-detail'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'deviceConfiguration',
|
||||||
|
'subject_external_id' => 'policy-123',
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => ['changed_fields' => ['assignments_hash']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventoryItem = InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy 123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($finding->fingerprint)
|
||||||
|
->assertSee($inventoryItem->display_name);
|
||||||
|
});
|
||||||
76
tests/Feature/Drift/DriftGenerationDeterminismTest.php
Normal file
76
tests/Feature/Drift/DriftGenerationDeterminismTest.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('drift generation is deterministic for the same baseline/current', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-determinism');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineAssignments = [
|
||||||
|
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-a']],
|
||||||
|
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-b']],
|
||||||
|
];
|
||||||
|
|
||||||
|
$currentAssignments = [
|
||||||
|
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-c']],
|
||||||
|
];
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $baselineAssignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $currentAssignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
|
||||||
|
$created1 = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
$fingerprints1 = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->pluck('fingerprint')
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$created2 = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
$fingerprints2 = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->pluck('fingerprint')
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($created1)->toBeGreaterThan(0);
|
||||||
|
expect($created2)->toBe(0);
|
||||||
|
expect($fingerprints2)->toBe($fingerprints1);
|
||||||
|
});
|
||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\DriftLanding;
|
use App\Filament\Pages\DriftLanding;
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -30,15 +32,81 @@
|
|||||||
|
|
||||||
Livewire::test(DriftLanding::class);
|
Livewire::test(DriftLanding::class);
|
||||||
|
|
||||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey): bool {
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$bulkRun = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($bulkRun)->not->toBeNull();
|
||||||
|
expect($bulkRun->resource)->toBe('drift');
|
||||||
|
expect($bulkRun->action)->toBe('generate');
|
||||||
|
expect($bulkRun->status)->toBe('pending');
|
||||||
|
|
||||||
|
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool {
|
||||||
return $job->tenantId === (int) $tenant->getKey()
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
&& $job->userId === (int) $user->getKey()
|
&& $job->userId === (int) $user->getKey()
|
||||||
&& $job->baselineRunId === (int) $baseline->getKey()
|
&& $job->baselineRunId === (int) $baseline->getKey()
|
||||||
&& $job->currentRunId === (int) $current->getKey()
|
&& $job->currentRunId === (int) $current->getKey()
|
||||||
&& $job->scopeKey === $scopeKey;
|
&& $job->scopeKey === $scopeKey
|
||||||
|
&& $job->bulkOperationRunId === (int) $bulkRun->getKey();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('opening Drift is idempotent while a run is pending', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-idempotent');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(DriftLanding::class);
|
||||||
|
Livewire::test(DriftLanding::class);
|
||||||
|
|
||||||
|
Queue::assertPushed(GenerateDriftFindingsJob::class, 1);
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
|
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
@ -57,4 +125,5 @@
|
|||||||
Livewire::test(DriftLanding::class);
|
Livewire::test(DriftLanding::class);
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
|
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
39
tests/Feature/Drift/DriftHasherTest.php
Normal file
39
tests/Feature/Drift/DriftHasherTest.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Drift\DriftHasher;
|
||||||
|
|
||||||
|
test('normalized hashing ignores volatile timestamps', function () {
|
||||||
|
$hasher = app(DriftHasher::class);
|
||||||
|
|
||||||
|
$a = [
|
||||||
|
'id' => 'abc',
|
||||||
|
'createdDateTime' => '2020-01-01T00:00:00Z',
|
||||||
|
'lastModifiedDateTime' => '2020-01-02T00:00:00Z',
|
||||||
|
'target' => ['groupId' => 'group-a'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$b = [
|
||||||
|
'id' => 'abc',
|
||||||
|
'createdDateTime' => '2025-01-01T00:00:00Z',
|
||||||
|
'lastModifiedDateTime' => '2026-01-02T00:00:00Z',
|
||||||
|
'target' => ['groupId' => 'group-a'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($hasher->hashNormalized($a))->toBe($hasher->hashNormalized($b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalized hashing is order-insensitive for lists', function () {
|
||||||
|
$hasher = app(DriftHasher::class);
|
||||||
|
|
||||||
|
$listA = [
|
||||||
|
['target' => ['groupId' => 'group-a']],
|
||||||
|
['target' => ['groupId' => 'group-b']],
|
||||||
|
];
|
||||||
|
|
||||||
|
$listB = [
|
||||||
|
['target' => ['groupId' => 'group-b']],
|
||||||
|
['target' => ['groupId' => 'group-a']],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($hasher->hashNormalized($listA))->toBe($hasher->hashNormalized($listB));
|
||||||
|
});
|
||||||
33
tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php
Normal file
33
tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('drift landing exposes baseline/current run ids and timestamps', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-landing-comparison-info');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(DriftLanding::class)
|
||||||
|
->assertSet('scopeKey', $scopeKey)
|
||||||
|
->assertSet('baselineRunId', (int) $baseline->getKey())
|
||||||
|
->assertSet('currentRunId', (int) $current->getKey())
|
||||||
|
->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString())
|
||||||
|
->assertSet('currentFinishedAt', $current->finished_at->toDateTimeString());
|
||||||
|
});
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it creates a drift finding when policy snapshot changes', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-snapshot');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Old value'],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'New value'],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(1);
|
||||||
|
|
||||||
|
$finding = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('subject_type', 'policy')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($finding)->not->toBeNull();
|
||||||
|
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||||
|
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||||
|
expect($finding->evidence_jsonb)
|
||||||
|
->toHaveKey('summary.changed_fields')
|
||||||
|
->and($finding->evidence_jsonb['summary']['changed_fields'])->toContain('snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('baseline.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('current.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash');
|
||||||
|
});
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it does not create a snapshot drift finding when only excluded metadata changes', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-snapshot-metadata-only');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'Old description',
|
||||||
|
],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'New description',
|
||||||
|
],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(0);
|
||||||
|
expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
84
tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php
Normal file
84
tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it creates a drift finding when policy scope tags change', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-scope-tags');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0'],
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0', 'a1b2c3'],
|
||||||
|
'names' => ['Default', 'Verbund-1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(1);
|
||||||
|
|
||||||
|
$finding = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('subject_type', 'scope_tag')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($finding)->not->toBeNull();
|
||||||
|
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||||
|
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||||
|
expect($finding->evidence_jsonb)
|
||||||
|
->toHaveKey('summary.kind', 'policy_scope_tags')
|
||||||
|
->toHaveKey('summary.changed_fields')
|
||||||
|
->and($finding->evidence_jsonb['summary']['changed_fields'])->toContain('scope_tags_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('baseline.scope_tags_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('current.scope_tags_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('current.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash');
|
||||||
|
});
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it does not create a scope tag drift finding when baseline has legacy names-only Default and current has ids', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-scope-tags-legacy-default');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
// legacy data shape (missing ids)
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0'],
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(0);
|
||||||
|
|
||||||
|
expect(Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->count())->toBe(0);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user