feat(044): generate assignment drift findings
This commit is contained in:
parent
242881c04e
commit
68ab61b5c0
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
app/Services/Drift/DriftFindingGenerator.php
Normal file
127
app/Services/Drift/DriftFindingGenerator.php
Normal file
@ -0,0 +1,127 @@
|
||||
<?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 Illuminate\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
class DriftFindingGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DriftHasher $hasher,
|
||||
private readonly DriftEvidence $evidence,
|
||||
) {}
|
||||
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
76
tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php
Normal file
76
tests/Feature/Drift/DriftAssignmentDriftDetectionTest.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('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,
|
||||
'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');
|
||||
});
|
||||
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