merge: spec-119 implementation
This commit is contained in:
commit
16e88acd4e
@ -1,326 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
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 = 'Governance';
|
||||
|
||||
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 $baselineCompareRunId = null;
|
||||
|
||||
public ?string $baselineCompareCoverageStatus = null;
|
||||
|
||||
public ?int $baselineCompareUncoveredTypesCount = null;
|
||||
|
||||
/** @var list<string>|null */
|
||||
public ?array $baselineCompareUncoveredTypes = null;
|
||||
|
||||
public ?string $baselineCompareFidelity = null;
|
||||
|
||||
public ?int $operationRunId = 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');
|
||||
}
|
||||
|
||||
$baselineCompareStats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
if ($baselineCompareStats->operationRunId !== null) {
|
||||
$this->baselineCompareRunId = (int) $baselineCompareStats->operationRunId;
|
||||
$this->baselineCompareCoverageStatus = $baselineCompareStats->coverageStatus;
|
||||
$this->baselineCompareUncoveredTypesCount = $baselineCompareStats->uncoveredTypesCount;
|
||||
$this->baselineCompareUncoveredTypes = $baselineCompareStats->uncoveredTypes !== [] ? $baselineCompareStats->uncoveredTypes : null;
|
||||
$this->baselineCompareFidelity = $baselineCompareStats->fidelity;
|
||||
}
|
||||
|
||||
$latestSuccessful = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->whereNotNull('completed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->first();
|
||||
|
||||
if (! $latestSuccessful instanceof OperationRun) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No successful inventory runs found yet.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
|
||||
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
|
||||
|
||||
if ($scopeKey === '') {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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->completed_at?->toDateTimeString();
|
||||
$this->currentFinishedAt = $current->completed_at?->toDateTimeString();
|
||||
|
||||
$existingOperationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('context->scope_key', $scopeKey)
|
||||
->where('context->baseline_operation_run_id', (int) $baseline->getKey())
|
||||
->where('context->current_operation_run_id', (int) $current->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existingOperationRun instanceof OperationRun) {
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
}
|
||||
|
||||
$exists = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_operation_run_id', $baseline->getKey())
|
||||
->where('current_operation_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_operation_run_id', $baseline->getKey())
|
||||
->where('current_operation_run_id', $current->getKey())
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
|
||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingOperationRun?->refresh();
|
||||
|
||||
if ($existingOperationRun instanceof OperationRun
|
||||
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
|
||||
) {
|
||||
$this->state = 'generating';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existingOperationRun instanceof OperationRun
|
||||
&& $existingOperationRun->status === 'completed'
|
||||
) {
|
||||
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
|
||||
$created = (int) ($counts['created'] ?? 0);
|
||||
|
||||
if ($existingOperationRun->outcome === 'failed') {
|
||||
$this->state = 'error';
|
||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($created === 0) {
|
||||
$this->state = 'ready';
|
||||
$this->statusCounts = [Finding::STATUS_NEW => 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.';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromQuery([
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$opRun = $opService->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'drift_generate_findings',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
|
||||
GenerateDriftFindingsJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
$this->operationRunId = (int) $opRun->getKey();
|
||||
$this->state = 'generating';
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): string
|
||||
{
|
||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getBaselineRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->baselineRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.operations.view', ['run' => $this->baselineRunId]);
|
||||
}
|
||||
|
||||
public function getCurrentRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->currentRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.operations.view', ['run' => $this->currentRunId]);
|
||||
}
|
||||
|
||||
public function getOperationRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->operationRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRunLinks::view($this->operationRunId, Tenant::current());
|
||||
}
|
||||
|
||||
public function getBaselineCompareRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->baselineCompareRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRunLinks::view($this->baselineCompareRunId, Tenant::current());
|
||||
}
|
||||
}
|
||||
@ -14,8 +14,8 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
|
||||
@ -253,25 +253,25 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Diff')
|
||||
->visible(fn (Finding $record): bool => $record->finding_type === Finding::FINDING_TYPE_DRIFT)
|
||||
->schema([
|
||||
TextEntry::make('diff_unavailable')
|
||||
->label('')
|
||||
->state(fn (Finding $record): string => static::driftDiffUnavailableMessage($record))
|
||||
->visible(fn (Finding $record): bool => ! static::canRenderDriftDiff($record))
|
||||
->columnSpanFull(),
|
||||
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' => []];
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
||||
|
||||
$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;
|
||||
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
||||
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
|
||||
}
|
||||
|
||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||
|
||||
@ -289,7 +289,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return $diff;
|
||||
})
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('scope_tags_diff')
|
||||
@ -298,23 +298,18 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant) {
|
||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
||||
|
||||
$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;
|
||||
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
||||
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
|
||||
}
|
||||
|
||||
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
||||
})
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('assignments_diff')
|
||||
@ -323,23 +318,18 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant) {
|
||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
[$baselineVersion, $currentVersion] = static::resolveDriftDiffVersions($record, $tenant);
|
||||
|
||||
$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;
|
||||
if (! static::hasRequiredDiffVersions($record, $baselineVersion, $currentVersion)) {
|
||||
return static::unavailableDiffState('Diff unavailable — referenced policy versions are missing.');
|
||||
}
|
||||
|
||||
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
||||
})
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
||||
->visible(fn (Finding $record): bool => static::canRenderDriftDiff($record) && Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsed()
|
||||
@ -357,6 +347,85 @@ public static function infolist(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
private static function driftChangeType(Finding $record): string
|
||||
{
|
||||
$changeType = Arr::get($record->evidence_jsonb ?? [], 'change_type');
|
||||
|
||||
return is_string($changeType) ? trim($changeType) : '';
|
||||
}
|
||||
|
||||
private static function hasBaselinePolicyVersionReference(Finding $record): bool
|
||||
{
|
||||
return is_numeric(Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id'));
|
||||
}
|
||||
|
||||
private static function hasCurrentPolicyVersionReference(Finding $record): bool
|
||||
{
|
||||
return is_numeric(Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id'));
|
||||
}
|
||||
|
||||
private static function canRenderDriftDiff(Finding $record): bool
|
||||
{
|
||||
return match (static::driftChangeType($record)) {
|
||||
'missing_policy' => static::hasBaselinePolicyVersionReference($record),
|
||||
'unexpected_policy' => static::hasCurrentPolicyVersionReference($record),
|
||||
default => static::hasBaselinePolicyVersionReference($record) && static::hasCurrentPolicyVersionReference($record),
|
||||
};
|
||||
}
|
||||
|
||||
private static function driftDiffUnavailableMessage(Finding $record): string
|
||||
{
|
||||
return match (static::driftChangeType($record)) {
|
||||
'missing_policy' => 'Diff unavailable — missing baseline policy version reference.',
|
||||
'unexpected_policy' => 'Diff unavailable — missing current policy version reference.',
|
||||
default => 'Diff unavailable — missing baseline/current policy version references.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?PolicyVersion, 1: ?PolicyVersion}
|
||||
*/
|
||||
private static function resolveDriftDiffVersions(Finding $record, Tenant $tenant): array
|
||||
{
|
||||
$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 [$baselineVersion, $currentVersion];
|
||||
}
|
||||
|
||||
private static function hasRequiredDiffVersions(
|
||||
Finding $record,
|
||||
?PolicyVersion $baselineVersion,
|
||||
?PolicyVersion $currentVersion,
|
||||
): bool {
|
||||
return match (static::driftChangeType($record)) {
|
||||
'missing_policy' => $baselineVersion instanceof PolicyVersion,
|
||||
'unexpected_policy' => $currentVersion instanceof PolicyVersion,
|
||||
default => $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summary: array{message: string}, added: array<int, mixed>, removed: array<int, mixed>, changed: array<int, mixed>}
|
||||
*/
|
||||
private static function unavailableDiffState(string $message): array
|
||||
{
|
||||
return [
|
||||
'summary' => ['message' => $message],
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'changed' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -44,4 +44,3 @@ public static function forRun(OperationRun $run): ?array
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -18,9 +18,7 @@ class Login extends BaseLogin
|
||||
* Filament's base login page uses Livewire-level rate limiting. We override it
|
||||
* to enforce the System panel policy via Laravel's RateLimiter (SR-003).
|
||||
*/
|
||||
protected function rateLimit($maxAttempts, $decaySeconds = 60, $method = null, $component = null): void
|
||||
{
|
||||
}
|
||||
protected function rateLimit($maxAttempts, $decaySeconds = 60, $method = null, $component = null): void {}
|
||||
|
||||
public function authenticate(): ?LoginResponse
|
||||
{
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
@ -56,50 +56,50 @@ protected function getViewData(): array
|
||||
];
|
||||
}
|
||||
|
||||
$latestDriftSuccess = OperationRun::query()
|
||||
$latestBaselineCompareSuccess = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('type', 'baseline_compare')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'succeeded')
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->first();
|
||||
|
||||
if (! $latestDriftSuccess) {
|
||||
if (! $latestBaselineCompareSuccess) {
|
||||
$items[] = [
|
||||
'title' => 'No drift scan yet',
|
||||
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
'title' => 'No baseline compare yet',
|
||||
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
} else {
|
||||
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||
|
||||
if ($isStale) {
|
||||
$items[] = [
|
||||
'title' => 'Drift stale',
|
||||
'body' => 'Last drift scan is older than 7 days.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
'title' => 'Baseline compare stale',
|
||||
'body' => 'Last baseline compare is older than 7 days.',
|
||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'badge' => 'Drift',
|
||||
'badgeColor' => 'warning',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$latestDriftFailure = OperationRun::query()
|
||||
$latestBaselineCompareFailure = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('type', 'baseline_compare')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'failed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($latestDriftFailure instanceof OperationRun) {
|
||||
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
||||
$items[] = [
|
||||
'title' => 'Drift generation failed',
|
||||
'title' => 'Baseline compare failed',
|
||||
'body' => 'Investigate the latest failed run.',
|
||||
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
||||
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
||||
'badge' => 'Operations',
|
||||
'badgeColor' => 'danger',
|
||||
];
|
||||
@ -133,12 +133,12 @@ protected function getViewData(): array
|
||||
'linkLabel' => 'View findings',
|
||||
],
|
||||
[
|
||||
'title' => 'Drift scans are up to date',
|
||||
'body' => $latestDriftSuccess?->completed_at
|
||||
? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
||||
: 'Drift scan history is available in Drift.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
'linkLabel' => 'Open Drift',
|
||||
'title' => 'Baseline compares are up to date',
|
||||
'body' => $latestBaselineCompareSuccess?->completed_at
|
||||
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
||||
: 'Baseline compare history is available in Baseline Compare.',
|
||||
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
'linkLabel' => 'Open Baseline Compare',
|
||||
],
|
||||
[
|
||||
'title' => 'No active operations',
|
||||
|
||||
@ -32,4 +32,3 @@ private function systemCookieName(): string
|
||||
return Str::slug((string) config('app.name', 'laravel')).'-system-session';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -63,7 +63,6 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->baselineHighDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->slaDueEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->baselineCompareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||
@ -292,43 +291,6 @@ private function baselineHighDriftEvents(int $workspaceId, CarbonImmutable $wind
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$failedRuns = OperationRun::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereNotNull('tenant_id')
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->where('created_at', '>', $windowStart)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($failedRuns as $failedRun) {
|
||||
$tenantId = (int) ($failedRun->tenant_id ?? 0);
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'event_type' => 'compare_failed',
|
||||
'tenant_id' => $tenantId,
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
|
||||
'title' => 'Drift compare failed',
|
||||
'body' => $this->firstFailureMessage($failedRun, 'A drift compare operation run failed.'),
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $failedRun->getKey(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -101,7 +102,16 @@ public function handle(
|
||||
$rolloutGate->assertEnabled();
|
||||
}
|
||||
|
||||
$inventoryResult = $this->collectInventorySubjects($sourceTenant, $effectiveScope);
|
||||
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant);
|
||||
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun
|
||||
? (int) $latestInventorySyncRun->getKey()
|
||||
: null;
|
||||
|
||||
$inventoryResult = $this->collectInventorySubjects(
|
||||
sourceTenant: $sourceTenant,
|
||||
scope: $effectiveScope,
|
||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||
);
|
||||
|
||||
$subjects = $inventoryResult['subjects'];
|
||||
$inventoryByKey = $inventoryResult['inventory_by_key'];
|
||||
@ -116,6 +126,7 @@ public function handle(
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: $subjectsTotal,
|
||||
effectiveScope: $effectiveScope,
|
||||
inventorySyncRunId: $latestInventorySyncRunId,
|
||||
);
|
||||
|
||||
$phaseStats = [
|
||||
@ -161,7 +172,7 @@ public function handle(
|
||||
tenant: $sourceTenant,
|
||||
subjects: $subjects,
|
||||
since: null,
|
||||
latestInventorySyncRunId: null,
|
||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||
);
|
||||
|
||||
$snapshotItems = $this->buildSnapshotItems(
|
||||
@ -224,6 +235,7 @@ public function handle(
|
||||
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
|
||||
[
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'inventory_sync_run_id' => $latestInventorySyncRunId,
|
||||
'evidence_capture' => $phaseStats,
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
@ -248,6 +260,7 @@ public function handle(
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: $subjectsTotal,
|
||||
inventorySyncRunId: $latestInventorySyncRunId,
|
||||
wasNewSnapshot: $wasNewSnapshot,
|
||||
evidenceCaptureStats: $phaseStats,
|
||||
gaps: [
|
||||
@ -276,10 +289,15 @@ public function handle(
|
||||
private function collectInventorySubjects(
|
||||
Tenant $sourceTenant,
|
||||
BaselineScope $scope,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
|
||||
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||
}
|
||||
|
||||
$query->whereIn('policy_type', $scope->allTypes());
|
||||
|
||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, display_name: ?string, category: ?string, platform: ?string}> $inventoryByKey */
|
||||
@ -520,6 +538,7 @@ private function auditStarted(
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
BaselineScope $effectiveScope,
|
||||
?int $inventorySyncRunId,
|
||||
): void {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -531,6 +550,7 @@ private function auditStarted(
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'capture_mode' => $captureMode->value,
|
||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||
'scope_types_total' => count($effectiveScope->allTypes()),
|
||||
'subjects_total' => $subjectsTotal,
|
||||
],
|
||||
@ -551,6 +571,7 @@ private function auditCompleted(
|
||||
?User $initiator,
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
?int $inventorySyncRunId,
|
||||
bool $wasNewSnapshot,
|
||||
array $evidenceCaptureStats,
|
||||
array $gaps,
|
||||
@ -565,6 +586,7 @@ private function auditCompleted(
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'capture_mode' => $captureMode->value,
|
||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
||||
@ -603,4 +625,17 @@ private function mergeGapCounts(array ...$gaps): array
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::InventorySync->value)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $run instanceof OperationRun ? $run : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -18,9 +19,16 @@
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
@ -46,6 +54,11 @@ class CompareBaselineToTenantJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $baselineContentHashCache = [];
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
@ -72,6 +85,7 @@ public function handle(
|
||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
||||
): void {
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
@ -79,6 +93,7 @@ public function handle(
|
||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
||||
@ -315,6 +330,7 @@ public function handle(
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
];
|
||||
$phaseResult = [];
|
||||
$phaseGaps = [];
|
||||
$resumeToken = null;
|
||||
|
||||
@ -354,6 +370,11 @@ public function handle(
|
||||
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
);
|
||||
|
||||
$resolvedCurrentEvidenceByExternalId = array_replace(
|
||||
$resolvedCurrentEvidenceByExternalId,
|
||||
$this->resolveCapturedCurrentEvidenceByExternalId($phaseResult),
|
||||
);
|
||||
|
||||
$resolvedCurrentMetaEvidenceByExternalId = $metaEvidenceProvider->resolve(
|
||||
tenant: $tenant,
|
||||
subjects: $subjects,
|
||||
@ -378,11 +399,28 @@ public function handle(
|
||||
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
|
||||
);
|
||||
|
||||
$baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class);
|
||||
$driftHasher = app(DriftHasher::class);
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
|
||||
$computeResult = $this->computeDrift(
|
||||
$baselineItems,
|
||||
$currentItems,
|
||||
$resolvedEffectiveCurrentEvidence,
|
||||
$this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||
tenant: $tenant,
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
baselineSnapshotId: (int) $snapshot->getKey(),
|
||||
compareOperationRunId: (int) $this->operationRun->getKey(),
|
||||
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
baselineItems: $baselineItems,
|
||||
currentItems: $currentItems,
|
||||
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
||||
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
||||
hasher: $driftHasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
contentEvidenceProvider: $contentEvidenceProvider,
|
||||
);
|
||||
$driftResults = $computeResult['drift'];
|
||||
$driftGaps = $computeResult['evidence_gaps'];
|
||||
@ -607,6 +645,56 @@ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $r
|
||||
return $rekeyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* captured_versions?: array<string, array{
|
||||
* policy_type: string,
|
||||
* subject_external_id: string,
|
||||
* version: PolicyVersion,
|
||||
* observed_at: string,
|
||||
* observed_operation_run_id: ?int
|
||||
* }>
|
||||
* } $phaseResult
|
||||
* @return array<string, ResolvedEvidence>
|
||||
*/
|
||||
private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult): array
|
||||
{
|
||||
$capturedVersions = is_array($phaseResult['captured_versions'] ?? null)
|
||||
? $phaseResult['captured_versions']
|
||||
: [];
|
||||
|
||||
if ($capturedVersions === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contentEvidenceProvider = app(ContentEvidenceProvider::class);
|
||||
$resolved = [];
|
||||
|
||||
foreach ($capturedVersions as $key => $capturedVersion) {
|
||||
$version = $capturedVersion['version'] ?? null;
|
||||
$subjectExternalId = trim((string) ($capturedVersion['subject_external_id'] ?? ''));
|
||||
$observedAt = $capturedVersion['observed_at'] ?? null;
|
||||
$observedAt = is_string($observedAt) && $observedAt !== ''
|
||||
? CarbonImmutable::parse($observedAt)
|
||||
: null;
|
||||
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
|
||||
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
|
||||
|
||||
if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved[$key] = $contentEvidenceProvider->fromPolicyVersion(
|
||||
version: $version,
|
||||
subjectExternalId: $subjectExternalId,
|
||||
observedAt: $observedAt,
|
||||
observedOperationRunId: $observedOperationRunId,
|
||||
);
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function completeWithCoverageWarning(
|
||||
OperationRunService $operationRunService,
|
||||
AuditLogger $auditLogger,
|
||||
@ -917,31 +1005,94 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
* evidence_gaps: array<string, int>
|
||||
* }
|
||||
*/
|
||||
private function computeDrift(array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping): array
|
||||
{
|
||||
private function computeDrift(
|
||||
Tenant $tenant,
|
||||
int $baselineProfileId,
|
||||
int $baselineSnapshotId,
|
||||
int $compareOperationRunId,
|
||||
int $inventorySyncRunId,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
DriftHasher $hasher,
|
||||
SettingsNormalizer $settingsNormalizer,
|
||||
AssignmentsNormalizer $assignmentsNormalizer,
|
||||
ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): array {
|
||||
$drift = [];
|
||||
$missingCurrentEvidence = 0;
|
||||
|
||||
$baselinePlaceholderProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: null,
|
||||
);
|
||||
|
||||
$currentMissingProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
foreach ($baselineItems as $key => $baselineItem) {
|
||||
$currentItem = $currentItems[$key] ?? null;
|
||||
|
||||
$policyType = (string) ($baselineItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
|
||||
|
||||
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
|
||||
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
||||
);
|
||||
$baselineComparableHash = $this->effectiveBaselineHash(
|
||||
tenant: $tenant,
|
||||
baselineItem: $baselineItem,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
contentEvidenceProvider: $contentEvidenceProvider,
|
||||
);
|
||||
|
||||
if (! is_array($currentItem)) {
|
||||
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'missing_policy',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: $baselineComparableHash,
|
||||
currentHash: null,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
currentProvenance: $currentMissingProvenance,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: null,
|
||||
summaryKind: 'policy_snapshot',
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'missing_policy',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'evidence_fidelity' => EvidenceProvenance::FidelityMeta,
|
||||
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => $baselineComparableHash,
|
||||
'current_hash' => '',
|
||||
'evidence' => [
|
||||
'change_type' => 'missing_policy',
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
||||
],
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
|
||||
continue;
|
||||
@ -955,39 +1106,54 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($baselineItem['baseline_hash'] !== $currentEvidence->hash) {
|
||||
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb']);
|
||||
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
||||
$evidenceFidelity = EvidenceProvenance::weakerFidelity($baselineFidelity, $currentEvidence->fidelity);
|
||||
if ($baselineComparableHash !== $currentEvidence->hash) {
|
||||
$displayName = $currentItem['meta_jsonb']['display_name']
|
||||
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
||||
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
$summaryKind = $this->selectSummaryKind(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
hasher: $hasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
);
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'different_version',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: $baselineComparableHash,
|
||||
currentHash: (string) $currentEvidence->hash,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
currentProvenance: $currentEvidence->tenantProvenance(),
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
summaryKind: $summaryKind,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'different_version',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'evidence_fidelity' => $evidenceFidelity,
|
||||
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => $baselineComparableHash,
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => [
|
||||
'change_type' => 'different_version',
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'display_name' => $displayName,
|
||||
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'baseline' => [
|
||||
'hash' => $baselineItem['baseline_hash'],
|
||||
'provenance' => $baselineProvenance,
|
||||
],
|
||||
'current' => [
|
||||
'hash' => $currentEvidence->hash,
|
||||
'provenance' => $currentEvidence->tenantProvenance(),
|
||||
],
|
||||
],
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1002,22 +1168,43 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
|
||||
|
||||
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'unexpected_policy',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: null,
|
||||
currentHash: (string) $currentEvidence->hash,
|
||||
baselineProvenance: $baselinePlaceholderProvenance,
|
||||
currentProvenance: $currentEvidence->tenantProvenance(),
|
||||
baselinePolicyVersionId: null,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
summaryKind: 'policy_snapshot',
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $currentItem['subject_key'],
|
||||
'policy_type' => $currentItem['policy_type'],
|
||||
'evidence_fidelity' => $currentEvidence->fidelity,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => '',
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'policy_type' => $currentItem['policy_type'],
|
||||
'subject_key' => $currentItem['subject_key'],
|
||||
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
|
||||
],
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1030,6 +1217,222 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
|
||||
*/
|
||||
private function effectiveBaselineHash(
|
||||
Tenant $tenant,
|
||||
array $baselineItem,
|
||||
?int $baselinePolicyVersionId,
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): string {
|
||||
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
|
||||
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
return $storedHash;
|
||||
}
|
||||
|
||||
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
|
||||
return $this->baselineContentHashCache[$baselinePolicyVersionId];
|
||||
}
|
||||
|
||||
$baselineVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($baselinePolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion) {
|
||||
return $storedHash;
|
||||
}
|
||||
|
||||
$hash = $contentEvidenceProvider->fromPolicyVersion(
|
||||
version: $baselineVersion,
|
||||
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
||||
)->hash;
|
||||
|
||||
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
private function resolveBaselinePolicyVersionId(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
array $baselineProvenance,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
): ?int {
|
||||
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
||||
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
|
||||
|
||||
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $baselineProvenance['observed_at'] ?? null;
|
||||
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
|
||||
|
||||
if (! is_string($observedAt) || $observedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $baselinePolicyVersionResolver->resolve(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
observedAt: $observedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
|
||||
{
|
||||
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
|
||||
|
||||
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
|
||||
}
|
||||
|
||||
private function selectSummaryKind(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
DriftHasher $hasher,
|
||||
SettingsNormalizer $settingsNormalizer,
|
||||
AssignmentsNormalizer $assignmentsNormalizer,
|
||||
ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
): string {
|
||||
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($baselinePolicyVersionId);
|
||||
|
||||
$currentVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($currentPolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$platform = is_string($baselineVersion->platform ?? null)
|
||||
? (string) $baselineVersion->platform
|
||||
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
|
||||
|
||||
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
|
||||
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
|
||||
|
||||
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineSnapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
$currentNormalized = $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $currentSnapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
|
||||
$baselineSnapshotHash = $hasher->hashNormalized($baselineNormalized);
|
||||
$currentSnapshotHash = $hasher->hashNormalized($currentNormalized);
|
||||
|
||||
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
||||
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
||||
|
||||
$baselineAssignmentsHash = $hasher->hashNormalized($assignmentsNormalizer->normalizeForDiff($baselineAssignments));
|
||||
$currentAssignmentsHash = $hasher->hashNormalized($assignmentsNormalizer->normalizeForDiff($currentAssignments));
|
||||
|
||||
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
||||
return 'policy_assignments';
|
||||
}
|
||||
|
||||
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
||||
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
||||
|
||||
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineScopeTagsHash = $hasher->hashNormalized($baselineScopeTagIds);
|
||||
$currentScopeTagsHash = $hasher->hashNormalized($currentScopeTagIds);
|
||||
|
||||
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
|
||||
return 'policy_scope_tags';
|
||||
}
|
||||
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
|
||||
* @param array<string, mixed> $currentProvenance
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildDriftEvidenceContract(
|
||||
string $changeType,
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
?string $displayName,
|
||||
?string $baselineHash,
|
||||
?string $currentHash,
|
||||
array $baselineProvenance,
|
||||
array $currentProvenance,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
string $summaryKind,
|
||||
int $baselineProfileId,
|
||||
int $baselineSnapshotId,
|
||||
int $compareOperationRunId,
|
||||
int $inventorySyncRunId,
|
||||
): array {
|
||||
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
|
||||
|
||||
return [
|
||||
'change_type' => $changeType,
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'display_name' => $displayName,
|
||||
'summary' => [
|
||||
'kind' => $summaryKind,
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => $baselinePolicyVersionId,
|
||||
'hash' => $baselineHash,
|
||||
'provenance' => $baselineProvenance,
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => $currentPolicyVersionId,
|
||||
'hash' => $currentHash,
|
||||
'provenance' => $currentProvenance,
|
||||
],
|
||||
'fidelity' => $fidelity,
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
'baseline_snapshot_id' => $baselineSnapshotId,
|
||||
'compare_operation_run_id' => $compareOperationRunId,
|
||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
||||
{
|
||||
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
|
||||
return 'content';
|
||||
}
|
||||
|
||||
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> ...$gaps
|
||||
* @return array<string, int>
|
||||
@ -1270,6 +1673,7 @@ private function upsertFindings(
|
||||
$reopenedCount = 0;
|
||||
$unchangedCount = 0;
|
||||
$seenFingerprints = [];
|
||||
$slaPolicy = app(FindingSlaPolicy::class);
|
||||
|
||||
foreach ($driftResults as $driftItem) {
|
||||
$subjectKey = (string) ($driftItem['subject_key'] ?? '');
|
||||
@ -1324,6 +1728,9 @@ private function upsertFindings(
|
||||
]);
|
||||
|
||||
if ($isNewFinding) {
|
||||
$severity = (string) $driftItem['severity'];
|
||||
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'reopened_at' => null,
|
||||
@ -1334,21 +1741,36 @@ private function upsertFindings(
|
||||
'first_seen_at' => $observedAt,
|
||||
'last_seen_at' => $observedAt,
|
||||
'times_seen' => 1,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
|
||||
$createdCount++;
|
||||
} elseif (Finding::isTerminalStatus($finding->status)) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'reopened_at' => now(),
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => null,
|
||||
'closed_reason' => null,
|
||||
'closed_by_user_id' => null,
|
||||
]);
|
||||
} elseif ((string) $finding->status === Finding::STATUS_RESOLVED) {
|
||||
$resolvedAt = $finding->resolved_at !== null
|
||||
? CarbonImmutable::instance($finding->resolved_at)
|
||||
: null;
|
||||
|
||||
$reopenedCount++;
|
||||
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
|
||||
$severity = (string) $driftItem['severity'];
|
||||
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'reopened_at' => $observedAt,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => null,
|
||||
'closed_reason' => null,
|
||||
'closed_by_user_id' => null,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
|
||||
$reopenedCount++;
|
||||
} else {
|
||||
$unchangedCount++;
|
||||
}
|
||||
} else {
|
||||
$unchangedCount++;
|
||||
}
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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 ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $baselineRunId,
|
||||
public int $currentRunId,
|
||||
public string $scopeKey,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
DriftFindingGenerator $generator,
|
||||
OperationRunService $runs,
|
||||
TargetScopeConcurrencyLimiter $limiter,
|
||||
): void {
|
||||
Log::info('GenerateDriftFindingsJob: started', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_operation_run_id' => $this->baselineRunId,
|
||||
'current_operation_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
]);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for drift generation.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$baseline = OperationRun::query()
|
||||
->whereKey($this->baselineRunId)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->first();
|
||||
if (! $baseline instanceof OperationRun) {
|
||||
throw new RuntimeException('Baseline run not found.');
|
||||
}
|
||||
|
||||
$current = OperationRun::query()
|
||||
->whereKey($this->currentRunId)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->first();
|
||||
if (! $current instanceof OperationRun) {
|
||||
throw new RuntimeException('Current run not found.');
|
||||
}
|
||||
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$counts = is_array($this->operationRun->summary_counts ?? null) ? $this->operationRun->summary_counts : [];
|
||||
if ((int) ($counts['total'] ?? 0) === 0) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
|
||||
}
|
||||
|
||||
$created = $generator->generate(
|
||||
tenant: $tenant,
|
||||
baseline: $baseline,
|
||||
current: $current,
|
||||
scopeKey: $this->scopeKey,
|
||||
);
|
||||
|
||||
Log::info('GenerateDriftFindingsJob: completed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_operation_run_id' => $this->baselineRunId,
|
||||
'current_operation_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'created_findings_count' => $created,
|
||||
]);
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'created' => $created,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('GenerateDriftFindingsJob: failed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_operation_run_id' => $this->baselineRunId,
|
||||
'current_operation_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'drift_generate_findings.failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@ -38,4 +38,3 @@ public function acknowledgedByUser(): BelongsTo
|
||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
@ -25,7 +26,14 @@ public function __construct(
|
||||
* @return array{
|
||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||
* gaps: array<string, int>,
|
||||
* resume_token: ?string
|
||||
* resume_token: ?string,
|
||||
* captured_versions: array<string, array{
|
||||
* policy_type: string,
|
||||
* subject_external_id: string,
|
||||
* version: PolicyVersion,
|
||||
* observed_at: string,
|
||||
* observed_operation_run_id: ?int
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public function capture(
|
||||
@ -68,6 +76,7 @@ public function capture(
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
$capturedVersions = [];
|
||||
|
||||
/**
|
||||
* @var array<string, true> $seen
|
||||
@ -140,6 +149,18 @@ public function capture(
|
||||
if (! (is_array($result) && array_key_exists('failure', $result))) {
|
||||
$stats['succeeded']++;
|
||||
|
||||
$version = $result['version'] ?? null;
|
||||
|
||||
if ($version instanceof PolicyVersion) {
|
||||
$capturedVersions[$subjectKey] = [
|
||||
'policy_type' => $policyType,
|
||||
'subject_external_id' => $externalId,
|
||||
'version' => $version,
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
'observed_operation_run_id' => $operationRunId,
|
||||
];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@ -190,6 +211,7 @@ public function capture(
|
||||
'stats' => $stats,
|
||||
'gaps' => $gaps,
|
||||
'resume_token' => $resumeTokenOut,
|
||||
'captured_versions' => $capturedVersions,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines\Evidence;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Throwable;
|
||||
|
||||
final class BaselinePolicyVersionResolver
|
||||
{
|
||||
/**
|
||||
* Cached map of (tenant_id, policy_type) => subject_key => policy_id.
|
||||
*
|
||||
* @var array<int, array<string, array<string, int>>>
|
||||
*/
|
||||
private array $policyIdIndex = [];
|
||||
|
||||
public function resolve(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
?string $observedAt,
|
||||
): ?int {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$policyType = trim($policyType);
|
||||
$subjectKey = trim($subjectKey);
|
||||
|
||||
if ($tenantId <= 0 || $policyType === '' || $subjectKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAtCarbon = $this->parseObservedAt($observedAt);
|
||||
|
||||
if (! $observedAtCarbon instanceof CarbonImmutable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$policyId = $this->resolvePolicyId($tenantId, $policyType, $subjectKey);
|
||||
|
||||
if ($policyId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rangeStart = $observedAtCarbon;
|
||||
$rangeEnd = $observedAtCarbon->addSecond();
|
||||
|
||||
$versionId = PolicyVersion::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('policy_id', $policyId)
|
||||
->whereNull('deleted_at')
|
||||
->where('captured_at', '>=', $rangeStart)
|
||||
->where('captured_at', '<', $rangeEnd)
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('version_number')
|
||||
->orderByDesc('id')
|
||||
->value('id');
|
||||
|
||||
return is_numeric($versionId) ? (int) $versionId : null;
|
||||
}
|
||||
|
||||
private function parseObservedAt(?string $observedAt): ?CarbonImmutable
|
||||
{
|
||||
if (! is_string($observedAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = trim($observedAt);
|
||||
|
||||
if ($observedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return CarbonImmutable::parse($observedAt);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePolicyId(int $tenantId, string $policyType, string $subjectKey): ?int
|
||||
{
|
||||
if (! array_key_exists($tenantId, $this->policyIdIndex) || ! array_key_exists($policyType, $this->policyIdIndex[$tenantId])) {
|
||||
$this->policyIdIndex[$tenantId][$policyType] = $this->buildIndex($tenantId, $policyType);
|
||||
}
|
||||
|
||||
$policyId = $this->policyIdIndex[$tenantId][$policyType][$subjectKey] ?? null;
|
||||
|
||||
return is_numeric($policyId) ? (int) $policyId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a subject_key => policy_id map for a given tenant + policy_type.
|
||||
*
|
||||
* If multiple policies map to the same subject_key, that key is treated as ambiguous and excluded.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function buildIndex(int $tenantId, string $policyType): array
|
||||
{
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('policy_type', $policyType)
|
||||
->get(['id', 'display_name']);
|
||||
|
||||
/** @var array<string, int> $index */
|
||||
$index = [];
|
||||
|
||||
/** @var array<string, true> $ambiguous */
|
||||
$ambiguous = [];
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
if (! $policy instanceof Policy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = BaselineSubjectKey::fromDisplayName($policy->display_name);
|
||||
|
||||
if ($key === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($key, $index)) {
|
||||
$ambiguous[$key] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$index[$key] = (int) $policy->getKey();
|
||||
}
|
||||
|
||||
foreach (array_keys($ambiguous) as $key) {
|
||||
unset($index[$key]);
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
@ -5,12 +5,13 @@
|
||||
namespace App\Services\Baselines\Evidence;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\CurrentStateEvidenceProvider;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -29,6 +30,28 @@ public function name(): string
|
||||
return 'policy_version';
|
||||
}
|
||||
|
||||
public function fromPolicyVersion(
|
||||
PolicyVersion $version,
|
||||
string $subjectExternalId,
|
||||
?CarbonImmutable $observedAt = null,
|
||||
?int $observedOperationRunId = null,
|
||||
): ResolvedEvidence {
|
||||
return $this->buildResolvedEvidence(
|
||||
policyType: (string) $version->policy_type,
|
||||
subjectExternalId: $subjectExternalId,
|
||||
platform: is_string($version->platform) ? $version->platform : null,
|
||||
snapshot: $version->snapshot,
|
||||
assignments: $version->assignments,
|
||||
scopeTags: $version->scope_tags,
|
||||
capturedAt: $version->captured_at,
|
||||
policyVersionId: (int) $version->getKey(),
|
||||
operationRunId: $version->operation_run_id,
|
||||
capturePurpose: $version->capture_purpose?->value,
|
||||
observedAt: $observedAt,
|
||||
observedOperationRunId: $observedOperationRunId,
|
||||
);
|
||||
}
|
||||
|
||||
public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since = null, ?int $latestInventorySyncRunId = null): array
|
||||
{
|
||||
if ($subjects === []) {
|
||||
@ -131,54 +154,17 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $version->snapshot ?? null;
|
||||
$snapshot = is_array($snapshot) ? $snapshot : (is_string($snapshot) ? json_decode($snapshot, true) : null);
|
||||
$snapshot = is_array($snapshot) ? $snapshot : [];
|
||||
|
||||
$assignments = $version->assignments ?? null;
|
||||
$assignments = is_array($assignments) ? $assignments : (is_string($assignments) ? json_decode($assignments, true) : null);
|
||||
$assignments = is_array($assignments) ? $assignments : [];
|
||||
|
||||
$scopeTags = $version->scope_tags ?? null;
|
||||
$scopeTags = is_array($scopeTags) ? $scopeTags : (is_string($scopeTags) ? json_decode($scopeTags, true) : null);
|
||||
$scopeTags = is_array($scopeTags) ? $scopeTags : [];
|
||||
|
||||
$platform = is_string($version->platform ?? null) ? (string) $version->platform : null;
|
||||
|
||||
$normalized = $this->settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $snapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
|
||||
$normalizedAssignments = $this->assignmentsNormalizer->normalizeForDiff($assignments);
|
||||
$normalizedScopeTagIds = $this->scopeTagsNormalizer->normalizeIds($scopeTags);
|
||||
|
||||
$hash = $this->hasher->hashNormalized([
|
||||
'settings' => $normalized,
|
||||
'assignments' => $normalizedAssignments,
|
||||
'scope_tag_ids' => $normalizedScopeTagIds,
|
||||
]);
|
||||
|
||||
$observedAt = is_string($version->captured_at ?? null) ? CarbonImmutable::parse((string) $version->captured_at) : null;
|
||||
$policyVersionId = is_numeric($version->id ?? null) ? (int) $version->id : null;
|
||||
$observedOperationRunId = is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null;
|
||||
$capturePurpose = is_string($version->capture_purpose ?? null) ? trim((string) $version->capture_purpose) : null;
|
||||
$capturePurpose = $capturePurpose !== '' ? $capturePurpose : null;
|
||||
|
||||
$resolved[$key] = new ResolvedEvidence(
|
||||
$resolved[$key] = $this->buildResolvedEvidence(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: $subjectExternalId,
|
||||
hash: $hash,
|
||||
fidelity: EvidenceProvenance::FidelityContent,
|
||||
source: EvidenceProvenance::SourcePolicyVersion,
|
||||
observedAt: $observedAt,
|
||||
observedOperationRunId: $observedOperationRunId,
|
||||
meta: [
|
||||
'policy_version_id' => $policyVersionId,
|
||||
'operation_run_id' => $observedOperationRunId,
|
||||
'capture_purpose' => $capturePurpose,
|
||||
],
|
||||
platform: is_string($version->platform ?? null) ? (string) $version->platform : null,
|
||||
snapshot: $version->snapshot ?? null,
|
||||
assignments: $version->assignments ?? null,
|
||||
scopeTags: $version->scope_tags ?? null,
|
||||
capturedAt: $version->captured_at ?? null,
|
||||
policyVersionId: is_numeric($version->id ?? null) ? (int) $version->id : null,
|
||||
operationRunId: is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null,
|
||||
capturePurpose: is_string($version->capture_purpose ?? null) ? trim((string) $version->capture_purpose) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -206,4 +192,63 @@ private function requestedKeys(array $subjects): array
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function buildResolvedEvidence(
|
||||
string $policyType,
|
||||
string $subjectExternalId,
|
||||
?string $platform,
|
||||
mixed $snapshot,
|
||||
mixed $assignments,
|
||||
mixed $scopeTags,
|
||||
mixed $capturedAt,
|
||||
?int $policyVersionId,
|
||||
mixed $operationRunId,
|
||||
?string $capturePurpose,
|
||||
?CarbonImmutable $observedAt = null,
|
||||
?int $observedOperationRunId = null,
|
||||
): ResolvedEvidence {
|
||||
$snapshot = is_array($snapshot) ? $snapshot : (is_string($snapshot) ? json_decode($snapshot, true) : null);
|
||||
$snapshot = is_array($snapshot) ? $snapshot : [];
|
||||
|
||||
$assignments = is_array($assignments) ? $assignments : (is_string($assignments) ? json_decode($assignments, true) : null);
|
||||
$assignments = is_array($assignments) ? $assignments : [];
|
||||
|
||||
$scopeTags = is_array($scopeTags) ? $scopeTags : (is_string($scopeTags) ? json_decode($scopeTags, true) : null);
|
||||
$scopeTags = is_array($scopeTags) ? $scopeTags : [];
|
||||
|
||||
$normalized = $this->settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $snapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
|
||||
$normalizedAssignments = $this->assignmentsNormalizer->normalizeForDiff($assignments);
|
||||
$normalizedScopeTagIds = $this->scopeTagsNormalizer->normalizeIds($scopeTags);
|
||||
|
||||
$hash = $this->hasher->hashNormalized([
|
||||
'settings' => $normalized,
|
||||
'assignments' => $normalizedAssignments,
|
||||
'scope_tag_ids' => $normalizedScopeTagIds,
|
||||
]);
|
||||
|
||||
$observedAt ??= is_string($capturedAt) ? CarbonImmutable::parse($capturedAt) : null;
|
||||
$observedOperationRunId ??= is_numeric($operationRunId) ? (int) $operationRunId : null;
|
||||
$capturePurpose = is_string($capturePurpose) ? trim($capturePurpose) : null;
|
||||
$capturePurpose = $capturePurpose !== '' ? $capturePurpose : null;
|
||||
|
||||
return new ResolvedEvidence(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: $subjectExternalId,
|
||||
hash: $hash,
|
||||
fidelity: EvidenceProvenance::FidelityContent,
|
||||
source: EvidenceProvenance::SourcePolicyVersion,
|
||||
observedAt: $observedAt,
|
||||
observedOperationRunId: $observedOperationRunId,
|
||||
meta: [
|
||||
'policy_version_id' => $policyVersionId,
|
||||
'operation_run_id' => is_numeric($operationRunId) ? (int) $operationRunId : null,
|
||||
'capture_purpose' => $capturePurpose,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,483 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
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,
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
private readonly FindingSlaPolicy $slaPolicy,
|
||||
) {}
|
||||
|
||||
public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
|
||||
{
|
||||
if (! $baseline->completed_at || ! $current->completed_at) {
|
||||
throw new RuntimeException('Baseline/current run must be finished.');
|
||||
}
|
||||
|
||||
$observedAt = CarbonImmutable::instance($current->completed_at);
|
||||
|
||||
/** @var array<string, mixed> $selection */
|
||||
$selection = is_array($current->context) ? $current->context : [];
|
||||
|
||||
$policyTypes = Arr::get($selection, 'policy_types');
|
||||
if (! is_array($policyTypes)) {
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
||||
|
||||
$created = 0;
|
||||
$resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT);
|
||||
$seenRecurrenceKeys = [];
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->orderBy('id')
|
||||
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, $observedAt, &$seenRecurrenceKeys, &$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',
|
||||
};
|
||||
|
||||
$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,
|
||||
],
|
||||
];
|
||||
|
||||
$dimension = $this->recurrenceDimension('policy_snapshot', $changeType);
|
||||
$wasNew = $this->upsertDriftFinding(
|
||||
tenant: $tenant,
|
||||
baseline: $baseline,
|
||||
current: $current,
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'policy',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
severity: $resolvedSeverity,
|
||||
dimension: $dimension,
|
||||
rawEvidence: $rawEvidence,
|
||||
observedAt: $observedAt,
|
||||
seenRecurrenceKeys: $seenRecurrenceKeys,
|
||||
);
|
||||
|
||||
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) {
|
||||
$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,
|
||||
],
|
||||
];
|
||||
|
||||
$dimension = $this->recurrenceDimension('policy_assignments', 'modified');
|
||||
$wasNew = $this->upsertDriftFinding(
|
||||
tenant: $tenant,
|
||||
baseline: $baseline,
|
||||
current: $current,
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'assignment',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
severity: $resolvedSeverity,
|
||||
dimension: $dimension,
|
||||
rawEvidence: $rawEvidence,
|
||||
observedAt: $observedAt,
|
||||
seenRecurrenceKeys: $seenRecurrenceKeys,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$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,
|
||||
],
|
||||
];
|
||||
|
||||
$dimension = $this->recurrenceDimension('policy_scope_tags', 'modified');
|
||||
$wasNew = $this->upsertDriftFinding(
|
||||
tenant: $tenant,
|
||||
baseline: $baseline,
|
||||
current: $current,
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'scope_tag',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
severity: $resolvedSeverity,
|
||||
dimension: $dimension,
|
||||
rawEvidence: $rawEvidence,
|
||||
observedAt: $observedAt,
|
||||
seenRecurrenceKeys: $seenRecurrenceKeys,
|
||||
);
|
||||
|
||||
if ($wasNew) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->resolveStaleDriftFindings(
|
||||
tenant: $tenant,
|
||||
scopeKey: $scopeKey,
|
||||
seenRecurrenceKeys: $seenRecurrenceKeys,
|
||||
observedAt: $observedAt,
|
||||
);
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function recurrenceDimension(string $kind, string $changeType): string
|
||||
{
|
||||
$kind = strtolower(trim($kind));
|
||||
$changeType = strtolower(trim($changeType));
|
||||
|
||||
return match ($kind) {
|
||||
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType),
|
||||
default => $kind,
|
||||
};
|
||||
}
|
||||
|
||||
private function recurrenceKey(
|
||||
int $tenantId,
|
||||
string $scopeKey,
|
||||
string $subjectType,
|
||||
string $subjectExternalId,
|
||||
string $dimension,
|
||||
): string {
|
||||
return hash('sha256', sprintf(
|
||||
'drift:%d:%s:%s:%s:%s',
|
||||
$tenantId,
|
||||
$scopeKey,
|
||||
$subjectType,
|
||||
$subjectExternalId,
|
||||
$dimension,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $seenRecurrenceKeys
|
||||
* @param array<string, mixed> $rawEvidence
|
||||
*/
|
||||
private function upsertDriftFinding(
|
||||
Tenant $tenant,
|
||||
OperationRun $baseline,
|
||||
OperationRun $current,
|
||||
string $scopeKey,
|
||||
string $subjectType,
|
||||
string $subjectExternalId,
|
||||
string $severity,
|
||||
string $dimension,
|
||||
array $rawEvidence,
|
||||
CarbonImmutable $observedAt,
|
||||
array &$seenRecurrenceKeys,
|
||||
): bool {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$recurrenceKey = $this->recurrenceKey($tenantId, $scopeKey, $subjectType, $subjectExternalId, $dimension);
|
||||
$seenRecurrenceKeys[] = $recurrenceKey;
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('recurrence_key', $recurrenceKey)
|
||||
->first();
|
||||
|
||||
$wasNew = ! $finding instanceof Finding;
|
||||
|
||||
if ($wasNew) {
|
||||
$finding = new Finding;
|
||||
} else {
|
||||
$this->observeFinding($finding, $observedAt, (int) $current->getKey());
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'tenant_id' => $tenantId,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'recurrence_key' => $recurrenceKey,
|
||||
'fingerprint' => $recurrenceKey,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_external_id' => $subjectExternalId,
|
||||
'severity' => $severity,
|
||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||
]);
|
||||
|
||||
if ($wasNew) {
|
||||
$slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
'first_seen_at' => $observedAt,
|
||||
'last_seen_at' => $observedAt,
|
||||
'times_seen' => 1,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
}
|
||||
|
||||
$status = (string) $finding->status;
|
||||
|
||||
if ($status === Finding::STATUS_RESOLVED) {
|
||||
$resolvedAt = $finding->resolved_at;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
||||
$slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'reopened_at' => $observedAt,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => null,
|
||||
'closed_reason' => null,
|
||||
'closed_by_user_id' => null,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$finding->save();
|
||||
|
||||
return $wasNew;
|
||||
}
|
||||
|
||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
|
||||
{
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
||||
$finding->last_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) {
|
||||
$finding->times_seen = max(0, $timesSeen) + 1;
|
||||
} elseif ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $seenRecurrenceKeys
|
||||
*/
|
||||
private function resolveStaleDriftFindings(
|
||||
Tenant $tenant,
|
||||
string $scopeKey,
|
||||
array $seenRecurrenceKeys,
|
||||
CarbonImmutable $observedAt,
|
||||
): void {
|
||||
$staleFindingsQuery = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->whereNotNull('recurrence_key')
|
||||
->whereIn('status', Finding::openStatusesForQuery());
|
||||
|
||||
if ($seenRecurrenceKeys !== []) {
|
||||
$staleFindingsQuery->whereNotIn('recurrence_key', $seenRecurrenceKeys);
|
||||
}
|
||||
|
||||
$staleFindings = $staleFindingsQuery->get();
|
||||
|
||||
foreach ($staleFindings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $observedAt,
|
||||
'resolved_reason' => 'no_longer_detected',
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion
|
||||
{
|
||||
if (! $run->completed_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', $policy->tenant_id)
|
||||
->where('policy_id', $policy->getKey())
|
||||
->where('captured_at', '<=', $run->completed_at)
|
||||
->latest('captured_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveSeverityForFindingType(Tenant $tenant, string $findingType): string
|
||||
{
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace && is_numeric($tenant->workspace_id)) {
|
||||
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Finding::SEVERITY_MEDIUM;
|
||||
}
|
||||
|
||||
$resolved = $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: 'drift',
|
||||
key: 'severity_mapping',
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
if (! is_array($resolved)) {
|
||||
return Finding::SEVERITY_MEDIUM;
|
||||
}
|
||||
|
||||
foreach ($resolved as $mappedFindingType => $mappedSeverity) {
|
||||
if (! is_string($mappedFindingType) || ! is_string($mappedSeverity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($mappedFindingType !== $findingType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedSeverity = strtolower($mappedSeverity);
|
||||
|
||||
if (in_array($normalizedSeverity, $this->supportedSeverities(), true)) {
|
||||
return $normalizedSeverity;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return Finding::SEVERITY_MEDIUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function supportedSeverities(): array
|
||||
{
|
||||
return [
|
||||
Finding::SEVERITY_LOW,
|
||||
Finding::SEVERITY_MEDIUM,
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
class DriftRunSelector
|
||||
{
|
||||
/**
|
||||
* @return array{baseline:OperationRun,current:OperationRun}|null
|
||||
*/
|
||||
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
||||
{
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->where('context->selection_hash', $scopeKey)
|
||||
->whereNotNull('completed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($runs->count() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $runs->first();
|
||||
$baseline = $runs->last();
|
||||
|
||||
if (! $baseline instanceof OperationRun || ! $current instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline' => $baseline,
|
||||
'current' => $current,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
|
||||
class DriftScopeKey
|
||||
{
|
||||
public function fromRun(OperationRun $run): string
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
return (string) ($context['selection_hash'] ?? '');
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,7 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
|
||||
$normalized = $this->normalize($snapshot, $policyType, $platform);
|
||||
$flat = $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
|
||||
|
||||
return array_merge($flat, $this->flattenComplianceNotificationsForDiff($snapshot));
|
||||
return array_merge($flat, $this->flattenNoncomplianceActionsForDiff($snapshot));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,62 +93,44 @@ private function buildComplianceBlocks(array $snapshot): array
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function flattenComplianceNotificationsForDiff(array $snapshot): array
|
||||
private function flattenNoncomplianceActionsForDiff(array $snapshot): array
|
||||
{
|
||||
$scheduled = $snapshot['scheduledActionsForRule'] ?? null;
|
||||
$actions = $this->canonicalNoncomplianceActions($snapshot['scheduledActionsForRule'] ?? null);
|
||||
|
||||
if (! is_array($scheduled)) {
|
||||
if ($actions === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateIds = [];
|
||||
$countsByType = [];
|
||||
|
||||
foreach ($scheduled as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configs = $rule['scheduledActionConfigurations'] ?? null;
|
||||
|
||||
if (! is_array($configs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($configs as $config) {
|
||||
if (! is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($config['actionType'] ?? null) !== 'notification') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateKey = $this->resolveNotificationTemplateKey($config);
|
||||
|
||||
if ($templateKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateId = $config[$templateKey] ?? null;
|
||||
|
||||
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateIds[] = $templateId;
|
||||
}
|
||||
foreach ($actions as $action) {
|
||||
$actionType = (string) $action['action_type'];
|
||||
$countsByType[$actionType] = ($countsByType[$actionType] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$templateIds = array_values(array_unique($templateIds));
|
||||
sort($templateIds);
|
||||
$occurrencesByType = [];
|
||||
$flat = [];
|
||||
|
||||
if ($templateIds === []) {
|
||||
return [];
|
||||
foreach ($actions as $action) {
|
||||
$actionType = (string) $action['action_type'];
|
||||
$occurrencesByType[$actionType] = ($occurrencesByType[$actionType] ?? 0) + 1;
|
||||
|
||||
$label = 'Actions for noncompliance > '.$this->actionTypeLabel($actionType);
|
||||
|
||||
if (($countsByType[$actionType] ?? 0) > 1) {
|
||||
$label .= ' #'.$occurrencesByType[$actionType];
|
||||
}
|
||||
|
||||
$ruleName = $action['rule_name'] ?? null;
|
||||
if (is_string($ruleName) && $ruleName !== '') {
|
||||
$flat[$label.' > Rule name'] = $ruleName;
|
||||
}
|
||||
|
||||
$flat[$label.' > Grace period'] = $this->formatGracePeriod($action['grace_period_hours'] ?? null);
|
||||
$flat[$label.' > Notification template ID'] = $action['notification_template_id'] ?? null;
|
||||
}
|
||||
|
||||
return [
|
||||
'Compliance notifications > Template IDs' => $templateIds,
|
||||
];
|
||||
return $flat;
|
||||
}
|
||||
|
||||
private function resolveNotificationTemplateKey(array $config): ?string
|
||||
@ -169,6 +151,146 @@ private function isEmptyGuid(string $value): bool
|
||||
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* action_type: string,
|
||||
* grace_period_hours: ?int,
|
||||
* notification_template_id: ?string,
|
||||
* rule_name: ?string
|
||||
* }>
|
||||
*/
|
||||
private function canonicalNoncomplianceActions(mixed $scheduled): array
|
||||
{
|
||||
if (! is_array($scheduled)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$actions = [];
|
||||
|
||||
foreach ($scheduled as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ruleName = $rule['ruleName'] ?? null;
|
||||
$ruleName = is_string($ruleName) ? trim($ruleName) : null;
|
||||
$ruleName = $ruleName !== '' ? $ruleName : null;
|
||||
|
||||
$configs = $rule['scheduledActionConfigurations'] ?? null;
|
||||
|
||||
if (! is_array($configs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($configs as $config) {
|
||||
if (! is_array($config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actionType = $config['actionType'] ?? null;
|
||||
$actionType = is_string($actionType) ? strtolower(trim($actionType)) : null;
|
||||
|
||||
if ($actionType === null || $actionType === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gracePeriodHours = $config['gracePeriodHours'] ?? null;
|
||||
$gracePeriodHours = is_numeric($gracePeriodHours) ? (int) $gracePeriodHours : null;
|
||||
|
||||
$actions[] = [
|
||||
'action_type' => $actionType,
|
||||
'grace_period_hours' => $gracePeriodHours,
|
||||
'notification_template_id' => $this->normalizeNotificationTemplateId($config),
|
||||
'rule_name' => $ruleName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($actions, function (array $left, array $right): int {
|
||||
$actionTypeComparison = $left['action_type'] <=> $right['action_type'];
|
||||
|
||||
if ($actionTypeComparison !== 0) {
|
||||
return $actionTypeComparison;
|
||||
}
|
||||
|
||||
$gracePeriodComparison = ($left['grace_period_hours'] ?? PHP_INT_MAX) <=> ($right['grace_period_hours'] ?? PHP_INT_MAX);
|
||||
|
||||
if ($gracePeriodComparison !== 0) {
|
||||
return $gracePeriodComparison;
|
||||
}
|
||||
|
||||
$templateComparison = ($left['notification_template_id'] ?? "\u{10FFFF}") <=> ($right['notification_template_id'] ?? "\u{10FFFF}");
|
||||
|
||||
if ($templateComparison !== 0) {
|
||||
return $templateComparison;
|
||||
}
|
||||
|
||||
return ($left['rule_name'] ?? "\u{10FFFF}") <=> ($right['rule_name'] ?? "\u{10FFFF}");
|
||||
});
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function normalizeNotificationTemplateId(array $config): ?string
|
||||
{
|
||||
$templateKey = $this->resolveNotificationTemplateKey($config);
|
||||
|
||||
if ($templateKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$templateId = $config[$templateKey] ?? null;
|
||||
$templateId = is_string($templateId) ? trim($templateId) : null;
|
||||
|
||||
if ($templateId === null || $templateId === '' || $this->isEmptyGuid($templateId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $templateId;
|
||||
}
|
||||
|
||||
private function actionTypeLabel(string $actionType): string
|
||||
{
|
||||
return match ($actionType) {
|
||||
'block' => 'Mark device noncompliant',
|
||||
'notification' => 'Send notification',
|
||||
'retire' => 'Add device to retire list',
|
||||
'wipe' => 'Wipe device',
|
||||
default => Str::headline($actionType),
|
||||
};
|
||||
}
|
||||
|
||||
private function formatGracePeriod(?int $hours): ?string
|
||||
{
|
||||
if ($hours === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($hours === 0) {
|
||||
return '0 hours';
|
||||
}
|
||||
|
||||
$days = intdiv($hours, 24);
|
||||
$remainingHours = $hours % 24;
|
||||
$parts = [];
|
||||
|
||||
if ($days > 0) {
|
||||
$parts[] = $days === 1 ? '1 day' : $days.' days';
|
||||
}
|
||||
|
||||
if ($remainingHours > 0) {
|
||||
$parts[] = $remainingHours === 1 ? '1 hour' : $remainingHours.' hours';
|
||||
}
|
||||
|
||||
$label = implode(' ', $parts);
|
||||
|
||||
if ($label === '') {
|
||||
return $hours === 1 ? '1 hour' : $hours.' hours';
|
||||
}
|
||||
|
||||
return sprintf('%s (%d hours)', $label, $hours);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{keys: array<int, string>, labels?: array<string, string>}
|
||||
*/
|
||||
|
||||
@ -5,13 +5,13 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GraphException;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
|
||||
@ -93,4 +93,3 @@ private function redactValue(mixed $value): mixed
|
||||
return $redacted;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -34,4 +34,3 @@ public function ensureAllowed(Tenant $tenant): void
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@ final class OperationRunTriageService
|
||||
'policy.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'drift_generate_findings',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
@ -30,7 +29,6 @@ final class OperationRunTriageService
|
||||
'policy.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'drift_generate_findings',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use App\Support\Verification\VerificationReportSanitizer;
|
||||
use App\Support\Verification\VerificationReportSchema;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@ -184,4 +184,3 @@ private function findCheckByKey(array $report, string $checkKey): array
|
||||
throw new InvalidArgumentException('Check not found in verification report.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,4 +11,3 @@ public static function insufficientPermission(): string
|
||||
return self::INSUFFICIENT_PERMISSION_ASK_OWNER;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,4 +33,3 @@ public static function selectOptions(): array
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class BaselineCompareStats
|
||||
@ -332,17 +334,25 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
||||
return 0;
|
||||
}
|
||||
|
||||
$compute = static function () use ($tenant, $policyTypes): int {
|
||||
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
|
||||
|
||||
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
|
||||
/**
|
||||
* @var array<string, int> $countsByKey
|
||||
*/
|
||||
$countsByKey = [];
|
||||
|
||||
InventoryItem::query()
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->whereNotNull('display_name')
|
||||
->select(['id', 'policy_type', 'display_name'])
|
||||
->select(['id', 'policy_type', 'display_name']);
|
||||
|
||||
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||
}
|
||||
|
||||
$query
|
||||
->orderBy('id')
|
||||
->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
@ -374,14 +384,28 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'baseline_compare:tenant:%d:duplicate_names:%s',
|
||||
'baseline_compare:tenant:%d:duplicate_names:%s:%s',
|
||||
(int) $tenant->getKey(),
|
||||
hash('sha256', implode('|', $policyTypes)),
|
||||
$latestInventorySyncRunId ?? 'all',
|
||||
);
|
||||
|
||||
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
}
|
||||
|
||||
private static function latestInventorySyncRunId(Tenant $tenant): ?int
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::InventorySync->value)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first(['id']);
|
||||
|
||||
return $run instanceof OperationRun ? (int) $run->getKey() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: list<string>, 2: ?string}
|
||||
*/
|
||||
|
||||
@ -83,4 +83,3 @@ private static function base64UrlDecode(string $value): ?string
|
||||
return is_string($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,4 +20,3 @@ public function assertEnabled(): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -32,4 +32,3 @@ public static function workspaceSafeSubjectExternalId(string $policyType, string
|
||||
return hash('sha256', $policyType.'|'.$subjectKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,4 +10,3 @@ enum PolicyVersionCapturePurpose: string
|
||||
case BaselineCapture = 'baseline_capture';
|
||||
case BaselineCompare = 'baseline_compare';
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,6 @@ public static function labels(): array
|
||||
'inventory_sync' => 'Inventory sync',
|
||||
'compliance.snapshot' => 'Compliance snapshot',
|
||||
'entra_group_sync' => 'Directory groups sync',
|
||||
'drift_generate_findings' => 'Drift generation',
|
||||
'backup_set.add_policies' => 'Backup set update',
|
||||
'backup_set.remove_policies' => 'Backup set update',
|
||||
'backup_set.delete' => 'Archive backup sets',
|
||||
@ -77,7 +76,6 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'inventory_sync' => 180,
|
||||
'compliance.snapshot' => 180,
|
||||
'entra_group_sync' => 120,
|
||||
'drift_generate_findings' => 240,
|
||||
'assignments.fetch', 'assignments.restore' => 60,
|
||||
'ops.reconcile_adapter_runs' => 120,
|
||||
'alerts.evaluate', 'alerts.deliver' => 120,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
@ -71,8 +71,8 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'drift_generate_findings') {
|
||||
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
if ($run->type === 'baseline_compare') {
|
||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||
|
||||
@ -10,7 +10,6 @@ enum OperationRunType: string
|
||||
case PolicySync = 'policy.sync';
|
||||
case PolicySyncOne = 'policy.sync_one';
|
||||
case DirectoryGroupsSync = 'entra_group_sync';
|
||||
case DriftGenerate = 'drift_generate_findings';
|
||||
case BackupSetAddPolicies = 'backup_set.add_policies';
|
||||
case BackupSetRemovePolicies = 'backup_set.remove_policies';
|
||||
case BackupScheduleExecute = 'backup_schedule_run';
|
||||
|
||||
@ -21,7 +21,6 @@ public static function baseline(): self
|
||||
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
||||
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\DriftLanding' => 'Drift landing retrofit deferred to drift-focused UI spec.',
|
||||
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage page retrofit deferred; no action-surface declaration yet.',
|
||||
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
|
||||
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log page retrofit deferred; no action-surface declaration yet.',
|
||||
|
||||
@ -60,4 +60,3 @@ private static function providerConnectionId(OperationRun $run): ?int
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -34,4 +34,3 @@ public function definition(): array
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ public function up(): void
|
||||
$table->json('scope_tags')->nullable()->after('assignments');
|
||||
$table->string('assignments_hash', 64)->nullable()->after('scope_tags');
|
||||
$table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash');
|
||||
|
||||
|
||||
$table->index('assignments_hash');
|
||||
$table->index('scope_tags_hash');
|
||||
});
|
||||
|
||||
@ -34,4 +34,3 @@ public function down(): void
|
||||
Schema::dropIfExists('verification_check_acknowledgements');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -32,4 +32,3 @@ public function down(): void
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -39,4 +39,3 @@ public function down(): void
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -71,4 +71,3 @@ public function down(): void
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('findings')
|
||||
->where('finding_type', 'drift')
|
||||
->where(static function (Builder $query): void {
|
||||
$query
|
||||
->whereNull('source')
|
||||
->orWhere('source', '!=', 'baseline.compare');
|
||||
})
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function down(): void {}
|
||||
};
|
||||
@ -1,156 +0,0 @@
|
||||
<x-filament::page>
|
||||
@php
|
||||
$baselineCompareHasWarnings = in_array(($baselineCompareCoverageStatus ?? null), ['warning', 'unproven'], true);
|
||||
@endphp
|
||||
|
||||
<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 operation 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 ($baselineCompareRunId)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Baseline compare
|
||||
@if ($this->getBaselineCompareRunUrl())
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBaselineCompareRunUrl() }}">
|
||||
#{{ $baselineCompareRunId }}
|
||||
</a>
|
||||
@else
|
||||
#{{ $baselineCompareRunId }}
|
||||
@endif
|
||||
|
||||
@if (filled($baselineCompareCoverageStatus))
|
||||
· Coverage
|
||||
<x-filament::badge :color="$baselineCompareCoverageStatus === 'ok' ? 'success' : 'warning'" size="sm">
|
||||
{{ $baselineCompareCoverageStatus === 'ok' ? 'OK' : 'Warnings' }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if (filled($baselineCompareFidelity))
|
||||
· Fidelity {{ Str::title($baselineCompareFidelity) }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($baselineCompareRunId && $baselineCompareHasWarnings)
|
||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-semibold">Baseline compare coverage warnings</div>
|
||||
<div class="text-sm">
|
||||
@if (($baselineCompareCoverageStatus ?? null) === 'unproven')
|
||||
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
||||
@else
|
||||
Some policy types were uncovered in the last baseline comparison, so findings may be incomplete.
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! empty($baselineCompareUncoveredTypes))
|
||||
<div class="mt-1 text-xs">
|
||||
Uncovered: {{ implode(', ', array_slice($baselineCompareUncoveredTypes, 0, 6)) }}@if (count($baselineCompareUncoveredTypes) > 6)…@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</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->getOperationRunUrl())
|
||||
<div class="text-sm">
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getOperationRunUrl() }}">
|
||||
View run #{{ $operationRunId }}
|
||||
</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->getOperationRunUrl())
|
||||
<div class="text-sm">
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getOperationRunUrl() }}">
|
||||
View run #{{ $operationRunId }}
|
||||
</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/119-baseline-drift-engine/checklists/requirements.md
Normal file
35
specs/119-baseline-drift-engine/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Drift Golden Master Cutover (Baseline Compare)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-04
|
||||
**Feature**: [specs/119-baseline-drift-engine/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
- Validated on 2026-03-04; all checks passing.
|
||||
197
specs/119-baseline-drift-engine/contracts/drift.openapi.yaml
Normal file
197
specs/119-baseline-drift-engine/contracts/drift.openapi.yaml
Normal file
@ -0,0 +1,197 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Drift (Golden Master) UI endpoints
|
||||
version: "1.0"
|
||||
description: |
|
||||
Minimal contract describing the drift entry point and findings surfaces after Spec 119 cutover.
|
||||
|
||||
Note: These are Filament (server-rendered / Livewire) endpoints, not a public JSON API.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/admin/t/{tenant}/baseline-compare-landing:
|
||||
get:
|
||||
summary: Drift entry point (Baseline Compare landing)
|
||||
description: |
|
||||
Tenant-scoped landing page used as the Drift entry point post-cutover.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Tenant member but missing capability
|
||||
"404":
|
||||
description: Not entitled to tenant/workspace scope (deny-as-not-found)
|
||||
"302":
|
||||
description: Redirect to login
|
||||
|
||||
/admin/t/{tenant}/findings:
|
||||
get:
|
||||
summary: Findings list (tenant-scoped)
|
||||
description: |
|
||||
Tenant-scoped Findings list. Drift findings post-cutover must have `source = baseline.compare`.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Tenant member but missing capability
|
||||
"404":
|
||||
description: Not entitled to tenant/workspace scope (deny-as-not-found)
|
||||
"302":
|
||||
description: Redirect to login
|
||||
|
||||
/admin/t/{tenant}/findings/{record}:
|
||||
get:
|
||||
summary: Finding detail view (tenant-scoped)
|
||||
description: |
|
||||
Tenant-scoped finding detail view. Diff rendering depends on evidence keys:
|
||||
- `summary.kind`
|
||||
- `baseline.policy_version_id`
|
||||
- `current.policy_version_id`
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: record
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Tenant member but missing capability
|
||||
"404":
|
||||
description: Not entitled to tenant/workspace scope (deny-as-not-found)
|
||||
"302":
|
||||
description: Redirect to login
|
||||
|
||||
/admin/operations/{runId}:
|
||||
get:
|
||||
summary: Operation run detail (canonical)
|
||||
description: Canonical tenantless run viewer (Monitoring → Operations → Run Detail).
|
||||
parameters:
|
||||
- name: runId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Workspace member but missing capability
|
||||
"404":
|
||||
description: Not entitled to workspace scope (deny-as-not-found)
|
||||
"302":
|
||||
description: Redirect to login
|
||||
|
||||
components:
|
||||
schemas:
|
||||
DriftSource:
|
||||
type: string
|
||||
enum: [baseline.compare]
|
||||
|
||||
DriftEvidenceSummaryKind:
|
||||
type: string
|
||||
enum: [policy_snapshot, policy_assignments, policy_scope_tags]
|
||||
|
||||
DriftEvidenceFidelity:
|
||||
type: string
|
||||
enum: [content, meta, mixed]
|
||||
|
||||
DriftFindingEvidence:
|
||||
type: object
|
||||
description: Evidence payload stored in `findings.evidence_jsonb` for drift findings.
|
||||
required: [change_type, policy_type, subject_key, summary, baseline, current, fidelity, provenance]
|
||||
properties:
|
||||
change_type:
|
||||
type: string
|
||||
enum: [missing_policy, unexpected_policy, different_version]
|
||||
policy_type:
|
||||
type: string
|
||||
subject_key:
|
||||
type: string
|
||||
summary:
|
||||
type: object
|
||||
required: [kind]
|
||||
properties:
|
||||
kind:
|
||||
$ref: "#/components/schemas/DriftEvidenceSummaryKind"
|
||||
note:
|
||||
type: string
|
||||
nullable: true
|
||||
fidelity:
|
||||
$ref: "#/components/schemas/DriftEvidenceFidelity"
|
||||
provenance:
|
||||
type: object
|
||||
required: [baseline_profile_id, baseline_snapshot_id, compare_operation_run_id]
|
||||
properties:
|
||||
baseline_profile_id:
|
||||
type: integer
|
||||
baseline_snapshot_id:
|
||||
type: integer
|
||||
compare_operation_run_id:
|
||||
type: integer
|
||||
inventory_sync_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
baseline:
|
||||
type: object
|
||||
required: [policy_version_id]
|
||||
properties:
|
||||
policy_version_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
hash:
|
||||
type: string
|
||||
nullable: true
|
||||
provenance:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
current:
|
||||
type: object
|
||||
required: [policy_version_id]
|
||||
properties:
|
||||
policy_version_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
hash:
|
||||
type: string
|
||||
nullable: true
|
||||
provenance:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
88
specs/119-baseline-drift-engine/data-model.md
Normal file
88
specs/119-baseline-drift-engine/data-model.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Data Model — Drift Golden Master Cutover (Spec 119)
|
||||
|
||||
This spec extends existing models and introduces no new tables.
|
||||
|
||||
## Entities
|
||||
|
||||
### 1) Finding (existing: `App\Models\Finding`)
|
||||
Baseline Compare drift findings are tenant-owned rows in `findings`.
|
||||
|
||||
Key fields used/extended by this feature:
|
||||
- `workspace_id` (derived from tenant; required)
|
||||
- `tenant_id` (required)
|
||||
- `finding_type = drift` (required)
|
||||
- `source = baseline.compare` (mandatory contract post-cutover)
|
||||
- `scope_key` (baseline compare grouping key; stable across re-runs)
|
||||
- `fingerprint` + `recurrence_key` (stable identity for idempotent upsert and lifecycle)
|
||||
- `severity` (`low|medium|high|critical`)
|
||||
- `status` (lifecycle): `new`, `reopened`, other open states, terminal states
|
||||
- `evidence_fidelity` (string): `content|meta|mixed`
|
||||
- `evidence_jsonb` (JSONB): enriched evidence contract (see below)
|
||||
- `current_operation_run_id` (the Baseline Compare `OperationRun` that observed the finding)
|
||||
- `baseline_operation_run_id` (unused for Baseline Compare drift; legacy run-to-run drift used it)
|
||||
|
||||
#### Evidence contract (`findings.evidence_jsonb`)
|
||||
Minimum required keys for diff-UX compatibility:
|
||||
- `change_type` (string): `missing_policy|unexpected_policy|different_version`
|
||||
- `policy_type` (string)
|
||||
- `subject_key` (string)
|
||||
- `summary.kind` (string): `policy_snapshot|policy_assignments|policy_scope_tags`
|
||||
- `baseline.policy_version_id` (int|null)
|
||||
- `current.policy_version_id` (int|null)
|
||||
- `baseline.hash` / `current.hash` (string; may be empty for missing/unexpected)
|
||||
- `baseline.provenance` / `current.provenance` (object; fidelity/source/observed_at/observed_operation_run_id)
|
||||
- `fidelity` (string): `content|meta|mixed`
|
||||
- `provenance` (object): `baseline_profile_id`, `baseline_snapshot_id`, `compare_operation_run_id`, and `inventory_sync_run_id` when applicable
|
||||
|
||||
Invariant:
|
||||
- `different_version` requires both policy version ids to render a detailed diff.
|
||||
- `missing_policy` may render a detailed diff with only `baseline.policy_version_id`.
|
||||
- `unexpected_policy` may render a detailed diff with only `current.policy_version_id`.
|
||||
- If the required policy version reference(s) for the finding’s change type are missing, the UI must treat the finding as “diff unavailable”.
|
||||
|
||||
### 2) OperationRun (existing: `App\Models\OperationRun`)
|
||||
Baseline Compare runs:
|
||||
- `type = baseline_compare`
|
||||
- `tenant_id` is required (tenant-scoped operation)
|
||||
- `status/outcome` transitions are service-owned (must go through `OperationRunService`)
|
||||
- `context.baseline_profile_id` and `context.baseline_snapshot_id` identify the compare baseline inputs
|
||||
- `context.baseline_compare.*` contains coverage + evidence gap breakdowns (read-only reporting)
|
||||
|
||||
Baseline Capture runs:
|
||||
- `type = baseline_capture`
|
||||
- `context.baseline_profile_id` and `context.source_tenant_id` identify the capture inputs
|
||||
- `context.baseline_capture.inventory_sync_run_id` records which completed Inventory Sync bounded subject selection when one existed
|
||||
- `context.baseline_capture.gaps.*` remains the canonical reporting block for snapshot-capture ambiguity or evidence issues
|
||||
|
||||
Legacy run-to-run drift generation runs:
|
||||
- `type = drift_generate_findings` (no longer created post-cutover; existing rows may remain historical)
|
||||
|
||||
### 3) BaselineProfile / BaselineSnapshot / BaselineSnapshotItem (existing)
|
||||
Workspace-owned baseline objects:
|
||||
- Baseline Profile (`baseline_profiles`) defines scope + capture mode and points to `active_snapshot_id`.
|
||||
- Baseline Snapshot (`baseline_snapshots`) is immutable and stores `summary_jsonb`.
|
||||
- Baseline Snapshot Items (`baseline_snapshot_items`) store:
|
||||
- `baseline_hash` (string)
|
||||
- `meta_jsonb.evidence` provenance fields (fidelity/source/observed_at), intentionally without tenant-owned identifiers (e.g., no `policy_version_id`).
|
||||
|
||||
### 4) PolicyVersion (existing: `App\Models\PolicyVersion`)
|
||||
Tenant-owned immutable policy snapshots used for:
|
||||
- Content hashing/normalization for Baseline Compare evidence
|
||||
- Rendering diffs in Findings detail view when both baseline/current policy version references exist
|
||||
|
||||
Relevant fields:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `policy_id` (ties versions to a tenant policy)
|
||||
- `capture_purpose` (e.g., `baseline_capture`, `baseline_compare`)
|
||||
- `operation_run_id` (which run captured it)
|
||||
- `captured_at`
|
||||
- `snapshot`, `assignments`, `scope_tags` (JSON/arrays)
|
||||
|
||||
## Derived/Computed Values
|
||||
- `evidence_jsonb.fidelity` should align with (or be derived from) the per-finding `evidence_fidelity` column.
|
||||
- `summary.kind` may be set conservatively (e.g., `policy_snapshot`) when dimension-level detection is not available.
|
||||
|
||||
## Invariants
|
||||
- Post-cutover drift findings must be queryable as: `finding_type = drift AND source = baseline.compare`.
|
||||
- Legacy drift findings are deleted by a one-time migration using: `finding_type = drift AND (source IS NULL OR source <> 'baseline.compare')`.
|
||||
146
specs/119-baseline-drift-engine/plan.md
Normal file
146
specs/119-baseline-drift-engine/plan.md
Normal file
@ -0,0 +1,146 @@
|
||||
# Implementation Plan: Drift Golden Master Cutover (Baseline Compare)
|
||||
|
||||
**Branch**: `feat/119-baseline-drift-engine` | **Date**: 2026-03-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Make Baseline Compare the only drift findings writer (`source = baseline.compare`) while preserving the existing diff UI by writing a diff-compatible `evidence_jsonb` structure (including `summary.kind` and baseline/current `policy_version_id` references when content evidence exists). `different_version` findings require both refs, while `missing_policy` and `unexpected_policy` may render against an empty side when exactly one ref exists. Align Baseline Snapshot capture with the latest completed Inventory Sync run so stale inventory rows do not create false `ambiguous_match` gaps. Treat full-content compare captures that reuse an identical existing `policy_version` as valid current evidence for the current run, instead of dropping them behind the `since` filter. Treat Intune compliance `scheduledActionsForRule` as canonical drift signal content (semantic fields only, deterministically sorted) and keep existing baseline snapshots comparable by recomputing effective baseline hashes from resolved baseline `policy_versions` when content provenance exists. Remove the legacy run-to-run drift generator (jobs, UI, run-type catalog, alerts/widget references, and tests) and delete legacy drift findings to avoid mixed evidence formats.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.1
|
||||
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4
|
||||
**Storage**: PostgreSQL (JSONB for evidence payloads)
|
||||
**Testing**: Pest 4 (PHPUnit 12)
|
||||
**Target Platform**: Linux containers (Dokploy) + local dev via Laravel Sail
|
||||
**Project Type**: Web application (Laravel + Filament admin panels)
|
||||
**Performance Goals**: Drift findings from a completed Baseline Compare run are visible within 5 minutes (spec SC-119-05); tenant pages render without timeouts under normal tenant sizes.
|
||||
**Constraints**: Hard cut (no feature flags, no dual-write), strict tenant/workspace isolation (404/403 semantics), Ops-UX `OperationRun` contract must remain intact, no new external integrations.
|
||||
**Scale/Scope**: Tenant-scoped drift findings + baseline compare runs; evidence enrichment must be deterministic and safe for list/detail rendering across typical governance datasets (paginated Findings list; evidence JSON schema remains stable).
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|---|---|---|
|
||||
| Inventory-first, Snapshots-second | PASS | Baseline Compare compares workspace-owned baseline snapshots against tenant “last observed” inventory/policy-version evidence; no change to snapshot immutability. |
|
||||
| Read/Write separation by default | PASS | Feature is drift detection + evidence enrichment; user-facing mutation remains Baseline Compare “Compare Now” (queued op with confirmation + audit). |
|
||||
| Single contract path to Graph | PASS | No new Graph endpoints; Baseline Compare continues to use existing evidence providers / contracts. |
|
||||
| Workspace + Tenant isolation (404/403 semantics) | PASS | Tenant-scoped Findings + Baseline Compare landing remain capability-gated; cleanup migration is scoped to drift findings only. |
|
||||
| Run observability + Ops-UX 3-surface feedback | PASS | Baseline Compare continues using `OperationRun`; removing legacy drift generation reduces competing run types and preserves Monitoring contract. |
|
||||
| Filament action surface contract | PASS | Drift entry point is Baseline Compare landing; Findings resource remains list+view with inspect affordance; destructive-like actions keep confirmations. |
|
||||
|
||||
Gate decision: **PASS** (no constitution exceptions required).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/119-baseline-drift-engine/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── BaselineCompareLanding.php
|
||||
│ │ │ └── DriftLanding.php # legacy (to remove)
|
||||
│ │ ├── Resources/
|
||||
│ │ │ └── FindingResource.php
|
||||
│ │ └── Widgets/
|
||||
│ │ └── Dashboard/NeedsAttention.php # legacy references (to update)
|
||||
│ ├── Jobs/
|
||||
│ │ ├── CompareBaselineToTenantJob.php
|
||||
│ │ └── GenerateDriftFindingsJob.php # legacy (to remove)
|
||||
│ ├── Jobs/Alerts/EvaluateAlertsJob.php # legacy compare_failed producer (to update/remove)
|
||||
│ ├── Services/
|
||||
│ │ ├── Baselines/
|
||||
│ │ │ ├── CurrentStateHashResolver.php
|
||||
│ │ │ └── Evidence/* # evidence providers + provenance
|
||||
│ │ └── Drift/* # keep diff + normalizers; remove generator-only pieces
|
||||
│ └── Support/
|
||||
│ ├── OperationCatalog.php # legacy run type label (to remove)
|
||||
│ ├── OperationRunLinks.php # legacy drift link (to update)
|
||||
│ └── OperationRunType.php # legacy enum case (to remove)
|
||||
├── database/migrations/
|
||||
│ └── (new) *_delete_legacy_drift_findings.php # one-time cleanup migration
|
||||
├── resources/views/filament/pages/
|
||||
│ ├── baseline-compare-landing.blade.php
|
||||
│ └── drift-landing.blade.php # legacy (to remove)
|
||||
└── tests/
|
||||
├── Feature/Alerts/* # update references to legacy drift type if any
|
||||
└── Feature/Drift/* # legacy tests (to remove/update)
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel application under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/` (Filament UI + queued jobs). Feature work stays within `app/`, `resources/`, `database/`, and `tests/` plus feature docs under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
No constitution exceptions are required for this feature.
|
||||
|
||||
## Phase 0 — Outline & Research
|
||||
|
||||
**Output**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/research.md`
|
||||
|
||||
- Confirm existing Baseline Compare drift writer contract (where `source` is set; what evidence fields exist today).
|
||||
- Identify the minimum evidence keys required for the existing diff renderer (`summary.kind`, baseline/current `policy_version_id`).
|
||||
- Identify all legacy drift generation touchpoints to remove (job, generator, UI landing, dashboard widget, operation catalog/links, alerts producer, and tests).
|
||||
- Define deterministic cleanup criteria for legacy drift findings deletion.
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
**Outputs**:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/contracts/*`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Drift findings remain tenant-owned (`findings.finding_type = drift`) with mandatory `source = baseline.compare`.
|
||||
- Baseline Compare evidence is enriched to be diff-renderable when content evidence exists:
|
||||
- Always write `evidence_jsonb.summary.kind` (allowed: `policy_snapshot`, `policy_assignments`, `policy_scope_tags`).
|
||||
- Write `evidence_jsonb.baseline.policy_version_id` and `evidence_jsonb.current.policy_version_id` when available (independently); compute `evidence_jsonb.fidelity` deterministically from presence (both = `content`, one = `mixed`, none = `meta`).
|
||||
- Resolve baseline `policy_version_id` deterministically using baseline snapshot item provenance (`observed_at`) + stable policy identity (`policy_type` + `subject_key`); if not resolvable, set null.
|
||||
- Add `evidence_jsonb.provenance` keys: `baseline_profile_id`, `baseline_snapshot_id`, `compare_operation_run_id` (Baseline Compare `OperationRun` id), and `inventory_sync_run_id` when applicable.
|
||||
- Findings UI renders one-sided diffs for `missing_policy` (baseline-only) and `unexpected_policy` (current-only); only `different_version` requires both refs.
|
||||
- Drift navigation entry point after cutover is Baseline Compare landing (`/admin/t/{tenant}/baseline-compare-landing`).
|
||||
|
||||
Re-check Constitution post-design: **PASS** (design stays within tenant/workspace isolation and existing `OperationRun` + Filament action-surface rules).
|
||||
|
||||
## Phase 2 — Implementation Plan (for /speckit.tasks)
|
||||
|
||||
Phase 2 will be expressed as concrete tasks in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/tasks.md` by `/speckit.tasks`. High-level sequencing:
|
||||
|
||||
1) **Evidence contract upgrade (Baseline Compare → drift findings)**
|
||||
- Extend Baseline Compare drift evidence writer to include `summary.kind`, baseline/current `policy_version_id` references (when content), explicit fidelity, and run/snapshot provenance.
|
||||
- Ensure evidence keys are stable and deterministic across runs.
|
||||
- Align Baseline Snapshot capture subject selection with the latest completed Inventory Sync run when available, matching Baseline Compare’s current-state boundary.
|
||||
- Treat same-run reused compare versions as valid current content evidence so deduplicated captures do not surface as `missing_current`.
|
||||
- Canonicalize compliance `scheduledActionsForRule` into semantic drift keys and compare content-backed baseline snapshots against recomputed baseline-version hashes so the signal can evolve without forcing snapshot recapture.
|
||||
|
||||
2) **Diff UX guardrails**
|
||||
- Update the Findings detail view so `different_version` diffs require both `baseline.policy_version_id` and `current.policy_version_id`, while `missing_policy` and `unexpected_policy` render against an empty side when their single required reference exists; otherwise show an explicit “diff unavailable” explanation.
|
||||
|
||||
3) **Hard-cut legacy drift removal**
|
||||
- Delete legacy drift generation job + generator-only services and remove tenant UI entry points that trigger or describe run-to-run drift generation.
|
||||
- Remove legacy operation run type registrations/catalog labels and any UI/widget/alert producers that reference drift generation.
|
||||
|
||||
4) **Data cleanup**
|
||||
- Add a one-time migration deleting findings where `finding_type = drift` AND (`source` is null OR `source` is not equal to `baseline.compare`), ensuring Baseline Compare rows remain.
|
||||
|
||||
5) **Tests**
|
||||
- Add/adjust tests asserting Baseline Compare drift findings include the new evidence contract and `source`.
|
||||
- Remove legacy drift generation tests and update any other tests that referenced `drift_generate_findings` only to simulate “some other type”.
|
||||
63
specs/119-baseline-drift-engine/quickstart.md
Normal file
63
specs/119-baseline-drift-engine/quickstart.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Quickstart — Spec 119 (Drift Golden Master Cutover)
|
||||
|
||||
## Prereqs
|
||||
- Run the app via Sail.
|
||||
|
||||
## Local setup
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
|
||||
## How to exercise the feature (manual)
|
||||
|
||||
### 1) Ensure baseline prerequisites exist
|
||||
- Ensure the tenant has a recent successful Inventory Sync.
|
||||
- Ensure a Baseline Profile is assigned and has an active Baseline Snapshot.
|
||||
- If you just deleted or renamed a duplicate policy in Intune, run Inventory Sync again before capturing a new Baseline Snapshot; capture now scopes subjects to the latest completed sync.
|
||||
|
||||
### 2) Run Baseline Compare (drift entry point)
|
||||
- Navigate to the Drift entry point (Baseline Compare landing):
|
||||
- `GET /admin/t/{tenant}/baseline-compare-landing`
|
||||
- Trigger “Compare Now” (queued operation).
|
||||
- Expected:
|
||||
- An `OperationRun` of type `baseline_compare` is created/updated and visible in Monitoring → Operations.
|
||||
- Drift findings are created/updated with `source = baseline.compare`.
|
||||
|
||||
### 2a) Trigger a guaranteed compliance-policy drift
|
||||
- Pick an in-scope compliance policy that is already present in the active Baseline Snapshot.
|
||||
- Change either a core compliance setting or an action under Intune “Actions for noncompliance” (for example: `gracePeriodHours`, removing `retire`, or changing the notification template).
|
||||
- Run Inventory Sync, then run Baseline Compare (full content).
|
||||
- Expected:
|
||||
- A `different_version` drift finding is created when the canonical compliance payload changed.
|
||||
- Reordering the same noncompliance actions or Graph-only ID churn does not create a finding.
|
||||
|
||||
### 3) Validate diff UX behavior
|
||||
- Open a drift finding in the tenant Findings UI:
|
||||
- `GET /admin/t/{tenant}/findings/{record}`
|
||||
- Expected:
|
||||
- `evidence_jsonb.summary.kind` is present (one of: `policy_snapshot`, `policy_assignments`, `policy_scope_tags`).
|
||||
- For `different_version`, if both `baseline.policy_version_id` and `current.policy_version_id` exist: the appropriate diff view renders.
|
||||
- For `missing_policy`, if `baseline.policy_version_id` exists: the diff renders against an empty current side.
|
||||
- For `unexpected_policy`, if `current.policy_version_id` exists: the diff renders against an empty baseline side.
|
||||
- If the required reference(s) for the change type are missing: the UI shows an explicit “diff unavailable” explanation.
|
||||
|
||||
### 3a) Validate no-drift full-content compare behavior
|
||||
- If you run a full-content Baseline Compare immediately after capturing a matching baseline snapshot, the run should resolve current content evidence without creating drift findings.
|
||||
- Expected:
|
||||
- `baseline_compare.reason_code = no_drift_detected`
|
||||
- no `missing_current` evidence gaps caused solely by reused identical compare-purpose `policy_versions`
|
||||
|
||||
### 4) Validate legacy drift removal (hard cut)
|
||||
- Expected post-cutover:
|
||||
- No Drift landing page that triggers “Generate drift” exists.
|
||||
- No operation run type catalog labels, widgets, or alerts reference legacy drift generation.
|
||||
|
||||
### 5) Validate legacy dataset cleanup
|
||||
- After deploying the cleanup migration:
|
||||
- Legacy drift findings where `source` is null or not `baseline.compare` are deleted.
|
||||
- Baseline Compare drift findings remain intact.
|
||||
|
||||
## Tests (Pest)
|
||||
- Run focused suites once implemented:
|
||||
- `vendor/bin/sail artisan test --compact --filter=Drift`
|
||||
- `vendor/bin/sail artisan test --compact --filter=BaselineCompare`
|
||||
- Or run specific files under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/`.
|
||||
105
specs/119-baseline-drift-engine/research.md
Normal file
105
specs/119-baseline-drift-engine/research.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Research — Drift Golden Master Cutover (Spec 119)
|
||||
|
||||
This document resolves planning unknowns and records implementation decisions for making Baseline Compare the single source of truth for drift findings while preserving the existing diff UI.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Golden-master drift source
|
||||
- Decision: All drift findings generated by Baseline Compare will use `findings.source = baseline.compare`.
|
||||
- Rationale: This is the single “origin label” used across the spec and is already set in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` when upserting findings.
|
||||
- Alternatives considered:
|
||||
- Keep `source` nullable / optional → rejected because it enables mixed states and breaks the single-source contract.
|
||||
|
||||
### 2) Drift navigation entry point (post-cutover)
|
||||
- Decision: The Drift navigation entry point becomes the Baseline Compare landing page (`/admin/t/{tenant}/baseline-compare-landing`).
|
||||
- Rationale: This preserves a single operational entry point for drift generation and reduces duplicated UI “landing” surfaces.
|
||||
- Alternatives considered:
|
||||
- Keep a separate Drift landing page and repurpose it → rejected (extra surface to maintain and re-explain).
|
||||
|
||||
### 3) Evidence contract for diff UX compatibility
|
||||
- Decision: Baseline Compare drift findings will write `evidence_jsonb` keys required by the existing diff renderer:
|
||||
- `summary.kind` with allowed values: `policy_snapshot`, `policy_assignments`, `policy_scope_tags`
|
||||
- `baseline.policy_version_id` and `current.policy_version_id` when content evidence exists
|
||||
- Explicit fidelity labeling + explicit compare provenance (baseline profile/snapshot + compare run id + inventory sync run id when available)
|
||||
- Rationale: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/FindingResource.php` uses `summary.kind` to decide which diff UI to render and reads the policy version IDs from `baseline.policy_version_id` and `current.policy_version_id`.
|
||||
- Alternatives considered:
|
||||
- Introduce a new diff UI for Baseline Compare evidence → rejected (scope; requires new UI + new contract).
|
||||
|
||||
### 4) Diff renderability rule (avoid misleading empty diffs)
|
||||
- Decision: Only render a detailed diff when both `baseline.policy_version_id` and `current.policy_version_id` are present; otherwise show “diff unavailable”.
|
||||
- Rationale: The diff builder can otherwise compare empty/null versions and display misleading results; the spec requires an explicit “diff unavailable” explanation.
|
||||
- Alternatives considered:
|
||||
- Render diffs even when one side is missing → rejected (misleading output; violates clarified rule).
|
||||
|
||||
### 8) One-sided diff rendering for policy presence changes
|
||||
- Decision: Render diffs against an empty side for `missing_policy` (baseline-only reference) and `unexpected_policy` (current-only reference). Keep the stricter two-reference rule only for `different_version`.
|
||||
- Rationale: Policy presence changes are easier to understand when operators can inspect the captured policy content that exists, instead of receiving a generic “diff unavailable” message.
|
||||
- Alternatives considered:
|
||||
- Keep treating all single-reference findings as non-renderable → rejected (hides useful evidence even when one side is fully captured).
|
||||
|
||||
### 9) Baseline capture must ignore stale inventory rows
|
||||
- Decision: When a latest completed Inventory Sync exists, Baseline Snapshot capture scopes `inventory_items` to that run before deriving `subject_key` matches.
|
||||
- Rationale: Capture and Compare must agree on the same “current observed state” boundary; otherwise deleted/renamed policies from older syncs can create false `ambiguous_match` gaps and omit valid baseline subjects.
|
||||
- Alternatives considered:
|
||||
- Continue scanning all tenant inventory rows during capture → rejected (nondeterministic snapshot gaps as historical rows accumulate).
|
||||
- Hard-fail capture when no completed Inventory Sync exists → deferred (larger product behavior change than this fix; current fallback remains acceptable).
|
||||
|
||||
### 10) Full-content compare must reuse same-run deduplicated evidence
|
||||
- Decision: When the compare-time content capture fetches current policy content successfully but reuses an older identical `policy_version` row instead of inserting a new one, the compare run will consume that returned version directly as current evidence for the run.
|
||||
- Rationale: The capture step has already validated current Graph content. Re-querying only by `captured_at >= snapshot.captured_at` misclassifies these successful deduplicated captures as `missing_current`, which incorrectly downgrades fidelity and emits `evidence_capture_incomplete`.
|
||||
- Alternatives considered:
|
||||
- Always insert a new `policy_version` row per compare run → rejected (breaks immutable dedupe strategy and inflates storage).
|
||||
- Keep relying only on the post-capture `since` query → rejected (produces false partial-success outcomes when content is unchanged).
|
||||
|
||||
### 11) Landing-page duplicate warnings must use the latest sync boundary
|
||||
- Decision: The Baseline Compare landing-page duplicate-name warning uses the latest completed Inventory Sync run when one exists, matching compare/capture subject selection.
|
||||
- Rationale: Operators should not keep seeing a duplicate-name warning after the duplicate only survives in stale historical inventory rows; the landing page must reflect the same current boundary as the underlying compare logic.
|
||||
- Alternatives considered:
|
||||
- Keep scanning all tenant inventory rows for the warning → rejected (UI keeps reporting already-resolved duplicates until stale rows are cleaned up out-of-band).
|
||||
|
||||
### 12) Compliance noncompliance actions belong in the policy drift signal
|
||||
- Decision: `deviceCompliancePolicy.scheduledActionsForRule` participates in `policy_snapshot` drift through a canonical semantic projection of each configured action.
|
||||
- Rationale: A compliance policy’s security effect depends on both the rule and its enforcement timeline/consequences. Changing `gracePeriodHours`, removing `retire`, or swapping notification templates changes governance behavior and must produce drift.
|
||||
- Alternatives considered:
|
||||
- Ignore noncompliance actions entirely → rejected (false negatives on meaningful governance changes).
|
||||
- Hash the raw Graph array directly → rejected (opaque IDs and order churn would create false positives).
|
||||
|
||||
### 13) Expand the drift signal without forcing baseline recapture
|
||||
- Decision: When baseline content provenance resolves to a tenant `policy_version`, Compare recomputes the effective baseline content hash from that immutable version instead of trusting only the stored snapshot hash.
|
||||
- Rationale: Existing baseline snapshots were captured under older normalization semantics. Recomputing from the resolved baseline version keeps those snapshots comparable as the canonical drift signal expands, which avoids rollout-time false positives and avoids forcing operators to recapture unchanged baselines.
|
||||
- Alternatives considered:
|
||||
- Require every tenant to recapture their baseline after signal changes → rejected (operationally brittle and easy to miss).
|
||||
- Keep comparing only the stored snapshot hash → rejected (old snapshots would flap as soon as the drift signal grows).
|
||||
|
||||
### 5) How policy version references are populated
|
||||
- Decision:
|
||||
- Current-side `policy_version_id`: taken from content evidence (`ResolvedEvidence.meta.policy_version_id`) when content fidelity is used.
|
||||
- Baseline-side `policy_version_id`: resolved opportunistically for the same tenant policy when baseline-side evidence is content-based (e.g., via baseline-capture policy versions), otherwise set to null.
|
||||
- Rationale: Baseline snapshots are workspace-owned and intentionally avoid persisting tenant-owned identifiers; the finding (tenant-owned) is the correct place to attach tenant-specific policy version references.
|
||||
- Alternatives considered:
|
||||
- Persist baseline policy version IDs in baseline snapshots → rejected (violates scope/ownership model for workspace-owned snapshots).
|
||||
|
||||
### 6) Legacy drift findings deletion criteria
|
||||
- Decision: One-time cleanup deletes drift findings where `source` is null or not equal to `baseline.compare` (scoped to `finding_type = drift`), and keeps `source = baseline.compare` rows.
|
||||
- Rationale: Legacy drift generator rows often have `source = NULL`; this filter removes mixed evidence formats without risking Baseline Compare drift data.
|
||||
- Alternatives considered:
|
||||
- Delete by “old evidence shape” heuristics only → rejected (brittle; source is the canonical differentiator post-cutover).
|
||||
|
||||
### 7) Legacy drift generator removal scope
|
||||
- Decision: Remove legacy run-to-run drift generation end-to-end:
|
||||
- `GenerateDriftFindingsJob` + generator-only services
|
||||
- Drift landing UI surface that triggers legacy drift generation
|
||||
- Operation run type catalog entries and any related UI/widget/alert producer references
|
||||
- Legacy tests that assert drift generation dispatch/notifications
|
||||
- Rationale: Hard cut means no dual-write/no feature flags; leaving legacy entry points risks reintroducing “two truths”.
|
||||
- Alternatives considered:
|
||||
- Leave legacy components present but unreachable → rejected (dead code + drift risk).
|
||||
|
||||
## Notes / Repo Facts Used
|
||||
- Baseline Compare upserts findings and already hard-sets `source = baseline.compare` in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php`.
|
||||
- The existing diff UI reads:
|
||||
- `evidence_jsonb.summary.kind`
|
||||
- `evidence_jsonb.baseline.policy_version_id`
|
||||
- `evidence_jsonb.current.policy_version_id`
|
||||
in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/FindingResource.php`.
|
||||
- Content evidence already carries `policy_version_id` in `ResolvedEvidence.meta` via `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/Evidence/ContentEvidenceProvider.php`.
|
||||
239
specs/119-baseline-drift-engine/spec.md
Normal file
239
specs/119-baseline-drift-engine/spec.md
Normal file
@ -0,0 +1,239 @@
|
||||
# Feature Specification: Drift Golden Master Cutover (Baseline Compare)
|
||||
|
||||
**Feature Branch**: `feat/119-baseline-drift-engine`
|
||||
**Created**: 2026-03-04
|
||||
**Status**: Draft (ready for implementation)
|
||||
**Owner**: Governance/Platform
|
||||
**Input**: User description: "Spec 119 — Drift Unification Cutover & Legacy Cleanup — Option A: Golden Master (Baseline Compare) becomes the single source of truth for drift findings, keeping the existing diff UX while removing the legacy run-to-run drift generator and cleaning up legacy findings."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant (drift findings + compare runs) + workspace (baseline profiles/snapshots referenced for compare)
|
||||
- **Primary Routes**:
|
||||
- Tenant-context admin: Baseline Compare landing (Drift entry), Findings (list + view), Operation Run detail
|
||||
- Workspace admin: Baseline Profiles (read-only impact via provenance links)
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned: drift findings, compare runs, policy versions/evidence used to explain drift
|
||||
- Workspace-owned: baseline profiles, baseline snapshots (this spec does not change snapshot semantics; it only references them for provenance)
|
||||
- **RBAC**:
|
||||
- Membership: workspace membership + tenant access are required for tenant-context surfaces (deny-as-not-found for non-members)
|
||||
- Capabilities (existing):
|
||||
- `tenant_findings.view`: view drift findings and any diff surfaces
|
||||
- `tenant.sync`: start baseline compare runs (manual) and authorize compare-time evidence refresh where applicable
|
||||
- `tenant.view`: view baseline compare landing and run summaries
|
||||
- 404 vs 403 semantics:
|
||||
- Non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||
- Member but missing capability → 403
|
||||
|
||||
For canonical-view specs: not applicable (this is a tenant-context feature).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-05
|
||||
|
||||
- Q: What is the drift finding `source` value for Baseline Compare? → A: `baseline.compare`
|
||||
- Q: What are the allowed `evidence.summary.kind` values for drift diff rendering? → A: `policy_snapshot`, `policy_assignments`, `policy_scope_tags`
|
||||
- Q: When is a `different_version` drift finding considered diff-renderable? → A: Only when both `baseline.policy_version_id` and `current.policy_version_id` are present; otherwise show “diff unavailable”.
|
||||
- Q: What is the legacy drift findings deletion rule for cleanup? → A: Delete any findings where `finding_type = drift` AND (`source` is null OR `source` is not equal to `baseline.compare`).
|
||||
- Q: What should the Drift navigation entry point be after cutover? → A: Baseline Compare landing.
|
||||
|
||||
### Session 2026-03-06
|
||||
|
||||
- Q: How should `missing_policy` and `unexpected_policy` findings render in the Finding view when exactly one policy-version reference exists? → A: Render a diff against an empty side (`removed` for baseline-only, `added` for current-only) instead of showing “diff unavailable”.
|
||||
- Q: Which inventory rows may baseline snapshot capture treat as current subjects when multiple historical Inventory Sync runs exist? → A: Use only rows from the latest completed Inventory Sync run; stale rows from older syncs must not create new snapshot ambiguity gaps.
|
||||
- Q: How should full-content Baseline Compare treat an identical `PolicyVersion` that was re-used instead of newly inserted during the current compare run? → A: Treat it as valid current content evidence for the current compare run; it must not be dropped as `missing_current` just because the reused row’s original `captured_at` predates the snapshot.
|
||||
- Q: How should the Baseline Compare landing-page duplicate-name warning be computed when historical Inventory Sync rows exist? → A: Warn only for duplicates present in the latest completed Inventory Sync scope; stale historical rows must not keep the warning visible.
|
||||
- Q: Should Intune `Actions for noncompliance` changes count as compliance-policy drift? → A: Yes. Canonicalize `scheduledActionsForRule` by semantic fields (`actionType`, `gracePeriodHours`, notification template id), ignore opaque IDs/order-only noise, and treat those changes as `policy_snapshot` drift.
|
||||
- Q: How should existing baseline snapshots remain comparable when the policy snapshot drift signal is expanded? → A: When a baseline item has content provenance, recompute the effective baseline hash from the resolved baseline `policy_version` during compare so previously captured snapshots do not require forced recapture.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Today, drift findings can be produced by two different generators with different “change types” and different evidence detail levels. This creates mixed data states, inconsistent UI rendering, and operator confusion (“two truths”) that is not acceptable for enterprise governance workflows.
|
||||
|
||||
This spec hard-cuts drift generation to a single golden-master source: Baseline Compare.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Single source of truth**: all drift findings are generated exclusively by Baseline Compare.
|
||||
- **Preserve Diff UX**: baseline-compare drift findings include enough evidence references to render the existing normalized diff views when content evidence exists, and provide a clear “diff not available” explanation when it does not.
|
||||
- **Hard cut (no dual-write)**: no feature flags, no dual-write period, no backward compatibility work for the legacy drift generator (project is not yet production).
|
||||
- **Legacy cleanup**: remove the legacy drift generator stack end-to-end (jobs, scheduling, UI affordances, tests/docs) and delete legacy drift findings to prevent confusion.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new reporting layer (e.g., Stored Reports / Evidence Items).
|
||||
- No soft deprecation, rollout toggles, or staged migration.
|
||||
- No change to Baseline Compare drift change-type semantics (missing policy / unexpected policy / different version remain conceptually the same).
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Drift finding**: a detected deviation between an approved baseline and a tenant’s current observed state, recorded as a finding for triage and audit.
|
||||
- **Baseline Compare**: the comparison process that evaluates baseline vs current state and emits drift findings.
|
||||
- **Legacy drift generator**: the older drift workflow that compares two historical runs (“run-to-run”) and writes drift findings independently of Baseline Compare.
|
||||
- **Source (origin label)**: a mandatory short identifier indicating which drift engine produced a drift finding; for this feature, the only allowed drift finding source is `baseline.compare`.
|
||||
- **Evidence**: the explanatory payload attached to a drift finding, including what changed and what reference snapshots/versions support a diff view.
|
||||
- **Evidence fidelity**:
|
||||
- **content**: evidence includes sufficient full-content references to support detailed diffs
|
||||
- **meta**: evidence is metadata-level only; diffs are not available
|
||||
- **mixed**: some dimensions have content-level evidence and others do not; UI must communicate limitations
|
||||
- **Diff kind**: the drift dimension the UI should render. Allowed values are `policy_snapshot`, `policy_assignments`, and `policy_scope_tags`.
|
||||
- **Provenance**: which baseline profile/snapshot and which compare run produced the finding (and when).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The system is not yet production; deleting legacy drift findings is acceptable to ensure a clean cutover.
|
||||
- Baseline Compare already exists as an observable operation (run history, outcome, counts) and already emits drift findings.
|
||||
- The Findings UI already supports rendering normalized diffs when evidence includes baseline/current references.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Understand drift with consistent diffs (Priority: P1)
|
||||
|
||||
As a tenant admin/operator, I can run Baseline Compare and open the resulting drift findings with a consistent, enterprise-friendly explanation. When content evidence exists, I can view a detailed diff; when it does not, the UI clearly explains why.
|
||||
|
||||
**Why this priority**: This is the core value of drift governance: explain what changed in a way that is actionable and trustworthy.
|
||||
|
||||
**Independent Test**: Run Baseline Compare to produce (a) a `different_version` finding with content-level evidence, (b) a `missing_policy` or `unexpected_policy` finding with a single policy-version reference, and (c) a meta-only finding, then verify the finding detail view renders the correct diff or an explicit “diff unavailable” explanation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a Baseline Compare run that produces a `different_version` drift finding with content-level evidence, **When** I open the finding detail view, **Then** the UI renders the appropriate diff view for the change dimension and shows baseline vs current references.
|
||||
2. **Given** a `missing_policy` or `unexpected_policy` drift finding with exactly one policy-version reference, **When** I open the finding detail view, **Then** the UI renders a diff against an empty side so the operator can see the added or removed policy content.
|
||||
3. **Given** a drift finding with meta-only evidence, **When** I open the finding detail view, **Then** the UI shows a clear “diff not available” explanation and still displays the finding summary and provenance.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Eliminate “two truths” for drift (Priority: P2)
|
||||
|
||||
As a tenant admin/operator, I only ever see drift findings coming from one source (Baseline Compare). I do not need to understand or choose between multiple drift engines or evidence formats.
|
||||
|
||||
**Why this priority**: Trust and supportability depend on consistent terminology and behavior.
|
||||
|
||||
**Independent Test**: After the cutover, verify that all drift findings shown in the UI are labeled as Baseline Compare-origin and no UI surface offers a legacy “Generate drift” action or “source” switching.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** any drift findings are visible for a tenant, **When** I view the Findings list and open drift findings, **Then** each drift finding clearly indicates a single origin (“Baseline Compare”) and the UI contains no “choose drift source” affordances.
|
||||
2. **Given** I navigate to the Drift entry point (Baseline Compare landing), **When** I view available actions, **Then** I can view Baseline Compare status and view findings, but I cannot start any legacy run-to-run drift generation workflow.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Clean cutover & legacy removal (Priority: P3)
|
||||
|
||||
As a platform/governance owner, I can deploy this change as a hard cut: legacy drift generation is removed end-to-end and legacy drift findings are deleted so operators never encounter mixed states.
|
||||
|
||||
**Why this priority**: This removes ambiguity and reduces long-term maintenance and support costs.
|
||||
|
||||
**Independent Test**: After deployment and the one-time cleanup step, verify that legacy drift findings no longer exist, legacy drift generation cannot be scheduled or started, and Baseline Compare drift continues to work.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant previously had legacy drift findings, **When** the system is upgraded and cleanup is applied, **Then** those legacy drift findings are no longer visible and only Baseline Compare drift findings remain.
|
||||
2. **Given** I inspect scheduled operations and run history, **When** I look for legacy drift generation, **Then** no scheduling or run types exist for the legacy drift generator and drift generation is attributable only to Baseline Compare runs.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Missing/unexpected policy outcomes**: a drift finding may only have a baseline reference or only a current reference; the UI should render a diff against an empty side when that single reference exists, and only fall back to “diff unavailable” when the required single reference is missing.
|
||||
- **Mixed fidelity**: a single compare run can produce findings where some drift dimensions have content-level evidence and others do not; fidelity labels and UI messaging must reflect the weakest/limiting evidence.
|
||||
- **Evidence gaps**: when evidence references are missing, the finding detail view must not error; it must show “diff unavailable” with a reason.
|
||||
- **Stale inventory rows**: when a policy was deleted or renamed after an older Inventory Sync, a new Baseline Snapshot capture must ignore stale `inventory_items` rows from older sync runs so historical duplicates do not create false `ambiguous_match` gaps.
|
||||
- **No baseline configured**: the Baseline Compare landing (Drift entry point) must show a blocked state with clear guidance (no silent empty states).
|
||||
- **Repeat compares**: repeated Baseline Compare runs without changes must not create confusing duplicates; findings lifecycle should remain stable and understandable.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Constitution alignment (required)
|
||||
|
||||
- Drift evaluation and findings persistence are tenant-owned and MUST remain strictly tenant-scoped.
|
||||
- This spec changes the drift-finding contract and removes legacy scheduled/queued work; it MUST preserve run observability and auditability through existing operations monitoring surfaces.
|
||||
- No new external integrations are introduced by this spec; it unifies drift generation pathways and evidence contracts.
|
||||
|
||||
### Constitution alignment (OPS-UX)
|
||||
|
||||
- Baseline Compare runs MUST remain observable operations with:
|
||||
- intent-only toast feedback when manually started,
|
||||
- progress visibility only via the active-ops widget and operation run detail,
|
||||
- a single terminal outcome notification for user-initiated runs (scheduled/system runs rely on Monitoring).
|
||||
- Run lifecycle transitions remain service-owned.
|
||||
- Run summary counts MUST remain numeric, stable, and suitable for operator understanding (e.g., subjects processed, findings created, evidence gaps).
|
||||
|
||||
### Constitution alignment (RBAC-UX)
|
||||
|
||||
- Authorization planes involved: tenant/admin `/admin` with tenant-context `/admin/t/{tenant}/...`.
|
||||
- Drift findings and any diff views MUST be deny-as-not-found (404) for non-members and capability-gated (403) for members without permission.
|
||||
- Any action that starts Baseline Compare is an operation-start mutation and MUST be enforced server-side, not only via UI visibility.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Phase 1 — Baseline Compare becomes the drift “golden master”
|
||||
|
||||
- **FR-119-P1-01 Single drift source**: The system MUST generate drift findings exclusively via Baseline Compare going forward; no legacy run-to-run drift generator may write drift findings.
|
||||
- **FR-119-P1-02 Mandatory origin label**: Every drift finding MUST carry a mandatory origin label (`source`). For Baseline Compare drift findings, `source` MUST be exactly `baseline.compare`.
|
||||
- **FR-119-P1-03 Evidence supports diff UX**: Baseline Compare drift findings MUST include evidence that allows the existing diff views to render when content references exist, including:
|
||||
- `evidence_jsonb.summary.kind` with one of: `policy_snapshot`, `policy_assignments`, `policy_scope_tags`, and
|
||||
- `evidence_jsonb.baseline.policy_version_id` and `evidence_jsonb.current.policy_version_id` (int|null) as content references when available, and
|
||||
- `evidence_jsonb.fidelity` and `findings.evidence_fidelity` with one of: `content`, `meta`, `mixed`.
|
||||
- **FR-119-P1-03a Diff-renderability rule**: The finding detail view MUST render detailed diffs according to change type:
|
||||
- `different_version` requires both `baseline.policy_version_id` and `current.policy_version_id`,
|
||||
- `missing_policy` requires `baseline.policy_version_id` and renders against an empty current side,
|
||||
- `unexpected_policy` requires `current.policy_version_id` and renders against an empty baseline side.
|
||||
If the required reference(s) are missing for the change type, the finding detail view MUST show an explicit “diff unavailable” explanation.
|
||||
- **FR-119-P1-03b Baseline policy-version resolution**: When baseline snapshot provenance indicates content evidence and provides an `observed_at` timestamp, the system MUST attempt to resolve `baseline.policy_version_id` deterministically using the baseline snapshot item identity (`policy_type` + `subject_key`) and the baseline item’s `observed_at`. If no matching policy version is found, `baseline.policy_version_id` MUST be set to null.
|
||||
- **FR-119-P1-03c Baseline capture freshness boundary**: When Baseline Snapshot capture builds its subject list and a latest completed Inventory Sync run exists for the tenant, it MUST scope eligible `inventory_items` to that run only. Stale rows from older sync runs MUST NOT create `ambiguous_match` gaps or leak removed policies into new snapshots.
|
||||
- **FR-119-P1-03d Reused current content evidence remains valid**: When full-content Baseline Compare captures current policy content and the capture layer reuses an identical existing `policy_version` row instead of inserting a new one, the compare run MUST still treat that reused version as valid current content evidence for the current run. It MUST NOT produce `missing_current` solely because the reused row’s persisted `captured_at` is older than the baseline snapshot.
|
||||
- **FR-119-P1-03e Duplicate-name warnings follow the current inventory boundary**: Any UI warning about duplicate policy display names preventing baseline matching MUST be computed from the latest completed Inventory Sync scope when one exists. Stale rows from older sync runs MUST NOT keep the warning visible.
|
||||
- **FR-119-P1-03f Compliance noncompliance actions are part of policy drift**: For `deviceCompliancePolicy`, the drift signal MUST include a canonical representation of `scheduledActionsForRule` using semantic fields only:
|
||||
- `actionType`,
|
||||
- `gracePeriodHours`,
|
||||
- notification template id when present.
|
||||
The representation MUST be deterministically sorted and MUST ignore opaque IDs, Graph metadata fields, and order-only noise so semantically identical payloads do not flap.
|
||||
- **FR-119-P1-03g Baseline hash compatibility for content-backed snapshots**: When a baseline item has content provenance and its baseline `policy_version_id` can be resolved, Baseline Compare MUST use the resolved baseline `policy_version` as the effective content-hash source during drift evaluation. Stored snapshot hashes from older normalization semantics MUST NOT force operators to recapture an unchanged baseline just to stay comparable.
|
||||
- **FR-119-P1-04 Provenance is explicit**: Baseline Compare drift findings MUST include provenance linking them to the baseline profile/snapshot and the compare run that produced them.
|
||||
- **FR-119-P1-04a Provenance keys are stable**: The provenance block MUST be present as `evidence_jsonb.provenance` and MUST include:
|
||||
- `baseline_profile_id` (int),
|
||||
- `baseline_snapshot_id` (int),
|
||||
- `compare_operation_run_id` (int; the Baseline Compare `OperationRun` id),
|
||||
- `inventory_sync_run_id` (int|null) when applicable.
|
||||
- **FR-119-P1-05 Fidelity is explicit**: Drift findings MUST include an evidence fidelity label (`content`, `meta`, or `mixed`). It MUST be deterministic and computed as:
|
||||
- `content` when both `baseline.policy_version_id` and `current.policy_version_id` are present,
|
||||
- `mixed` when exactly one of the two references is present,
|
||||
- `meta` when neither reference is present.
|
||||
When evidence is meta-only, the UI MUST clearly communicate that diffs are not available.
|
||||
- **FR-119-P1-06 Change-type semantics unchanged**: Baseline Compare drift change types remain conceptually the same (missing policy / unexpected policy / different version); this spec only standardizes evidence and origin labeling.
|
||||
|
||||
#### Phase 2 — Hard cut cleanup (“Search & Destroy”)
|
||||
|
||||
- **FR-119-P2-01 Remove legacy drift generation workflow**: The legacy run-to-run drift generation workflow MUST be removed end-to-end (operation types, scheduling, UI affordances, and any related documentation/tests).
|
||||
- **FR-119-P2-02 Drift entry point is Baseline Compare**: The Drift navigation entry MUST open the Baseline Compare landing and MUST no longer start or configure legacy drift generation. It MUST present Baseline Compare-driven drift status and a path to view findings.
|
||||
- **FR-119-P2-03 Legacy drift findings deleted**: A one-time cleanup step MUST delete legacy drift findings so the dataset is not polluted by mixed evidence formats. Baseline Compare drift findings MUST remain intact.
|
||||
- **FR-119-P2-03a Cleanup filter**: The cleanup step MUST delete all findings where `finding_type = drift` AND (`source` is null OR `source` is not equal to `baseline.compare`). It MUST NOT delete drift findings where `source = baseline.compare`.
|
||||
- **FR-119-P2-04 No “legacy source” UI states**: The UI MUST not include badges, filters, or labels that reference the legacy drift generator after cutover.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-119-01 Determinism**: For the same baseline snapshot and the same current observed state, Baseline Compare MUST produce consistent drift outcomes and evidence labels (no nondeterministic “flapping” source/fidelity values).
|
||||
- **NFR-119-02 Contract stability**: The drift evidence contract used for diff rendering MUST be stable across runs so operators do not experience regressions in how drift is explained.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Page | app/Filament/Pages/BaselineCompareLanding.php | “Compare Now” (confirmation; capability-gated) | Link to Operation Run detail | None | None | Single CTA when blocked: “Fix prerequisites” guidance | N/A | N/A | Yes | This is the Drift navigation entry point after cutover, and remains the only drift generation entry point. |
|
||||
| Resource | app/Filament/Resources/FindingResource.php | “Triage all matching” (confirmation; capability-gated) | View action to open finding | “More” workflow actions | Bulk actions grouped under “More” | None (no create) | Workflow actions (capability-gated) | N/A | Yes | Evidence/diff is read-only; lifecycle actions unchanged by this spec. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Drift Finding**: A tenant-owned governance record describing a deviation (type, severity/status, origin, evidence fidelity, provenance, evidence payload for diff rendering).
|
||||
- **Baseline Compare Run**: An observable operation that compares baseline vs current state for a tenant and produces drift findings with counts and coverage/fidelity context.
|
||||
- **Evidence Payload**: The structured explanation attached to a drift finding, including diff kind, baseline/current references (when available), provenance, and fidelity.
|
||||
- **Policy Version**: An immutable snapshot/version reference used to support detailed diffs when content evidence exists.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-119-01 Single-source drift**: 100% of drift findings shown to operators have `source = baseline.compare`; no UI surface references multiple drift sources.
|
||||
- **SC-119-02 Diff UX preserved**: For drift findings with content-level evidence, operators can open the finding and view an appropriate diff (settings / assignments / scope tags) without errors, including one-sided diffs for `missing_policy` and `unexpected_policy`; for meta-only findings, operators see a clear “diff unavailable” explanation.
|
||||
- **SC-119-03 Legacy workflow removed**: Operators cannot start, schedule, or configure legacy run-to-run drift generation anywhere in the product after deployment.
|
||||
- **SC-119-04 Legacy dataset cleaned**: After the one-time cleanup step, zero legacy drift findings remain visible; Baseline Compare drift findings remain available.
|
||||
- **SC-119-05 Timely visibility**: Drift findings from a completed Baseline Compare run are visible in the Findings list within 5 minutes of run completion under normal operating conditions.
|
||||
227
specs/119-baseline-drift-engine/tasks.md
Normal file
227
specs/119-baseline-drift-engine/tasks.md
Normal file
@ -0,0 +1,227 @@
|
||||
---
|
||||
description: "Task list for Spec 119 implementation"
|
||||
---
|
||||
|
||||
# Tasks: Drift Golden Master Cutover (Baseline Compare) (Spec 119)
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/`
|
||||
**Prerequisites**:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/plan.md` (required)
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/spec.md` (required)
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/research.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/contracts/drift.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) because this feature changes runtime behavior.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Local readiness + baseline validation before changing runtime behavior
|
||||
|
||||
- [X] T001 Start local stack and run migrations using `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` (up -d, artisan migrate)
|
||||
- [X] T002 Run a baseline test subset using `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` to confirm a green starting point (artisan test --compact --filter=BaselineCompare)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared building blocks used across user stories (evidence contract + resolvers)
|
||||
|
||||
**⚠️ CRITICAL**: Complete this phase before starting any user story work.
|
||||
|
||||
- [X] T003 Create baseline policy-version resolver in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php` (resolve baseline `policy_version_id` deterministically from baseline snapshot item identity `policy_type` + `subject_key` and baseline evidence provenance `observed_at`; return null when no match)
|
||||
- [X] T004 [P] Add resolver unit tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php` (covers: found, not found, invalid observed_at, deterministic tie-breaker when multiple candidates exist)
|
||||
- [X] T005 [P] Add drift evidence contract assertion helper in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Support/AssertsDriftEvidenceContract.php` (required keys, allowed `summary.kind`, provenance keys, fidelity algorithm, and diff-renderability rule)
|
||||
|
||||
**Checkpoint**: Contract helper + resolver exist; US1 tests can be written against them.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Understand drift with consistent diffs (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Baseline Compare drift findings carry diff-compatible evidence so operators get consistent diffs when content evidence exists, and a clear “diff unavailable” explanation when it does not.
|
||||
|
||||
**Independent Test**: Run Baseline Compare to produce (a) a `different_version` drift finding with both refs, (b) a `missing_policy` or `unexpected_policy` finding with a single required ref, and (c) a meta-only finding, then verify the finding detail view renders the correct diff or an explicit “diff unavailable” explanation.
|
||||
|
||||
### Tests for User Story 1 (write first) ⚠️
|
||||
|
||||
- [X] T006 [P] [US1] Add Baseline Compare evidence-contract tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php` (different_version + missing_policy; asserts `source`, `change_type` semantics unchanged, `summary.kind`, baseline/current `policy_version_id`, fidelity algorithm, and `evidence_jsonb.provenance.*` keys including `compare_operation_run_id`)
|
||||
- [X] T007 [P] [US1] Add FindingResource “diff unavailable” regression test in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php` (missing baseline/current refs → explicit message)
|
||||
- [X] T008 [P] [US1] Update Baseline Compare findings tests for new evidence shape in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/BaselineCompareFindingsTest.php` (replace `current_hash`/`baseline_hash` assertions with nested baseline/current evidence + `summary.kind`)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T009 [US1] Upgrade Baseline Compare drift evidence schema in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` (write `evidence_jsonb.summary.kind`, `evidence_jsonb.baseline.policy_version_id`, `evidence_jsonb.current.policy_version_id`, compute `evidence_jsonb.fidelity`, and write `evidence_jsonb.provenance.{baseline_profile_id,baseline_snapshot_id,compare_operation_run_id,inventory_sync_run_id}`)
|
||||
- [X] T010 [US1] Populate `evidence_jsonb.current.policy_version_id` from content evidence meta in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` (use `ResolvedEvidence.meta.policy_version_id` when available; else null)
|
||||
- [X] T011 [US1] Populate `evidence_jsonb.baseline.policy_version_id` via `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php` and integrate into `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` (when baseline snapshot provenance indicates content evidence + has `observed_at`, attempt resolve; otherwise null)
|
||||
- [X] T012 [US1] Implement `summary.kind` selection in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` by “stealing” dimension detection logic from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Drift/DriftFindingGenerator.php` (prefer settings snapshot changes; else assignments; else scope tags; fallback policy_snapshot)
|
||||
- [X] T013 [US1] Ensure `findings.evidence_fidelity` and `evidence_jsonb.fidelity` stay aligned in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` (compute deterministically from policy-version refs: both present = `content`, exactly one = `mixed`, none = `meta`)
|
||||
- [X] T014 [US1] Enforce diff-renderability rule in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/FindingResource.php` (`different_version` requires both refs; `missing_policy`/`unexpected_policy` render against an empty side when their single required ref exists; otherwise show explicit “diff unavailable” explanation in the Diff section)
|
||||
- [X] T015 [US1] Run US1-focused tests via `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` (artisan test --compact --filter=BaselineCompareDriftEvidenceContract; artisan test --compact --filter=DriftFindingDiffUnavailable)
|
||||
|
||||
**Checkpoint**: Baseline Compare findings show consistent diffs for content evidence; meta-only shows “diff unavailable” without errors.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Eliminate “two truths” for drift (Priority: P2)
|
||||
|
||||
**Goal**: Operators only see Baseline Compare as the drift engine; no legacy “Generate drift” UI or source switching remains.
|
||||
|
||||
**Independent Test**: After the cutover, verify all drift findings shown in the UI are Baseline Compare-origin and no UI surface offers a legacy “Generate drift” action or “source” switching.
|
||||
|
||||
### Tests for User Story 2 (write first) ⚠️
|
||||
|
||||
- [X] T016 [P] [US2] Remove/replace DriftLanding enforcement tests in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Rbac/DriftLandingUiEnforcementTest.php` (assert drift entry point is Baseline Compare landing instead)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T017 [US2] Remove legacy Drift landing surface by deleting `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/DriftLanding.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/drift-landing.blade.php`
|
||||
- [X] T018 [US2] Update related-run links to point drift to Baseline Compare landing in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperationRunLinks.php` (remove DriftLanding import; add BaselineCompareLanding link for baseline_compare runs)
|
||||
- [X] T019 [US2] Update dashboard attention widget to use Baseline Compare runs (not drift_generate) in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Dashboard/NeedsAttention.php` (stale/failed checks + URLs → Baseline Compare landing / Operations)
|
||||
- [X] T020 [US2] Remove DriftLanding action-surface exemption in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- [X] T021 [US2] Update Filament auth allowlist to remove DriftLanding in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php`
|
||||
- [X] T022 [US2] Run US2-focused tests via `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` (artisan test --compact --filter=BaselineCompareLanding; artisan test --compact --filter=NeedsAttention)
|
||||
|
||||
**Checkpoint**: No DriftLanding surface exists; “Drift” entry point routes to Baseline Compare landing.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Clean cutover & legacy removal (Priority: P3)
|
||||
|
||||
**Goal**: Hard cut: legacy run-to-run drift generator is removed end-to-end and legacy drift findings are deleted so operators never encounter mixed states.
|
||||
|
||||
**Independent Test**: After deployment and the one-time cleanup step, verify legacy drift findings no longer exist, legacy drift generation cannot be started, and Baseline Compare drift continues to work.
|
||||
|
||||
### Tests for User Story 3 (write first) ⚠️
|
||||
|
||||
- [X] T023 [P] [US3] Add cleanup migration test in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/LegacyDriftFindingsCleanupMigrationTest.php` (deletes `finding_type=drift` where `source` is null or != baseline.compare; keeps baseline.compare)
|
||||
- [X] T024 [P] [US3] Update alert + monitoring tests to remove drift_generate_findings references in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Alerts/BaselineCompareFailedAlertTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
- [X] T025 [P] [US3] Remove legacy drift generator tests tied to DriftLanding/GenerateDriftFindingsJob/DriftFindingGenerator in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/` (delete or rewrite: DriftGenerationDispatchTest.php, DriftLandingCopyTest.php, DriftLandingShowsComparisonInfoTest.php, GenerateDriftFindingsJobNotificationTest.php, Drift*DriftDetectionTest.php, DriftGenerationDeterminismTest.php, DriftTenantIsolationTest.php)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Delete legacy drift generation runtime code: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/GenerateDriftFindingsJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Drift/DriftFindingGenerator.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Drift/DriftRunSelector.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Drift/DriftScopeKey.php`
|
||||
- [X] T027 [US3] Remove legacy drift operation type from catalogs + triage: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperationRunType.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperationCatalog.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/SystemConsole/OperationRunTriageService.php` (no drift_generate_findings label/duration/retry/cancel)
|
||||
- [X] T028 [US3] Remove legacy drift-generate alert event producer in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Alerts/EvaluateAlertsJob.php` (drop compareFailedEvents() and its call site)
|
||||
- [X] T029 [US3] Add one-time cleanup migration in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/migrations/2026_03_05_000001_delete_legacy_drift_findings.php` (delete `finding_type=drift` where `source` is null or <> baseline.compare)
|
||||
- [X] T030 [US3] Remove remaining legacy drift references discovered by search in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/` (target: no DriftLanding / drift_generate_findings strings outside historical migrations/specs; explicitly audit Findings UI for legacy-source badges/filters/labels or “source switching” states)
|
||||
- [X] T031 [US3] Run US3-focused tests via `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` (artisan test --compact --filter=Alerts; artisan test --compact --filter=OperationRun; artisan test --compact --filter=Drift)
|
||||
|
||||
**Checkpoint**: Legacy generator is gone; DB cleanup migration removes legacy drift findings; all tests green.
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final quality pass across all user stories
|
||||
|
||||
- [X] T032 [P] Format changed files using `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` (php vendor/bin/pint)
|
||||
- [X] T033 Run full test suite using `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail` (artisan test)
|
||||
- [X] T034 [P] Validate manual smoke steps remain accurate in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/quickstart.md` (update only if behavior/screens changed)
|
||||
- [X] T035 [P] Update Spec 119 docs for one-sided drift diff rendering in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/` (spec, plan, research, data-model, quickstart)
|
||||
- [X] T036 [US1] Extend `FindingResource` one-sided diff rendering in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/FindingResource.php` (`unexpected_policy` => added against empty baseline, `missing_policy` => removed against empty current)
|
||||
- [X] T037 [P] Extend drift view regressions in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php` (cover one-sided empty-side rendering and explicit unavailable messaging)
|
||||
- [X] T038 [US1] Scope baseline snapshot capture to the latest completed Inventory Sync in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CaptureBaselineSnapshotJob.php` (ignore stale `inventory_items` rows from older sync runs when deriving subject-key matches; record `baseline_capture.inventory_sync_run_id` for operability)
|
||||
- [X] T039 [P] Add baseline-capture stale-inventory regression coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php` (duplicates in the same latest sync still gap; stale duplicates from older syncs no longer do)
|
||||
- [X] T040 [P] Update Spec 119 docs for baseline-capture latest-sync scoping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/` (spec, plan, research, data-model, quickstart)
|
||||
- [X] T041 [US1] Reuse same-run full-content capture evidence in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` (overlay compare-time captured/reused `policy_versions` before the `since`-based resolver so unchanged policies do not become `missing_current`)
|
||||
- [X] T042 [P] Add full-content compare reuse regression in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` (reused identical compare-purpose version still yields `no_drift_detected`, not `evidence_capture_incomplete`)
|
||||
- [X] T043 [P] Update Spec 119 docs for reused compare-evidence handling in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/` (spec, plan, research, quickstart)
|
||||
- [X] T044 [US1] Scope Baseline Compare landing duplicate-name warnings to the latest completed Inventory Sync in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Baselines/BaselineCompareStats.php` (historical stale duplicates must not keep the warning banner visible)
|
||||
- [X] T045 [P] Add landing-page stale-duplicate regression in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php` (latest sync clean ⇒ no warning, latest sync duplicate ⇒ warning remains)
|
||||
- [X] T046 [P] Update Spec 119 docs for landing-page duplicate warning scoping in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/` (spec, research)
|
||||
- [X] T047 [US1] Canonicalize compliance `scheduledActionsForRule` in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/CompliancePolicyNormalizer.php` (include semantic noncompliance action fields in the drift signal; ignore opaque IDs/order-only noise)
|
||||
- [X] T048 [P] Add unit coverage for canonical compliance action drift normalization in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/CompliancePolicyNormalizerTest.php` (grace-period/template signal, stable ordering, ignored internal IDs)
|
||||
- [X] T049 [US1] Recompute effective baseline content hashes from resolved baseline `policy_versions` in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php` (keep existing content-backed baseline snapshots comparable when drift-signal semantics expand)
|
||||
- [X] T050 [P] Add compare regressions + docs for compliance noncompliance action drift in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/BaselineDriftEngine/ComplianceNoncomplianceActionsDriftTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/119-baseline-drift-engine/` (unchanged legacy-hash snapshot stays quiet; changed grace/action semantics create drift)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- Stories can proceed in priority order (P1 → P2 → P3)
|
||||
- Or in parallel after Phase 2 if staffed (be mindful of overlapping files)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: No dependencies after Phase 2; delivers the MVP (diff-compatible evidence + diff guardrails)
|
||||
- **US2 (P2)**: Depends on US1 being stable in UI terms (so removing DriftLanding doesn’t remove drift visibility)
|
||||
- **US3 (P3)**: Can start after Phase 2, but recommended after US1/US2 so the cutover is clean and operators still have a drift workflow
|
||||
|
||||
### Parallel Opportunities (examples)
|
||||
|
||||
- Tests marked `[P]` can be written in parallel with implementation (and should fail before the fix lands).
|
||||
- Deletions of legacy drift files in US3 can be parallelized with catalog/triage cleanup (different files).
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: US1
|
||||
|
||||
```bash
|
||||
# In parallel (different files):
|
||||
Task: "Add Baseline Compare evidence-contract tests in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php"
|
||||
Task: "Implement evidence schema upgrade in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php"
|
||||
Task: "Add diff-unavailable regression test in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Drift/DriftFindingDiffUnavailableTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: US2
|
||||
|
||||
```bash
|
||||
# In parallel (different files):
|
||||
Task: "Delete Drift landing surface in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/DriftLanding.php"
|
||||
Task: "Update dashboard widget links in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Dashboard/NeedsAttention.php"
|
||||
Task: "Update run links in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperationRunLinks.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: US3
|
||||
|
||||
```bash
|
||||
# In parallel (different files):
|
||||
Task: "Add cleanup migration in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/migrations/2026_03_05_000001_delete_legacy_drift_findings.php"
|
||||
Task: "Remove legacy drift code in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/GenerateDriftFindingsJob.php"
|
||||
Task: "Update triage/catalog in /Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OperationCatalog.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks = different files, no dependencies
|
||||
- `[US#]` label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Prefer Sail for all local commands: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/vendor/bin/sail`
|
||||
@ -104,7 +104,7 @@ function invokeBaselineCompareFailedEvents(int $workspaceId, CarbonImmutable $wi
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'drift_generate_findings',
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => $now->subMinutes(5),
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
],
|
||||
]);
|
||||
@ -50,6 +51,7 @@
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
],
|
||||
]);
|
||||
@ -80,6 +82,7 @@
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
],
|
||||
]);
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
],
|
||||
]);
|
||||
|
||||
@ -150,7 +150,10 @@
|
||||
]);
|
||||
|
||||
$user = PlatformUser::factory()->create([
|
||||
'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL],
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
|
||||
@ -51,19 +51,19 @@
|
||||
|
||||
// Get available policies (should be empty since policy is already in backup)
|
||||
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
|
||||
|
||||
|
||||
expect($existingPolicyIds)->toContain($this->policy->id);
|
||||
|
||||
|
||||
// Soft-delete the backup item
|
||||
$backupItem->delete();
|
||||
|
||||
|
||||
// Verify it's soft-deleted
|
||||
expect($this->backupSet->items()->count())->toBe(0);
|
||||
expect($this->backupSet->items()->withTrashed()->count())->toBe(1);
|
||||
|
||||
|
||||
// Get available policies again - soft-deleted items should NOT be in the list (UI can re-add them)
|
||||
$existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all();
|
||||
|
||||
|
||||
expect($existingPolicyIds)->not->toContain($this->policy->id)
|
||||
->and($existingPolicyIds)->toHaveCount(0);
|
||||
});
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
// Try to add the same policy again via BackupService
|
||||
$service = app(BackupService::class);
|
||||
|
||||
|
||||
$result = $service->addPoliciesToSet(
|
||||
tenant: $this->tenant,
|
||||
backupSet: $this->backupSet->refresh(),
|
||||
@ -129,7 +129,7 @@
|
||||
|
||||
// Check available policies - should include the new one but not the deleted one
|
||||
$existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all();
|
||||
|
||||
|
||||
expect($existingPolicyIds)->toContain($this->policy->id)
|
||||
->and($existingPolicyIds)->not->toContain($otherPolicy->id);
|
||||
});
|
||||
|
||||
@ -44,4 +44,3 @@
|
||||
|
||||
expect($spec->label)->toBe('Needs attention');
|
||||
});
|
||||
|
||||
|
||||
@ -72,4 +72,3 @@
|
||||
expect($completedMeta)->toHaveKey('subjects_total');
|
||||
expect($completedMeta)->toHaveKey('gaps');
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
@ -104,4 +103,3 @@
|
||||
expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBeNull();
|
||||
expect(data_get($meta, 'evidence.policy_version_id'))->toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
@ -167,4 +167,3 @@ public function capture(
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
expect(data_get($meta, 'evidence.fidelity'))->toBe('content');
|
||||
});
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
it('does not create drift for unchanged compliance actions when the baseline snapshot hash used the legacy signal', function (): void {
|
||||
[$tenant, $run] = createComplianceActionCompareFixture(
|
||||
baselineSnapshotPayload: [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'bitLockerEnabled' => true,
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => null,
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'id' => 'baseline-block',
|
||||
'actionType' => 'block',
|
||||
'gracePeriodHours' => 0,
|
||||
'notificationTemplateId' => '00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
[
|
||||
'id' => 'baseline-retire',
|
||||
'actionType' => 'retire',
|
||||
'gracePeriodHours' => 2664,
|
||||
'notificationTemplateId' => '6a204cbf-1acc-434d-83f9-486af129941f',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
currentSnapshotPayload: [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'bitLockerEnabled' => true,
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => null,
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'id' => 'current-block',
|
||||
'actionType' => 'block',
|
||||
'gracePeriodHours' => 0,
|
||||
'notificationTemplateId' => '00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
[
|
||||
'id' => 'current-retire',
|
||||
'actionType' => 'retire',
|
||||
'gracePeriodHours' => 2664,
|
||||
'notificationTemplateId' => '6a204cbf-1acc-434d-83f9-486af129941f',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect(data_get($run->context, 'baseline_compare.reason_code'))->toBe('no_drift_detected');
|
||||
expect(Finding::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('creates drift when compliance noncompliance action timing changes', function (): void {
|
||||
[$tenant, $run] = createComplianceActionCompareFixture(
|
||||
baselineSnapshotPayload: [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'bitLockerEnabled' => true,
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => null,
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'id' => 'baseline-block',
|
||||
'actionType' => 'block',
|
||||
'gracePeriodHours' => 0,
|
||||
'notificationTemplateId' => '00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
[
|
||||
'id' => 'baseline-retire',
|
||||
'actionType' => 'retire',
|
||||
'gracePeriodHours' => 2664,
|
||||
'notificationTemplateId' => '6a204cbf-1acc-434d-83f9-486af129941f',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
currentSnapshotPayload: [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'bitLockerEnabled' => true,
|
||||
'scheduledActionsForRule' => [
|
||||
[
|
||||
'ruleName' => null,
|
||||
'scheduledActionConfigurations' => [
|
||||
[
|
||||
'id' => 'current-block',
|
||||
'actionType' => 'block',
|
||||
'gracePeriodHours' => 264,
|
||||
'notificationTemplateId' => '00000000-0000-0000-0000-000000000000',
|
||||
],
|
||||
[
|
||||
'id' => 'current-retire',
|
||||
'actionType' => 'retire',
|
||||
'gracePeriodHours' => 2664,
|
||||
'notificationTemplateId' => '6a204cbf-1acc-434d-83f9-486af129941f',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->first();
|
||||
|
||||
expect($finding)->toBeInstanceOf(Finding::class);
|
||||
expect(data_get($finding, 'evidence_jsonb.change_type'))->toBe('different_version');
|
||||
expect(data_get($finding, 'evidence_jsonb.summary.kind'))->toBe('policy_snapshot');
|
||||
expect(data_get($finding, 'evidence_jsonb.baseline.policy_version_id'))->toBeInt();
|
||||
expect(data_get($finding, 'evidence_jsonb.current.policy_version_id'))->toBeInt();
|
||||
});
|
||||
|
||||
function createComplianceActionCompareFixture(array $baselineSnapshotPayload, array $currentSnapshotPayload): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceCompliancePolicy'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$baselineCapturedAt = CarbonImmutable::parse('2026-03-06 00:18:39');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceCompliancePolicy' => 'succeeded'],
|
||||
);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'external_id' => 'bitlocker-require-policy',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Bitlocker Require',
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
'snapshot' => $baselineSnapshotPayload,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => legacyComplianceSnapshotHash($baselineSnapshotPayload),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $baselineCapturedAt->toIso8601String(),
|
||||
'observed_operation_run_id' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'etag' => 'W/"same-etag"',
|
||||
'scope_tag_ids' => [],
|
||||
'assignment_target_count' => 0,
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => $baselineCapturedAt->addHour(),
|
||||
'snapshot' => $currentSnapshotPayload,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$run = app(OperationRunService::class)->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceCompliancePolicy'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
return [$tenant, $run];
|
||||
}
|
||||
|
||||
function legacyComplianceSnapshotHash(array $snapshot): string
|
||||
{
|
||||
$snapshotWithoutScheduledActions = $snapshot;
|
||||
unset($snapshotWithoutScheduledActions['scheduledActionsForRule'], $snapshotWithoutScheduledActions['scheduledActionsForRule@odata.context']);
|
||||
|
||||
$settings = app(SettingsNormalizer::class)->normalizeForDiff(
|
||||
snapshot: $snapshotWithoutScheduledActions,
|
||||
policyType: 'deviceCompliancePolicy',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$templateIds = [];
|
||||
$scheduledActions = $snapshot['scheduledActionsForRule'] ?? null;
|
||||
|
||||
if (is_array($scheduledActions)) {
|
||||
foreach ($scheduledActions as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$configs = $rule['scheduledActionConfigurations'] ?? null;
|
||||
|
||||
if (! is_array($configs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($configs as $config) {
|
||||
if (! is_array($config) || ($config['actionType'] ?? null) !== 'notification') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateId = $config['notificationTemplateId'] ?? $config['notificationMessageTemplateId'] ?? null;
|
||||
$templateId = is_string($templateId) ? trim($templateId) : null;
|
||||
|
||||
if ($templateId === null || $templateId === '' || strtolower($templateId) === '00000000-0000-0000-0000-000000000000') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$templateIds[] = $templateId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$templateIds = array_values(array_unique($templateIds));
|
||||
sort($templateIds);
|
||||
|
||||
if ($templateIds !== []) {
|
||||
$settings['Compliance notifications > Template IDs'] = $templateIds;
|
||||
}
|
||||
|
||||
return app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => $settings,
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
}
|
||||
@ -11,8 +11,8 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -102,6 +102,17 @@
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
'snapshot' => $baselineSnapshotPayload,
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => $baselineCapturedAt->addHour(),
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
it('Baseline resolver prefers content evidence over meta evidence when available', function () {
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
|
||||
// --- T039: Assignment CRUD tests (RBAC + uniqueness) ---
|
||||
|
||||
|
||||
@ -22,6 +22,10 @@
|
||||
]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -30,6 +34,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -38,6 +44,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E2'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -46,6 +54,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Unique Policy',
|
||||
'meta_jsonb' => ['etag' => 'E_UNIQUE'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -102,3 +112,116 @@
|
||||
->where('subject_external_id', $workspaceSafeExternalId)
|
||||
->sole();
|
||||
});
|
||||
|
||||
it('ignores stale duplicate subject_key rows from older inventory sync runs', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$olderInventoryRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
attributes: [
|
||||
'finished_at' => now()->subMinutes(10),
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
],
|
||||
);
|
||||
|
||||
$latestInventoryRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
attributes: [
|
||||
'finished_at' => now(),
|
||||
'completed_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'stale-standard',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Standard',
|
||||
'meta_jsonb' => ['etag' => 'E_STALE'],
|
||||
'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(),
|
||||
'last_seen_at' => now()->subMinutes(10),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'current-standard',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Standard',
|
||||
'meta_jsonb' => ['etag' => 'E_CURRENT'],
|
||||
'last_seen_operation_run_id' => (int) $latestInventoryRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'current-unique',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Unique Policy',
|
||||
'meta_jsonb' => ['etag' => 'E_UNIQUE'],
|
||||
'last_seen_operation_run_id' => (int) $latestInventoryRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCapture->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(2);
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(2);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $latestInventoryRun->getKey());
|
||||
expect(data_get($context, 'baseline_capture.gaps.by_reason.ambiguous_match'))->toBeNull();
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
expect(
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->count(),
|
||||
)->toBe(2);
|
||||
|
||||
$standardSubjectKey = BaselineSubjectKey::fromDisplayName('Standard');
|
||||
expect($standardSubjectKey)->not->toBeNull();
|
||||
|
||||
$standardExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $standardSubjectKey,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_external_id', $standardExternalId)
|
||||
->sole();
|
||||
});
|
||||
|
||||
@ -115,4 +115,3 @@
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@ -173,4 +173,3 @@ public function capture(
|
||||
expect($completedMeta)->toHaveKey('evidence_capture');
|
||||
expect($completedMeta)->toHaveKey('gaps');
|
||||
});
|
||||
|
||||
|
||||
@ -156,4 +156,3 @@
|
||||
$context = is_array($compareRun->context) ? $compareRun->context : [];
|
||||
expect(data_get($context, 'baseline_compare.coverage.uncovered_types'))->toContain('deviceCompliancePolicy');
|
||||
});
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -143,4 +143,3 @@
|
||||
->count(),
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Tests\Support\AssertsDriftEvidenceContract;
|
||||
|
||||
it('writes diff-compatible drift evidence for different_version drift findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$policyType = 'deviceConfiguration';
|
||||
$displayName = 'Policy Alpha';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => [$policyType], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$baselineCapturedAt = CarbonImmutable::now()->subHours(2)->setMicrosecond(123456);
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $baselineCapturedAt->subSecond(),
|
||||
]);
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: [$policyType => 'succeeded'],
|
||||
);
|
||||
|
||||
$externalId = 'policy-alpha-uuid';
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => $policyType,
|
||||
'platform' => 'windows10',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => $policyType,
|
||||
'platform' => 'windows10',
|
||||
'version_number' => 1,
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
'snapshot' => ['etag' => 'E_BASELINE'],
|
||||
'assignments' => [],
|
||||
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => $policyType,
|
||||
'platform' => 'windows10',
|
||||
'version_number' => 2,
|
||||
'captured_at' => $baselineCapturedAt->addMinutes(10),
|
||||
'snapshot' => ['etag' => 'E_CURRENT'],
|
||||
'assignments' => [],
|
||||
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
|
||||
]);
|
||||
|
||||
$hasher = app(DriftHasher::class);
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
|
||||
$baselineHash = $hasher->hashNormalized([
|
||||
'settings' => $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineVersion->snapshot ?? [],
|
||||
policyType: $policyType,
|
||||
platform: $baselineVersion->platform,
|
||||
),
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments ?? []),
|
||||
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($baselineVersion->scope_tags ?? []),
|
||||
]);
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $baselineCapturedAt->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => $policyType,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => [$policyType], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->sole();
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
|
||||
AssertsDriftEvidenceContract::assertValid($evidence);
|
||||
|
||||
expect((string) ($evidence['change_type'] ?? ''))->toBe('different_version');
|
||||
expect(data_get($evidence, 'summary.kind'))->toBe('policy_snapshot');
|
||||
expect(data_get($evidence, 'baseline.policy_version_id'))->toBe((int) $baselineVersion->getKey());
|
||||
expect(data_get($evidence, 'current.policy_version_id'))->toBe((int) $currentVersion->getKey());
|
||||
expect(data_get($evidence, 'provenance.baseline_profile_id'))->toBe((int) $profile->getKey());
|
||||
expect(data_get($evidence, 'provenance.baseline_snapshot_id'))->toBe((int) $snapshot->getKey());
|
||||
expect(data_get($evidence, 'provenance.compare_operation_run_id'))->toBe((int) $compareRun->getKey());
|
||||
expect(data_get($evidence, 'provenance.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||
});
|
||||
|
||||
it('writes diff-compatible drift evidence for missing_policy drift findings', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$policyType = 'deviceConfiguration';
|
||||
$displayName = 'Policy Alpha';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => [$policyType], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$baselineCapturedAt = CarbonImmutable::now()->subHours(2)->setMicrosecond(123456);
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $baselineCapturedAt->subSecond(),
|
||||
]);
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: [$policyType => 'succeeded'],
|
||||
);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-alpha-uuid',
|
||||
'policy_type' => $policyType,
|
||||
'platform' => 'windows10',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => $policyType,
|
||||
'platform' => 'windows10',
|
||||
'version_number' => 1,
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
'snapshot' => ['etag' => 'E_BASELINE'],
|
||||
'assignments' => [],
|
||||
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
|
||||
]);
|
||||
|
||||
$hasher = app(DriftHasher::class);
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
|
||||
$baselineHash = $hasher->hashNormalized([
|
||||
'settings' => $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineVersion->snapshot ?? [],
|
||||
policyType: $policyType,
|
||||
platform: $baselineVersion->platform,
|
||||
),
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments ?? []),
|
||||
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($baselineVersion->scope_tags ?? []),
|
||||
]);
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $baselineCapturedAt->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => [$policyType], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->sole();
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
|
||||
AssertsDriftEvidenceContract::assertValid($evidence);
|
||||
|
||||
expect((string) ($evidence['change_type'] ?? ''))->toBe('missing_policy');
|
||||
expect(data_get($evidence, 'summary.kind'))->toBe('policy_snapshot');
|
||||
expect(data_get($evidence, 'baseline.policy_version_id'))->toBe((int) $baselineVersion->getKey());
|
||||
expect(data_get($evidence, 'current.policy_version_id'))->toBeNull();
|
||||
expect(data_get($evidence, 'provenance.baseline_profile_id'))->toBe((int) $profile->getKey());
|
||||
expect(data_get($evidence, 'provenance.baseline_snapshot_id'))->toBe((int) $snapshot->getKey());
|
||||
expect(data_get($evidence, 'provenance.compare_operation_run_id'))->toBe((int) $compareRun->getKey());
|
||||
expect(data_get($evidence, 'provenance.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||
});
|
||||
@ -157,4 +157,3 @@
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint);
|
||||
expect($finding->times_seen)->toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@ -247,10 +247,18 @@
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$realSettingsResolver = app(SettingsResolver::class);
|
||||
|
||||
$settingsResolver = mock(SettingsResolver::class);
|
||||
$settingsResolver
|
||||
->shouldReceive('resolveValue')
|
||||
->andThrow(new InvalidArgumentException('Unknown setting key: baseline.severity_mapping'));
|
||||
->andReturnUsing(function ($workspace, $domain, $key, $tenant = null) use ($realSettingsResolver): mixed {
|
||||
if ($domain === 'baseline' && $key === 'severity_mapping') {
|
||||
throw new InvalidArgumentException('Unknown setting key: baseline.severity_mapping');
|
||||
}
|
||||
|
||||
return $realSettingsResolver->resolveValue($workspace, $domain, $key, $tenant);
|
||||
});
|
||||
|
||||
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
|
||||
|
||||
@ -466,8 +474,9 @@
|
||||
expect($finding->times_seen)->toBe(1);
|
||||
|
||||
$fingerprint = (string) $finding->fingerprint;
|
||||
$currentHash1 = (string) ($finding->evidence_jsonb['current_hash'] ?? '');
|
||||
$currentHash1 = (string) data_get($finding->evidence_jsonb, 'current.hash');
|
||||
expect($currentHash1)->not->toBe('');
|
||||
expect(data_get($finding->evidence_jsonb, 'summary.kind'))->toBe('policy_snapshot');
|
||||
|
||||
// Retry the same run ID (job retry): times_seen MUST NOT increment twice for the same run.
|
||||
$job->handle(
|
||||
@ -479,7 +488,8 @@
|
||||
$finding->refresh();
|
||||
expect($finding->times_seen)->toBe(1);
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint);
|
||||
expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->toBe($currentHash1);
|
||||
expect(data_get($finding->evidence_jsonb, 'summary.kind'))->toBe('policy_snapshot');
|
||||
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->toBe($currentHash1);
|
||||
|
||||
// Change inventory evidence (hash changes) and run compare again with a new OperationRun.
|
||||
InventoryItem::query()
|
||||
@ -511,7 +521,8 @@
|
||||
$finding->refresh();
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint);
|
||||
expect($finding->times_seen)->toBe(2);
|
||||
expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->not->toBe($currentHash1);
|
||||
expect(data_get($finding->evidence_jsonb, 'summary.kind'))->toBe('policy_snapshot');
|
||||
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
|
||||
});
|
||||
|
||||
it('does not create new finding identities when a new snapshot is captured', function () {
|
||||
|
||||
@ -4,15 +4,27 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
it('records no_subjects_in_scope when the resolved subject list is empty', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -195,6 +207,178 @@
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
});
|
||||
|
||||
it('records no_drift_detected when full-content compare reuses an older identical version', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshotCapturedAt = CarbonImmutable::parse('2026-03-06 00:41:38');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $snapshotCapturedAt,
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => 'stable-policy',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Stable Policy',
|
||||
]);
|
||||
|
||||
$snapshotPayload = [
|
||||
'settings' => [
|
||||
['displayName' => 'SettingStable', 'value' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $snapshotCapturedAt->subMinutes(10)->toIso8601String(),
|
||||
'observed_operation_run_id' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
attributes: [
|
||||
'completed_at' => $snapshotCapturedAt->addSeconds(5),
|
||||
],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
||||
'etag' => 'W/"stable"',
|
||||
'scope_tag_ids' => [],
|
||||
'assignment_target_count' => 1,
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => $snapshotCapturedAt->addSeconds(5),
|
||||
]);
|
||||
|
||||
$existingVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => $snapshotCapturedAt->subMinutes(10),
|
||||
'snapshot' => $snapshotPayload,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$fakeOrchestrator = new class($existingVersion) extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyVersion $existingVersion,
|
||||
) {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
return [
|
||||
'version' => $this->existingVersion->fresh(),
|
||||
'captured' => [
|
||||
'payload' => $this->existingVersion->snapshot,
|
||||
'assignments' => $this->existingVersion->assignments,
|
||||
'scope_tags' => $this->existingVersion->scope_tags,
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.coverage.resolved_content'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.evidence_gaps.count'))->toBe(0);
|
||||
expect(
|
||||
Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->count(),
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
it('records coverage_unproven when findings are suppressed due to missing coverage proof', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'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');
|
||||
});
|
||||
@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
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');
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'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');
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
expect($selector->selectBaselineAndCurrent($tenant, $scopeKey))->toBeNull();
|
||||
});
|
||||
@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\OperationRun;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
OperationRun::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'drift_generate_findings',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => 'drift-zero-findings',
|
||||
'summary_counts' => [
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'failed' => 0,
|
||||
'created' => 0,
|
||||
],
|
||||
'context' => [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
->assertSet('state', 'ready')
|
||||
->assertSet('scopeKey', $scopeKey);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
Queue::assertNotPushed(GenerateDriftFindingsJob::class);
|
||||
});
|
||||
178
tests/Feature/Drift/DriftFindingDiffUnavailableTest.php
Normal file
178
tests/Feature/Drift/DriftFindingDiffUnavailableTest.php
Normal file
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
|
||||
it('shows an explicit diff unavailable message when policy version references are missing', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-alpha-uuid',
|
||||
'evidence_fidelity' => 'meta',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'different_version',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'policy alpha',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => null,
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => null,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => 1,
|
||||
'baseline_snapshot_id' => 1,
|
||||
'compare_operation_run_id' => 1,
|
||||
'inventory_sync_run_id' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
)))
|
||||
->assertOk()
|
||||
->assertSee('Diff unavailable')
|
||||
->assertDontSee('No normalized changes were found');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty baseline for unexpected_policy findings with a current policy version reference', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-unexpected-uuid',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Bitlocker Require',
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'windows',
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'passwordRequired' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-unexpected-uuid',
|
||||
'evidence_fidelity' => 'mixed',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'subject_key' => 'bitlocker require',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => null,
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => (int) $currentVersion->getKey(),
|
||||
],
|
||||
'fidelity' => 'mixed',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => 1,
|
||||
'baseline_snapshot_id' => 1,
|
||||
'compare_operation_run_id' => 1,
|
||||
'inventory_sync_run_id' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
)))
|
||||
->assertOk()
|
||||
->assertDontSee('Diff unavailable')
|
||||
->assertSee('1 added')
|
||||
->assertSee('Password required');
|
||||
});
|
||||
|
||||
it('renders a diff against an empty current side for missing_policy findings with a baseline policy version reference', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'policy-missing-uuid',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Bitlocker Require',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'platform' => 'windows',
|
||||
'snapshot' => [
|
||||
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
|
||||
'passwordRequired' => true,
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-missing-uuid',
|
||||
'evidence_fidelity' => 'mixed',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'missing_policy',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'subject_key' => 'bitlocker require',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => (int) $baselineVersion->getKey(),
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => null,
|
||||
],
|
||||
'fidelity' => 'mixed',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => 1,
|
||||
'baseline_snapshot_id' => 1,
|
||||
'compare_operation_run_id' => 1,
|
||||
'inventory_sync_run_id' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('filament.tenant.resources.findings.view', array_merge(
|
||||
filamentTenantRouteParams($tenant),
|
||||
['record' => $finding],
|
||||
)))
|
||||
->assertOk()
|
||||
->assertDontSee('Diff unavailable')
|
||||
->assertSee('1 removed')
|
||||
->assertSee('Password required');
|
||||
});
|
||||
@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'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);
|
||||
});
|
||||
@ -1,156 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||
$mock->shouldReceive('listPolicies')->never();
|
||||
$mock->shouldReceive('getPolicy')->never();
|
||||
$mock->shouldReceive('getOrganization')->never();
|
||||
$mock->shouldReceive('applyPolicy')->never();
|
||||
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||
$mock->shouldReceive('request')->never();
|
||||
});
|
||||
});
|
||||
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift_generate_findings')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun?->status)->toBe('queued');
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $opRun): 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->operationRun instanceof OperationRun
|
||||
&& (int) $job->operationRun->getKey() === (int) $opRun?->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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, 1);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift_generate_findings')
|
||||
->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');
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift_generate_findings')
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('opening Drift does not dispatch generation for readonly users', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-readonly-blocked');
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift_generate_findings')
|
||||
->count())->toBe(0);
|
||||
});
|
||||
@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('uses operation runs wording on the drift landing page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
->assertSee('operation runs')
|
||||
->assertDontSee('inventory sync runs');
|
||||
});
|
||||
@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'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());
|
||||
});
|
||||
|
||||
test('drift landing exposes baseline compare coverage + fidelity context when available', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-landing-comparison-info');
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$compareRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => [],
|
||||
'uncovered_types' => ['deviceConfiguration'],
|
||||
'proof' => false,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
->assertSet('baselineCompareRunId', (int) $compareRun->getKey())
|
||||
->assertSet('baselineCompareCoverageStatus', 'unproven')
|
||||
->assertSet('baselineCompareFidelity', 'meta')
|
||||
->assertSet('baselineCompareUncoveredTypesCount', 1);
|
||||
});
|
||||
@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
|
||||
test('uses medium severity for drift findings when no severity mapping exists', function () {
|
||||
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-policy-snapshot-default-severity');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'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->severity)->toBe(Finding::SEVERITY_MEDIUM);
|
||||
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');
|
||||
});
|
||||
|
||||
test('applies workspace drift severity mapping when configured', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
WorkspaceSetting::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'domain' => 'drift',
|
||||
'key' => 'severity_mapping',
|
||||
'value' => ['drift' => 'critical'],
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-policy-snapshot-mapped-severity');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'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->severity)->toBe(Finding::SEVERITY_CRITICAL);
|
||||
});
|
||||
@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'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);
|
||||
});
|
||||
@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'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');
|
||||
});
|
||||
@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
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 = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||
'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);
|
||||
});
|
||||
@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
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 = createInventorySyncOperationRun($tenantA, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$currentA = createInventorySyncOperationRun($tenantA, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'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);
|
||||
});
|
||||
@ -1,157 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
test('drift generation job sends completion notification with view link', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
config()->set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-job-notification-success');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$opRun = OperationRun::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'drift_generate_findings',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'drift-hash-1',
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => 'entra-1',
|
||||
],
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('generate')->once()->andReturn(0);
|
||||
});
|
||||
|
||||
$job = new GenerateDriftFindingsJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
app(DriftFindingGenerator::class),
|
||||
app(OperationRunService::class),
|
||||
app(TargetScopeConcurrencyLimiter::class),
|
||||
);
|
||||
|
||||
$opRun->refresh();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||
});
|
||||
|
||||
test('drift generation job sends failure notification with view link', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
config()->set('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-job-notification-failure');
|
||||
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$opRun = OperationRun::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'drift_generate_findings',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'drift-hash-2',
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => 'entra-1',
|
||||
],
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('generate')->once()->andThrow(new \RuntimeException('boom'));
|
||||
});
|
||||
|
||||
$job = new GenerateDriftFindingsJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
try {
|
||||
$job->handle(
|
||||
app(DriftFindingGenerator::class),
|
||||
app(OperationRunService::class),
|
||||
app(TargetScopeConcurrencyLimiter::class),
|
||||
);
|
||||
} catch (\RuntimeException) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
$opRun->refresh();
|
||||
expect($opRun->status)->toBe('completed')
|
||||
->and($opRun->outcome)->toBe('failed');
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
|
||||
it('deletes legacy drift findings and keeps baseline compare drift findings', function (): void {
|
||||
$legacyNullSource = Finding::factory()->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => null,
|
||||
]);
|
||||
|
||||
$legacyOtherSource = Finding::factory()->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'legacy.drift',
|
||||
]);
|
||||
|
||||
$baselineCompareFinding = Finding::factory()->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
]);
|
||||
|
||||
$nonDriftFinding = Finding::factory()->permissionPosture()->create();
|
||||
|
||||
$migrationPath = base_path('database/migrations/2026_03_05_000001_delete_legacy_drift_findings.php');
|
||||
|
||||
expect(file_exists($migrationPath))->toBeTrue();
|
||||
|
||||
$migration = require $migrationPath;
|
||||
|
||||
$migration->up();
|
||||
|
||||
expect(Finding::query()->whereKey($legacyNullSource->getKey())->exists())->toBeFalse();
|
||||
expect(Finding::query()->whereKey($legacyOtherSource->getKey())->exists())->toBeFalse();
|
||||
expect(Finding::query()->whereKey($baselineCompareFinding->getKey())->exists())->toBeTrue();
|
||||
expect(Finding::query()->whereKey($nonDriftFinding->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
@ -34,6 +34,10 @@
|
||||
]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -41,6 +45,8 @@
|
||||
'external_id' => 'dup-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -49,6 +55,8 @@
|
||||
'external_id' => 'dup-2',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
@ -56,3 +64,82 @@
|
||||
->assertSee('share the same display name')
|
||||
->assertSee('cannot match them to the baseline');
|
||||
});
|
||||
|
||||
it('does not show the duplicate-name warning for stale rows outside the latest inventory sync', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$olderInventoryRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
attributes: [
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
'finished_at' => now()->subMinutes(10),
|
||||
],
|
||||
);
|
||||
|
||||
$latestInventoryRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
attributes: [
|
||||
'completed_at' => now(),
|
||||
'finished_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'stale-duplicate',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Standard',
|
||||
'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(),
|
||||
'last_seen_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'current-standard',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Standard',
|
||||
'last_seen_operation_run_id' => (int) $latestInventoryRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'current-unique',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Unique Policy',
|
||||
'last_seen_operation_run_id' => (int) $latestInventoryRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertDontSee(__('baseline-compare.duplicate_warning_title'))
|
||||
->assertDontSee('share the same display name')
|
||||
->assertDontSee('cannot match them to the baseline');
|
||||
});
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
@ -133,4 +132,3 @@
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@ -80,4 +80,3 @@
|
||||
|
||||
expect($restoreRun->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user