diff --git a/app/Jobs/GenerateDriftFindingsJob.php b/app/Jobs/GenerateDriftFindingsJob.php index 8c0de42..645dae4 100644 --- a/app/Jobs/GenerateDriftFindingsJob.php +++ b/app/Jobs/GenerateDriftFindingsJob.php @@ -2,11 +2,15 @@ namespace App\Jobs; +use App\Models\InventorySyncRun; +use App\Models\Tenant; +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 RuntimeException; class GenerateDriftFindingsJob implements ShouldQueue { @@ -23,8 +27,28 @@ public function __construct( /** * Execute the job. */ - public function handle(): void + public function handle(DriftFindingGenerator $generator): void { - // Implemented in later tasks (T020/T021). + $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.'); + } + + $generator->generate( + tenant: $tenant, + baseline: $baseline, + current: $current, + scopeKey: $this->scopeKey, + ); } } diff --git a/app/Services/Drift/DriftFindingGenerator.php b/app/Services/Drift/DriftFindingGenerator.php new file mode 100644 index 0000000..1fc88fa --- /dev/null +++ b/app/Services/Drift/DriftFindingGenerator.php @@ -0,0 +1,127 @@ +finished_at || ! $current->finished_at) { + throw new RuntimeException('Baseline/current run must be finished.'); + } + + /** @var array $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) { + continue; + } + + $baselineAssignmentsHash = $baselineVersion->assignments_hash ?? null; + $currentAssignmentsHash = $currentVersion->assignments_hash ?? null; + + if ($baselineAssignmentsHash === $currentAssignmentsHash) { + continue; + } + + $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' => 'Policy assignments changed', + '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::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->getKey(), + 'fingerprint' => $fingerprint, + ], + [ + '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, + 'status' => Finding::STATUS_NEW, + 'acknowledged_at' => null, + 'acknowledged_by_user_id' => null, + 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), + ], + ); + + $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(); + } +} diff --git a/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php new file mode 100644 index 0000000..5a186ea --- /dev/null +++ b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php @@ -0,0 +1,76 @@ +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, + 'assignments_hash' => hash('sha256', json_encode($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, + 'assignments_hash' => hash('sha256', json_encode($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'); +}); diff --git a/tests/Feature/Drift/DriftTenantIsolationTest.php b/tests/Feature/Drift/DriftTenantIsolationTest.php new file mode 100644 index 0000000..a6661a2 --- /dev/null +++ b/tests/Feature/Drift/DriftTenantIsolationTest.php @@ -0,0 +1,80 @@ +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); +});