feat/044-drift-mvp #58

Merged
ahmido merged 8 commits from feat/044-drift-mvp into dev 2026-01-14 23:16:11 +00:00
17 changed files with 681 additions and 0 deletions
Showing only changes of commit 242881c04e - Show all commits

View File

@ -0,0 +1,83 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Resources\FindingResource;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\Finding;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Drift\DriftRunSelector;
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 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) {
return;
}
$scopeKey = (string) $latestSuccessful->selection_hash;
$selector = app(DriftRunSelector::class);
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
if ($comparison === null) {
return;
}
$baseline = $comparison['baseline'];
$current = $comparison['current'];
$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) {
return;
}
GenerateDriftFindingsJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
baselineRunId: (int) $baseline->getKey(),
currentRunId: (int) $current->getKey(),
scopeKey: $scopeKey,
);
}
public function getFindingsUrl(): string
{
return FindingResource::getUrl('index', tenant: Tenant::current());
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\FindingResource\Pages;
use App\Models\Finding;
use App\Models\Tenant;
use BackedEnum;
use Filament\Actions;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
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 table(Table $table): Table
{
return $table
->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_type')->label('Subject')->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'),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->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}'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Resources\FindingResource;
use Filament\Resources\Pages\ListRecords;
class ListFindings extends ListRecords
{
protected static string $resource = FindingResource::class;
}

View 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;
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
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,
) {}
/**
* Execute the job.
*/
public function handle(): void
{
// Implemented in later tasks (T020/T021).
}
}

65
app/Models/Finding.php Normal file
View File

@ -0,0 +1,65 @@
<?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(),
]);
}
}

View 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,
};
}
}

View File

@ -3,10 +3,14 @@
namespace App\Providers;
use App\Models\BackupSchedule;
use App\Models\BulkOperationRun;
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\FindingPolicy;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient;
@ -108,5 +112,6 @@ public function boot(): void
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
Gate::policy(Finding::class, FindingPolicy::class);
}
}

View 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;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Services\Drift;
class DriftHasher
{
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));
}
}

View 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,
];
}
}

View 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;
}
}

View 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' => [],
];
}
}

View File

@ -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');
}
};

View File

@ -0,0 +1,15 @@
<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>
<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>

View 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();
});

View File

@ -0,0 +1,60 @@
<?php
use App\Filament\Pages\DriftLanding;
use App\Jobs\GenerateDriftFindingsJob;
use App\Models\InventorySyncRun;
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);
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey): 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;
});
});
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();
});