feat: baseline drift engine v1

- Implement Spec 116 baseline capture/compare + coverage guard\n- Add UI surfaces and widgets for baseline compare\n- Add tests and research report
This commit is contained in:
Ahmed Darrazi 2026-03-02 23:01:39 +01:00
parent 8079bd9cdd
commit 04d61cbad0
51 changed files with 4029 additions and 325 deletions

View File

@ -59,8 +59,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 116-baseline-drift-engine-session-1772451227: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 116-baseline-drift-engine: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 116-baseline-drift-engine: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 - 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -15,6 +15,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
@ -61,6 +62,15 @@ class BaselineCompareLanding extends Page
public ?string $failureReason = null; public ?string $failureReason = null;
public ?string $coverageStatus = null;
public ?int $uncoveredTypesCount = null;
/** @var list<string>|null */
public ?array $uncoveredTypes = null;
public ?string $fidelity = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = auth()->user(); $user = auth()->user();
@ -100,6 +110,11 @@ public function refreshStats(): void
$this->lastComparedAt = $stats->lastComparedHuman; $this->lastComparedAt = $stats->lastComparedHuman;
$this->lastComparedIso = $stats->lastComparedIso; $this->lastComparedIso = $stats->lastComparedIso;
$this->failureReason = $stats->failureReason; $this->failureReason = $stats->failureReason;
$this->coverageStatus = $stats->coverageStatus;
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
$this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null;
$this->fidelity = $stats->fidelity;
} }
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
@ -125,13 +140,12 @@ protected function getHeaderActions(): array
private function compareNowAction(): Action private function compareNowAction(): Action
{ {
return Action::make('compareNow') $action = Action::make('compareNow')
->label('Compare Now') ->label('Compare Now')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Start baseline comparison') ->modalHeading('Start baseline comparison')
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.') ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
->visible(fn (): bool => $this->canCompare())
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true)) ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
->action(function (): void { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
@ -181,25 +195,11 @@ private function compareNowAction(): Action
] : []) ] : [])
->send(); ->send();
}); });
}
private function canCompare(): bool return UiEnforcement::forAction($action)
{ ->requireCapability(Capabilities::TENANT_SYNC)
$user = auth()->user(); ->preserveDisabled()
->apply();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
} }
public function getFindingsUrl(): ?string public function getFindingsUrl(): ?string

View File

@ -13,6 +13,7 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -47,6 +48,17 @@ class DriftLanding extends Page
public ?string $currentFinishedAt = 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; public ?int $operationRunId = null;
/** @var array<string, int>|null */ /** @var array<string, int>|null */
@ -66,6 +78,16 @@ public function mount(): void
abort(403, 'Not allowed'); 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() $latestSuccessful = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync') ->where('type', 'inventory_sync')
@ -292,4 +314,13 @@ public function getOperationRunUrl(): ?string
return OperationRunLinks::view($this->operationRunId, Tenant::current()); 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());
}
} }

View File

@ -13,6 +13,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -25,6 +26,7 @@
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -38,6 +40,8 @@
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
use UnitEnum; use UnitEnum;
class BaselineProfileResource extends Resource class BaselineProfileResource extends Resource
@ -147,39 +151,53 @@ public static function form(Schema $schema): Schema
TextInput::make('name') TextInput::make('name')
->required() ->required()
->maxLength(255) ->maxLength(255)
->rule(fn (?BaselineProfile $record): Unique => Rule::unique('baseline_profiles', 'name')
->where('workspace_id', $record?->workspace_id ?? app(WorkspaceContext::class)->currentWorkspaceId(request()))
->ignore($record))
->helperText('A descriptive name for this baseline profile.'), ->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description') Textarea::make('description')
->rows(3) ->rows(3)
->maxLength(1000) ->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'), ->helperText('Explain the purpose and scope of this baseline.'),
]),
Section::make('Controls')
->schema([
Select::make('status')
->required()
->options(fn (?BaselineProfile $record): array => self::statusOptionsForRecord($record))
->default(BaselineProfileStatus::Draft->value)
->native(false)
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
->helperText(fn (?BaselineProfile $record): string => match ($record?->status) {
BaselineProfileStatus::Archived => 'Archived baselines cannot be reactivated.',
BaselineProfileStatus::Active => 'Changing status to Archived is permanent.',
default => 'Only active baselines are enforced during compliance checks.',
}),
TextInput::make('version_label') TextInput::make('version_label')
->label('Version label') ->label('Version label')
->maxLength(50) ->maxLength(50)
->placeholder('e.g. v2.1 — February rollout') ->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'), ->helperText('Optional label to identify this version.'),
Select::make('status')
->required()
->options([
BaselineProfile::STATUS_DRAFT => 'Draft',
BaselineProfile::STATUS_ACTIVE => 'Active',
BaselineProfile::STATUS_ARCHIVED => 'Archived',
])
->default(BaselineProfile::STATUS_DRAFT)
->native(false)
->helperText('Only active baselines are enforced during compliance checks.'),
])
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
Select::make('scope_jsonb.policy_types') Select::make('scope_jsonb.policy_types')
->label('Policy type scope') ->label('Policy types')
->multiple() ->multiple()
->options(self::policyTypeOptions()) ->options(self::policyTypeOptions())
->helperText('Leave empty to include all policy types.') ->helperText('Leave empty to include all supported policy types (excluding foundations).')
->native(false), ->native(false),
Select::make('scope_jsonb.foundation_types')
->label('Foundations')
->multiple()
->options(self::foundationTypeOptions())
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
->native(false),
Placeholder::make('metadata')
->label('Last modified')
->content(fn (?BaselineProfile $record): string => $record?->updated_at
? $record->updated_at->diffForHumans()
: '—')
->visible(fn (?BaselineProfile $record): bool => $record !== null),
]) ])
->columnSpanFull(), ->columns(2),
]); ]);
} }
@ -207,14 +225,23 @@ public static function infolist(Schema $schema): Schema
Section::make('Scope') Section::make('Scope')
->schema([ ->schema([
TextEntry::make('scope_jsonb.policy_types') TextEntry::make('scope_jsonb.policy_types')
->label('Policy type scope') ->label('Policy types')
->badge() ->badge()
->formatStateUsing(function (string $state): string { ->formatStateUsing(function (string $state): string {
$options = self::policyTypeOptions(); $options = self::policyTypeOptions();
return $options[$state] ?? $state; return $options[$state] ?? $state;
}) })
->placeholder('All policy types'), ->placeholder('All supported policy types (excluding foundations)'),
TextEntry::make('scope_jsonb.foundation_types')
->label('Foundations')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::foundationTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('None'),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Metadata') Section::make('Metadata')
@ -314,7 +341,21 @@ public static function getPages(): array
*/ */
public static function policyTypeOptions(): array public static function policyTypeOptions(): array
{ {
return collect(InventoryPolicyTypeMeta::all()) return collect(InventoryPolicyTypeMeta::supported())
->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
])
->sort()
->all();
}
/**
* @return array<string, string>
*/
public static function foundationTypeOptions(): array
{
return collect(InventoryPolicyTypeMeta::foundations())
->filter(fn (array $row): bool => filled($row['type'] ?? null)) ->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [ ->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => (string) ($row['label'] ?? $row['type']), (string) $row['type'] => (string) ($row['label'] ?? $row['type']),
@ -346,6 +387,24 @@ public static function audit(BaselineProfile $record, AuditActionId $actionId, a
); );
} }
/**
* Status options scoped to valid transitions from the current record state.
*
* @return array<string, string>
*/
private static function statusOptionsForRecord(?BaselineProfile $record): array
{
if ($record === null) {
return [BaselineProfileStatus::Draft->value => BaselineProfileStatus::Draft->label()];
}
$currentStatus = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $currentStatus->selectOptions();
}
private static function resolveWorkspace(): ?Workspace private static function resolveWorkspace(): ?Workspace
{ {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
@ -381,13 +440,13 @@ private static function archiveTableAction(?Workspace $workspace): Action
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Archive baseline profile') ->modalHeading('Archive baseline profile')
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.') ->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability()) ->hidden(fn (BaselineProfile $record): bool => $record->status === BaselineProfileStatus::Archived)
->action(function (BaselineProfile $record): void { ->action(function (BaselineProfile $record): void {
if (! self::hasManageCapability()) { if (! self::hasManageCapability()) {
throw new AuthorizationException; throw new AuthorizationException;
} }
$record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save(); $record->forceFill(['status' => BaselineProfileStatus::Archived->value])->save();
self::audit($record, AuditActionId::BaselineProfileArchived, [ self::audit($record, AuditActionId::BaselineProfileArchived, [
'baseline_profile_id' => (int) $record->getKey(), 'baseline_profile_id' => (int) $record->getKey(),

View File

@ -8,6 +8,7 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\User; use App\Models\User;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
@ -28,8 +29,14 @@ protected function mutateFormDataBeforeCreate(array $data): array
$user = auth()->user(); $user = auth()->user();
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null; $data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
$policyTypes = $data['scope_jsonb']['policy_types'] ?? []; if (isset($data['scope_jsonb'])) {
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; $policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
$data['scope_jsonb'] = [
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
];
}
return $data; return $data;
} }
@ -45,7 +52,9 @@ protected function afterCreate(): void
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [ BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
'baseline_profile_id' => (int) $record->getKey(), 'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name, 'name' => (string) $record->name,
'status' => (string) $record->status, 'status' => $record->status instanceof BaselineProfileStatus
? $record->status->value
: (string) $record->status,
]); ]);
Notification::make() Notification::make()

View File

@ -7,6 +7,7 @@
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@ -14,14 +15,49 @@ class EditBaselineProfile extends EditRecord
{ {
protected static string $resource = BaselineProfileResource::class; protected static string $resource = BaselineProfileResource::class;
public function getSubHeading(): string
{
$record = $this->getRecord();
$status = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $status->label();
}
public function getSubHeadingBadgeColor(): string
{
$record = $this->getRecord();
$status = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $status->color();
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return array<string, mixed> * @return array<string, mixed>
*/ */
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeSave(array $data): array
{ {
$policyTypes = $data['scope_jsonb']['policy_types'] ?? []; $record = $this->getRecord();
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []]; $currentStatus = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
if ($currentStatus === BaselineProfileStatus::Archived) {
unset($data['status']);
}
if (isset($data['scope_jsonb'])) {
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
$data['scope_jsonb'] = [
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
];
}
return $data; return $data;
} }
@ -37,7 +73,9 @@ protected function afterSave(): void
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [ BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
'baseline_profile_id' => (int) $record->getKey(), 'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name, 'name' => (string) $record->name,
'status' => (string) $record->status, 'status' => $record->status instanceof BaselineProfileStatus
? $record->status->value
: (string) $record->status,
]); ]);
Notification::make() Notification::make()
@ -45,4 +83,9 @@ protected function afterSave(): void
->success() ->success()
->send(); ->send();
} }
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]);
}
} }

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
@ -36,13 +37,10 @@ protected function getHeaderActions(): array
private function captureAction(): Action private function captureAction(): Action
{ {
return Action::make('capture') $action = Action::make('capture')
->label('Capture Snapshot') ->label('Capture Snapshot')
->icon('heroicon-o-camera') ->icon('heroicon-o-camera')
->color('primary') ->color('primary')
->visible(fn (): bool => $this->hasManageCapability())
->disabled(fn (): bool => ! $this->hasManageCapability())
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Capture Baseline Snapshot') ->modalHeading('Capture Baseline Snapshot')
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.') ->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
@ -56,13 +54,8 @@ private function captureAction(): Action
->action(function (array $data): void { ->action(function (array $data): void {
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) { if (! $user instanceof User) {
Notification::make() abort(403);
->title('Permission denied')
->danger()
->send();
return;
} }
/** @var BaselineProfile $profile */ /** @var BaselineProfile $profile */
@ -123,6 +116,12 @@ private function captureAction(): Action
->actions([$viewAction]) ->actions([$viewAction])
->send(); ->send();
}); });
return WorkspaceUiEnforcement::forTableAction($action, fn (): ?Workspace => Workspace::query()
->whereKey((int) $this->getRecord()->workspace_id)
->first())
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->apply();
} }
/** /**

View File

@ -55,7 +55,8 @@ public function table(Table $table): Table
->sortable(), ->sortable(),
]) ])
->headerActions([ ->headerActions([
$this->assignTenantAction(), $this->assignTenantAction()
->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()),
]) ])
->actions([ ->actions([
$this->removeAssignmentAction(), $this->removeAssignmentAction(),
@ -63,7 +64,7 @@ public function table(Table $table): Table
->emptyStateHeading('No tenants assigned') ->emptyStateHeading('No tenants assigned')
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.') ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
->emptyStateActions([ ->emptyStateActions([
$this->assignTenantAction(), $this->assignTenantAction()->name('assignEmpty'),
]); ]);
} }

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\FindingResource\Pages; namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Jobs\BackfillFindingLifecycleJob; use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant; use App\Models\Tenant;
@ -27,6 +28,13 @@ class ListFindings extends ListRecords
{ {
protected static string $resource = FindingResource::class; protected static string $resource = FindingResource::class;
protected function getHeaderWidgets(): array
{
return [
BaselineCompareCoverageBanner::class,
];
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$actions = []; $actions = [];

View File

@ -184,6 +184,81 @@ public static function infolist(Schema $schema): Schema
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary)) ->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(), ->columnSpanFull(),
Section::make('Baseline compare')
->schema([
TextEntry::make('baseline_compare_fidelity')
->label('Fidelity')
->badge()
->getStateUsing(function (OperationRun $record): string {
$context = is_array($record->context) ? $record->context : [];
$fidelity = data_get($context, 'baseline_compare.fidelity');
return is_string($fidelity) && $fidelity !== '' ? $fidelity : 'meta';
}),
TextEntry::make('baseline_compare_coverage_status')
->label('Coverage')
->badge()
->getStateUsing(function (OperationRun $record): string {
$context = is_array($record->context) ? $record->context : [];
$proof = data_get($context, 'baseline_compare.coverage.proof');
$proof = is_bool($proof) ? $proof : null;
$uncovered = data_get($context, 'baseline_compare.coverage.uncovered_types');
$uncovered = is_array($uncovered) ? array_values(array_filter($uncovered, 'is_string')) : [];
return match (true) {
$proof === false => 'unproven',
$uncovered !== [] => 'warnings',
$proof === true => 'ok',
default => 'unknown',
};
})
->color(fn (?string $state): string => match ((string) $state) {
'ok' => 'success',
'warnings', 'unproven' => 'warning',
default => 'gray',
}),
TextEntry::make('baseline_compare_uncovered_types')
->label('Uncovered types')
->getStateUsing(function (OperationRun $record): ?string {
$context = is_array($record->context) ? $record->context : [];
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
$types = is_array($types) ? array_values(array_filter($types, 'is_string')) : [];
$types = array_values(array_unique(array_filter(array_map('trim', $types), fn (string $type): bool => $type !== '')));
if ($types === []) {
return null;
}
sort($types, SORT_STRING);
return implode(', ', array_slice($types, 0, 12)).(count($types) > 12 ? '…' : '');
})
->visible(function (OperationRun $record): bool {
$context = is_array($record->context) ? $record->context : [];
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
return is_array($types) && $types !== [];
})
->columnSpanFull(),
TextEntry::make('baseline_compare_inventory_sync_run_id')
->label('Inventory sync run')
->getStateUsing(function (OperationRun $record): ?string {
$context = is_array($record->context) ? $record->context : [];
$syncRunId = data_get($context, 'baseline_compare.inventory_sync_run_id');
return is_numeric($syncRunId) ? '#'.(string) (int) $syncRunId : null;
})
->visible(function (OperationRun $record): bool {
$context = is_array($record->context) ? $record->context : [];
return is_numeric(data_get($context, 'baseline_compare.inventory_sync_run_id'));
}),
])
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
->columns(2)
->columnSpanFull(),
Section::make('Verification report') Section::make('Verification report')
->schema([ ->schema([
ViewEntry::make('verification_report') ViewEntry::make('verification_report')

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class BaselineCompareCoverageBanner extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.baseline-compare-coverage-banner';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'shouldShow' => false,
];
}
$stats = BaselineCompareStats::forTenant($tenant);
$uncoveredTypes = $stats->uncoveredTypes ?? [];
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
$coverageStatus = $stats->coverageStatus;
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
$runUrl = null;
if ($stats->operationRunId !== null) {
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
}
return [
'shouldShow' => $hasWarnings && $runUrl !== null,
'runUrl' => $runUrl,
'coverageStatus' => $coverageStatus,
'fidelity' => $stats->fidelity,
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
'uncoveredTypes' => $uncoveredTypes,
];
}
}

View File

@ -11,8 +11,10 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -45,6 +47,7 @@ public function middleware(): array
public function handle( public function handle(
BaselineSnapshotIdentity $identity, BaselineSnapshotIdentity $identity,
InventoryMetaContract $metaContract,
AuditLogger $auditLogger, AuditLogger $auditLogger,
OperationRunService $operationRunService, OperationRunService $operationRunService,
): void { ): void {
@ -78,7 +81,7 @@ public function handle(
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator); $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity); $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity, $metaContract);
$identityHash = $identity->computeIdentity($snapshotItems); $identityHash = $identity->computeIdentity($snapshotItems);
@ -90,7 +93,7 @@ public function handle(
$wasNewSnapshot = $snapshot->wasRecentlyCreated; $wasNewSnapshot = $snapshot->wasRecentlyCreated;
if ($profile->status === BaselineProfile::STATUS_ACTIVE) { if ($profile->status === BaselineProfileStatus::Active) {
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
} }
@ -127,22 +130,31 @@ private function collectSnapshotItems(
Tenant $sourceTenant, Tenant $sourceTenant,
BaselineScope $scope, BaselineScope $scope,
BaselineSnapshotIdentity $identity, BaselineSnapshotIdentity $identity,
InventoryMetaContract $metaContract,
): array { ): array {
$query = InventoryItem::query() $query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey()); ->where('tenant_id', $sourceTenant->getKey());
if (! $scope->isEmpty()) { $query->whereIn('policy_type', $scope->allTypes());
$query->whereIn('policy_type', $scope->policyTypes);
}
$items = []; $items = [];
$query->orderBy('policy_type') $query->orderBy('policy_type')
->orderBy('external_id') ->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items, $identity): void { ->chunk(500, function ($inventoryItems) use (&$items, $identity, $metaContract): void {
foreach ($inventoryItems as $inventoryItem) { foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$baselineHash = $identity->hashItemContent($metaJsonb); $contract = $metaContract->build(
policyType: (string) $inventoryItem->policy_type,
subjectExternalId: (string) $inventoryItem->external_id,
metaJsonb: $metaJsonb,
);
$baselineHash = $identity->hashItemContent(
policyType: (string) $inventoryItem->policy_type,
subjectExternalId: (string) $inventoryItem->external_id,
metaJsonb: $metaJsonb,
);
$items[] = [ $items[] = [
'subject_type' => 'policy', 'subject_type' => 'policy',
@ -153,6 +165,13 @@ private function collectSnapshotItems(
'display_name' => $inventoryItem->display_name, 'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category, 'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform, 'platform' => $inventoryItem->platform,
'meta_contract' => $contract,
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => $inventoryItem->last_seen_at?->toIso8601String(),
'observed_operation_run_id' => is_numeric($inventoryItem->last_seen_operation_run_id)
? (int) $inventoryItem->last_seen_operation_run_id
: null,
], ],
]; ];
} }

View File

@ -15,11 +15,11 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
@ -52,7 +52,6 @@ public function middleware(): array
} }
public function handle( public function handle(
DriftHasher $driftHasher,
BaselineSnapshotIdentity $snapshotIdentity, BaselineSnapshotIdentity $snapshotIdentity,
AuditLogger $auditLogger, AuditLogger $auditLogger,
OperationRunService $operationRunService, OperationRunService $operationRunService,
@ -95,13 +94,75 @@ public function handle(
: null; : null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null); $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$effectiveTypes = $effectiveScope->allTypes();
$scopeKey = 'baseline_profile:'.$profile->getKey(); $scopeKey = 'baseline_profile:'.$profile->getKey();
$this->auditStarted($auditLogger, $tenant, $profile, $initiator); $this->auditStarted($auditLogger, $tenant, $profile, $initiator);
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope); if ($effectiveTypes === []) {
$latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant); $this->completeWithCoverageWarning(
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId); operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: null,
coverageProof: false,
effectiveTypes: [],
coveredTypes: [],
uncoveredTypes: [],
errorsRecorded: 1,
);
return;
}
$inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant);
$coverage = $inventorySyncRun instanceof OperationRun
? InventoryCoverage::fromContext($inventorySyncRun->context)
: null;
if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: false,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
);
return;
}
$coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()));
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
if ($coveredTypes === []) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: true,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
);
return;
}
$baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes);
$currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, $snapshotIdentity, (int) $inventorySyncRun->getKey());
$driftResults = $this->computeDrift( $driftResults = $this->computeDrift(
$baselineItems, $baselineItems,
@ -110,20 +171,22 @@ public function handle(
); );
$upsertResult = $this->upsertFindings( $upsertResult = $this->upsertFindings(
$driftHasher,
$tenant, $tenant,
$profile, $profile,
$snapshotId,
$scopeKey, $scopeKey,
$driftResults, $driftResults,
); );
$severityBreakdown = $this->countBySeverity($driftResults); $severityBreakdown = $this->countBySeverity($driftResults);
$countsByChangeType = $this->countByChangeType($driftResults);
$summaryCounts = [ $summaryCounts = [
'total' => count($driftResults), 'total' => count($driftResults),
'processed' => count($driftResults), 'processed' => count($driftResults),
'succeeded' => (int) $upsertResult['processed_count'], 'succeeded' => (int) $upsertResult['processed_count'],
'failed' => 0, 'failed' => 0,
'errors_recorded' => count($uncoveredTypes),
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0, 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0, 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0, 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
@ -135,7 +198,7 @@ public function handle(
$operationRunService->updateRun( $operationRunService->updateRun(
$this->operationRun, $this->operationRun,
status: OperationRunStatus::Completed->value, status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value, outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value,
summaryCounts: $summaryCounts, summaryCounts: $summaryCounts,
); );
@ -160,6 +223,25 @@ public function handle(
} }
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
[
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'coverage' => [
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
'proof' => true,
],
'fidelity' => 'meta',
],
);
$updatedContext['findings'] = array_merge(
is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [],
[
'counts_by_change_type' => $countsByChangeType,
],
);
$updatedContext['result'] = [ $updatedContext['result'] = [
'findings_total' => count($driftResults), 'findings_total' => count($driftResults),
'findings_upserted' => (int) $upsertResult['processed_count'], 'findings_upserted' => (int) $upsertResult['processed_count'],
@ -171,21 +253,89 @@ public function handle(
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts); $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
} }
private function completeWithCoverageWarning(
OperationRunService $operationRunService,
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
?OperationRun $inventorySyncRun,
bool $coverageProof,
array $effectiveTypes,
array $coveredTypes,
array $uncoveredTypes,
int $errorsRecorded,
): void {
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'errors_recorded' => max(1, $errorsRecorded),
'high' => 0,
'medium' => 0,
'low' => 0,
'findings_created' => 0,
'findings_reopened' => 0,
'findings_unchanged' => 0,
];
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: $summaryCounts,
);
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
[
'inventory_sync_run_id' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null,
'coverage' => [
'effective_types' => array_values($effectiveTypes),
'covered_types' => array_values($coveredTypes),
'uncovered_types' => array_values($uncoveredTypes),
'proof' => $coverageProof,
],
'fidelity' => 'meta',
],
);
$updatedContext['findings'] = array_merge(
is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [],
[
'counts_by_change_type' => [],
],
);
$updatedContext['result'] = [
'findings_total' => 0,
'findings_upserted' => 0,
'findings_resolved' => 0,
'severity_breakdown' => [],
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
}
/** /**
* Load baseline snapshot items keyed by "policy_type|subject_external_id". * Load baseline snapshot items keyed by "policy_type|subject_external_id".
* *
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> * @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/ */
private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array private function loadBaselineItems(int $snapshotId, array $policyTypes): array
{ {
$items = []; $items = [];
if ($policyTypes === []) {
return $items;
}
$query = BaselineSnapshotItem::query() $query = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId); ->where('baseline_snapshot_id', $snapshotId);
if (! $scope->isEmpty()) { $query->whereIn('policy_type', $policyTypes);
$query->whereIn('policy_type', $scope->policyTypes);
}
$query $query
->orderBy('id') ->orderBy('id')
@ -212,7 +362,7 @@ private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
*/ */
private function loadCurrentInventory( private function loadCurrentInventory(
Tenant $tenant, Tenant $tenant,
BaselineScope $scope, array $policyTypes,
BaselineSnapshotIdentity $snapshotIdentity, BaselineSnapshotIdentity $snapshotIdentity,
?int $latestInventorySyncRunId = null, ?int $latestInventorySyncRunId = null,
): array { ): array {
@ -223,10 +373,12 @@ private function loadCurrentInventory(
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId); $query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
} }
if (! $scope->isEmpty()) { if ($policyTypes === []) {
$query->whereIn('policy_type', $scope->policyTypes); return [];
} }
$query->whereIn('policy_type', $policyTypes);
$items = []; $items = [];
$query->orderBy('policy_type') $query->orderBy('policy_type')
@ -234,7 +386,11 @@ private function loadCurrentInventory(
->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void { ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
foreach ($inventoryItems as $inventoryItem) { foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb); $currentHash = $snapshotIdentity->hashItemContent(
policyType: (string) $inventoryItem->policy_type,
subjectExternalId: (string) $inventoryItem->external_id,
metaJsonb: $metaJsonb,
);
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id; $key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
$items[$key] = [ $items[$key] = [
@ -253,7 +409,7 @@ private function loadCurrentInventory(
return $items; return $items;
} }
private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
{ {
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
@ -261,11 +417,9 @@ private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at') ->orderByDesc('completed_at')
->orderByDesc('id') ->orderByDesc('id')
->first(['id']); ->first();
$runId = $run?->getKey(); return $run instanceof OperationRun ? $run : null;
return is_numeric($runId) ? (int) $runId : null;
} }
/** /**
@ -351,9 +505,9 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>} * @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
*/ */
private function upsertFindings( private function upsertFindings(
DriftHasher $driftHasher,
Tenant $tenant, Tenant $tenant,
BaselineProfile $profile, BaselineProfile $profile,
int $baselineSnapshotId,
string $scopeKey, string $scopeKey,
array $driftResults, array $driftResults,
): array { ): array {
@ -366,16 +520,16 @@ private function upsertFindings(
$seenFingerprints = []; $seenFingerprints = [];
foreach ($driftResults as $driftItem) { foreach ($driftResults as $driftItem) {
$fingerprint = $driftHasher->fingerprint( $recurrenceKey = $this->recurrenceKey(
tenantId: $tenantId, tenantId: $tenantId,
scopeKey: $scopeKey, baselineSnapshotId: $baselineSnapshotId,
subjectType: $driftItem['subject_type'], policyType: $driftItem['policy_type'],
subjectExternalId: $driftItem['subject_external_id'], subjectExternalId: $driftItem['subject_external_id'],
changeType: $driftItem['change_type'], changeType: $driftItem['change_type'],
baselineHash: $driftItem['baseline_hash'],
currentHash: $driftItem['current_hash'],
); );
$fingerprint = $recurrenceKey;
$seenFingerprints[] = $fingerprint; $seenFingerprints[] = $fingerprint;
$finding = Finding::query() $finding = Finding::query()
@ -404,6 +558,7 @@ private function upsertFindings(
'subject_external_id' => $driftItem['subject_external_id'], 'subject_external_id' => $driftItem['subject_external_id'],
'severity' => $driftItem['severity'], 'severity' => $driftItem['severity'],
'fingerprint' => $fingerprint, 'fingerprint' => $fingerprint,
'recurrence_key' => $recurrenceKey,
'evidence_jsonb' => $driftItem['evidence'], 'evidence_jsonb' => $driftItem['evidence'],
'baseline_operation_run_id' => null, 'baseline_operation_run_id' => null,
'current_operation_run_id' => (int) $this->operationRun->getKey(), 'current_operation_run_id' => (int) $this->operationRun->getKey(),
@ -471,6 +626,55 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
} }
} }
/**
* Stable identity for baseline-compare findings, scoped to a baseline snapshot.
*/
private function recurrenceKey(
int $tenantId,
int $baselineSnapshotId,
string $policyType,
string $subjectExternalId,
string $changeType,
): string {
$parts = [
(string) $tenantId,
(string) $baselineSnapshotId,
$this->normalizeKeyPart($policyType),
$this->normalizeKeyPart($subjectExternalId),
$this->normalizeKeyPart($changeType),
];
return hash('sha256', implode('|', $parts));
}
private function normalizeKeyPart(string $value): string
{
return trim(mb_strtolower($value));
}
/**
* @param array<int, array{change_type: string}> $driftResults
* @return array<string, int>
*/
private function countByChangeType(array $driftResults): array
{
$counts = [];
foreach ($driftResults as $item) {
$changeType = (string) ($item['change_type'] ?? '');
if ($changeType === '') {
continue;
}
$counts[$changeType] = ($counts[$changeType] ?? 0) + 1;
}
ksort($counts);
return $counts;
}
/** /**
* @param array<int, array{severity: string}> $driftResults * @param array<int, array{severity: string}> $driftResults
* @return array<string, int> * @return array<string, int>

View File

@ -9,6 +9,8 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Inventory\InventoryCoverage;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -70,8 +72,20 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$context = is_array($this->operationRun->context) ? $this->operationRun->context : []; $context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$policyTypes = $context['policy_types'] ?? []; $policyTypes = $context['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : []; $policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
$includeFoundations = (bool) ($context['include_foundations'] ?? false);
$foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
->map(fn (array $row): mixed => $row['type'] ?? null)
->filter(fn (mixed $type): bool => is_string($type) && $type !== '')
->values()
->all();
$attemptedTypes = $includeFoundations
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
: array_values(array_diff($policyTypes, $foundationTypes));
$processedPolicyTypes = []; $processedPolicyTypes = [];
$coverageStatusByType = [];
$successCount = 0; $successCount = 0;
$failedCount = 0; $failedCount = 0;
@ -84,8 +98,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$this->operationRun, $this->operationRun,
$tenant, $tenant,
$context, $context,
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void { function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
$processedPolicyTypes[] = $policyType; $processedPolicyTypes[] = $policyType;
$coverageStatusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
if ($success) { if ($success) {
$successCount++; $successCount++;
@ -97,7 +114,37 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
}, },
); );
$statusByType = [];
foreach ($attemptedTypes as $type) {
if (! is_string($type) || $type === '') {
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
}
foreach ($coverageStatusByType as $type => $status) {
if (! is_string($type) || $type === '') {
continue;
}
$statusByType[$type] = $status;
}
if ((string) ($result['status'] ?? '') === 'skipped') {
foreach ($statusByType as $type => $status) {
$statusByType[$type] = InventoryCoverage::StatusSkipped;
}
}
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : []; $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['inventory'] = array_merge(
is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [],
[
'coverage' => InventoryCoverage::buildPayload($statusByType, $foundationTypes),
],
);
$updatedContext['result'] = [ $updatedContext['result'] = [
'had_errors' => (bool) ($result['had_errors'] ?? true), 'had_errors' => (bool) ($result['had_errors'] ?? true),
'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [], 'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [],

View File

@ -1,28 +1,103 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use JsonException;
class BaselineProfile extends Model class BaselineProfile extends Model
{ {
use HasFactory; use HasFactory;
/**
* @deprecated Use BaselineProfileStatus::Draft instead.
*/
public const string STATUS_DRAFT = 'draft'; public const string STATUS_DRAFT = 'draft';
/**
* @deprecated Use BaselineProfileStatus::Active instead.
*/
public const string STATUS_ACTIVE = 'active'; public const string STATUS_ACTIVE = 'active';
/**
* @deprecated Use BaselineProfileStatus::Archived instead.
*/
public const string STATUS_ARCHIVED = 'archived'; public const string STATUS_ARCHIVED = 'archived';
protected $guarded = []; /** @var list<string> */
protected $fillable = [
protected $casts = [ 'workspace_id',
'scope_jsonb' => 'array', 'name',
'description',
'version_label',
'status',
'scope_jsonb',
'active_snapshot_id',
'created_by_user_id',
]; ];
/**
* @return array<string, mixed>
*/
protected function casts(): array
{
return [
'status' => BaselineProfileStatus::class,
];
}
protected function scopeJsonb(): Attribute
{
return Attribute::make(
get: function (mixed $value): array {
return BaselineScope::fromJsonb(
$this->decodeScopeJsonb($value)
)->toJsonb();
},
set: function (mixed $value): string {
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toJsonb();
try {
return json_encode($scope, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return '{"policy_types":[],"foundation_types":[]}';
}
},
);
}
/**
* Decode raw scope_jsonb value from the database.
*
* @return array<string, mixed>|null
*/
private function decodeScopeJsonb(mixed $value): ?array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
try {
$decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : null;
} catch (JsonException) {
return null;
}
}
return null;
}
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
return $this->belongsTo(Workspace::class); return $this->belongsTo(Workspace::class);

View File

@ -10,6 +10,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType; use App\Support\OperationRunType;
@ -41,7 +42,7 @@ public function startCapture(
$context = [ $context = [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $sourceTenant->getKey(), 'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toJsonb(), 'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
]; ];
$run = $this->runs->ensureRunWithIdentity( $run = $this->runs->ensureRunWithIdentity(
@ -63,7 +64,7 @@ public function startCapture(
private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string
{ {
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { if ($profile->status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE; return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE;
} }

View File

@ -6,11 +6,13 @@
use App\Jobs\CompareBaselineToTenantJob; use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType; use App\Support\OperationRunType;
@ -27,6 +29,7 @@ public function __construct(
public function startCompare( public function startCompare(
Tenant $tenant, Tenant $tenant,
User $initiator, User $initiator,
?int $baselineSnapshotId = null,
): array { ): array {
$assignment = BaselineTenantAssignment::query() $assignment = BaselineTenantAssignment::query()
->where('workspace_id', $tenant->workspace_id) ->where('workspace_id', $tenant->workspace_id)
@ -43,13 +46,28 @@ public function startCompare(
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE]; return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
} }
$precondition = $this->validatePreconditions($profile); $hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
if ($precondition !== null) { if ($precondition !== null) {
return ['ok' => false, 'reason_code' => $precondition]; return ['ok' => false, 'reason_code' => $precondition];
} }
$snapshotId = (int) $profile->active_snapshot_id; $snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
if ($snapshotId > 0) {
$snapshot = BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId)
->first(['id']);
if (! $snapshot instanceof BaselineSnapshot) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
}
} else {
$snapshotId = (int) $profile->active_snapshot_id;
}
$profileScope = BaselineScope::fromJsonb( $profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
@ -63,7 +81,7 @@ public function startCompare(
$context = [ $context = [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId, 'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toJsonb(), 'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
]; ];
$run = $this->runs->ensureRunWithIdentity( $run = $this->runs->ensureRunWithIdentity(
@ -83,13 +101,13 @@ public function startCompare(
return ['ok' => true, 'run' => $run]; return ['ok' => true, 'run' => $run];
} }
private function validatePreconditions(BaselineProfile $profile): ?string private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
{ {
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) { if ($profile->status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
} }
if ($profile->active_snapshot_id === null) { if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT; return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
} }

View File

@ -16,6 +16,7 @@ final class BaselineSnapshotIdentity
{ {
public function __construct( public function __construct(
private readonly DriftHasher $hasher, private readonly DriftHasher $hasher,
private readonly InventoryMetaContract $metaContract,
) {} ) {}
/** /**
@ -50,10 +51,18 @@ public function computeIdentity(array $items): string
/** /**
* Compute a stable content hash for a single inventory item's metadata. * Compute a stable content hash for a single inventory item's metadata.
* *
* Strips volatile OData keys and normalizes for stable comparison. * Hashes ONLY the Spec 116 meta contract output (not the full meta_jsonb payload).
*
* @param array<string, mixed> $metaJsonb
*/ */
public function hashItemContent(mixed $metaJsonb): string public function hashItemContent(string $policyType, string $subjectExternalId, array $metaJsonb): string
{ {
return $this->hasher->hashNormalized($metaJsonb); $contract = $this->metaContract->build(
policyType: $policyType,
subjectExternalId: $subjectExternalId,
metaJsonb: $metaJsonb,
);
return $this->hasher->hashNormalized($contract);
} }
} }

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
final class InventoryMetaContract
{
public const int VERSION = 1;
/**
* Build the v1 meta contract for hashing and auditability.
*
* Missing signals MUST be represented as null (not omitted) to keep the hash stable across partial inventories.
*
* @param array<string, mixed> $metaJsonb
* @return array{
* version: int,
* policy_type: string,
* subject_external_id: string,
* odata_type: ?string,
* etag: ?string,
* scope_tag_ids: ?list<string>,
* assignment_target_count: ?int
* }
*/
public function build(string $policyType, string $subjectExternalId, array $metaJsonb): array
{
$odataType = $metaJsonb['odata_type'] ?? null;
$odataType = is_string($odataType) ? trim($odataType) : null;
$odataType = $odataType !== '' ? $odataType : null;
$etag = $metaJsonb['etag'] ?? null;
$etag = is_string($etag) ? trim($etag) : null;
$etag = $etag !== '' ? $etag : null;
$scopeTagIds = $metaJsonb['scope_tag_ids'] ?? null;
$scopeTagIds = is_array($scopeTagIds) ? $this->normalizeStringList($scopeTagIds) : null;
$assignmentTargetCount = $metaJsonb['assignment_target_count'] ?? null;
$assignmentTargetCount = is_numeric($assignmentTargetCount) ? (int) $assignmentTargetCount : null;
return [
'version' => self::VERSION,
'policy_type' => trim($policyType),
'subject_external_id' => trim($subjectExternalId),
'odata_type' => $odataType,
'etag' => $etag,
'scope_tag_ids' => $scopeTagIds,
'assignment_target_count' => $assignmentTargetCount,
];
}
/**
* @param array<int, mixed> $values
* @return list<string>
*/
private function normalizeStringList(array $values): array
{
$values = array_values(array_filter($values, 'is_string'));
$values = array_map('trim', $values);
$values = array_values(array_filter($values, fn (string $value): bool => $value !== ''));
$values = array_values(array_unique($values));
sort($values, SORT_STRING);
return $values;
}
}

View File

@ -11,6 +11,7 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway; use App\Services\Providers\ProviderGateway;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
@ -62,16 +63,41 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
'started_at' => now(), 'started_at' => now(),
]); ]);
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection); $policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
$foundationTypes = $this->foundationTypes();
$includeFoundations = (bool) ($normalizedSelection['include_foundations'] ?? false);
$attemptedTypes = $includeFoundations
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
: array_values(array_diff($policyTypes, $foundationTypes));
$statusByType = [];
foreach ($attemptedTypes as $type) {
if (! is_string($type) || $type === '') {
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
}
$result = $this->executeSelection(
$operationRun,
$tenant,
$normalizedSelection,
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void {
$statusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
},
);
$status = (string) ($result['status'] ?? 'failed'); $status = (string) ($result['status'] ?? 'failed');
$hadErrors = (bool) ($result['had_errors'] ?? true); $hadErrors = (bool) ($result['had_errors'] ?? true);
$errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : []; $errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [];
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null; $errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
$policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
$operationOutcome = match ($status) { $operationOutcome = match ($status) {
'success' => OperationRunOutcome::Succeeded->value, 'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value, 'partial' => OperationRunOutcome::PartiallySucceeded->value,
@ -95,6 +121,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
} }
$updatedContext = is_array($operationRun->context) ? $operationRun->context : []; $updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
$coverageStatusByType = $statusByType;
if ($status === 'skipped') {
foreach ($coverageStatusByType as $type => $coverageStatus) {
$coverageStatusByType[$type] = InventoryCoverage::StatusSkipped;
}
}
$updatedContext['inventory'] = array_merge(
is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [],
[
'coverage' => InventoryCoverage::buildPayload(
statusByType: $coverageStatusByType,
foundationTypes: $foundationTypes,
),
],
);
$updatedContext['result'] = [ $updatedContext['result'] = [
'had_errors' => $hadErrors, 'had_errors' => $hadErrors,
'error_codes' => $errorCodes, 'error_codes' => $errorCodes,

View File

@ -4,22 +4,22 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Models\BaselineProfile;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Baselines\BaselineProfileStatus;
final class BaselineProfileStatusBadge implements BadgeMapper final class BaselineProfileStatusBadge implements BadgeMapper
{ {
public function spec(mixed $value): BadgeSpec public function spec(mixed $value): BadgeSpec
{ {
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
$enum = BaselineProfileStatus::tryFrom($state);
return match ($state) { if ($enum === null) {
BaselineProfile::STATUS_DRAFT => new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'), return BadgeSpec::unknown();
BaselineProfile::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'), }
BaselineProfile::STATUS_ARCHIVED => new BadgeSpec('Archived', 'warning', 'heroicon-m-archive-box'),
default => BadgeSpec::unknown(), return new BadgeSpec($enum->label(), $enum->color(), $enum->icon());
};
} }
} }

View File

@ -14,6 +14,7 @@ final class BaselineCompareStats
{ {
/** /**
* @param array<string, int> $severityCounts * @param array<string, int> $severityCounts
* @param list<string> $uncoveredTypes
*/ */
private function __construct( private function __construct(
public readonly string $state, public readonly string $state,
@ -27,6 +28,10 @@ private function __construct(
public readonly ?string $lastComparedHuman, public readonly ?string $lastComparedHuman,
public readonly ?string $lastComparedIso, public readonly ?string $lastComparedIso,
public readonly ?string $failureReason, public readonly ?string $failureReason,
public readonly ?string $coverageStatus = null,
public readonly ?int $uncoveredTypesCount = null,
public readonly array $uncoveredTypes = [],
public readonly ?string $fidelity = null,
) {} ) {}
public static function forTenant(?Tenant $tenant): self public static function forTenant(?Tenant $tenant): self
@ -74,6 +79,8 @@ public static function forTenant(?Tenant $tenant): self
->latest('id') ->latest('id')
->first(); ->first();
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
// Active run (queued/running) // Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) { if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
return new self( return new self(
@ -88,6 +95,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: null, lastComparedHuman: null,
lastComparedIso: null, lastComparedIso: null,
failureReason: null, failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
); );
} }
@ -110,6 +121,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $latestRun->finished_at?->diffForHumans(), lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
lastComparedIso: $latestRun->finished_at?->toIso8601String(), lastComparedIso: $latestRun->finished_at?->toIso8601String(),
failureReason: (string) $failureReason, failureReason: (string) $failureReason,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
); );
} }
@ -154,13 +169,19 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $lastComparedHuman, lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso, lastComparedIso: $lastComparedIso,
failureReason: null, failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
); );
} }
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') { if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && in_array($latestRun->outcome, ['succeeded', 'partially_succeeded'], true)) {
return new self( return new self(
state: 'ready', state: 'ready',
message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.', message: $latestRun->outcome === 'succeeded'
? 'No open drift findings for this baseline comparison. The tenant matches the baseline.'
: 'Comparison completed with warnings. Findings may be incomplete due to missing coverage.',
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: $snapshotId, snapshotId: $snapshotId,
@ -170,6 +191,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $lastComparedHuman, lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso, lastComparedIso: $lastComparedIso,
failureReason: null, failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
); );
} }
@ -185,6 +210,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $lastComparedHuman, lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso, lastComparedIso: $lastComparedIso,
failureReason: null, failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
); );
} }
@ -248,6 +277,50 @@ public static function forWidget(?Tenant $tenant): self
); );
} }
/**
* @return array{0: ?string, 1: list<string>, 2: ?string}
*/
private static function coverageInfoForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return [null, [], null];
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return [null, [], null];
}
$coverage = $baselineCompare['coverage'] ?? null;
$coverage = is_array($coverage) ? $coverage : [];
$proof = $coverage['proof'] ?? null;
$proof = is_bool($proof) ? $proof : null;
$uncoveredTypes = $coverage['uncovered_types'] ?? null;
$uncoveredTypes = is_array($uncoveredTypes) ? array_values(array_filter($uncoveredTypes, 'is_string')) : [];
$uncoveredTypes = array_values(array_unique(array_filter(array_map('trim', $uncoveredTypes), fn (string $type): bool => $type !== '')));
sort($uncoveredTypes, SORT_STRING);
$coverageStatus = null;
if ($proof === false) {
$coverageStatus = 'unproven';
} elseif ($uncoveredTypes !== []) {
$coverageStatus = 'warning';
} elseif ($proof === true) {
$coverageStatus = 'ok';
}
$fidelity = $baselineCompare['fidelity'] ?? null;
$fidelity = is_string($fidelity) ? trim($fidelity) : null;
$fidelity = $fidelity !== '' ? $fidelity : null;
return [$coverageStatus, $uncoveredTypes, $fidelity];
}
private static function empty( private static function empty(
string $state, string $state,
?string $message, ?string $message,

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum BaselineProfileStatus: string
{
case Draft = 'draft';
case Active = 'active';
case Archived = 'archived';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Active => 'Active',
self::Archived => 'Archived',
};
}
/**
* Filament badge color for this status.
*/
public function color(): string
{
return match ($this) {
self::Draft => 'gray',
self::Active => 'success',
self::Archived => 'warning',
};
}
/**
* Heroicon identifier for this status.
*/
public function icon(): string
{
return match ($this) {
self::Draft => 'heroicon-m-pencil-square',
self::Active => 'heroicon-m-check-circle',
self::Archived => 'heroicon-m-archive-box',
};
}
/**
* Whether this status allows editing the profile.
*/
public function isEditable(): bool
{
return $this !== self::Archived;
}
/**
* Allowed transitions from this status.
*
* @return array<self>
*/
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::Draft, self::Active],
self::Active => [self::Active, self::Archived],
self::Archived => [self::Archived],
};
}
/**
* Status options for a Filament Select field, scoped to allowed transitions.
*
* @return array<string, string>
*/
public function selectOptions(): array
{
return collect($this->allowedTransitions())
->mapWithKeys(fn (self $s): array => [$s->value => $s->label()])
->all();
}
}

View File

@ -21,4 +21,6 @@ final class BaselineReasonCodes
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active'; public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot'; public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
} }

View File

@ -8,15 +8,20 @@
* Value object for baseline scope resolution. * Value object for baseline scope resolution.
* *
* A scope defines which policy types are included in a baseline profile. * A scope defines which policy types are included in a baseline profile.
* An empty policy_types array means "all types" (no filter). *
* Spec 116 semantics:
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
*/ */
final class BaselineScope final class BaselineScope
{ {
/** /**
* @param array<string> $policyTypes * @param array<string> $policyTypes
* @param array<string> $foundationTypes
*/ */
public function __construct( public function __construct(
public readonly array $policyTypes = [], public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
) {} ) {}
/** /**
@ -31,9 +36,11 @@ public static function fromJsonb(?array $scopeJsonb): self
} }
$policyTypes = $scopeJsonb['policy_types'] ?? []; $policyTypes = $scopeJsonb['policy_types'] ?? [];
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
return new self( return new self(
policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [], policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
foundationTypes: is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
); );
} }
@ -41,64 +48,69 @@ public static function fromJsonb(?array $scopeJsonb): self
* Normalize the effective scope by intersecting profile scope with an optional override. * Normalize the effective scope by intersecting profile scope with an optional override.
* *
* Override can only narrow the profile scope (subset enforcement). * Override can only narrow the profile scope (subset enforcement).
* If the profile scope is empty (all types), the override becomes the effective scope. * Empty override means "no override".
* If the override is empty or null, the profile scope is used as-is.
*/ */
public static function effective(self $profileScope, ?self $overrideScope): self public static function effective(self $profileScope, ?self $overrideScope): self
{ {
$profileScope = $profileScope->expandDefaults();
if ($overrideScope === null || $overrideScope->isEmpty()) { if ($overrideScope === null || $overrideScope->isEmpty()) {
return $profileScope; return $profileScope;
} }
if ($profileScope->isEmpty()) { $overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
return $overrideScope; $overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
}
$intersected = array_values(array_intersect($profileScope->policyTypes, $overrideScope->policyTypes)); $effectivePolicyTypes = $overridePolicyTypes !== []
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
: $profileScope->policyTypes;
return new self(policyTypes: $intersected); $effectiveFoundationTypes = $overrideFoundationTypes !== []
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
: $profileScope->foundationTypes;
return new self(
policyTypes: self::uniqueSorted($effectivePolicyTypes),
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
);
} }
/** /**
* An empty scope means "all types". * An empty scope means "no override" (for override_scope semantics).
*/ */
public function isEmpty(): bool public function isEmpty(): bool
{ {
return $this->policyTypes === []; return $this->policyTypes === [] && $this->foundationTypes === [];
} }
/** /**
* Check if a policy type is included in this scope. * Apply Spec 116 defaults and filter to supported types.
*/ */
public function includes(string $policyType): bool public function expandDefaults(): self
{ {
if ($this->isEmpty()) { $policyTypes = $this->policyTypes === []
return true; ? self::supportedPolicyTypes()
} : self::normalizePolicyTypes($this->policyTypes);
return in_array($policyType, $this->policyTypes, true); $foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
} }
/** /**
* Validate that override is a subset of the profile scope. * @return list<string>
*/ */
public static function isValidOverride(self $profileScope, self $overrideScope): bool public function allTypes(): array
{ {
if ($overrideScope->isEmpty()) { $expanded = $this->expandDefaults();
return true;
}
if ($profileScope->isEmpty()) { return self::uniqueSorted(array_merge(
return true; $expanded->policyTypes,
} $expanded->foundationTypes,
));
foreach ($overrideScope->policyTypes as $type) {
if (! in_array($type, $profileScope->policyTypes, true)) {
return false;
}
}
return true;
} }
/** /**
@ -108,6 +120,102 @@ public function toJsonb(): array
{ {
return [ return [
'policy_types' => $this->policyTypes, 'policy_types' => $this->policyTypes,
'foundation_types' => $this->foundationTypes,
]; ];
} }
/**
* Effective scope payload for OperationRun.context.
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
if (! is_array($supported)) {
return [];
}
$types = collect($supported)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @return list<string>
*/
private static function supportedFoundationTypes(): array
{
$foundations = config('tenantpilot.foundation_types', []);
if (! is_array($foundations)) {
return [];
}
$types = collect($foundations)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizePolicyTypes(array $types): array
{
$supported = self::supportedPolicyTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizeFoundationTypes(array $types): array
{
$supported = self::supportedFoundationTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<int, string> $types
* @return list<string>
*/
private static function uniqueSorted(array $types): array
{
$types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')));
sort($types, SORT_STRING);
return $types;
}
} }

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
final readonly class InventoryCoverage
{
public const string StatusSucceeded = 'succeeded';
public const string StatusFailed = 'failed';
public const string StatusSkipped = 'skipped';
/**
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $policyTypes
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $foundationTypes
*/
public function __construct(
public array $policyTypes,
public array $foundationTypes,
) {}
public static function fromContext(mixed $context): ?self
{
if (! is_array($context)) {
return null;
}
$inventory = $context['inventory'] ?? null;
if (! is_array($inventory)) {
return null;
}
$coverage = $inventory['coverage'] ?? null;
if (! is_array($coverage)) {
return null;
}
$policyTypes = self::normalizeCoverageMap($coverage['policy_types'] ?? null);
$foundationTypes = self::normalizeCoverageMap($coverage['foundation_types'] ?? null);
if ($policyTypes === [] && $foundationTypes === []) {
return null;
}
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function coveredTypes(): array
{
$covered = [];
foreach (array_merge($this->policyTypes, $this->foundationTypes) as $type => $meta) {
if (($meta['status'] ?? null) === self::StatusSucceeded) {
$covered[] = $type;
}
}
sort($covered, SORT_STRING);
return array_values(array_unique($covered));
}
/**
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
*
* @param array<string, string> $statusByType
* @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
*/
public static function buildPayload(array $statusByType, array $foundationTypes): array
{
$foundationTypes = array_values(array_unique(array_filter($foundationTypes, fn (mixed $type): bool => is_string($type) && $type !== '')));
$foundationLookup = array_fill_keys($foundationTypes, true);
$policy = [];
$foundations = [];
foreach ($statusByType as $type => $status) {
if (! is_string($type) || $type === '') {
continue;
}
$normalizedStatus = self::normalizeStatus($status);
if ($normalizedStatus === null) {
continue;
}
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row;
continue;
}
$policy[$type] = $row;
}
ksort($policy);
ksort($foundations);
return [
'policy_types' => $policy,
'foundation_types' => $foundations,
];
}
private static function normalizeStatus(mixed $status): ?string
{
if (! is_string($status)) {
return null;
}
return match ($status) {
self::StatusSucceeded, self::StatusFailed, self::StatusSkipped => $status,
default => null,
};
}
/**
* @return array<string, array{status: string, item_count?: int, error_code?: string|null}>
*/
private static function normalizeCoverageMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $type => $meta) {
if (! is_string($type) || $type === '') {
continue;
}
if (! is_array($meta)) {
continue;
}
$status = self::normalizeStatus($meta['status'] ?? null);
if ($status === null) {
continue;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $meta) && is_int($meta['item_count'])) {
$row['item_count'] = $meta['item_count'];
}
if (array_key_exists('error_code', $meta) && (is_string($meta['error_code']) || $meta['error_code'] === null)) {
$row['error_code'] = $meta['error_code'];
}
$normalized[$type] = $row;
}
ksort($normalized);
return $normalized;
}
}

View File

@ -50,6 +50,8 @@ final class UiEnforcement
private bool $preserveExistingVisibility = false; private bool $preserveExistingVisibility = false;
private bool $preserveExistingDisabled = false;
private function __construct(Action|BulkAction $action) private function __construct(Action|BulkAction $action)
{ {
$this->action = $action; $this->action = $action;
@ -167,6 +169,21 @@ public function preserveVisibility(): self
return $this; return $this;
} }
/**
* Preserve the action's existing disabled logic.
*
* Use this when the action is disabled for business reasons (e.g. operation
* state) and you still want UiEnforcement to add capability gating on top.
*
* @return $this
*/
public function preserveDisabled(): self
{
$this->preserveExistingDisabled = true;
return $this;
}
/** /**
* Apply all enforcement rules to the action and return it. * Apply all enforcement rules to the action and return it.
* *
@ -316,9 +333,17 @@ private function applyDisabledState(): void
return; return;
} }
$existingDisabled = $this->preserveExistingDisabled
? $this->getExistingDisabledCondition()
: null;
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null) { $this->action->disabled(function (?Model $record = null) use ($existingDisabled) {
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
return true;
}
if ($this->isBulk && $this->action instanceof BulkAction) { if ($this->isBulk && $this->action instanceof BulkAction) {
$user = auth()->user(); $user = auth()->user();
@ -371,6 +396,62 @@ private function applyDisabledState(): void
}); });
} }
/**
* Attempt to retrieve the existing disabled condition from the action.
*
* Filament stores this as the protected property `$isDisabled` (bool|Closure)
* on actions via the CanBeDisabled concern.
*/
private function getExistingDisabledCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isDisabled')) {
return null;
}
$property = $ref->getProperty('isDisabled');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
/**
* Evaluate an existing bool|Closure disabled condition.
*
* This is a best-effort evaluator for business disabled closures.
* If the closure cannot be evaluated safely, we fail closed (return true).
*/
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return true;
}
return (bool) $condition($record);
} catch (Throwable) {
return true;
}
}
/** /**
* Add confirmation modal for destructive actions. * Add confirmation modal for destructive actions.
*/ */

View File

@ -5,6 +5,7 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Baselines\BaselineProfileStatus;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -24,8 +25,8 @@ public function definition(): array
'name' => fake()->unique()->words(3, true), 'name' => fake()->unique()->words(3, true),
'description' => fake()->optional()->sentence(), 'description' => fake()->optional()->sentence(),
'version_label' => fake()->optional()->numerify('v#.#'), 'version_label' => fake()->optional()->numerify('v#.#'),
'status' => BaselineProfile::STATUS_DRAFT, 'status' => BaselineProfileStatus::Draft->value,
'scope_jsonb' => ['policy_types' => []], 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
'active_snapshot_id' => null, 'active_snapshot_id' => null,
'created_by_user_id' => null, 'created_by_user_id' => null,
]; ];
@ -34,21 +35,21 @@ public function definition(): array
public function active(): static public function active(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => BaselineProfile::STATUS_ACTIVE, 'status' => BaselineProfileStatus::Active->value,
]); ]);
} }
public function archived(): static public function archived(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => BaselineProfile::STATUS_ARCHIVED, 'status' => BaselineProfileStatus::Archived->value,
]); ]);
} }
public function withScope(array $policyTypes): static public function withScope(array $policyTypes): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'scope_jsonb' => ['policy_types' => $policyTypes], 'scope_jsonb' => ['policy_types' => $policyTypes, 'foundation_types' => []],
]); ]);
} }

View File

@ -0,0 +1,664 @@
# Golden Master / Baseline Drift — Deep Settings-Drift (Content-Fidelity) Analysis
> Enterprise Research Report for TenantAtlas / TenantPilot
> Date: 2025-07-15
> Scope: Architecture, code evidence, implementation proposal
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [System Map — Side-by-Side Comparison](#2-system-map)
3. [Architecture Decision Record (ADR-001): Unify vs Separate](#3-adr-001)
4. [Deep-Dive: Why Settings Changes Don't Produce Baseline Drift](#4-deep-dive)
5. [Code Evidence Table](#5-code-evidence)
6. [Type Coverage Matrix](#6-type-coverage-matrix)
7. [Proposal: Deep Drift Implementation Plan](#7-deep-drift-plan)
8. [Test Plan (Enterprise)](#8-test-plan)
9. [Open Questions / Assumptions](#9-open-questions)
10. [Key Questions Answered (KQ-01 through KQ-06)](#10-key-questions)
---
## 1. Executive Summary
1. **Two parallel drift systems exist**: *Baseline Compare* (meta fidelity, inventory-sourced) and *Backup Drift* (content fidelity, PolicyVersion-sourced). They share `DriftHasher` but are otherwise separate data paths with separate finding generators.
2. **The core gap**: `CompareBaselineToTenantJob` hashes `InventoryMetaContract` v1 — which contains only `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` — never actual policy settings. When an admin changes a Wi-Fi password or a compliance threshold in Intune, _none of these meta signals necessarily change_.
3. **Inventory sync uses Graph LIST endpoints**, which return metadata and display fields only. Per-item GET (which fetches settings, assignments, scope tags) is only performed during _Backup_ via `PolicyCaptureOrchestrator`.
4. **`DriftFindingGenerator`** (the backup drift system) _does_ detect settings changes — it normalizes `PolicyVersion.snapshot` via `SettingsNormalizer``PolicyNormalizer::flattenForDiff()` → type-specific normalizers, then hashes with `DriftHasher`.
5. **Spec 116 already designs v2** with a provider precedence chain (`PolicyVersion → Inventory content → Meta fallback`), which is the correct architectural direction. The v1 meta baseline shipped first as a deliberate, safe-to-ship initial milestone.
6. **Unification is recommended** (provider chain approach) — not merging the two jobs, but enabling `CompareBaselineToTenantJob` to optionally consume `PolicyVersion` snapshots as a content-fidelity provider, falling back to InventoryMetaContract when no PolicyVersion is available.
7. **28 supported policy types** are registered in `tenantpilot.php`, plus 3 foundation types. Of these, 10+ have complex hydration (settings catalog, group policy, security baselines, compliance actions) and would benefit most from deep-drift detection.
8. **The `etag` signal** is unreliable as a settings-change proxy: Microsoft Graph etag semantics vary per resource type, and etag may or may not change when settings are modified. It is useful as a _hint_ but not a _guarantee_.
9. **API cost is the primary constraint**: content-fidelity compare requires per-item GET calls (or a recent Backup that already captured PolicyVersions). The hybrid provider chain avoids this by opportunistically _reusing_ existing PolicyVersions without requiring a full backup before every compare.
10. **Coverage Guard is critical for v2**: the baseline system must know _which types have fresh PolicyVersions_ and suppress content-fidelity findings for types where no recent version exists (falling back to meta fidelity).
11. **Risk profile**: Shipping deep-drift for wrong types (without proper per-type normalization) could produce false positives. Type-specific normalizers already exist for the backup drift path; reusing them is safe.
12. **Recommended phasing**: v1.5 (current sprint) = add `content_hash_source` column to `baseline_snapshot_items` + provider chain in compare job. v2.0 = on-demand per-item GET during baseline capture for types lacking recent PolicyVersions.
---
## 2. System Map
### Side-by-Side Comparison Table
| Dimension | System A: Baseline Compare | System B: Backup Drift |
|---|---|---|
| **Entry point** | `CompareBaselineToTenantJob` | `GenerateDriftFindingsJob``DriftFindingGenerator` |
| **Data source (current)** | `InventoryItem` (from LIST sync) | `PolicyVersion` (from per-item GET backup) |
| **Data source (baseline)** | `BaselineSnapshotItem` (captured from inventory) | Earlier `PolicyVersion` from prior `OperationRun` |
| **Hash contract** | `InventoryMetaContract` v1 → `DriftHasher::hashNormalized()` | `SettingsNormalizer``PolicyNormalizer::flattenForDiff()``DriftHasher::hashNormalized()` |
| **Hash inputs** | `version`, `policy_type`, `subject_external_id`, `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` | Full `PolicyVersion.snapshot` JSON (with volatile key removal) |
| **Fidelity** | `meta` (persisted as `fidelity='meta'` in snapshot context) | `content` (settings + assignments + scope_tags) |
| **Dimensions detected** | `missing_policy`, `different_version`, `unexpected_policy` | `policy_snapshot` (added/removed/modified), `policy_assignments` (modified), `policy_scope_tags` (modified) |
| **Finding identity** | `recurrence_key = sha256(tenantId\|snapshotId\|policyType\|extId\|changeType)` | `recurrence_key = sha256(drift:tenantId:scopeKey:subjectType:extId:dimension)` |
| **Scope key** | `baseline_profile:{profileId}` | `DriftScopeKey::fromSelectionHash()` |
| **Auto-close** | `BaselineAutoCloseService` (stale finding resolution) | `resolveStaleDriftFindings()` within `DriftFindingGenerator` |
| **Coverage guard** | `InventoryCoverage::fromContext()` → uncovered types → partial outcome | None (trusts backup captured all types) |
| **Graph API calls** | Zero at compare time (reads from DB) | Zero at compare time (reads PolicyVersions from DB) |
| **Graph API calls (capture)** | Zero (inventory sync did LIST) | Per-item GET via `PolicyCaptureOrchestrator` |
| **Normalizer pipeline** | None (meta contract is the normalization) | `SettingsNormalizer``PolicyNormalizer` → type normalizers |
| **Shared components** | `DriftHasher`, `Finding` model | `DriftHasher`, `Finding` model |
| **Trigger** | After inventory sync, on schedule/manual | After backup, on schedule/manual |
### Data Flow Diagrams
```
SYSTEM A — Baseline Compare (Meta Fidelity)
============================================
Graph LIST ──► InventorySyncService ──► InventoryItem (meta_jsonb)
CaptureBaselineSnapshotJob
├─ InventoryMetaContract.build()
├─ DriftHasher.hashNormalized()
└─► BaselineSnapshotItem (baseline_hash)
CompareBaselineToTenantJob
├─ loadCurrentInventory() → InventoryItem
├─ BaselineSnapshotIdentity.hashItemContent()
│ └─ InventoryMetaContract.build()
│ └─ DriftHasher.hashNormalized()
├─ computeDrift() → hash compare
└─ upsertFindings() → Finding records
SYSTEM B — Backup Drift (Content Fidelity)
============================================
Graph GET ──► PolicySnapshotService.fetch() ──► full JSON snapshot
PolicyCaptureOrchestrator.capture()
├─ assignments GET
├─ scope tags resolve
└─► VersionService.captureVersion() ──► PolicyVersion
DriftFindingGenerator.generate()
├─ versionForRun() → baseline/current PV
├─ SettingsNormalizer.normalizeForDiff()
│ └─ PolicyNormalizer.flattenForDiff()
├─ DriftHasher.hashNormalized() × 3
│ (snapshot, assignments, scope_tags)
└─ upsertDriftFinding() → Finding records
```
---
## 3. ADR-001: Unify vs Separate
### Title
ADR-001: Golden Master Baseline Compare — Provider Chain for Content Fidelity
### Status
PROPOSED
### Context
TenantPilot has two drift detection systems that evolved independently:
- **System A (Baseline Compare)**: Designed for "does the tenant still match the golden master?" Use case. Ships with meta-fidelity (v1) — fast, cheap, zero additional Graph calls at compare time. Detects structural drift (policy added/removed/meta-changed) but is blind to _settings_ changes.
- **System B (Backup Drift)**: Designed for "what changed between two backup points?" Use case. Content-fidelity — full PolicyVersion snapshots with per-type normalization. Detects settings, assignments, and scope tag changes.
The two systems cannot be merged into one without fundamentally changing their triggering, scoping, and API cost models. However, System A's accuracy can be dramatically improved by _consuming_ data already produced by System B.
### Decision
**Adopt the Provider Chain pattern** as already designed in Spec 116 v2:
```
ContentProvider = PolicyVersion → InventoryContent → MetaFallback
```
Specifically:
1. `CompareBaselineToTenantJob` gains a `ContentProviderChain` that, for each `(policy_type, external_id)`:
- **First**: Looks for a `PolicyVersion` captured since the last baseline snapshot timestamp. If found, normalizes via `SettingsNormalizer``DriftHasher` → returns `content` fidelity hash.
- **Second (future)**: Looks for enriched inventory content if inventory sync is upgraded to capture settings (v2.0+).
- **Fallback**: Builds `InventoryMetaContract` v1 → `DriftHasher` → returns `meta` fidelity hash.
2. Each baseline snapshot item records its `fidelity` (`meta` | `content`) and `content_hash_source` (`inventory_meta_v1` | `policy_version:{id}` | `inventory_content_v2`).
3. Compare findings carry `fidelity` in evidence, enabling UI to display confidence level.
4. Coverage Guard is extended: a type is `content-covered` only if PolicyVersions exist for ≥N% of items. Below that threshold, fallback to meta fidelity (do not suppress).
### Consequences
- **Positive**: No new Graph API calls needed (reuses existing PolicyVersions from backups). Zero additional infrastructure. Incremental rollout per policy type. Existing meta-fidelity behavior preserved as fallback.
- **Negative**: Content fidelity depends on backup recency. If a tenant hasn't been backed up, only meta fidelity is available. Could create "mixed fidelity" findings within a single compare run.
- **Rejected Alternative**: Full merge of System A and B into a single system. Rejected because they serve different use cases (golden master comparison vs point-in-time drift), have different scoping models (BaselineProfile vs selection_hash), and different triggering models (post-inventory-sync vs post-backup).
- **Rejected Alternative**: Always-GET during baseline compare. Rejected due to API cost (30+ types × 100s of policies = 1000s of GET calls per tenant per compare run).
### Compliance Notes
- Livewire v4.0+ / Filament v5: no UI changes in core ADR; provider chain is purely backend.
- Provider registration: n/a (backend services only).
- No destructive actions.
- Asset strategy: no new assets.
---
## 4. Deep-Dive: Why Settings Changes Don't Produce Baseline Drift
### The Root Cause Chain
**Step 1: Inventory Sync captures only LIST metadata**
`InventorySyncService::executeSelectionUnderLock()` (line ~340-450) calls Graph LIST endpoints. For each policy, it extracts:
- `display_name`, `category`, `platform` (display fields)
- `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` (meta signals)
These are stored in `InventoryItem.meta_jsonb`. **No settings values are fetched or stored.**
**Step 2: Baseline Capture hashes only the Meta Contract**
`CaptureBaselineSnapshotJob::collectSnapshotItems()` reads from `InventoryItem`, then calls `BaselineSnapshotIdentity::hashItemContent()`:
```php
// BaselineSnapshotIdentity.php, line 56-67
public function hashItemContent(string $policyType, string $subjectExternalId, array $metaJsonb): string
{
$contract = $this->metaContract->build(
policyType: $policyType,
subjectExternalId: $subjectExternalId,
metaJsonb: $metaJsonb,
);
return $this->hasher->hashNormalized($contract);
}
```
The `InventoryMetaContract::build()` output is:
```php
[
'version' => 1,
'policy_type' => 'settingsCatalogPolicy',
'subject_external_id' => '<guid>',
'odata_type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'etag' => '"abc..."', // ← unreliable change indicator
'scope_tag_ids' => ['0'],
'assignment_target_count' => 3,
]
```
**This is ALL that gets hashed.** Actual policy settings (the Wi-Fi password, the compliance threshold, the firewall rule) are _nowhere_ in this contract.
**Step 3: Baseline Compare re-computes the same meta hash**
`CompareBaselineToTenantJob::loadCurrentInventory()` (line 367-409) reads current `InventoryItem` records and calls the same `BaselineSnapshotIdentity::hashItemContent()` with the same `InventoryMetaContract`, producing the same hash structure.
`computeDrift()` (line 435-500) then compares `baseline_hash` vs `current_hash`:
```php
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
$drift[] = ['change_type' => 'different_version', ...];
}
```
**If the admin changed a policy setting but the meta signals (etag, scope_tag_ids, assignment_target_count) stayed the same, `baseline_hash === current_hash` and NO drift is detected.**
### Why etag is unreliable
Microsoft Graph etag behavior varies by resource type:
- **Some types** update etag on any property change (including settings)
- **Some types** update etag only on top-level property changes (not nested settings)
- **Settings Catalog policies** may or may not update the parent resource etag when child `settings` are modified (the settings are a separate subresource at `/configurationPolicies/{id}/settings`)
- **Group Policy Configurations** have settings in `definitionValues``presentationValues` (multi-level nesting); etag at root level may not reflect these changes
### The Contrast: How Backup Drift _Does_ Detect Settings Changes
`DriftFindingGenerator::generate()` (line 32-80) operates on `PolicyVersion.snapshot` — the full JSON captured via per-item GET:
```php
$baselineSnapshot = $baselineVersion->snapshot; // Full JSON from Graph GET
$currentSnapshot = $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) {
// → Drift finding with change_type = 'modified'
}
```
This pipeline captures actual settings values, normalizes them per policy type, strips volatile metadata, and hashes the result. If a setting changed, the hash changes, and drift is detected.
### Summary Visualization
```
Admin changes Wi-Fi password in Intune
┌─────────────────────────────────┐
│ Graph LIST (inventory sync) │
│ returns: displayName, etag, ... │
│ │
│ etag MAY change, settings NOT │
│ returned by LIST endpoint │
└────────────┬────────────────────┘
┌───────┴────────┐
▼ ▼
InventoryItem PolicyVersion
(meta only) (if backup ran)
│ │
▼ ▼
Meta Contract Full Snapshot
hash unchanged hash CHANGED
│ │
▼ ▼
Baseline Backup Drift:
Compare: "modified" ✅
NO DRIFT ❌
```
---
## 5. Code Evidence Table
| # | Class / File | Lines | Role | Key Finding |
|---|---|---|---|---|
| 1 | `app/Jobs/CompareBaselineToTenantJob.php` | 785 total; L367-409 (loadCurrentInventory), L435-500 (computeDrift) | Core baseline compare job | Reads from `InventoryItem` only; hashes via `InventoryMetaContract`**blind to settings** |
| 2 | `app/Services/Baselines/InventoryMetaContract.php` | 75 total; L30-57 (build) | Meta hash contract builder | Hashes only: version, policy_type, external_id, odata_type, etag, scope_tag_ids, assignment_target_count — **no settings content** |
| 3 | `app/Services/Baselines/BaselineSnapshotIdentity.php` | 73 total; L56-67 (hashItemContent) | Per-item hash via meta contract | Delegates to `InventoryMetaContract.build()``DriftHasher.hashNormalized()` |
| 4 | `app/Jobs/CaptureBaselineSnapshotJob.php` | 305 total | Captures snapshot from inventory | Reads `InventoryItem`, stores `fidelity='meta'` and `source='inventory'` |
| 5 | `app/Services/Drift/DriftFindingGenerator.php` | 484 total; L32-80 (generate), L250-267 (recurrenceKey) | Backup drift finding generator | Uses `PolicyVersion.snapshot` with `SettingsNormalizer`**detects settings changes** |
| 6 | `app/Services/Drift/DriftHasher.php` | 100 total; L13-24 (hashNormalized) | Shared hasher | `sha256(json_encode(normalized))` with volatile key removal. **SHARED by both systems.** |
| 7 | `app/Services/Drift/Normalizers/SettingsNormalizer.php` | 22 total | Thin wrapper | Delegates to `PolicyNormalizer::flattenForDiff()`. Used by System B only. |
| 8 | `app/Services/Intune/PolicyNormalizer.php` | 67 total | Type-specific normalizer router | Routes to per-type normalizers for diff operations |
| 9 | `app/Services/Inventory/InventorySyncService.php` | 652 total; L340-450 (executeSelectionUnderLock) | LIST-based sync | Fetches from Graph LIST endpoints; extracts meta signals only; upserts `InventoryItem` |
| 10 | `app/Services/Intune/BackupService.php` | 438 total | Backup orchestration | Creates `BackupSet`, uses `PolicyCaptureOrchestrator` for per-item GET → PolicyVersion |
| 11 | `app/Services/Intune/PolicyCaptureOrchestrator.php` | 429 total | Per-item GET + hydration | Fetches full snapshot, assignments, scope tags; creates PolicyVersion with all content |
| 12 | `app/Services/Intune/PolicySnapshotService.php` | 852 total | Per-item Graph GET | Type-specific hydration (hydrateConfigurationPolicySettings, hydrateGroupPolicyConfiguration, etc.) |
| 13 | `app/Services/Intune/VersionService.php` | 312 total; L1-150 (captureVersion) | PolicyVersion persistence | Transactional, locking, consecutive version_number |
| 14 | `app/Models/PolicyVersion.php` | Model | PolicyVersion model | Casts: snapshot(array), assignments(array), scope_tags(array), plus hash columns |
| 15 | `app/Models/InventoryItem.php` | Model | Inventory item model | Casts: meta_jsonb(array) — **no settings content** |
| 16 | `app/Models/BaselineSnapshotItem.php` | Model | Snapshot item model | Has `baseline_hash(64)`, `meta_jsonb` |
| 17 | `app/Support/Inventory/InventoryCoverage.php` | 173 total | Coverage parser | `fromContext()` extracts per-type status from sync run context |
| 18 | `app/Services/Drift/DriftRunSelector.php` | ~60 total | Run pair selector | Selects 2 most recent sync runs with same `selection_hash` (System B only) |
| 19 | `app/Jobs/GenerateDriftFindingsJob.php` | ~200 total | Dispatcher for System B | Dispatches `DriftFindingGenerator` for policy-version-based drift |
| 20 | `config/graph_contracts.php` | 867 total | Policy type registry | Defines endpoints, hydration strategies, subresources, type families per policy type |
| 21 | `config/tenantpilot.php` | 385 total; L18-293 (supported_policy_types) | Application config | 28 supported policy types + 3 foundation types |
| 22 | `specs/116-baseline-drift-engine/spec.md` | 237 total | Feature spec | Defines v1 (meta) and v2 (content fidelity) requirements |
| 23 | `specs/116-baseline-drift-engine/research.md` | 200 total | Phase 0 research | 6 key decisions including v2 architecture strategy |
| 24 | `specs/116-baseline-drift-engine/plan.md` | 259 total | Implementation plan | Steps 1-7 for v1; v2 deferred |
---
## 6. Type Coverage Matrix
Coverage assessment for deep-drift feasibility: **which types have per-type normalization and hydration support?**
| # | `policy_type` | Label | Hydration | Subresources | Per-Type Normalizer | Deep-Drift Feasible | Notes |
|---|---|---|---|---|---|---|---|
| 1 | `settingsCatalogPolicy` | Settings Catalog Policy | `configurationPolicies` | `settings` (list) | Yes (via PolicyNormalizer) | **YES** | Most impactful — complex nested settings |
| 2 | `endpointSecurityPolicy` | Endpoint Security Policies | `configurationPolicies` | `settings` (list) | Yes (shared with settings catalog) | **YES** | Same endpoint family as settings catalog |
| 3 | `securityBaselinePolicy` | Security Baselines | `configurationPolicies` | `settings` (list) | Yes (shared) | **YES** | Same pipeline |
| 4 | `groupPolicyConfiguration` | Administrative Templates | `groupPolicyConfigurations` | `definitionValues``presentationValues` | Yes (via PolicyNormalizer) | **YES** | Multi-level nesting; hydration required |
| 5 | `deviceConfiguration` | Device Configuration | `deviceConfigurations` | None (properties on root) | Yes (via PolicyNormalizer) | **YES** | Properties directly on resource |
| 6 | `deviceCompliancePolicy` | Device Compliance | `deviceCompliancePolicies` | `scheduledActionsForRule` (expand) | Yes (via PolicyNormalizer) | **YES** | Actions subresource needs expand |
| 7 | `windowsUpdateRing` | Software Update Ring | `deviceConfigurations` (filtered) | None (properties on root) | Yes (shared with deviceConfig) | **YES** | Subset of deviceConfiguration |
| 8 | `appProtectionPolicy` | App Protection (MAM) | `managedAppPolicies` | None (properties) | Partial (via PolicyNormalizer) | **YES** | Mobile-focused |
| 9 | `conditionalAccessPolicy` | Conditional Access | `identity/conditionalAccess/policies` | None (properties) | Yes (via PolicyNormalizer) | **YES** | High-risk, preview-only restore |
| 10 | `deviceManagementScript` | PowerShell Scripts | `deviceManagementScripts` | None (scriptContent base64) | Partial | **PARTIAL** | Script content is base64 in snapshot |
| 11 | `deviceShellScript` | macOS Shell Scripts | `deviceShellScripts` | None (scriptContent base64) | Partial | **PARTIAL** | Same pattern as PS scripts |
| 12 | `deviceHealthScript` | Proactive Remediations | `deviceHealthScripts` | None | Partial | **PARTIAL** | Detection + remediation scripts |
| 13 | `deviceComplianceScript` | Custom Compliance Scripts | `deviceComplianceScripts` | None | Partial | **PARTIAL** | Script content |
| 14 | `windowsFeatureUpdateProfile` | Feature Updates | `windowsFeatureUpdateProfiles` | None | Yes | **YES** | Simple properties |
| 15 | `windowsQualityUpdateProfile` | Quality Updates | `windowsQualityUpdateProfiles` | None | Yes | **YES** | Simple properties |
| 16 | `windowsDriverUpdateProfile` | Driver Updates | `windowsDriverUpdateProfiles` | None | Yes | **YES** | Simple properties |
| 17 | `mamAppConfiguration` | App Config (MAM) | `targetedManagedAppConfigurations` | None | Partial | **YES** | Properties-based |
| 18 | `managedDeviceAppConfiguration` | App Config (Device) | `mobileAppConfigurations` | None | Partial | **YES** | Properties-based |
| 19 | `windowsAutopilotDeploymentProfile` | Autopilot Profiles | `windowsAutopilotDeploymentProfiles` | None | Minimal | **YES** | Properties-based |
| 20 | `windowsEnrollmentStatusPage` | Enrollment Status Page | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Enrollment types have limited settings |
| 21 | `deviceEnrollmentLimitConfiguration` | Enrollment Limits | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Numeric limit only |
| 22 | `deviceEnrollmentPlatformRestrictionsConfiguration` | Platform Restrictions | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Nested restriction config |
| 23 | `deviceEnrollmentNotificationConfiguration` | Enrollment Notifications | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Template snapshots nested |
| 24 | `enrollmentRestriction` | Enrollment Restrictions | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Mixed config type |
| 25 | `termsAndConditions` | Terms & Conditions | `termsAndConditions` | None | Yes | **YES** | bodyText, acceptanceStatement |
| 26 | `endpointSecurityIntent` | Endpoint Security Intents | `intents` | categories/settings (legacy) | Partial | **PARTIAL** | Legacy intent API; migrating to configPolicies |
| 27 | `mobileApp` | Applications | `mobileApps` | None | Minimal | **META-ONLY** | Metadata-only backup per config |
| 28 | `policySet` | Policy Sets | (if supported) | assignments | Minimal | **META-ONLY** | Container for other policies |
**Foundation Types:**
| # | `foundation_type` | Label | Deep-Drift | Notes |
|---|---|---|---|---|
| F1 | `assignmentFilter` | Assignment Filter | **YES** | `rule` property is key content |
| F2 | `roleScopeTag` | Scope Tag | **META-ONLY** | displayName + description only |
| F3 | `notificationMessageTemplate` | Notification Template | **PARTIAL** | Localized messages are subresource |
**Summary:**
- **Full content-fidelity feasible**: 16 types (settingsCatalog, endpointSecurity, securityBaseline, groupPolicy, deviceConfig, compliance, updateRings/profiles, appProtection, conditionalAccess, appConfigs, autopilot, termsAndConditions, assignmentFilter)
- **Partial** (script content / legacy APIs): 5 types
- **Meta-only sufficient**: 7 types (enrollment configs, mobileApp, roleScopeTag)
---
## 7. Proposal: Deep Drift Implementation Plan
### Phase v1.5 — Provider Chain (Opportunistic Content Fidelity)
**Goal**: Enable baseline compare to use existing PolicyVersions for content-fidelity hash when available, with meta-fidelity fallback.
**Estimated effort**: 3-5 days
#### Step 1: ContentHashProvider Interface
```php
// app/Contracts/Baselines/ContentHashProvider.php
interface ContentHashProvider
{
/**
* @return array{hash: string, fidelity: string, source: string}|null
*/
public function resolve(string $policyType, string $externalId, int $tenantId, CarbonImmutable $since): ?array;
}
```
#### Step 2: PolicyVersionContentProvider
```php
// app/Services/Baselines/PolicyVersionContentProvider.php
// Looks up the latest PolicyVersion for (tenant_id, external_id, policy_type)
// captured_at >= $since (baseline snapshot timestamp)
// Returns SettingsNormalizer → DriftHasher hash with fidelity='content'
```
#### Step 3: MetaFallbackProvider (existing logic)
```php
// Wraps InventoryMetaContract → DriftHasher → fidelity='meta'
```
#### Step 4: ContentProviderChain
```php
// Iterates [PolicyVersionContentProvider, MetaFallbackProvider]
// Returns first non-null result
```
#### Step 5: Integration in CompareBaselineToTenantJob
- `loadCurrentInventory()` accepts optional `ContentProviderChain`
- For each item: try chain, record fidelity + source
- `computeDrift()` unchanged (still hash vs hash comparison)
- Finding evidence includes `fidelity` and `content_hash_source`
#### Step 6: CaptureBaselineSnapshotJob enhancement
- Optional: during capture, also try `PolicyVersionContentProvider` to store content-fidelity baseline_hash
- Store `content_hash_source` in `baseline_snapshot_items.meta_jsonb`
- This means: if a backup was taken before baseline capture, the baseline itself is content-fidelity
#### Step 7: Coverage extension
- Add `content_coverage` to compare run context: which types had PolicyVersions, which fell back to meta
- Display in operation detail UI
#### Migration
```sql
-- Optional: add column for source tracking
ALTER TABLE baseline_snapshot_items
ADD COLUMN content_hash_source VARCHAR(255) NULL DEFAULT 'inventory_meta_v1';
```
### Phase v2.0 — On-Demand Content Capture (Future)
**Goal**: For types without recent PolicyVersions, perform targeted per-item GET during baseline capture/compare.
**Estimated effort**: 5-8 days
- Introduce `BaselineContentCaptureJob` that, for a given baseline profile's scope, identifies items lacking recent PolicyVersions and performs targeted GET + PolicyVersion creation.
- Reuses existing `PolicyCaptureOrchestrator` with a new "baseline-triggered" context.
- Adds `capture_mode` to baseline profile: `meta_only` (v1), `opportunistic` (v1.5), `full_content` (v2.0).
- Rate limiting: per-tenant throttle to avoid Graph API quota issues.
- Budget guard: max N items per capture run, with continuation support.
### Phase v2.5 — Inventory Content Enrichment (Future, Optional)
**Goal**: Optionally have inventory sync capture settings content inline during LIST (where type supports `$expand`).
- Some types support `$expand=settings` on LIST (settings catalog, endpoint security).
- This would give "free" content fidelity without per-item GET.
- High complexity: varies per type, may increase LIST payload size significantly.
- Evaluate ROI after v2.0 ships.
---
## 8. Test Plan (Enterprise)
### Unit Tests
| # | Test File | Scope | Key Assertions |
|---|---|---|---|
| U1 | `tests/Unit/Baselines/ContentProviderChainTest.php` | Provider chain resolution | First provider wins; null fallback; fidelity recorded correctly |
| U2 | `tests/Unit/Baselines/PolicyVersionContentProviderTest.php` | PolicyVersion lookup + normalization | Correct hash for known snapshot; returns null when no PV; respects `$since` cutoff |
| U3 | `tests/Unit/Baselines/MetaFallbackProviderTest.php` | Meta contract fallback | Produces `fidelity='meta'`; matches existing `InventoryMetaContract` behavior exactly |
| U4 | `tests/Unit/Baselines/InventoryMetaContractTest.php` | (Existing) contract stability | Null handling, ordering, versioning — extend for edge cases |
### Feature Tests
| # | Test File | Scope | Key Assertions |
|---|---|---|---|
| F1 | `tests/Feature/Baselines/BaselineCompareContentFidelityTest.php` | End-to-end compare with PolicyVersions available | Settings change → `different_version` finding with `fidelity='content'` |
| F2 | `tests/Feature/Baselines/BaselineCompareMixedFidelityTest.php` | Some types have PV, some don't | Mixed `fidelity` values in findings; coverage context records both |
| F3 | `tests/Feature/Baselines/BaselineCompareFallbackTest.php` | No PolicyVersions available | Falls back to meta fidelity; identical behavior to v1 |
| F4 | `tests/Feature/Baselines/BaselineCaptureFidelityTest.php` | Capture with PolicyVersions present | `baseline_hash` uses content fidelity; `content_hash_source` recorded |
| F5 | `tests/Feature/Baselines/BaselineCompareStaleVersionTest.php` | PolicyVersion older than snapshot | Falls back to meta (stale PV not used) |
| F6 | `tests/Feature/Baselines/BaselineCompareCoverageGuardContentTest.php` | Coverage reporting for content types | `content_coverage` in run context shows which types are content-covered |
### Existing Tests to Preserve
| # | Test File | Impact |
|---|---|---|
| E1 | `tests/Feature/Baselines/BaselineCompareFindingsTest.php` | Must still pass — meta fidelity is default when no PV exists |
| E2 | `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` | No change expected |
| E3 | `tests/Feature/Baselines/BaselineCompareStatsTest.php` | Stats remain grouped by scope_key; may need fidelity breakdown |
| E4 | `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` | Auto-close unaffected by fidelity source |
### Integration / Regression
| # | Test | Scope |
|---|---|---|
| I1 | Content hash stability across serialization | JSON encode/decode round-trip does not change hash |
| I2 | PolicyVersion normalizer alignment | Same snapshot → `SettingsNormalizer` produces same hash in both System A (via provider) and System B (via DriftFindingGenerator) |
| I3 | Hash collision protection | Different settings → different hashes (property-based test with sample data) |
| I4 | Empty snapshot edge case | PolicyVersion with empty/null snapshot → provider returns null → fallback works |
### Performance Tests
| # | Test | Acceptance Criteria |
|---|---|---|
| P1 | Compare job with 500 items, 50% with PolicyVersions | Completes in < 30s (DB-only, no Graph calls) |
| P2 | Provider chain query efficiency | PolicyVersion lookup uses batch query, not N+1 |
---
## 9. Open Questions / Assumptions
### Open Questions
| # | Question | Impact | Proposed Resolution |
|---|---|---|---|
| OQ-1 | **Staleness threshold for PolicyVersions**: How old can a PolicyVersion be before we reject it as a content source? | Determines false-negative risk | Default: PolicyVersion must be captured after the baseline snapshot's `captured_at`. Configurable per workspace. |
| OQ-2 | **Mixed fidelity UX**: How should the UI display findings with different fidelity levels? | User trust and understanding | Badge/icon on finding cards: "High confidence (content)" vs "Structural only (meta)". Filterable in findings table. |
| OQ-3 | **Should baseline capture _force_ a backup** if no recent PolicyVersions exist? | API cost vs accuracy trade-off | No for v1.5 (opportunistic only). Yes for v2.0 as opt-in `capture_mode: full_content`. |
| OQ-4 | **etag as change hint**: Should we use etag changes as a _trigger_ for on-demand PolicyVersion capture? | Could reduce unnecessary GETs | Worth investigating in v2.0. If etag changes during inventory sync, schedule targeted per-item GET for that policy only. |
| OQ-5 | **Settings Catalog `$expand=settings`** on LIST: Does Microsoft Graph support this? | Could give "free" content fidelity for settings catalog types | Needs validation against Graph API. If supported, would eliminate per-item GET for the most impactful type. |
| OQ-6 | **Retention / pruning interaction**: If old PolicyVersions are pruned, does that affect baseline compare? | Could lose content fidelity for old baselines | Baseline compare only needs versions captured _after_ baseline snapshot. Pruning policy should respect active baseline snapshots. |
### Assumptions
| # | Assumption | Risk if Wrong |
|---|---|---|
| A-1 | `DriftHasher::hashNormalized()` is deterministic across PHP serialization boundaries | Hash mismatch → false drift findings. **Validated**: uses `json_encode` with stable flags + `ksort`. |
| A-2 | `SettingsNormalizer` / `PolicyNormalizer` produce the same output for the same input regardless of call context (System A vs System B) | Hash inconsistency between systems. **Low risk**: same code path. |
| A-3 | PolicyVersions from backups contain complete settings (not partial hydration) | Incomplete content → false negatives or incorrect hashes. **Validated**: `PolicySnapshotService` performs full hydration per type. |
| A-4 | The `Finding` model's `fingerprint`/`recurrence_key` identity allows mixed fidelity sources | Identity collision if fidelity changes source. **Safe**: recurrence_key includes snapshot_id, not hash value. |
| A-5 | Graph LIST endpoints do NOT return settings values for any supported policy type | If wrong, inventory sync could capture settings "for free". **Validated**: LIST returns only `$select` fields per `graph_contracts.php`. |
| A-6 | Per-type normalizers in backup drift path handle all 28 supported policy types | If not, some types would produce unstable hashes. **Partially validated**: `PolicyNormalizer` has a fallback for unknown types. |
---
## 10. Key Questions Answered
### KQ-01: Are Baseline Compare and Backup Drift truly separate systems?
**Yes.** They share `DriftHasher` and the `Finding` model, but differ in:
- Data source: `InventoryItem` vs `PolicyVersion`
- Hash contract: `InventoryMetaContract` (7 fields, meta only) vs `SettingsNormalizer → PolicyNormalizer` (full snapshot)
- Finding generator: `CompareBaselineToTenantJob::computeDrift()` vs `DriftFindingGenerator::generate()`
- Finding identity: different recurrence key structures
- Scope model: `BaselineProfile`-scoped vs `selection_hash`-scoped
- Trigger: post-inventory-sync vs post-backup
- Coverage: `InventoryCoverage` guard vs none (trusts backup completeness)
### KQ-02: Should they be unified or remain separate?
**Hybrid approach (Provider Chain)** — as designed in Spec 116 v2. Keep separate triggering and scoping, but let System A _consume_ data produced by System B (PolicyVersions) via a provider chain. This avoids:
- Merging two fundamentally different scoping models
- Introducing new Graph API costs
- Disrupting existing backup drift workflows
### KQ-03: What is the minimal viable "v1.5" to bridge the gap?
Add a `PolicyVersionContentProvider` that checks for recent PolicyVersions as part of baseline compare's hash computation. For types where a PolicyVersion exists (i.e., a backup was taken), the compare immediately gains content-fidelity. For types without, meta-fidelity continues as before. **Net code change: ~200-300 lines** (interface + 2 providers + chain + integration).
### KQ-04: Which types benefit most from content-fidelity drift?
**Top priority** (complex settings, high change frequency):
1. `settingsCatalogPolicy` — most common, deeply nested settings
2. `groupPolicyConfiguration` — multi-level nesting (definitionValues → presentationValues)
3. `deviceCompliancePolicy` — compliance rules + scheduled actions
4. `deviceConfiguration` — broad category, many OData sub-types
5. `endpointSecurityPolicy` — critical security settings
6. `securityBaselinePolicy` — security-critical baselines
7. `conditionalAccessPolicy` — identity security gate
**Medium priority** (simpler settings but still valuable):
8. `appProtectionPolicy`, `windowsUpdateRing`, `windowsFeatureUpdateProfile`, `windowsQualityUpdateProfile`
### KQ-05: How does coverage work and how should it extend for content fidelity?
Currently: `InventoryCoverage::fromContext(latestSyncRun->context)``coveredTypes()` returns types with `status=succeeded`. Uncovered types → findings suppressed, outcome = `partially_succeeded`.
For v1.5: Add `content_coverage` alongside `meta_coverage`:
- `content_covered_types`: types where PolicyVersion exists post-baseline
- `meta_only_types`: types where only meta is available
- `uncovered_types`: types with no coverage at all (findings suppressed)
Finding evidence should include:
```json
{
"fidelity": "content",
"content_hash_source": "policy_version:42",
"note": "Hash computed from PolicyVersion #42 captured 2025-07-14T10:30:00Z"
}
```
### KQ-06: What is the long-term unified architecture?
**Provider precedence chain** with configurable capture modes:
```
BaselineProfile.capture_mode:
'meta_only' → InventoryMetaContract only (v1)
'opportunistic' → PolicyVersion if available → meta fallback (v1.5)
'full_content' → On-demand GET for missing types → PolicyVersion → meta (v2.0)
ContentProviderChain:
1. PolicyVersionContentProvider (checks existing PolicyVersions)
2. InventoryContentProvider (future: if inventory sync enriched)
3. MetaFallbackProvider (InventoryMetaContract v1)
```
The long-term vision is that baseline capture + compare use the **same normalizer pipeline** as backup drift, producing identical hashes for identical content regardless of which system produced the PolicyVersion. This is achievable because `DriftHasher` and `SettingsNormalizer` are already shared code.
---
## Appendix: Database Schema Reference
### `baseline_snapshot_items` (current)
```
id BIGINT PK
baseline_snapshot_id BIGINT FK → baseline_snapshots
subject_type VARCHAR(255) -- 'policy'
subject_external_id VARCHAR(255) -- Graph resource GUID
policy_type VARCHAR(255) -- e.g. 'settingsCatalogPolicy'
baseline_hash VARCHAR(64) -- sha256 of InventoryMetaContract
meta_jsonb JSONB -- {display_name, category, platform, meta_contract: {...}, fidelity, source}
created_at TIMESTAMP
updated_at TIMESTAMP
```
### `inventory_items` (current)
```
id BIGINT PK
tenant_id BIGINT FK → tenants
policy_type VARCHAR(255)
external_id VARCHAR(255)
display_name VARCHAR(255)
category VARCHAR(255) NULL
platform VARCHAR(255) NULL
meta_jsonb JSONB -- {odata_type, etag, scope_tag_ids, assignment_target_count}
last_seen_at TIMESTAMP NULL
last_seen_operation_run_id BIGINT NULL
created_at TIMESTAMP
updated_at TIMESTAMP
```
### `policy_versions` (current)
```
id BIGINT PK
tenant_id BIGINT FK → tenants
policy_id BIGINT FK → policies
version_number INTEGER
policy_type VARCHAR(255)
platform VARCHAR(255) NULL
created_by VARCHAR(255) NULL
captured_at TIMESTAMP
snapshot JSON -- FULL Graph GET response (hydrated)
metadata JSON -- additional metadata
assignments JSON NULL -- full assignments array
scope_tags JSON NULL -- scope tag IDs
assignments_hash VARCHAR(64) NULL
scope_tags_hash VARCHAR(64) NULL
created_at TIMESTAMP
updated_at TIMESTAMP
deleted_at TIMESTAMP NULL -- soft delete
```
### Proposed v1.5 Addition
```sql
ALTER TABLE baseline_snapshot_items
ADD COLUMN content_hash_source VARCHAR(255) NULL DEFAULT 'inventory_meta_v1';
-- Values: 'inventory_meta_v1', 'policy_version:{id}', 'inventory_content_v2'
```

View File

@ -4,6 +4,10 @@
<div wire:poll.5s="refreshStats"></div> <div wire:poll.5s="refreshStats"></div>
@endif @endif
@php
$hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
@endphp
{{-- Row 1: Stats Overview --}} {{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed'])) @if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
@ -12,11 +16,29 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div> <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div> <div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
@if ($snapshotId) <div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="success" size="sm" class="w-fit"> @if ($snapshotId)
Snapshot #{{ $snapshotId }} <x-filament::badge color="success" size="sm" class="w-fit">
</x-filament::badge> Snapshot #{{ $snapshotId }}
@endif </x-filament::badge>
@endif
@if (filled($coverageStatus))
<x-filament::badge
:color="$coverageStatus === 'ok' ? 'success' : 'warning'"
size="sm"
class="w-fit"
>
Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }}
</x-filament::badge>
@endif
@if (filled($fidelity))
<x-filament::badge color="gray" size="sm" class="w-fit">
Fidelity: {{ Str::title($fidelity) }}
</x-filament::badge>
@endif
</div>
</div> </div>
</x-filament::section> </x-filament::section>
@ -27,7 +49,7 @@
@if ($state === 'failed') @if ($state === 'failed')
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div> <div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
@else @else
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}"> <div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : ($hasCoverageWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400') }}">
{{ $findingsCount ?? 0 }} {{ $findingsCount ?? 0 }}
</div> </div>
@endif @endif
@ -36,8 +58,10 @@
<x-filament::loading-indicator class="h-3 w-3" /> <x-filament::loading-indicator class="h-3 w-3" />
Comparing… Comparing…
</div> </div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready') @elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasCoverageWarnings)
<span class="text-sm text-success-600 dark:text-success-400">All clear</span> <span class="text-sm text-success-600 dark:text-success-400">All clear</span>
@elseif ($state === 'ready' && $hasCoverageWarnings)
<span class="text-sm text-warning-600 dark:text-warning-400">Coverage warnings</span>
@endif @endif
</div> </div>
</x-filament::section> </x-filament::section>
@ -59,6 +83,47 @@
</div> </div>
@endif @endif
{{-- Coverage warnings banner --}}
@if ($state === 'ready' && $hasCoverageWarnings)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
Comparison completed with warnings
</div>
<div class="text-sm text-warning-800 dark:text-warning-300">
@if (($coverageStatus ?? null) === 'unproven')
Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.
@else
Findings were skipped for {{ (int) ($uncoveredTypesCount ?? 0) }} policy {{ Str::plural('type', (int) ($uncoveredTypesCount ?? 0)) }} due to incomplete coverage.
@endif
@if (! empty($uncoveredTypes))
<div class="mt-2 text-xs text-warning-800 dark:text-warning-300">
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)@endif
</div>
@endif
</div>
@if ($this->getRunUrl())
<div class="mt-2">
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="warning"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
View run
</x-filament::button>
</div>
@endif
</div>
</div>
</div>
@endif
{{-- Failed run banner --}} {{-- Failed run banner --}}
@if ($state === 'failed') @if ($state === 'failed')
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50"> <div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
@ -189,7 +254,7 @@
@endif @endif
{{-- Ready: no drift --}} {{-- Ready: no drift --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0) @if ($state === 'ready' && ($findingsCount ?? 0) === 0 && ! $hasCoverageWarnings)
<x-filament::section> <x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center"> <div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" /> <x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
@ -213,6 +278,31 @@
</x-filament::section> </x-filament::section>
@endif @endif
{{-- Ready: warnings, no findings --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && $hasCoverageWarnings)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">Coverage Warnings</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.
</div>
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
Review last run
</x-filament::button>
@endif
</div>
</x-filament::section>
@endif
{{-- Idle state --}} {{-- Idle state --}}
@if ($state === 'idle') @if ($state === 'idle')
<x-filament::section> <x-filament::section>

View File

@ -1,4 +1,8 @@
<x-filament::page> <x-filament::page>
@php
$baselineCompareHasWarnings = in_array(($baselineCompareCoverageStatus ?? null), ['warning', 'unproven'], true);
@endphp
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
@ -35,6 +39,51 @@
</div> </div>
@endif @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') @if ($state === 'blocked')
<x-filament::badge color="gray"> <x-filament::badge color="gray">
Blocked Blocked

View File

@ -0,0 +1,46 @@
@php
/** @var bool $shouldShow */
/** @var ?string $runUrl */
/** @var ?string $coverageStatus */
/** @var ?string $fidelity */
/** @var int $uncoveredTypesCount */
/** @var list<string> $uncoveredTypes */
$coverageHasWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
@endphp
<div>
@if ($shouldShow && $coverageHasWarnings)
<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 (($coverageStatus ?? null) === 'unproven')
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
@else
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
@endif
@if (filled($fidelity))
<span class="ml-1 text-xs text-warning-800 dark:text-warning-300">Fidelity: {{ Str::title($fidelity) }}</span>
@endif
</div>
@if (! empty($uncoveredTypes))
<div class="mt-1 text-xs">
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)@endif
</div>
@endif
@if (filled($runUrl))
<div class="mt-2">
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
View run
</a>
</div>
@endif
</div>
</div>
@endif
</div>

View File

@ -141,6 +141,15 @@ ### Step 1 — Baseline scope schema + UI picker
- `policy_types: []` meaning “all supported policy types excluding foundations” - `policy_types: []` meaning “all supported policy types excluding foundations”
- `foundation_types: []` meaning “none” - `foundation_types: []` meaning “none”
- Update `BaselineProfile` form schema (Filament Resource) to show multi-selects for Policy Types and Foundations. - Update `BaselineProfile` form schema (Filament Resource) to show multi-selects for Policy Types and Foundations.
- Document selector-to-config mapping (source of truth for option lists + defaults):
| Selector | Form state path | Options source | Default semantics |
|---|---|---|---|
| Policy Types | `scope_jsonb.policy_types` | `config('tenantpilot.supported_policy_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::supported()` | Empty ⇒ all supported policy types (**excluding foundations**) |
| Foundations | `scope_jsonb.foundation_types` | `config('tenantpilot.foundation_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::foundations()` | Empty ⇒ none |
Notes:
- Inventory sync selection uses `App\Services\BackupScheduling\PolicyTypeResolver::supportedPolicyTypes()` for policy types, and `InventorySyncService::foundationTypes()` (derived from `config('tenantpilot.foundation_types')`) when `include_foundations=true`.
Tests: Tests:
- Update/add Pest tests around scope expansion defaults (prefer a focused unit-like test if an expansion helper exists). - Update/add Pest tests around scope expansion defaults (prefer a focused unit-like test if an expansion helper exists).

View File

@ -34,11 +34,15 @@ ### 3) Compare baseline to tenant
Expected: Expected:
- Compare uses latest successful baseline snapshot by default (or explicit snapshot selection if provided). - Compare uses latest successful baseline snapshot by default (or explicit snapshot selection if provided).
- Compare uses the latest inventory sync run coverage: - Compare uses the latest **completed** inventory sync run coverage:
- For uncovered policy types, **no findings are emitted**. - For uncovered policy types, **no findings are emitted**.
- OperationRun outcome becomes “completed with warnings” (partial) when uncovered types exist. - OperationRun outcome becomes “completed with warnings” (partial) when uncovered types exist.
- `summary_counts.errors_recorded = count(uncovered_types)`. - `summary_counts.errors_recorded = count(uncovered_types)`.
- Edge case: if effective scope expands to zero types, outcome is still partial (warnings) and `summary_counts.errors_recorded = 1`. - Edge case: if effective scope expands to zero types, outcome is still partial (warnings) and `summary_counts.errors_recorded = 1`.
- Fail-safe: if there is **no completed inventory sync run** or the coverage payload is missing/unreadable, coverage is treated as **unproven** for all effective types:
- **no findings are emitted**
- outcome is partial (warnings)
- compare run context includes `baseline_compare.coverage.proof = false`
- Findings identity: - Findings identity:
- stable `recurrence_key` uses `baseline_snapshot_id` and does **not** include baseline/current hashes. - stable `recurrence_key` uses `baseline_snapshot_id` and does **not** include baseline/current hashes.
- `fingerprint == recurrence_key`. - `fingerprint == recurrence_key`.
@ -53,5 +57,6 @@ ## Minimal smoke test checklist
- Compare with full coverage: produces correct findings; outcome success. - Compare with full coverage: produces correct findings; outcome success.
- Compare with partial coverage: produces findings only for covered types; outcome partial; uncovered types listed in context. - Compare with partial coverage: produces findings only for covered types; outcome partial; uncovered types listed in context.
- Compare with unproven coverage (no completed sync / missing coverage): emits zero findings; outcome partial; warning visible in UI.
- Re-run compare with no changes: no new findings; `times_seen` increments. - Re-run compare with no changes: no new findings; `times_seen` increments.
- Re-capture snapshot and compare: findings identity changes (snapshot-scoped). - Re-capture snapshot and compare: findings identity changes (snapshot-scoped).

View File

@ -16,11 +16,11 @@ ## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure local dev + feature artifacts are ready. **Purpose**: Ensure local dev + feature artifacts are ready.
- [ ] T001 Re-run Speckit prerequisites check via `.specify/scripts/bash/check-prerequisites.sh --json` (references `specs/116-baseline-drift-engine/plan.md`) - [X] T001 Re-run Speckit prerequisites check via `.specify/scripts/bash/check-prerequisites.sh --json` (references `specs/116-baseline-drift-engine/plan.md`)
- [ ] T002 Run required agent context update via `.specify/scripts/bash/update-agent-context.sh copilot` (required by `specs/116-baseline-drift-engine/plan.md`) - [X] T002 Run required agent context update via `.specify/scripts/bash/update-agent-context.sh copilot` (required by `specs/116-baseline-drift-engine/plan.md`)
- [ ] T003 Ensure Sail + migrations are up for local validation (references `vendor/bin/sail`, `docker-compose.yml`, and `database/migrations/`) - [X] T003 Ensure Sail + migrations are up for local validation (references `vendor/bin/sail`, `docker-compose.yml`, and `database/migrations/`)
- [ ] T004 [P] Re-validate Spec 116 UI Action Matrix: confirm “no changes required” OR update the matrix table in `specs/116-baseline-drift-engine/spec.md` - [X] T004 [P] Re-validate Spec 116 UI Action Matrix: confirm “no changes required” OR update the matrix table in `specs/116-baseline-drift-engine/spec.md`
- [ ] T005 [P] Document the mapping of “Policy Types” / “Foundations” selectors to config sources in `specs/116-baseline-drift-engine/plan.md` (and adjust `config/tenantpilot.php` / `app/Support/Inventory/InventoryPolicyTypeMeta.php` if mismatched) - [X] T005 [P] Document the mapping of “Policy Types” / “Foundations” selectors to config sources in `specs/116-baseline-drift-engine/plan.md` (and adjust `config/tenantpilot.php` / `app/Support/Inventory/InventoryPolicyTypeMeta.php` if mismatched)
--- ---
@ -30,14 +30,14 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**Independent Test**: Baseline Profile can be created with the new scope shape, and scope defaults expand deterministically. **Independent Test**: Baseline Profile can be created with the new scope shape, and scope defaults expand deterministically.
- [ ] T006 Update baseline scope schema + default semantics (policy_types excludes foundations by default; foundation_types defaults to none) in `app/Support/Baselines/BaselineScope.php` - [X] T006 Update baseline scope schema + default semantics (policy_types excludes foundations by default; foundation_types defaults to none) in `app/Support/Baselines/BaselineScope.php`
- [ ] T007 [P] Update BaselineProfile default scope shape to include `foundation_types` in `database/factories/BaselineProfileFactory.php` - [X] T007 [P] Update BaselineProfile default scope shape to include `foundation_types` in `database/factories/BaselineProfileFactory.php`
- [ ] T008 [P] Ensure BaselineProfile scope casting/normalization supports `foundation_types` safely in `app/Models/BaselineProfile.php` - [X] T008 [P] Ensure BaselineProfile scope casting/normalization supports `foundation_types` safely in `app/Models/BaselineProfile.php`
- [ ] T009 [P] Create focused tests for scope expansion defaults (empty policy_types => supported excluding foundations; empty foundation_types => none) in `tests/Unit/Baselines/BaselineScopeTest.php` - [X] T009 [P] Create focused tests for scope expansion defaults (empty policy_types => supported excluding foundations; empty foundation_types => none) in `tests/Unit/Baselines/BaselineScopeTest.php`
- [ ] T010 Update BaselineProfile Create/Edit form (UX-001 Main/Aside), scope picker (Policy Types + Foundations), and infolist display semantics in `app/Filament/Resources/BaselineProfileResource.php` - [X] T010 Update BaselineProfile Create/Edit form (UX-001 Main/Aside), scope picker (Policy Types + Foundations), and infolist display semantics in `app/Filament/Resources/BaselineProfileResource.php`
- [ ] T011 [P] Update create-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php` - [X] T011 [P] Update create-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php`
- [ ] T012 [P] Update edit-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php` - [X] T012 [P] Update edit-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php`
- [ ] T013 Run focused verification for foundational scope/UI changes with `vendor/bin/sail artisan test --compact tests/Unit/Baselines/BaselineScopeTest.php` (references `tests/Unit/Baselines/BaselineScopeTest.php`) - [X] T013 Run focused verification for foundational scope/UI changes with `vendor/bin/sail artisan test --compact tests/Unit/Baselines/BaselineScopeTest.php` (references `tests/Unit/Baselines/BaselineScopeTest.php`)
**Checkpoint**: Scope semantics + scope UI are correct and test-covered. **Checkpoint**: Scope semantics + scope UI are correct and test-covered.
@ -51,25 +51,25 @@ ## Phase 3: User Story 1 — Capture and compare a baseline with stable findings
### Tests for User Story 1 (write first) ### Tests for User Story 1 (write first)
- [ ] T014 [P] [US1] Update capture tests for effective_scope recording + contract-based hashing in `tests/Feature/Baselines/BaselineCaptureTest.php` - [X] T014 [P] [US1] Update capture tests for effective_scope recording + contract-based hashing in `tests/Feature/Baselines/BaselineCaptureTest.php`
- [ ] T015 [P] [US1] Update compare findings tests to assert recurrence-key-based identity (no hashes in fingerprint) + lifecycle idempotency per run + `run.context.findings.counts_by_change_type` is present and accurate in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` - [X] T015 [P] [US1] Update compare findings tests to assert recurrence-key-based identity (no hashes in fingerprint) + lifecycle idempotency per run + `run.context.findings.counts_by_change_type` is present and accurate in `tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [ ] T016 [P] [US1] Add unit test for InventoryMetaContract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php` - [X] T016 [P] [US1] Add unit test for InventoryMetaContract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php`
- [ ] T017 [P] [US1] Add/adjust preconditions tests for default snapshot selection via `baseline_profiles.active_snapshot_id` (“latest successful”) + explicit snapshot override (per `specs/116-baseline-drift-engine/contracts/openapi.yaml`) in `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` - [X] T017 [P] [US1] Add/adjust preconditions tests for default snapshot selection via `baseline_profiles.active_snapshot_id` (“latest successful”) + explicit snapshot override (per `specs/116-baseline-drift-engine/contracts/openapi.yaml`) in `tests/Feature/Baselines/BaselineComparePreconditionsTest.php`
- [ ] T018 [P] [US1] Add test that re-capturing (new snapshot id) produces new finding identities (snapshot-scoped) in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` - [X] T018 [P] [US1] Add test that re-capturing (new snapshot id) produces new finding identities (snapshot-scoped) in `tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [ ] T019 [P] [US1] Extend compare-start auth tests to cover: unauthenticated→302, authenticated non-member/not entitled tenant access→404, authenticated member missing `tenant.sync`→403, and success path in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` - [X] T019 [P] [US1] Extend compare-start auth tests to cover: unauthenticated→302, authenticated non-member/not entitled tenant access→404, authenticated member missing `tenant.sync`→403, and success path in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
- [ ] T020 [P] [US1] Add capture-start auth tests for Baseline Profile “Capture Snapshot” action to cover: unauthenticated→302, authenticated non-member/not entitled workspace access→404, authenticated member missing `workspace_baselines.manage`→403, and success path in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` - [X] T020 [P] [US1] Add capture-start auth tests for Baseline Profile “Capture Snapshot” action to cover: unauthenticated→302, authenticated non-member/not entitled workspace access→404, authenticated member missing `workspace_baselines.manage`→403, and success path in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
- [ ] T021 [P] [US1] Confirm baseline profile create/edit mutation surfaces have positive + negative authorization coverage (302/404/403 semantics) and update for new scope shape if needed in `tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` - [X] T021 [P] [US1] Confirm baseline profile create/edit mutation surfaces have positive + negative authorization coverage (302/404/403 semantics) and update for new scope shape if needed in `tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
### Implementation for User Story 1 ### Implementation for User Story 1
- [ ] T022 [US1] Create + implement Inventory Meta Contract builder (normalized whitelist inputs; deterministic ordering) in `app/Services/Baselines/InventoryMetaContract.php` - [X] T022 [US1] Create + implement Inventory Meta Contract builder (normalized whitelist inputs; deterministic ordering) in `app/Services/Baselines/InventoryMetaContract.php`
- [ ] T023 [US1] Update snapshot hashing to hash ONLY the meta contract output (not entire meta_jsonb) in `app/Services/Baselines/BaselineSnapshotIdentity.php` - [X] T023 [US1] Update snapshot hashing to hash ONLY the meta contract output (not entire meta_jsonb) in `app/Services/Baselines/BaselineSnapshotIdentity.php`
- [ ] T024 [US1] Update baseline capture job to compute/store `baseline_hash` via InventoryMetaContract and persist `meta_jsonb` evidence (`meta_contract`, `fidelity`, `source`, `observed_at`, `observed_operation_run_id`) in `app/Jobs/CaptureBaselineSnapshotJob.php` - [X] T024 [US1] Update baseline capture job to compute/store `baseline_hash` via InventoryMetaContract and persist `meta_jsonb` evidence (`meta_contract`, `fidelity`, `source`, `observed_at`, `observed_operation_run_id`) in `app/Jobs/CaptureBaselineSnapshotJob.php`
- [ ] T025 [US1] Ensure capture OperationRun context records `effective_scope.*` (policy_types, foundation_types, all_types, foundations_included) in `app/Services/Baselines/BaselineCaptureService.php` - [X] T025 [US1] Ensure capture OperationRun context records `effective_scope.*` (policy_types, foundation_types, all_types, foundations_included) in `app/Services/Baselines/BaselineCaptureService.php`
- [ ] T026 [US1] Update baseline compare job to compute `current_hash` via InventoryMetaContract consistently with capture in `app/Jobs/CompareBaselineToTenantJob.php` - [X] T026 [US1] Update baseline compare job to compute `current_hash` via InventoryMetaContract consistently with capture in `app/Jobs/CompareBaselineToTenantJob.php`
- [ ] T027 [US1] Switch baseline-compare finding identity to recurrence key derived from (tenant, baseline_snapshot_id, policy_type, external_id, change_type) and set `fingerprint == recurrence_key` in `app/Jobs/CompareBaselineToTenantJob.php` - [X] T027 [US1] Switch baseline-compare finding identity to recurrence key derived from (tenant, baseline_snapshot_id, policy_type, external_id, change_type) and set `fingerprint == recurrence_key` in `app/Jobs/CompareBaselineToTenantJob.php`
- [ ] T028 [US1] Enforce per-run idempotency by using `findings.current_operation_run_id` (and/or evidence) so `times_seen` increments at most once per run identity in `app/Jobs/CompareBaselineToTenantJob.php` - [X] T028 [US1] Enforce per-run idempotency by using `findings.current_operation_run_id` (and/or evidence) so `times_seen` increments at most once per run identity in `app/Jobs/CompareBaselineToTenantJob.php`
- [ ] T029 [US1] Write compare audit context fields (baseline ids + `findings.counts_by_change_type`) onto the compare OperationRun context in `app/Jobs/CompareBaselineToTenantJob.php` - [X] T029 [US1] Write compare audit context fields (baseline ids + `findings.counts_by_change_type`) onto the compare OperationRun context in `app/Jobs/CompareBaselineToTenantJob.php`
**Checkpoint**: US1 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineComparePreconditionsTest.php`. **Checkpoint**: US1 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineComparePreconditionsTest.php`.
@ -83,15 +83,15 @@ ## Phase 4: User Story 2 — Coverage warnings prevent misleading missing-policy
### Tests for User Story 2 (write first) ### Tests for User Story 2 (write first)
- [ ] T030 [P] [US2] Extend inventory sync tests to assert per-type coverage payload is written to OperationRun context in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php` - [X] T030 [P] [US2] Extend inventory sync tests to assert per-type coverage payload is written to OperationRun context in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`
- [ ] T031 [P] [US2] Create coverage-guard regression test: (a) uncovered types => no findings of any kind; (b) no completed inventory sync run / missing coverage payload => fail-safe zero findings; (c) effective scope expands to zero types => warnings + zero findings; outcome partially_succeeded; covered types still emit findings in `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php` - [X] T031 [P] [US2] Create coverage-guard regression test: (a) uncovered types => no findings of any kind; (b) no completed inventory sync run / missing coverage payload => fail-safe zero findings; (c) effective scope expands to zero types => warnings + zero findings; outcome partially_succeeded; covered types still emit findings in `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php`
### Implementation for User Story 2 ### Implementation for User Story 2
- [ ] T032 [US2] Persist inventory sync coverage payload into latest inventory sync run context (`inventory.coverage.policy_types` + `inventory.coverage.foundation_types`) in `app/Services/Inventory/InventorySyncService.php` - [X] T032 [US2] Persist inventory sync coverage payload into latest inventory sync run context (`inventory.coverage.policy_types` + `inventory.coverage.foundation_types`) in `app/Services/Inventory/InventorySyncService.php`
- [ ] T033 [P] [US2] Create a small coverage parser/helper to normalize context payload for downstream consumers in `app/Support/Inventory/InventoryCoverage.php` - [X] T033 [P] [US2] Create a small coverage parser/helper to normalize context payload for downstream consumers in `app/Support/Inventory/InventoryCoverage.php`
- [ ] T034 [US2] Update baseline compare to read latest inventory sync run coverage, compute uncovered types, skip emission for uncovered types, and write coverage details into compare run context in `app/Jobs/CompareBaselineToTenantJob.php` - [X] T034 [US2] Update baseline compare to read latest inventory sync run coverage, compute uncovered types, skip emission for uncovered types, and write coverage details into compare run context in `app/Jobs/CompareBaselineToTenantJob.php`
- [ ] T035 [US2] Treat missing coverage proof (no completed inventory sync run, or unreadable/missing coverage payload) as uncovered-for-all-types (fail-safe): emit zero findings and mark outcome partially_succeeded (via OperationRunService), setting numeric summary_counts (including errors_recorded) using canonical keys only in `app/Jobs/CompareBaselineToTenantJob.php` - [X] T035 [US2] Treat missing coverage proof (no completed inventory sync run, or unreadable/missing coverage payload) as uncovered-for-all-types (fail-safe): emit zero findings and mark outcome partially_succeeded (via OperationRunService), setting numeric summary_counts (including errors_recorded) using canonical keys only in `app/Jobs/CompareBaselineToTenantJob.php`
**Note (canonical warning magnitude)**: For (c) “effective scope expands to zero types”, the compare MUST still surface a warning and therefore MUST set `summary_counts.errors_recorded = 1` (even though uncovered-types count is 0), to keep the warning visible under the numeric-only summary_counts contract. **Note (canonical warning magnitude)**: For (c) “effective scope expands to zero types”, the compare MUST still surface a warning and therefore MUST set `summary_counts.errors_recorded = 1` (even though uncovered-types count is 0), to keep the warning visible under the numeric-only summary_counts contract.
@ -107,16 +107,16 @@ ## Phase 5: User Story 3 — Operators can understand scope, coverage, and fidel
### Tests for User Story 3 (write first) ### Tests for User Story 3 (write first)
- [ ] T036 [P] [US3] Update Baseline Compare landing tests to cover warning/coverage state rendering inputs (stats DTO fields) in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` - [X] T036 [P] [US3] Update Baseline Compare landing tests to cover warning/coverage state rendering inputs (stats DTO fields) in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
- [ ] T037 [P] [US3] Update drift landing comparison-info tests to include coverage/fidelity context when source is baseline compare in `tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php` - [X] T037 [P] [US3] Update drift landing comparison-info tests to include coverage/fidelity context when source is baseline compare in `tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php`
### Implementation for User Story 3 ### Implementation for User Story 3
- [ ] T038 [US3] Extend BaselineCompareStats DTO to include coverage status + uncovered types summary + fidelity indicator sourced from latest compare run context in `app/Support/Baselines/BaselineCompareStats.php` - [X] T038 [US3] Extend BaselineCompareStats DTO to include coverage status + uncovered types summary + fidelity indicator sourced from latest compare run context in `app/Support/Baselines/BaselineCompareStats.php`
- [ ] T039 [US3] Wire new stats fields into the BaselineCompareLanding Livewire page state in `app/Filament/Pages/BaselineCompareLanding.php` - [X] T039 [US3] Wire new stats fields into the BaselineCompareLanding Livewire page state in `app/Filament/Pages/BaselineCompareLanding.php`
- [ ] T040 [US3] Render coverage badge + warning banner + fidelity label on the landing view in `resources/views/filament/pages/baseline-compare-landing.blade.php` - [X] T040 [US3] Render coverage badge + warning banner + fidelity label on the landing view in `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [ ] T041 [US3] Add a findings-list banner when latest baseline compare run had uncovered types (linking to the run) in `app/Filament/Resources/FindingResource/Pages/ListFindings.php` - [X] T041 [US3] Add a findings-list banner when latest baseline compare run had uncovered types (linking to the run) in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- [ ] T042 [US3] Ensure run detail already shows context; if needed, add baseline compare “Coverage” summary entry for readability in `app/Filament/Resources/OperationRunResource.php` - [X] T042 [US3] Ensure run detail already shows context; if needed, add baseline compare “Coverage” summary entry for readability in `app/Filament/Resources/OperationRunResource.php`
**Checkpoint**: US3 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php`. **Checkpoint**: US3 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php`.
@ -126,19 +126,19 @@ ## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Preserve operability semantics (auto-close, stats), Ops-UX compliance, and fast regression feedback. **Purpose**: Preserve operability semantics (auto-close, stats), Ops-UX compliance, and fast regression feedback.
- [ ] T043 Confirm baseline compare stats remain profile-grouped via `scope_key = baseline_profile:{id}` after identity change in `app/Support/Baselines/BaselineCompareStats.php` - [X] T043 Confirm baseline compare stats remain profile-grouped via `scope_key = baseline_profile:{id}` after identity change in `app/Support/Baselines/BaselineCompareStats.php`
- [ ] T044 Ensure baseline auto-close behavior still works with snapshot-scoped identities (no stale open findings after successful compare) in `app/Services/Baselines/BaselineAutoCloseService.php` - [X] T044 Ensure baseline auto-close behavior still works with snapshot-scoped identities (no stale open findings after successful compare) in `app/Services/Baselines/BaselineAutoCloseService.php`
- [ ] T045 [P] Update/verify auto-close regression test remains valid after identity change in `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` - [X] T045 [P] Update/verify auto-close regression test remains valid after identity change in `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php`
- [ ] T046 [P] Add/extend guard test asserting OperationRun summary_counts are numeric-only and keys are limited to `OperationSummaryKeys::all()` for baseline capture/compare runs in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` - [X] T046 [P] Add/extend guard test asserting OperationRun summary_counts are numeric-only and keys are limited to `OperationSummaryKeys::all()` for baseline capture/compare runs in `tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [ ] T047 [P] Add Spec 116 “One engine” regression guard to prevent legacy compare/hash paths (no `DriftHasher::fingerprint()` use for baseline compare; capture/compare hashing flows through `InventoryMetaContract`) in `tests/Feature/Guards/Spec116OneEngineGuardTest.php` - [X] T047 [P] Add Spec 116 “One engine” regression guard to prevent legacy compare/hash paths (no `DriftHasher::fingerprint()` use for baseline compare; capture/compare hashing flows through `InventoryMetaContract`) in `tests/Feature/Guards/Spec116OneEngineGuardTest.php`
- [ ] T048 [P] Add Spec 116 performance regression guard (compare is DB-only; no Graph/hydration calls; compare processes snapshot + inventory via chunking) in `tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php` - [X] T048 [P] Add Spec 116 performance regression guard (compare is DB-only; no Graph/hydration calls; compare processes snapshot + inventory via chunking) in `tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`
- [ ] T049 [P] Verify OPS-UX-GUARD-001 remains satisfied for Spec 116 code paths; if guard tests fail, fix code (preferred) or update guard expectations intentionally in `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`, `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` - [X] T049 [P] Verify OPS-UX-GUARD-001 remains satisfied for Spec 116 code paths; if guard tests fail, fix code (preferred) or update guard expectations intentionally in `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`, `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
- [ ] T050 [P] Create Baseline Profile archive action tests (confirmation required + RBAC 403/404 semantics + success path) in `tests/Feature/Baselines/BaselineProfileArchiveActionTest.php` - [X] T050 [P] Create Baseline Profile archive action tests (confirmation required + RBAC 403/404 semantics + success path) in `tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`
- [ ] T051 [P] Ensure archive action is declared in Action Surface slots and remains “More” row action only (max 2 visible row actions) in `tests/Feature/Guards/ActionSurfaceContractTest.php` - [X] T051 [P] Ensure archive action is declared in Action Surface slots and remains “More” row action only (max 2 visible row actions) in `tests/Feature/Guards/ActionSurfaceContractTest.php`
- [ ] T052 Run baseline-focused test pack for Spec 116: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` (references `tests/Feature/Baselines/`) - [X] T052 Run baseline-focused test pack for Spec 116: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` (references `tests/Feature/Baselines/`)
- [ ] T053 Run Ops-UX guard test pack: `vendor/bin/sail artisan test --compact --group=ops-ux` (references `tests/Feature/OpsUx/Constitution/`) - [X] T053 Run Ops-UX guard test pack: `vendor/bin/sail artisan test --compact --group=ops-ux` (references `tests/Feature/OpsUx/Constitution/`)
- [ ] T054 Run Pint formatter on changed files: `vendor/bin/sail pint --dirty --format agent` (references `app/` and `tests/`) - [X] T054 Run Pint formatter on changed files: `vendor/bin/sail pint --dirty --format agent` (references `app/` and `tests/`)
- [ ] T055 Validate developer quickstart still matches real behavior (update if needed) in `specs/116-baseline-drift-engine/quickstart.md` - [X] T055 Validate developer quickstart still matches real behavior (update if needed) in `specs/116-baseline-drift-engine/quickstart.md`
--- ---

View File

@ -8,8 +8,11 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\Baselines\BaselineCaptureService; use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
// --- T031: Capture enqueue + precondition tests --- // --- T031: Capture enqueue + precondition tests ---
@ -21,7 +24,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
/** @var BaselineCaptureService $service */ /** @var BaselineCaptureService $service */
@ -40,6 +43,13 @@
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey()); expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
expect($context['source_tenant_id'])->toBe((int) $tenant->getKey()); expect($context['source_tenant_id'])->toBe((int) $tenant->getKey());
expect($context)->toHaveKey('effective_scope');
$effectiveScope = is_array($context['effective_scope'] ?? null) ? $context['effective_scope'] : [];
expect($effectiveScope['policy_types'])->toBe(['deviceConfiguration']);
expect($effectiveScope['foundation_types'])->toBe([]);
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
expect($effectiveScope['foundations_included'])->toBeFalse();
Queue::assertPushed(CaptureBaselineSnapshotJob::class); Queue::assertPushed(CaptureBaselineSnapshotJob::class);
}); });
@ -51,7 +61,7 @@
$profile = BaselineProfile::factory()->create([ $profile = BaselineProfile::factory()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'status' => BaselineProfile::STATUS_DRAFT, 'status' => BaselineProfileStatus::Draft->value,
]); ]);
$service = app(BaselineCaptureService::class); $service = app(BaselineCaptureService::class);
@ -111,7 +121,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$service = app(BaselineCaptureService::class); $service = app(BaselineCaptureService::class);
@ -133,14 +143,29 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
InventoryItem::factory()->count(3)->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-a',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-b',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-c',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -151,7 +176,7 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
@ -159,6 +184,7 @@
$job = new CaptureBaselineSnapshotJob($run); $job = new CaptureBaselineSnapshotJob($run);
$job->handle( $job->handle(
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
); );
@ -178,6 +204,39 @@
expect($snapshot)->not->toBeNull(); expect($snapshot)->not->toBeNull();
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3); expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$items = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshot->getKey())
->orderBy('subject_external_id')
->get();
expect($items->pluck('subject_external_id')->all())->toBe(['policy-a', 'policy-b', 'policy-c']);
foreach ($items as $item) {
/** @var BaselineSnapshotItem $item */
$inventory = InventoryItem::query()
->where('tenant_id', $tenant->getKey())
->where('policy_type', $item->policy_type)
->where('external_id', $item->subject_external_id)
->first();
expect($inventory)->not->toBeNull();
$contract = $builder->build(
policyType: (string) $inventory->policy_type,
subjectExternalId: (string) $inventory->external_id,
metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [],
);
expect($item->baseline_hash)->toBe($hasher->hashNormalized($contract));
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
expect($meta)->toHaveKey('meta_contract');
expect($meta['meta_contract'])->toBe($contract);
}
$profile->refresh(); $profile->refresh();
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey()); expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
}); });
@ -187,7 +246,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
InventoryItem::factory()->count(2)->create([ InventoryItem::factory()->count(2)->create([
@ -199,6 +258,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$idService = app(BaselineSnapshotIdentity::class); $idService = app(BaselineSnapshotIdentity::class);
$metaContract = app(InventoryMetaContract::class);
$auditLogger = app(AuditLogger::class); $auditLogger = app(AuditLogger::class);
$run1 = $opService->ensureRunWithIdentity( $run1 = $opService->ensureRunWithIdentity(
@ -208,13 +268,13 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job1 = new CaptureBaselineSnapshotJob($run1); $job1 = new CaptureBaselineSnapshotJob($run1);
$job1->handle($idService, $auditLogger, $opService); $job1->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterFirst = BaselineSnapshot::query() $snapshotCountAfterFirst = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
@ -230,16 +290,16 @@
'type' => 'baseline_capture', 'type' => 'baseline_capture',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp), 'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
'context' => [ 'context' => [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
]); ]);
$job2 = new CaptureBaselineSnapshotJob($run2); $job2 = new CaptureBaselineSnapshotJob($run2);
$job2->handle($idService, $auditLogger, $opService); $job2->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterSecond = BaselineSnapshot::query() $snapshotCountAfterSecond = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
@ -255,7 +315,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']], 'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -266,7 +326,7 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['nonExistentPolicyType']], 'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
@ -274,6 +334,7 @@
$job = new CaptureBaselineSnapshotJob($run); $job = new CaptureBaselineSnapshotJob($run);
$job->handle( $job->handle(
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
); );
@ -299,7 +360,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => []], 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
@ -311,7 +372,14 @@
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'compliancePolicy', 'policy_type' => 'deviceCompliancePolicy',
]);
// Foundation types are excluded by default (unless foundation_types is selected).
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'assignmentFilter',
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -322,7 +390,7 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => []], 'effective_scope' => ['policy_types' => [], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
@ -330,6 +398,7 @@
$job = new CaptureBaselineSnapshotJob($run); $job = new CaptureBaselineSnapshotJob($run);
$job->handle( $job->handle(
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
); );

View File

@ -0,0 +1,354 @@
<?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\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
it('skips findings for uncovered types and marks compare partially_succeeded', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'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()]);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$coveredContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'covered-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'covered-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($coveredContract),
'meta_jsonb' => ['display_name' => 'Covered Policy'],
]);
$uncoveredContract = $builder->build(
policyType: 'deviceCompliancePolicy',
subjectExternalId: 'uncovered-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceCompliancePolicy', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'uncovered-uuid',
'policy_type' => 'deviceCompliancePolicy',
'baseline_hash' => $hasher->hashNormalized($uncoveredContract),
'meta_jsonb' => ['display_name' => 'Uncovered Policy'],
]);
$inventorySyncRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'inventory' => [
'coverage' => [
'policy_types' => [
'deviceConfiguration' => ['status' => 'succeeded'],
'deviceCompliancePolicy' => ['status' => 'failed'],
],
'foundation_types' => [],
],
],
],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'covered-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Covered Policy Changed',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->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', 'deviceCompliancePolicy'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->status)->toBe('completed');
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
$findings = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->get();
expect($findings)->toHaveCount(1);
expect((string) data_get($findings->first(), 'evidence_jsonb.change_type'))->toBe('different_version');
});
it('emits zero findings when there is no completed inventory sync run (fail-safe)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$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(),
]);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$contract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($contract),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Policy Changed',
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->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),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->status)->toBe('completed');
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
expect((int) ($counts['total'] ?? -1))->toBe(0);
expect(
Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->count()
)->toBe(0);
});
it('emits zero findings when coverage payload is missing (fail-safe)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$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(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'selection_hash' => 'latest',
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline'),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Policy Changed',
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->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),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
expect((int) ($counts['total'] ?? -1))->toBe(0);
expect(
Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->count()
)->toBe(0);
});
it('emits a warning and zero findings when effective scope expands to zero types', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['unsupported_type'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->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' => ['unsupported_type'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
expect((int) ($counts['total'] ?? -1))->toBe(0);
expect(
Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->count()
)->toBe(0);
});

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob; use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
@ -9,13 +10,13 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher; use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationSummaryKeys;
use function Pest\Laravel\mock; use function Pest\Laravel\mock;
@ -26,7 +27,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$snapshot = BaselineSnapshot::factory()->create([ $snapshot = BaselineSnapshot::factory()->create([
@ -36,6 +37,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// Baseline has policyA and policyB // Baseline has policyA and policyB
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
@ -62,6 +68,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true], 'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified', 'display_name' => 'Policy A modified',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -70,6 +78,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['new_policy' => true], 'meta_jsonb' => ['new_policy' => true],
'display_name' => 'Policy C unexpected', 'display_name' => 'Policy C unexpected',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -80,14 +90,13 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job = new CompareBaselineToTenantJob($run); $job = new CompareBaselineToTenantJob($run);
$job->handle( $job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -97,6 +106,14 @@
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded'); expect($run->outcome)->toBe('succeeded');
$context = is_array($run->context) ? $run->context : [];
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;
expect($countsByChangeType)->toBe([
'different_version' => 1,
'missing_policy' => 1,
'unexpected_policy' => 1,
]);
$scopeKey = 'baseline_profile:'.$profile->getKey(); $scopeKey = 'baseline_profile:'.$profile->getKey();
$findings = Finding::query() $findings = Finding::query()
@ -107,6 +124,7 @@
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings // policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
expect($findings->count())->toBe(3); expect($findings->count())->toBe(3);
expect($findings->every(fn (Finding $finding): bool => filled($finding->recurrence_key) && $finding->recurrence_key === $finding->fingerprint))->toBeTrue();
// Lifecycle v2 fields must be initialized for new findings. // Lifecycle v2 fields must be initialized for new findings.
expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count()); expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count());
@ -134,6 +152,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
@ -158,6 +181,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true], 'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified', 'display_name' => 'Policy A modified',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -181,7 +206,6 @@
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver); $baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
(new CompareBaselineToTenantJob($run))->handle( (new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -222,6 +246,26 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$olderInventoryRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['settingsCatalogPolicy' => 'succeeded'],
attributes: [
'selection_hash' => 'older',
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'finished_at' => now()->subMinutes(5),
],
);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['settingsCatalogPolicy' => 'succeeded'],
attributes: [
'selection_hash' => 'latest',
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'finished_at' => now(),
],
);
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
@ -231,19 +275,6 @@
'meta_jsonb' => ['display_name' => 'Settings Catalog A'], 'meta_jsonb' => ['display_name' => 'Settings Catalog A'],
]); ]);
$olderInventoryRun = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subMinutes(5),
'context' => [
'policy_types' => ['settingsCatalogPolicy'],
'selection_hash' => 'older',
],
]);
// Inventory item exists, but it was NOT observed in the latest sync run. // Inventory item exists, but it was NOT observed in the latest sync run.
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -256,19 +287,6 @@
'last_seen_at' => now()->subMinutes(5), 'last_seen_at' => now()->subMinutes(5),
]); ]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'policy_types' => ['settingsCatalogPolicy'],
'selection_hash' => 'latest',
],
]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
@ -283,7 +301,6 @@
); );
(new CompareBaselineToTenantJob($run))->handle( (new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -305,12 +322,12 @@
expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy'); expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy');
}); });
it('produces idempotent fingerprints so re-running compare updates existing findings', function () { it('uses recurrence-key identity (no hashes in fingerprint) and increments times_seen at most once per run', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$snapshot = BaselineSnapshot::factory()->create([ $snapshot = BaselineSnapshot::factory()->create([
@ -320,19 +337,42 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid', 'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'), 'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => 'Policy X'], 'meta_jsonb' => ['display_name' => 'Policy X'],
]); ]);
// Tenant does NOT have policy-x → missing_policy finding InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
'display_name' => 'Policy X modified',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
// First run
$run1 = $opService->ensureRunWithIdentity( $run1 = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCompare->value, type: OperationRunType::BaselineCompare->value,
@ -340,31 +380,54 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job1 = new CompareBaselineToTenantJob($run1); $job = new CompareBaselineToTenantJob($run1);
$job1->handle( $job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
); );
$scopeKey = 'baseline_profile:'.$profile->getKey(); $scopeKey = 'baseline_profile:'.$profile->getKey();
$countAfterFirst = Finding::query()
$finding = Finding::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare') ->where('source', 'baseline.compare')
->where('scope_key', $scopeKey) ->where('scope_key', $scopeKey)
->count(); ->sole();
expect($countAfterFirst)->toBe(1); expect($finding->recurrence_key)->not->toBeNull();
expect($finding->fingerprint)->toBe($finding->recurrence_key);
expect($finding->times_seen)->toBe(1);
// Second run - new OperationRun so we can dispatch again $fingerprint = (string) $finding->fingerprint;
// Mark first run as completed so ensureRunWithIdentity creates a new one $currentHash1 = (string) ($finding->evidence_jsonb['current_hash'] ?? '');
$run1->update(['status' => 'completed', 'completed_at' => now()]); expect($currentHash1)->not->toBe('');
// Retry the same run ID (job retry): times_seen MUST NOT increment twice for the same run.
$job->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding->refresh();
expect($finding->times_seen)->toBe(1);
expect((string) $finding->fingerprint)->toBe($fingerprint);
expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->toBe($currentHash1);
// Change inventory evidence (hash changes) and run compare again with a new OperationRun.
InventoryItem::query()
->where('tenant_id', $tenant->getKey())
->where('policy_type', 'deviceConfiguration')
->where('external_id', 'policy-x-uuid')
->update([
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_2'],
]);
$run2 = $opService->ensureRunWithIdentity( $run2 = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
@ -373,27 +436,139 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job2 = new CompareBaselineToTenantJob($run2); (new CompareBaselineToTenantJob($run2))->handle(
$job2->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
); );
$countAfterSecond = Finding::query() $finding->refresh();
expect((string) $finding->fingerprint)->toBe($fingerprint);
expect($finding->times_seen)->toBe(2);
expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->not->toBe($currentHash1);
});
it('creates new finding identities when a new snapshot is captured (snapshot-scoped recurrence)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
$baselineHash = $hasher->hashNormalized($baselineContract);
$snapshot1 = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot1->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $baselineHash,
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$run1 = $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) $snapshot1->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run1))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$fingerprint1 = (string) Finding::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare') ->where('source', 'baseline.compare')
->where('scope_key', $scopeKey) ->where('scope_key', $scopeKey)
->count(); ->orderBy('id')
->firstOrFail()
->fingerprint;
// Same fingerprint → same finding updated, not duplicated $snapshot2 = BaselineSnapshot::factory()->create([
expect($countAfterSecond)->toBe(1); 'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot2->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $baselineHash,
]);
$run2 = $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) $snapshot2->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->orderBy('id')
->get();
expect($findings)->toHaveCount(2);
expect($findings->pluck('fingerprint')->unique()->count())->toBe(2);
expect($findings->pluck('fingerprint')->all())->toContain($fingerprint1);
}); });
it('creates zero findings when baseline matches tenant inventory exactly', function () { it('creates zero findings when baseline matches tenant inventory exactly', function () {
@ -401,7 +576,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$snapshot = BaselineSnapshot::factory()->create([ $snapshot = BaselineSnapshot::factory()->create([
@ -411,10 +586,26 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// Baseline item // Baseline item
$metaContent = ['policy_key' => 'value123']; $metaContent = [
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E1',
'scope_tag_ids' => ['scope-2', 'scope-1'],
'assignment_target_count' => 3,
];
$builder = app(InventoryMetaContract::class);
$driftHasher = app(DriftHasher::class); $driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent); $contentHash = $driftHasher->hashNormalized($builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'matching-uuid',
metaJsonb: $metaContent,
));
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
@ -433,6 +624,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent, 'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy', 'display_name' => 'Matching Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -443,14 +636,13 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job = new CompareBaselineToTenantJob($run); $job = new CompareBaselineToTenantJob($run);
$job->handle( $job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -476,7 +668,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$snapshot = BaselineSnapshot::factory()->create([ $snapshot = BaselineSnapshot::factory()->create([
@ -486,9 +678,25 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$metaContent = ['policy_key' => 'value123']; $inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$metaContent = [
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E1',
'scope_tag_ids' => ['scope-2', 'scope-1'],
'assignment_target_count' => 3,
];
$builder = app(InventoryMetaContract::class);
$driftHasher = app(DriftHasher::class); $driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent); $contentHash = $driftHasher->hashNormalized($builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'matching-uuid',
metaJsonb: $metaContent,
));
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
@ -515,6 +723,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent, 'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy', 'display_name' => 'Matching Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
@ -524,6 +734,8 @@
'policy_type' => 'notificationMessageTemplate', 'policy_type' => 'notificationMessageTemplate',
'meta_jsonb' => ['some' => 'value'], 'meta_jsonb' => ['some' => 'value'],
'display_name' => 'Foundation Template', 'display_name' => 'Foundation Template',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -534,13 +746,12 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(), 'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
(new CompareBaselineToTenantJob($run))->handle( (new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -578,6 +789,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// 2 baseline items: one will be missing (high), one will be different (medium) // 2 baseline items: one will be missing (high), one will be different (medium)
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
@ -604,6 +820,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['modified_content' => true], 'meta_jsonb' => ['modified_content' => true],
'display_name' => 'Changed Policy', 'display_name' => 'Changed Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -612,6 +830,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['extra_content' => true], 'meta_jsonb' => ['extra_content' => true],
'display_name' => 'Extra Policy', 'display_name' => 'Extra Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -629,7 +849,6 @@
$job = new CompareBaselineToTenantJob($run); $job = new CompareBaselineToTenantJob($run);
$job->handle( $job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -659,6 +878,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// One missing policy // One missing policy
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
@ -683,7 +907,6 @@
$job = new CompareBaselineToTenantJob($run); $job = new CompareBaselineToTenantJob($run);
$job->handle( $job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$opService, $opService,
@ -715,6 +938,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
@ -739,7 +967,6 @@
); );
(new CompareBaselineToTenantJob($firstRun))->handle( (new CompareBaselineToTenantJob($firstRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$operationRuns, $operationRuns,
@ -771,7 +998,6 @@
); );
(new CompareBaselineToTenantJob($secondRun))->handle( (new CompareBaselineToTenantJob($secondRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$operationRuns, $operationRuns,
@ -799,6 +1025,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
@ -823,7 +1054,6 @@
); );
(new CompareBaselineToTenantJob($firstRun))->handle( (new CompareBaselineToTenantJob($firstRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$operationRuns, $operationRuns,
@ -854,7 +1084,6 @@
); );
(new CompareBaselineToTenantJob($secondRun))->handle( (new CompareBaselineToTenantJob($secondRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$operationRuns, $operationRuns,
@ -892,6 +1121,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([ BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(), 'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy', 'subject_type' => 'policy',
@ -916,6 +1150,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true], 'meta_jsonb' => ['different_content' => true],
'display_name' => 'Different Policy', 'display_name' => 'Different Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -924,6 +1160,8 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['unexpected' => true], 'meta_jsonb' => ['unexpected' => true],
'display_name' => 'Unexpected Policy', 'display_name' => 'Unexpected Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$operationRuns = app(OperationRunService::class); $operationRuns = app(OperationRunService::class);
@ -940,7 +1178,6 @@
); );
(new CompareBaselineToTenantJob($run))->handle( (new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$operationRuns, $operationRuns,
@ -956,3 +1193,88 @@
expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW); expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW);
expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM); expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM);
}); });
it('writes numeric-only summary_counts with whitelisted keys for capture and compare runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-a',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
]);
$operationRuns = app(OperationRunService::class);
$captureRun = $operationRuns->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($captureRun))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$operationRuns,
);
$captureRun->refresh();
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->update([
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
expect($snapshotId)->toBeGreaterThan(0);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$allowedKeys = OperationSummaryKeys::all();
foreach ([$captureRun->fresh(), $compareRun->fresh()] as $run) {
$counts = is_array($run?->summary_counts) ? $run->summary_counts : [];
foreach ($counts as $key => $value) {
expect($allowedKeys)->toContain((string) $key);
expect(is_array($value))->toBeFalse();
expect(is_numeric($value))->toBeTrue();
}
}
});

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$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()]);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => 'Policy'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Policy Changed',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->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,
);
assertNoOutboundHttp(function () use ($compareRun, $operationRuns): void {
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
});
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
$code = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($code)->toBeString();
expect($code)->toContain('->chunk(');
});

View File

@ -6,6 +6,7 @@
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService; use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -34,7 +35,7 @@
$profile = BaselineProfile::factory()->create([ $profile = BaselineProfile::factory()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'status' => BaselineProfile::STATUS_DRAFT, 'status' => BaselineProfileStatus::Draft->value,
]); ]);
BaselineTenantAssignment::create([ BaselineTenantAssignment::create([
@ -111,7 +112,7 @@
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$snapshot = BaselineSnapshot::factory()->create([ $snapshot = BaselineSnapshot::factory()->create([
@ -145,6 +146,48 @@
Queue::assertPushed(CompareBaselineToTenantJob::class); Queue::assertPushed(CompareBaselineToTenantJob::class);
}); });
it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$activeSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$overrideSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $activeSnapshot->getKey()]);
BaselineTenantAssignment::create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$service = app(BaselineCompareService::class);
$result = $service->startCompare($tenant, $user, baselineSnapshotId: (int) $overrideSnapshot->getKey());
expect($result['ok'])->toBeTrue();
/** @var OperationRun $run */
$run = $result['run'];
$context = is_array($run->context) ? $run->context : [];
expect($context['baseline_snapshot_id'])->toBe((int) $overrideSnapshot->getKey());
Queue::assertPushed(CompareBaselineToTenantJob::class);
});
// --- EC-004: Concurrent compare reuses active run --- // --- EC-004: Concurrent compare reuses active run ---
it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () { it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () {

View File

@ -11,7 +11,6 @@
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -46,6 +45,11 @@ function runBaselineCompareForSnapshot(
BaselineProfile $profile, BaselineProfile $profile,
BaselineSnapshot $snapshot, BaselineSnapshot $snapshot,
): OperationRun { ): OperationRun {
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$operationRuns = app(OperationRunService::class); $operationRuns = app(OperationRunService::class);
$run = $operationRuns->ensureRunWithIdentity( $run = $operationRuns->ensureRunWithIdentity(
@ -62,7 +66,6 @@ function runBaselineCompareForSnapshot(
$job = new CompareBaselineToTenantJob($run); $job = new CompareBaselineToTenantJob($run);
$job->handle( $job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class), app(BaselineSnapshotIdentity::class),
app(AuditLogger::class), app(AuditLogger::class),
$operationRuns, $operationRuns,

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
use App\Models\BaselineProfile;
use App\Models\Workspace;
use App\Support\Auth\UiTooltips;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Actions\Action;
use Livewire\Livewire;
it('requires confirmation for the archive table action', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $profile);
});
it('disables archive for workspace members missing workspace_baselines.manage', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertTableActionVisible('archive', $profile)
->assertTableActionDisabled('archive', $profile)
->assertTableActionExists('archive', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $profile)
->callTableAction('archive', $profile);
$profile->refresh();
expect($profile->status)->toBe(BaselineProfileStatus::Active);
});
it('archives baseline profiles for authorized workspace members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertTableActionVisible('archive', $profile)
->assertTableActionEnabled('archive', $profile)
->callTableAction('archive', $profile)
->assertHasNoTableActionErrors()
->assertTableActionHidden('archive', $profile->fresh());
$profile->refresh();
expect($profile->status)->toBe(BaselineProfileStatus::Archived);
});
it('does not show workspace-owned baseline profiles from other workspaces in the list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$otherWorkspace = Workspace::factory()->create();
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertCanNotSeeTableRecords([$profile]);
});

View File

@ -1,6 +1,13 @@
<?php <?php
use App\Filament\Pages\DriftLanding; 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 Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
@ -30,3 +37,68 @@
->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString()) ->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString())
->assertSet('currentFinishedAt', $current->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);
});

View File

@ -7,11 +7,65 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
it('redirects unauthenticated users (302)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant'))
->assertStatus(302);
});
it('returns 404 for authenticated users not entitled to the tenant', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner');
$nonMember = \App\Models\User::factory()->create();
$this->actingAs($nonMember)
->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});
it('does not start baseline compare for members missing tenant.sync', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$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(),
]);
Livewire::test(BaselineCompareLanding::class)
->assertActionVisible('compareNow')
->assertActionDisabled('compareNow')
->callAction('compareNow')
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
});
it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void { it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void {
Queue::fake(); Queue::fake();
@ -65,3 +119,112 @@
->call('refreshStats') ->call('refreshStats')
->assertStatus(200); ->assertStatus(200);
}); });
it('exposes full coverage + fidelity context in stats', 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,
]);
$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::Succeeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->call('refreshStats')
->assertSet('operationRunId', (int) $compareRun->getKey())
->assertSet('coverageStatus', 'ok')
->assertSet('uncoveredTypesCount', 0)
->assertSet('fidelity', 'meta');
});
it('exposes coverage warning context in stats', 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,
]);
$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(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => true,
],
'fidelity' => 'meta',
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->call('refreshStats')
->assertSet('operationRunId', (int) $compareRun->getKey())
->assertSet('coverageStatus', 'warning')
->assertSet('uncoveredTypesCount', 1)
->assertSet('uncoveredTypes', ['deviceCompliancePolicy'])
->assertSet('fidelity', 'meta');
});

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
->assertStatus(302);
});
it('returns 404 for authenticated users accessing a baseline profile from another workspace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$otherUser, $otherTenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $otherTenant->workspace_id);
$this->actingAs($otherUser)
->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
->assertNotFound();
});
it('does not start capture for workspace members missing workspace_baselines.manage', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionDisabled('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
});
it('starts capture successfully for authorized workspace members', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionEnabled('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertStatus(200);
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'baseline_capture')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\OperationRunResource; use App\Filament\Resources\OperationRunResource;
@ -10,6 +11,7 @@
use App\Filament\Resources\PolicyResource\Pages\ListPolicies; use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\BaselineProfile;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
@ -18,6 +20,7 @@
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition; use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator; use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope; use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -92,6 +95,54 @@
} }
}); });
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$declaration = BaselineProfileResource::actionSurfaceDeclaration();
$details = $declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details;
expect($details)->toBeString();
expect($details)->toContain('archive');
$this->actingAs($user);
$livewire = Livewire::test(ListBaselineProfiles::class)
->assertCanSeeTableRecords([$profile]);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($moreGroup)->toBeInstanceOf(ActionGroup::class);
expect($moreGroup?->getLabel())->toBe('More');
$primaryRowActionNames = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($primaryRowActionNames)->toContain('view');
expect($primaryRowActionNames)->not->toContain('archive');
$primaryRowActionCount = count($primaryRowActionNames);
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$moreActionNames = collect($moreGroup?->getActions())
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($moreActionNames)->toContain('archive');
});
it('ensures representative declarations satisfy required slots', function (): void { it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition; $profiles = new ActionSurfaceProfileDefinition;

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
it('keeps baseline capture/compare hashing on the InventoryMetaContract engine', function (): void {
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('hashItemContent');
expect($compareJob)->not->toContain('->fingerprint(');
expect($compareJob)->not->toContain('::fingerprint(');
$captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php'));
expect($captureJob)->toBeString();
expect($captureJob)->toContain('InventoryMetaContract');
expect($captureJob)->toContain('hashItemContent');
expect($captureJob)->not->toContain('->fingerprint(');
expect($captureJob)->not->toContain('::fingerprint(');
$identity = file_get_contents(base_path('app/Services/Baselines/BaselineSnapshotIdentity.php'));
expect($identity)->toBeString();
expect($identity)->toContain('InventoryMetaContract');
expect($identity)->toContain('hashNormalized');
expect($identity)->not->toContain('fingerprint(');
});

View File

@ -29,10 +29,14 @@
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class); $sync = app(InventorySyncService::class);
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? []; $policyTypes = array_slice($sync->defaultSelectionPayload()['policy_types'] ?? [], 0, 2);
Livewire::test(ListInventoryItems::class) Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]); ->callAction('run_inventory_sync', data: [
'policy_types' => $policyTypes,
'include_foundations' => false,
'include_dependencies' => false,
]);
$opRun = OperationRun::query() $opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
@ -49,10 +53,52 @@
expect(collect($notifications)->last()['actions'][0]['url'] ?? null) expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant)); ->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool { $capturedJob = null;
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use (&$capturedJob, $tenant, $user, $opRun): bool {
$capturedJob = $job;
return $job->tenantId === (int) $tenant->getKey() return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey() && $job->userId === (int) $user->getKey()
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
}); });
expect($capturedJob)->toBeInstanceOf(RunInventorySyncJob::class);
$mockSync = \Mockery::mock(InventorySyncService::class);
$mockSync
->shouldReceive('executeSelection')
->once()
->andReturnUsing(function (OperationRun $operationRun, $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed): array {
$policyTypes = $selectionPayload['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
foreach ($policyTypes as $policyType) {
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
}
return [
'status' => 'success',
'had_errors' => false,
'error_codes' => [],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'processed_policy_types' => $policyTypes,
'failed_policy_types' => [],
'skipped_policy_types' => [],
];
});
$capturedJob->handle($mockSync, app(\App\Services\Intune\AuditLogger::class), app(\App\Services\OperationRunService::class));
$opRun->refresh();
$context = is_array($opRun->context) ? $opRun->context : [];
$coverage = $context['inventory']['coverage']['policy_types'] ?? null;
expect($coverage)->toBeArray();
expect(array_keys($coverage))->toEqualCanonicalizing($policyTypes);
}); });

View File

@ -200,6 +200,41 @@ function createInventorySyncOperationRun(Tenant $tenant, array $attributes = [])
->create($attributes); ->create($attributes);
} }
/**
* Create a completed inventory sync run with coverage proof in context.
*
* @param array<string, string> $statusByType Example: ['deviceConfiguration' => 'succeeded']
* @param list<string> $foundationTypes
* @param array<string, mixed> $attributes
*/
function createInventorySyncOperationRunWithCoverage(
Tenant $tenant,
array $statusByType,
array $foundationTypes = [],
array $attributes = [],
): \App\Models\OperationRun {
$context = is_array($attributes['context'] ?? null) ? $attributes['context'] : [];
$inventory = is_array($context['inventory'] ?? null) ? $context['inventory'] : [];
$inventory['coverage'] = \App\Support\Inventory\InventoryCoverage::buildPayload(
statusByType: $statusByType,
foundationTypes: $foundationTypes,
);
$context['inventory'] = $inventory;
$attributes['context'] = $context;
if (! array_key_exists('finished_at', $attributes) && ! array_key_exists('completed_at', $attributes)) {
$attributes['finished_at'] = now();
}
$attributes['type'] ??= \App\Support\OperationRunType::InventorySync->value;
$attributes['status'] ??= \App\Support\OperationRunStatus::Completed->value;
$attributes['outcome'] ??= \App\Support\OperationRunOutcome::Succeeded->value;
return createInventorySyncOperationRun($tenant, $attributes);
}
/** /**
* @return array{0: User, 1: Tenant} * @return array{0: User, 1: Tenant}
*/ */

View File

@ -0,0 +1,49 @@
<?php
use App\Support\Baselines\BaselineScope;
it('expands empty policy_types to supported policy types (excluding foundations) and defaults foundation_types to none', function () {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter'],
]);
$scope = BaselineScope::fromJsonb([
'policy_types' => [],
'foundation_types' => [],
])->expandDefaults();
expect($scope->policyTypes)->toBe([
'deviceCompliancePolicy',
'deviceConfiguration',
]);
expect($scope->foundationTypes)->toBe([]);
expect($scope->allTypes())->toBe([
'deviceCompliancePolicy',
'deviceConfiguration',
]);
});
it('filters unknown types and does not allow foundations inside policy_types', function () {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter'],
]);
$scope = BaselineScope::fromJsonb([
'policy_types' => ['deviceConfiguration', 'assignmentFilter', 'unknown'],
'foundation_types' => ['assignmentFilter', 'unknown'],
])->expandDefaults();
expect($scope->policyTypes)->toBe(['deviceConfiguration']);
expect($scope->foundationTypes)->toBe(['assignmentFilter']);
expect($scope->allTypes())->toBe(['assignmentFilter', 'deviceConfiguration']);
});

View File

@ -0,0 +1,59 @@
<?php
use App\Services\Baselines\InventoryMetaContract;
it('builds a deterministic v1 contract regardless of input ordering', function () {
$builder = app(InventoryMetaContract::class);
$a = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-a',
metaJsonb: [
'etag' => 'E1',
'odata_type' => '#microsoft.graph.deviceConfiguration',
'scope_tag_ids' => ['2', '1'],
'assignment_target_count' => 3,
],
);
$b = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-a',
metaJsonb: [
'assignment_target_count' => 3,
'scope_tag_ids' => ['1', '2'],
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E1',
],
);
expect($a)->toBe($b);
});
it('represents missing signals as null (not omitted)', function () {
$builder = app(InventoryMetaContract::class);
$contract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-a',
metaJsonb: [],
);
expect($contract)->toHaveKeys([
'version',
'policy_type',
'subject_external_id',
'odata_type',
'etag',
'scope_tag_ids',
'assignment_target_count',
]);
expect($contract['version'])->toBe(1);
expect($contract['policy_type'])->toBe('deviceConfiguration');
expect($contract['subject_external_id'])->toBe('policy-a');
expect($contract['odata_type'])->toBeNull();
expect($contract['etag'])->toBeNull();
expect($contract['scope_tag_ids'])->toBeNull();
expect($contract['assignment_target_count'])->toBeNull();
});