feat/044-drift-mvp #58
235
app/Filament/Pages/DriftLanding.php
Normal file
235
app/Filament/Pages/DriftLanding.php
Normal file
@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use App\Support\RunIdempotency;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class DriftLanding extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
protected static ?string $navigationLabel = 'Drift';
|
||||
|
||||
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
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$latestSuccessful = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->first();
|
||||
|
||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No successful inventory runs found yet.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||
$this->scopeKey = $scopeKey;
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
if ($comparison === null) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$baseline = $comparison['baseline'];
|
||||
$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()
|
||||
->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())
|
||||
->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;
|
||||
}
|
||||
|
||||
$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(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
bulkOperationRunId: (int) $run->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): string
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
390
app/Filament/Resources/FindingResource.php
Normal file
390
app/Filament/Resources/FindingResource.php
Normal file
@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use BackedEnum;
|
||||
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\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function form(Schema $schema): 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
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
||||
Tables\Columns\TextColumn::make('status')->badge(),
|
||||
Tables\Columns\TextColumn::make('severity')->badge(),
|
||||
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('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||
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\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(),
|
||||
])
|
||||
->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
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListFindings::route('/'),
|
||||
'view' => Pages\ViewFinding::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
171
app/Filament/Resources/FindingResource/Pages/ListFindings.php
Normal file
171
app/Filament/Resources/FindingResource/Pages/ListFindings.php
Normal file
@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
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 Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewFinding extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
}
|
||||
104
app/Jobs/GenerateDriftFindingsJob.php
Normal file
104
app/Jobs/GenerateDriftFindingsJob.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class GenerateDriftFindingsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $baselineRunId,
|
||||
public int $currentRunId,
|
||||
public string $scopeKey,
|
||||
public int $bulkOperationRunId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
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);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
||||
if (! $baseline instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Baseline run not found.');
|
||||
}
|
||||
|
||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
||||
if (! $current instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Current run not found.');
|
||||
}
|
||||
|
||||
$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,
|
||||
baseline: $baseline,
|
||||
current: $current,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Models/Finding.php
Normal file
67
app/Models/Finding.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Finding extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\FindingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const string FINDING_TYPE_DRIFT = 'drift';
|
||||
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_MEDIUM = 'medium';
|
||||
|
||||
public const string SEVERITY_HIGH = 'high';
|
||||
|
||||
public const string STATUS_NEW = 'new';
|
||||
|
||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'acknowledged_at' => 'datetime',
|
||||
'evidence_jsonb' => 'array',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function baselineRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id');
|
||||
}
|
||||
|
||||
public function currentRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'current_run_id');
|
||||
}
|
||||
|
||||
public function acknowledgedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): void
|
||||
{
|
||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
66
app/Policies/FindingPolicy.php
Normal file
66
app/Policies/FindingPolicy.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class FindingPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, Finding $finding): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $finding->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
public function update(User $user, Finding $finding): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
return match ($role) {
|
||||
TenantRole::Owner,
|
||||
TenantRole::Manager,
|
||||
TenantRole::Operator => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,18 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Policies\BackupSchedulePolicy;
|
||||
use App\Policies\BulkOperationRunPolicy;
|
||||
use App\Policies\EntraGroupPolicy;
|
||||
use App\Policies\EntraGroupSyncRunPolicy;
|
||||
use App\Policies\FindingPolicy;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
@ -108,7 +116,8 @@ public function boot(): void
|
||||
|
||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
||||
Gate::policy(\App\Models\EntraGroupSyncRun::class, \App\Policies\EntraGroupSyncRunPolicy::class);
|
||||
Gate::policy(\App\Models\EntraGroup::class, \App\Policies\EntraGroupPolicy::class);
|
||||
Gate::policy(Finding::class, FindingPolicy::class);
|
||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Services/Drift/DriftEvidence.php
Normal file
31
app/Services/Drift/DriftEvidence.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
class DriftEvidence
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitize(array $payload): array
|
||||
{
|
||||
$allowedKeys = [
|
||||
'change_type',
|
||||
'summary',
|
||||
'baseline',
|
||||
'current',
|
||||
'diff',
|
||||
'notes',
|
||||
];
|
||||
|
||||
$safe = [];
|
||||
foreach ($allowedKeys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
$safe[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $safe;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
305
app/Services/Drift/DriftFindingGenerator.php
Normal file
305
app/Services/Drift/DriftFindingGenerator.php
Normal file
@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use Illuminate\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
class DriftFindingGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DriftHasher $hasher,
|
||||
private readonly DriftEvidence $evidence,
|
||||
private readonly SettingsNormalizer $settingsNormalizer,
|
||||
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
) {}
|
||||
|
||||
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
||||
{
|
||||
if (! $baseline->finished_at || ! $current->finished_at) {
|
||||
throw new RuntimeException('Baseline/current run must be finished.');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $selection */
|
||||
$selection = is_array($current->selection_payload) ? $current->selection_payload : [];
|
||||
|
||||
$policyTypes = Arr::get($selection, 'policy_types');
|
||||
if (! is_array($policyTypes)) {
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
||||
|
||||
$created = 0;
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->orderBy('id')
|
||||
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$created): void {
|
||||
foreach ($policies as $policy) {
|
||||
if (! $policy instanceof Policy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineVersion = $this->versionForRun($policy, $baseline);
|
||||
$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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
||||
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
||||
|
||||
$baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments);
|
||||
$currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments);
|
||||
|
||||
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
||||
$fingerprint = $this->hasher->fingerprint(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'assignment',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
changeType: 'modified',
|
||||
baselineHash: (string) ($baselineAssignmentsHash ?? ''),
|
||||
currentHash: (string) ($currentAssignmentsHash ?? ''),
|
||||
);
|
||||
|
||||
$rawEvidence = [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_assignments',
|
||||
'changed_fields' => ['assignments_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'assignments_hash' => $baselineAssignmentsHash,
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'assignments_hash' => $currentAssignmentsHash,
|
||||
],
|
||||
];
|
||||
|
||||
$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' => 'assignment',
|
||||
'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++;
|
||||
}
|
||||
}
|
||||
|
||||
$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++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function versionForRun(Policy $policy, InventorySyncRun $run): ?PolicyVersion
|
||||
{
|
||||
if (! $run->finished_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', $policy->tenant_id)
|
||||
->where('policy_id', $policy->getKey())
|
||||
->where('captured_at', '<=', $run->finished_at)
|
||||
->latest('captured_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
101
app/Services/Drift/DriftHasher.php
Normal file
101
app/Services/Drift/DriftHasher.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
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(
|
||||
int $tenantId,
|
||||
string $scopeKey,
|
||||
string $subjectType,
|
||||
string $subjectExternalId,
|
||||
string $changeType,
|
||||
string $baselineHash,
|
||||
string $currentHash,
|
||||
): string {
|
||||
$parts = [
|
||||
(string) $tenantId,
|
||||
$this->normalize($scopeKey),
|
||||
$this->normalize($subjectType),
|
||||
$this->normalize($subjectExternalId),
|
||||
$this->normalize($changeType),
|
||||
$this->normalize($baselineHash),
|
||||
$this->normalize($currentHash),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
40
app/Services/Drift/DriftRunSelector.php
Normal file
40
app/Services/Drift/DriftRunSelector.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
class DriftRunSelector
|
||||
{
|
||||
/**
|
||||
* @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null
|
||||
*/
|
||||
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
||||
{
|
||||
$runs = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_hash', $scopeKey)
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($runs->count() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $runs->first();
|
||||
$baseline = $runs->last();
|
||||
|
||||
if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline' => $baseline,
|
||||
'current' => $current,
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Services/Drift/DriftScopeKey.php
Normal file
13
app/Services/Drift/DriftScopeKey.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
|
||||
class DriftScopeKey
|
||||
{
|
||||
public function fromRun(InventorySyncRun $run): string
|
||||
{
|
||||
return (string) $run->selection_hash;
|
||||
}
|
||||
}
|
||||
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',
|
||||
'type' => 'application',
|
||||
'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',
|
||||
'type' => 'application',
|
||||
'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',
|
||||
@ -72,7 +72,7 @@
|
||||
'key' => 'Group.Read.All',
|
||||
'type' => 'application',
|
||||
'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',
|
||||
|
||||
37
database/factories/FindingFactory.php
Normal file
37
database/factories/FindingFactory.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Finding>
|
||||
*/
|
||||
class FindingFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => hash('sha256', fake()->uuid()),
|
||||
'baseline_run_id' => null,
|
||||
'current_run_id' => null,
|
||||
'fingerprint' => hash('sha256', fake()->uuid()),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => fake()->uuid(),
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('findings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('tenant_id')->constrained();
|
||||
|
||||
$table->string('finding_type');
|
||||
$table->string('scope_key');
|
||||
|
||||
$table->foreignId('baseline_run_id')->nullable()->constrained('inventory_sync_runs');
|
||||
$table->foreignId('current_run_id')->nullable()->constrained('inventory_sync_runs');
|
||||
|
||||
$table->string('fingerprint', 64);
|
||||
|
||||
$table->string('subject_type');
|
||||
$table->string('subject_external_id');
|
||||
|
||||
$table->string('severity');
|
||||
$table->string('status');
|
||||
|
||||
$table->timestampTz('acknowledged_at')->nullable();
|
||||
$table->foreignId('acknowledged_by_user_id')->nullable()->constrained('users');
|
||||
|
||||
$table->jsonb('evidence_jsonb')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'fingerprint']);
|
||||
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'scope_key']);
|
||||
$table->index(['tenant_id', 'baseline_run_id']);
|
||||
$table->index(['tenant_id', 'current_run_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('findings');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
107
resources/views/filament/pages/drift-landing.blade.php
Normal file
107
resources/views/filament/pages/drift-landing.blade.php
Normal file
@ -0,0 +1,107 @@
|
||||
<x-filament::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review new drift findings between the last two inventory sync runs for the current scope.
|
||||
</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">
|
||||
<x-filament::button tag="a" :href="$this->getFindingsUrl()">
|
||||
Findings
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament::page>
|
||||
35
specs/044-drift-mvp/checklists/requirements.md
Normal file
35
specs/044-drift-mvp/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Drift MVP (044)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to implementation
|
||||
**Created**: 2026-01-12
|
||||
**Feature**: [specs/044-drift-mvp/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [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, Acceptance Criteria)
|
||||
- [x] Written for non-technical stakeholders (spec.md uses plain language; avoids code/framework terms)
|
||||
- [x] All mandatory sections completed (spec.md includes Purpose, User Scenarios, Rules, Acceptance Criteria)
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain (spec.md: no "[NEEDS CLARIFICATION]" markers)
|
||||
- [x] Requirements are testable and unambiguous (spec.md: Rules + Acceptance Criteria)
|
||||
- [x] Success criteria are measurable (spec.md: Acceptance Criteria)
|
||||
- [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] Edge cases are identified (spec.md: blocked state; error state; acknowledgement per comparison)
|
||||
- [x] Scope is clearly bounded (spec.md: Rules → Coverage (MVP))
|
||||
- [x] Dependencies and assumptions identified (spec.md: Rules → UI states; Run tracking)
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [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] Feature meets measurable outcomes defined in Success Criteria (spec.md: Acceptance Criteria are measurable and testable)
|
||||
- [x] No implementation details leak into specification (spec.md avoids implementation and names a generic “persisted run record” only)
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`.
|
||||
- Constitution gate: this checklist must exist for features that change runtime behavior.
|
||||
167
specs/044-drift-mvp/contracts/admin-findings.openapi.yaml
Normal file
167
specs/044-drift-mvp/contracts/admin-findings.openapi.yaml
Normal file
@ -0,0 +1,167 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Admin Findings API (Internal)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Internal contracts for the generic Findings pipeline.
|
||||
Drift MVP is the first generator (finding_type=drift).
|
||||
|
||||
servers:
|
||||
- url: /admin/api
|
||||
|
||||
paths:
|
||||
/findings:
|
||||
get:
|
||||
summary: List findings
|
||||
parameters:
|
||||
- in: query
|
||||
name: finding_type
|
||||
schema:
|
||||
type: string
|
||||
enum: [drift, audit, compare]
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [new, acknowledged]
|
||||
- in: query
|
||||
name: scope_key
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: current_run_id
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
/findings/{id}:
|
||||
get:
|
||||
summary: Get finding detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
/findings/{id}/acknowledge:
|
||||
post:
|
||||
summary: Acknowledge a finding
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
/drift/generate:
|
||||
post:
|
||||
summary: Generate drift findings (async)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
scope_key:
|
||||
type: string
|
||||
description: Inventory selection hash
|
||||
required: [scope_key]
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DriftGenerateAccepted'
|
||||
|
||||
components:
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
finding_type:
|
||||
type: string
|
||||
enum: [drift, audit, compare]
|
||||
tenant_id:
|
||||
type: integer
|
||||
scope_key:
|
||||
type: string
|
||||
baseline_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
current_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
fingerprint:
|
||||
type: string
|
||||
subject_type:
|
||||
type: string
|
||||
subject_external_id:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
enum: [low, medium, high]
|
||||
status:
|
||||
type: string
|
||||
enum: [new, acknowledged]
|
||||
acknowledged_at:
|
||||
type: string
|
||||
nullable: true
|
||||
acknowledged_by_user_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
evidence_jsonb:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
57
specs/044-drift-mvp/data-model.md
Normal file
57
specs/044-drift-mvp/data-model.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Phase 1 Design: Data Model (044)
|
||||
|
||||
## Entities
|
||||
|
||||
### Finding
|
||||
|
||||
New table: `findings`
|
||||
|
||||
**Purpose**: Generic, persisted pipeline for analytic findings (Drift now; Audit/Compare later).
|
||||
|
||||
**Core fields (MVP)**
|
||||
- `id` (pk)
|
||||
- `tenant_id` (fk tenants)
|
||||
- `finding_type` (`drift` in MVP; later `audit`/`compare`)
|
||||
- `scope_key` (string; deterministic; reuse Inventory selection hash)
|
||||
- `baseline_run_id` (nullable fk inventory_sync_runs)
|
||||
- `current_run_id` (nullable fk inventory_sync_runs)
|
||||
- `fingerprint` (string; deterministic)
|
||||
- `subject_type` (string; e.g. policy type)
|
||||
- `subject_external_id` (string; Graph external id)
|
||||
- `severity` (`low|medium|high`; MVP default `medium`)
|
||||
- `status` (`new|acknowledged`)
|
||||
- `acknowledged_at` (nullable)
|
||||
- `acknowledged_by_user_id` (nullable fk users)
|
||||
- `evidence_jsonb` (jsonb; sanitized, small; allowlist)
|
||||
|
||||
**Prepared for later (nullable, out of MVP)**
|
||||
- `rule_id`, `control_id`, `expected_value`, `source`
|
||||
|
||||
## Constraints & Indexes
|
||||
|
||||
**Uniqueness**
|
||||
- Unique: `(tenant_id, fingerprint)`
|
||||
|
||||
**Lookup indexes (suggested)**
|
||||
- `(tenant_id, finding_type, status)`
|
||||
- `(tenant_id, scope_key)`
|
||||
- `(tenant_id, current_run_id)`
|
||||
- `(tenant_id, baseline_run_id)`
|
||||
- `(tenant_id, subject_type, subject_external_id)`
|
||||
|
||||
## Relationships
|
||||
|
||||
- `Finding` belongs to `Tenant`.
|
||||
- `Finding` belongs to `User` via `acknowledged_by_user_id`.
|
||||
- `Finding` belongs to `InventorySyncRun` via `baseline_run_id` (nullable) and `current_run_id` (nullable).
|
||||
|
||||
## Evidence shape (MVP allowlist)
|
||||
|
||||
For Drift MVP, `evidence_jsonb` should contain only:
|
||||
- `change_type`
|
||||
- `changed_fields` (list) and/or `change_counts`
|
||||
- `run`:
|
||||
- `baseline_run_id`, `current_run_id`
|
||||
- `baseline_finished_at`, `current_finished_at`
|
||||
|
||||
No raw policy payload dumps; exclude secrets/tokens; exclude volatile fields for hashing.
|
||||
@ -1,24 +1,115 @@
|
||||
# Implementation Plan: Drift MVP
|
||||
# Implementation Plan: Drift MVP (044)
|
||||
|
||||
**Date**: 2026-01-07
|
||||
**Spec**: `specs/044-drift-mvp/spec.md`
|
||||
**Branch**: `feat/044-drift-mvp` | **Date**: 2026-01-12 | **Spec**: `specs/044-drift-mvp/spec.md`
|
||||
**Input**: Feature specification from `specs/044-drift-mvp/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add drift findings generation and UI using inventory and sync run metadata.
|
||||
Introduce a generic, persisted Finding pipeline and implement Drift as the first generator.
|
||||
|
||||
## Dependencies
|
||||
- Drift compares Inventory Sync Runs for the same selection scope (`scope_key`).
|
||||
- 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`).
|
||||
- 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.
|
||||
|
||||
- Inventory core + run tracking (Spec 040)
|
||||
- Inventory UI patterns (Spec 041)
|
||||
## Technical Context
|
||||
|
||||
## Deliverables
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Framework**: Laravel 12
|
||||
**Admin UI**: Filament v4 + Livewire v3
|
||||
**Storage**: PostgreSQL (JSONB)
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Docker (Sail-first local), Dokploy container deployments
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**:
|
||||
- Drift generation happens async (job), with deterministic output
|
||||
- Drift listing remains filterable and index-backed
|
||||
**Constraints**:
|
||||
- Tenant isolation for all reads/writes
|
||||
- No render-time Graph calls; labels resolved from DB caches
|
||||
- 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**:
|
||||
- Tenants may have large inventories; findings must be indexed for typical filtering
|
||||
|
||||
- Baseline definition and drift finding generation
|
||||
- Drift summary + detail UI
|
||||
- Acknowledge/triage actions
|
||||
## Constitution Check
|
||||
|
||||
## Risks
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- False positives if baseline definition is unclear
|
||||
- Data volume for large tenants
|
||||
- Inventory-first: Drift is derived from Inventory Sync Runs and Inventory Items (“last observed” state).
|
||||
- Read/write separation: Drift generation is analytical; writes are limited to triage acknowledgement and must be audited + tested.
|
||||
- Graph contract path: Drift UI performs no Graph calls; Graph calls remain isolated in existing Inventory/Graph client layers.
|
||||
- Deterministic capabilities: drift scope derives from existing selection hashing and inventory type registries.
|
||||
- Tenant isolation: all reads/writes tenant-scoped; no cross-tenant leakage.
|
||||
- Automation: drift generation is queued; jobs are deduped/locked per scope+run pair and observable.
|
||||
- Data minimization: store only minimized evidence JSON; logs contain no secrets/tokens.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/044-drift-mvp/
|
||||
├── plan.md # This file (/speckit.plan output)
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── admin-findings.openapi.yaml
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── DriftLanding.php # drift landing page (summary + generation status)
|
||||
│ └── Resources/
|
||||
│ └── FindingResource/ # list/detail + acknowledge action (tenant-scoped)
|
||||
├── Jobs/
|
||||
│ └── GenerateDriftFindingsJob.php # async generator (on-demand)
|
||||
├── Models/
|
||||
│ └── Finding.php # generic finding model
|
||||
└── Services/
|
||||
└── Drift/
|
||||
├── DriftFindingGenerator.php # computes deterministic findings for baseline/current
|
||||
├── DriftHasher.php # baseline_hash/current_hash helpers
|
||||
└── DriftScopeKey.php # scope_key is InventorySyncRun.selection_hash (single canonical definition)
|
||||
|
||||
database/migrations/
|
||||
└── 2026_.._.._create_findings_table.php
|
||||
|
||||
tests/Feature/Drift/
|
||||
├── DriftGenerationDeterminismTest.php
|
||||
├── DriftTenantIsolationTest.php
|
||||
└── DriftAcknowledgeTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith using Filament pages/resources and queued jobs.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| (none) | | |
|
||||
|
||||
## Phase 0 Output (Research)
|
||||
|
||||
Completed in `specs/044-drift-mvp/research.md`.
|
||||
|
||||
## Phase 1 Output (Design)
|
||||
|
||||
Completed in:
|
||||
|
||||
- `specs/044-drift-mvp/data-model.md`
|
||||
- `specs/044-drift-mvp/contracts/`
|
||||
- `specs/044-drift-mvp/quickstart.md`
|
||||
|
||||
## Phase 2 Planning Notes
|
||||
|
||||
Next step is expanding `specs/044-drift-mvp/tasks.md` (via `/speckit.tasks`) with phased, test-first implementation tasks.
|
||||
|
||||
32
specs/044-drift-mvp/quickstart.md
Normal file
32
specs/044-drift-mvp/quickstart.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Quickstart: Drift MVP (044)
|
||||
|
||||
## Run locally (Sail)
|
||||
|
||||
```bash
|
||||
./vendor/bin/sail up -d
|
||||
./vendor/bin/sail artisan queue:work --tries=1
|
||||
```
|
||||
|
||||
## Prepare data
|
||||
|
||||
1. Open the admin panel and select a tenant context.
|
||||
2. Navigate to Inventory and run an Inventory Sync **twice** with the same selection (same `selection_hash`).
|
||||
|
||||
## Use Drift
|
||||
|
||||
1. Navigate to the new Drift area.
|
||||
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`
|
||||
- current = latest successful run for the same `scope_key`
|
||||
4. Refresh the page once generation finishes.
|
||||
|
||||
## Triage
|
||||
|
||||
- Acknowledge a finding; it moves out of the default “new” view but remains visible/auditable.
|
||||
- Use the status filter to include acknowledged findings.
|
||||
|
||||
## Notes
|
||||
|
||||
- UI must remain DB-only for label resolution (no render-time Graph calls).
|
||||
- Findings store minimal, sanitized evidence only.
|
||||
72
specs/044-drift-mvp/research.md
Normal file
72
specs/044-drift-mvp/research.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Phase 0 Output: Research (044)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) `scope_key` reuse
|
||||
|
||||
- Decision: Use the existing Inventory selection hash as `scope_key`.
|
||||
- Concretely: `scope_key = InventorySyncRun.selection_hash`.
|
||||
- Rationale:
|
||||
- Inventory already normalizes + hashes selection payload deterministically (via `InventorySelectionHasher`).
|
||||
- It is already used for concurrency/deduping inventory runs, so it’s the right stable scope identifier.
|
||||
- Alternatives considered:
|
||||
- Compute a second hash (duplicate of selection_hash) → adds drift without benefit.
|
||||
- Store the raw selection payload as the primary key → not stable without strict normalization.
|
||||
|
||||
### 2) Baseline selection (MVP)
|
||||
|
||||
- Decision: Baseline run = previous successful inventory sync run for the same `scope_key`; comparison run = latest successful inventory sync run for the same `scope_key`.
|
||||
- Rationale:
|
||||
- Matches “run at least twice” scenario.
|
||||
- Deterministic and explainable.
|
||||
- Alternatives considered:
|
||||
- User-pinned baselines → valuable, but deferred (design must allow later via `scope_key`).
|
||||
|
||||
### 3) Persisted generic Findings
|
||||
|
||||
- Decision: Persist Findings in a generic `findings` table.
|
||||
- Rationale:
|
||||
- Enables stable triage (`acknowledged`) without recomputation drift.
|
||||
- Reusable pipeline for Drift now, Audit/Compare later.
|
||||
- Alternatives considered:
|
||||
- Compute-on-demand and store only acknowledgements by fingerprint → harder operationally and can surprise users when diff rules evolve.
|
||||
|
||||
### 4) Generation trigger (MVP)
|
||||
|
||||
- Decision: On opening Drift, if findings for (tenant, `scope_key`, baseline_run_id, current_run_id) do not exist, dispatch an async job to generate them.
|
||||
- Rationale:
|
||||
- Avoids long request times.
|
||||
- Avoids scheduled complexity in MVP.
|
||||
- Alternatives considered:
|
||||
- Generate after every inventory run → may be expensive; can be added later.
|
||||
- Nightly schedule → hides immediacy and complicates operations.
|
||||
|
||||
### 5) Fingerprint and state hashing
|
||||
|
||||
- Decision: Use a deterministic fingerprint that changes when the underlying state changes.
|
||||
- Fingerprint = `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)`.
|
||||
- baseline_hash/current_hash are computed over normalized, sanitized comparison data (exclude volatile fields like timestamps).
|
||||
- Rationale:
|
||||
- Stable identity for triage and audit.
|
||||
- Supports future generators (audit/compare) using same semantics.
|
||||
- Alternatives considered:
|
||||
- Fingerprint without baseline/current hash → cannot distinguish changed vs unchanged findings.
|
||||
|
||||
### 6) Evidence minimization
|
||||
|
||||
- Decision: Store small, sanitized `evidence_jsonb` with an allowlist shape; no raw payload dumps.
|
||||
- Rationale:
|
||||
- Aligns with data minimization + safe logging.
|
||||
- Avoids storing secrets/tokens.
|
||||
|
||||
### 7) Name resolution and Graph safety
|
||||
|
||||
- Decision: UI resolves human-readable labels using DB-backed Inventory + Foundations (047) + Groups Cache (051). No render-time Graph calls.
|
||||
- Rationale:
|
||||
- Works offline / when tokens are broken.
|
||||
- Keeps UI safe and predictable.
|
||||
|
||||
## Notes / Follow-ups for Phase 1
|
||||
|
||||
- Define the `findings` table indexes carefully for tenant-scoped filtering (status, type, scope_key, run_ids).
|
||||
- Consider using existing observable run patterns (BulkOperationRun + AuditLogger) for drift generation jobs.
|
||||
@ -1,55 +1,81 @@
|
||||
# Feature Specification: Drift MVP
|
||||
|
||||
**Feature Branch**: `feat/044-drift-mvp`
|
||||
**Created**: 2026-01-07
|
||||
**Status**: Draft
|
||||
# Feature Specification: Drift MVP (044)
|
||||
|
||||
## 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.
|
||||
|
||||
## User Scenarios & Testing
|
||||
## User Scenarios
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
- Given a drift finding exists
|
||||
- When the admin opens the finding
|
||||
- Then they see what changed, when, and which run observed it
|
||||
|
||||
- Given drift findings exist for a comparison
|
||||
- When an admin opens a specific finding
|
||||
- Then they can see what changed and which two snapshots were compared
|
||||
|
||||
### Scenario 3: Acknowledge / triage
|
||||
|
||||
- Given a drift finding exists
|
||||
- When the admin marks it acknowledged
|
||||
- Then it is hidden from “new” lists but remains auditable
|
||||
- When an admin acknowledges it
|
||||
- Then it no longer appears in “new” views, but remains available for audit/history
|
||||
|
||||
## Functional Requirements
|
||||
## Rules
|
||||
|
||||
- FR1: Define a baseline concept (e.g., last completed run for a selection scope).
|
||||
- FR2: Produce drift findings for adds/removals/metadata changes based on inventory/run state.
|
||||
- FR3: Provide drift UI with summary and details.
|
||||
- FR4: Allow acknowledgement/triage states.
|
||||
### Coverage (MVP)
|
||||
|
||||
## Non-Functional Requirements
|
||||
- Drift findings cover **policies, their assignments, and scope tags** for the selected scope.
|
||||
|
||||
- NFR1: Drift generation must be deterministic for the same baseline and scope.
|
||||
- NFR2: Drift must remain tenant-scoped and safe to display.
|
||||
### Baseline and comparison selection
|
||||
|
||||
## Success Criteria
|
||||
- Drift always compares two successful inventory snapshots for the same selection/scope.
|
||||
- The “current” snapshot is the latest successful snapshot for that scope.
|
||||
- The “baseline” snapshot is the previous successful snapshot for that scope.
|
||||
|
||||
- SC1: Admins can identify drift across supported types in under 3 minutes.
|
||||
- SC2: Drift results are consistent across repeated generation for the same baseline.
|
||||
### Change types
|
||||
|
||||
## Out of Scope
|
||||
Each drift finding must be categorized as one of:
|
||||
|
||||
- Automatic revert/promotion.
|
||||
- **added**: the item exists in current but not in baseline
|
||||
- **removed**: the item exists in baseline but not in current
|
||||
- **modified**: the item exists in both but differs (including assignment target and/or intent changes)
|
||||
|
||||
## Related Specs
|
||||
### Acknowledgement
|
||||
|
||||
- Program: `specs/039-inventory-program/spec.md`
|
||||
- Core: `specs/040-inventory-core/spec.md`
|
||||
- Compare: `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||
- Acknowledgement is **per comparison** (baseline + current within a scope).
|
||||
- Acknowledgement does **not** carry forward to later comparisons.
|
||||
|
||||
### UI states
|
||||
|
||||
- **blocked**: If fewer than two successful snapshots exist for the same scope, Drift shows a clear blocked state and does not attempt generation.
|
||||
- **error**: If drift generation fails for a comparison, Drift shows a clear error state with safe information and reference identifiers to the recorded run.
|
||||
|
||||
### Default views
|
||||
|
||||
- Default Drift summary and default finding lists show **new** findings only.
|
||||
- Acknowledged findings are accessible via an explicit filter.
|
||||
|
||||
### Run tracking (status, errors, idempotency)
|
||||
|
||||
- Drift generation status and errors must be recorded in a **persisted run record** so that progress/failure survives refresh and can be inspected later.
|
||||
- Re-opening Drift for the same comparison must be idempotent (it should not create duplicate work for the same comparison).
|
||||
|
||||
### Determinism and stable identity
|
||||
|
||||
- For the same scope + baseline + current, Drift must produce the same set of findings.
|
||||
- Each finding must have a stable identifier (“fingerprint”) so triage actions can reliably reference the same drift item within a comparison.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- With two successful snapshots for the same scope, Drift shows a summary of **added/removed/modified** items for that comparison.
|
||||
- With fewer than two successful snapshots for the same scope, Drift shows **blocked** and does not start generation.
|
||||
- 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.
|
||||
|
||||
@ -1,7 +1,185 @@
|
||||
# Tasks: Drift MVP
|
||||
---
|
||||
|
||||
- [ ] T001 Define baseline and scope rules
|
||||
- [ ] T002 Drift finding generation (deterministic)
|
||||
- [ ] T003 Drift summary + detail UI
|
||||
- [ ] T004 Acknowledge/triage state
|
||||
- [ ] T005 Tests for determinism and tenant scoping
|
||||
description: "Task list for feature 044 drift MVP"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Drift MVP (044)
|
||||
|
||||
**Input**: Design documents from `specs/044-drift-mvp/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required), plus `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) - feature introduces runtime behavior + new persistence.
|
||||
|
||||
**Organization**: Tasks are grouped by user story (Scenario 1/2/3 in spec).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project wiring for Drift MVP.
|
||||
|
||||
- [x] T001 Create/update constitution gate checklist in `specs/044-drift-mvp/checklists/requirements.md`
|
||||
- [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}`
|
||||
- [x] T003 Add Drift landing page shell in `app/Filament/Pages/DriftLanding.php`
|
||||
- [x] T004 [P] Add Finding resource shells in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Persistence + authorization + deterministic IDs that all stories depend on.
|
||||
|
||||
**Checkpoint**: DB schema exists, tenant scoping enforced, and tests can create Finding rows.
|
||||
|
||||
- [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)
|
||||
- [x] T006 Create `Finding` model in `app/Models/Finding.php` (casts for `evidence_jsonb`, enums/constants for `finding_type`/`severity`/`status`, `acknowledged_at` handling)
|
||||
- [x] T007 [P] Add `FindingFactory` in `database/factories/FindingFactory.php`
|
||||
- [x] T008 Ensure `InventorySyncRunFactory` exists (or add it) in `database/factories/InventorySyncRunFactory.php`
|
||||
- [x] T009 Add authorization policy in `app/Policies/FindingPolicy.php` and wire it in `app/Providers/AuthServiceProvider.php` (or project equivalent)
|
||||
- [x] T010 Add Drift permissions in `config/intune_permissions.php` (view + acknowledge) and wire them into Filament navigation/actions
|
||||
- [x] T011 Enforce tenant scoping for Finding queries in `app/Models/Finding.php` and/or `app/Filament/Resources/FindingResource.php`
|
||||
- [x] T012 Implement fingerprint helper in `app/Services/Drift/DriftHasher.php` (sha256 per spec)
|
||||
- [x] T013 Pin `scope_key = InventorySyncRun.selection_hash` in `app/Services/Drift/DriftScopeKey.php` (single canonical definition)
|
||||
- [x] T014 Implement evidence allowlist builder in `app/Services/Drift/DriftEvidence.php` (new file)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - View drift summary (Priority: P1) MVP
|
||||
|
||||
**Goal**: Opening Drift generates (async) and displays a summary of new drift findings for the latest scope.
|
||||
|
||||
**Independent Test**: With 2 successful inventory runs for the same selection hash, opening Drift dispatches generation if missing and then shows summary counts.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T015 [P] [US1] Baseline selection tests in `tests/Feature/Drift/DriftBaselineSelectionTest.php`
|
||||
- [x] T016 [P] [US1] Generation dispatch tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
|
||||
- [x] T017 [P] [US1] Tenant isolation tests in `tests/Feature/Drift/DriftTenantIsolationTest.php`
|
||||
- [x] T018 [P] [US1] Assignment drift detection test (targets + intent changes per FR2b) in `tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T019 [US1] Implement run selection service in `app/Services/Drift/DriftRunSelector.php`
|
||||
- [x] 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)
|
||||
- [x] T022 [US1] Implement landing behavior (dispatch + status UI) in `app/Filament/Pages/DriftLanding.php`
|
||||
- [x] T023 [US1] Implement summary queries/widgets in `app/Filament/Pages/DriftLanding.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Drill into a drift finding (Priority: P2)
|
||||
|
||||
**Goal**: Admin can view a finding and see sanitized evidence + run references (DB-only label resolution).
|
||||
|
||||
**Independent Test**: A persisted finding renders details without Graph calls.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T024 [P] [US2] Finding detail test in `tests/Feature/Drift/DriftFindingDetailTest.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
|
||||
|
||||
- [x] T026 [US2] Implement list UI in `app/Filament/Resources/FindingResource.php` (filters: status, scope_key, run)
|
||||
- [x] T027 [US2] Implement detail UI in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||
- [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`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Acknowledge/triage (Priority: P3)
|
||||
|
||||
**Goal**: Admin can acknowledge findings; new lists hide acknowledged but records remain auditable.
|
||||
|
||||
**Independent Test**: Acknowledging sets `acknowledged_at` + `acknowledged_by_user_id` and flips status.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T029 [P] [US3] Acknowledge action test in `tests/Feature/Drift/DriftAcknowledgeTest.php`
|
||||
- [x] T030 [P] [US3] Acknowledge authorization test in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T031 [US3] Add acknowledge actions in `app/Filament/Resources/FindingResource.php`
|
||||
- [x] T032 [US3] Implement `acknowledge()` domain method in `app/Models/Finding.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
|
||||
|
||||
- [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)
|
||||
- [x] T035 [P] Add determinism test in `tests/Feature/Drift/DriftGenerationDeterminismTest.php` (same baseline/current => same fingerprints)
|
||||
- [x] T036 Add job observability logs in `app/Jobs/GenerateDriftFindingsJob.php` (no secrets)
|
||||
- [x] T037 Add idempotency/upsert strategy in `app/Services/Drift/DriftFindingGenerator.php`
|
||||
- [x] T038 Ensure volatile fields excluded from hashing in `app/Services/Drift/DriftHasher.php` and cover in `tests/Feature/Drift/DriftHasherTest.php`
|
||||
- [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`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- Setup (Phase 1) -> Foundational (Phase 2) -> US1 -> US2 -> US3 -> Polish
|
||||
|
||||
### Parallel execution examples
|
||||
|
||||
- Foundational: T007, T008, T012, T013, T014
|
||||
- US1 tests: T015, T016, T017, T018
|
||||
- US2 tests: T024, T025
|
||||
- US3 tests: T029, T030
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP scope
|
||||
|
||||
- MVP = Phase 1 + Phase 2 + US1.
|
||||
|
||||
### Format validation
|
||||
|
||||
- All tasks use `- [ ] T###` format
|
||||
- Story tasks include `[US1]`/`[US2]`/`[US3]`
|
||||
- All tasks include file paths
|
||||
|
||||
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());
|
||||
});
|
||||
74
tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php
Normal file
74
tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?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 assignment targets change', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-assignments');
|
||||
|
||||
$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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$currentAssignments = [
|
||||
[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-b',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
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);
|
||||
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||
|
||||
expect($created)->toBe(1);
|
||||
|
||||
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
|
||||
expect($finding)->not->toBeNull();
|
||||
expect($finding->subject_type)->toBe('assignment');
|
||||
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||
});
|
||||
60
tests/Feature/Drift/DriftBaselineSelectionTest.php
Normal file
60
tests/Feature/Drift/DriftBaselineSelectionTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
|
||||
test('it selects the previous and latest successful runs for the same scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-a');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
$selected = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
expect($selected)->not->toBeNull();
|
||||
expect($selected['baseline']->getKey())->toBe($baseline->getKey());
|
||||
expect($selected['current']->getKey())->toBe($current->getKey());
|
||||
});
|
||||
|
||||
test('it returns null when fewer than two successful runs exist for scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-b');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
expect($selector->selectBaselineAndCurrent($tenant, $scopeKey))->toBeNull();
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
129
tests/Feature/Drift/DriftGenerationDispatchTest.php
Normal file
129
tests/Feature/Drift/DriftGenerationDispatchTest.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?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 dispatches generation when findings are missing', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-dispatch');
|
||||
|
||||
$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);
|
||||
|
||||
$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()
|
||||
&& $job->userId === (int) $user->getKey()
|
||||
&& $job->baselineRunId === (int) $baseline->getKey()
|
||||
&& $job->currentRunId === (int) $current->getKey()
|
||||
&& $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 () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-blocked');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
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);
|
||||
});
|
||||
80
tests/Feature/Drift/DriftTenantIsolationTest.php
Normal file
80
tests/Feature/Drift/DriftTenantIsolationTest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?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 tenant isolated', function () {
|
||||
[$userA, $tenantA] = createUserWithTenant(role: 'manager');
|
||||
[$userB, $tenantB] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-tenant');
|
||||
|
||||
$baselineA = InventorySyncRun::factory()->for($tenantA)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$currentA = InventorySyncRun::factory()->for($tenantA)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create([
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$baselineAssignments = [['target' => ['groupId' => 'group-a'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||
$currentAssignments = [['target' => ['groupId' => 'group-b'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||
|
||||
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'captured_at' => $baselineA->finished_at->copy()->subMinute(),
|
||||
'assignments' => $baselineAssignments,
|
||||
'assignments_hash' => hash('sha256', json_encode($baselineAssignments)),
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policyA->policy_type,
|
||||
'captured_at' => $currentA->finished_at->copy()->subMinute(),
|
||||
'assignments' => $currentAssignments,
|
||||
'assignments_hash' => hash('sha256', json_encode($currentAssignments)),
|
||||
]);
|
||||
|
||||
$policyB = Policy::factory()->for($tenantB)->create([
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$baselineAssignmentsB = [['target' => ['groupId' => 'group-x'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||
$currentAssignmentsB = [['target' => ['groupId' => 'group-y'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||
|
||||
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policyB->policy_type,
|
||||
'captured_at' => now()->subDays(2)->subMinute(),
|
||||
'assignments' => $baselineAssignmentsB,
|
||||
'assignments_hash' => hash('sha256', json_encode($baselineAssignmentsB)),
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policyB->policy_type,
|
||||
'captured_at' => now()->subDay()->subMinute(),
|
||||
'assignments' => $currentAssignmentsB,
|
||||
'assignments_hash' => hash('sha256', json_encode($currentAssignmentsB)),
|
||||
]);
|
||||
|
||||
$generator = app(DriftFindingGenerator::class);
|
||||
$generator->generate($tenantA, $baselineA, $currentA, $scopeKey);
|
||||
|
||||
expect(Finding::query()->where('tenant_id', $tenantA->getKey())->count())->toBe(1);
|
||||
expect(Finding::query()->where('tenant_id', $tenantB->getKey())->count())->toBe(0);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user