Spec 116: Baseline drift engine v1 (meta fidelity + coverage guard) #141
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -39,6 +39,7 @@ ## Active Technologies
|
||||
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
|
||||
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
|
||||
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -58,8 +59,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## 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
|
||||
- 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
|
||||
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
@ -61,6 +62,15 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
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
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -100,6 +110,11 @@ public function refreshStats(): void
|
||||
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||
$this->lastComparedIso = $stats->lastComparedIso;
|
||||
$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
|
||||
@ -125,13 +140,12 @@ protected function getHeaderActions(): array
|
||||
|
||||
private function compareNowAction(): Action
|
||||
{
|
||||
return Action::make('compareNow')
|
||||
$action = Action::make('compareNow')
|
||||
->label('Compare Now')
|
||||
->icon('heroicon-o-play')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Start baseline comparison')
|
||||
->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))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
@ -181,25 +195,11 @@ private function compareNowAction(): Action
|
||||
] : [])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function canCompare(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
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);
|
||||
return UiEnforcement::forAction($action)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): ?string
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -47,6 +48,17 @@ class DriftLanding extends Page
|
||||
|
||||
public ?string $currentFinishedAt = null;
|
||||
|
||||
public ?int $baselineCompareRunId = null;
|
||||
|
||||
public ?string $baselineCompareCoverageStatus = null;
|
||||
|
||||
public ?int $baselineCompareUncoveredTypesCount = null;
|
||||
|
||||
/** @var list<string>|null */
|
||||
public ?array $baselineCompareUncoveredTypes = null;
|
||||
|
||||
public ?string $baselineCompareFidelity = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
@ -66,6 +78,16 @@ public function mount(): void
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$baselineCompareStats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
if ($baselineCompareStats->operationRunId !== null) {
|
||||
$this->baselineCompareRunId = (int) $baselineCompareStats->operationRunId;
|
||||
$this->baselineCompareCoverageStatus = $baselineCompareStats->coverageStatus;
|
||||
$this->baselineCompareUncoveredTypesCount = $baselineCompareStats->uncoveredTypesCount;
|
||||
$this->baselineCompareUncoveredTypes = $baselineCompareStats->uncoveredTypes !== [] ? $baselineCompareStats->uncoveredTypes : null;
|
||||
$this->baselineCompareFidelity = $baselineCompareStats->fidelity;
|
||||
}
|
||||
|
||||
$latestSuccessful = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
@ -292,4 +314,13 @@ public function getOperationRunUrl(): ?string
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -25,6 +26,7 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -38,6 +40,8 @@
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineProfileResource extends Resource
|
||||
@ -147,39 +151,53 @@ public static function form(Schema $schema): Schema
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->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.'),
|
||||
Textarea::make('description')
|
||||
->rows(3)
|
||||
->maxLength(1000)
|
||||
->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')
|
||||
->label('Version label')
|
||||
->maxLength(50)
|
||||
->placeholder('e.g. v2.1 — February rollout')
|
||||
->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')
|
||||
->label('Policy type scope')
|
||||
->label('Policy types')
|
||||
->multiple()
|
||||
->options(self::policyTypeOptions())
|
||||
->helperText('Leave empty to include all policy types.')
|
||||
->helperText('Leave empty to include all supported policy types (excluding foundations).')
|
||||
->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')
|
||||
->schema([
|
||||
TextEntry::make('scope_jsonb.policy_types')
|
||||
->label('Policy type scope')
|
||||
->label('Policy types')
|
||||
->badge()
|
||||
->formatStateUsing(function (string $state): string {
|
||||
$options = self::policyTypeOptions();
|
||||
|
||||
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(),
|
||||
Section::make('Metadata')
|
||||
@ -314,7 +341,21 @@ public static function getPages(): 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))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(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
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
@ -381,13 +440,13 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Archive baseline profile')
|
||||
->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 {
|
||||
if (! self::hasManageCapability()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save();
|
||||
$record->forceFill(['status' => BaselineProfileStatus::Archived->value])->save();
|
||||
|
||||
self::audit($record, AuditActionId::BaselineProfileArchived, [
|
||||
'baseline_profile_id' => (int) $record->getKey(),
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
@ -28,8 +29,14 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
$user = auth()->user();
|
||||
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
||||
|
||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
|
||||
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;
|
||||
}
|
||||
@ -45,7 +52,9 @@ protected function afterCreate(): void
|
||||
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
|
||||
'baseline_profile_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'status' => (string) $record->status,
|
||||
'status' => $record->status instanceof BaselineProfileStatus
|
||||
? $record->status->value
|
||||
: (string) $record->status,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@ -14,14 +15,49 @@ class EditBaselineProfile extends EditRecord
|
||||
{
|
||||
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
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
|
||||
$record = $this->getRecord();
|
||||
$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;
|
||||
}
|
||||
@ -37,7 +73,9 @@ protected function afterSave(): void
|
||||
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
|
||||
'baseline_profile_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'status' => (string) $record->status,
|
||||
'status' => $record->status instanceof BaselineProfileStatus
|
||||
? $record->status->value
|
||||
: (string) $record->status,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
@ -45,4 +83,9 @@ protected function afterSave(): void
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\EditAction;
|
||||
@ -36,13 +37,10 @@ protected function getHeaderActions(): array
|
||||
|
||||
private function captureAction(): Action
|
||||
{
|
||||
return Action::make('capture')
|
||||
$action = Action::make('capture')
|
||||
->label('Capture Snapshot')
|
||||
->icon('heroicon-o-camera')
|
||||
->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()
|
||||
->modalHeading('Capture 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 {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $this->hasManageCapability()) {
|
||||
Notification::make()
|
||||
->title('Permission denied')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
@ -123,6 +116,12 @@ private function captureAction(): Action
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
|
||||
return WorkspaceUiEnforcement::forTableAction($action, fn (): ?Workspace => Workspace::query()
|
||||
->whereKey((int) $this->getRecord()->workspace_id)
|
||||
->first())
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->apply();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -55,7 +55,8 @@ public function table(Table $table): Table
|
||||
->sortable(),
|
||||
])
|
||||
->headerActions([
|
||||
$this->assignTenantAction(),
|
||||
$this->assignTenantAction()
|
||||
->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()),
|
||||
])
|
||||
->actions([
|
||||
$this->removeAssignmentAction(),
|
||||
@ -63,7 +64,7 @@ public function table(Table $table): Table
|
||||
->emptyStateHeading('No tenants assigned')
|
||||
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
|
||||
->emptyStateActions([
|
||||
$this->assignTenantAction(),
|
||||
$this->assignTenantAction()->name('assignEmpty'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
@ -27,6 +28,13 @@ class ListFindings extends ListRecords
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
BaselineCompareCoverageBanner::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
@ -184,6 +184,81 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||
->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')
|
||||
->schema([
|
||||
ViewEntry::make('verification_report')
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,10 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -45,6 +47,7 @@ public function middleware(): array
|
||||
|
||||
public function handle(
|
||||
BaselineSnapshotIdentity $identity,
|
||||
InventoryMetaContract $metaContract,
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
): void {
|
||||
@ -78,7 +81,7 @@ public function handle(
|
||||
|
||||
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
|
||||
|
||||
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity);
|
||||
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity, $metaContract);
|
||||
|
||||
$identityHash = $identity->computeIdentity($snapshotItems);
|
||||
|
||||
@ -90,7 +93,7 @@ public function handle(
|
||||
|
||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||
|
||||
if ($profile->status === BaselineProfile::STATUS_ACTIVE) {
|
||||
if ($profile->status === BaselineProfileStatus::Active) {
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
}
|
||||
|
||||
@ -127,22 +130,31 @@ private function collectSnapshotItems(
|
||||
Tenant $sourceTenant,
|
||||
BaselineScope $scope,
|
||||
BaselineSnapshotIdentity $identity,
|
||||
InventoryMetaContract $metaContract,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
|
||||
if (! $scope->isEmpty()) {
|
||||
$query->whereIn('policy_type', $scope->policyTypes);
|
||||
}
|
||||
$query->whereIn('policy_type', $scope->allTypes());
|
||||
|
||||
$items = [];
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$items, $identity): void {
|
||||
->chunk(500, function ($inventoryItems) use (&$items, $identity, $metaContract): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$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[] = [
|
||||
'subject_type' => 'policy',
|
||||
@ -153,6 +165,13 @@ private function collectSnapshotItems(
|
||||
'display_name' => $inventoryItem->display_name,
|
||||
'category' => $inventoryItem->category,
|
||||
'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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@ -15,11 +15,11 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -52,7 +52,6 @@ public function middleware(): array
|
||||
}
|
||||
|
||||
public function handle(
|
||||
DriftHasher $driftHasher,
|
||||
BaselineSnapshotIdentity $snapshotIdentity,
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
@ -95,13 +94,75 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$effectiveTypes = $effectiveScope->allTypes();
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
||||
|
||||
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope);
|
||||
$latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant);
|
||||
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId);
|
||||
if ($effectiveTypes === []) {
|
||||
$this->completeWithCoverageWarning(
|
||||
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(
|
||||
$baselineItems,
|
||||
@ -110,20 +171,22 @@ public function handle(
|
||||
);
|
||||
|
||||
$upsertResult = $this->upsertFindings(
|
||||
$driftHasher,
|
||||
$tenant,
|
||||
$profile,
|
||||
$snapshotId,
|
||||
$scopeKey,
|
||||
$driftResults,
|
||||
);
|
||||
|
||||
$severityBreakdown = $this->countBySeverity($driftResults);
|
||||
$countsByChangeType = $this->countByChangeType($driftResults);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
'succeeded' => (int) $upsertResult['processed_count'],
|
||||
'failed' => 0,
|
||||
'errors_recorded' => count($uncoveredTypes),
|
||||
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
||||
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
||||
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
|
||||
@ -135,7 +198,7 @@ public function handle(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
|
||||
@ -160,6 +223,25 @@ public function handle(
|
||||
}
|
||||
|
||||
$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'] = [
|
||||
'findings_total' => count($driftResults),
|
||||
'findings_upserted' => (int) $upsertResult['processed_count'],
|
||||
@ -171,21 +253,89 @@ public function handle(
|
||||
$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".
|
||||
*
|
||||
* @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 = [];
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
$query = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', $snapshotId);
|
||||
|
||||
if (! $scope->isEmpty()) {
|
||||
$query->whereIn('policy_type', $scope->policyTypes);
|
||||
}
|
||||
$query->whereIn('policy_type', $policyTypes);
|
||||
|
||||
$query
|
||||
->orderBy('id')
|
||||
@ -212,7 +362,7 @@ private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
|
||||
*/
|
||||
private function loadCurrentInventory(
|
||||
Tenant $tenant,
|
||||
BaselineScope $scope,
|
||||
array $policyTypes,
|
||||
BaselineSnapshotIdentity $snapshotIdentity,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
): array {
|
||||
@ -223,10 +373,12 @@ private function loadCurrentInventory(
|
||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||
}
|
||||
|
||||
if (! $scope->isEmpty()) {
|
||||
$query->whereIn('policy_type', $scope->policyTypes);
|
||||
if ($policyTypes === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query->whereIn('policy_type', $policyTypes);
|
||||
|
||||
$items = [];
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
@ -234,7 +386,11 @@ private function loadCurrentInventory(
|
||||
->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$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;
|
||||
$items[$key] = [
|
||||
@ -253,7 +409,7 @@ private function loadCurrentInventory(
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
|
||||
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -261,11 +417,9 @@ private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first(['id']);
|
||||
->first();
|
||||
|
||||
$runId = $run?->getKey();
|
||||
|
||||
return is_numeric($runId) ? (int) $runId : null;
|
||||
return $run instanceof OperationRun ? $run : 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>}
|
||||
*/
|
||||
private function upsertFindings(
|
||||
DriftHasher $driftHasher,
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
int $baselineSnapshotId,
|
||||
string $scopeKey,
|
||||
array $driftResults,
|
||||
): array {
|
||||
@ -366,16 +520,16 @@ private function upsertFindings(
|
||||
$seenFingerprints = [];
|
||||
|
||||
foreach ($driftResults as $driftItem) {
|
||||
$fingerprint = $driftHasher->fingerprint(
|
||||
$recurrenceKey = $this->recurrenceKey(
|
||||
tenantId: $tenantId,
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: $driftItem['subject_type'],
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
policyType: $driftItem['policy_type'],
|
||||
subjectExternalId: $driftItem['subject_external_id'],
|
||||
changeType: $driftItem['change_type'],
|
||||
baselineHash: $driftItem['baseline_hash'],
|
||||
currentHash: $driftItem['current_hash'],
|
||||
);
|
||||
|
||||
$fingerprint = $recurrenceKey;
|
||||
|
||||
$seenFingerprints[] = $fingerprint;
|
||||
|
||||
$finding = Finding::query()
|
||||
@ -404,6 +558,7 @@ private function upsertFindings(
|
||||
'subject_external_id' => $driftItem['subject_external_id'],
|
||||
'severity' => $driftItem['severity'],
|
||||
'fingerprint' => $fingerprint,
|
||||
'recurrence_key' => $recurrenceKey,
|
||||
'evidence_jsonb' => $driftItem['evidence'],
|
||||
'baseline_operation_run_id' => null,
|
||||
'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
|
||||
* @return array<string, int>
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
@ -70,8 +72,20 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$policyTypes = $context['policy_types'] ?? [];
|
||||
$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 = [];
|
||||
$coverageStatusByType = [];
|
||||
$successCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
@ -84,8 +98,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
||||
$this->operationRun,
|
||||
$tenant,
|
||||
$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;
|
||||
$coverageStatusByType[$policyType] = $success
|
||||
? InventoryCoverage::StatusSucceeded
|
||||
: InventoryCoverage::StatusFailed;
|
||||
|
||||
if ($success) {
|
||||
$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['inventory'] = array_merge(
|
||||
is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [],
|
||||
[
|
||||
'coverage' => InventoryCoverage::buildPayload($statusByType, $foundationTypes),
|
||||
],
|
||||
);
|
||||
$updatedContext['result'] = [
|
||||
'had_errors' => (bool) ($result['had_errors'] ?? true),
|
||||
'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [],
|
||||
|
||||
@ -1,28 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use JsonException;
|
||||
|
||||
class BaselineProfile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @deprecated Use BaselineProfileStatus::Draft instead.
|
||||
*/
|
||||
public const string STATUS_DRAFT = 'draft';
|
||||
|
||||
/**
|
||||
* @deprecated Use BaselineProfileStatus::Active instead.
|
||||
*/
|
||||
public const string STATUS_ACTIVE = 'active';
|
||||
|
||||
/**
|
||||
* @deprecated Use BaselineProfileStatus::Archived instead.
|
||||
*/
|
||||
public const string STATUS_ARCHIVED = 'archived';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'scope_jsonb' => 'array',
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'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
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\OperationRunType;
|
||||
@ -41,7 +42,7 @@ public function startCapture(
|
||||
$context = [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toJsonb(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
@ -63,7 +64,7 @@ public function startCapture(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -6,11 +6,13 @@
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\OperationRunType;
|
||||
@ -27,6 +29,7 @@ public function __construct(
|
||||
public function startCompare(
|
||||
Tenant $tenant,
|
||||
User $initiator,
|
||||
?int $baselineSnapshotId = null,
|
||||
): array {
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', $tenant->workspace_id)
|
||||
@ -43,13 +46,28 @@ public function startCompare(
|
||||
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) {
|
||||
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(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
@ -63,7 +81,7 @@ public function startCompare(
|
||||
$context = [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toJsonb(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
@ -83,13 +101,13 @@ public function startCompare(
|
||||
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;
|
||||
}
|
||||
|
||||
if ($profile->active_snapshot_id === null) {
|
||||
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ final class BaselineSnapshotIdentity
|
||||
{
|
||||
public function __construct(
|
||||
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.
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
69
app/Services/Baselines/InventoryMetaContract.php
Normal file
69
app/Services/Baselines/InventoryMetaContract.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -62,16 +63,41 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
'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');
|
||||
$hadErrors = (bool) ($result['had_errors'] ?? true);
|
||||
$errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [];
|
||||
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
|
||||
|
||||
$policyTypes = $normalizedSelection['policy_types'] ?? [];
|
||||
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
|
||||
|
||||
$operationOutcome = match ($status) {
|
||||
'success' => OperationRunOutcome::Succeeded->value,
|
||||
'partial' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
@ -95,6 +121,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
}
|
||||
|
||||
$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'] = [
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => $errorCodes,
|
||||
|
||||
@ -4,22 +4,22 @@
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
|
||||
final class BaselineProfileStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$enum = BaselineProfileStatus::tryFrom($state);
|
||||
|
||||
return match ($state) {
|
||||
BaselineProfile::STATUS_DRAFT => new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'),
|
||||
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(),
|
||||
};
|
||||
if ($enum === null) {
|
||||
return BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
return new BadgeSpec($enum->label(), $enum->color(), $enum->icon());
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ final class BaselineCompareStats
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $severityCounts
|
||||
* @param list<string> $uncoveredTypes
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $state,
|
||||
@ -27,6 +28,10 @@ private function __construct(
|
||||
public readonly ?string $lastComparedHuman,
|
||||
public readonly ?string $lastComparedIso,
|
||||
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
|
||||
@ -74,6 +79,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
return new self(
|
||||
@ -88,6 +95,10 @@ public static function forTenant(?Tenant $tenant): self
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: 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(),
|
||||
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
|
||||
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,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
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(
|
||||
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,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
@ -170,6 +191,10 @@ public static function forTenant(?Tenant $tenant): self
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
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,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
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(
|
||||
string $state,
|
||||
?string $message,
|
||||
|
||||
79
app/Support/Baselines/BaselineProfileStatus.php
Normal file
79
app/Support/Baselines/BaselineProfileStatus.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -21,4 +21,6 @@ final class BaselineReasonCodes
|
||||
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_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
||||
}
|
||||
|
||||
@ -8,15 +8,20 @@
|
||||
* Value object for baseline scope resolution.
|
||||
*
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* @param array<string> $policyTypes
|
||||
* @param array<string> $foundationTypes
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $policyTypes = [],
|
||||
public readonly array $foundationTypes = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -31,9 +36,11 @@ public static function fromJsonb(?array $scopeJsonb): self
|
||||
}
|
||||
|
||||
$policyTypes = $scopeJsonb['policy_types'] ?? [];
|
||||
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
|
||||
|
||||
return new self(
|
||||
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.
|
||||
*
|
||||
* Override can only narrow the profile scope (subset enforcement).
|
||||
* If the profile scope is empty (all types), the override becomes the effective scope.
|
||||
* If the override is empty or null, the profile scope is used as-is.
|
||||
* Empty override means "no override".
|
||||
*/
|
||||
public static function effective(self $profileScope, ?self $overrideScope): self
|
||||
{
|
||||
$profileScope = $profileScope->expandDefaults();
|
||||
|
||||
if ($overrideScope === null || $overrideScope->isEmpty()) {
|
||||
return $profileScope;
|
||||
}
|
||||
|
||||
if ($profileScope->isEmpty()) {
|
||||
return $overrideScope;
|
||||
}
|
||||
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
|
||||
$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
|
||||
{
|
||||
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()) {
|
||||
return true;
|
||||
}
|
||||
$policyTypes = $this->policyTypes === []
|
||||
? 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()) {
|
||||
return true;
|
||||
}
|
||||
$expanded = $this->expandDefaults();
|
||||
|
||||
if ($profileScope->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($overrideScope->policyTypes as $type) {
|
||||
if (! in_array($type, $profileScope->policyTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return self::uniqueSorted(array_merge(
|
||||
$expanded->policyTypes,
|
||||
$expanded->foundationTypes,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,6 +120,102 @@ public function toJsonb(): array
|
||||
{
|
||||
return [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
172
app/Support/Inventory/InventoryCoverage.php
Normal file
172
app/Support/Inventory/InventoryCoverage.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -50,6 +50,8 @@ final class UiEnforcement
|
||||
|
||||
private bool $preserveExistingVisibility = false;
|
||||
|
||||
private bool $preserveExistingDisabled = false;
|
||||
|
||||
private function __construct(Action|BulkAction $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
@ -167,6 +169,21 @@ public function preserveVisibility(): self
|
||||
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.
|
||||
*
|
||||
@ -316,9 +333,17 @@ private function applyDisabledState(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$existingDisabled = $this->preserveExistingDisabled
|
||||
? $this->getExistingDisabledCondition()
|
||||
: null;
|
||||
|
||||
$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) {
|
||||
$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.
|
||||
*/
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -24,8 +25,8 @@ public function definition(): array
|
||||
'name' => fake()->unique()->words(3, true),
|
||||
'description' => fake()->optional()->sentence(),
|
||||
'version_label' => fake()->optional()->numerify('v#.#'),
|
||||
'status' => BaselineProfile::STATUS_DRAFT,
|
||||
'scope_jsonb' => ['policy_types' => []],
|
||||
'status' => BaselineProfileStatus::Draft->value,
|
||||
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
|
||||
'active_snapshot_id' => null,
|
||||
'created_by_user_id' => null,
|
||||
];
|
||||
@ -34,21 +35,21 @@ public function definition(): array
|
||||
public function active(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => BaselineProfile::STATUS_ACTIVE,
|
||||
'status' => BaselineProfileStatus::Active->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function archived(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => BaselineProfile::STATUS_ARCHIVED,
|
||||
'status' => BaselineProfileStatus::Archived->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withScope(array $policyTypes): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'scope_jsonb' => ['policy_types' => $policyTypes],
|
||||
'scope_jsonb' => ['policy_types' => $policyTypes, 'foundation_types' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
664
docs/research/golden-master-baseline-drift-deep-analysis.md
Normal file
664
docs/research/golden-master-baseline-drift-deep-analysis.md
Normal 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'
|
||||
```
|
||||
@ -4,6 +4,10 @@
|
||||
<div wire:poll.5s="refreshStats"></div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
|
||||
@endphp
|
||||
|
||||
{{-- Row 1: Stats Overview --}}
|
||||
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
||||
<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="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>
|
||||
@if ($snapshotId)
|
||||
<x-filament::badge color="success" size="sm" class="w-fit">
|
||||
Snapshot #{{ $snapshotId }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($snapshotId)
|
||||
<x-filament::badge color="success" size="sm" class="w-fit">
|
||||
Snapshot #{{ $snapshotId }}
|
||||
</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>
|
||||
</x-filament::section>
|
||||
|
||||
@ -27,7 +49,7 @@
|
||||
@if ($state === 'failed')
|
||||
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
|
||||
@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 }}
|
||||
</div>
|
||||
@endif
|
||||
@ -36,8 +58,10 @@
|
||||
<x-filament::loading-indicator class="h-3 w-3" />
|
||||
Comparing…
|
||||
</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>
|
||||
@elseif ($state === 'ready' && $hasCoverageWarnings)
|
||||
<span class="text-sm text-warning-600 dark:text-warning-400">Coverage warnings</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@ -59,6 +83,47 @@
|
||||
</div>
|
||||
@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 --}}
|
||||
@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">
|
||||
@ -189,7 +254,7 @@
|
||||
@endif
|
||||
|
||||
{{-- Ready: no drift --}}
|
||||
@if ($state === 'ready' && ($findingsCount ?? 0) === 0)
|
||||
@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-check-circle class="h-12 w-12 text-success-500" />
|
||||
@ -213,6 +278,31 @@
|
||||
</x-filament::section>
|
||||
@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 --}}
|
||||
@if ($state === 'idle')
|
||||
<x-filament::section>
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
<x-filament::page>
|
||||
@php
|
||||
$baselineCompareHasWarnings = in_array(($baselineCompareCoverageStatus ?? null), ['warning', 'unproven'], true);
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
@ -35,6 +39,51 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($baselineCompareRunId)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Baseline compare
|
||||
@if ($this->getBaselineCompareRunUrl())
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBaselineCompareRunUrl() }}">
|
||||
#{{ $baselineCompareRunId }}
|
||||
</a>
|
||||
@else
|
||||
#{{ $baselineCompareRunId }}
|
||||
@endif
|
||||
|
||||
@if (filled($baselineCompareCoverageStatus))
|
||||
· Coverage
|
||||
<x-filament::badge :color="$baselineCompareCoverageStatus === 'ok' ? 'success' : 'warning'" size="sm">
|
||||
{{ $baselineCompareCoverageStatus === 'ok' ? 'OK' : 'Warnings' }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if (filled($baselineCompareFidelity))
|
||||
· Fidelity {{ Str::title($baselineCompareFidelity) }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($baselineCompareRunId && $baselineCompareHasWarnings)
|
||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-semibold">Baseline compare coverage warnings</div>
|
||||
<div class="text-sm">
|
||||
@if (($baselineCompareCoverageStatus ?? null) === 'unproven')
|
||||
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
||||
@else
|
||||
Some policy types were uncovered in the last baseline comparison, so findings may be incomplete.
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! empty($baselineCompareUncoveredTypes))
|
||||
<div class="mt-1 text-xs">
|
||||
Uncovered: {{ implode(', ', array_slice($baselineCompareUncoveredTypes, 0, 6)) }}@if (count($baselineCompareUncoveredTypes) > 6)…@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($state === 'blocked')
|
||||
<x-filament::badge color="gray">
|
||||
Blocked
|
||||
|
||||
@ -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>
|
||||
|
||||
35
specs/116-baseline-drift-engine/checklists/requirements.md
Normal file
35
specs/116-baseline-drift-engine/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Baseline Drift Engine (Final Architecture)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-01
|
||||
**Feature**: [specs/116-baseline-drift-engine/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
- This spec uses internal domain terms like “Operation run”, “capability”, and “hash fidelity” intentionally; they are defined in-context and treated as product concepts rather than framework implementation.
|
||||
157
specs/116-baseline-drift-engine/contracts/openapi.yaml
Normal file
157
specs/116-baseline-drift-engine/contracts/openapi.yaml
Normal file
@ -0,0 +1,157 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Spec 116 - Baseline Drift Engine
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Minimal contracts for Baseline capture/compare operations and finding summaries.
|
||||
This repo is primarily Filament-driven; these endpoints represent conceptual contracts
|
||||
or internal routes/services rather than guaranteed public APIs.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
|
||||
paths:
|
||||
/internal/baselines/{baselineProfileId}/snapshots:
|
||||
post:
|
||||
summary: Capture a baseline snapshot
|
||||
parameters:
|
||||
- in: path
|
||||
name: baselineProfileId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: false
|
||||
responses:
|
||||
'202':
|
||||
description: Snapshot capture queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationQueued'
|
||||
|
||||
/internal/baselines/{baselineProfileId}/compare:
|
||||
post:
|
||||
summary: Compare baseline snapshot to tenant inventory
|
||||
parameters:
|
||||
- in: path
|
||||
name: baselineProfileId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineCompareRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Compare queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationQueued'
|
||||
|
||||
/internal/tenants/{tenantId}/findings:
|
||||
get:
|
||||
summary: List findings for a tenant (filtered)
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenantId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: scope_key
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: status
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [open, resolved]
|
||||
responses:
|
||||
'200':
|
||||
description: Findings list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
BaselineCompareRequest:
|
||||
type: object
|
||||
required: [tenant_id]
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
baseline_snapshot_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Optional explicit snapshot selection. If omitted, latest successful snapshot is used.
|
||||
|
||||
OperationQueued:
|
||||
type: object
|
||||
required: [operation_run_id]
|
||||
properties:
|
||||
operation_run_id:
|
||||
type: integer
|
||||
|
||||
Finding:
|
||||
type: object
|
||||
required: [id, tenant_id, fingerprint, scope_key, created_at]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
fingerprint:
|
||||
type: string
|
||||
description: Stable identifier; for baseline drift equals recurrence_key.
|
||||
recurrence_key:
|
||||
type: string
|
||||
nullable: true
|
||||
scope_key:
|
||||
type: string
|
||||
change_type:
|
||||
type: string
|
||||
nullable: true
|
||||
policy_type:
|
||||
type: string
|
||||
nullable: true
|
||||
subject_external_id:
|
||||
type: string
|
||||
nullable: true
|
||||
evidence:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
first_seen_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_seen_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
times_seen:
|
||||
type: integer
|
||||
nullable: true
|
||||
resolved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
123
specs/116-baseline-drift-engine/data-model.md
Normal file
123
specs/116-baseline-drift-engine/data-model.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Phase 1 — Data Model (Baseline Drift Engine)
|
||||
|
||||
This document identifies the data/entities involved in Spec 116 and the minimal schema/config changes needed to implement it in this repository.
|
||||
|
||||
## Existing Entities (Confirmed)
|
||||
|
||||
### BaselineProfile
|
||||
Represents a baseline definition.
|
||||
|
||||
- Fields (confirmed in migrations): `id`, `workspace_id`, `name`, `description`, `version_label`, `status`, `scope_jsonb` (jsonb), `active_snapshot_id`, `created_by_user_id`, timestamps
|
||||
- Relationships: has many snapshots; assigned to tenants via `BaselineTenantAssignment`
|
||||
|
||||
### BaselineSnapshot
|
||||
Immutable capture of baseline state at a point in time.
|
||||
|
||||
- Fields (confirmed in migrations): `id`, `workspace_id`, `baseline_profile_id`, `snapshot_identity_hash`, `captured_at`, `summary_jsonb` (jsonb), timestamps
|
||||
- Relationships: has many items; belongs to baseline profile
|
||||
|
||||
### BaselineSnapshotItem
|
||||
One item in a baseline snapshot.
|
||||
|
||||
- Fields (confirmed in migrations):
|
||||
- `id`, `baseline_snapshot_id`
|
||||
- `subject_type`
|
||||
- `subject_external_id`
|
||||
- `policy_type`
|
||||
- `baseline_hash` (string)
|
||||
- `meta_jsonb` (jsonb)
|
||||
- timestamps
|
||||
|
||||
### Finding
|
||||
Generic drift finding storage.
|
||||
|
||||
- Fields (confirmed by usage): `tenant_id`, `fingerprint` (unique with tenant), `recurrence_key` (nullable), `scope_key`, lifecycle fields (`first_seen_at`, `last_seen_at`, `times_seen`), evidence (jsonb)
|
||||
|
||||
### OperationRun
|
||||
Tracks long-running operations.
|
||||
|
||||
- Fields (by convention): `type`, `status/outcome`, `summary_counts` (numeric map), `context` (jsonb)
|
||||
|
||||
## New / Adjusted Data Requirements
|
||||
|
||||
### 1) Inventory sync coverage context
|
||||
|
||||
**Goal:** Baseline compare must know which policy types were actually processed successfully by inventory sync.
|
||||
|
||||
**Where:** `operation_runs.context` for the latest inventory sync run.
|
||||
|
||||
**Shape (proposed):**
|
||||
|
||||
```json
|
||||
{
|
||||
"inventory": {
|
||||
"coverage": {
|
||||
"policy_types": {
|
||||
"deviceConfigurations": {"status": "succeeded", "item_count": 123},
|
||||
"compliancePolicies": {"status": "failed", "error": "..."}
|
||||
},
|
||||
"foundation_types": {
|
||||
"securityBaselines": {"status": "succeeded", "item_count": 4}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Only `summary_counts` must remain numeric; detailed coverage lists live in `context`.
|
||||
- For Spec 116 v1, it’s sufficient to store `policy_types` coverage; adding `foundation_types` coverage at the same time keeps parity with scope rules.
|
||||
|
||||
### 2) Baseline scope schema
|
||||
|
||||
**Goal:** Support both policy and foundation scope with correct defaults.
|
||||
|
||||
**Current:** `policy_types` only.
|
||||
|
||||
**Target:**
|
||||
|
||||
```json
|
||||
{
|
||||
"policy_types": ["deviceConfigurations", "compliancePolicies"],
|
||||
"foundation_types": ["securityBaselines"]
|
||||
}
|
||||
```
|
||||
|
||||
**Default semantics:**
|
||||
- Empty `policy_types` means “all supported policy types excluding foundations”.
|
||||
- Empty `foundation_types` means “none”.
|
||||
|
||||
### 3) Findings recurrence strategy
|
||||
|
||||
**Goal:** Stable identity per snapshot and per subject.
|
||||
|
||||
- `findings.recurrence_key`: populated for baseline compare findings.
|
||||
- `findings.fingerprint`: set to the same recurrence key (to satisfy existing uniqueness constraint).
|
||||
|
||||
**Recurrence key inputs:**
|
||||
- `tenant_id`
|
||||
- `baseline_snapshot_id`
|
||||
- `policy_type`
|
||||
- `subject_external_id`
|
||||
- `change_type`
|
||||
|
||||
**Grouping (scope_key):**
|
||||
- Keep `findings.scope_key = baseline_profile:{baselineProfileId}` for baseline compare findings.
|
||||
|
||||
### 4) Inventory meta contract
|
||||
|
||||
**Goal:** Explicitly define what is hashed for v1 comparisons.
|
||||
|
||||
- Implemented as a dedicated builder class (no schema change required).
|
||||
- Used by baseline capture to compute `baseline_hash` and by compare to compute `current_hash`.
|
||||
- Persist the exact contract payload used for hashing to `baseline_snapshot_items.meta_jsonb.meta_contract` (versioned) for auditability/reproducibility.
|
||||
|
||||
## Potential Migrations (Likely)
|
||||
|
||||
- If `baseline_profiles.scope` is not jsonb or does not include foundation types → migration to adjust structure (jsonb stays the same, but add support in code; DB change may be optional).
|
||||
- If coverage context needs persistence beyond operation run context → avoid adding tables unless proven necessary; context-based is sufficient for v1.
|
||||
|
||||
## Index / Performance Notes
|
||||
|
||||
- Findings queries commonly filter by `tenant_id` + `scope_key`; ensure there is an index on `(tenant_id, scope_key)`.
|
||||
- Baseline snapshot items must be efficiently loaded by `(baseline_snapshot_id, policy_type)`.
|
||||
258
specs/116-baseline-drift-engine/plan.md
Normal file
258
specs/116-baseline-drift-engine/plan.md
Normal file
@ -0,0 +1,258 @@
|
||||
# Implementation Plan: 116 — Baseline Drift Engine (Final Architecture)
|
||||
|
||||
**Branch**: `116-baseline-drift-engine` | **Date**: 2026-03-01 | **Spec**: `specs/116-baseline-drift-engine/spec.md`
|
||||
**Input**: Feature specification from `specs/116-baseline-drift-engine/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Align the existing baseline capture/compare pipeline to Spec 116 by (1) defining an explicit meta-fidelity hash contract, (2) enforcing the “coverage guard” based on the latest inventory sync run, and (3) switching baseline-compare findings to snapshot-scoped stable identities (recurrence keys) while preserving existing baseline-profile grouping for UI/stats and auto-close semantics.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (via Sail)
|
||||
**Testing**: Pest v4 (PHPUnit 12)
|
||||
**Target Platform**: Docker (Laravel Sail)
|
||||
**Project Type**: Web application (Laravel)
|
||||
**Performance Goals**: Compare jobs must remain bounded by scope size; avoid N+1 queries when loading snapshot + current inventory
|
||||
**Constraints**:
|
||||
- Ops-UX: OperationRun lifecycle + 3-surface feedback (toast queued-only, progress in widget/run detail, terminal DB notification exactly-once)
|
||||
- Summary counts numeric-only and keys restricted to `OperationSummaryKeys`
|
||||
- Tenant/workspace isolation + RBAC deny-as-not-found rules
|
||||
**Scale/Scope**: Tenant inventories may be large; baseline compare must be efficient on `(tenant_id, policy_type)` filtering
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — compare uses Inventory as last observed state; baselines are immutable snapshots.
|
||||
- Read/write separation: PASS — this feature is read-only analysis; no Graph writes.
|
||||
- Graph contract path: PASS — inventory sync already uses `GraphClientInterface`; baseline compare itself is DB-only at render time.
|
||||
- Deterministic capabilities: PASS — baseline capability checks use existing registries/policies; no new ad-hoc strings.
|
||||
- Workspace + tenant isolation: PASS — baseline profiles are workspace-owned; runs/findings are tenant-owned; authorization remains deny-as-not-found for non-members.
|
||||
- Run observability (OperationRun): PASS — capture/compare already use OperationRun + queued jobs.
|
||||
- Ops-UX 3-surface feedback: PASS — existing pages use canonical queued toast presenter.
|
||||
- Ops-UX lifecycle: PASS — transitions must remain inside `OperationRunService`.
|
||||
- Ops-UX summary counts: PASS — only numeric summary counts using canonical keys.
|
||||
- Filament UI contract: PASS — only small scope-picker adjustments; no new pages beyond what exists.
|
||||
- Filament UX-001 layout: PASS — Baseline Profile Create/Edit will be updated to a Main/Aside layout as part of the scope-picker work.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/116-baseline-drift-engine/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/ # Baseline compare landing + run detail links (existing)
|
||||
│ ├── Resources/ # BaselineProfileResource (existing)
|
||||
│ └── Widgets/ # Baseline compare widgets (existing)
|
||||
├── Jobs/
|
||||
│ ├── CaptureBaselineSnapshotJob.php
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Models/
|
||||
│ ├── BaselineProfile.php
|
||||
│ ├── BaselineSnapshot.php
|
||||
│ ├── BaselineSnapshotItem.php
|
||||
│ ├── Finding.php
|
||||
│ ├── InventoryItem.php
|
||||
│ └── OperationRun.php
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCaptureService.php
|
||||
│ │ ├── BaselineCompareService.php
|
||||
│ │ ├── BaselineAutoCloseService.php
|
||||
│ │ └── BaselineSnapshotIdentity.php
|
||||
│ ├── Drift/
|
||||
│ │ ├── DriftFindingGenerator.php
|
||||
│ │ └── DriftHasher.php
|
||||
│ ├── Inventory/
|
||||
│ │ └── InventorySyncService.php
|
||||
│ └── OperationRunService.php
|
||||
└── Support/
|
||||
├── Baselines/BaselineCompareStats.php
|
||||
└── OpsUx/OperationSummaryKeys.php
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
└── Baselines/
|
||||
├── BaselineCompareFindingsTest.php
|
||||
├── BaselineComparePreconditionsTest.php
|
||||
├── BaselineCompareStatsTest.php
|
||||
└── BaselineOperabilityAutoCloseTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Laravel 12) — all work stays in existing `app/` services/jobs/models and `tests/Feature`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature.
|
||||
|
||||
## Phase 0 — Outline & Research (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/116-baseline-drift-engine/research.md`
|
||||
|
||||
Key reconciliations captured:
|
||||
- Baseline compare finding identity will move to recurrence-key based upsert (snapshot-scoped identity) aligned with the existing `DriftFindingGenerator` pattern.
|
||||
- Coverage guard requires persisting per-type coverage outcomes into the latest inventory sync run context.
|
||||
- Scope must include `policy_types` + `foundation_types` with correct empty-default semantics.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/116-baseline-drift-engine/data-model.md`
|
||||
- `specs/116-baseline-drift-engine/contracts/openapi.yaml`
|
||||
- `specs/116-baseline-drift-engine/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Coverage lives in `operation_runs.context` for inventory sync runs (detailed lists), while `summary_counts` remain numeric-only.
|
||||
- Findings use `recurrence_key` and `fingerprint = recurrence_key` for idempotent upserts.
|
||||
- Findings remain grouped by `scope_key = baseline_profile:{id}` to preserve existing UI/stats and auto-close behavior.
|
||||
|
||||
## Phase 1 — Agent Context Update (REQUIRED)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
### Step 1 — Baseline scope schema + UI picker
|
||||
|
||||
Goal: implement FR-116v1-01 and FR-116v1-02.
|
||||
|
||||
Changes:
|
||||
- Update baseline scope handling (`app/Support/Baselines/BaselineScope.php`) to support:
|
||||
- `policy_types: []` meaning “all supported policy types excluding foundations”
|
||||
- `foundation_types: []` meaning “none”
|
||||
- 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:
|
||||
- Update/add Pest tests around scope expansion defaults (prefer a focused unit-like test if an expansion helper exists).
|
||||
|
||||
### Step 2 — Inventory Meta Contract (explicit hash input)
|
||||
|
||||
Goal: implement FR-116v1-04, FR-116v1-05, FR-116v1-06, FR-116v1-06a.
|
||||
|
||||
Changes:
|
||||
- Introduce a dedicated contract builder (e.g. `App\Services\Baselines\InventoryMetaContract`) that returns a normalized array for hashing.
|
||||
- Contract output must be explicitly versioned (e.g., `meta_contract.version = 1`) so future additions do not retroactively change v1 semantics.
|
||||
- Contract signals are best-effort: missing signals are represented as `null` (not omitted) to keep hashing stable across partial inventories.
|
||||
- Update baseline capture hashing (`BaselineSnapshotIdentity::hashItemContent()` or the capture service) to hash the contract output only.
|
||||
- Persist the exact contract payload used for hashing to `baseline_snapshot_items.meta_jsonb.meta_contract` for auditability/reproducibility.
|
||||
- Persist observation metadata alongside the hash in `baseline_snapshot_items.meta_jsonb` (at minimum: `fidelity`, `source`, `observed_at`; when available: `observed_operation_run_id`).
|
||||
- Update baseline compare to compute `current_hash` using the same contract builder.
|
||||
- Current-state `observed_at` is derived from persisted inventory evidence (`inventory_items.last_seen_at`) and MUST NOT require per-item external hydration calls during compare.
|
||||
- Define “latest successful snapshot” (v1) as `baseline_profiles.active_snapshot_id` and ensure compare start is blocked when it is `null` (no “pick newest captured_at” fallback).
|
||||
|
||||
Tests:
|
||||
- Add a small Pest test for contract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php`.
|
||||
- Update baseline capture/compare tests if they currently assume hashing full `meta_jsonb`.
|
||||
|
||||
### Step 3 — Inventory sync coverage recording
|
||||
|
||||
Goal: provide coverage for FR-116v1-07.
|
||||
|
||||
Changes:
|
||||
- Extend inventory sync pipeline (in `App\Services\Inventory\InventorySyncService` and/or the job that orchestrates sync) to write a coverage payload into the inventory sync `OperationRun.context`:
|
||||
- Per policy type: status (`succeeded|failed|skipped`) and optional `item_count`.
|
||||
- Foundations can be included in the same shape if they are part of selection.
|
||||
- Ensure this is written even when some types fail, so downstream compare can determine uncovered types.
|
||||
|
||||
Tests:
|
||||
- Add/extend tests around inventory sync operation context writing (mocking Graph calls as needed; keep scope minimal).
|
||||
|
||||
### Step 4 — Baseline compare coverage guard + outcome semantics
|
||||
|
||||
Goal: implement FR-116v1-07 and align to Ops-UX.
|
||||
|
||||
Changes:
|
||||
- In baseline compare job/service:
|
||||
- Resolve the latest inventory sync run for the tenant.
|
||||
- Compute `covered_policy_types` from sync run context.
|
||||
- Compute `uncovered_policy_types = effective_scope.policy_types - covered_policy_types`.
|
||||
- Skip emission of *all* finding types for uncovered policy types.
|
||||
- Record coverage details into the compare run `context` for auditability.
|
||||
- If uncovered types exist, set compare outcome to `partially_succeeded` via `OperationRunService` and set `summary_counts.errors_recorded = count(uncovered_policy_types)`.
|
||||
- If effective scope expands to zero types, complete as `partially_succeeded` and set `summary_counts.errors_recorded = 1` so the warning remains visible under numeric-only summary counts.
|
||||
- If there is no completed inventory sync run (or coverage proof is missing/unreadable), treat coverage as unproven for all effective-scope types (fail-safe): emit zero findings and complete as `partially_succeeded`.
|
||||
|
||||
Tests:
|
||||
- Add a new Pest test in `tests/Feature/Baselines` asserting:
|
||||
- uncovered types cause partial outcome
|
||||
- uncovered types produce zero findings (even if snapshot/current data would otherwise create missing/unexpected/different)
|
||||
- covered types still produce findings
|
||||
|
||||
### Step 5 — Snapshot-scoped stable finding identity
|
||||
|
||||
Goal: implement FR-116v1-09 and FR-116v1-10.
|
||||
|
||||
Changes:
|
||||
- Replace hash-evidence-based `fingerprint` generation in baseline compare with a stable recurrence key:
|
||||
- Inputs: `tenant_id`, `baseline_snapshot_id`, `policy_type`, `subject_external_id`, `change_type`
|
||||
- Persist:
|
||||
- `findings.recurrence_key = <computed>`
|
||||
- `findings.fingerprint = <same computed>`
|
||||
- Keep `scope_key = baseline_profile:{baselineProfileId}`.
|
||||
- Ensure retry idempotency: do not increment lifecycle counters more than once per run identity.
|
||||
|
||||
Tests:
|
||||
- Update `tests/Feature/Baselines/BaselineCompareFindingsTest.php`:
|
||||
- Ensure fingerprint no longer depends on baseline/current hash.
|
||||
- Assert stable identity across re-runs with changed evidence hashes.
|
||||
- Add coverage for “recapture uses new snapshot id → new finding identity”.
|
||||
|
||||
### Step 6 — Auto-close + stats compatibility
|
||||
|
||||
Goal: preserve existing operability expectations and keep UI stable.
|
||||
|
||||
Changes:
|
||||
- Ensure `BaselineAutoCloseService` still resolves stale findings after a fully successful compare, even though identities now include snapshot id.
|
||||
- Confirm `BaselineCompareStats` remains correct for grouping by `scope_key = baseline_profile:{id}`.
|
||||
|
||||
Tests:
|
||||
- Update/keep `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` passing.
|
||||
- Update `tests/Feature/Baselines/BaselineCompareStatsTest.php` only if scope semantics change.
|
||||
|
||||
### Step 7 — Ops UX + auditability
|
||||
|
||||
Goal: implement FR-116v1-03 and FR-116v1-11.
|
||||
|
||||
Changes:
|
||||
- Ensure both capture and compare runs write:
|
||||
- `effective_scope.*` in run context
|
||||
- coverage summary and uncovered lists when partial
|
||||
- numeric summary counts using canonical keys only
|
||||
- per-change-type finding counts in `operation_runs.context.findings.counts_by_change_type`
|
||||
- Treat the `operation_runs` record as the canonical audit trail for this feature slice (do not add parallel “audit summary” persistence for the same data).
|
||||
|
||||
Tests:
|
||||
- Add a regression test that asserts `summary_counts` contains only allowed keys and numeric values (where a helper exists).
|
||||
|
||||
## Post-design Constitution Re-check
|
||||
|
||||
Expected: PASS (no changes introduce new Graph endpoints or bypass services; OperationRun lifecycle + 3-surface feedback remain intact; RBAC deny-as-not-found semantics preserved).
|
||||
62
specs/116-baseline-drift-engine/quickstart.md
Normal file
62
specs/116-baseline-drift-engine/quickstart.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Quickstart — Spec 116 Baseline Drift Engine
|
||||
|
||||
This quickstart is for developers validating the behavior locally.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Sail up: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1) Run an inventory sync (establish coverage)
|
||||
|
||||
- Trigger inventory sync for a tenant using the existing UI/command for inventory sync.
|
||||
- Verify the latest inventory sync `operation_runs.context` contains a coverage payload:
|
||||
- `inventory.coverage.policy_types.{type}.status = succeeded|failed`
|
||||
- (optionally) `inventory.coverage.foundation_types.{type}.status = ...`
|
||||
|
||||
Expected:
|
||||
- If some types fail or are not processed, the coverage payload reflects that.
|
||||
|
||||
### 2) Capture a baseline snapshot
|
||||
|
||||
- Use the Baseline Profile UI to capture a snapshot.
|
||||
|
||||
Expected:
|
||||
- Snapshot items store `baseline_hash` computed from the Inventory Meta Contract.
|
||||
- Snapshot identity/dedupe follows existing snapshot identity rules, but content hashes come from the explicit contract.
|
||||
|
||||
### 3) Compare baseline to tenant
|
||||
|
||||
- Trigger “Compare now” from the Baseline Compare landing page.
|
||||
|
||||
Expected:
|
||||
- Compare uses latest successful baseline snapshot by default (or explicit snapshot selection if provided).
|
||||
- Compare uses the latest **completed** inventory sync run coverage:
|
||||
- For uncovered policy types, **no findings are emitted**.
|
||||
- OperationRun outcome becomes “completed with warnings” (partial) when uncovered types exist.
|
||||
- `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`.
|
||||
- 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:
|
||||
- stable `recurrence_key` uses `baseline_snapshot_id` and does **not** include baseline/current hashes.
|
||||
- `fingerprint == recurrence_key`.
|
||||
- `scope_key` remains profile-scoped (`baseline_profile:{id}`).
|
||||
|
||||
### 4) Validate UI counts
|
||||
|
||||
- Verify baseline compare stats remain grouped by the profile scope (`scope_key = baseline_profile:{id}`), consistent with research.md Decision #2.
|
||||
- Validate that re-capturing (new snapshot) creates a new set of findings due to snapshot-scoped identity (recurrence key includes `baseline_snapshot_id`).
|
||||
|
||||
## Minimal smoke test checklist
|
||||
|
||||
- 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 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-capture snapshot and compare: findings identity changes (snapshot-scoped).
|
||||
104
specs/116-baseline-drift-engine/research.md
Normal file
104
specs/116-baseline-drift-engine/research.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Phase 0 — Research (Baseline Drift Engine)
|
||||
|
||||
This document resolves the open design/implementation questions needed to produce a concrete implementation plan for Spec 116, grounded in the current codebase.
|
||||
|
||||
## Repo Reality Check (What already exists)
|
||||
|
||||
- Baseline domain tables exist: `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`.
|
||||
- Baseline ops exist:
|
||||
- Capture: `App\Services\Baselines\BaselineCaptureService` → `App\Jobs\CaptureBaselineSnapshotJob`.
|
||||
- Compare: `App\Services\Baselines\BaselineCompareService` → `App\Jobs\CompareBaselineToTenantJob`.
|
||||
- Findings lifecycle primitives exist (times_seen/first_seen/last_seen) and recurrence support exists (`findings.recurrence_key`).
|
||||
- An existing recurrence-based drift generator exists: `App\Services\Drift\DriftFindingGenerator` (uses `recurrence_key` and also sets `fingerprint = recurrence_key` to satisfy the unique constraint).
|
||||
- Inventory sync is OperationRun-based and stamps `inventory_items.last_seen_operation_run_id`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Finding identity for baseline compare
|
||||
|
||||
**Decision:** Baseline compare findings MUST use a stable recurrence key derived from:
|
||||
- `tenant_id`
|
||||
- `baseline_snapshot_id` (not baseline profile id)
|
||||
- `policy_type`
|
||||
- `subject_external_id`
|
||||
- `change_type`
|
||||
|
||||
This recurrence key is stored in `findings.recurrence_key` and ALSO used as `findings.fingerprint` (to keep the existing unique constraint `unique(tenant_id, fingerprint)` effective).
|
||||
|
||||
**Rationale:**
|
||||
- Matches Spec 116 (identity tied to `baseline_snapshot_id` and independent of evidence hashes).
|
||||
- Aligns with existing, proven pattern in `DriftFindingGenerator` (recurrence_key-based upsert; fingerprint reused).
|
||||
|
||||
**Alternatives considered:**
|
||||
- Keep `DriftHasher::fingerprint(...)` with baseline/current hashes included → rejected because it changes identity when evidence changes (violates FR-116v1-09).
|
||||
- Add a new unique DB constraint on `(tenant_id, recurrence_key)` → possible later hardening; not required initially because fingerprint uniqueness already enforces dedupe when `fingerprint = recurrence_key`.
|
||||
|
||||
### 2) Scope key for baseline compare findings
|
||||
|
||||
**Decision:** Keep findings grouped by baseline profile using `scope_key = baseline_profile:{baselineProfileId}`.
|
||||
|
||||
**Rationale:**
|
||||
- Spec 116 requires snapshot-scoped *identity* (via `baseline_snapshot_id` in the recurrence key), but does not require snapshot-scoped grouping.
|
||||
- The repository already has UI widgets/stats and auto-close behavior keyed to `baseline_profile:{id}`; keeping scope_key stable minimizes churn and preserves existing semantics.
|
||||
- Re-captures still create new finding identities because the recurrence key includes `baseline_snapshot_id`.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Snapshot-scoped `scope_key = baseline_snapshot:{id}` → rejected for v1 because it would require larger refactors to stats, widgets, and auto-close queries, without being mandated by the spec.
|
||||
|
||||
### 3) Coverage guard (prevent false missing policies)
|
||||
|
||||
**Decision:** Coverage MUST be derived from the latest completed `inventory_sync` OperationRun for the tenant:
|
||||
- Record per-policy-type processing outcomes into that run’s context (coverage payload).
|
||||
- Baseline compare MUST compute `uncovered_policy_types = effective_scope - covered_policy_types`.
|
||||
- Baseline compare MUST emit **no findings of any kind** for uncovered policy types.
|
||||
- The compare OperationRun outcome should be `partially_succeeded` when uncovered types exist ("completed with warnings" in Ops UX), and summary counts should include `errors_recorded = count(uncovered_policy_types)`.
|
||||
|
||||
**Rationale:**
|
||||
- Spec FR-116v1-07 and SC-116-03.
|
||||
- Current compare logic uses `inventory_items.last_seen_operation_run_id` filter only; without an explicit coverage list, a missing type looks identical to a truly empty tenant.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Infer coverage purely from "were there any inventory items for this policy type in the last sync run" → rejected because a legitimately empty type would be indistinguishable from "not synced".
|
||||
|
||||
### 4) Inventory meta contract hashing (v1 fidelity=meta)
|
||||
|
||||
**Decision:** Introduce an explicit "Inventory Meta Contract" builder used by BOTH capture and compare in v1.
|
||||
|
||||
- Inputs: `policy_type`, `external_id`, and a whitelist of stable signals from inventory/meta (etag, last modified, scope tags, assignment target count, version marker when available).
|
||||
- Output: a normalized associative array, hashed deterministically.
|
||||
|
||||
**Rationale:**
|
||||
- Spec FR-116v1-04: hashing must be based on a stable contract, not arbitrary meta.
|
||||
- Current `BaselineSnapshotIdentity::hashItemContent()` hashes the entire `meta_jsonb` (including keys like `etag` which may be noisy and keys that may expand over time).
|
||||
|
||||
**Alternatives considered:**
|
||||
- Keep current hashing of `meta_jsonb` → rejected because it is not an explicit contract and may drift as we add inventory metadata.
|
||||
|
||||
### 5) Baseline scope + foundations
|
||||
|
||||
**Decision:** Extend baseline scope JSON to include:
|
||||
- `policy_types: []` (empty means default "all supported policy types excluding foundations")
|
||||
- `foundation_types: []` (empty means default "none")
|
||||
|
||||
Foundations list must be derived from the same canonical foundation list used by inventory sync selection logic.
|
||||
|
||||
**Rationale:**
|
||||
- Spec FR-116v1-01.
|
||||
- Current `BaselineScope` only supports `policy_types` and treats empty as "all" (including foundations) which conflicts with the spec default.
|
||||
|
||||
### 6) v2 architecture strategy (content fidelity)
|
||||
|
||||
**Decision:** v2 is implemented as an extension of the same pipeline via a provider precedence chain:
|
||||
|
||||
`PolicyVersion (if available) → Inventory content (if available) → Meta contract fallback (degraded)`
|
||||
|
||||
The baseline compare engine stores dimension flags on the same finding (no additional finding identities).
|
||||
|
||||
**Rationale:**
|
||||
- Spec FR-116v2-01 and FR-116v2-05.
|
||||
- There is already a content-normalization + hashing stack in `DriftFindingGenerator` (policy snapshot / assignments / scope tags) which can inform the content fidelity provider.
|
||||
|
||||
## Notes / Risks
|
||||
|
||||
- Existing baseline compare findings are currently keyed by `fingerprint` that includes baseline/current hashes and uses `scope_key = baseline_profile:{id}`. The v1 migration should plan for “old findings become stale” behavior; do not attempt silent in-place identity rewriting without an explicit migration/backfill plan.
|
||||
- Coverage persistence must remain numeric-only in `summary_counts` (per Ops-UX). Detailed coverage lists belong in `operation_runs.context`.
|
||||
236
specs/116-baseline-drift-engine/spec.md
Normal file
236
specs/116-baseline-drift-engine/spec.md
Normal file
@ -0,0 +1,236 @@
|
||||
# Feature Specification: Baseline Drift Engine (Final Architecture)
|
||||
|
||||
**Feature Branch**: `116-baseline-drift-engine`
|
||||
**Created**: 2026-03-01
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 116 — Baseline Drift Engine (Final Architecture)"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace (baseline definition + capture) + tenant (baseline compare monitoring)
|
||||
- **Primary Routes**:
|
||||
- Workspace (admin): Baseline Profiles (create/edit scope, capture baseline)
|
||||
- Tenant-context (admin): Baseline Compare runs (compare now, run detail) and Drift Findings landing
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: Baseline profiles and baseline snapshots
|
||||
- Tenant-scoped (within a workspace): Operation runs for baseline capture/compare; drift findings produced by compare
|
||||
- Baseline snapshots are workspace-owned standards captured from a chosen tenant, but snapshot items MUST NOT persist tenant identifiers (e.g., no `tenant_id` column on snapshot items).
|
||||
- **RBAC**:
|
||||
- Workspace (Baselines):
|
||||
- `workspace_baselines.view`: view baseline profiles + snapshots
|
||||
- `workspace_baselines.manage`: create/edit/archive baseline profiles, start capture runs
|
||||
- Tenant (Compare):
|
||||
- `tenant.sync`: start baseline compare runs
|
||||
- `tenant_findings.view`: view drift findings
|
||||
- Tenant access is required for tenant-context surfaces, in addition to workspace membership
|
||||
|
||||
For canonical-view specs: not applicable (this is not a canonical-view feature).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-01
|
||||
|
||||
- Q: Should finding identity be stable across baseline re-captures, or tied to a specific baseline snapshot? → A: Tie finding identity to `baseline_snapshot_id` (stable within a snapshot; re-capture creates new finding identities).
|
||||
- Q: In v2, should drift dimensions be stored as flags on a single finding, or as separate findings per dimension? → A: Use one finding with dimension flags (no separate findings per dimension).
|
||||
- Q: When running a compare, which baseline snapshot should be used by default? → A: Default to the baseline profile’s `active_snapshot_id` (updated only by successful captures); allow explicitly selecting a snapshot.
|
||||
- Q: When coverage is missing for a policy type, should compare emit any findings for that type? → A: Skip all finding emission for uncovered types (no `missing_policy`, no `unexpected_policy`, no `different_version`).
|
||||
|
||||
## Outcomes
|
||||
|
||||
- **O-1 One engine**: There is exactly one baseline drift compare engine; no parallel legacy compare/hash paths.
|
||||
- **O-2 Stable findings (recurrence)**: The same underlying drift maps to the same finding identity across retries and across runs, with lifecycle counters.
|
||||
- **O-3 Auditability & operator UX**: Each compare run records scope, coverage, and fidelity; partial coverage produces warnings (not misleading “missing policy” noise).
|
||||
- **O-4 No legacy logic after v2**: After the v2 extension, there are no “meta compare here / diff there” special cases; all drift flows through the same pipeline.
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Subject key**: A compare object identity independent of tenant, identified by `(policy_type, external_id)`.
|
||||
- **Tenant subject**: A subject key within a tenant context, identified by `(tenant_id, policy_type, external_id)`.
|
||||
- **Policy state**: A normalized representation of a tenant subject, containing a deterministic hash, fidelity, and observation metadata.
|
||||
- **Fidelity**:
|
||||
- **meta**: drift signal based on a stable “inventory meta contract” (signal-based fields)
|
||||
- **content**: drift signal based on canonicalized policy content (semantic)
|
||||
- **Effective scope**: The expanded set of policy types processed by a run.
|
||||
- **Coverage**: Which policy types are confirmed to be present/updated in the tenant current state at the time of compare.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Baseline drift is sold as “signal-based drift detection” in v1 (meta fidelity), and later upgraded to deep drift (content fidelity) without changing the compare engine semantics.
|
||||
- The system already has a tenant-scoped inventory sync mechanism capable of recording per-run coverage of which policy types were synced.
|
||||
- Foundations are treated as opt-in policy types; they are excluded unless explicitly selected.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Capture and compare a baseline with stable findings (Priority: P1)
|
||||
|
||||
As a workspace admin, I want to define a baseline scope, capture a baseline snapshot, and compare a tenant against that baseline, so I can reliably detect and track drift over time.
|
||||
|
||||
**Why this priority**: This is the core product slice that makes baseline drift sellable: consistent capture, consistent compare, and stable findings.
|
||||
|
||||
**Independent Test**: Can be tested by creating a baseline profile with a defined scope, capturing a snapshot, running compare twice, and verifying stable finding identity and lifecycle counters.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline profile with scope “all policy types (excluding foundations)”, **When** I capture a baseline snapshot, **Then** the snapshot contains only in-scope policy subjects and each snapshot item records its hash and fidelity.
|
||||
2. **Given** a captured baseline snapshot and a tenant current state, **When** I run compare twice with the same inputs, **Then** the same drift maps to the same finding identity and lifecycle counters increment at most once per run.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Coverage warnings prevent misleading missing-policy findings (Priority: P1)
|
||||
|
||||
As an operator, I want the compare run to warn when current-state coverage is partial, so that missing policies are not falsely reported when the system simply lacks data.
|
||||
|
||||
**Why this priority**: Trust depends on avoiding false negatives/positives; “missing policy” findings on partial sync is unacceptable noise.
|
||||
|
||||
**Independent Test**: Can be tested by running compare with an effective scope where some policy types are intentionally marked as not synced, verifying warning outcome and suppression behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a compare run where some policy types in effective scope were not synced, **When** compare is executed, **Then** the run completes with warnings and produces no findings at all for those missing-coverage types.
|
||||
2. **Given** a compare run where coverage is complete, **When** a baseline policy subject is missing in current state for a covered type, **Then** a missing-policy finding is produced.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Operators can understand scope, coverage, and fidelity in the UI (Priority: P2)
|
||||
|
||||
As an operator, I want drift screens to clearly show what was compared (scope), how complete the data was (coverage), and how “deep” the drift signal is (fidelity), so I can interpret findings correctly.
|
||||
|
||||
**Why this priority**: Drift findings are only actionable when the operator understands context and limitations.
|
||||
|
||||
**Independent Test**: Can be tested by executing a compare run with and without coverage warnings, verifying that run detail and drift landing surfaces render scope counts, coverage badge, and fidelity indicators.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a compare run with full coverage, **When** I open run detail, **Then** I see the compared scope and a coverage status of OK.
|
||||
2. **Given** a compare run with partial coverage, **When** I open the drift landing and run detail, **Then** I see a warning banner and can see which types were missing coverage.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Compare is retried after a transient failure: findings are not duplicated; lifecycle increments happen at most once per run identity.
|
||||
- Baseline capture is executed with empty scope lists (interpreted as default semantics): policy types means “all supported types excluding foundations”; foundations list means “none”.
|
||||
- Effective scope expands to zero types (e.g., no supported types): run completes with an explicit warning and produces no findings.
|
||||
- Policy subjects appear/disappear between inventory sync and compare: handled according to coverage rules; does not create missing-policy noise for uncovered types.
|
||||
- Two different policy subjects accidentally share an external identifier across types: identity is still unambiguous because `policy_type` is part of the subject key.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
This feature introduces/extends long-running compare work and uses `OperationRun` for capture and compare runs.
|
||||
It must comply with:
|
||||
|
||||
- **Run observability**: Every capture/compare run must have a visible run identity, scope context, coverage context, and outcome.
|
||||
- **Safety**: Compare must never claim missing policies for policy types where current-state coverage is not proven.
|
||||
- **Tenant isolation**: Inventory items, operation runs, and findings are tenant-scoped; cross-tenant access must be deny-as-not-found. Baseline profiles/snapshots are workspace-owned and must not persist tenant identifiers.
|
||||
|
||||
### Operational UX Contract (Ops-UX)
|
||||
|
||||
- Capture and compare run lifecycle transitions are service-owned (not UI-owned).
|
||||
- Run summaries provide numeric-only counters using ONLY keys from `app/Support/OpsUx/OperationSummaryKeys.php`.
|
||||
- Coverage warnings MUST be represented using an existing canonical numeric key (default: `errors_recorded`).
|
||||
- Warning semantics mapping (canonical):
|
||||
- Any “completed with warnings” case MUST be represented as `OperationRun.outcome = partially_succeeded`.
|
||||
- `summary_counts.errors_recorded` MUST be a numeric indicator of warning magnitude.
|
||||
- Default: number of uncovered policy types in effective scope.
|
||||
- Edge case (effective scope expands to zero types): `summary_counts.errors_recorded = 1` so the warning remains visible under the numeric-only summary_counts contract.
|
||||
- Scheduled/system-initiated runs (if any) must not generate user terminal DB notifications; audit is handled via monitoring surfaces.
|
||||
- Regression guard tests are added/updated to enforce correct run outcome semantics and summary counter rules.
|
||||
|
||||
### Authorization Contract (RBAC-UX)
|
||||
|
||||
- Workspace membership + capability gates:
|
||||
- `workspace_baselines.view` is required to view baseline profiles and snapshots.
|
||||
- `workspace_baselines.manage` is required to create/edit/archive baseline profiles and start capture runs.
|
||||
- `tenant.sync` is required to start compare runs.
|
||||
- `tenant_findings.view` is required to view drift findings.
|
||||
- 404 vs 403 semantics:
|
||||
- Non-member or not entitled to workspace/tenant scope → 404 (deny-as-not-found)
|
||||
- Member but missing capability → 403
|
||||
- Destructive-like actions (e.g., archiving a baseline profile) require an explicit confirmation step.
|
||||
- At least one positive and one negative authorization test exist for each mutation surface.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### v1 — Meta-fidelity baseline compare (sellable)
|
||||
|
||||
- **FR-116v1-01 Baseline profile scope**: Baseline profiles MUST store a scope object with `policy_types` and `foundation_types` lists.
|
||||
- Default semantics: `policy_types = []` means all supported policy types excluding foundations; `foundation_types = []` means no foundations.
|
||||
- Foundations MUST only be included when explicitly selected.
|
||||
- **FR-116v1-02 UI scope picker**: The UI MUST provide multi-select controls for Policy Types and Foundations and communicate the default semantics (empty selection = default behavior).
|
||||
- **FR-116v1-03 Effective scope recorded on runs**: Capture and compare runs MUST record expanded effective scope in run context:
|
||||
- `effective_scope.policy_types[]`, `effective_scope.foundation_types[]`, `effective_scope.all_types[]`, and a boolean `effective_scope.foundations_included`.
|
||||
- **FR-116v1-04 Inventory meta contract**: The system MUST define and persist a stable “inventory meta contract” (signal-based fields) for drift hashing.
|
||||
- Minimum required signals: type identifier, version marker (when available), last modified time (when available), scope tags (when available), and assignment target count (when available).
|
||||
- Drift hashing for v1 MUST be based only on this contract (not arbitrary meta fields).
|
||||
- Contract outputs MUST be versioned so future additions do not retroactively change v1 semantics (e.g., `meta_contract.version = 1`).
|
||||
- For baseline snapshot items, the exact contract payload used for hashing MUST be persisted in the snapshot item `meta_jsonb` (e.g., `meta_jsonb.meta_contract`).
|
||||
- **FR-116v1-05 Provide current-state policy states (meta fidelity)**: For all policy subjects in effective scope, the system MUST produce a normalized policy state for compare, including:
|
||||
- subject key (policy type + external id), deterministic hash, fidelity=`meta`, source indicator, and observed timestamp.
|
||||
- In v1, `observed_at` MUST be derived from persisted inventory evidence (`inventory_items.last_seen_at`), not from per-item external hydration calls during compare.
|
||||
- In v1, `source` MUST indicate the meta-fidelity source (e.g., `inventory_meta_contract:v1`) and MAY include stable provenance (e.g., `inventory_items.last_seen_operation_run_id`) for traceability.
|
||||
- **FR-116v1-06 Baseline capture stores states (not raw)**: Baseline capture MUST store per-subject snapshot items that include the subject identity and the captured hash + fidelity + source + observed timestamp.
|
||||
- Baseline snapshots MUST NOT contain out-of-scope items.
|
||||
- Snapshot items MUST store observation metadata in `baseline_snapshot_items.meta_jsonb` (at minimum: `fidelity`, `source`, `observed_at`; when available: `observed_operation_run_id`).
|
||||
- **FR-116v1-06a Compare snapshot selection**: Baseline compare MUST, by default, use the latest successful baseline snapshot of the selected baseline profile.
|
||||
- Definition (v1): “latest successful baseline snapshot” is `baseline_profiles.active_snapshot_id` (updated only after a successful capture run persists a snapshot + items).
|
||||
- If `active_snapshot_id` is `null`, compare start MUST be blocked with a clear precondition failure (no implicit “pick the newest captured_at” fallback).
|
||||
- The UI MAY allow selecting a specific snapshot explicitly for historical comparisons.
|
||||
- **FR-116v1-07 Coverage guard**: Compare MUST check current-state coverage recorded by the most recent inventory sync run.
|
||||
- If effective scope contains policy types not present in coverage, the compare run MUST complete with warnings.
|
||||
- For any uncovered policy type, the compare MUST NOT emit findings of any kind for that type (no `missing_policy`, no `unexpected_policy`, no `different_version`).
|
||||
- Drift findings for types with proven coverage may still be produced.
|
||||
- If there is no completed inventory sync run (or coverage proof is missing/unreadable), coverage MUST be treated as unproven for all types and the compare MUST produce zero findings (fail-safe) and complete with warnings.
|
||||
- **FR-116v1-08 Drift rules**: Compare MUST produce drift results per policy subject:
|
||||
- Baseline-only → `missing_policy` (only when coverage is proven for the subject’s type)
|
||||
- Current-only → `unexpected_policy`
|
||||
- Both present and hashes differ → `different_version` (with fidelity=`meta`)
|
||||
- **FR-116v1-09 Stable finding identity**: Findings MUST have a stable identity key derived from: tenant, baseline snapshot, policy type, external id, and change type.
|
||||
- Hashes are evidence fields and may update without changing identity.
|
||||
- Finding identity MUST be tied to a specific baseline snapshot (re-capture creates a new baseline snapshot and therefore new finding identities).
|
||||
- **FR-116v1-10 Finding lifecycle + retry idempotency**: Findings MUST record first seen, last seen, and times seen.
|
||||
- For a given run identity, lifecycle counters MUST not increment more than once.
|
||||
- **FR-116v1-11 Auditability**: Each capture and compare run MUST write an audit trail including effective scope counts, coverage warning summary (if any), and finding counts per change type.
|
||||
- Audit trail storage (canonical):
|
||||
- Aggregations that do not fit `summary_counts` MUST be stored in `operation_runs.context` (not new summary keys).
|
||||
- Compare MUST store per-change-type counts in run context under `findings.counts_by_change_type` (e.g., keys: `missing_policy`, `unexpected_policy`, `different_version`).
|
||||
- For this repository, the canonical audit trail is the `operation_runs` record itself (status/outcome + context + numeric summary_counts); do not introduce parallel “audit summary” persistence for the same data.
|
||||
- **FR-116v1-12 Drift UI context**: Compare run detail and drift landing MUST surface scope, coverage status, and fidelity (meta-based drift) and show a warning banner when coverage warnings were present.
|
||||
|
||||
#### v2 — Content-fidelity extension (deep drift, same engine)
|
||||
|
||||
**Deferred / out of scope for this delivery**: The v2 requirements below are intentionally not covered by `specs/116-baseline-drift-engine/tasks.md` and will be implemented in a follow-up spec/milestone.
|
||||
|
||||
- **FR-116v2-01 Provider precedence**: Current state MUST be sourced with a precedence chain per policy type: “policy version (if available) → inventory content (if available) → meta fallback (explicitly marked degraded)”.
|
||||
- **FR-116v2-02 Content hash availability**: The inventory system MUST persist a content hash and capture timestamp for hydrated policy content.
|
||||
- **FR-116v2-03 Quota-aware hydration**: Content hydration MUST be throttling-safe and resumable, with explicit per-run caps and concurrency limits, and must record hydration coverage in run context.
|
||||
- **FR-116v2-04 Content normalization rules**: The system MUST define canonicalization rules per policy type, including volatile-field removal and (where needed) redaction hooks.
|
||||
- **FR-116v2-05 Drift dimensions (optional but final)**: The compare output MAY include dimension flags (content, assignments, scope tags) without changing finding identity.
|
||||
- If dimension flags are present, they MUST be stored on the same finding record as evidence/flags; the system MUST NOT create separate findings per dimension.
|
||||
- `change_type` semantics remain compatible with v1 (dimensions refine the “different_version” class rather than multiplying identities).
|
||||
- **FR-116v2-06 Capture/compare use the same pipeline**: Capture and compare MUST use the same policy state pipeline and hashing semantics; v2 must not introduce special-case compare paths.
|
||||
- **FR-116v2-07 Coverage/fidelity guard**: If content hydration is incomplete for some types, compare MAY still run but must clearly indicate degraded fidelity and must follow registry-defined behavior for those types.
|
||||
- **FR-116v2-08 No-legacy guarantee**: After v2 cutover, legacy compare/hash helpers are removed and CI guards prevent re-introduction.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline Profiles | Workspace admin | Create Baseline Profile | View action / record inspection (per Action Surface Contract) | Edit, Archive (confirmed) | None | “Create Baseline Profile” | Capture Baseline (compare is tenant-context) | Save, Cancel | Yes | Archive requires confirmation; capture starts OperationRuns and is audited |
|
||||
| Baseline Capture Run Detail | Workspace admin | None | Linked from runs list | None | None | None | None | N/A | Yes | Shows effective scope + fidelity + counts + warnings |
|
||||
| Baseline Compare Run Detail | Tenant-context admin | Run Compare (if shown), Re-run Compare (if allowed) | Linked from runs list | None | None | None | None | N/A | Yes | Shows coverage badge and warning banner; uncovered types emit no findings |
|
||||
| Drift Findings Landing | Tenant-context admin | None | Table filter by change type | View (optional), Acknowledge/Resolve (if workflow exists) | None | None | None | N/A | Yes | Surfaces fidelity + coverage context; no destructive actions required for v1 |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Baseline profile**: Defines scope (policy types + opt-in foundations) and is the parent for baseline snapshots.
|
||||
- **Baseline snapshot item**: Stores per-policy-subject baseline state evidence (hash, fidelity, source, observed timestamp).
|
||||
- **Compare run**: A recorded operation that compares a tenant current state to a baseline snapshot, including effective scope and coverage warnings.
|
||||
- **Finding**: A stable, recurring drift finding with lifecycle fields (first seen, last seen, times seen) and evidence (baseline/current hashes, fidelity).
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-116-01 One engine**: All baseline compare and capture runs use exactly one drift pipeline; no alternative compare paths exist in production code.
|
||||
- **SC-116-02 Stable recurrence**: For a fixed baseline snapshot + tenant + policy subject + change type, repeated compares (including retries) produce at most one finding identity, and lifecycle counters increment at most once per run.
|
||||
- **SC-116-03 Coverage safety**: When coverage is partial for any effective-scope type, the compare run is visibly marked as “completed with warnings” and produces zero findings for those uncovered types.
|
||||
- **SC-116-04 Operator clarity**: On the compare run detail screen, operators can see effective scope counts, coverage status, and fidelity within one page load, with a clear warning banner when applicable.
|
||||
- **SC-116-05 Performance guard (v1)**: Compare runs complete without per-item external hydration calls; runtime scales with number of in-scope subjects via chunking.
|
||||
244
specs/116-baseline-drift-engine/tasks.md
Normal file
244
specs/116-baseline-drift-engine/tasks.md
Normal file
@ -0,0 +1,244 @@
|
||||
---
|
||||
|
||||
description: "Executable task breakdown for Spec 116 implementation"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: 116 — Baseline Drift Engine (Final Architecture)
|
||||
|
||||
**Input**: Design documents in `specs/116-baseline-drift-engine/` (`plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `specs/116-baseline-drift-engine/contracts/openapi.yaml`)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Ensure local dev + feature artifacts are ready.
|
||||
|
||||
- [X] T001 Re-run Speckit prerequisites check via `.specify/scripts/bash/check-prerequisites.sh --json` (references `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`)
|
||||
- [X] T003 Ensure Sail + migrations are up for local validation (references `vendor/bin/sail`, `docker-compose.yml`, and `database/migrations/`)
|
||||
- [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`
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives that all user stories depend on (scope semantics, data defaults, and UI inputs).
|
||||
|
||||
**Independent Test**: Baseline Profile can be created with the new scope shape, and scope defaults expand deterministically.
|
||||
|
||||
- [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`
|
||||
- [X] T007 [P] Update BaselineProfile default scope shape to include `foundation_types` in `database/factories/BaselineProfileFactory.php`
|
||||
- [X] T008 [P] Ensure BaselineProfile scope casting/normalization supports `foundation_types` safely in `app/Models/BaselineProfile.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`
|
||||
- [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`
|
||||
- [X] T011 [P] Update create-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.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`
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Capture and compare a baseline with stable findings (Priority: P1)
|
||||
|
||||
**Goal**: Define the v1 meta-fidelity hash contract, use it for capture/compare, and make baseline-compare findings snapshot-scoped stable identities.
|
||||
|
||||
**Independent Test**: Capture a snapshot, run compare twice with the same inputs, and verify finding identity stability + lifecycle idempotency.
|
||||
|
||||
### Tests for User Story 1 (write first)
|
||||
|
||||
- [X] T014 [P] [US1] Update capture tests for effective_scope recording + contract-based hashing in `tests/Feature/Baselines/BaselineCaptureTest.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`
|
||||
- [X] T016 [P] [US1] Add unit test for InventoryMetaContract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.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`
|
||||
- [X] T018 [P] [US1] Add test that re-capturing (new snapshot id) produces new finding identities (snapshot-scoped) in `tests/Feature/Baselines/BaselineCompareFindingsTest.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`
|
||||
- [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`
|
||||
- [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
|
||||
|
||||
- [X] T022 [US1] Create + implement Inventory Meta Contract builder (normalized whitelist inputs; deterministic ordering) in `app/Services/Baselines/InventoryMetaContract.php`
|
||||
- [X] T023 [US1] Update snapshot hashing to hash ONLY the meta contract output (not entire meta_jsonb) in `app/Services/Baselines/BaselineSnapshotIdentity.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`
|
||||
- [X] T025 [US1] Ensure capture OperationRun context records `effective_scope.*` (policy_types, foundation_types, all_types, foundations_included) in `app/Services/Baselines/BaselineCaptureService.php`
|
||||
- [X] T026 [US1] Update baseline compare job to compute `current_hash` via InventoryMetaContract consistently with capture 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`
|
||||
- [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`
|
||||
- [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`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Coverage warnings prevent misleading missing-policy findings (Priority: P1)
|
||||
|
||||
**Goal**: Persist per-type coverage from inventory sync and enforce the coverage guard in baseline compare (uncovered types emit zero findings; compare outcome is partial with warnings).
|
||||
|
||||
**Independent Test**: Run compare with missing coverage for some in-scope types; verify partial outcome + zero findings for uncovered types.
|
||||
|
||||
### Tests for User Story 2 (write first)
|
||||
|
||||
- [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`
|
||||
- [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
|
||||
|
||||
- [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`
|
||||
- [X] T033 [P] [US2] Create a small coverage parser/helper to normalize context payload for downstream consumers in `app/Support/Inventory/InventoryCoverage.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`
|
||||
- [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.
|
||||
|
||||
**Checkpoint**: US2 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncStartSurfaceTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Operators can understand scope, coverage, and fidelity in the UI (Priority: P2)
|
||||
|
||||
**Goal**: Surface effective scope, coverage status, and fidelity (meta) in Baseline Compare landing + drift findings surfaces.
|
||||
|
||||
**Independent Test**: Execute a compare with and without coverage warnings; verify UI surfaces show badge/banner + scope/coverage/fidelity context.
|
||||
|
||||
### Tests for User Story 3 (write first)
|
||||
|
||||
- [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`
|
||||
- [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
|
||||
|
||||
- [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`
|
||||
- [X] T039 [US3] Wire new stats fields into the BaselineCompareLanding Livewire page state in `app/Filament/Pages/BaselineCompareLanding.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`
|
||||
- [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`
|
||||
- [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`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Preserve operability semantics (auto-close, stats), Ops-UX compliance, and fast regression feedback.
|
||||
|
||||
- [X] T043 Confirm baseline compare stats remain profile-grouped via `scope_key = baseline_profile:{id}` after identity change in `app/Support/Baselines/BaselineCompareStats.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`
|
||||
- [X] T045 [P] Update/verify auto-close regression test remains valid after identity change in `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.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`
|
||||
- [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`
|
||||
- [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`
|
||||
- [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`
|
||||
- [X] T050 [P] Create Baseline Profile archive action tests (confirmation required + RBAC 403/404 semantics + success path) in `tests/Feature/Baselines/BaselineProfileArchiveActionTest.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`
|
||||
- [X] T052 Run baseline-focused test pack for Spec 116: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` (references `tests/Feature/Baselines/`)
|
||||
- [X] T053 Run Ops-UX guard test pack: `vendor/bin/sail artisan test --compact --group=ops-ux` (references `tests/Feature/OpsUx/Constitution/`)
|
||||
- [X] T054 Run Pint formatter on changed files: `vendor/bin/sail pint --dirty --format agent` (references `app/` and `tests/`)
|
||||
- [X] T055 Validate developer quickstart still matches real behavior (update if needed) in `specs/116-baseline-drift-engine/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### User Story Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
|
||||
P2 --> US1[US1: Stable capture/compare + findings]
|
||||
US1 --> US2[US2: Coverage guard]
|
||||
US2 --> US3[US3: UI context]
|
||||
US2 --> P6[Phase 6: Polish]
|
||||
US3 --> P6
|
||||
```
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)** → blocks nothing, but should be done first.
|
||||
- **Foundational (Phase 2)** → BLOCKS all user stories.
|
||||
- **US1 / US2 / US3** → can start after Foundational; in practice US1 then US2 reduces merge conflicts in `app/Jobs/CompareBaselineToTenantJob.php`.
|
||||
- **Polish (Phase 6)** → after US1/US2 at minimum.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Depends on Phase 2.
|
||||
- **US2 (P1)**: Depends on Phase 2; touches compare + inventory sync. Strongly recommended after US1 to keep compare changes coherent.
|
||||
- **US3 (P2)**: Depends on US2 (needs coverage context) and Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Tests can be updated in parallel:
|
||||
Task: "Update capture tests" (tests/Feature/Baselines/BaselineCaptureTest.php)
|
||||
Task: "Update compare findings tests" (tests/Feature/Baselines/BaselineCompareFindingsTest.php)
|
||||
Task: "Update compare preconditions tests" (tests/Feature/Baselines/BaselineComparePreconditionsTest.php)
|
||||
Task: "Update compare-start auth tests" (tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php)
|
||||
Task: "Add capture-start auth tests" (tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php)
|
||||
|
||||
# Implementation can be split with care (different files):
|
||||
Task: "Implement InventoryMetaContract" (app/Services/Baselines/InventoryMetaContract.php)
|
||||
Task: "Update CaptureBaselineSnapshotJob hashing" (app/Jobs/CaptureBaselineSnapshotJob.php)
|
||||
Task: "Update CompareBaselineToTenantJob identity + hashing" (app/Jobs/CompareBaselineToTenantJob.php)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Tests can be updated in parallel:
|
||||
Task: "Assert inventory sync coverage is written" (tests/Feature/Inventory/InventorySyncStartSurfaceTest.php)
|
||||
Task: "Add coverage-guard regression test" (tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php)
|
||||
|
||||
# Implementation can be split (different files):
|
||||
Task: "Write coverage payload to sync run context" (app/Services/Inventory/InventorySyncService.php)
|
||||
Task: "Implement coverage parser helper" (app/Support/Inventory/InventoryCoverage.php)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Tests can be updated in parallel:
|
||||
Task: "Update landing test for coverage/fidelity state" (tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php)
|
||||
Task: "Update drift landing comparison-info test" (tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php)
|
||||
|
||||
# Implementation can be split (different files):
|
||||
Task: "Extend stats DTO + wiring" (app/Support/Baselines/BaselineCompareStats.php)
|
||||
Task: "Render landing banner/badges" (resources/views/filament/pages/baseline-compare-landing.blade.php)
|
||||
Task: "Add findings list banner" (app/Filament/Resources/FindingResource/Pages/ListFindings.php)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- **MVP**: US1 (stable capture/compare + stable findings).
|
||||
- **Trust-critical follow-up**: US2 (coverage guard) is also P1 in the spec and should typically ship immediately after MVP.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 + Phase 2
|
||||
2. US1 → validate stable identities + contract hashing
|
||||
3. US2 → validate coverage guard + partial outcome semantics
|
||||
4. US3 → validate operator clarity (badges/banners)
|
||||
5. Phase 6 → ensure operability + guards + formatting
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = parallelizable (different files, minimal dependency)
|
||||
- All tasks include explicit file targets for fast handoff
|
||||
- Destructive actions already require confirmation (Filament actions use `->requiresConfirmation()`); keep that invariant when editing UI surfaces
|
||||
- Spec 116 includes a v2 section for future work; v2 requirements are explicitly deferred and are not covered by this tasks list
|
||||
@ -8,8 +8,11 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
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\Baselines\BaselineProfileStatus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// --- T031: Capture enqueue + precondition tests ---
|
||||
@ -21,7 +24,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
/** @var BaselineCaptureService $service */
|
||||
@ -40,6 +43,13 @@
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect($context['baseline_profile_id'])->toBe((int) $profile->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);
|
||||
});
|
||||
@ -51,7 +61,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'status' => BaselineProfile::STATUS_DRAFT,
|
||||
'status' => BaselineProfileStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$service = app(BaselineCaptureService::class);
|
||||
@ -111,7 +121,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$service = app(BaselineCaptureService::class);
|
||||
@ -133,14 +143,29 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'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(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-a',
|
||||
'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);
|
||||
@ -151,7 +176,7 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
@ -159,6 +184,7 @@
|
||||
$job = new CaptureBaselineSnapshotJob($run);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
@ -178,6 +204,39 @@
|
||||
expect($snapshot)->not->toBeNull();
|
||||
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();
|
||||
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||
});
|
||||
@ -187,7 +246,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->count(2)->create([
|
||||
@ -199,6 +258,7 @@
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$idService = app(BaselineSnapshotIdentity::class);
|
||||
$metaContract = app(InventoryMetaContract::class);
|
||||
$auditLogger = app(AuditLogger::class);
|
||||
|
||||
$run1 = $opService->ensureRunWithIdentity(
|
||||
@ -208,13 +268,13 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job1 = new CaptureBaselineSnapshotJob($run1);
|
||||
$job1->handle($idService, $auditLogger, $opService);
|
||||
$job1->handle($idService, $metaContract, $auditLogger, $opService);
|
||||
|
||||
$snapshotCountAfterFirst = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
@ -230,16 +290,16 @@
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp),
|
||||
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
$job2 = new CaptureBaselineSnapshotJob($run2);
|
||||
$job2->handle($idService, $auditLogger, $opService);
|
||||
$job2->handle($idService, $metaContract, $auditLogger, $opService);
|
||||
|
||||
$snapshotCountAfterSecond = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
@ -255,7 +315,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']],
|
||||
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -266,7 +326,7 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['nonExistentPolicyType']],
|
||||
'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
@ -274,6 +334,7 @@
|
||||
$job = new CaptureBaselineSnapshotJob($run);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
@ -299,7 +360,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => []],
|
||||
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -311,7 +372,14 @@
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'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);
|
||||
@ -322,7 +390,7 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => []],
|
||||
'effective_scope' => ['policy_types' => [], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
@ -330,6 +398,7 @@
|
||||
$job = new CaptureBaselineSnapshotJob($run);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
354
tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php
Normal file
354
tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php
Normal 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);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -9,13 +10,13 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\WorkspaceSetting;
|
||||
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\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationSummaryKeys;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
@ -26,7 +27,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
@ -36,6 +37,11 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
// Baseline has policyA and policyB
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
@ -62,6 +68,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Policy A modified',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -70,6 +78,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['new_policy' => true],
|
||||
'display_name' => 'Policy C unexpected',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -80,14 +90,13 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run);
|
||||
$job->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -97,6 +106,14 @@
|
||||
expect($run->status)->toBe('completed');
|
||||
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();
|
||||
|
||||
$findings = Finding::query()
|
||||
@ -107,6 +124,7 @@
|
||||
|
||||
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
|
||||
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.
|
||||
expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count());
|
||||
@ -134,6 +152,11 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
@ -158,6 +181,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Policy A modified',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -181,7 +206,6 @@
|
||||
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -222,6 +246,26 @@
|
||||
|
||||
$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([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
@ -231,19 +275,6 @@
|
||||
'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.
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -256,19 +287,6 @@
|
||||
'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);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
@ -283,7 +301,6 @@
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -305,12 +322,12 @@
|
||||
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');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
@ -320,19 +337,42 @@
|
||||
|
||||
$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([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-x-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-content'),
|
||||
'baseline_hash' => $hasher->hashNormalized($baselineContract),
|
||||
'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);
|
||||
|
||||
// First run
|
||||
$run1 = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
@ -340,31 +380,54 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job1 = new CompareBaselineToTenantJob($run1);
|
||||
$job1->handle(
|
||||
app(DriftHasher::class),
|
||||
$job = new CompareBaselineToTenantJob($run1);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
$countAfterFirst = Finding::query()
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->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
|
||||
// Mark first run as completed so ensureRunWithIdentity creates a new one
|
||||
$run1->update(['status' => 'completed', 'completed_at' => now()]);
|
||||
$fingerprint = (string) $finding->fingerprint;
|
||||
$currentHash1 = (string) ($finding->evidence_jsonb['current_hash'] ?? '');
|
||||
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(
|
||||
tenant: $tenant,
|
||||
@ -373,27 +436,139 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job2 = new CompareBaselineToTenantJob($run2);
|
||||
$job2->handle(
|
||||
app(DriftHasher::class),
|
||||
(new CompareBaselineToTenantJob($run2))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$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('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->count();
|
||||
->orderBy('id')
|
||||
->firstOrFail()
|
||||
->fingerprint;
|
||||
|
||||
// Same fingerprint → same finding updated, not duplicated
|
||||
expect($countAfterSecond)->toBe(1);
|
||||
$snapshot2 = BaselineSnapshot::factory()->create([
|
||||
'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 () {
|
||||
@ -401,7 +576,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
@ -411,10 +586,26 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
// 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);
|
||||
$contentHash = $driftHasher->hashNormalized($metaContent);
|
||||
$contentHash = $driftHasher->hashNormalized($builder->build(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectExternalId: 'matching-uuid',
|
||||
metaJsonb: $metaContent,
|
||||
));
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
@ -433,6 +624,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => $metaContent,
|
||||
'display_name' => 'Matching Policy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -443,14 +636,13 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run);
|
||||
$job->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -476,7 +668,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
@ -486,9 +678,25 @@
|
||||
|
||||
$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);
|
||||
$contentHash = $driftHasher->hashNormalized($metaContent);
|
||||
$contentHash = $driftHasher->hashNormalized($builder->build(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectExternalId: 'matching-uuid',
|
||||
metaJsonb: $metaContent,
|
||||
));
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
@ -515,6 +723,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => $metaContent,
|
||||
'display_name' => 'Matching Policy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -524,6 +734,8 @@
|
||||
'policy_type' => 'notificationMessageTemplate',
|
||||
'meta_jsonb' => ['some' => 'value'],
|
||||
'display_name' => 'Foundation Template',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -534,13 +746,12 @@
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -578,6 +789,11 @@
|
||||
|
||||
$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)
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
@ -604,6 +820,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['modified_content' => true],
|
||||
'display_name' => 'Changed Policy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -612,6 +830,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['extra_content' => true],
|
||||
'display_name' => 'Extra Policy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -629,7 +849,6 @@
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run);
|
||||
$job->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -659,6 +878,11 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
// One missing policy
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
@ -683,7 +907,6 @@
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run);
|
||||
$job->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
@ -715,6 +938,11 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
@ -739,7 +967,6 @@
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($firstRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
@ -771,7 +998,6 @@
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($secondRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
@ -799,6 +1025,11 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
@ -823,7 +1054,6 @@
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($firstRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
@ -854,7 +1084,6 @@
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($secondRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
@ -892,6 +1121,11 @@
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
@ -916,6 +1150,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Different Policy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -924,6 +1160,8 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['unexpected' => true],
|
||||
'display_name' => 'Unexpected Policy',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
@ -940,7 +1178,6 @@
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
@ -956,3 +1193,88 @@
|
||||
expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW);
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
102
tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php
Normal file
102
tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php
Normal 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(');
|
||||
});
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -34,7 +35,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'status' => BaselineProfile::STATUS_DRAFT,
|
||||
'status' => BaselineProfileStatus::Draft->value,
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::create([
|
||||
@ -111,7 +112,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
@ -145,6 +146,48 @@
|
||||
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 ---
|
||||
|
||||
it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () {
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -46,6 +45,11 @@ function runBaselineCompareForSnapshot(
|
||||
BaselineProfile $profile,
|
||||
BaselineSnapshot $snapshot,
|
||||
): OperationRun {
|
||||
createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
|
||||
$run = $operationRuns->ensureRunWithIdentity(
|
||||
@ -62,7 +66,6 @@ function runBaselineCompareForSnapshot(
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run);
|
||||
$job->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
|
||||
73
tests/Feature/Baselines/BaselineProfileArchiveActionTest.php
Normal file
73
tests/Feature/Baselines/BaselineProfileArchiveActionTest.php
Normal 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]);
|
||||
});
|
||||
@ -1,6 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -30,3 +37,68 @@
|
||||
->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString())
|
||||
->assertSet('currentFinishedAt', $current->finished_at->toDateTimeString());
|
||||
});
|
||||
|
||||
test('drift landing exposes baseline compare coverage + fidelity context when available', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-landing-comparison-info');
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$compareRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => [],
|
||||
'uncovered_types' => ['deviceConfiguration'],
|
||||
'proof' => false,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
->assertSet('baselineCompareRunId', (int) $compareRun->getKey())
|
||||
->assertSet('baselineCompareCoverageStatus', 'unproven')
|
||||
->assertSet('baselineCompareFidelity', 'meta')
|
||||
->assertSet('baselineCompareUncoveredTypesCount', 1);
|
||||
});
|
||||
|
||||
@ -7,11 +7,65 @@
|
||||
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 App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
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 {
|
||||
Queue::fake();
|
||||
|
||||
@ -65,3 +119,112 @@
|
||||
->call('refreshStats')
|
||||
->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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
@ -10,6 +11,7 @@
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -18,6 +20,7 @@
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
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 {
|
||||
$profiles = new ActionSurfaceProfileDefinition;
|
||||
|
||||
|
||||
24
tests/Feature/Guards/Spec116OneEngineGuardTest.php
Normal file
24
tests/Feature/Guards/Spec116OneEngineGuardTest.php
Normal 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(');
|
||||
});
|
||||
@ -29,10 +29,14 @@
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$sync = app(InventorySyncService::class);
|
||||
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
|
||||
$policyTypes = array_slice($sync->defaultSelectionPayload()['policy_types'] ?? [], 0, 2);
|
||||
|
||||
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()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
@ -49,10 +53,52 @@
|
||||
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||
->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()
|
||||
&& $job->userId === (int) $user->getKey()
|
||||
&& $job->operationRun instanceof OperationRun
|
||||
&& (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);
|
||||
});
|
||||
|
||||
@ -200,6 +200,41 @@ function createInventorySyncOperationRun(Tenant $tenant, array $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}
|
||||
*/
|
||||
|
||||
49
tests/Unit/Baselines/BaselineScopeTest.php
Normal file
49
tests/Unit/Baselines/BaselineScopeTest.php
Normal 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']);
|
||||
});
|
||||
59
tests/Unit/Baselines/InventoryMetaContractTest.php
Normal file
59
tests/Unit/Baselines/InventoryMetaContractTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user