Spec 118: Resumable baseline evidence capture + snapshot UX (#143)
Implements Spec 118 baseline drift engine improvements: - Resumable, budget-aware evidence capture for baseline capture/compare runs (resume token + UI action) - “Why no findings?” reason-code driven explanations and richer run context panels - Baseline Snapshot resource (list/detail) with fidelity visibility - Retention command + schedule for pruning baseline-purpose PolicyVersions - i18n strings for Baseline Compare landing Verification: - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact --filter=Baseline` (159 passed) Note: - `docs/audits/redaction-audit-2026-03-04.md` left untracked (not part of PR). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #143
This commit is contained in:
parent
f08924525d
commit
92704a2f7e
@ -73,3 +73,10 @@ ENTRA_AUTHORITY_TENANT=organizations
|
||||
# System panel break-glass (Platform Operators)
|
||||
BREAK_GLASS_ENABLED=false
|
||||
BREAK_GLASS_TTL_MINUTES=60
|
||||
|
||||
# Baselines (Spec 118: full-content drift detection)
|
||||
TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3
|
||||
TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneBaselineEvidencePolicyVersionsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'tenantpilot:baseline-evidence:prune {--days= : Number of days to retain baseline evidence policy versions}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Soft-delete baseline-capture/baseline-compare policy versions older than the configured retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: config('tenantpilot.baselines.full_content_capture.retention_days', 90));
|
||||
|
||||
if ($days < 1) {
|
||||
$this->error('Retention days must be at least 1.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = PolicyVersion::query()
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('capture_purpose', [
|
||||
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
])
|
||||
->where('captured_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} baseline evidence policy version(s) older than {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -43,12 +44,18 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?string $reasonCode = null;
|
||||
|
||||
public ?string $reasonMessage = null;
|
||||
|
||||
public ?string $profileName = null;
|
||||
|
||||
public ?int $profileId = null;
|
||||
|
||||
public ?int $snapshotId = null;
|
||||
|
||||
public ?int $duplicateNamePoliciesCount = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
@ -71,6 +78,11 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?string $fidelity = null;
|
||||
|
||||
public ?int $evidenceGapsCount = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $evidenceGapsTopReasons = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -104,17 +116,118 @@ public function refreshStats(): void
|
||||
$this->profileName = $stats->profileName;
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||
$this->lastComparedIso = $stats->lastComparedIso;
|
||||
$this->failureReason = $stats->failureReason;
|
||||
$this->reasonCode = $stats->reasonCode;
|
||||
$this->reasonMessage = $stats->reasonMessage;
|
||||
|
||||
$this->coverageStatus = $stats->coverageStatus;
|
||||
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
|
||||
$this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null;
|
||||
$this->fidelity = $stats->fidelity;
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed view data exposed to the Blade template.
|
||||
*
|
||||
* Moves presentational logic out of Blade `@php` blocks so the
|
||||
* template only receives ready-to-render values.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
$evidenceGapsTooltip = __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the colour class for the findings-count stat card.
|
||||
// Only show danger-red when high-severity findings exist;
|
||||
// use warning-orange for low/medium-only, and success-green for zero.
|
||||
$findingsColorClass = $this->resolveFindingsColorClass($hasWarnings);
|
||||
|
||||
// "Why no findings" explanation when count is zero.
|
||||
$whyNoFindingsMessage = filled($this->reasonMessage) ? (string) $this->reasonMessage : null;
|
||||
$whyNoFindingsFallback = ! $hasWarnings
|
||||
? __('baseline-compare.no_findings_all_clear')
|
||||
: ($hasCoverageWarnings
|
||||
? __('baseline-compare.no_findings_coverage_warnings')
|
||||
: ($hasEvidenceGaps
|
||||
? __('baseline-compare.no_findings_evidence_gaps')
|
||||
: __('baseline-compare.no_findings_default')));
|
||||
$whyNoFindingsColor = $hasWarnings
|
||||
? 'text-warning-600 dark:text-warning-400'
|
||||
: 'text-success-600 dark:text-success-400';
|
||||
|
||||
if ($this->reasonCode === 'no_subjects_in_scope') {
|
||||
$whyNoFindingsColor = 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
|
||||
return [
|
||||
'hasCoverageWarnings' => $hasCoverageWarnings,
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Tailwind colour class for the Total Findings stat.
|
||||
*
|
||||
* - Red (danger) only when high-severity findings exist
|
||||
* - Orange (warning) for medium/low-only findings or when warnings present
|
||||
* - Green (success) when fully clear
|
||||
*/
|
||||
private function resolveFindingsColorClass(bool $hasWarnings): string
|
||||
{
|
||||
$count = (int) ($this->findingsCount ?? 0);
|
||||
|
||||
if ($count === 0) {
|
||||
return $hasWarnings
|
||||
? 'text-warning-600 dark:text-warning-400'
|
||||
: 'text-success-600 dark:text-success-400';
|
||||
}
|
||||
|
||||
$hasHigh = ($this->severityCounts['high'] ?? 0) > 0;
|
||||
|
||||
return $hasHigh
|
||||
? 'text-danger-600 dark:text-danger-400'
|
||||
: 'text-warning-600 dark:text-warning-400';
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
@ -140,12 +253,28 @@ protected function getHeaderActions(): array
|
||||
|
||||
private function compareNowAction(): Action
|
||||
{
|
||||
$isFullContent = false;
|
||||
|
||||
if (is_int($this->profileId) && $this->profileId > 0) {
|
||||
$profile = \App\Models\BaselineProfile::query()->find($this->profileId);
|
||||
$mode = $profile?->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: (is_string($profile?->capture_mode) ? BaselineCaptureMode::tryFrom($profile->capture_mode) : null);
|
||||
|
||||
$isFullContent = $mode === BaselineCaptureMode::FullContent;
|
||||
}
|
||||
|
||||
$label = $isFullContent ? 'Compare now (full content)' : 'Compare now';
|
||||
$modalDescription = $isFullContent
|
||||
? 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.'
|
||||
: 'This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.';
|
||||
|
||||
$action = Action::make('compareNow')
|
||||
->label('Compare Now')
|
||||
->label($label)
|
||||
->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.')
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -9,10 +9,16 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -105,6 +111,8 @@ protected function getHeaderActions(): array
|
||||
->color('gray');
|
||||
}
|
||||
|
||||
$actions[] = $this->resumeCaptureAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
@ -139,4 +147,120 @@ public function content(Schema $schema): Schema
|
||||
EmbeddedSchema::make('infolist'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resumeCaptureAction(): Action
|
||||
{
|
||||
return Action::make('resumeCapture')
|
||||
->label('Resume capture')
|
||||
->icon('heroicon-o-forward')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Resume capture')
|
||||
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
||||
->visible(fn (): bool => $this->canResumeCapture())
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! isset($this->run)) {
|
||||
Notification::make()
|
||||
->title('Run not loaded')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineEvidenceCaptureResumeService::class);
|
||||
$result = $service->resume($this->run, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reason = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot resume capture')
|
||||
->body('Reason: '.str_replace('.', ' ', $reason))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot resume capture')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView($run));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
private function canResumeCapture(): bool
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((string) $this->run->status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array((string) $this->run->type, ['baseline_capture', 'baseline_compare'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$tokenKey = (string) $this->run->type === 'baseline_capture'
|
||||
? 'baseline_capture.resume_token'
|
||||
: 'baseline_compare.resume_token';
|
||||
$token = data_get($context, $tokenKey);
|
||||
|
||||
if (! is_string($token) || trim($token) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $this->run->workspace;
|
||||
|
||||
if (! $workspace instanceof \App\Models\Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,9 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -173,6 +175,23 @@ public static function form(Schema $schema): Schema
|
||||
BaselineProfileStatus::Active => 'Changing status to Archived is permanent.',
|
||||
default => 'Only active baselines are enforced during compliance checks.',
|
||||
}),
|
||||
Select::make('capture_mode')
|
||||
->label('Capture mode')
|
||||
->required()
|
||||
->options(BaselineCaptureMode::selectOptions())
|
||||
->default(BaselineCaptureMode::Opportunistic->value)
|
||||
->native(false)
|
||||
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
|
||||
->disableOptionWhen(function (string $value): bool {
|
||||
if ($value !== BaselineCaptureMode::FullContent->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! app(BaselineFullContentRolloutGate::class)->enabled();
|
||||
})
|
||||
->helperText(fn (): string => app(BaselineFullContentRolloutGate::class)->enabled()
|
||||
? 'Full content capture enables deep drift detection by capturing policy evidence on demand.'
|
||||
: 'Full content capture is currently disabled by rollout configuration.'),
|
||||
TextInput::make('version_label')
|
||||
->label('Version label')
|
||||
->maxLength(50)
|
||||
@ -213,6 +232,30 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
|
||||
TextEntry::make('capture_mode')
|
||||
->label('Capture mode')
|
||||
->badge()
|
||||
->formatStateUsing(function (mixed $state): string {
|
||||
if ($state instanceof BaselineCaptureMode) {
|
||||
return $state->label();
|
||||
}
|
||||
|
||||
$parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null;
|
||||
|
||||
return $parsed?->label() ?? (is_string($state) ? $state : '—');
|
||||
})
|
||||
->color(function (mixed $state): string {
|
||||
$mode = $state instanceof BaselineCaptureMode
|
||||
? $state
|
||||
: (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null);
|
||||
|
||||
return match ($mode) {
|
||||
BaselineCaptureMode::FullContent => 'success',
|
||||
BaselineCaptureMode::Opportunistic => 'warning',
|
||||
BaselineCaptureMode::MetaOnly => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}),
|
||||
TextEntry::make('version_label')
|
||||
->label('Version')
|
||||
->placeholder('—'),
|
||||
@ -279,6 +322,31 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
|
||||
->sortable(),
|
||||
TextColumn::make('capture_mode')
|
||||
->label('Capture mode')
|
||||
->badge()
|
||||
->formatStateUsing(function (mixed $state): string {
|
||||
if ($state instanceof BaselineCaptureMode) {
|
||||
return $state->label();
|
||||
}
|
||||
|
||||
$parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null;
|
||||
|
||||
return $parsed?->label() ?? (is_string($state) ? $state : '—');
|
||||
})
|
||||
->color(function (mixed $state): string {
|
||||
$mode = $state instanceof BaselineCaptureMode
|
||||
? $state
|
||||
: (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null);
|
||||
|
||||
return match ($mode) {
|
||||
BaselineCaptureMode::FullContent => 'success',
|
||||
BaselineCaptureMode::Opportunistic => 'warning',
|
||||
BaselineCaptureMode::MetaOnly => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
})
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('version_label')
|
||||
->label('Version')
|
||||
->placeholder('—'),
|
||||
|
||||
@ -6,11 +6,16 @@
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -30,6 +35,7 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->captureAction(),
|
||||
$this->compareNowAction(),
|
||||
EditAction::make()
|
||||
->visible(fn (): bool => $this->hasManageCapability()),
|
||||
];
|
||||
@ -37,13 +43,28 @@ protected function getHeaderActions(): array
|
||||
|
||||
private function captureAction(): Action
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
$label = $captureMode === BaselineCaptureMode::FullContent
|
||||
? 'Capture baseline (full content)'
|
||||
: 'Capture baseline';
|
||||
|
||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||
? 'Select the source tenant. This will capture content evidence on demand (redacted) and may take longer depending on scope.'
|
||||
: 'Select the source tenant whose current inventory will be captured as the baseline snapshot.';
|
||||
|
||||
$action = Action::make('capture')
|
||||
->label('Capture Snapshot')
|
||||
->label($label)
|
||||
->icon('heroicon-o-camera')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Capture Baseline Snapshot')
|
||||
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
->form([
|
||||
Select::make('source_tenant_id')
|
||||
->label('Source Tenant')
|
||||
@ -75,9 +96,18 @@ private function captureAction(): Action
|
||||
$result = $service->startCapture($profile, $sourceTenant, $user);
|
||||
|
||||
if (! $result['ok']) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start capture')
|
||||
->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
|
||||
->body($message)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -124,6 +154,141 @@ private function captureAction(): Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function compareNowAction(): Action
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
$label = $captureMode === BaselineCaptureMode::FullContent
|
||||
? 'Compare now (full content)'
|
||||
: 'Compare now';
|
||||
|
||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
||||
|
||||
return Action::make('compareNow')
|
||||
->label($label)
|
||||
->icon('heroicon-o-play')
|
||||
->requiresConfirmation()
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
->form([
|
||||
Select::make('target_tenant_id')
|
||||
->label('Target Tenant')
|
||||
->options(fn (): array => $this->getEligibleCompareTenantOptions())
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||
->action(function (array $data): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
$targetTenant = Tenant::query()->find((int) $data['target_tenant_id']);
|
||||
|
||||
if (! $targetTenant instanceof Tenant || (int) $targetTenant->workspace_id !== (int) $profile->workspace_id) {
|
||||
Notification::make()
|
||||
->title('Target tenant not found')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('tenant_id', (int) $targetTenant->getKey())
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->first();
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||
Notification::make()
|
||||
->title('Tenant not assigned')
|
||||
->body('This tenant is not assigned to this baseline profile.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_SYNC)) {
|
||||
Notification::make()
|
||||
->title('Permission denied')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($targetTenant, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
->body($message)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof \App\Models\OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $targetTenant));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@ -142,6 +307,55 @@ private function getWorkspaceTenantOptions(): array
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getEligibleCompareTenantOptions(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
$tenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$options = [];
|
||||
|
||||
$tenants = Tenant::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('id', $tenantIds)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
if (! $tenant instanceof Tenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[(int) $tenant->getKey()] = (string) $tenant->name;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function hasManageCapability(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
275
app/Filament/Resources/BaselineSnapshotResource.php
Normal file
275
app/Filament/Resources/BaselineSnapshotResource.php
Normal file
@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class BaselineSnapshotResource extends Resource
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = BaselineSnapshot::class;
|
||||
|
||||
protected static ?string $slug = 'baseline-snapshots';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-camera';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Baseline Snapshots';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = self::resolveWorkspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
return self::canViewAny();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; no row actions besides view.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('baselineProfile')
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('captured_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->sortable(),
|
||||
TextColumn::make('baselineProfile.name')
|
||||
->label('Baseline')
|
||||
->wrap()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('captured_at')
|
||||
->label('Captured')
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(static fn (?int $state): string => $state ? '#'.$state : '—'),
|
||||
TextEntry::make('baselineProfile.name')
|
||||
->label('Baseline'),
|
||||
TextEntry::make('captured_at')
|
||||
->label('Captured')
|
||||
->dateTime(),
|
||||
TextEntry::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
TextEntry::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)),
|
||||
TextEntry::make('evidence_gaps')
|
||||
->label('Evidence gaps')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): int => self::gapsCount($record)),
|
||||
TextEntry::make('snapshot_identity_hash')
|
||||
->label('Identity hash')
|
||||
->copyable()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Summary')
|
||||
->schema([
|
||||
ViewEntry::make('summary_jsonb')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(static fn (BaselineSnapshot $record): array => is_array($record->summary_jsonb) ? $record->summary_jsonb : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBaselineSnapshots::route('/'),
|
||||
'view' => Pages\ViewBaselineSnapshot::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveWorkspace(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if ($workspaceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
private static function summary(BaselineSnapshot $snapshot): array
|
||||
{
|
||||
return is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||
}
|
||||
|
||||
private static function fidelityCounts(BaselineSnapshot $snapshot): array
|
||||
{
|
||||
$summary = self::summary($snapshot);
|
||||
$counts = $summary['fidelity_counts'] ?? null;
|
||||
$counts = is_array($counts) ? $counts : [];
|
||||
|
||||
$content = $counts['content'] ?? 0;
|
||||
$meta = $counts['meta'] ?? 0;
|
||||
|
||||
return [
|
||||
'content' => is_numeric($content) ? (int) $content : 0,
|
||||
'meta' => is_numeric($meta) ? (int) $meta : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$counts = self::fidelityCounts($snapshot);
|
||||
|
||||
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
||||
}
|
||||
|
||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
{
|
||||
$summary = self::summary($snapshot);
|
||||
$gaps = $summary['gaps'] ?? null;
|
||||
$gaps = is_array($gaps) ? $gaps : [];
|
||||
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
return is_numeric($count) ? (int) $count : 0;
|
||||
}
|
||||
|
||||
private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
{
|
||||
return self::gapsCount($snapshot) > 0;
|
||||
}
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBaselineSnapshots extends ListRecords
|
||||
{
|
||||
protected static string $resource = BaselineSnapshotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BaselineSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBaselineSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BaselineSnapshotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -144,7 +144,20 @@ public static function infolist(Schema $schema): Schema
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
||||
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
||||
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||
TextEntry::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->state(function (Finding $record): ?string {
|
||||
$state = $record->subject_display_name;
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}),
|
||||
TextEntry::make('subject_type')->label('Subject type'),
|
||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||
TextEntry::make('baseline_operation_run_id')
|
||||
@ -372,7 +385,19 @@ public static function table(Table $table): Table
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('subject_display_name')
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->formatStateUsing(function (?string $state, Finding $record): ?string {
|
||||
if (is_string($state) && trim($state) !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
|
||||
Tables\Columns\TextColumn::make('due_at')
|
||||
->label('Due')
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -218,6 +219,30 @@ public static function infolist(Schema $schema): Schema
|
||||
'warnings', 'unproven' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextEntry::make('baseline_compare_why_no_findings')
|
||||
->label('Why no findings')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$code = data_get($context, 'baseline_compare.reason_code');
|
||||
$code = is_string($code) ? trim($code) : null;
|
||||
$code = $code !== '' ? $code : null;
|
||||
|
||||
if ($code === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enum = BaselineCompareReasonCode::tryFrom($code);
|
||||
$message = $enum?->message();
|
||||
|
||||
return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : '');
|
||||
})
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$code = data_get($context, 'baseline_compare.reason_code');
|
||||
|
||||
return is_string($code) && trim($code) !== '';
|
||||
})
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('baseline_compare_uncovered_types')
|
||||
->label('Uncovered types')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
@ -259,6 +284,130 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Baseline compare evidence')
|
||||
->schema([
|
||||
TextEntry::make('baseline_compare_subjects_total')
|
||||
->label('Subjects total')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.subjects_total');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_compare_gap_count')
|
||||
->label('Evidence gaps')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.evidence_gaps.count');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_compare_resume_token')
|
||||
->label('Resume token')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
})
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull()
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '';
|
||||
}),
|
||||
ViewEntry::make('baseline_compare_evidence_capture')
|
||||
->label('Evidence capture')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.evidence_capture');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('baseline_compare_evidence_gaps')
|
||||
->label('Evidence gaps')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.evidence_gaps');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Baseline capture evidence')
|
||||
->schema([
|
||||
TextEntry::make('baseline_capture_subjects_total')
|
||||
->label('Subjects total')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.subjects_total');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_capture_gap_count')
|
||||
->label('Gaps')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.gaps.count');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_capture_resume_token')
|
||||
->label('Resume token')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
})
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull()
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '';
|
||||
}),
|
||||
ViewEntry::make('baseline_capture_evidence_capture')
|
||||
->label('Evidence capture')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.evidence_capture');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('baseline_capture_gaps')
|
||||
->label('Gaps')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.gaps');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_capture')
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Verification report')
|
||||
->schema([
|
||||
ViewEntry::make('verification_report')
|
||||
|
||||
@ -22,14 +22,13 @@ class ListPolicies extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->makeSyncAction()
|
||||
->visible(fn (): bool => $this->getFilteredTableQuery()->exists()),
|
||||
$this->makeSyncAction(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [$this->makeSyncAction('syncEmpty')];
|
||||
return [$this->makeSyncAction()];
|
||||
}
|
||||
|
||||
private function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -825,10 +826,29 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenant = Tenant::currentOrFail();
|
||||
$tenantId = $tenant->getKey();
|
||||
$user = auth()->user();
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$canSeeBaselinePurposeEvidence = $user instanceof User
|
||||
&& (
|
||||
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|
||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||
);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereNull('capture_purpose')
|
||||
->orWhereNotIn('capture_purpose', [
|
||||
PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->with('policy');
|
||||
}
|
||||
|
||||
|
||||
@ -10,14 +10,19 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@ -53,8 +58,12 @@ public function handle(
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
$this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.'));
|
||||
@ -84,107 +93,69 @@ public function handle(
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
|
||||
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $metaContract, $hashResolver);
|
||||
|
||||
$identityHash = $identity->computeIdentity($snapshotItems);
|
||||
|
||||
$snapshot = $this->findOrCreateSnapshot(
|
||||
$profile,
|
||||
$identityHash,
|
||||
$snapshotItems,
|
||||
);
|
||||
|
||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||
|
||||
if ($profile->status === BaselineProfileStatus::Active) {
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
$rolloutGate->assertEnabled();
|
||||
}
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($snapshotItems),
|
||||
'processed' => count($snapshotItems),
|
||||
'succeeded' => count($snapshotItems),
|
||||
'failed' => 0,
|
||||
];
|
||||
$inventoryResult = $this->collectInventorySubjects($sourceTenant, $effectiveScope);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
$subjects = $inventoryResult['subjects'];
|
||||
$inventoryByKey = $inventoryResult['inventory_by_key'];
|
||||
$subjectsTotal = $inventoryResult['subjects_total'];
|
||||
$captureGaps = $inventoryResult['gaps'];
|
||||
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $sourceTenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: $subjectsTotal,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$updatedContext['result'] = [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => count($snapshotItems),
|
||||
$phaseStats = [
|
||||
'requested' => 0,
|
||||
'succeeded' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
$phaseGaps = [];
|
||||
$resumeToken = null;
|
||||
|
||||
$this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems);
|
||||
}
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
$budgets = [
|
||||
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
||||
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
||||
'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3),
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
|
||||
*/
|
||||
private function collectSnapshotItems(
|
||||
Tenant $sourceTenant,
|
||||
BaselineScope $scope,
|
||||
InventoryMetaContract $metaContract,
|
||||
CurrentStateHashResolver $hashResolver,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
$resumeTokenIn = null;
|
||||
|
||||
$query->whereIn('policy_type', $scope->allTypes());
|
||||
if (is_array($context['baseline_capture'] ?? null)) {
|
||||
$resumeTokenIn = $context['baseline_capture']['resume_token'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<string, array{
|
||||
* subject_external_id: string,
|
||||
* policy_type: string,
|
||||
* display_name: ?string,
|
||||
* category: ?string,
|
||||
* platform: ?string,
|
||||
* meta_contract: array<string, mixed>
|
||||
* }>
|
||||
*/
|
||||
$inventoryByKey = [];
|
||||
$phaseResult = $contentCapturePhase->capture(
|
||||
tenant: $sourceTenant,
|
||||
subjects: $subjects,
|
||||
purpose: PolicyVersionCapturePurpose::BaselineCapture,
|
||||
budgets: $budgets,
|
||||
resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null,
|
||||
operationRunId: (int) $this->operationRun->getKey(),
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
createdBy: $initiator?->email,
|
||||
);
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, $metaContract): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||
$contract = $metaContract->build(
|
||||
policyType: (string) $inventoryItem->policy_type,
|
||||
subjectExternalId: (string) $inventoryItem->external_id,
|
||||
metaJsonb: $metaJsonb,
|
||||
);
|
||||
|
||||
$key = (string) $inventoryItem->policy_type.'|'.(string) $inventoryItem->external_id;
|
||||
|
||||
$inventoryByKey[$key] = [
|
||||
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||
'policy_type' => (string) $inventoryItem->policy_type,
|
||||
'display_name' => is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null,
|
||||
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
|
||||
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
|
||||
'meta_contract' => $contract,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$subjects = array_values(array_map(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['subject_external_id'],
|
||||
],
|
||||
$inventoryByKey,
|
||||
));
|
||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||
}
|
||||
|
||||
$resolvedEvidence = $hashResolver->resolveForSubjects(
|
||||
tenant: $sourceTenant,
|
||||
@ -193,40 +164,295 @@ private function collectSnapshotItems(
|
||||
latestInventorySyncRunId: null,
|
||||
);
|
||||
|
||||
$snapshotItems = $this->buildSnapshotItems(
|
||||
inventoryByKey: $inventoryByKey,
|
||||
resolvedEvidence: $resolvedEvidence,
|
||||
captureMode: $captureMode,
|
||||
gaps: $captureGaps,
|
||||
);
|
||||
|
||||
$items = $snapshotItems['items'] ?? [];
|
||||
|
||||
$identityHash = $identity->computeIdentity($items);
|
||||
|
||||
$gapsByReason = $this->mergeGapCounts($captureGaps, $phaseGaps);
|
||||
$gapsCount = array_sum($gapsByReason);
|
||||
|
||||
$snapshotSummary = [
|
||||
'total_items' => count($items),
|
||||
'policy_type_counts' => $this->countByPolicyType($items),
|
||||
'fidelity_counts' => $snapshotItems['fidelity_counts'] ?? ['content' => 0, 'meta' => 0],
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
],
|
||||
];
|
||||
|
||||
$snapshot = $this->findOrCreateSnapshot(
|
||||
$profile,
|
||||
$identityHash,
|
||||
$items,
|
||||
$snapshotSummary,
|
||||
);
|
||||
|
||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||
|
||||
if ($profile->status === BaselineProfileStatus::Active) {
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
}
|
||||
|
||||
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
|
||||
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
|
||||
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => $subjectsTotal,
|
||||
'processed' => $subjectsTotal,
|
||||
'succeeded' => $snapshotItems['items_count'],
|
||||
'failed' => max(0, $subjectsTotal - $snapshotItems['items_count']),
|
||||
];
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$updatedContext['baseline_capture'] = array_merge(
|
||||
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
|
||||
[
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'evidence_capture' => $phaseStats,
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
],
|
||||
);
|
||||
$updatedContext['result'] = [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $snapshotItems['items_count'],
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $sourceTenant,
|
||||
profile: $profile,
|
||||
snapshot: $snapshot,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: $subjectsTotal,
|
||||
wasNewSnapshot: $wasNewSnapshot,
|
||||
evidenceCaptureStats: $phaseStats,
|
||||
gaps: [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* subjects_total: int,
|
||||
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
||||
* inventory_by_key: array<string, array{
|
||||
* tenant_subject_external_id: string,
|
||||
* workspace_subject_external_id: string,
|
||||
* subject_key: string,
|
||||
* policy_type: string,
|
||||
* display_name: ?string,
|
||||
* category: ?string,
|
||||
* platform: ?string
|
||||
* }>,
|
||||
* gaps: array<string, int>
|
||||
* }
|
||||
*/
|
||||
private function collectInventorySubjects(
|
||||
Tenant $sourceTenant,
|
||||
BaselineScope $scope,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
|
||||
$query->whereIn('policy_type', $scope->allTypes());
|
||||
|
||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, display_name: ?string, category: ?string, platform: ?string}> $inventoryByKey */
|
||||
$inventoryByKey = [];
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
|
||||
/**
|
||||
* Ensure we only include unambiguous subjects when matching by subject_key (derived from display name).
|
||||
*
|
||||
* When multiple inventory items share the same "policy_type|subject_key" we cannot reliably map them
|
||||
* across tenants, so we treat them as an evidence gap and exclude them from the snapshot.
|
||||
*
|
||||
* @var array<string, true> $ambiguousKeys
|
||||
*/
|
||||
$ambiguousKeys = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string> $subjectKeyToInventoryKey
|
||||
*/
|
||||
$subjectKeyToInventoryKey = [];
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
|
||||
if ($subjectKey === null) {
|
||||
$gaps['missing_subject_key'] = ($gaps['missing_subject_key'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) $inventoryItem->policy_type;
|
||||
$logicalKey = $policyType.'|'.$subjectKey;
|
||||
|
||||
if (array_key_exists($logicalKey, $ambiguousKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($logicalKey, $subjectKeyToInventoryKey)) {
|
||||
$ambiguousKeys[$logicalKey] = true;
|
||||
|
||||
$previousKey = $subjectKeyToInventoryKey[$logicalKey];
|
||||
unset($subjectKeyToInventoryKey[$logicalKey], $inventoryByKey[$previousKey]);
|
||||
|
||||
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
);
|
||||
|
||||
$key = $policyType.'|'.(string) $inventoryItem->external_id;
|
||||
$subjectKeyToInventoryKey[$logicalKey] = $key;
|
||||
|
||||
$inventoryByKey[$key] = [
|
||||
'tenant_subject_external_id' => (string) $inventoryItem->external_id,
|
||||
'workspace_subject_external_id' => $workspaceSafeId,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'display_name' => $displayName,
|
||||
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
|
||||
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
ksort($gaps);
|
||||
|
||||
$subjects = array_values(array_map(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||
],
|
||||
$inventoryByKey,
|
||||
));
|
||||
|
||||
return [
|
||||
'subjects_total' => count($subjects),
|
||||
'subjects' => $subjects,
|
||||
'inventory_by_key' => $inventoryByKey,
|
||||
'gaps' => $gaps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{
|
||||
* tenant_subject_external_id: string,
|
||||
* workspace_subject_external_id: string,
|
||||
* subject_key: string,
|
||||
* policy_type: string,
|
||||
* display_name: ?string,
|
||||
* category: ?string,
|
||||
* platform: ?string,
|
||||
* }> $inventoryByKey
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedEvidence
|
||||
* @param array<string, int> $gaps
|
||||
* @return array{
|
||||
* items: array<int, array{
|
||||
* subject_type: string,
|
||||
* subject_external_id: string,
|
||||
* subject_key: string,
|
||||
* policy_type: string,
|
||||
* baseline_hash: string,
|
||||
* meta_jsonb: array<string, mixed>
|
||||
* }>,
|
||||
* items_count: int,
|
||||
* fidelity_counts: array{content: int, meta: int}
|
||||
* }
|
||||
*/
|
||||
private function buildSnapshotItems(
|
||||
array $inventoryByKey,
|
||||
array $resolvedEvidence,
|
||||
BaselineCaptureMode $captureMode,
|
||||
array &$gaps,
|
||||
): array {
|
||||
$items = [];
|
||||
$fidelityCounts = ['content' => 0, 'meta' => 0];
|
||||
|
||||
foreach ($inventoryByKey as $key => $inventoryItem) {
|
||||
$evidence = $resolvedEvidence[$key] ?? null;
|
||||
|
||||
if (! $evidence instanceof ResolvedEvidence) {
|
||||
$gaps['missing_evidence'] = ($gaps['missing_evidence'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$provenance = $evidence->provenance();
|
||||
unset($provenance['observed_operation_run_id']);
|
||||
|
||||
$fidelity = (string) ($provenance['fidelity'] ?? 'meta');
|
||||
$fidelityCounts[$fidelity === 'content' ? 'content' : 'meta']++;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent && $fidelity !== 'content') {
|
||||
$gaps['meta_fallback'] = ($gaps['meta_fallback'] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $inventoryItem['subject_external_id'],
|
||||
'subject_external_id' => (string) $inventoryItem['workspace_subject_external_id'],
|
||||
'subject_key' => (string) $inventoryItem['subject_key'],
|
||||
'policy_type' => (string) $inventoryItem['policy_type'],
|
||||
'baseline_hash' => $evidence->hash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $inventoryItem['display_name'],
|
||||
'category' => $inventoryItem['category'],
|
||||
'platform' => $inventoryItem['platform'],
|
||||
'meta_contract' => $inventoryItem['meta_contract'],
|
||||
'evidence' => $evidence->provenance(),
|
||||
'evidence' => $provenance,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
return [
|
||||
'items' => $items,
|
||||
'items_count' => count($items),
|
||||
'fidelity_counts' => $fidelityCounts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $snapshotItems
|
||||
*/
|
||||
private function findOrCreateSnapshot(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $snapshotItems,
|
||||
array $summaryJsonb,
|
||||
): BaselineSnapshot {
|
||||
$existing = BaselineSnapshot::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
@ -243,10 +469,7 @@ private function findOrCreateSnapshot(
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'captured_at' => now(),
|
||||
'summary_jsonb' => [
|
||||
'total_items' => count($snapshotItems),
|
||||
'policy_type_counts' => $this->countByPolicyType($snapshotItems),
|
||||
],
|
||||
'summary_jsonb' => $summaryJsonb,
|
||||
]);
|
||||
|
||||
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||
@ -255,6 +478,7 @@ private function findOrCreateSnapshot(
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => $item['subject_type'],
|
||||
'subject_external_id' => $item['subject_external_id'],
|
||||
'subject_key' => $item['subject_key'],
|
||||
'policy_type' => $item['policy_type'],
|
||||
'baseline_hash' => $item['baseline_hash'],
|
||||
'meta_jsonb' => json_encode($item['meta_jsonb']),
|
||||
@ -293,6 +517,9 @@ private function auditStarted(
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
?User $initiator,
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
BaselineScope $effectiveScope,
|
||||
): void {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -302,6 +529,10 @@ private function auditStarted(
|
||||
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'capture_mode' => $captureMode->value,
|
||||
'scope_types_total' => count($effectiveScope->allTypes()),
|
||||
'subjects_total' => $subjectsTotal,
|
||||
],
|
||||
],
|
||||
actorId: $initiator?->id,
|
||||
@ -318,7 +549,11 @@ private function auditCompleted(
|
||||
BaselineProfile $profile,
|
||||
BaselineSnapshot $snapshot,
|
||||
?User $initiator,
|
||||
array $snapshotItems,
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
bool $wasNewSnapshot,
|
||||
array $evidenceCaptureStats,
|
||||
array $gaps,
|
||||
): void {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -328,10 +563,14 @@ private function auditCompleted(
|
||||
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'capture_mode' => $captureMode->value,
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
|
||||
'items_captured' => count($snapshotItems),
|
||||
'was_new_snapshot' => $snapshot->wasRecentlyCreated,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'evidence_capture' => $evidenceCaptureStats,
|
||||
'gaps' => $gaps,
|
||||
],
|
||||
],
|
||||
actorId: $initiator?->id,
|
||||
@ -341,4 +580,27 @@ private function auditCompleted(
|
||||
resourceId: (string) $this->operationRun->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> ...$gaps
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function mergeGapCounts(array ...$gaps): array
|
||||
{
|
||||
$merged = [];
|
||||
|
||||
foreach ($gaps as $gapMap) {
|
||||
foreach ($gapMap as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$merged[$reason] = ($merged[$reason] ?? 0) + (int) $count;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($merged);
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||
@ -23,7 +24,12 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -64,11 +70,15 @@ public function handle(
|
||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
||||
@ -106,9 +116,61 @@ public function handle(
|
||||
$effectiveTypes = $effectiveScope->allTypes();
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
try {
|
||||
$rolloutGate->assertEnabled();
|
||||
} catch (RuntimeException) {
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$effectiveTypeCount = count($effectiveTypes);
|
||||
$gapCount = max(1, $effectiveTypeCount);
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
inventorySyncRun: null,
|
||||
coverageProof: false,
|
||||
effectiveTypes: $effectiveTypes,
|
||||
coveredTypes: [],
|
||||
uncoveredTypes: $effectiveTypes,
|
||||
errorsRecorded: $gapCount,
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::RolloutDisabled,
|
||||
evidenceGapsByReason: [
|
||||
BaselineCompareReasonCode::RolloutDisabled->value => $gapCount,
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($effectiveTypes === []) {
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
@ -121,6 +183,9 @@ public function handle(
|
||||
coveredTypes: [],
|
||||
uncoveredTypes: [],
|
||||
errorsRecorded: 1,
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::NoSubjectsInScope,
|
||||
evidenceGapsByReason: [],
|
||||
);
|
||||
|
||||
return;
|
||||
@ -132,6 +197,16 @@ public function handle(
|
||||
: null;
|
||||
|
||||
if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) {
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
@ -144,6 +219,7 @@ public function handle(
|
||||
coveredTypes: [],
|
||||
uncoveredTypes: $effectiveTypes,
|
||||
errorsRecorded: count($effectiveTypes),
|
||||
captureMode: $captureMode,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -153,6 +229,16 @@ public function handle(
|
||||
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
|
||||
|
||||
if ($coveredTypes === []) {
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$this->completeWithCoverageWarning(
|
||||
operationRunService: $operationRunService,
|
||||
auditLogger: $auditLogger,
|
||||
@ -165,6 +251,7 @@ public function handle(
|
||||
coveredTypes: [],
|
||||
uncoveredTypes: $effectiveTypes,
|
||||
errorsRecorded: count($effectiveTypes),
|
||||
captureMode: $captureMode,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -184,8 +271,22 @@ public function handle(
|
||||
? CarbonImmutable::instance($snapshot->captured_at)
|
||||
: null;
|
||||
|
||||
$baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes);
|
||||
$currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey());
|
||||
$baselineResult = $this->loadBaselineItems($snapshotId, $coveredTypes);
|
||||
$baselineItems = $baselineResult['items'];
|
||||
$baselineGaps = $baselineResult['gaps'];
|
||||
|
||||
$currentResult = $this->loadCurrentInventory($tenant, $coveredTypes, (int) $inventorySyncRun->getKey());
|
||||
$currentItems = $currentResult['items'];
|
||||
$currentGaps = $currentResult['gaps'];
|
||||
|
||||
$ambiguousKeys = array_values(array_unique(array_filter(array_merge(
|
||||
is_array($baselineResult['ambiguous_keys'] ?? null) ? $baselineResult['ambiguous_keys'] : [],
|
||||
is_array($currentResult['ambiguous_keys'] ?? null) ? $currentResult['ambiguous_keys'] : [],
|
||||
), 'is_string')));
|
||||
|
||||
foreach ($ambiguousKeys as $ambiguousKey) {
|
||||
unset($baselineItems[$ambiguousKey], $currentItems[$ambiguousKey]);
|
||||
}
|
||||
|
||||
$subjects = array_values(array_map(
|
||||
static fn (array $item): array => [
|
||||
@ -195,20 +296,81 @@ public function handle(
|
||||
$currentItems,
|
||||
));
|
||||
|
||||
$resolvedCurrentEvidence = $hashResolver->resolveForSubjects(
|
||||
$subjectsTotal = count($subjects);
|
||||
|
||||
$this->auditStarted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: $subjectsTotal,
|
||||
effectiveScope: $effectiveScope,
|
||||
);
|
||||
|
||||
$phaseStats = [
|
||||
'requested' => 0,
|
||||
'succeeded' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
];
|
||||
$phaseGaps = [];
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
$budgets = [
|
||||
'max_items_per_run' => (int) config('tenantpilot.baselines.full_content_capture.max_items_per_run', 200),
|
||||
'max_concurrency' => (int) config('tenantpilot.baselines.full_content_capture.max_concurrency', 5),
|
||||
'max_retries' => (int) config('tenantpilot.baselines.full_content_capture.max_retries', 3),
|
||||
];
|
||||
|
||||
$resumeTokenIn = null;
|
||||
|
||||
if (is_array($context['baseline_compare'] ?? null)) {
|
||||
$resumeTokenIn = $context['baseline_compare']['resume_token'] ?? null;
|
||||
}
|
||||
|
||||
$phaseResult = $contentCapturePhase->capture(
|
||||
tenant: $tenant,
|
||||
subjects: $subjects,
|
||||
purpose: PolicyVersionCapturePurpose::BaselineCompare,
|
||||
budgets: $budgets,
|
||||
resumeToken: is_string($resumeTokenIn) ? $resumeTokenIn : null,
|
||||
operationRunId: (int) $this->operationRun->getKey(),
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
createdBy: $initiator?->email,
|
||||
);
|
||||
|
||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||
}
|
||||
|
||||
$resolvedCurrentEvidenceByExternalId = $hashResolver->resolveForSubjects(
|
||||
tenant: $tenant,
|
||||
subjects: $subjects,
|
||||
since: $since,
|
||||
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
);
|
||||
|
||||
$resolvedCurrentMetaEvidence = $metaEvidenceProvider->resolve(
|
||||
$resolvedCurrentMetaEvidenceByExternalId = $metaEvidenceProvider->resolve(
|
||||
tenant: $tenant,
|
||||
subjects: $subjects,
|
||||
since: $since,
|
||||
latestInventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
);
|
||||
|
||||
$resolvedCurrentEvidence = $this->rekeyResolvedEvidenceBySubjectKey(
|
||||
currentItems: $currentItems,
|
||||
resolvedByExternalId: $resolvedCurrentEvidenceByExternalId,
|
||||
);
|
||||
|
||||
$resolvedCurrentMetaEvidence = $this->rekeyResolvedEvidenceBySubjectKey(
|
||||
currentItems: $currentItems,
|
||||
resolvedByExternalId: $resolvedCurrentMetaEvidenceByExternalId,
|
||||
);
|
||||
|
||||
$resolvedEffectiveCurrentEvidence = $this->resolveEffectiveCurrentEvidence(
|
||||
baselineItems: $baselineItems,
|
||||
currentItems: $currentItems,
|
||||
@ -223,12 +385,11 @@ public function handle(
|
||||
$this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||
);
|
||||
$driftResults = $computeResult['drift'];
|
||||
$evidenceGaps = $computeResult['evidence_gaps'];
|
||||
$driftGaps = $computeResult['evidence_gaps'];
|
||||
|
||||
$upsertResult = $this->upsertFindings(
|
||||
$tenant,
|
||||
$profile,
|
||||
$snapshotId,
|
||||
$scopeKey,
|
||||
$driftResults,
|
||||
);
|
||||
@ -236,6 +397,9 @@ public function handle(
|
||||
$severityBreakdown = $this->countBySeverity($driftResults);
|
||||
$countsByChangeType = $this->countByChangeType($driftResults);
|
||||
|
||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||
$gapsCount = array_sum($gapsByReason);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
@ -250,10 +414,13 @@ public function handle(
|
||||
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
|
||||
];
|
||||
|
||||
$warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== [];
|
||||
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
|
||||
@ -272,26 +439,46 @@ public function handle(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
}
|
||||
|
||||
$coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedCurrentEvidence);
|
||||
$coverageBreakdown = $this->summarizeCurrentEvidenceCoverage($currentItems, $resolvedEffectiveCurrentEvidence);
|
||||
$baselineCoverage = $this->summarizeBaselineEvidenceCoverage($baselineItems);
|
||||
|
||||
$overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0
|
||||
|| ($coverageBreakdown['resolved_meta'] ?? 0) > 0
|
||||
|| ($evidenceGaps['missing_current'] ?? 0) > 0
|
||||
|| ($gapsByReason['missing_current'] ?? 0) > 0
|
||||
? EvidenceProvenance::FidelityMeta
|
||||
: EvidenceProvenance::FidelityContent;
|
||||
|
||||
$reasonCode = null;
|
||||
|
||||
if ($subjectsTotal === 0) {
|
||||
$reasonCode = BaselineCompareReasonCode::NoSubjectsInScope;
|
||||
} elseif (count($driftResults) === 0) {
|
||||
$reasonCode = match (true) {
|
||||
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
|
||||
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
|
||||
default => BaselineCompareReasonCode::NoDriftDetected,
|
||||
};
|
||||
}
|
||||
|
||||
$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(),
|
||||
'since' => $since?->toIso8601String(),
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'evidence_capture' => $phaseStats,
|
||||
'evidence_gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
...$gapsByReason,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'coverage' => [
|
||||
'effective_types' => $effectiveTypes,
|
||||
'covered_types' => $coveredTypes,
|
||||
@ -301,7 +488,7 @@ public function handle(
|
||||
...$baselineCoverage,
|
||||
],
|
||||
'fidelity' => $overallFidelity,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'reason_code' => $reasonCode?->value,
|
||||
],
|
||||
);
|
||||
$updatedContext['findings'] = array_merge(
|
||||
@ -318,7 +505,20 @@ public function handle(
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
|
||||
$this->auditCompleted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: $subjectsTotal,
|
||||
evidenceCaptureStats: $phaseStats,
|
||||
gaps: [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
],
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -379,6 +579,34 @@ private function resolveEffectiveCurrentEvidence(
|
||||
return $effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekey resolved evidence from "policy_type|external_id" to the current items key ("policy_type|subject_key").
|
||||
*
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedByExternalId
|
||||
* @return array<string, ResolvedEvidence|null>
|
||||
*/
|
||||
private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $resolvedByExternalId): array
|
||||
{
|
||||
$rekeyed = [];
|
||||
|
||||
foreach ($currentItems as $key => $currentItem) {
|
||||
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
||||
$externalId = (string) ($currentItem['subject_external_id'] ?? '');
|
||||
|
||||
if ($policyType === '' || $externalId === '') {
|
||||
$rekeyed[$key] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedKey = $policyType.'|'.$externalId;
|
||||
$rekeyed[$key] = $resolvedByExternalId[$resolvedKey] ?? null;
|
||||
}
|
||||
|
||||
return $rekeyed;
|
||||
}
|
||||
|
||||
private function completeWithCoverageWarning(
|
||||
OperationRunService $operationRunService,
|
||||
AuditLogger $auditLogger,
|
||||
@ -391,6 +619,9 @@ private function completeWithCoverageWarning(
|
||||
array $coveredTypes,
|
||||
array $uncoveredTypes,
|
||||
int $errorsRecorded,
|
||||
BaselineCaptureMode $captureMode,
|
||||
BaselineCompareReasonCode $reasonCode = BaselineCompareReasonCode::CoverageUnproven,
|
||||
?array $evidenceGapsByReason = null,
|
||||
): void {
|
||||
$summaryCounts = [
|
||||
'total' => 0,
|
||||
@ -414,10 +645,31 @@ private function completeWithCoverageWarning(
|
||||
);
|
||||
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$evidenceCapture = [
|
||||
'requested' => 0,
|
||||
'succeeded' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
];
|
||||
|
||||
$evidenceGapsByReason ??= [
|
||||
BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded),
|
||||
];
|
||||
|
||||
$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,
|
||||
'subjects_total' => 0,
|
||||
'evidence_capture' => $evidenceCapture,
|
||||
'evidence_gaps' => [
|
||||
'count' => array_sum($evidenceGapsByReason),
|
||||
'by_reason' => $evidenceGapsByReason,
|
||||
...$evidenceGapsByReason,
|
||||
],
|
||||
'resume_token' => null,
|
||||
'reason_code' => $reasonCode->value,
|
||||
'coverage' => [
|
||||
'effective_types' => array_values($effectiveTypes),
|
||||
'covered_types' => array_values($coveredTypes),
|
||||
@ -431,11 +683,6 @@ private function completeWithCoverageWarning(
|
||||
'policy_types_meta_only' => [],
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
'evidence_gaps' => [
|
||||
'missing_baseline' => 0,
|
||||
'missing_current' => 0,
|
||||
'missing_both' => 0,
|
||||
],
|
||||
],
|
||||
);
|
||||
$updatedContext['findings'] = array_merge(
|
||||
@ -453,20 +700,47 @@ private function completeWithCoverageWarning(
|
||||
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
|
||||
$this->auditCompleted(
|
||||
auditLogger: $auditLogger,
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
initiator: $initiator,
|
||||
captureMode: $captureMode,
|
||||
subjectsTotal: 0,
|
||||
evidenceCaptureStats: $evidenceCapture,
|
||||
gaps: [
|
||||
'count' => array_sum($evidenceGapsByReason),
|
||||
'by_reason' => $evidenceGapsByReason,
|
||||
],
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load baseline snapshot items keyed by "policy_type|subject_external_id".
|
||||
* Load baseline snapshot items keyed by "policy_type|subject_key".
|
||||
*
|
||||
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
|
||||
* @return array{
|
||||
* items: array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>,
|
||||
* gaps: array<string, int>,
|
||||
* ambiguous_keys: list<string>
|
||||
* }
|
||||
*/
|
||||
private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
||||
{
|
||||
$items = [];
|
||||
$gaps = [];
|
||||
|
||||
/**
|
||||
* @var array<string, true>
|
||||
*/
|
||||
$ambiguousKeys = [];
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return $items;
|
||||
return [
|
||||
'items' => $items,
|
||||
'gaps' => $gaps,
|
||||
'ambiguous_keys' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$query = BaselineSnapshotItem::query()
|
||||
@ -476,26 +750,68 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
||||
|
||||
$query
|
||||
->orderBy('id')
|
||||
->chunk(500, function ($snapshotItems) use (&$items): void {
|
||||
->chunk(500, function ($snapshotItems) use (&$items, &$gaps, &$ambiguousKeys): void {
|
||||
foreach ($snapshotItems as $item) {
|
||||
$key = $item->policy_type.'|'.$item->subject_external_id;
|
||||
$metaJsonb = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
|
||||
$subjectKey = is_string($item->subject_key) ? trim((string) $item->subject_key) : '';
|
||||
|
||||
if ($subjectKey === '') {
|
||||
$displayName = $metaJsonb['display_name'] ?? ($metaJsonb['displayName'] ?? null);
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName(is_string($displayName) ? $displayName : null) ?? '';
|
||||
} else {
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($subjectKey) ?? '';
|
||||
}
|
||||
|
||||
if ($subjectKey === '') {
|
||||
$gaps['missing_subject_key_baseline'] = ($gaps['missing_subject_key_baseline'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $item->policy_type.'|'.$subjectKey;
|
||||
|
||||
if (array_key_exists($key, $ambiguousKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($key, $items)) {
|
||||
$ambiguousKeys[$key] = true;
|
||||
unset($items[$key]);
|
||||
|
||||
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[$key] = [
|
||||
'subject_type' => (string) $item->subject_type,
|
||||
'subject_external_id' => (string) $item->subject_external_id,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => (string) $item->policy_type,
|
||||
'baseline_hash' => (string) $item->baseline_hash,
|
||||
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
|
||||
'meta_jsonb' => $metaJsonb,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
return $items;
|
||||
ksort($gaps);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'gaps' => $gaps,
|
||||
'ambiguous_keys' => array_values(array_keys($ambiguousKeys)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current inventory items keyed by "policy_type|external_id".
|
||||
* Load current inventory items keyed by "policy_type|subject_key".
|
||||
*
|
||||
* @return array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}>
|
||||
* @return array{
|
||||
* items: array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}>,
|
||||
* gaps: array<string, int>,
|
||||
* ambiguous_keys: list<string>
|
||||
* }
|
||||
*/
|
||||
private function loadCurrentInventory(
|
||||
Tenant $tenant,
|
||||
@ -510,20 +826,53 @@ private function loadCurrentInventory(
|
||||
}
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return [];
|
||||
return [
|
||||
'items' => [],
|
||||
'gaps' => [],
|
||||
'ambiguous_keys' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$query->whereIn('policy_type', $policyTypes);
|
||||
|
||||
$items = [];
|
||||
$gaps = [];
|
||||
|
||||
/**
|
||||
* @var array<string, true>
|
||||
*/
|
||||
$ambiguousKeys = [];
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$items): void {
|
||||
->chunk(500, function ($inventoryItems) use (&$items, &$gaps, &$ambiguousKeys): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName(is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null) ?? '';
|
||||
|
||||
if ($subjectKey === '') {
|
||||
$gaps['missing_subject_key_current'] = ($gaps['missing_subject_key_current'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $inventoryItem->policy_type.'|'.$subjectKey;
|
||||
|
||||
if (array_key_exists($key, $ambiguousKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists($key, $items)) {
|
||||
$ambiguousKeys[$key] = true;
|
||||
unset($items[$key]);
|
||||
|
||||
$gaps['ambiguous_match'] = ($gaps['ambiguous_match'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[$key] = [
|
||||
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => (string) $inventoryItem->policy_type,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $inventoryItem->display_name,
|
||||
@ -534,7 +883,13 @@ private function loadCurrentInventory(
|
||||
}
|
||||
});
|
||||
|
||||
return $items;
|
||||
ksort($gaps);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'gaps' => $gaps,
|
||||
'ambiguous_keys' => array_values(array_keys($ambiguousKeys)),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
@ -553,11 +908,14 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
/**
|
||||
* Compare baseline items vs current inventory and produce drift results.
|
||||
*
|
||||
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
* @param array<string, string> $severityMapping
|
||||
* @return array{drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>, evidence_gaps: array{missing_baseline: int, missing_current: int, missing_both: int}}
|
||||
* @return array{
|
||||
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
||||
* evidence_gaps: array<string, int>
|
||||
* }
|
||||
*/
|
||||
private function computeDrift(array $baselineItems, array $currentItems, array $resolvedCurrentEvidence, array $severityMapping): array
|
||||
{
|
||||
@ -565,12 +923,15 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
$missingCurrentEvidence = 0;
|
||||
|
||||
foreach ($baselineItems as $key => $baselineItem) {
|
||||
if (! array_key_exists($key, $currentItems)) {
|
||||
$currentItem = $currentItems[$key] ?? null;
|
||||
|
||||
if (! is_array($currentItem)) {
|
||||
$drift[] = [
|
||||
'change_type' => 'missing_policy',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'evidence_fidelity' => EvidenceProvenance::FidelityMeta,
|
||||
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||
@ -578,6 +939,7 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
'evidence' => [
|
||||
'change_type' => 'missing_policy',
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
||||
],
|
||||
];
|
||||
@ -597,12 +959,15 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb']);
|
||||
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
||||
$evidenceFidelity = EvidenceProvenance::weakerFidelity($baselineFidelity, $currentEvidence->fidelity);
|
||||
$displayName = $currentItem['meta_jsonb']['display_name']
|
||||
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'different_version',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'evidence_fidelity' => $evidenceFidelity,
|
||||
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||
@ -610,7 +975,8 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
'evidence' => [
|
||||
'change_type' => 'different_version',
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
|
||||
'subject_key' => $baselineItem['subject_key'],
|
||||
'display_name' => $displayName,
|
||||
'baseline_hash' => $baselineItem['baseline_hash'],
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'baseline' => [
|
||||
@ -619,7 +985,7 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
],
|
||||
'current' => [
|
||||
'hash' => $currentEvidence->hash,
|
||||
'provenance' => $currentEvidence->provenance(),
|
||||
'provenance' => $currentEvidence->tenantProvenance(),
|
||||
],
|
||||
],
|
||||
];
|
||||
@ -641,13 +1007,15 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $currentItem['subject_key'],
|
||||
'policy_type' => $currentItem['policy_type'],
|
||||
'evidence_fidelity' => EvidenceProvenance::FidelityMeta,
|
||||
'evidence_fidelity' => $currentEvidence->fidelity,
|
||||
'baseline_hash' => '',
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'policy_type' => $currentItem['policy_type'],
|
||||
'subject_key' => $currentItem['subject_key'],
|
||||
'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
|
||||
],
|
||||
];
|
||||
@ -657,13 +1025,40 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => [
|
||||
'missing_baseline' => 0,
|
||||
'missing_current' => $missingCurrentEvidence,
|
||||
'missing_both' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> ...$gaps
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function mergeGapCounts(array ...$gaps): array
|
||||
{
|
||||
$merged = [];
|
||||
|
||||
foreach ($gaps as $gap) {
|
||||
foreach ($gap as $reason => $count) {
|
||||
if (! is_string($reason) || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = (int) $count;
|
||||
|
||||
if ($count <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$merged[$reason] = ($merged[$reason] ?? 0) + $count;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($merged);
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
@ -858,17 +1253,17 @@ private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array
|
||||
/**
|
||||
* Upsert drift findings using stable fingerprints.
|
||||
*
|
||||
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
|
||||
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
|
||||
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
|
||||
*/
|
||||
private function upsertFindings(
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
int $baselineSnapshotId,
|
||||
string $scopeKey,
|
||||
array $driftResults,
|
||||
): array {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$baselineProfileId = (int) $profile->getKey();
|
||||
$observedAt = CarbonImmutable::now();
|
||||
$processedCount = 0;
|
||||
$createdCount = 0;
|
||||
@ -877,12 +1272,18 @@ private function upsertFindings(
|
||||
$seenFingerprints = [];
|
||||
|
||||
foreach ($driftResults as $driftItem) {
|
||||
$subjectKey = (string) ($driftItem['subject_key'] ?? '');
|
||||
|
||||
if (trim($subjectKey) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recurrenceKey = $this->recurrenceKey(
|
||||
tenantId: $tenantId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
policyType: $driftItem['policy_type'],
|
||||
subjectExternalId: $driftItem['subject_external_id'],
|
||||
changeType: $driftItem['change_type'],
|
||||
baselineProfileId: $baselineProfileId,
|
||||
policyType: (string) ($driftItem['policy_type'] ?? ''),
|
||||
subjectKey: $subjectKey,
|
||||
changeType: (string) ($driftItem['change_type'] ?? ''),
|
||||
);
|
||||
|
||||
$fingerprint = $recurrenceKey;
|
||||
@ -985,20 +1386,20 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for baseline-compare findings, scoped to a baseline snapshot.
|
||||
* Stable identity for baseline-compare findings, scoped to a baseline profile + subject key.
|
||||
*/
|
||||
private function recurrenceKey(
|
||||
int $tenantId,
|
||||
int $baselineSnapshotId,
|
||||
int $baselineProfileId,
|
||||
string $policyType,
|
||||
string $subjectExternalId,
|
||||
string $subjectKey,
|
||||
string $changeType,
|
||||
): string {
|
||||
$parts = [
|
||||
(string) $tenantId,
|
||||
(string) $baselineSnapshotId,
|
||||
(string) $baselineProfileId,
|
||||
$this->normalizeKeyPart($policyType),
|
||||
$this->normalizeKeyPart($subjectExternalId),
|
||||
$this->normalizeKeyPart($subjectKey),
|
||||
$this->normalizeKeyPart($changeType),
|
||||
];
|
||||
|
||||
@ -1092,6 +1493,9 @@ private function auditStarted(
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
?User $initiator,
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
BaselineScope $effectiveScope,
|
||||
): void {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -1101,6 +1505,10 @@ private function auditStarted(
|
||||
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'capture_mode' => $captureMode->value,
|
||||
'scope_types_total' => count($effectiveScope->allTypes()),
|
||||
'subjects_total' => $subjectsTotal,
|
||||
],
|
||||
],
|
||||
actorId: $initiator?->id,
|
||||
@ -1116,6 +1524,10 @@ private function auditCompleted(
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
?User $initiator,
|
||||
BaselineCaptureMode $captureMode,
|
||||
int $subjectsTotal,
|
||||
array $evidenceCaptureStats,
|
||||
array $gaps,
|
||||
array $summaryCounts,
|
||||
): void {
|
||||
$auditLogger->log(
|
||||
@ -1126,10 +1538,15 @@ private function auditCompleted(
|
||||
'operation_run_id' => (int) $this->operationRun->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_profile_name' => (string) $profile->name,
|
||||
'purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'capture_mode' => $captureMode->value,
|
||||
'subjects_total' => $subjectsTotal,
|
||||
'findings_total' => $summaryCounts['total'] ?? 0,
|
||||
'high' => $summaryCounts['high'] ?? 0,
|
||||
'medium' => $summaryCounts['medium'] ?? 0,
|
||||
'low' => $summaryCounts['low'] ?? 0,
|
||||
'evidence_capture' => $evidenceCaptureStats,
|
||||
'gaps' => $gaps,
|
||||
],
|
||||
],
|
||||
actorId: $initiator?->id,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -39,6 +40,7 @@ class BaselineProfile extends Model
|
||||
'description',
|
||||
'version_label',
|
||||
'status',
|
||||
'capture_mode',
|
||||
'scope_jsonb',
|
||||
'active_snapshot_id',
|
||||
'created_by_user_id',
|
||||
@ -51,6 +53,7 @@ protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => BaselineProfileStatus::class,
|
||||
'capture_mode' => BaselineCaptureMode::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -22,6 +23,7 @@ class PolicyVersion extends Model
|
||||
'assignments' => 'array',
|
||||
'scope_tags' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::class,
|
||||
];
|
||||
|
||||
public function previous(): ?self
|
||||
@ -45,6 +47,16 @@ public function policy(): BelongsTo
|
||||
return $this->belongsTo(Policy::class);
|
||||
}
|
||||
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
public function baselineProfile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BaselineProfile::class);
|
||||
}
|
||||
|
||||
public function scopePruneEligible($query, int $days = 90)
|
||||
{
|
||||
return $query
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
@ -179,6 +180,7 @@ public function panel(Panel $panel): Panel
|
||||
AlertDeliveryResource::class,
|
||||
WorkspaceResource::class,
|
||||
BaselineProfileResource::class,
|
||||
BaselineSnapshotResource::class,
|
||||
])
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
@ -19,6 +21,7 @@ final class BaselineCaptureService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -39,10 +42,19 @@ public function startCapture(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
$context = [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $sourceTenant->graphTenantId(),
|
||||
'entra_tenant_name' => (string) $sourceTenant->name,
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
@ -68,6 +80,10 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT
|
||||
return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE;
|
||||
}
|
||||
|
||||
if ($profile->capture_mode === BaselineCaptureMode::FullContent && ! $this->rolloutGate->enabled()) {
|
||||
return BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED;
|
||||
}
|
||||
|
||||
if ($sourceTenant->workspace_id === null) {
|
||||
return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
@ -21,6 +23,7 @@ final class BaselineCompareService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -78,10 +81,19 @@ public function startCompare(
|
||||
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
: BaselineCaptureMode::Opportunistic;
|
||||
|
||||
$context = [
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||
'entra_tenant_name' => (string) $tenant->name,
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
@ -107,6 +119,10 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
}
|
||||
|
||||
if ($profile->capture_mode === BaselineCaptureMode::FullContent && ! $this->rolloutGate->enabled()) {
|
||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||
}
|
||||
|
||||
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||
}
|
||||
|
||||
208
app/Services/Baselines/BaselineContentCapturePhase.php
Normal file
208
app/Services/Baselines/BaselineContentCapturePhase.php
Normal file
@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Throwable;
|
||||
|
||||
final class BaselineContentCapturePhase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
||||
*
|
||||
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
||||
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
||||
* @return array{
|
||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||
* gaps: array<string, int>,
|
||||
* resume_token: ?string
|
||||
* }
|
||||
*/
|
||||
public function capture(
|
||||
Tenant $tenant,
|
||||
array $subjects,
|
||||
PolicyVersionCapturePurpose $purpose,
|
||||
array $budgets,
|
||||
?string $resumeToken = null,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
?string $createdBy = null,
|
||||
): array {
|
||||
$subjects = array_values($subjects);
|
||||
|
||||
$maxItemsPerRun = max(0, (int) ($budgets['max_items_per_run'] ?? 0));
|
||||
$maxConcurrency = max(1, (int) ($budgets['max_concurrency'] ?? 1));
|
||||
$maxRetries = max(0, (int) ($budgets['max_retries'] ?? 0));
|
||||
|
||||
$offset = 0;
|
||||
|
||||
if (is_string($resumeToken) && $resumeToken !== '') {
|
||||
$state = BaselineEvidenceResumeToken::decode($resumeToken) ?? [];
|
||||
$offset = is_numeric($state['offset'] ?? null) ? max(0, (int) $state['offset']) : 0;
|
||||
}
|
||||
|
||||
if ($offset >= count($subjects)) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$remaining = array_slice($subjects, $offset);
|
||||
$batch = $maxItemsPerRun > 0 ? array_slice($remaining, 0, $maxItemsPerRun) : [];
|
||||
|
||||
$stats = [
|
||||
'requested' => count($batch),
|
||||
'succeeded' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'throttled' => 0,
|
||||
];
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
|
||||
/**
|
||||
* @var array<string, true> $seen
|
||||
*/
|
||||
$seen = [];
|
||||
|
||||
foreach (array_chunk($batch, $maxConcurrency) as $chunk) {
|
||||
foreach ($chunk as $subject) {
|
||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||
|
||||
if ($policyType === '' || $externalId === '') {
|
||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjectKey = $policyType.'|'.$externalId;
|
||||
|
||||
if (isset($seen[$subjectKey])) {
|
||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$subjectKey] = true;
|
||||
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('policy_type', $policyType)
|
||||
->where('external_id', $externalId)
|
||||
->first();
|
||||
|
||||
if (! $policy instanceof Policy) {
|
||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$attempt = 0;
|
||||
$result = null;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$result = $this->captureOrchestrator->capture(
|
||||
policy: $policy,
|
||||
tenant: $tenant,
|
||||
includeAssignments: true,
|
||||
includeScopeTags: true,
|
||||
createdBy: $createdBy,
|
||||
metadata: [
|
||||
'capture_source' => 'baseline_evidence',
|
||||
],
|
||||
capturePurpose: $purpose,
|
||||
operationRunId: $operationRunId,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$result = [
|
||||
'failure' => [
|
||||
'reason' => $throwable->getMessage(),
|
||||
'status' => is_numeric($throwable->getCode()) ? (int) $throwable->getCode() : null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (! (is_array($result) && array_key_exists('failure', $result))) {
|
||||
$stats['succeeded']++;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$failure = is_array($result['failure'] ?? null) ? $result['failure'] : [];
|
||||
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||
|
||||
$isThrottled = in_array($status, [429, 503], true);
|
||||
|
||||
if ($isThrottled && $attempt < $maxRetries) {
|
||||
$delayMs = $this->retryDelayMs($attempt);
|
||||
usleep($delayMs * 1000);
|
||||
$attempt++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isThrottled) {
|
||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||
$stats['throttled']++;
|
||||
} else {
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$processed = $offset + count($batch);
|
||||
$resumeTokenOut = null;
|
||||
|
||||
if ($processed < count($subjects)) {
|
||||
$resumeTokenOut = BaselineEvidenceResumeToken::encode([
|
||||
'offset' => $processed,
|
||||
'total' => count($subjects),
|
||||
]);
|
||||
|
||||
$remainingCount = max(0, count($subjects) - $processed);
|
||||
if ($remainingCount > 0) {
|
||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($gaps);
|
||||
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'gaps' => $gaps,
|
||||
'resume_token' => $resumeTokenOut,
|
||||
];
|
||||
}
|
||||
|
||||
private function retryDelayMs(int $attempt): int
|
||||
{
|
||||
$attempt = max(0, $attempt);
|
||||
|
||||
$baseDelayMs = 500;
|
||||
$maxDelayMs = 30_000;
|
||||
|
||||
$delayMs = (int) min($maxDelayMs, $baseDelayMs * (2 ** $attempt));
|
||||
$jitterMs = random_int(0, 250);
|
||||
|
||||
return $delayMs + $jitterMs;
|
||||
}
|
||||
}
|
||||
148
app/Services/Baselines/BaselineEvidenceCaptureResumeService.php
Normal file
148
app/Services/Baselines/BaselineEvidenceCaptureResumeService.php
Normal file
@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineEvidenceCaptureResumeService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start a follow-up baseline capture / compare run from an existing run + resume token.
|
||||
*
|
||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||
*/
|
||||
public function resume(OperationRun $priorRun, User $initiator): array
|
||||
{
|
||||
$runType = trim((string) $priorRun->type);
|
||||
|
||||
if (! in_array($runType, [OperationRunType::BaselineCapture->value, OperationRunType::BaselineCompare->value], true)) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.unsupported_run_type'];
|
||||
}
|
||||
|
||||
if ($priorRun->status !== OperationRunStatus::Completed->value) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.run_not_completed'];
|
||||
}
|
||||
|
||||
$tenantId = (int) ($priorRun->tenant_id ?? 0);
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_tenant'];
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.tenant_not_found'];
|
||||
}
|
||||
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_workspace'];
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.workspace_not_found'];
|
||||
}
|
||||
|
||||
if (! $this->workspaceCapabilities->isMember($initiator, $workspace)) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.not_workspace_member'];
|
||||
}
|
||||
|
||||
if (! $this->workspaceCapabilities->can($initiator, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.forbidden'];
|
||||
}
|
||||
|
||||
$this->rolloutGate->assertEnabled();
|
||||
|
||||
$context = is_array($priorRun->context) ? $priorRun->context : [];
|
||||
$profileId = (int) ($context['baseline_profile_id'] ?? 0);
|
||||
|
||||
if ($profileId <= 0) {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_profile'];
|
||||
}
|
||||
|
||||
$resumeSection = $runType === OperationRunType::BaselineCapture->value ? 'baseline_capture' : 'baseline_compare';
|
||||
$resumeToken = data_get($context, "{$resumeSection}.resume_token");
|
||||
|
||||
if (! is_string($resumeToken) || trim($resumeToken) === '') {
|
||||
return ['ok' => false, 'reason_code' => 'baseline.resume.missing_resume_token'];
|
||||
}
|
||||
|
||||
$newContext = [];
|
||||
|
||||
foreach (['target_scope', 'baseline_profile_id', 'baseline_snapshot_id', 'source_tenant_id', 'effective_scope', 'capture_mode'] as $key) {
|
||||
if (array_key_exists($key, $context)) {
|
||||
$newContext[$key] = $context[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$newContext['resume_from_operation_run_id'] = (int) $priorRun->getKey();
|
||||
|
||||
$newContext[$resumeSection] = [
|
||||
'resume_token' => $resumeToken,
|
||||
'resume_from_operation_run_id' => (int) $priorRun->getKey(),
|
||||
];
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: $runType,
|
||||
identityInputs: [
|
||||
'baseline_profile_id' => $profileId,
|
||||
],
|
||||
context: $newContext,
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($run->wasRecentlyCreated) {
|
||||
match ($runType) {
|
||||
OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run),
|
||||
OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'baseline.evidence.resume.started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'prior_operation_run_id' => (int) $priorRun->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'baseline_profile_id' => $profileId,
|
||||
'run_type' => $runType,
|
||||
],
|
||||
],
|
||||
actorId: (int) $initiator->getKey(),
|
||||
actorEmail: (string) $initiator->email,
|
||||
actorName: (string) $initiator->name,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $priorRun->getKey(),
|
||||
);
|
||||
|
||||
return ['ok' => true, 'run' => $run];
|
||||
}
|
||||
}
|
||||
@ -23,9 +23,9 @@ public function __construct(
|
||||
* Compute identity hash over a set of snapshot items.
|
||||
*
|
||||
* Each item is represented as an associative array with:
|
||||
* - subject_type, subject_external_id, policy_type, baseline_hash
|
||||
* - policy_type, subject_key, baseline_hash
|
||||
*
|
||||
* @param array<int, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string}> $items
|
||||
* @param array<int, array{policy_type: string, subject_key: ?string, baseline_hash: string}> $items
|
||||
*/
|
||||
public function computeIdentity(array $items): string
|
||||
{
|
||||
@ -35,9 +35,8 @@ public function computeIdentity(array $items): string
|
||||
|
||||
$normalized = array_map(
|
||||
fn (array $item): string => implode('|', [
|
||||
trim((string) ($item['subject_type'] ?? '')),
|
||||
trim((string) ($item['subject_external_id'] ?? '')),
|
||||
trim((string) ($item['policy_type'] ?? '')),
|
||||
trim((string) ($item['subject_key'] ?? '')),
|
||||
trim((string) ($item['baseline_hash'] ?? '')),
|
||||
]),
|
||||
$items,
|
||||
|
||||
@ -8,7 +8,9 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\CurrentStateEvidenceProvider;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -18,6 +20,8 @@ final class ContentEvidenceProvider implements CurrentStateEvidenceProvider
|
||||
public function __construct(
|
||||
private readonly DriftHasher $hasher,
|
||||
private readonly SettingsNormalizer $settingsNormalizer,
|
||||
private readonly AssignmentsNormalizer $assignmentsNormalizer,
|
||||
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
@ -85,11 +89,15 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
|
||||
$baseQuery = DB::table('policy_versions')
|
||||
->select([
|
||||
'policy_versions.id',
|
||||
'policy_versions.operation_run_id',
|
||||
'policy_versions.capture_purpose',
|
||||
'policy_versions.policy_id',
|
||||
'policy_versions.policy_type',
|
||||
'policy_versions.platform',
|
||||
'policy_versions.captured_at',
|
||||
'policy_versions.snapshot',
|
||||
'policy_versions.assignments',
|
||||
'policy_versions.scope_tags',
|
||||
'policy_versions.version_number',
|
||||
])
|
||||
->selectRaw('ROW_NUMBER() OVER (PARTITION BY policy_id ORDER BY captured_at DESC, version_number DESC, id DESC) as rn')
|
||||
@ -127,6 +135,14 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
|
||||
$snapshot = is_array($snapshot) ? $snapshot : (is_string($snapshot) ? json_decode($snapshot, true) : null);
|
||||
$snapshot = is_array($snapshot) ? $snapshot : [];
|
||||
|
||||
$assignments = $version->assignments ?? null;
|
||||
$assignments = is_array($assignments) ? $assignments : (is_string($assignments) ? json_decode($assignments, true) : null);
|
||||
$assignments = is_array($assignments) ? $assignments : [];
|
||||
|
||||
$scopeTags = $version->scope_tags ?? null;
|
||||
$scopeTags = is_array($scopeTags) ? $scopeTags : (is_string($scopeTags) ? json_decode($scopeTags, true) : null);
|
||||
$scopeTags = is_array($scopeTags) ? $scopeTags : [];
|
||||
|
||||
$platform = is_string($version->platform ?? null) ? (string) $version->platform : null;
|
||||
|
||||
$normalized = $this->settingsNormalizer->normalizeForDiff(
|
||||
@ -135,10 +151,20 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
|
||||
platform: $platform,
|
||||
);
|
||||
|
||||
$hash = $this->hasher->hashNormalized($normalized);
|
||||
$normalizedAssignments = $this->assignmentsNormalizer->normalizeForDiff($assignments);
|
||||
$normalizedScopeTagIds = $this->scopeTagsNormalizer->normalizeIds($scopeTags);
|
||||
|
||||
$hash = $this->hasher->hashNormalized([
|
||||
'settings' => $normalized,
|
||||
'assignments' => $normalizedAssignments,
|
||||
'scope_tag_ids' => $normalizedScopeTagIds,
|
||||
]);
|
||||
|
||||
$observedAt = is_string($version->captured_at ?? null) ? CarbonImmutable::parse((string) $version->captured_at) : null;
|
||||
$policyVersionId = is_numeric($version->id ?? null) ? (int) $version->id : null;
|
||||
$observedOperationRunId = is_numeric($version->operation_run_id ?? null) ? (int) $version->operation_run_id : null;
|
||||
$capturePurpose = is_string($version->capture_purpose ?? null) ? trim((string) $version->capture_purpose) : null;
|
||||
$capturePurpose = $capturePurpose !== '' ? $capturePurpose : null;
|
||||
|
||||
$resolved[$key] = new ResolvedEvidence(
|
||||
policyType: $policyType,
|
||||
@ -147,9 +173,11 @@ public function resolve(Tenant $tenant, array $subjects, ?CarbonImmutable $since
|
||||
fidelity: EvidenceProvenance::FidelityContent,
|
||||
source: EvidenceProvenance::SourcePolicyVersion,
|
||||
observedAt: $observedAt,
|
||||
observedOperationRunId: null,
|
||||
observedOperationRunId: $observedOperationRunId,
|
||||
meta: [
|
||||
'policy_version_id' => $policyVersionId,
|
||||
'operation_run_id' => $observedOperationRunId,
|
||||
'capture_purpose' => $capturePurpose,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,6 +45,18 @@ public function provenance(): array
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-scoped provenance including additional metadata (e.g. policy_version_id).
|
||||
*
|
||||
* Do NOT use this for workspace-owned baseline snapshot items.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function tenantProvenance(): array
|
||||
{
|
||||
return array_merge($this->provenance(), $this->meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{hash: string, provenance: array<string, mixed>}
|
||||
*/
|
||||
@ -52,7 +64,7 @@ public function toFindingSideEvidence(): array
|
||||
{
|
||||
return [
|
||||
'hash' => $this->hash,
|
||||
'provenance' => $this->provenance(),
|
||||
'provenance' => $this->tenantProvenance(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Services\Graph\AssignmentFetcher;
|
||||
use App\Services\Graph\AssignmentFilterResolver;
|
||||
use App\Services\Graph\GraphException;
|
||||
@ -23,6 +24,7 @@ class PolicyCaptureOrchestrator
|
||||
public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly PolicySnapshotRedactor $snapshotRedactor,
|
||||
private readonly AssignmentFetcher $assignmentFetcher,
|
||||
private readonly GroupResolver $groupResolver,
|
||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||
@ -41,7 +43,10 @@ public function capture(
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = []
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant);
|
||||
$tenantIdentifier = (string) ($graphOptions['tenant'] ?? '');
|
||||
@ -127,11 +132,21 @@ public function capture(
|
||||
$scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds);
|
||||
}
|
||||
|
||||
// 4. Check if PolicyVersion with same snapshot already exists
|
||||
$snapshotHash = hash('sha256', json_encode($payload));
|
||||
$redactedPayload = $this->snapshotRedactor->redactPayload($payload);
|
||||
$redactedAssignments = $this->snapshotRedactor->redactAssignments($assignments);
|
||||
$redactedScopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
|
||||
|
||||
// 4. Check if PolicyVersion with same snapshot already exists (based on redacted content)
|
||||
$snapshotHash = hash('sha256', json_encode($redactedPayload));
|
||||
|
||||
// Find existing version by comparing snapshot content (database-agnostic)
|
||||
$existingVersion = PolicyVersion::where('policy_id', $policy->id)
|
||||
$existingVersion = PolicyVersion::query()
|
||||
->where('policy_id', $policy->id)
|
||||
->where('capture_purpose', $capturePurpose->value)
|
||||
->when(
|
||||
$capturePurpose !== PolicyVersionCapturePurpose::Backup && $baselineProfileId !== null,
|
||||
fn ($query) => $query->where('baseline_profile_id', $baselineProfileId),
|
||||
)
|
||||
->get()
|
||||
->first(function ($version) use ($snapshotHash) {
|
||||
return hash('sha256', json_encode($version->snapshot)) === $snapshotHash;
|
||||
@ -141,13 +156,13 @@ public function capture(
|
||||
$updates = [];
|
||||
|
||||
if ($includeAssignments && $existingVersion->assignments === null) {
|
||||
$updates['assignments'] = $assignments;
|
||||
$updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null;
|
||||
$updates['assignments'] = $redactedAssignments;
|
||||
$updates['assignments_hash'] = $redactedAssignments ? hash('sha256', json_encode($redactedAssignments)) : null;
|
||||
}
|
||||
|
||||
if ($includeScopeTags && $existingVersion->scope_tags === null) {
|
||||
$updates['scope_tags'] = $scopeTags;
|
||||
$updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null;
|
||||
$updates['scope_tags'] = $redactedScopeTags;
|
||||
$updates['scope_tags_hash'] = $redactedScopeTags ? hash('sha256', json_encode($redactedScopeTags)) : null;
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
@ -165,9 +180,9 @@ public function capture(
|
||||
return [
|
||||
'version' => $existingVersion->fresh(),
|
||||
'captured' => [
|
||||
'payload' => $payload,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'payload' => $redactedPayload,
|
||||
'assignments' => $redactedAssignments,
|
||||
'scope_tags' => $redactedScopeTags,
|
||||
'metadata' => $captureMetadata,
|
||||
],
|
||||
];
|
||||
@ -183,9 +198,9 @@ public function capture(
|
||||
return [
|
||||
'version' => $existingVersion,
|
||||
'captured' => [
|
||||
'payload' => $payload,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'payload' => $redactedPayload,
|
||||
'assignments' => $redactedAssignments,
|
||||
'scope_tags' => $redactedScopeTags,
|
||||
'metadata' => $captureMetadata,
|
||||
],
|
||||
];
|
||||
@ -200,11 +215,14 @@ public function capture(
|
||||
|
||||
$version = $this->versionService->captureVersion(
|
||||
policy: $policy,
|
||||
payload: $payload,
|
||||
payload: $redactedPayload,
|
||||
createdBy: $createdBy,
|
||||
metadata: $metadata,
|
||||
assignments: $assignments,
|
||||
scopeTags: $scopeTags,
|
||||
assignments: $redactedAssignments,
|
||||
scopeTags: $redactedScopeTags,
|
||||
capturePurpose: $capturePurpose,
|
||||
operationRunId: $operationRunId,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
);
|
||||
|
||||
Log::info('Policy captured via orchestrator', [
|
||||
@ -219,9 +237,9 @@ public function capture(
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $payload,
|
||||
'assignments' => $assignments,
|
||||
'scope_tags' => $scopeTags,
|
||||
'payload' => $redactedPayload,
|
||||
'assignments' => $redactedAssignments,
|
||||
'scope_tags' => $redactedScopeTags,
|
||||
'metadata' => $captureMetadata,
|
||||
],
|
||||
];
|
||||
|
||||
96
app/Services/Intune/PolicySnapshotRedactor.php
Normal file
96
app/Services/Intune/PolicySnapshotRedactor.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
final class PolicySnapshotRedactor
|
||||
{
|
||||
private const string REDACTED = '[REDACTED]';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array SENSITIVE_KEY_PATTERNS = [
|
||||
'/password/i',
|
||||
'/secret/i',
|
||||
'/token/i',
|
||||
'/client[_-]?secret/i',
|
||||
'/private[_-]?key/i',
|
||||
'/shared[_-]?secret/i',
|
||||
'/preshared/i',
|
||||
'/certificate/i',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function redactPayload(array $payload): array
|
||||
{
|
||||
return $this->redactValue($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>>|null $assignments
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
public function redactAssignments(?array $assignments): ?array
|
||||
{
|
||||
if ($assignments === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$redacted = $this->redactValue($assignments);
|
||||
|
||||
return is_array($redacted) ? $redacted : $assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>>|null $scopeTags
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
public function redactScopeTags(?array $scopeTags): ?array
|
||||
{
|
||||
if ($scopeTags === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$redacted = $this->redactValue($scopeTags);
|
||||
|
||||
return is_array($redacted) ? $redacted : $scopeTags;
|
||||
}
|
||||
|
||||
private function isSensitiveKey(string $key): bool
|
||||
{
|
||||
foreach (self::SENSITIVE_KEY_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $key) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function redactValue(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$redacted = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($key) && $this->isSensitiveKey($key)) {
|
||||
$redacted[$key] = self::REDACTED;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$redacted[$key] = $this->redactValue($item);
|
||||
}
|
||||
|
||||
return $redacted;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Graph\ScopeTagResolver;
|
||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
@ -21,6 +22,7 @@ class VersionService
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly PolicySnapshotService $snapshotService,
|
||||
private readonly PolicySnapshotRedactor $snapshotRedactor,
|
||||
private readonly AssignmentFetcher $assignmentFetcher,
|
||||
private readonly GroupResolver $groupResolver,
|
||||
private readonly AssignmentFilterResolver $assignmentFilterResolver,
|
||||
@ -35,13 +37,20 @@ public function captureVersion(
|
||||
array $metadata = [],
|
||||
?array $assignments = null,
|
||||
?array $scopeTags = null,
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): PolicyVersion {
|
||||
$payload = $this->snapshotRedactor->redactPayload($payload);
|
||||
$assignments = $this->snapshotRedactor->redactAssignments($assignments);
|
||||
$scopeTags = $this->snapshotRedactor->redactScopeTags($scopeTags);
|
||||
|
||||
$version = null;
|
||||
$versionNumber = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
try {
|
||||
[$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags): array {
|
||||
[$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags, $capturePurpose, $operationRunId, $baselineProfileId): array {
|
||||
// Serialize version number allocation per policy.
|
||||
Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first();
|
||||
|
||||
@ -61,6 +70,9 @@ public function captureVersion(
|
||||
'scope_tags' => $scopeTags,
|
||||
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
|
||||
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
|
||||
'capture_purpose' => $capturePurpose->value,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [$version, $versionNumber];
|
||||
@ -121,6 +133,9 @@ public function captureFromGraph(
|
||||
array $metadata = [],
|
||||
bool $includeAssignments = true,
|
||||
bool $includeScopeTags = true,
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): PolicyVersion {
|
||||
$graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant);
|
||||
$tenantIdentifier = (string) ($graphOptions['tenant'] ?? '');
|
||||
@ -213,6 +228,9 @@ public function captureFromGraph(
|
||||
metadata: $metadata,
|
||||
assignments: $assignments,
|
||||
scopeTags: $scopeTags,
|
||||
capturePurpose: $capturePurpose,
|
||||
operationRunId: $operationRunId,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
36
app/Support/Baselines/BaselineCaptureMode.php
Normal file
36
app/Support/Baselines/BaselineCaptureMode.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum BaselineCaptureMode: string
|
||||
{
|
||||
case MetaOnly = 'meta_only';
|
||||
case Opportunistic = 'opportunistic';
|
||||
case FullContent = 'full_content';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MetaOnly => 'Meta only',
|
||||
self::Opportunistic => 'Opportunistic',
|
||||
self::FullContent => 'Full content',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function selectOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
$options[$case->value] = $case->label();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Support/Baselines/BaselineCompareReasonCode.php
Normal file
25
app/Support/Baselines/BaselineCompareReasonCode.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum BaselineCompareReasonCode: string
|
||||
{
|
||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||
case CoverageUnproven = 'coverage_unproven';
|
||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||
case RolloutDisabled = 'rollout_disabled';
|
||||
case NoDriftDetected = 'no_drift_detected';
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
|
||||
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
|
||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -7,14 +7,17 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class BaselineCompareStats
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $severityCounts
|
||||
* @param list<string> $uncoveredTypes
|
||||
* @param array<string, int> $evidenceGapsTopReasons
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $state,
|
||||
@ -22,16 +25,21 @@ private function __construct(
|
||||
public readonly ?string $profileName,
|
||||
public readonly ?int $profileId,
|
||||
public readonly ?int $snapshotId,
|
||||
public readonly ?int $duplicateNamePoliciesCount,
|
||||
public readonly ?int $operationRunId,
|
||||
public readonly ?int $findingsCount,
|
||||
public readonly array $severityCounts,
|
||||
public readonly ?string $lastComparedHuman,
|
||||
public readonly ?string $lastComparedIso,
|
||||
public readonly ?string $failureReason,
|
||||
public readonly ?string $reasonCode = null,
|
||||
public readonly ?string $reasonMessage = null,
|
||||
public readonly ?string $coverageStatus = null,
|
||||
public readonly ?int $uncoveredTypesCount = null,
|
||||
public readonly array $uncoveredTypes = [],
|
||||
public readonly ?string $fidelity = null,
|
||||
public readonly ?int $evidenceGapsCount = null,
|
||||
public readonly array $evidenceGapsTopReasons = [],
|
||||
) {}
|
||||
|
||||
public static function forTenant(?Tenant $tenant): self
|
||||
@ -64,12 +72,23 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$profileId = (int) $profile->getKey();
|
||||
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||
? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
|
||||
: null;
|
||||
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
|
||||
|
||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||
|
||||
if ($snapshotId === null) {
|
||||
return self::empty(
|
||||
'no_snapshot',
|
||||
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
);
|
||||
}
|
||||
|
||||
@ -80,6 +99,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
->first();
|
||||
|
||||
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
|
||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
@ -89,16 +110,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: null,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,16 +141,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
|
||||
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
|
||||
failureReason: (string) $failureReason,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
);
|
||||
}
|
||||
|
||||
@ -163,16 +194,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
);
|
||||
}
|
||||
|
||||
@ -185,16 +221,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: 0,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
);
|
||||
}
|
||||
|
||||
@ -204,16 +245,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
coverageStatus: $coverageStatus,
|
||||
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
);
|
||||
}
|
||||
|
||||
@ -264,6 +310,7 @@ public static function forWidget(?Tenant $tenant): self
|
||||
profileName: (string) $profile->name,
|
||||
profileId: (int) $profile->getKey(),
|
||||
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||
duplicateNamePoliciesCount: null,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: [
|
||||
@ -277,6 +324,64 @@ public static function forWidget(?Tenant $tenant): self
|
||||
);
|
||||
}
|
||||
|
||||
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
|
||||
{
|
||||
$policyTypes = $effectiveScope->allTypes();
|
||||
|
||||
if ($policyTypes === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$compute = static function () use ($tenant, $policyTypes): int {
|
||||
/**
|
||||
* @var array<string, int> $countsByKey
|
||||
*/
|
||||
$countsByKey = [];
|
||||
|
||||
InventoryItem::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->whereNotNull('display_name')
|
||||
->select(['id', 'policy_type', 'display_name'])
|
||||
->orderBy('id')
|
||||
->chunkById(1_000, function ($inventoryItems) use (&$countsByKey): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$displayName = is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null;
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
|
||||
if ($subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$logicalKey = (string) $inventoryItem->policy_type.'|'.$subjectKey;
|
||||
$countsByKey[$logicalKey] = ($countsByKey[$logicalKey] ?? 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
$duplicatePolicies = 0;
|
||||
|
||||
foreach ($countsByKey as $count) {
|
||||
if ($count > 1) {
|
||||
$duplicatePolicies += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $duplicatePolicies;
|
||||
};
|
||||
|
||||
if (app()->environment('testing')) {
|
||||
return $compute();
|
||||
}
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'baseline_compare:tenant:%d:duplicate_names:%s',
|
||||
(int) $tenant->getKey(),
|
||||
hash('sha256', implode('|', $policyTypes)),
|
||||
);
|
||||
|
||||
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: list<string>, 2: ?string}
|
||||
*/
|
||||
@ -321,11 +426,90 @@ private static function coverageInfoForRun(?OperationRun $run): array
|
||||
return [$coverageStatus, $uncoveredTypes, $fidelity];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string}
|
||||
*/
|
||||
private static function reasonInfoForRun(?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];
|
||||
}
|
||||
|
||||
$reasonCode = $baselineCompare['reason_code'] ?? null;
|
||||
$reasonCode = is_string($reasonCode) ? trim($reasonCode) : null;
|
||||
$reasonCode = $reasonCode !== '' ? $reasonCode : null;
|
||||
|
||||
$enum = $reasonCode !== null ? BaselineCompareReasonCode::tryFrom($reasonCode) : null;
|
||||
|
||||
return [$reasonCode, $enum?->message()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?int, 1: array<string, int>}
|
||||
*/
|
||||
private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||
|
||||
if (! is_array($gaps)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$count = $gaps['count'] ?? null;
|
||||
$count = is_numeric($count) ? (int) $count : null;
|
||||
|
||||
$byReason = $gaps['by_reason'] ?? null;
|
||||
$byReason = is_array($byReason) ? $byReason : [];
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($byReason as $reason => $value) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$intValue = (int) $value;
|
||||
|
||||
if ($intValue <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $intValue;
|
||||
}
|
||||
|
||||
if ($count === null) {
|
||||
$count = array_sum($normalized);
|
||||
}
|
||||
|
||||
arsort($normalized);
|
||||
|
||||
return [$count, array_slice($normalized, 0, 6, true)];
|
||||
}
|
||||
|
||||
private static function empty(
|
||||
string $state,
|
||||
?string $message,
|
||||
?string $profileName = null,
|
||||
?int $profileId = null,
|
||||
?int $duplicateNamePoliciesCount = null,
|
||||
): self {
|
||||
return new self(
|
||||
state: $state,
|
||||
@ -333,6 +517,7 @@ private static function empty(
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
|
||||
86
app/Support/Baselines/BaselineEvidenceResumeToken.php
Normal file
86
app/Support/Baselines/BaselineEvidenceResumeToken.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use JsonException;
|
||||
|
||||
final class BaselineEvidenceResumeToken
|
||||
{
|
||||
private const int VERSION = 1;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
public static function encode(array $state): string
|
||||
{
|
||||
$payload = [
|
||||
'v' => self::VERSION,
|
||||
'state' => $state,
|
||||
];
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
|
||||
return self::base64UrlEncode($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function decode(string $token): ?array
|
||||
{
|
||||
$token = trim($token);
|
||||
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = self::base64UrlDecode($token);
|
||||
|
||||
if ($json === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = $decoded['v'] ?? null;
|
||||
$version = is_int($version) ? $version : (is_numeric($version) ? (int) $version : null);
|
||||
|
||||
if ($version !== self::VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = $decoded['state'] ?? null;
|
||||
|
||||
return is_array($state) ? $state : null;
|
||||
}
|
||||
|
||||
private static function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $value): ?string
|
||||
{
|
||||
$padded = strtr($value, '-_', '+/');
|
||||
$padding = strlen($padded) % 4;
|
||||
|
||||
if ($padding !== 0) {
|
||||
$padded .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
$decoded = base64_decode($padded, true);
|
||||
|
||||
return is_string($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Support/Baselines/BaselineFullContentRolloutGate.php
Normal file
23
app/Support/Baselines/BaselineFullContentRolloutGate.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class BaselineFullContentRolloutGate
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('tenantpilot.baselines.full_content_capture.enabled', false);
|
||||
}
|
||||
|
||||
public function assertEnabled(): void
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
throw new RuntimeException('Baseline full-content capture is disabled by rollout configuration.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ final class BaselineReasonCodes
|
||||
|
||||
public const string CAPTURE_PROFILE_NOT_ACTIVE = 'baseline.capture.profile_not_active';
|
||||
|
||||
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
|
||||
|
||||
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
|
||||
|
||||
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
|
||||
@ -23,4 +25,6 @@ final class BaselineReasonCodes
|
||||
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
|
||||
|
||||
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
|
||||
|
||||
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
|
||||
}
|
||||
|
||||
35
app/Support/Baselines/BaselineSubjectKey.php
Normal file
35
app/Support/Baselines/BaselineSubjectKey.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
final class BaselineSubjectKey
|
||||
{
|
||||
public static function fromDisplayName(?string $displayName): ?string
|
||||
{
|
||||
if (! is_string($displayName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($displayName);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$collapsed = preg_replace('/\\s+/u', ' ', $trimmed);
|
||||
$collapsed = is_string($collapsed) ? $collapsed : $trimmed;
|
||||
|
||||
$normalized = mb_strtolower($collapsed);
|
||||
$normalized = trim($normalized);
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
public static function workspaceSafeSubjectExternalId(string $policyType, string $subjectKey): string
|
||||
{
|
||||
return hash('sha256', $policyType.'|'.$subjectKey);
|
||||
}
|
||||
}
|
||||
|
||||
13
app/Support/Baselines/PolicyVersionCapturePurpose.php
Normal file
13
app/Support/Baselines/PolicyVersionCapturePurpose.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum PolicyVersionCapturePurpose: string
|
||||
{
|
||||
case Backup = 'backup';
|
||||
case BaselineCapture = 'baseline_capture';
|
||||
case BaselineCompare = 'baseline_compare';
|
||||
}
|
||||
|
||||
@ -375,6 +375,16 @@
|
||||
'include_operations_default' => (bool) env('TENANTPILOT_REVIEW_PACK_INCLUDE_OPERATIONS_DEFAULT', true),
|
||||
],
|
||||
|
||||
'baselines' => [
|
||||
'full_content_capture' => [
|
||||
'enabled' => (bool) env('TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED', false),
|
||||
'max_items_per_run' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN', 200),
|
||||
'max_concurrency' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY', 5),
|
||||
'max_retries' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES', 3),
|
||||
'retention_days' => (int) env('TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS', 90),
|
||||
],
|
||||
],
|
||||
|
||||
'hardening' => [
|
||||
'intune_write_gate' => [
|
||||
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
@ -26,6 +27,7 @@ public function definition(): array
|
||||
'description' => fake()->optional()->sentence(),
|
||||
'version_label' => fake()->optional()->numerify('v#.#'),
|
||||
'status' => BaselineProfileStatus::Draft->value,
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
|
||||
'active_snapshot_id' => null,
|
||||
'created_by_user_id' => null,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -18,13 +19,22 @@ class BaselineSnapshotItemFactory extends Factory
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$displayName = fake()->words(3, true);
|
||||
$policyType = 'deviceConfiguration';
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
$subjectExternalId = $subjectKey !== null
|
||||
? BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, $subjectKey)
|
||||
: fake()->uuid();
|
||||
|
||||
return [
|
||||
'baseline_snapshot_id' => BaselineSnapshot::factory(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => fake()->uuid(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_external_id' => $subjectExternalId,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => hash('sha256', fake()->uuid()),
|
||||
'meta_jsonb' => ['display_name' => fake()->words(3, true)],
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -28,6 +29,9 @@ public function definition(): array
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['example' => true],
|
||||
'metadata' => [],
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
'operation_run_id' => null,
|
||||
'baseline_profile_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_profiles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('baseline_profiles', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('baseline_profiles', 'capture_mode')) {
|
||||
$table->string('capture_mode')->default('opportunistic')->after('status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_profiles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('baseline_profiles', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('baseline_profiles', 'capture_mode')) {
|
||||
$table->dropColumn('capture_mode');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_snapshot_items')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('baseline_snapshot_items', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('baseline_snapshot_items', 'subject_key')) {
|
||||
$table->string('subject_key')->nullable()->after('subject_external_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('baseline_snapshot_items', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('baseline_snapshot_items', 'subject_key')) {
|
||||
$table->index(['baseline_snapshot_id', 'policy_type', 'subject_key'], 'baseline_snapshot_items_subject_key_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_snapshot_items')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('baseline_snapshot_items', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('baseline_snapshot_items', 'subject_key')) {
|
||||
$table->dropIndex('baseline_snapshot_items_subject_key_idx');
|
||||
$table->dropColumn('subject_key');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('policy_versions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('policy_versions', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('policy_versions', 'capture_purpose')) {
|
||||
$table->string('capture_purpose')->default('backup')->after('scope_tags_hash');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('policy_versions', 'operation_run_id')) {
|
||||
$table->unsignedBigInteger('operation_run_id')->nullable()->after('capture_purpose');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('policy_versions', 'baseline_profile_id')) {
|
||||
$table->unsignedBigInteger('baseline_profile_id')->nullable()->after('operation_run_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('policy_versions', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('policy_versions', 'capture_purpose')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table->index(['tenant_id', 'policy_id', 'capture_purpose', 'captured_at'], 'policy_versions_tenant_policy_purpose_captured_idx');
|
||||
|
||||
if (Schema::hasColumn('policy_versions', 'operation_run_id')) {
|
||||
$table->index(['tenant_id', 'capture_purpose', 'operation_run_id'], 'policy_versions_tenant_purpose_run_idx');
|
||||
$table->foreign('operation_run_id')->references('id')->on('operation_runs')->nullOnDelete();
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('policy_versions', 'baseline_profile_id')) {
|
||||
$table->index(['tenant_id', 'capture_purpose', 'baseline_profile_id'], 'policy_versions_tenant_purpose_profile_idx');
|
||||
$table->foreign('baseline_profile_id')->references('id')->on('baseline_profiles')->nullOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('policy_versions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('policy_versions', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('policy_versions', 'baseline_profile_id')) {
|
||||
$table->dropForeign(['baseline_profile_id']);
|
||||
$table->dropIndex('policy_versions_tenant_purpose_profile_idx');
|
||||
$table->dropColumn('baseline_profile_id');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('policy_versions', 'operation_run_id')) {
|
||||
$table->dropForeign(['operation_run_id']);
|
||||
$table->dropIndex('policy_versions_tenant_purpose_run_idx');
|
||||
$table->dropColumn('operation_run_id');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('policy_versions', 'capture_purpose')) {
|
||||
$table->dropIndex('policy_versions_tenant_policy_purpose_captured_idx');
|
||||
$table->dropColumn('capture_purpose');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
81
lang/en/baseline-compare.php
Normal file
81
lang/en/baseline-compare.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Baseline Compare Landing Page
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Duplicate-name warning banner
|
||||
'duplicate_warning_title' => 'Warning',
|
||||
'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.',
|
||||
'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
|
||||
|
||||
// Stats card labels
|
||||
'stat_assigned_baseline' => 'Assigned Baseline',
|
||||
'stat_total_findings' => 'Total Findings',
|
||||
'stat_last_compared' => 'Last Compared',
|
||||
'stat_last_compared_never' => 'Never',
|
||||
'stat_error' => 'Error',
|
||||
|
||||
// Badges
|
||||
'badge_snapshot' => 'Snapshot #:id',
|
||||
'badge_coverage_ok' => 'Coverage: OK',
|
||||
'badge_coverage_warnings' => 'Coverage: Warnings',
|
||||
'badge_fidelity' => 'Fidelity: :level',
|
||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
||||
|
||||
// Comparing state
|
||||
'comparing_indicator' => 'Comparing…',
|
||||
|
||||
// Why-no-findings explanations
|
||||
'no_findings_all_clear' => 'All clear',
|
||||
'no_findings_coverage_warnings' => 'Coverage warnings',
|
||||
'no_findings_evidence_gaps' => 'Evidence gaps',
|
||||
'no_findings_default' => 'No findings',
|
||||
|
||||
// Coverage warning banner
|
||||
'coverage_warning_title' => 'Comparison completed with warnings',
|
||||
'coverage_unproven_body' => 'Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.',
|
||||
'coverage_incomplete_body' => 'Findings were skipped for :count policy :types due to incomplete coverage.',
|
||||
'coverage_uncovered_label' => 'Uncovered: :list',
|
||||
|
||||
// Failed banner
|
||||
'failed_title' => 'Comparison Failed',
|
||||
'failed_body_default' => 'The last baseline comparison failed. Review the run details or retry.',
|
||||
|
||||
// Critical drift banner
|
||||
'critical_drift_title' => 'Critical Drift Detected',
|
||||
'critical_drift_body' => 'The current tenant state deviates from baseline :profile. :count high-severity :findings require immediate attention.',
|
||||
|
||||
// Empty states
|
||||
'empty_no_tenant' => 'No Tenant Selected',
|
||||
'empty_no_assignment' => 'No Baseline Assigned',
|
||||
'empty_no_snapshot' => 'No Snapshot Available',
|
||||
|
||||
// Findings section
|
||||
'findings_description' => 'The tenant configuration drifted from the baseline profile.',
|
||||
|
||||
// No drift
|
||||
'no_drift_title' => 'No Drift Detected',
|
||||
'no_drift_body' => 'The tenant configuration matches the baseline profile. Everything looks good.',
|
||||
|
||||
// Coverage warnings (no findings)
|
||||
'coverage_warnings_title' => 'Coverage Warnings',
|
||||
'coverage_warnings_body' => 'The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.',
|
||||
|
||||
// Idle
|
||||
'idle_title' => 'Ready to Compare',
|
||||
|
||||
// Buttons
|
||||
'button_view_run' => 'View run',
|
||||
'button_view_failed_run' => 'View failed run',
|
||||
'button_view_findings' => 'View all findings',
|
||||
'button_review_last_run' => 'Review last run',
|
||||
|
||||
];
|
||||
@ -5,21 +5,40 @@
|
||||
@endif
|
||||
|
||||
@php
|
||||
$hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
|
||||
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
||||
@endphp
|
||||
|
||||
@if ($duplicateNamePoliciesCountValue > 0)
|
||||
<div role="alert" 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">
|
||||
{{ __('baseline-compare.duplicate_warning_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-warning-800 dark:text-warning-300">
|
||||
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
|
||||
'count' => $duplicateNamePoliciesCountValue,
|
||||
'app' => config('app.name', 'TenantPilot'),
|
||||
]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Row 1: Stats Overview --}}
|
||||
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{{-- Stat: Assigned Baseline --}}
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_assigned_baseline') }}</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($snapshotId)
|
||||
<x-filament::badge color="success" size="sm" class="w-fit">
|
||||
Snapshot #{{ $snapshotId }}
|
||||
{{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@ -29,39 +48,49 @@
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
>
|
||||
Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }}
|
||||
{{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if (filled($fidelity))
|
||||
<x-filament::badge color="gray" size="sm" class="w-fit">
|
||||
Fidelity: {{ Str::title($fidelity) }}
|
||||
{{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($hasEvidenceGaps)
|
||||
<x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip">
|
||||
{{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
|
||||
<div class="mt-1 text-xs text-warning-700 dark:text-warning-300">
|
||||
{{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Stat: Total Findings --}}
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_total_findings') }}</div>
|
||||
@if ($state === 'failed')
|
||||
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
|
||||
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">{{ __('baseline-compare.stat_error') }}</div>
|
||||
@else
|
||||
<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') }}">
|
||||
<div class="text-3xl font-bold {{ $findingsColorClass }}">
|
||||
{{ $findingsCount ?? 0 }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($state === 'comparing')
|
||||
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
|
||||
<x-filament::loading-indicator class="h-3 w-3" />
|
||||
Comparing…
|
||||
{{ __('baseline-compare.comparing_indicator') }}
|
||||
</div>
|
||||
@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>
|
||||
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
|
||||
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $whyNoFindingsMessage ?? $whyNoFindingsFallback }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@ -69,13 +98,13 @@ class="w-fit"
|
||||
{{-- Stat: Last Compared --}}
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_last_compared') }}</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
|
||||
{{ $lastComparedAt ?? 'Never' }}
|
||||
{{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::link :href="$this->getRunUrl()" size="sm">
|
||||
View run
|
||||
{{ __('baseline-compare.button_view_run') }}
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
@ -85,23 +114,28 @@ class="w-fit"
|
||||
|
||||
{{-- 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 role="alert" 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
|
||||
{{ __('baseline-compare.coverage_warning_title') }}
|
||||
</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.
|
||||
{{ __('baseline-compare.coverage_unproven_body') }}
|
||||
@else
|
||||
Findings were skipped for {{ (int) ($uncoveredTypesCount ?? 0) }} policy {{ Str::plural('type', (int) ($uncoveredTypesCount ?? 0)) }} due to incomplete coverage.
|
||||
{{ __('baseline-compare.coverage_incomplete_body', [
|
||||
'count' => (int) ($uncoveredTypesCount ?? 0),
|
||||
'types' => Str::plural('type', (int) ($uncoveredTypesCount ?? 0)),
|
||||
]) }}
|
||||
@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
|
||||
{{ __('baseline-compare.coverage_uncovered_label', [
|
||||
'list' => implode(', ', array_slice($uncoveredTypes, 0, 6)) . (count($uncoveredTypes) > 6 ? '…' : ''),
|
||||
]) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -115,7 +149,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
View run
|
||||
{{ __('baseline-compare.button_view_run') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
@ -126,15 +160,15 @@ class="w-fit"
|
||||
|
||||
{{-- 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">
|
||||
<div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
||||
Comparison Failed
|
||||
{{ __('baseline-compare.failed_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-danger-700 dark:text-danger-300">
|
||||
{{ $failureReason ?? 'The last baseline comparison failed. Review the run details or retry.' }}
|
||||
{{ $failureReason ?? __('baseline-compare.failed_body_default') }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
@if ($this->getRunUrl())
|
||||
@ -146,7 +180,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
View failed run
|
||||
{{ __('baseline-compare.button_view_failed_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -157,16 +191,19 @@ class="w-fit"
|
||||
|
||||
{{-- Critical drift banner --}}
|
||||
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
|
||||
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div role="alert" class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
||||
Critical Drift Detected
|
||||
{{ __('baseline-compare.critical_drift_title') }}
|
||||
</div>
|
||||
<div class="text-sm text-danger-700 dark:text-danger-300">
|
||||
The current tenant state deviates from baseline <strong>{{ $profileName }}</strong>.
|
||||
{{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention.
|
||||
{{ __('baseline-compare.critical_drift_body', [
|
||||
'profile' => $profileName,
|
||||
'count' => $severityCounts['high'],
|
||||
'findings' => Str::plural('finding', $severityCounts['high']),
|
||||
]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -179,13 +216,13 @@ class="w-fit"
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
|
||||
@if ($state === 'no_tenant')
|
||||
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Tenant Selected</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_tenant') }}</div>
|
||||
@elseif ($state === 'no_assignment')
|
||||
<x-heroicon-o-link-slash class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Baseline Assigned</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_assignment') }}</div>
|
||||
@elseif ($state === 'no_snapshot')
|
||||
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Snapshot Available</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_snapshot') }}</div>
|
||||
@endif
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
|
||||
</div>
|
||||
@ -199,7 +236,7 @@ class="w-fit"
|
||||
{{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
|
||||
</x-slot>
|
||||
<x-slot name="description">
|
||||
The tenant configuration drifted from the baseline profile.
|
||||
{{ __('baseline-compare.findings_description') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@ -232,7 +269,7 @@ class="w-fit"
|
||||
icon="heroicon-o-eye"
|
||||
size="sm"
|
||||
>
|
||||
View all findings
|
||||
{{ __('baseline-compare.button_view_findings') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@ -245,7 +282,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
Review last run
|
||||
{{ __('baseline-compare.button_review_last_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -258,9 +295,9 @@ class="w-fit"
|
||||
<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" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">No Drift Detected</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.no_drift_title') }}</div>
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
||||
The tenant configuration matches the baseline profile. Everything looks good.
|
||||
{{ __('baseline-compare.no_drift_body') }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::button
|
||||
@ -271,7 +308,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
Review last run
|
||||
{{ __('baseline-compare.button_review_last_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -283,9 +320,9 @@ class="w-fit"
|
||||
<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="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.coverage_warnings_title') }}</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.
|
||||
{{ __('baseline-compare.coverage_warnings_body') }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::button
|
||||
@ -296,7 +333,7 @@ class="w-fit"
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
Review last run
|
||||
{{ __('baseline-compare.button_review_last_run') }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
@ -308,7 +345,7 @@ class="w-fit"
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
||||
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">Ready to Compare</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.idle_title') }}</div>
|
||||
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $message }}
|
||||
</div>
|
||||
|
||||
@ -39,6 +39,11 @@
|
||||
->name('tenantpilot:review-pack:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('tenantpilot:baseline-evidence:prune')
|
||||
->daily()
|
||||
->name('tenantpilot:baseline-evidence:prune')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::call(function (): void {
|
||||
$tenants = Tenant::query()
|
||||
->whereHas('providerConnections', function ($q): void {
|
||||
|
||||
48
specs/118-baseline-drift-engine/checklists/requirements.md
Normal file
48
specs/118-baseline-drift-engine/checklists/requirements.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Specification Quality Checklist: Golden Master Deep Drift v2 (Full Content Capture)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-03
|
||||
**Feature**: [specs/118-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
|
||||
|
||||
## Constitution & Spec 118 Gates
|
||||
|
||||
- [x] Cross-tenant subject matching terminology is defined and consistent (`subject_key` is defined and tied to normalization rules)
|
||||
- [x] Workspace-owned snapshot items explicitly forbid persisting tenant identifiers (including tenant IDs and tenant external IDs)
|
||||
- [x] Compare behavior for missing/ambiguous cross-tenant matching is specified (gap reason + suppress drift evaluation)
|
||||
- [x] Coverage proof guard is specified (missing-policy outcomes suppressed when coverage is unproven)
|
||||
- [x] Rollout gate requirement exists for full-content mode (canary flag)
|
||||
- [x] Security requirement exists to redact secrets/PII before persistence/fingerprinting
|
||||
- [x] Baseline-purpose evidence visibility is explicitly tied to baseline-related capabilities (no `tenant.view`-only access)
|
||||
- [x] Audit events requirement exists for starting capture/compare runs (and includes purpose + summary context)
|
||||
- [x] Retention requirement exists for baseline-purpose evidence distinct from long-term backups
|
||||
- [x] Findings recurrence identity/lifecycle requirement exists and is independent of fingerprints
|
||||
- [x] Ops-UX “no silent zeros” requirement exists (reason codes + UI explanation)
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-03-03; all checks passing.
|
||||
95
specs/118-baseline-drift-engine/contracts/openapi.yaml
Normal file
95
specs/118-baseline-drift-engine/contracts/openapi.yaml
Normal file
@ -0,0 +1,95 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot — Spec 118 Golden Master Deep Drift v2
|
||||
version: 0.1.0
|
||||
description: |
|
||||
This contract documents existing Filament panel routes and Monitoring surfaces involved
|
||||
in baseline capture/compare and drift findings. Spec 118 does not add new public HTTP APIs;
|
||||
it extends queued operation behavior and Filament action surfaces.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
|
||||
tags:
|
||||
- name: Baselines
|
||||
- name: Findings
|
||||
- name: Operations
|
||||
|
||||
paths:
|
||||
/admin/baseline-profiles:
|
||||
get:
|
||||
tags: [Baselines]
|
||||
summary: Baseline profiles index (Filament)
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/admin/baseline-profiles/{record}:
|
||||
get:
|
||||
tags: [Baselines]
|
||||
summary: Baseline profile detail (Filament)
|
||||
parameters:
|
||||
- name: record
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/admin/t/{tenant}/baseline-compare-landing:
|
||||
get:
|
||||
tags: [Baselines]
|
||||
summary: Baseline compare landing (Filament tenant-context)
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/admin/t/{tenant}/findings:
|
||||
get:
|
||||
tags: [Findings]
|
||||
summary: Findings list (Filament tenant-context)
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/admin/t/{tenant}/findings/{record}:
|
||||
get:
|
||||
tags: [Findings]
|
||||
summary: Finding detail (Filament tenant-context)
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: record
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/admin/operations:
|
||||
get:
|
||||
tags: [Operations]
|
||||
summary: Operation runs list (Monitoring)
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
tags: [Operations]
|
||||
summary: Operation run detail (Monitoring)
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
178
specs/118-baseline-drift-engine/data-model.md
Normal file
178
specs/118-baseline-drift-engine/data-model.md
Normal file
@ -0,0 +1,178 @@
|
||||
# Data Model — Spec 118 Golden Master Deep Drift v2
|
||||
|
||||
This document describes the data shapes required to implement full-content baseline capture/compare with quota-aware, resumable evidence capture.
|
||||
|
||||
Spec reference: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md`
|
||||
|
||||
## Entities (existing)
|
||||
|
||||
### `baseline_profiles` (workspace-owned)
|
||||
|
||||
- Purpose: defines baseline name, scope, and (new) capture mode.
|
||||
- Current fields (from repo):
|
||||
- `id`, `workspace_id`, `name`, `description`, `version_label`, `status`
|
||||
- `scope_jsonb`
|
||||
- `active_snapshot_id`
|
||||
- `created_by_user_id`
|
||||
|
||||
### `baseline_snapshots` (workspace-owned)
|
||||
|
||||
- Purpose: immutable baseline snapshot, deduped by a snapshot identity hash.
|
||||
- Current fields:
|
||||
- `id`, `workspace_id`, `baseline_profile_id`
|
||||
- `snapshot_identity_hash` (sha256 string)
|
||||
- `captured_at`
|
||||
- `summary_jsonb`
|
||||
|
||||
### `baseline_snapshot_items` (workspace-owned; no tenant identifiers)
|
||||
|
||||
- Purpose: per-subject baseline evidence for drift evaluation.
|
||||
- Current fields:
|
||||
- `baseline_snapshot_id`
|
||||
- `subject_type` (currently `policy`)
|
||||
- `subject_external_id` (legacy column name; MUST NOT store tenant external IDs in Spec 118 flows)
|
||||
- `policy_type`
|
||||
- `baseline_hash` (fingerprint)
|
||||
- `meta_jsonb` (metadata + provenance)
|
||||
|
||||
### `policy_versions` (tenant-owned evidence)
|
||||
|
||||
- Purpose: immutable captured policy content with assignments/scope tags and hashes, used as content-fidelity evidence.
|
||||
- Current fields (selected):
|
||||
- `tenant_id`, `policy_id`, `policy_type`, `platform`
|
||||
- `captured_at`
|
||||
- `snapshot`, `metadata`, `assignments`, `scope_tags`
|
||||
- `assignments_hash`, `scope_tags_hash`
|
||||
|
||||
### `operation_runs` (tenant-owned operational record)
|
||||
|
||||
- Purpose: observable lifecycle for capture/compare operations; `summary_counts` is numeric-only and key-whitelisted; diagnostics go in `context`.
|
||||
|
||||
### `findings` (tenant-owned drift outcomes)
|
||||
|
||||
- Purpose: drift findings produced by compare; recurrence/lifecycle fields already exist in the repo (incl. `recurrence_key`).
|
||||
|
||||
## Proposed changes (Spec 118)
|
||||
|
||||
### 1) BaselineProfile: add capture mode
|
||||
|
||||
**Add column**: `baseline_profiles.capture_mode` (string)
|
||||
|
||||
- Allowed values: `meta_only | opportunistic | full_content`
|
||||
- Default: `opportunistic` (maintains current behavior unless explicitly enabled)
|
||||
- Validation: only allow known values
|
||||
|
||||
### 2) Baseline snapshot item: introduce a cross-tenant subject key
|
||||
|
||||
**Add column**: `baseline_snapshot_items.subject_key` (string)
|
||||
|
||||
- Meaning: cross-tenant match key for a subject: `normalized_display_name`
|
||||
- Normalization rules: trim, collapse internal whitespace, lowercase
|
||||
- Index: `index(baseline_snapshot_id, policy_type, subject_key)`
|
||||
|
||||
Notes:
|
||||
- Workspace-owned snapshot items MUST NOT persist tenant identifiers. In Spec 118 flows:
|
||||
- `baseline_snapshot_items.subject_external_id` is treated as an opaque, workspace-safe **subject id** derived from `policy_type + subject_key` (e.g. `sha256(policy_type|subject_key)`), solely to satisfy existing uniqueness/lookup needs.
|
||||
- Tenant-specific external IDs remain tenant-scoped and live only in tenant-owned tables (`policies`, `inventory_items`, `policy_versions`) and in tenant-scoped `operation_runs.context`.
|
||||
- `meta_jsonb` stored on snapshot items MUST be baseline-safe (no tenant external IDs, no operation run IDs, no policy version IDs). It should include only cross-tenant metadata like `display_name`, `policy_type`, and a fidelity indicator (`content` vs `meta`).
|
||||
- Duplicate/ambiguous `subject_key` values within the same policy type are treated as evidence gaps and are not evaluated for drift.
|
||||
|
||||
### 3) PolicyVersion: purpose tagging + traceability
|
||||
|
||||
**Add columns** (all nullable except purpose):
|
||||
|
||||
- `policy_versions.capture_purpose` (string)
|
||||
- Allowed: `backup | baseline_capture | baseline_compare`
|
||||
- Default for existing rows: `backup` (or null → treated as `backup` at read time; exact backfill strategy documented in migration plan)
|
||||
- `policy_versions.operation_run_id` (unsigned bigint, nullable) → FK to `operation_runs.id`
|
||||
- `policy_versions.baseline_profile_id` (unsigned bigint, nullable) → FK to `baseline_profiles.id`
|
||||
|
||||
**Indexes** (for audit/debug + idempotency checks):
|
||||
|
||||
- `(tenant_id, policy_id, capture_purpose, captured_at desc)`
|
||||
- `(tenant_id, capture_purpose, operation_run_id)`
|
||||
- `(tenant_id, capture_purpose, baseline_profile_id)`
|
||||
|
||||
Retention:
|
||||
- Baseline-purpose evidence is eligible for shorter retention (configurable) than long-term backup evidence.
|
||||
|
||||
### 4) OperationRun context: baseline capture/compare contract
|
||||
|
||||
Baseline runs should populate `operation_runs.context` with stable, operator-facing keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"target_scope": {
|
||||
"entra_tenant_id": "...",
|
||||
"entra_tenant_name": "...",
|
||||
"directory_context_id": "..."
|
||||
},
|
||||
"baseline_profile_id": 123,
|
||||
"baseline_snapshot_id": 456,
|
||||
"capture_mode": "full_content",
|
||||
"effective_scope": {
|
||||
"policy_types": ["..."],
|
||||
"foundation_types": ["..."],
|
||||
"all_types": ["..."]
|
||||
},
|
||||
"baseline_capture": {
|
||||
"subjects_total": 500,
|
||||
"evidence_capture": {
|
||||
"requested": 200,
|
||||
"succeeded": 180,
|
||||
"skipped": 10,
|
||||
"failed": 10,
|
||||
"throttled": 0
|
||||
},
|
||||
"gaps": {
|
||||
"count": 25,
|
||||
"top_reasons": ["forbidden", "throttled", "ambiguous_match"]
|
||||
},
|
||||
"resume_token": "opaque_token_string"
|
||||
},
|
||||
"baseline_compare": {
|
||||
"inventory_sync_run_id": 999,
|
||||
"since": "2026-03-03T09:00:00Z",
|
||||
"coverage": {
|
||||
"proof": true,
|
||||
"effective_types": ["..."],
|
||||
"covered_types": ["..."],
|
||||
"uncovered_types": ["..."]
|
||||
},
|
||||
"fidelity": "content|meta|mixed",
|
||||
"evidence_capture": {
|
||||
"requested": 200,
|
||||
"succeeded": 180,
|
||||
"skipped": 10,
|
||||
"failed": 10,
|
||||
"throttled": 0
|
||||
},
|
||||
"evidence_gaps": {
|
||||
"missing_current": 20,
|
||||
"ambiguous_match": 3
|
||||
},
|
||||
"reason_code": "no_subjects_in_scope|coverage_unproven|evidence_capture_incomplete|rollout_disabled|no_drift_detected|..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `target_scope` is required for Monitoring UI (“Target” display).
|
||||
- Rich diagnostics remain in `context`; `summary_counts` stays within the numeric key whitelist.
|
||||
|
||||
## Migration strategy
|
||||
|
||||
1) Add `baseline_profiles.capture_mode`.
|
||||
2) Add `baseline_snapshot_items.subject_key` + index.
|
||||
3) Add `policy_versions.capture_purpose`, `operation_run_id`, `baseline_profile_id` + indexes.
|
||||
4) Backfill strategy:
|
||||
- Existing `policy_versions` rows: set `capture_purpose = backup` (or treat null as backup in code until backfill finishes).
|
||||
- Existing baseline snapshot items: set `subject_key` from stored `meta_jsonb.display_name` when available (else empty; treated as gap in new logic).
|
||||
|
||||
## Validation rules
|
||||
|
||||
- `capture_mode` must be one of: `meta_only`, `opportunistic`, `full_content`.
|
||||
- `subject_key` must be non-empty to be eligible for drift evaluation.
|
||||
- For full-content capture mode:
|
||||
- Capture/compare runs must record evidence capture stats and gaps.
|
||||
- Compare must not emit “missing policy” findings for uncovered policy types.
|
||||
161
specs/118-baseline-drift-engine/plan.md
Normal file
161
specs/118-baseline-drift-engine/plan.md
Normal file
@ -0,0 +1,161 @@
|
||||
# Implementation Plan: Golden Master Deep Drift v2 (Full Content Capture)
|
||||
|
||||
**Branch**: `118-baseline-drift-engine` | **Date**: 2026-03-03 | **Spec**: /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Enable reliable, settings-level drift detection (“deep drift”) for Golden Master baselines by making baseline capture and baseline compare self-sufficient:
|
||||
|
||||
- For baseline profiles configured for full-content capture, both capture and compare automatically capture the required policy content evidence on demand (quota-aware, resumable), rather than relying on opportunistic evidence.
|
||||
- Drift comparison uses the existing canonical fingerprinting pipeline and evidence provider chain (content-first, explicit degraded fallback), with “no legacy” enforced via code paths and automated guards.
|
||||
- Operations are observable and explainable: each run records effective scope, coverage proof, fidelity breakdown, evidence capture stats, evidence gaps, and “why no findings” reason codes.
|
||||
- Security and governance constraints are enforced: captured policy evidence is redacted before persistence/fingerprinting, audit events are emitted for capture/compare/resume mutations, baseline-purpose evidence is pruned per retention policy, and full-content mode is gated by a short-lived rollout flag.
|
||||
- Admin UX exposes single-click actions (“Capture baseline (full content)”, “Compare now (full content)”, and “Resume capture” when applicable), surfaces evidence gaps clearly, and provides baseline snapshot fidelity visibility (content-complete vs gaps).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12.52, Filament 5.2, Livewire 4.1, Microsoft Graph integration via `GraphClientInterface`
|
||||
**Storage**: PostgreSQL (JSONB-heavy for evidence/snapshots)
|
||||
**Testing**: Pest 4.3 (PHPUnit 12.5)
|
||||
**Target Platform**: Containerized web app (Local: Sail; Staging/Production: Dokploy)
|
||||
**Project Type**: Web application (Laravel monolith + Filament admin panel)
|
||||
**Performance Goals**: Capture/compare runs handle 200–500 in-scope subjects per run under throttling constraints, without blocking UI; evidence capture is bounded and resumable.
|
||||
**Constraints**: All long-running + remote work is async + observable via `OperationRun`; rate limits (429/503) must back off safely; no secrets/PII persisted in evidence or logs; tenant/workspace isolation is strict.
|
||||
**Scale/Scope**: Multi-workspace, multi-tenant; per tenant potentially hundreds–thousands of policies; baselines may be assigned to multiple tenants in a workspace.
|
||||
|
||||
**Initial budget defaults (v1, adjustable via config)**:
|
||||
- `TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200`
|
||||
- `TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5`
|
||||
- `TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3`
|
||||
- `TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90`
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- PASS — Inventory-first: Inventory remains the subject index (“last observed”), while content evidence is captured explicitly as immutable policy versions for comparison.
|
||||
- PASS — Read/write separation: this feature adds/extends read-only capture/compare operations (no restore); any destructive UI actions remain confirmed + audited.
|
||||
- PASS — Graph contract path: evidence capture uses existing Graph client abstractions and contract registry (`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/graph_contracts.php`); no direct/adhoc endpoints in feature code.
|
||||
- PASS — Deterministic capabilities: capability gating continues through the canonical capability resolvers and enforcement helpers (no role-string checks).
|
||||
- PASS — RBAC-UX: workspace membership + capability gates enforced server-side; non-member access is deny-as-not-found; member missing capability is forbidden.
|
||||
- PASS — Workspace & tenant isolation: baseline profiles/snapshots are workspace-owned; compare runs/findings/evidence remain tenant-scoped; canonical Monitoring pages remain DB-only at render time.
|
||||
- PASS — Ops observability: baseline capture/compare are `OperationRun`-backed; start surfaces enqueue-only; no remote work at render time.
|
||||
- PASS — Ops-UX 3-surface feedback + lifecycle + summary counts: enqueue toast uses the canonical presenter; progress shown only in global widget + run detail; completion emits exactly one terminal DB notification to initiator; status/outcome transitions remain service-owned; summary counts stay numeric-only using canonical keys.
|
||||
- PASS — Automation & throttling: evidence capture respects 429/503 backoff + jitter (client + phase-level budget handling) and supports resumption via an opaque token stored in run context.
|
||||
- PASS — BADGE-001: any new/changed badges use existing badge catalog mapping (no ad-hoc).
|
||||
- PASS — Filament action surface + UX-001: actions are declared, capability-gated, and confirmed where destructive-like; tables maintain an inspect affordance; view uses infolists; empty states have 1 CTA.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/BaselineCompareLanding.php
|
||||
│ ├── Resources/BaselineProfileResource.php
|
||||
│ └── Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
|
||||
├── Jobs/
|
||||
│ ├── CaptureBaselineSnapshotJob.php
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Models/
|
||||
│ ├── BaselineProfile.php
|
||||
│ ├── BaselineSnapshot.php
|
||||
│ ├── BaselineSnapshotItem.php
|
||||
│ ├── BaselineTenantAssignment.php
|
||||
│ ├── Policy.php
|
||||
│ ├── PolicyVersion.php
|
||||
│ ├── InventoryItem.php
|
||||
│ ├── OperationRun.php
|
||||
│ └── Finding.php
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCaptureService.php
|
||||
│ │ ├── BaselineCompareService.php
|
||||
│ │ ├── CurrentStateHashResolver.php
|
||||
│ │ └── Evidence/
|
||||
│ ├── Intune/PolicyCaptureOrchestrator.php
|
||||
│ └── OperationRunService.php
|
||||
├── Support/
|
||||
│ ├── Baselines/
|
||||
│ ├── OpsUx/
|
||||
│ └── OperationRunType.php
|
||||
config/
|
||||
├── graph_contracts.php
|
||||
└── tenantpilot.php
|
||||
database/
|
||||
└── migrations/
|
||||
tests/
|
||||
└── Feature/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. Baseline drift orchestration lives in `app/Services/Baselines` + `app/Jobs`, UI in `app/Filament`, and evidence capture reuses `app/Services/Intune` capture orchestration.
|
||||
|
||||
Tasks are defined in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/tasks.md`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for Spec 118 planning. (Table intentionally omitted.)
|
||||
|
||||
## Phase 0 — Research (output: research.md)
|
||||
|
||||
Goals:
|
||||
- Confirm precise extension points for adding full-content evidence capture to existing baseline capture/compare jobs.
|
||||
- Decide the purpose-tagging and idempotency strategy for baseline evidence captured as `PolicyVersion`.
|
||||
- Confirm Monitoring run detail requirements for `context.target_scope` and baseline-specific context sections.
|
||||
|
||||
Deliverable: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/research.md`
|
||||
|
||||
## Phase 1 — Design (output: data-model.md + contracts/* + quickstart.md)
|
||||
|
||||
Deliverables:
|
||||
- Data model changes + JSON context shapes: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/data-model.md`
|
||||
- Route surface contract reference: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/contracts/openapi.yaml`
|
||||
- Developer quickstart: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/quickstart.md`
|
||||
|
||||
Post-design constitution re-check: PASS (see decisions in research + data model docs; Ops-UX and RBAC constraints preserved).
|
||||
|
||||
## Phase 2 — Implementation Planning (high-level)
|
||||
|
||||
1) Add migrations:
|
||||
- `baseline_profiles.capture_mode`
|
||||
- `baseline_snapshot_items.subject_key`
|
||||
- `policy_versions.capture_purpose`, `operation_run_id`, `baseline_profile_id` + indexes
|
||||
2) Implement quota-aware, resumable baseline evidence capture phase:
|
||||
- reuse existing capture orchestration (policy payload + assignments + scope tags)
|
||||
- emit capture stats + resume token in `OperationRun.context`
|
||||
3) Integrate the capture phase into:
|
||||
- baseline capture job (before snapshot build)
|
||||
- baseline compare job (refresh phase before drift evaluation)
|
||||
4) Update drift matching to use cross-tenant subject key (`policy_type + subject_key`) where `subject_key` is the normalized display name, and record ambiguous/missing match as evidence gaps (no finding).
|
||||
5) Update Ops-UX context:
|
||||
- ensure `context.target_scope` exists for baseline capture/compare runs
|
||||
- add “why no findings” reason codes
|
||||
6) Update UI action surfaces:
|
||||
- Baseline profile: capture mode + “Capture baseline (full content)” + tenant-targeted “Compare now (full content)”
|
||||
- Operation run detail: evidence capture panel + “Resume capture” when token exists
|
||||
7) Add focused Pest tests:
|
||||
- full-content capture creates content-fidelity snapshot items (or warnings + gaps)
|
||||
- compare detects settings drift with content evidence
|
||||
- throttling/resume semantics and “no silent zeros” reason codes
|
||||
8) Add governance hardening:
|
||||
- enforce rollout gate across UI/services/jobs for full-content mode
|
||||
- redact secrets/PII from captured evidence before persistence/fingerprinting
|
||||
- emit audit events for capture/compare/resume operations
|
||||
- prune baseline-purpose evidence per retention policy (scheduled)
|
||||
43
specs/118-baseline-drift-engine/quickstart.md
Normal file
43
specs/118-baseline-drift-engine/quickstart.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Quickstart — Spec 118 Golden Master Deep Drift v2
|
||||
|
||||
Spec reference: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md`
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Dependencies installed: `vendor/bin/sail composer install`
|
||||
- Containers up: `vendor/bin/sail up -d`
|
||||
|
||||
## Run the minimum checks
|
||||
|
||||
- Format (dirty only): `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- Tests (focused): `vendor/bin/sail artisan test --compact --filter=Baseline` (adjust filter to match added tests)
|
||||
|
||||
## Manual verification flow (admin)
|
||||
|
||||
1) In `/admin`, open a Baseline Profile and set capture mode to **Full content**.
|
||||
2) Run **Capture baseline (full content)** and select a source tenant.
|
||||
3) Open Monitoring → Operations → the capture `OperationRun`:
|
||||
- Verify `context.target_scope` renders a “Target” (no “No target scope details…”).
|
||||
- Verify `context.baseline_capture.evidence_capture` and `context.baseline_capture.gaps` exist.
|
||||
- If capture was incomplete, verify `context.baseline_capture.resume_token` exists and UI offers **Resume capture**.
|
||||
4) In `/admin/t/{tenant}`, open Baseline Compare and run **Compare now (full content)**.
|
||||
5) Open Monitoring → the compare `OperationRun`:
|
||||
- Verify coverage proof is recorded (`context.baseline_compare.coverage`).
|
||||
- Verify evidence capture stats and evidence gaps are present.
|
||||
- Verify “Why no findings?” is explained via `context.baseline_compare.reason_code` when applicable.
|
||||
6) Open Findings:
|
||||
- Verify drift findings appear when policy settings differ.
|
||||
- Verify findings include fidelity/provenance fields for baseline and current evidence.
|
||||
|
||||
## Forcing a resumable run (dev-only)
|
||||
|
||||
To test resume behavior without waiting for real throttling:
|
||||
|
||||
- Temporarily reduce the per-run capture budget in config (planned in Spec 118) so the run cannot process the full scope.
|
||||
- Re-run capture/compare and verify a resume token is recorded and **Resume capture** continues work without duplicating already-captured subjects.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If UI changes don’t appear, run assets: `vendor/bin/sail npm run dev`.
|
||||
- If tests fail due to stale schema, run: `vendor/bin/sail artisan migrate`.
|
||||
102
specs/118-baseline-drift-engine/research.md
Normal file
102
specs/118-baseline-drift-engine/research.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Research — Spec 118 Golden Master Deep Drift v2
|
||||
|
||||
This document resolves planning unknowns for implementing `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/118-baseline-drift-engine/spec.md` in the existing Laravel + Filament codebase.
|
||||
|
||||
## Decision 1 — Full-content evidence capture orchestration
|
||||
|
||||
**Decision**: Introduce a dedicated “baseline content capture” phase that can be invoked from both baseline capture and baseline compare:
|
||||
|
||||
- Baseline capture (`baseline_capture` run): capture evidence needed to build a content-fidelity baseline snapshot (as budget allows).
|
||||
- Baseline compare (`baseline_compare` run): refresh current evidence before drift evaluation (as budget allows).
|
||||
|
||||
The phase reuses the existing Intune capture orchestration (`/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Intune/PolicyCaptureOrchestrator.php`) so we do not introduce a second capture implementation.
|
||||
|
||||
**Rationale**:
|
||||
- Aligns with Spec 118 goal: deep drift by default, without per-policy manual capture.
|
||||
- Keeps a single source of truth for content capture (policy payload + assignments + scope tags).
|
||||
- Makes quota management, retries, and resumability explicit at the operation level.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Opportunistic only (rejected: repeats Spec 117 fragility; “no drift” can still be a silent failure).
|
||||
- UI-driven per-policy capture (rejected: explicitly out of UX goals).
|
||||
|
||||
## Decision 2 — PolicyVersion purpose tagging + run traceability
|
||||
|
||||
**Decision**: Extend `policy_versions` with baseline-purpose attribution:
|
||||
|
||||
- `capture_purpose`: `backup | baseline_capture | baseline_compare`
|
||||
- `operation_run_id` (nullable): link to the run that captured the version
|
||||
- `baseline_profile_id` (nullable): link for baseline_* captures
|
||||
|
||||
**Rationale**:
|
||||
- Enables audit/debug (“which run produced this evidence, for what purpose?”) without introducing a separate evidence table.
|
||||
- Supports idempotency and “resume capture” semantics (skip already-captured subjects for the same run/purpose).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store purpose only in `policy_versions.metadata` (rejected: harder to index/query; weaker guardrails).
|
||||
- Create an EvidenceItems model now (rejected: explicitly not required in Spec 118).
|
||||
|
||||
## Decision 3 — Golden Master subject matching across tenants
|
||||
|
||||
**Decision**: Treat the Golden Master “subject identity” as a cross-tenant match key derived from policy display name:
|
||||
|
||||
- Subject match key: `policy_type + normalized_display_name`
|
||||
- `normalized_display_name` rules: trim leading/trailing whitespace, collapse internal whitespace to single spaces, lowercase.
|
||||
|
||||
Implementation uses a dedicated snapshot-item field (e.g., `baseline_snapshot_items.subject_key`) for matching, while preserving tenant-specific external IDs separately for evidence resolution.
|
||||
|
||||
Ambiguous/missing match handling:
|
||||
- Missing match in current tenant → eligible for “missing policy” (only with coverage proof).
|
||||
- Multiple matches for the same key within a tenant/type → record evidence gap and suppress drift evaluation for that subject key (no finding).
|
||||
|
||||
**Rationale**:
|
||||
- Baselines are workspace-owned and can be assigned to multiple tenants; external IDs are tenant-specific and cannot be used for cross-tenant matching.
|
||||
- The match key keeps snapshot items free of tenant identifiers while enabling consistent comparisons.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Match by tenant external ID (rejected: breaks cross-tenant baseline assignment).
|
||||
- Require per-tenant baseline snapshots (rejected for Spec 118: changes product semantics and assignment UX).
|
||||
- Introduce an explicit mapping table (rejected for R1: higher effort and requires operational UX not described in spec).
|
||||
|
||||
## Decision 4 — Quota-aware capture + resumable token
|
||||
|
||||
**Decision**: Evidence capture is bounded and resumable:
|
||||
|
||||
- Enforce per-run limits (max items, max concurrency, max retry attempts).
|
||||
- Store an opaque “resume token” in `operation_runs.context` when a run cannot complete within budget.
|
||||
- Provide a “Resume capture” UI action that starts a follow-up run continuing from that token.
|
||||
|
||||
**Rationale**:
|
||||
- Large tenants/scopes must not create uncontrolled queue storms or long-running jobs.
|
||||
- Operators need explicit visibility into “what was captured vs skipped” and a safe path to completion.
|
||||
|
||||
**Alternatives considered**:
|
||||
- “Always finish no matter what” (rejected: risks rate limiting and operational instability).
|
||||
- Mark run failed on any capture failure (rejected: Spec 118 allows partial failure with warnings).
|
||||
|
||||
## Decision 5 — Ops-UX + run context contract (“Why no findings?”)
|
||||
|
||||
**Decision**: Baseline runs explicitly populate:
|
||||
|
||||
- `context.target_scope` (required for Monitoring run detail; avoids “No target scope details…”)
|
||||
- `context.effective_scope` + `context.capture_mode`
|
||||
- evidence capture stats + gaps + reason codes when subjects processed = 0 or findings = 0
|
||||
|
||||
Keep `summary_counts` numeric-only and limited to keys from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/OperationSummaryKeys.php`; store richer detail in `context`.
|
||||
|
||||
**Rationale**:
|
||||
- Eliminates ambiguous “0 findings” outcomes and improves operator trust.
|
||||
- Conforms to Ops-UX 3-surface feedback contract and Monitoring expectations.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Put details into `summary_counts` (rejected: key whitelist contract).
|
||||
- Only log details (rejected: operators need UI visibility).
|
||||
|
||||
## Notes on current codebase (facts observed)
|
||||
|
||||
- Baseline capture run creation: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/BaselineCaptureService.php`
|
||||
- Baseline compare run creation: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/BaselineCompareService.php`
|
||||
- Capture job (currently opportunistic content): `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- Compare job (provider-chain evidence): `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- Evidence providers + resolver: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/CurrentStateHashResolver.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/Evidence/*`
|
||||
- Monitoring target scope rendering expects `context.target_scope`: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php`
|
||||
281
specs/118-baseline-drift-engine/spec.md
Normal file
281
specs/118-baseline-drift-engine/spec.md
Normal file
@ -0,0 +1,281 @@
|
||||
# Feature Specification: Golden Master Deep Drift v2 (Full Content Capture)
|
||||
|
||||
**Feature Branch**: `118-baseline-drift-engine`
|
||||
**Created**: 2026-03-03
|
||||
**Status**: Draft (implementable)
|
||||
**Input**: User description: "Spec 118 — Golden Master Deep Drift v2: Full Content Capture (policy snapshot-backed), quota-aware, resumable, no-legacy"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace (baseline definition + baseline snapshots) + tenant (compare runs + findings + evidence capture)
|
||||
- **Primary Routes**:
|
||||
- Workspace admin: Baseline Profiles (list, create/edit, detail) + Baseline Snapshots (list/detail)
|
||||
- Tenant-context admin: Baseline Compare runs (start, list, detail) + Drift Findings landing
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: baseline profiles, baseline snapshots, baseline snapshot items
|
||||
- Tenant-scoped (within a workspace): operation runs for baseline capture/compare, drift findings, and tenant policy evidence captured for baseline purposes
|
||||
- Baseline snapshots are workspace-owned standards captured from a chosen tenant and are comparable against other tenants in the same workspace, but snapshot items MUST NOT persist tenant identifiers (including tenant IDs, tenant external IDs, policy version IDs, and operation run IDs).
|
||||
- **RBAC**:
|
||||
- Workspace Baselines:
|
||||
- `workspace_baselines.view`: view baseline profiles + snapshots
|
||||
- `workspace_baselines.manage`: create/edit baseline profiles, start baseline capture
|
||||
- Tenant Compare + Findings:
|
||||
- `tenant.sync`: start baseline compare runs (and any compare-time evidence refresh)
|
||||
- `tenant_findings.view`: view drift findings
|
||||
- Tenant access is required for tenant-context surfaces, in addition to workspace membership
|
||||
- Evidence created for baseline purposes MUST NOT be broadly discoverable outside baseline-related permissions.
|
||||
- **Baseline-purpose evidence visibility**: Tenant-owned evidence snapshots / policy versions captured for baseline purposes (e.g. `capture_purpose=baseline_capture|baseline_compare`) MUST be visible only to tenant members with `tenant.sync` or `tenant_findings.view` (never via `tenant.view` alone).
|
||||
|
||||
For canonical-view specs: not applicable (this is not a canonical-view feature).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-03
|
||||
|
||||
- Q: Are baseline snapshots reusable across multiple tenants in the workspace? → A: Yes — baseline snapshots are reusable across multiple tenants in the same workspace (cross-tenant compare is in-scope).
|
||||
- Q: How should cross-tenant subject matching work? → A: Match by `policy_type + normalized display_name`.
|
||||
- Q: What should compare do when cross-tenant matching is missing/ambiguous? → A: Record an evidence gap reason and suppress drift evaluation for those subjects.
|
||||
- Q: What are the exact rules for `normalized display_name`? → A: `trim` + collapse internal whitespace to single spaces + lowercase.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Golden Master baseline compare frequently produces “no drift” even when policy settings changed, because the current state used for comparison is often limited to a metadata-level signal, while the real configuration is only visible in full policy content.
|
||||
|
||||
This spec makes Golden Master self-sufficient for deep drift: when a baseline profile is configured for full-content capture, baseline capture and baseline compare automatically generate the required evidence on demand and compare stable content-based fingerprints.
|
||||
|
||||
## Goals
|
||||
|
||||
- Deep drift by default for baselines configured for full-content capture.
|
||||
- One compare engine: no parallel legacy compare / fingerprinting / canonicalization logic paths.
|
||||
- Quota-aware and resumable evidence capture that remains safe under throttling and transient upstream errors.
|
||||
- Auditability: each run clearly documents scope, coverage, fidelity, evidence capture stats, and any evidence gaps.
|
||||
- Operator UX: an admin can “Capture baseline (full content)” and “Compare now (full content)” without per-policy manual capture.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No export/PDF/report packaging pipeline.
|
||||
- No SIEM replacement or ingestion of external audit streams.
|
||||
- No requirement to introduce a separate “evidence item” reporting model; this spec remains compatible with a future evidence/reporting layer.
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Subject**: a single compare object identified for cross-tenant comparison by `policy_type + subject_key` (tenant context is provided by the run, not persisted in the workspace-owned snapshot item).
|
||||
- **Normalized display name**: derived from display name by trimming leading/trailing whitespace, collapsing internal whitespace to single spaces, and converting to lowercase.
|
||||
- **Subject key (`subject_key`)**: the stored, cross-tenant match key for a subject, equal to the normalized display name.
|
||||
- **Baseline snapshot**: a workspace-owned captured snapshot of subjects within a baseline scope.
|
||||
- **Evidence snapshot**: an immutable record of full policy content captured from the tenant for a subject, used to produce a stable, comparable fingerprint.
|
||||
- **Fidelity**:
|
||||
- **content**: drift signal derived from canonicalized full policy content (including assignments and scope tags when applicable)
|
||||
- **meta**: drift signal derived from a stable metadata contract (explicitly marked degraded)
|
||||
- **Coverage proof**: proof that the tenant current-state index is complete enough to safely determine missing-policy outcomes for the scope.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Baseline drift already records observable run records for capture and compare.
|
||||
- The system already has a canonical process for turning full policy content into a stable fingerprint for comparison, used by other workflows.
|
||||
- Some subjects may not be capturable (permissions, unsupported endpoints, temporary upstream issues); these produce warnings and explicit gaps rather than silent success.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Capture a full-content baseline without per-policy steps (Priority: P1)
|
||||
|
||||
As a workspace admin, I want to capture a baseline snapshot with full-content fidelity across the entire configured scope, so that Golden Master comparisons detect settings drift reliably without manually capturing each policy.
|
||||
|
||||
**Why this priority**: This is the primary value proposition for deep drift: baseline capture must produce strong evidence automatically.
|
||||
|
||||
**Independent Test**: Can be tested by creating a baseline profile configured for full-content capture, running “Capture baseline”, and validating that a baseline snapshot is created with content fidelity for all capturable subjects and explicit gaps for any non-capturable subjects.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline profile configured for full-content capture and a tenant with in-scope subjects, **When** I run “Capture baseline (full content)”, **Then** the system captures evidence snapshots on demand for subjects missing suitable evidence and produces a baseline snapshot with per-subject fingerprints and fidelity.
|
||||
2. **Given** a capture run where some subjects cannot be captured due to throttling or access limitations, **When** the run completes, **Then** the run outcome is “completed with warnings” and the UI shows an evidence gap summary (counts + reasons) rather than presenting a misleading “fully captured” state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Compare now with full content and get explainable drift (Priority: P1)
|
||||
|
||||
As an operator, I want to run “Compare now (full content)” and see reliable drift findings (missing/unexpected/different), with clear context about coverage and evidence fidelity, so that I can act on findings with confidence.
|
||||
|
||||
**Why this priority**: The feature is only trustworthy when “no drift” is explainable and “drift” is based on strong evidence.
|
||||
|
||||
**Independent Test**: Can be tested by capturing a full-content baseline, simulating a settings-only change for a subject, running “Compare now (full content)”, and asserting that a drift finding is produced with content fidelity provenance.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a full-content baseline snapshot and current evidence refreshed as part of compare, **When** a subject’s settings differ between baseline and current, **Then** compare emits a “different version” finding with content fidelity and stores evidence provenance for both baseline and current.
|
||||
2. **Given** a compare run where coverage proof is missing for some policy types, **When** compare runs, **Then** the system suppresses “missing policy” outcomes for uncovered types and records a coverage warning and explanation in the run detail.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Throttling-safe, resumable evidence capture (Priority: P1)
|
||||
|
||||
As an operator, I want evidence capture to respect rate limits and safely resume from where it left off, so that deep drift can be executed in large scopes without manual babysitting.
|
||||
|
||||
**Why this priority**: Full-content capture is only viable if it behaves predictably under real-world quotas.
|
||||
|
||||
**Independent Test**: Can be tested by simulating rate limiting for part of the scope, verifying that the run completes with warnings and a resume token, then resuming and eventually completing without duplicating evidence work.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a capture/compare run that hits rate limiting before completing the scope, **When** the run ends, **Then** it records an opaque resume token and a deterministic gap list, and it can be resumed via a single UI action.
|
||||
2. **Given** a resumed capture, **When** it continues from the resume token, **Then** it does not re-capture subjects already captured in the prior run for the same purpose.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - “Why no findings?” is always clear (Priority: P2)
|
||||
|
||||
As an operator, I want the compare run detail to explain “why no findings” (e.g., no subjects, coverage unproven, evidence capture incomplete), so that zero findings never looks like a silent failure.
|
||||
|
||||
**Why this priority**: Operator trust depends on eliminating ambiguous “0 findings” states.
|
||||
|
||||
**Independent Test**: Can be tested by running compare in a scenario that processes zero subjects (or suppresses findings due to coverage), and verifying that the UI shows a clear explanation sourced from run context.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a compare run where the resolved subject list is empty, **When** the run completes, **Then** the run context contains a reason code explaining why and the UI displays it.
|
||||
2. **Given** a compare run that produces zero findings but processed subjects, **When** it completes, **Then** it still records a reason code such as “no drift detected” and provides evidence/fidelity context.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Scope resolves to zero subjects: compare and capture complete with warnings and an explicit reason code; no silent success.
|
||||
- Some subjects are forbidden/unsupported: they are recorded as evidence gaps with reasons; drift evaluation is degraded or skipped per rules.
|
||||
- Evidence is available but cannot be normalized deterministically: the run degrades fidelity for that subject and records the gap reason.
|
||||
- Compare is retried after a transient failure: findings are not duplicated; lifecycle increments happen at most once per run identity.
|
||||
- Mixed evidence (some content, some meta): the run clearly reports breakdown; findings display the weaker-of-two fidelity for badge/filter semantics.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Constitution alignment (required)
|
||||
|
||||
- This feature performs outbound reads to capture full policy content as evidence, and it does so via observable long-running runs.
|
||||
- The feature MUST use a single canonical method to produce content-fidelity fingerprints, shared with other workflows.
|
||||
- “No legacy” is enforced: capture/compare orchestration does not implement per-policy fingerprinting logic and does not call legacy meta drift helpers.
|
||||
|
||||
### Operational UX Contract (Ops-UX)
|
||||
|
||||
- Baseline capture and baseline compare MUST run as observable operations with a run identity, start/stop times, outcome, and a user-facing progress surface.
|
||||
- Run lifecycle transitions are service-owned.
|
||||
- “Completed with warnings” MUST be used when evidence capture or coverage proof is incomplete.
|
||||
- Compare runs MUST NEVER silently produce “0 findings” without an explicit explanation. The run context MUST include a reason code when:
|
||||
- the resolved subject total is 0, or
|
||||
- the processed subject count is 0, or
|
||||
- findings are suppressed due to coverage/evidence rules.
|
||||
|
||||
### Authorization Contract (RBAC-UX)
|
||||
|
||||
- Authorization planes:
|
||||
- Workspace admin surfaces for baseline profiles/snapshots
|
||||
- Tenant-context admin surfaces for compare runs and findings
|
||||
- 404 vs 403 semantics:
|
||||
- Non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||
- Member but missing capability → 403
|
||||
- Starting runs (“Capture baseline”, “Compare now”, “Resume capture”) is a mutation and MUST be enforced server-side.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Configuration & Modes
|
||||
|
||||
- **FR-118-01 Capture mode**: Baseline profiles MUST support a capture mode with at least: meta-only, opportunistic, and full-content.
|
||||
- **FR-118-02 Deep drift by default**: For baseline profiles with capture mode = full-content, baseline capture and compare MUST prioritize content fidelity for all capturable subjects.
|
||||
- **FR-118-03 One engine, no legacy**: There MUST be exactly one compare engine and one canonical fingerprinting method. No parallel “legacy compare” or “legacy fingerprint” implementations may exist.
|
||||
|
||||
#### Subject Scope & Coverage
|
||||
|
||||
- **FR-118-04 Effective scope resolution**: Each run MUST resolve and persist an effective scope for the baseline profile (including total subject count).
|
||||
- **FR-118-04a Cross-tenant matching**: When comparing a workspace-owned baseline snapshot to a tenant’s current state, subject matching MUST use `policy_type + subject_key` where `subject_key` is the normalized display name, and workspace-owned snapshot items MUST NOT persist tenant identifiers.
|
||||
- **FR-118-04a1 Normalization rules**: The definition of `subject_key` (normalized display name) MUST be consistent across baseline capture and compare: trim leading/trailing whitespace, collapse internal whitespace to single spaces, and lowercase.
|
||||
- **FR-118-04b Ambiguous/missing match handling**: If cross-tenant matching is missing or ambiguous for a subject (e.g., missing display name, multiple candidates for the same normalized name within a policy type), compare MUST record an evidence gap reason and MUST suppress drift evaluation for that subject.
|
||||
- **FR-118-05 Coverage proof guard**: Compare MUST only emit “missing policy” outcomes when coverage proof exists for the policy type. If coverage proof is missing/unproven, missing-policy outcomes for that type MUST be suppressed and a warning MUST be recorded.
|
||||
|
||||
#### Baseline Capture (full-content)
|
||||
|
||||
- **FR-118-CAP-01 Preflight**: Capture MUST resolve the subject list for the effective scope and record the total subject count in run context.
|
||||
- **FR-118-CAP-02 Evidence capture on demand**: For full-content capture, the system MUST capture any missing or stale evidence snapshots for in-scope subjects, up to a configurable per-run budget.
|
||||
- **FR-118-CAP-03 Idempotency within run**: Within a single run, the same subject MUST NOT be captured more than once for the same capture purpose.
|
||||
- **FR-118-CAP-04 Snapshot build**: Baseline snapshots MUST store a per-subject stable fingerprint plus:
|
||||
- fingerprint fidelity (`content` vs `meta`)
|
||||
- fingerprint source/provenance indicator
|
||||
- observed timestamp
|
||||
- **FR-118-CAP-05 Incomplete capture semantics**: If full-content capture is incomplete, the run MUST complete with warnings, the snapshot may still be created, and any subjects that fell back to meta fidelity (or were skipped) MUST be recorded as gaps.
|
||||
|
||||
#### Baseline Compare (full-content)
|
||||
|
||||
- **FR-118-CMP-01 Current evidence refresh**: For full-content compares, compare MUST refresh current evidence for in-scope subjects before drift evaluation, within a configurable budget.
|
||||
- **FR-118-CMP-02 Best-available state resolution**: Current state resolution MUST always prefer full-content evidence when available and fall back to explicitly degraded metadata evidence only when necessary. Compare orchestration MUST NOT implement fingerprinting itself.
|
||||
- **FR-118-CMP-03 Drift rules**: For each subject:
|
||||
- baseline-only → missing policy (only when coverage proof exists for the type)
|
||||
- current-only → unexpected policy
|
||||
- both present and fingerprints differ → different version
|
||||
- **FR-118-CMP-04 Stable finding identity + lifecycle**: Findings MUST have a stable recurrence identity independent of fingerprints, and MUST maintain lifecycle fields (first seen, last seen, times seen). Retries MUST NOT duplicate findings.
|
||||
- **FR-118-CMP-05 Explainability**: Compare run context MUST include:
|
||||
- scope totals and processed counts
|
||||
- coverage proof status
|
||||
- fidelity breakdown (content vs meta)
|
||||
- evidence capture stats (requested/succeeded/skipped/failed/throttled)
|
||||
- evidence gaps (counts + top reasons, including missing/ambiguous cross-tenant match)
|
||||
|
||||
#### Quota, Throttling, Resume
|
||||
|
||||
- **FR-118-Q-01 Budget controls**: Evidence capture MUST be bounded by configurable limits (concurrency, items-per-run, retry limits) with safe defaults; default values MUST be explicitly defined in configuration and documented in the implementation plan.
|
||||
- **FR-118-Q-02 Throttling behavior**: When rate limiting or temporary upstream errors occur, capture MUST back off and retry within limits, and then record throttling as a gap reason if it cannot complete.
|
||||
- **FR-118-Q-03 Resumable token**: When a run cannot complete the scope within its budget, the run context MUST include an opaque resume token and enough information to resume deterministically.
|
||||
- **FR-118-Q-04 Partial failure**: Individual subjects may fail without failing the entire run, but the run MUST complete with warnings and must deterministically report gaps.
|
||||
|
||||
#### Auditability & Retention
|
||||
|
||||
- **FR-118-AUD-01 Run auditing**: Each run MUST record scope, coverage, fidelity breakdown, evidence capture stats, and evidence gaps.
|
||||
- **FR-118-AUD-02 Evidence purpose tagging**: Evidence snapshots captured for baseline purposes MUST be attributable to the initiating run and baseline profile for audit/debugging.
|
||||
- **FR-118-AUD-03 Retention policy**: Evidence captured for baseline purposes MUST have a configurable retention distinct from long-term backup evidence.
|
||||
|
||||
#### Security & Compliance
|
||||
|
||||
- **FR-118-SEC-01 Redaction before persistence**: The system MUST remove secrets/PII from captured policy content before it is stored or used to produce fingerprints.
|
||||
- **FR-118-SEC-02 Least privilege access**: Evidence captured for baseline purposes MUST be access-controlled and not broadly visible outside baseline-related permissions.
|
||||
- **FR-118-SEC-03 Audit events**: Starting baseline evidence capture and compare runs MUST write audit events that include purpose, scope counts, and gap/warning summaries.
|
||||
|
||||
#### UX Requirements
|
||||
|
||||
- **FR-118-UX-01 Single-action buttons**: Baseline profile screens MUST provide:
|
||||
- “Capture baseline (full content)”
|
||||
- “Compare now (full content)”
|
||||
- **FR-118-UX-02 Evidence gaps panel**: Compare run detail MUST include an “Evidence capture” panel showing content coverage percentage, fallback counts, and top gap reasons, and MUST provide “Resume capture” when a resume token exists.
|
||||
- **FR-118-UX-03 Snapshot fidelity visibility**: Snapshot list/detail MUST show whether the snapshot is content-complete or captured with gaps, and show counts by fidelity.
|
||||
- **FR-118-UX-04 Why-no-findings explanation**: When a run processes zero subjects or produces zero findings, the UI MUST display a clear explanation sourced from the run context reason code.
|
||||
|
||||
#### Rollout
|
||||
|
||||
- **FR-118-ROL-01 Controlled rollout**: Full-content baseline capture/compare MUST be gated by a short-lived rollout flag for canary deployment.
|
||||
- **FR-118-ROL-02 No-legacy regression guard**: Automated guardrails MUST prevent re-introduction of legacy fingerprinting/compare paths.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
This spec adds/changes operational actions and run-detail panels on existing baseline/compare surfaces.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| 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 Profile (workspace admin) | Admin workspace | Capture baseline (full content); Compare now (full content) | View/inspect baseline profile | Edit (existing), Archive (existing, confirmed) | None | Create baseline profile (existing) | N/A | Save/Cancel (existing) | Yes | Starting capture/compare writes audit events and creates observable runs |
|
||||
| Compare Run Detail (tenant-context admin) | Admin tenant-context | Resume capture (only when resume token exists) | Linked from runs list | None | None | N/A | N/A | N/A | Yes | Evidence capture panel + why-no-findings explanation sourced from run context |
|
||||
| Drift Findings landing (tenant-context admin) | Admin tenant-context | None | Open finding (existing) | None | None | Existing CTA | N/A | N/A | Yes (existing) | Findings show fidelity badge + provenance for baseline/current evidence |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Baseline profile**: Defines scope and capture mode, and is the parent for baseline snapshots.
|
||||
- **Baseline snapshot**: A captured baseline reference set for a baseline profile.
|
||||
- **Baseline snapshot item**: Per-subject baseline evidence (fingerprint, fidelity, provenance, observed timestamp).
|
||||
- **Evidence snapshot**: Immutable captured policy content used to produce a stable, comparable fingerprint.
|
||||
- **Operation run**: Observable record of a capture/compare execution, including context, coverage, fidelity breakdown, stats, and gaps.
|
||||
- **Finding**: Recurring drift result with stable identity and lifecycle fields, plus evidence for baseline/current.
|
||||
- **Resume token**: Opaque token that enables resuming evidence capture deterministically.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-118-01 Deep drift reliability**: For baseline profiles configured for full-content capture, settings-only changes for in-scope subjects produce a “different version” drift finding with a success rate of at least 95% in controlled tests.
|
||||
- **SC-118-02 No silent zeros**: 100% of compare runs that process zero subjects or produce zero findings include a run-context reason code and display a corresponding explanation in the UI.
|
||||
- **SC-118-03 Resumable capture**: In controlled tests with simulated rate limiting, evidence capture completes across one or more resumed runs without duplicating captured subjects and with deterministic gap reporting.
|
||||
- **SC-118-04 Operator clarity**: On run detail pages, operators can access effective scope, coverage status, fidelity breakdown, capture stats, and evidence gaps without navigating to additional pages.
|
||||
- **SC-118-05 No-legacy enforcement**: Automated checks reliably fail when legacy fingerprinting/compare helpers are referenced by baseline capture/compare orchestration.
|
||||
243
specs/118-baseline-drift-engine/tasks.md
Normal file
243
specs/118-baseline-drift-engine/tasks.md
Normal file
@ -0,0 +1,243 @@
|
||||
---
|
||||
|
||||
description: "Task list for Spec 118 implementation"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Golden Master Deep Drift v2 (Full Content Capture)
|
||||
|
||||
**Input**: Design documents from `/specs/118-baseline-drift-engine/`
|
||||
|
||||
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior.
|
||||
|
||||
**Terminology**: `subject_key` = Spec 118 `normalized display_name` (trim + collapse internal whitespace + lowercase).
|
||||
|
||||
**Data isolation (SCOPE-001)**: Workspace-owned `baseline_snapshot_items` MUST NOT persist tenant identifiers (no tenant IDs, no tenant external IDs, no operation run IDs, no policy version IDs) — only cross-tenant keys + non-tenant metadata.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Establish a safe baseline and introduce feature-level configuration scaffolding.
|
||||
|
||||
- [X] T001 Capture current baseline behavior by running existing suites in `tests/Feature/Baselines/BaselineCaptureTest.php`, `tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||
- [X] T002 [P] Add Spec 118 rollout + budget env vars to `.env.example` (e.g. `TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED`, `TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200`, `TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5`, `TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3`, `TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90`)
|
||||
- [X] T003 [P] Add config surface for Spec 118 rollout + budgets in `config/tenantpilot.php` (new `baselines.full_content_capture.*` keys sourced from env)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives required by ALL user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Add baseline capture mode enum in `app/Support/Baselines/BaselineCaptureMode.php` (values: `meta_only`, `opportunistic`, `full_content`)
|
||||
- [X] T005 [P] Add policy version capture purpose enum in `app/Support/Baselines/PolicyVersionCapturePurpose.php` (values: `backup`, `baseline_capture`, `baseline_compare`)
|
||||
- [X] T006 [P] Add subject-key helper in `app/Support/Baselines/BaselineSubjectKey.php` (normalize display name + derive workspace-safe subject id as `sha256(policy_type|subject_key)` for `baseline_snapshot_items.subject_external_id`)
|
||||
- [X] T007 [P] Add baseline compare “why no findings” reason codes in `app/Support/Baselines/BaselineCompareReasonCode.php` (e.g. `no_subjects_in_scope`, `coverage_unproven`, `evidence_capture_incomplete`, `rollout_disabled`, `no_drift_detected`)
|
||||
- [X] T008 [P] Add full-content rollout gate helper in `app/Support/Baselines/BaselineFullContentRolloutGate.php` (reads `config('tenantpilot.baselines.full_content_capture.enabled')`, provides an `assertEnabled()` used by services + jobs)
|
||||
- [X] T009 [P] Add resume token contract in `app/Support/Baselines/BaselineEvidenceResumeToken.php` (versioned encode/decode; stored as opaque string in `operation_runs.context.*.resume_token`)
|
||||
- [X] T010 [P] Add policy snapshot redactor in `app/Services/Intune/PolicySnapshotRedactor.php` (remove secrets/PII from payload/assignments/scope tags before persistence + hashing)
|
||||
- [X] T011 [P] Add redaction coverage test in `tests/Feature/Intune/PolicySnapshotRedactionTest.php` (assert stored `PolicyVersion.snapshot` is redacted and content hash uses redacted content)
|
||||
|
||||
- [X] T012 Add migration for `baseline_profiles.capture_mode` in `database/migrations/2026_03_03_100001_add_capture_mode_to_baseline_profiles_table.php`
|
||||
- [X] T013 [P] Add migration for `baseline_snapshot_items.subject_key` + index in `database/migrations/2026_03_03_100002_add_subject_key_to_baseline_snapshot_items_table.php`
|
||||
- [X] T014 [P] Add migration for `policy_versions.capture_purpose`, `policy_versions.operation_run_id`, `policy_versions.baseline_profile_id` + indexes in `database/migrations/2026_03_03_100003_add_baseline_purpose_to_policy_versions_table.php`
|
||||
|
||||
- [X] T015 Update `app/Models/BaselineProfile.php` to store/cast `capture_mode` via `BaselineCaptureMode` and include it in `$fillable` (default: `opportunistic`)
|
||||
- [X] T016 [P] Update factory defaults/states for capture mode in `database/factories/BaselineProfileFactory.php`
|
||||
- [X] T017 [P] Update `database/factories/BaselineSnapshotItemFactory.php` to set `subject_key` derived from `meta_jsonb.display_name` via `BaselineSubjectKey` and set `subject_external_id` using the workspace-safe subject id (no tenant external IDs)
|
||||
- [X] T018 Update `app/Models/PolicyVersion.php` to cast `capture_purpose` and define relationships to `OperationRun` + `BaselineProfile` (new nullable FKs)
|
||||
- [X] T019 [P] Update `database/factories/PolicyVersionFactory.php` to default `capture_purpose` to `backup`
|
||||
|
||||
- [X] T020 Update `app/Services/Intune/VersionService.php` to apply `PolicySnapshotRedactor` before persistence/hashing and persist `capture_purpose`, `operation_run_id`, and `baseline_profile_id` when capturing versions (including via `captureFromGraph()`)
|
||||
- [X] T021 Update `app/Services/Intune/PolicyCaptureOrchestrator.php` to pass baseline-purpose attribution into `PolicyVersion` creation/reuse/backfill and ensure snapshot dedupe uses redacted payloads (no secrets/PII in stored snapshots)
|
||||
|
||||
- [X] T022 Update content hashing to include settings + assignments + scope tags in `app/Services/Baselines/Evidence/ContentEvidenceProvider.php` (use `SettingsNormalizer`, hash normalized `assignments`, and hash normalized scope-tag IDs via `ScopeTagsNormalizer`)
|
||||
- [X] T023 Ensure content evidence provenance includes `policy_version_id`, `operation_run_id`, and `capture_purpose` in `app/Services/Baselines/Evidence/ContentEvidenceProvider.php` (tenant-scoped only; snapshot items must strip tenant identifiers)
|
||||
|
||||
- [X] T024 Implement quota-aware baseline evidence capture phase scaffold in `app/Services/Baselines/BaselineContentCapturePhase.php` (inputs: tenant + subjects + purpose + budgets incl. concurrency + optional resume token; outputs: stats + gaps + optional resume token)
|
||||
|
||||
- [X] T025 Update run start context to include `target_scope` + `capture_mode` and enforce rollout gate for `full_content` in `app/Services/Baselines/BaselineCaptureService.php` (reject start if disabled)
|
||||
- [X] T026 [P] Update run start context to include `target_scope` + `capture_mode` and enforce rollout gate for `full_content` in `app/Services/Baselines/BaselineCompareService.php` (reject start if disabled)
|
||||
|
||||
- [X] T027 Add capture mode field + badge to Filament baseline profile CRUD in `app/Filament/Resources/BaselineProfileResource.php` (hide/disable `full_content` option when rollout flag is disabled)
|
||||
|
||||
**Checkpoint**: DB + enums + capture phase scaffolding are in place; user stories can be implemented and tested independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Capture a full-content baseline without per-policy steps (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Capture a baseline snapshot that uses full-content evidence by default (with explicit gaps + warnings if capture is incomplete).
|
||||
|
||||
**Independent Test**: Create a baseline profile configured for full-content capture, run “Capture baseline (full content)”, and validate the snapshot items have content-fidelity evidence (or explicit gaps) and the run context records capture stats.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T028 [P] [US1] Add baseline full-content on-demand evidence test in `tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php` (no PolicyVersion exists → capture creates one with `capture_purpose=baseline_capture` and snapshot item fidelity is `content`)
|
||||
- [X] T029 [P] [US1] Update meta-fallback test to assert opportunistic mode degrades to meta when evidence is missing in `tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php`
|
||||
- [X] T030 [P] [US1] Update capture start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
|
||||
- [X] T031 [P] [US1] Add snapshot item isolation test in `tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php` (assert `baseline_snapshot_items` do not store tenant external IDs and `meta_jsonb` omits tenant identifiers like `meta_contract.subject_external_id` and `evidence.observed_operation_run_id`)
|
||||
- [X] T032 [P] [US1] Add audit event coverage for baseline capture start/completion in `tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php` (assert action metadata includes purpose, scope counts, and gap/warning summary)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T033 [US1] Update baseline capture action labeling + modal copy + rollout gate messaging in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (show “Capture baseline (full content)” when `capture_mode=full_content`)
|
||||
- [X] T034 [US1] Integrate `BaselineContentCapturePhase` into baseline capture in `app/Jobs/CaptureBaselineSnapshotJob.php` (purpose `baseline_capture`, budgeted, record `context.baseline_capture.evidence_capture`, `context.baseline_capture.gaps`, `context.baseline_capture.resume_token`, and add job-level rollout gate guard)
|
||||
- [X] T035 [US1] Persist `subject_key` and workspace-safe `subject_external_id` (derived via `BaselineSubjectKey`) when building snapshot items, and sanitize `meta_jsonb` to exclude tenant identifiers in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- [X] T036 [US1] Update baseline snapshot identity hashing to use `policy_type + subject_key + baseline_hash` in `app/Services/Baselines/BaselineSnapshotIdentity.php` (dedupe must not depend on tenant-specific external IDs)
|
||||
- [X] T037 [US1] Ensure capture run `status`/`outcome` transitions go through `OperationRunService` and mark warnings (`OperationRunOutcome::PartiallySucceeded`) when any subject falls back to meta or is skipped in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- [X] T038 [US1] Expand capture audit events to include purpose, scope counts, evidence capture stats, and gap/warning summary in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- [X] T039 [US1] Add snapshot fidelity + gaps counts into `baseline_snapshots.summary_jsonb` for snapshot list/detail UX in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
|
||||
**Parallel execution example (US1)**:
|
||||
|
||||
- Developer A: T028, T034, T036
|
||||
- Developer B: T030, T033, T035, T038
|
||||
|
||||
**Checkpoint**: A baseline snapshot can be captured in full-content mode without per-policy steps, and runs are explainable when gaps exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Compare now with full content and get explainable drift (Priority: P1)
|
||||
|
||||
**Goal**: Compare baseline vs current using content-first evidence refresh, cross-tenant subject matching, and explainable run context.
|
||||
|
||||
**Independent Test**: Capture a full-content baseline, simulate a settings-only change for a subject, run “Compare now (full content)”, and assert a “different version” finding exists with content provenance.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T040 [P] [US2] Add cross-tenant match test (policy_type + `subject_key`) in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php`
|
||||
- [X] T041 [P] [US2] Add ambiguous match suppression test in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` (duplicate `subject_key` values → evidence gap; no finding)
|
||||
- [X] T042 [P] [US2] Add coverage proof guard test in `tests/Feature/Baselines/BaselineCompareCoverageProofGuardTest.php` (uncovered types suppress `missing_policy` outcomes; run completes with warnings + records context)
|
||||
- [X] T043 [P] [US2] Add stable recurrence identity test in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` (recurrence key independent of hashes; retries don’t duplicate; lifecycle fields update)
|
||||
- [X] T044 [P] [US2] Update compare start surface expectations for full-content labeling + rollout gating in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||
- [X] T045 [P] [US2] Add baseline profile “Compare now (full content)” start-surface test in `tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
||||
- [X] T046 [P] [US2] Add audit event coverage for baseline compare start/completion in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php` (purpose, scope counts, gaps/warnings summary)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T047 [US2] Add “Compare now (full content)” header action to baseline profile view in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` (select target tenant; require `tenant.sync`; enforce rollout gate server-side)
|
||||
- [X] T048 [US2] Integrate `BaselineContentCapturePhase` refresh into compare in `app/Jobs/CompareBaselineToTenantJob.php` (purpose `baseline_compare`, budgeted, record `context.baseline_compare.evidence_capture`, `context.baseline_compare.evidence_gaps`, `context.baseline_compare.resume_token`, and add job-level rollout gate guard)
|
||||
- [X] T049 [US2] Switch compare matching to `policy_type + subject_key` in `app/Jobs/CompareBaselineToTenantJob.php` (load baseline items by `subject_key`; compute current `subject_key` from inventory display name; detect missing/empty/duplicate keys on either side; record gap reasons; suppress drift evaluation for those keys)
|
||||
- [X] T050 [US2] Enforce coverage proof guard behavior in `app/Jobs/CompareBaselineToTenantJob.php` (suppress `missing_policy` for uncovered/unproven types; record warning + `BaselineCompareReasonCode` when suppression affects outcomes)
|
||||
- [X] T051 [US2] Update finding recurrence identity to be stable and independent of hashes in `app/Jobs/CompareBaselineToTenantJob.php` (recurrence key uses tenant_id + baseline_profile_id + policy_type + subject_key + change_type; retries must not duplicate findings)
|
||||
- [X] T052 [US2] Ensure findings carry `subject_key` + `display_name` fallbacks in `evidence_jsonb` and update subject display name fallback logic in `app/Filament/Resources/FindingResource.php` (COALESCE inventory display name with evidence display name)
|
||||
- [X] T053 [US2] Ensure compare run context contains scope totals, processed counts, coverage proof status, fidelity breakdown, evidence capture stats, and top gap reasons in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T054 [US2] Update baseline compare landing to label “Compare now (full content)” when applicable in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
- [X] T055 [US2] Extend stats DTO to surface fidelity + evidence gap summary from run context in `app/Support/Baselines/BaselineCompareStats.php`
|
||||
- [X] T056 [US2] Add evidence capture + gaps panels for baseline capture/compare runs in Monitoring detail in `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T057 [US2] Expand compare audit events to include purpose, scope counts, evidence capture stats, and gaps/warnings summary in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
|
||||
**Parallel execution example (US2)**:
|
||||
|
||||
- Developer A: T040, T048, T050, T056
|
||||
- Developer B: T045, T047, T054, T055, T052
|
||||
|
||||
**Checkpoint**: Compare runs refresh evidence when needed, generate findings reliably, and provide explainable context even with coverage warnings or gaps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Throttling-safe, resumable evidence capture (Priority: P1)
|
||||
|
||||
**Goal**: Evidence capture respects quotas, records a resume token, and resumes deterministically without duplicating work.
|
||||
|
||||
**Independent Test**: Simulate throttling/budget exhaustion, verify run records a resume token, then resume and complete without re-capturing already-captured subjects.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T058 [P] [US3] Add “budget exhaustion produces resume token” test in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- [X] T059 [P] [US3] Add “resume is idempotent” test in `tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php`
|
||||
- [X] T060 [P] [US3] Add resume token contract test in `tests/Feature/Baselines/BaselineEvidenceResumeTokenContractTest.php` (token is opaque; decode yields deterministic resume state)
|
||||
- [X] T061 [P] [US3] Add run-detail resume action test in `tests/Feature/Filament/OperationRunResumeCaptureActionTest.php`
|
||||
- [X] T062 [P] [US3] Add audit event coverage for resume capture in `tests/Feature/Baselines/BaselineResumeCaptureAuditEventsTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T063 [US3] Implement budgets (items-per-run + concurrency + retries) + retry/backoff/jitter + throttling gap reasons + resume cursor handling in `app/Services/Baselines/BaselineContentCapturePhase.php` (use `BaselineEvidenceResumeToken` encode/decode)
|
||||
- [X] T064 [US3] Add resume starter service in `app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` (start follow-up `baseline_capture`/`baseline_compare` runs from a prior run + resume token; enforce RBAC; write audit events)
|
||||
- [X] T065 [US3] Add “Resume capture” header action for eligible runs in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (requires confirmation; uses Ops-UX queued toast + canonical view-run link)
|
||||
- [X] T066 [US3] Wire resume token consumption + re-emission into `app/Jobs/CaptureBaselineSnapshotJob.php` (baseline capture) and `app/Jobs/CompareBaselineToTenantJob.php` (baseline compare)
|
||||
|
||||
**Parallel execution example (US3)**:
|
||||
|
||||
- Developer A: T058, T063, T066
|
||||
- Developer B: T061, T064, T065
|
||||
|
||||
**Checkpoint**: Operators can safely complete large scopes via resumable capture without manual per-policy capture.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — “Why no findings?” is always clear (Priority: P2)
|
||||
|
||||
**Goal**: Zero findings never looks like a silent failure; compare run detail clearly explains the outcome.
|
||||
|
||||
**Independent Test**: Run compare with zero subjects (or with suppressed findings due to coverage/gaps) and verify a clear explanation sourced from run context is displayed.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T067 [P] [US4] Add reason-code coverage test for zero-subject / zero-findings / suppressed-by-coverage outcomes in `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||
- [X] T068 [P] [US4] Add UI assertion test for “why no findings” messaging in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T069 [US4] Populate `context.baseline_compare.reason_code` for all 0-subject / 0-findings outcomes in `app/Jobs/CompareBaselineToTenantJob.php` (use `BaselineCompareReasonCode`, including `coverage_unproven`/`rollout_disabled` where applicable)
|
||||
- [X] T070 [US4] Render reason-code explanation + evidence context in Monitoring run detail in `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T071 [US4] Replace “All clear” copy with reason-aware messaging on baseline compare landing in `resources/views/filament/pages/baseline-compare-landing.blade.php` (source reason code from `BaselineCompareStats`)
|
||||
- [X] T072 [US4] Propagate reason code + human message from run context in `app/Support/Baselines/BaselineCompareStats.php`
|
||||
|
||||
**Parallel execution example (US4)**:
|
||||
|
||||
- Developer A: T067, T069
|
||||
- Developer B: T068, T071, T072
|
||||
|
||||
**Checkpoint**: Every compare run with “0 findings” has a clear, user-visible explanation and supporting evidence context.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Guardrails, visibility, and validation across all stories.
|
||||
|
||||
- [X] T073 [P] Add Spec 118 no-legacy regression guard(s) in `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` (assert capture/compare do not implement hashing outside the provider/hasher pipeline and do not reference deprecated helpers)
|
||||
- [X] T074 Update PolicyVersion listing to hide baseline-purpose evidence by default (unless the actor has `tenant.sync` or `tenant_findings.view`) in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T075 [P] Add visibility/authorization coverage for baseline-purpose PolicyVersions in `tests/Feature/Filament/PolicyVersionBaselineEvidenceVisibilityTest.php` (assert baseline-purpose rows are hidden for `tenant.view`-only actors)
|
||||
- [X] T076 Implement baseline-purpose PolicyVersion retention enforcement in `app/Console/Commands/PruneBaselineEvidencePolicyVersionsCommand.php` and schedule it in `routes/console.php` (prune `baseline_capture`/`baseline_compare` older than configured retention; do not prune `backup`) + tests in `tests/Feature/Retention/PruneBaselineEvidencePolicyVersionsTest.php` and `tests/Feature/Scheduling/PruneBaselineEvidencePolicyVersionsScheduleTest.php`
|
||||
- [X] T077 Add Baseline Snapshot list/detail surfaces with fidelity visibility in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` (badge + counts by fidelity; “captured with gaps” state) + tests in `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`
|
||||
- [X] T078 Run formatting on changed files using `vendor/bin/sail bin pint --dirty --format agent` (touchpoints include `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`)
|
||||
- [X] T079 Run targeted test suite from `specs/118-baseline-drift-engine/quickstart.md` and update it if any step is inaccurate in `specs/118-baseline-drift-engine/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Story completion order
|
||||
|
||||
- Phase 1 (Setup) → Phase 2 (Foundational) → user stories.
|
||||
- User stories after Phase 2:
|
||||
- **US1 (P1)** is the MVP capture capability and should be implemented first end-to-end.
|
||||
- **US2 (P1)** depends on US1 for end-to-end validation (a baseline snapshot must exist), but implementation can proceed in parallel after Phase 2.
|
||||
- **US3 (P1)** depends on the capture phase being integrated in US1/US2.
|
||||
- **US4 (P2)** depends on US2’s run-context fields.
|
||||
|
||||
### Dependency graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1["Phase 1: Setup"] --> P2["Phase 2: Foundational"]
|
||||
P2 --> US1["US1: Capture baseline (full content)"]
|
||||
P2 --> US2["US2: Compare now (full content)"]
|
||||
US1 --> US2
|
||||
US2 --> US3["US3: Resumable capture"]
|
||||
US2 --> US4["US4: Why no findings"]
|
||||
US3 --> POLISH["Phase 7: Polish"]
|
||||
US4 --> POLISH
|
||||
```
|
||||
|
||||
## Implementation Strategy (MVP-first)
|
||||
|
||||
1) Ship **US1** with a strict run-context contract and explicit gap reporting (no silent success).
|
||||
2) Add **US2** compare refresh + cross-tenant matching with explainability.
|
||||
3) Harden with **US3** resumability and throttle-safe behavior.
|
||||
4) Complete operator trust with **US4** reason-code UX.
|
||||
5) Enforce “no legacy” and visibility constraints in **Polish**.
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
|
||||
it('writes audit events for baseline capture start and completion with scope + gap summary', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'audit-policy-a',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Audit Policy A',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_capture',
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$started = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.capture.started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$completed = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.capture.completed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($started)->not->toBeNull();
|
||||
expect($completed)->not->toBeNull();
|
||||
|
||||
$startedMeta = is_array($started?->metadata) ? $started->metadata : [];
|
||||
expect($startedMeta)->toHaveKey('purpose');
|
||||
expect($startedMeta)->toHaveKey('subjects_total');
|
||||
|
||||
$completedMeta = is_array($completed?->metadata) ? $completed->metadata : [];
|
||||
expect($completedMeta)->toHaveKey('purpose');
|
||||
expect($completedMeta)->toHaveKey('subjects_total');
|
||||
expect($completedMeta)->toHaveKey('gaps');
|
||||
});
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('Baseline snapshot items do not persist tenant identifiers', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => 'tenant-policy-external-id',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Isolated Policy',
|
||||
]);
|
||||
|
||||
$lastSeenRun = 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(),
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
||||
'etag' => 'E_ISOLATION',
|
||||
'scope_tag_ids' => [],
|
||||
'assignment_target_count' => 1,
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $lastSeenRun->getKey(),
|
||||
'last_seen_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_capture',
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$item = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_key', (string) $subjectKey)
|
||||
->sole();
|
||||
|
||||
expect($item->subject_external_id)->toBe($workspaceSafeExternalId);
|
||||
expect($item->subject_external_id)->not->toBe((string) $policy->external_id);
|
||||
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
expect(data_get($meta, 'meta_contract.subject_external_id'))->toBeNull();
|
||||
expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBeNull();
|
||||
expect(data_get($meta, 'evidence.policy_version_id'))->toBeNull();
|
||||
});
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -62,7 +65,11 @@
|
||||
]);
|
||||
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
[
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
],
|
||||
);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -89,24 +96,24 @@
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$item = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_external_id', (string) $policy->external_id)
|
||||
->where('subject_external_id', $workspaceSafeExternalId)
|
||||
->sole();
|
||||
|
||||
expect($item->baseline_hash)->toBe($expectedContentHash);
|
||||
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
expect($meta)->toHaveKey('meta_contract');
|
||||
expect($meta)->toHaveKey('evidence');
|
||||
expect(data_get($meta, 'evidence.fidelity'))->toBe('content');
|
||||
expect(data_get($meta, 'evidence.source'))->toBe('policy_version');
|
||||
expect(data_get($meta, 'evidence.observed_at'))->not->toBeNull();
|
||||
|
||||
$contract = app(InventoryMetaContract::class)->build(
|
||||
policyType: (string) $inventory->policy_type,
|
||||
subjectExternalId: (string) $inventory->external_id,
|
||||
metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [],
|
||||
);
|
||||
expect($meta['meta_contract'])->toBe($contract);
|
||||
});
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
|
||||
it('Baseline capture (full content) captures evidence on demand when missing', function () {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => 'policy-on-demand',
|
||||
'platform' => 'windows',
|
||||
'display_name' => 'Policy On Demand',
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
||||
'etag' => 'E_ON_DEMAND',
|
||||
'scope_tag_ids' => [],
|
||||
'assignment_target_count' => 1,
|
||||
],
|
||||
'last_seen_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0);
|
||||
|
||||
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
/**
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$this->calls[] = [
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'capture_purpose' => $capturePurpose->value,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
];
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => [
|
||||
'settings' => [
|
||||
['displayName' => 'SettingX', 'value' => 1],
|
||||
],
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $version->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_capture',
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
app(CurrentStateHashResolver::class),
|
||||
$contentCapturePhase,
|
||||
);
|
||||
|
||||
expect($fakeOrchestrator->calls)->toHaveCount(1);
|
||||
|
||||
$version = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('policy_id', (int) $policy->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($version)->not->toBeNull();
|
||||
expect($version?->capture_purpose)->toBe(PolicyVersionCapturePurpose::BaselineCapture);
|
||||
expect($version?->operation_run_id)->toBe((int) $run->getKey());
|
||||
expect($version?->baseline_profile_id)->toBe((int) $profile->getKey());
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$item = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_external_id', $workspaceSafeExternalId)
|
||||
->sole();
|
||||
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
expect(data_get($meta, 'evidence.fidelity'))->toBe('content');
|
||||
});
|
||||
|
||||
@ -11,15 +11,18 @@
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('Baseline capture falls back to meta fidelity when PolicyVersion evidence is missing', function () {
|
||||
it('Baseline capture degrades to meta fidelity in opportunistic mode when PolicyVersion evidence is missing', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
@ -81,13 +84,25 @@
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$item = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_external_id', (string) $policy->external_id)
|
||||
->where('subject_external_id', $workspaceSafeExternalId)
|
||||
->sole();
|
||||
|
||||
expect($item->baseline_hash)->toBe($expectedMetaHash);
|
||||
@ -95,5 +110,5 @@
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
expect(data_get($meta, 'evidence.fidelity'))->toBe('meta');
|
||||
expect(data_get($meta, 'evidence.source'))->toBe('inventory');
|
||||
expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBe((int) $lastSeenRun->getKey());
|
||||
expect(data_get($meta, 'evidence.observed_operation_run_id'))->toBeNull();
|
||||
});
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
@ -53,18 +56,27 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'),
|
||||
);
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Policy A',
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
@ -168,18 +180,27 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
);
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Policy B',
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
@ -68,14 +71,21 @@
|
||||
metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [],
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineMetaHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Policy Meta',
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
@ -123,7 +133,7 @@
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta');
|
||||
expect(data_get($context, 'baseline_compare.coverage.resolved_content'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.coverage.baseline_meta'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -164,18 +174,27 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineContentHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
);
|
||||
$baselineContentHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineContentHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Policy Content',
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
@ -234,7 +253,7 @@
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta');
|
||||
expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(0);
|
||||
expect(data_get($context, 'baseline_compare.coverage.baseline_content'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.missing_current'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
@ -51,14 +54,23 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
);
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
@ -169,10 +181,17 @@
|
||||
metaJsonb: $baselineMetaJsonb,
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
@ -51,18 +54,27 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
);
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: (string) $policy->policy_type,
|
||||
subjectKey: (string) $subjectKey,
|
||||
),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Policy Provenance',
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
it('Baseline resolver prefers content evidence over meta evidence when available', function () {
|
||||
@ -46,13 +48,15 @@
|
||||
'last_seen_operation_run_id' => null,
|
||||
]);
|
||||
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized(
|
||||
app(SettingsNormalizer::class)->normalizeForDiff(
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff(
|
||||
is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [],
|
||||
(string) $policyVersion->policy_type,
|
||||
is_string($policyVersion->platform) ? $policyVersion->platform : null,
|
||||
),
|
||||
);
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
$expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: (string) $inventory->policy_type,
|
||||
|
||||
104
tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php
Normal file
104
tests/Feature/Baselines/BaselineCaptureAmbiguousMatchGapTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
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\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('treats duplicate subject_key matches as an evidence gap and captures remaining subjects', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-2',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E2'],
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'unique-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Unique Policy',
|
||||
'meta_jsonb' => ['etag' => 'E_UNIQUE'],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCapture->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? 0))->toBe(1);
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(1);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_capture.gaps.by_reason.ambiguous_match'))->toBe(1);
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
expect(
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->count(),
|
||||
)->toBe(1);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName('Unique Policy');
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->where('subject_external_id', $workspaceSafeExternalId)
|
||||
->sole();
|
||||
});
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// --- T031: Capture enqueue + precondition tests ---
|
||||
@ -146,25 +147,28 @@
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
$inventoryA = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-a',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy A',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
$inventoryB = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-b',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy B',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
$inventoryC = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-c',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Policy C',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
|
||||
]);
|
||||
|
||||
@ -212,29 +216,50 @@
|
||||
->orderBy('subject_external_id')
|
||||
->get();
|
||||
|
||||
expect($items->pluck('subject_external_id')->all())->toBe(['policy-a', 'policy-b', 'policy-c']);
|
||||
$subjectKeyA = BaselineSubjectKey::fromDisplayName((string) $inventoryA->display_name);
|
||||
$subjectKeyB = BaselineSubjectKey::fromDisplayName((string) $inventoryB->display_name);
|
||||
$subjectKeyC = BaselineSubjectKey::fromDisplayName((string) $inventoryC->display_name);
|
||||
|
||||
expect($subjectKeyA)->not->toBeNull();
|
||||
expect($subjectKeyB)->not->toBeNull();
|
||||
expect($subjectKeyC)->not->toBeNull();
|
||||
|
||||
$expectedSubjectExternalIds = [
|
||||
BaselineSubjectKey::workspaceSafeSubjectExternalId((string) $inventoryA->policy_type, (string) $subjectKeyA),
|
||||
BaselineSubjectKey::workspaceSafeSubjectExternalId((string) $inventoryB->policy_type, (string) $subjectKeyB),
|
||||
BaselineSubjectKey::workspaceSafeSubjectExternalId((string) $inventoryC->policy_type, (string) $subjectKeyC),
|
||||
];
|
||||
sort($expectedSubjectExternalIds, SORT_STRING);
|
||||
|
||||
expect($items->pluck('subject_external_id')->all())->toBe($expectedSubjectExternalIds);
|
||||
|
||||
$inventoryBySubjectKey = [
|
||||
(string) $subjectKeyA => $inventoryA,
|
||||
(string) $subjectKeyB => $inventoryB,
|
||||
(string) $subjectKeyC => $inventoryC,
|
||||
];
|
||||
|
||||
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();
|
||||
$inventory = $inventoryBySubjectKey[(string) $item->subject_key] ?? null;
|
||||
|
||||
expect($inventory)->not->toBeNull();
|
||||
|
||||
$contractSubjectExternalId = (string) ($inventory->external_id ?? '');
|
||||
|
||||
$contract = $builder->build(
|
||||
policyType: (string) $inventory->policy_type,
|
||||
subjectExternalId: (string) $inventory->external_id,
|
||||
subjectExternalId: $contractSubjectExternalId,
|
||||
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);
|
||||
expect(data_get($meta, 'display_name'))->toBe((string) ($inventory->display_name ?? ''));
|
||||
expect(data_get($meta, 'evidence.fidelity'))->toBe('meta');
|
||||
expect(data_get($meta, 'evidence.source'))->toBe('inventory');
|
||||
expect(data_get($meta, 'meta_contract'))->toBeNull();
|
||||
}
|
||||
|
||||
$profile->refresh();
|
||||
|
||||
118
tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php
Normal file
118
tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?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\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('treats duplicate subject_key matches as an evidence gap and suppresses findings', function () {
|
||||
[$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(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $baselineSubjectExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
// Two current policies with the same display name (→ same subject_key).
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-2',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E2'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
expect(
|
||||
Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->count(),
|
||||
)->toBe(0);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1);
|
||||
});
|
||||
|
||||
176
tests/Feature/Baselines/BaselineCompareAuditEventsTest.php
Normal file
176
tests/Feature/Baselines/BaselineCompareAuditEventsTest.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('writes audit events for baseline compare start and completion with scope + gap summary', function () {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayName = 'Audit Compare Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => 'audit-compare-policy',
|
||||
'platform' => 'windows',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$version = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => [
|
||||
'settings' => [
|
||||
['displayName' => 'SettingX', 'value' => 2],
|
||||
],
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $version->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$started = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.compare.started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$completed = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.compare.completed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($started)->not->toBeNull();
|
||||
expect($completed)->not->toBeNull();
|
||||
|
||||
$startedMeta = is_array($started?->metadata) ? $started->metadata : [];
|
||||
expect($startedMeta)->toHaveKey('purpose');
|
||||
expect($startedMeta)->toHaveKey('subjects_total');
|
||||
expect($startedMeta)->toHaveKey('scope_types_total');
|
||||
|
||||
$completedMeta = is_array($completed?->metadata) ? $completed->metadata : [];
|
||||
expect($completedMeta)->toHaveKey('purpose');
|
||||
expect($completedMeta)->toHaveKey('subjects_total');
|
||||
expect($completedMeta)->toHaveKey('evidence_capture');
|
||||
expect($completedMeta)->toHaveKey('gaps');
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -43,13 +44,19 @@
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
|
||||
);
|
||||
|
||||
$coveredDisplayName = 'Covered Policy';
|
||||
$coveredSubjectKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName);
|
||||
expect($coveredSubjectKey)->not->toBeNull();
|
||||
$coveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $coveredSubjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'covered-uuid',
|
||||
'subject_external_id' => $coveredWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $coveredSubjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $hasher->hashNormalized($coveredContract),
|
||||
'meta_jsonb' => ['display_name' => 'Covered Policy'],
|
||||
'meta_jsonb' => ['display_name' => $coveredDisplayName],
|
||||
]);
|
||||
|
||||
$uncoveredContract = $builder->build(
|
||||
@ -58,13 +65,19 @@
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceCompliancePolicy', 'etag' => 'E_BASELINE'],
|
||||
);
|
||||
|
||||
$uncoveredDisplayName = 'Uncovered Policy';
|
||||
$uncoveredSubjectKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName);
|
||||
expect($uncoveredSubjectKey)->not->toBeNull();
|
||||
$uncoveredWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', (string) $uncoveredSubjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'uncovered-uuid',
|
||||
'subject_external_id' => $uncoveredWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $uncoveredSubjectKey,
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'baseline_hash' => $hasher->hashNormalized($uncoveredContract),
|
||||
'meta_jsonb' => ['display_name' => 'Uncovered Policy'],
|
||||
'meta_jsonb' => ['display_name' => $uncoveredDisplayName],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = OperationRun::factory()->create([
|
||||
@ -93,7 +106,7 @@
|
||||
'external_id' => 'covered-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
|
||||
'display_name' => 'Covered Policy Changed',
|
||||
'display_name' => $coveredDisplayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
<?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\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('suppresses missing_policy outcomes for uncovered types and records coverage context', function () {
|
||||
[$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(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$coveredExternalId = 'covered-uuid';
|
||||
$coveredDisplayName = 'Covered Policy';
|
||||
$coveredKey = BaselineSubjectKey::fromDisplayName($coveredDisplayName);
|
||||
expect($coveredKey)->not->toBeNull();
|
||||
|
||||
$coveredWorkspaceId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $coveredKey,
|
||||
);
|
||||
|
||||
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectExternalId: $coveredExternalId,
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $coveredWorkspaceId,
|
||||
'subject_key' => (string) $coveredKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $coveredDisplayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$uncoveredDisplayName = 'Uncovered Policy';
|
||||
$uncoveredKey = BaselineSubjectKey::fromDisplayName($uncoveredDisplayName);
|
||||
expect($uncoveredKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceCompliancePolicy', (string) $uncoveredKey),
|
||||
'subject_key' => (string) $uncoveredKey,
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'baseline_hash' => hash('sha256', 'uncovered'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $uncoveredDisplayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$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' => $coveredExternalId,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $coveredDisplayName,
|
||||
'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);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect($compareRun->status)->toBe('completed');
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$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');
|
||||
|
||||
$context = is_array($compareRun->context) ? $compareRun->context : [];
|
||||
expect(data_get($context, 'baseline_compare.coverage.uncovered_types'))->toContain('deviceCompliancePolicy');
|
||||
});
|
||||
|
||||
146
tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php
Normal file
146
tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php
Normal file
@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('matches baseline to tenant inventory by policy_type + subject_key (cross-tenant)', function () {
|
||||
[$user, $sourceTenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$targetTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $sourceTenant->workspace_id,
|
||||
]);
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$targetTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $sourceTenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $sourceTenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$displayName = 'Shared Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$snapshotPayload = [
|
||||
'settings' => [
|
||||
['displayName' => 'SettingX', 'value' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $baselineSubjectExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $expectedContentHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $targetTenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => 'tenant-policy-uuid',
|
||||
'platform' => 'windows',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => $snapshotPayload,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'workspace_id' => (int) $targetTenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $targetTenant,
|
||||
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($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
|
||||
expect(
|
||||
Finding::query()
|
||||
->where('tenant_id', (int) $targetTenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->count(),
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
<?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\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('uses a stable recurrence key independent of hashes and snapshot id', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$displayName = 'Policy X';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$snapshot1 = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot1->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-v1'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot2 = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot2->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-v2'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'policy-x-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$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,
|
||||
);
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run1);
|
||||
$job->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
expect($finding->recurrence_key)->not->toBeNull();
|
||||
expect($finding->fingerprint)->toBe($finding->recurrence_key);
|
||||
expect($finding->times_seen)->toBe(1);
|
||||
|
||||
$fingerprint = (string) $finding->fingerprint;
|
||||
|
||||
// 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);
|
||||
|
||||
// Compare against a different baseline snapshot (hash changes), but recurrence identity stays stable.
|
||||
$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,
|
||||
);
|
||||
|
||||
$finding->refresh();
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint);
|
||||
expect($finding->times_seen)->toBe(2);
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationSummaryKeys;
|
||||
|
||||
@ -42,22 +43,46 @@
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
$policyType = 'deviceConfiguration';
|
||||
|
||||
$displayNameA = 'Policy A';
|
||||
$subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA);
|
||||
expect($subjectKeyA)->not->toBeNull();
|
||||
$workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA);
|
||||
$baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: 'policy-a-uuid',
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_A'],
|
||||
);
|
||||
|
||||
$displayNameB = 'Policy B';
|
||||
$subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB);
|
||||
expect($subjectKeyB)->not->toBeNull();
|
||||
$workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB);
|
||||
$baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: 'policy-b-uuid',
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_B'],
|
||||
);
|
||||
|
||||
// Baseline has policyA and policyB
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-a-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'content-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy A'],
|
||||
'subject_external_id' => $workspaceSafeExternalIdA,
|
||||
'subject_key' => (string) $subjectKeyA,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => $baselineHashA,
|
||||
'meta_jsonb' => ['display_name' => $displayNameA],
|
||||
]);
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-b-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'content-b'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy B'],
|
||||
'subject_external_id' => $workspaceSafeExternalIdB,
|
||||
'subject_key' => (string) $subjectKeyB,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => $baselineHashB,
|
||||
'meta_jsonb' => ['display_name' => $displayNameB],
|
||||
]);
|
||||
|
||||
// Tenant has policyA (different content) and policyC (unexpected)
|
||||
@ -65,9 +90,9 @@
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-a-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Policy A modified',
|
||||
'policy_type' => $policyType,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_A'],
|
||||
'display_name' => $displayNameA,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -75,9 +100,9 @@
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-c-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['new_policy' => true],
|
||||
'display_name' => 'Policy C unexpected',
|
||||
'policy_type' => $policyType,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_C'],
|
||||
'display_name' => 'Policy C',
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -157,30 +182,54 @@
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
$policyType = 'deviceConfiguration';
|
||||
|
||||
$displayNameA = 'Policy A';
|
||||
$subjectKeyA = BaselineSubjectKey::fromDisplayName($displayNameA);
|
||||
expect($subjectKeyA)->not->toBeNull();
|
||||
$workspaceSafeExternalIdA = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyA);
|
||||
$baselineHashA = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: 'policy-a-uuid',
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_A'],
|
||||
);
|
||||
|
||||
$displayNameB = 'Policy B';
|
||||
$subjectKeyB = BaselineSubjectKey::fromDisplayName($displayNameB);
|
||||
expect($subjectKeyB)->not->toBeNull();
|
||||
$workspaceSafeExternalIdB = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKeyB);
|
||||
$baselineHashB = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: 'policy-b-uuid',
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE_B'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-a-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'content-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy A'],
|
||||
'subject_external_id' => $workspaceSafeExternalIdA,
|
||||
'subject_key' => (string) $subjectKeyA,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => $baselineHashA,
|
||||
'meta_jsonb' => ['display_name' => $displayNameA],
|
||||
]);
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-b-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'content-b'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy B'],
|
||||
'subject_external_id' => $workspaceSafeExternalIdB,
|
||||
'subject_key' => (string) $subjectKeyB,
|
||||
'policy_type' => $policyType,
|
||||
'baseline_hash' => $baselineHashB,
|
||||
'meta_jsonb' => ['display_name' => $displayNameB],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-a-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Policy A modified',
|
||||
'policy_type' => $policyType,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_A'],
|
||||
'display_name' => $displayNameA,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -266,13 +315,19 @@
|
||||
],
|
||||
);
|
||||
|
||||
$displayName = 'Settings Catalog A';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('settingsCatalogPolicy', (string) $subjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'settings-catalog-policy-uuid',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'baseline_hash' => hash('sha256', 'content-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Settings Catalog A'],
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
]);
|
||||
|
||||
// Inventory item exists, but it was NOT observed in the latest sync run.
|
||||
@ -281,7 +336,7 @@
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'settings-catalog-policy-uuid',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog A',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['etag' => 'abc'],
|
||||
'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(),
|
||||
'last_seen_at' => now()->subMinutes(5),
|
||||
@ -351,13 +406,19 @@
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
|
||||
);
|
||||
|
||||
$displayName = 'Policy X';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-x-uuid',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $hasher->hashNormalized($baselineContract),
|
||||
'meta_jsonb' => ['display_name' => 'Policy X'],
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -366,7 +427,7 @@
|
||||
'external_id' => 'policy-x-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
|
||||
'display_name' => 'Policy X modified',
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -453,7 +514,7 @@
|
||||
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 () {
|
||||
it('does not create new finding identities when a new snapshot is captured', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
@ -476,6 +537,11 @@
|
||||
);
|
||||
$baselineHash = $hasher->hashNormalized($baselineContract);
|
||||
|
||||
$displayName = 'Policy X';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
|
||||
|
||||
$snapshot1 = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
@ -484,9 +550,11 @@
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot1->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-x-uuid',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -495,6 +563,7 @@
|
||||
'external_id' => 'policy-x-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -520,13 +589,14 @@
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$fingerprint1 = (string) Finding::query()
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->orderBy('id')
|
||||
->firstOrFail()
|
||||
->fingerprint;
|
||||
->sole();
|
||||
|
||||
expect($finding->times_seen)->toBe(1);
|
||||
$fingerprint1 = (string) $finding->fingerprint;
|
||||
|
||||
$snapshot2 = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
@ -536,9 +606,11 @@
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot2->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-x-uuid',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
]);
|
||||
|
||||
$run2 = $opService->ensureRunWithIdentity(
|
||||
@ -566,9 +638,9 @@
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($findings)->toHaveCount(2);
|
||||
expect($findings->pluck('fingerprint')->unique()->count())->toBe(2);
|
||||
expect($findings->pluck('fingerprint')->all())->toContain($fingerprint1);
|
||||
expect($findings)->toHaveCount(1);
|
||||
expect((string) $findings->first()?->fingerprint)->toBe($fingerprint1);
|
||||
expect((int) $findings->first()?->times_seen)->toBe(2);
|
||||
});
|
||||
|
||||
it('creates zero findings when baseline matches tenant inventory exactly', function () {
|
||||
@ -607,13 +679,19 @@
|
||||
metaJsonb: $metaContent,
|
||||
));
|
||||
|
||||
$displayName = 'Matching Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'matching-uuid',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $contentHash,
|
||||
'meta_jsonb' => ['display_name' => 'Matching Policy'],
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
]);
|
||||
|
||||
// Tenant inventory with same content → same hash
|
||||
@ -623,7 +701,7 @@
|
||||
'external_id' => 'matching-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => $metaContent,
|
||||
'display_name' => 'Matching Policy',
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -698,22 +776,37 @@
|
||||
metaJsonb: $metaContent,
|
||||
));
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'matching-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $contentHash,
|
||||
'meta_jsonb' => ['display_name' => 'Matching Policy'],
|
||||
]);
|
||||
$displayName = 'Matching Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'foundation-uuid',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $contentHash,
|
||||
'meta_jsonb' => ['display_name' => $displayName],
|
||||
]);
|
||||
|
||||
$foundationDisplayName = 'Foundation Template';
|
||||
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName($foundationDisplayName);
|
||||
expect($foundationSubjectKey)->not->toBeNull();
|
||||
$foundationWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
'notificationMessageTemplate',
|
||||
(string) $foundationSubjectKey,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $foundationWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $foundationSubjectKey,
|
||||
'policy_type' => 'notificationMessageTemplate',
|
||||
'baseline_hash' => hash('sha256', 'foundation-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Foundation Template'],
|
||||
'meta_jsonb' => ['display_name' => $foundationDisplayName],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -722,7 +815,7 @@
|
||||
'external_id' => 'matching-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => $metaContent,
|
||||
'display_name' => 'Matching Policy',
|
||||
'display_name' => $displayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -733,7 +826,7 @@
|
||||
'external_id' => 'foundation-uuid',
|
||||
'policy_type' => 'notificationMessageTemplate',
|
||||
'meta_jsonb' => ['some' => 'value'],
|
||||
'display_name' => 'Foundation Template',
|
||||
'display_name' => $foundationDisplayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -795,21 +888,34 @@
|
||||
);
|
||||
|
||||
// 2 baseline items: one will be missing (high), one will be different (medium)
|
||||
$missingDisplayName = 'Missing Policy';
|
||||
$missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName);
|
||||
expect($missingSubjectKey)->not->toBeNull();
|
||||
$missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'missing-uuid',
|
||||
'subject_external_id' => $missingWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $missingSubjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'missing-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Missing Policy'],
|
||||
'meta_jsonb' => ['display_name' => $missingDisplayName],
|
||||
]);
|
||||
|
||||
$changedDisplayName = 'Changed Policy';
|
||||
$changedSubjectKey = BaselineSubjectKey::fromDisplayName($changedDisplayName);
|
||||
expect($changedSubjectKey)->not->toBeNull();
|
||||
$changedWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $changedSubjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'changed-uuid',
|
||||
'subject_external_id' => $changedWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $changedSubjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'original-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Changed Policy'],
|
||||
'meta_jsonb' => ['display_name' => $changedDisplayName],
|
||||
]);
|
||||
|
||||
// Tenant only has changed-uuid with different content + extra-uuid (unexpected)
|
||||
@ -819,7 +925,7 @@
|
||||
'external_id' => 'changed-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['modified_content' => true],
|
||||
'display_name' => 'Changed Policy',
|
||||
'display_name' => $changedDisplayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
@ -1126,21 +1232,34 @@
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
$missingDisplayName = 'Missing Policy';
|
||||
$missingSubjectKey = BaselineSubjectKey::fromDisplayName($missingDisplayName);
|
||||
expect($missingSubjectKey)->not->toBeNull();
|
||||
$missingWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $missingSubjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'missing-policy',
|
||||
'subject_external_id' => $missingWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $missingSubjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Missing Policy'],
|
||||
'meta_jsonb' => ['display_name' => $missingDisplayName],
|
||||
]);
|
||||
|
||||
$differentDisplayName = 'Different Policy';
|
||||
$differentSubjectKey = BaselineSubjectKey::fromDisplayName($differentDisplayName);
|
||||
expect($differentSubjectKey)->not->toBeNull();
|
||||
$differentWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $differentSubjectKey);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'different-policy',
|
||||
'subject_external_id' => $differentWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $differentSubjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-b'),
|
||||
'meta_jsonb' => ['display_name' => 'Different Policy'],
|
||||
'meta_jsonb' => ['display_name' => $differentDisplayName],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
@ -1149,7 +1268,7 @@
|
||||
'external_id' => 'different-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Different Policy',
|
||||
'display_name' => $differentDisplayName,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
199
tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php
Normal file
199
tests/Feature/Baselines/BaselineCompareResumeIdempotencyTest.php
Normal file
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('resumes full-content compare deterministically without re-capturing already-captured subjects', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayNames = ['Resume Idempotent A', 'Resume Idempotent B'];
|
||||
|
||||
foreach ($displayNames as $displayName) {
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
|
||||
foreach ($displayNames as $i => $displayName) {
|
||||
$policies[] = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => $i === 0 ? 'resume-idem-a' : 'resume-idem-b',
|
||||
'platform' => 'windows',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
}
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public array $capturedExternalIds = [];
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$this->capturedExternalIds[] = (string) $policy->external_id;
|
||||
|
||||
$version = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['settings' => [['key' => 'k', 'value' => 1]]],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $version->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$firstRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($firstRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$firstRun->refresh();
|
||||
$firstContext = is_array($firstRun->context) ? $firstRun->context : [];
|
||||
$resumeToken = $firstContext['baseline_compare']['resume_token'] ?? null;
|
||||
expect($resumeToken)->toBeString();
|
||||
|
||||
expect($fakeOrchestrator->capturedExternalIds)->toHaveCount(1);
|
||||
expect($fakeOrchestrator->capturedExternalIds[0])->toBe('resume-idem-a');
|
||||
|
||||
$secondRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_compare' => [
|
||||
'resume_token' => (string) $resumeToken,
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($secondRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$secondRun->refresh();
|
||||
$secondContext = is_array($secondRun->context) ? $secondRun->context : [];
|
||||
expect($secondContext['baseline_compare']['resume_token'] ?? null)->toBeNull();
|
||||
|
||||
expect($fakeOrchestrator->capturedExternalIds)->toBe(['resume-idem-a', 'resume-idem-b']);
|
||||
});
|
||||
172
tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
Normal file
172
tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
Normal file
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('records a resume token when full-content compare cannot capture all subjects within budget', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayNames = ['Resume Token A', 'Resume Token B'];
|
||||
|
||||
foreach ($displayNames as $displayName) {
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$policies = [];
|
||||
|
||||
foreach ($displayNames as $i => $displayName) {
|
||||
$policies[] = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => $i === 0 ? 'resume-token-a' : 'resume-token-b',
|
||||
'platform' => 'windows',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
}
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'display_name' => (string) $policy->display_name,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$fakeOrchestrator = new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
$version = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'platform' => (string) $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'snapshot' => ['settings' => [['key' => 'k', 'value' => 1]]],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'operation_run_id' => $operationRunId,
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'captured' => [
|
||||
'payload' => $version->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase($fakeOrchestrator);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$token = $context['baseline_compare']['resume_token'] ?? null;
|
||||
expect($token)->toBeString();
|
||||
|
||||
$state = BaselineEvidenceResumeToken::decode((string) $token);
|
||||
expect($state)->toBeArray();
|
||||
expect($state)->toHaveKey('offset');
|
||||
expect($state['offset'])->toBe(1);
|
||||
});
|
||||
@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
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\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('records no_subjects_in_scope when the resolved subject list is empty', 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(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = \App\Models\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' => [
|
||||
'inventory' => [
|
||||
'coverage' => [
|
||||
'policy_types' => [
|
||||
'deviceConfiguration' => ['status' => 'succeeded'],
|
||||
],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
|
||||
});
|
||||
|
||||
it('records no_drift_detected when subjects are processed but no drift findings are produced', 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(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$externalId = 'policy-uuid';
|
||||
$displayName = 'Stable Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectKey: (string) $subjectKey,
|
||||
);
|
||||
|
||||
$metaJsonb = [
|
||||
'odata_type' => '#microsoft.graph.deviceConfiguration',
|
||||
'etag' => 'E_STABLE',
|
||||
];
|
||||
|
||||
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectExternalId: $externalId,
|
||||
metaJsonb: $metaJsonb,
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = \App\Models\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' => [
|
||||
'inventory' => [
|
||||
'coverage' => [
|
||||
'policy_types' => [
|
||||
'deviceConfiguration' => ['status' => 'succeeded'],
|
||||
],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => $metaJsonb,
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
});
|
||||
|
||||
it('records coverage_unproven when findings are suppressed due to missing coverage proof', 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(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$compareRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => [],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::CoverageUnproven->value);
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
|
||||
it('encodes and decodes resume token state deterministically', function (): void {
|
||||
$state = [
|
||||
'offset' => 12,
|
||||
'note' => 'opaque to callers',
|
||||
];
|
||||
|
||||
$token = BaselineEvidenceResumeToken::encode($state);
|
||||
|
||||
expect($token)->toBeString();
|
||||
expect($token)->not->toContain('+');
|
||||
expect($token)->not->toContain('/');
|
||||
expect($token)->not->toContain('=');
|
||||
|
||||
$decoded = BaselineEvidenceResumeToken::decode($token);
|
||||
|
||||
expect($decoded)->toBe($state);
|
||||
});
|
||||
|
||||
it('returns null for invalid resume tokens', function (): void {
|
||||
expect(BaselineEvidenceResumeToken::decode(''))->toBeNull();
|
||||
expect(BaselineEvidenceResumeToken::decode('not-base64url'))->toBeNull();
|
||||
|
||||
$payload = json_encode(['v' => 999, 'state' => ['offset' => 1]], JSON_THROW_ON_ERROR);
|
||||
$token = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||||
|
||||
expect(BaselineEvidenceResumeToken::decode($token))->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('writes an audit event when resuming evidence capture', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_compare' => [
|
||||
'resume_token' => BaselineEvidenceResumeToken::encode(['offset' => 1]),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(BaselineEvidenceCaptureResumeService::class);
|
||||
$result = $service->resume($run, $user);
|
||||
|
||||
expect($result['ok'] ?? false)->toBeTrue();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', 'baseline.evidence.resume.started')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\InventoryItem;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows a warning banner when duplicate policy names make baseline matching ambiguous', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$displayName = 'Duplicate Policy';
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'dup-2',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee(__('baseline-compare.duplicate_warning_title'))
|
||||
->assertSee('share the same display name')
|
||||
->assertSee('cannot match them to the baseline');
|
||||
});
|
||||
@ -68,6 +68,7 @@
|
||||
|
||||
it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -77,6 +78,7 @@
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
@ -93,6 +95,7 @@
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertActionHasLabel('compareNow', 'Compare now (full content)')
|
||||
->callAction('compareNow')
|
||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
|
||||
|
||||
@ -108,6 +111,45 @@
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
|
||||
it('does not start full-content baseline compare when rollout is disabled', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', false);
|
||||
|
||||
[$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,
|
||||
'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$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)
|
||||
->assertActionHasLabel('compareNow', 'Compare now (full content)')
|
||||
->assertActionEnabled('compareNow')
|
||||
->callAction('compareNow')
|
||||
->assertNotified('Cannot start comparison')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('can refresh stats without calling mount directly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows a clear why-no-findings explanation when the last run had zero drift', 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(),
|
||||
]);
|
||||
|
||||
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' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
|
||||
});
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -56,6 +57,7 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('capture')
|
||||
->assertActionHasLabel('capture', 'Capture baseline')
|
||||
->assertActionDisabled('capture')
|
||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertStatus(200);
|
||||
@ -65,11 +67,13 @@
|
||||
|
||||
it('starts capture successfully for authorized workspace members', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
@ -77,6 +81,7 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('capture')
|
||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||
->assertActionEnabled('capture')
|
||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertStatus(200);
|
||||
@ -92,3 +97,29 @@
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
|
||||
it('does not start full-content capture when rollout is disabled', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', false);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('capture')
|
||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||
->assertActionEnabled('capture')
|
||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start capture')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('compareNow')
|
||||
->assertActionHasLabel('compareNow', 'Compare now (full content)')
|
||||
->assertActionDisabled('compareNow')
|
||||
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('starts baseline compare successfully for authorized workspace members', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('compareNow')
|
||||
->assertActionHasLabel('compareNow', 'Compare now (full content)')
|
||||
->assertActionEnabled('compareNow')
|
||||
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
|
||||
it('does not start full-content baseline compare when rollout is disabled', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', false);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('compareNow')
|
||||
->assertActionHasLabel('compareNow', 'Compare now (full content)')
|
||||
->assertActionEnabled('compareNow')
|
||||
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertNotified('Cannot start comparison')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows snapshot fidelity counts and gap state on list and view pages', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$complete = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'summary_jsonb' => [
|
||||
'total_items' => 5,
|
||||
'fidelity_counts' => ['content' => 5, 'meta' => 0],
|
||||
'gaps' => ['count' => 0, 'by_reason' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
$withGaps = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinutes(10),
|
||||
'summary_jsonb' => [
|
||||
'total_items' => 5,
|
||||
'fidelity_counts' => ['content' => 3, 'meta' => 2],
|
||||
'gaps' => ['count' => 2, 'by_reason' => ['meta_fallback' => 2]],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Complete')
|
||||
->assertSee('Captured with gaps')
|
||||
->assertSee('Content 5, Meta 0')
|
||||
->assertSee('Content 3, Meta 2');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $withGaps], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Captured with gaps')
|
||||
->assertSee('Content 3, Meta 2')
|
||||
->assertSee('Evidence gaps')
|
||||
->assertSee('2');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $complete], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Complete')
|
||||
->assertSee('Content 5, Meta 0')
|
||||
->assertSee('Evidence gaps')
|
||||
->assertSee('0');
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('offers a Resume capture action on eligible baseline compare runs with a resume token', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$token = BaselineEvidenceResumeToken::encode(['offset' => 1]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_compare' => [
|
||||
'resume_token' => $token,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('resumeCapture')
|
||||
->callAction('resumeCapture')
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
|
||||
$resumed = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::BaselineCompare->value)
|
||||
->where('status', OperationRunStatus::Queued->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($resumed)->not->toBeNull();
|
||||
$context = is_array($resumed?->context) ? $resumed->context : [];
|
||||
expect($context['baseline_compare']['resume_token'] ?? null)->toBe($token);
|
||||
});
|
||||
@ -26,7 +26,7 @@ function getPolicySyncHeaderAction(Testable $component, string $name): ?Action
|
||||
return null;
|
||||
}
|
||||
|
||||
it('shows sync only in empty state when policies table is empty', function (): void {
|
||||
it('shows sync in header and empty state when policies table is empty', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -34,11 +34,11 @@ function getPolicySyncHeaderAction(Testable $component, string $name): ?Action
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ListPolicies::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['syncEmpty']);
|
||||
->assertTableEmptyStateActionsExistInOrder(['sync']);
|
||||
|
||||
$headerSync = getPolicySyncHeaderAction($component, 'sync');
|
||||
expect($headerSync)->not->toBeNull();
|
||||
expect($headerSync?->isVisible())->toBeFalse();
|
||||
expect($headerSync?->isVisible())->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows sync only in header when policies table is not empty', function (): void {
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('hides baseline-purpose policy versions for tenant.view-only actors', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$resolver = \Mockery::mock(CapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('primeMemberships')->andReturnNull();
|
||||
$resolver->shouldReceive('can')
|
||||
->andReturnUsing(static fn ($user, $tenant, string $capability): bool => $capability === Capabilities::TENANT_VIEW);
|
||||
|
||||
app()->instance(CapabilityResolver::class, $resolver);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$baselineProfile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$backupVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
]);
|
||||
|
||||
$baselinePurposeVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'baseline_profile_id' => (int) $baselineProfile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicyVersions::class)
|
||||
->assertCanSeeTableRecords([$backupVersion])
|
||||
->assertCanNotSeeTableRecords([$baselinePurposeVersion]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(PolicyVersionResource::getUrl('view', ['record' => $baselinePurposeVersion], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('shows baseline-purpose policy versions for actors with tenant.sync', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
app()->forgetInstance(CapabilityResolver::class);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$baselineProfile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$backupVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
]);
|
||||
|
||||
$baselinePurposeVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'baseline_profile_id' => (int) $baselineProfile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicyVersions::class)
|
||||
->assertCanSeeTableRecords([$backupVersion, $baselinePurposeVersion]);
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('prevents legacy fingerprinting/compare helpers from re-entering baseline orchestration (Spec 118)', function (): void {
|
||||
$forbiddenTokens = [
|
||||
'PolicyNormalizer',
|
||||
'VersionDiff',
|
||||
'flattenForDiff',
|
||||
'SettingsNormalizer',
|
||||
'ScopeTagsNormalizer',
|
||||
'->hashNormalized(',
|
||||
'::hashNormalized(',
|
||||
];
|
||||
|
||||
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
|
||||
expect($compareJob)->toBeString();
|
||||
expect($compareJob)->toContain('CurrentStateHashResolver');
|
||||
|
||||
foreach ($forbiddenTokens as $token) {
|
||||
expect($compareJob)->not->toContain($token);
|
||||
}
|
||||
|
||||
$captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php'));
|
||||
expect($captureJob)->toBeString();
|
||||
expect($captureJob)->toContain('CurrentStateHashResolver');
|
||||
|
||||
foreach ($forbiddenTokens as $token) {
|
||||
expect($captureJob)->not->toContain($token);
|
||||
}
|
||||
});
|
||||
90
tests/Feature/Intune/PolicySnapshotRedactionTest.php
Normal file
90
tests/Feature/Intune/PolicySnapshotRedactionTest.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Intune\VersionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('redacts secrets before persisting snapshots and hashing content', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
/** @var VersionService $service */
|
||||
$service = app(VersionService::class);
|
||||
|
||||
$version1 = $service->captureVersion(
|
||||
policy: $policy,
|
||||
payload: [
|
||||
'wifi' => [
|
||||
'ssid' => 'Corp',
|
||||
'password' => 'super-secret-1',
|
||||
],
|
||||
'settings' => [
|
||||
'example' => true,
|
||||
],
|
||||
],
|
||||
createdBy: $user->email,
|
||||
);
|
||||
|
||||
$version2 = $service->captureVersion(
|
||||
policy: $policy,
|
||||
payload: [
|
||||
'wifi' => [
|
||||
'ssid' => 'Corp',
|
||||
'password' => 'super-secret-2',
|
||||
],
|
||||
'settings' => [
|
||||
'example' => true,
|
||||
],
|
||||
],
|
||||
createdBy: $user->email,
|
||||
);
|
||||
|
||||
$fresh1 = PolicyVersion::query()->findOrFail((int) $version1->getKey());
|
||||
$fresh2 = PolicyVersion::query()->findOrFail((int) $version2->getKey());
|
||||
|
||||
expect($fresh1->snapshot['wifi']['password'])->toBe('[REDACTED]');
|
||||
expect($fresh2->snapshot['wifi']['password'])->toBe('[REDACTED]');
|
||||
expect($fresh1->snapshot['wifi']['ssid'])->toBe('Corp');
|
||||
expect($fresh2->snapshot['wifi']['ssid'])->toBe('Corp');
|
||||
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
$hasher = app(DriftHasher::class);
|
||||
|
||||
$hash1 = $hasher->hashNormalized([
|
||||
'settings' => $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $fresh1->snapshot,
|
||||
policyType: (string) $fresh1->policy_type,
|
||||
platform: is_string($fresh1->platform) ? (string) $fresh1->platform : null,
|
||||
),
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($fresh1->assignments ?? []),
|
||||
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($fresh1->scope_tags ?? []),
|
||||
]);
|
||||
|
||||
$hash2 = $hasher->hashNormalized([
|
||||
'settings' => $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $fresh2->snapshot,
|
||||
policyType: (string) $fresh2->policy_type,
|
||||
platform: is_string($fresh2->platform) ? (string) $fresh2->platform : null,
|
||||
),
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($fresh2->assignments ?? []),
|
||||
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($fresh2->scope_tags ?? []),
|
||||
]);
|
||||
|
||||
expect($hash1)->toBe($hash2);
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('prunes baseline-purpose policy versions past retention but keeps backups', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.retention_days', 30);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$oldBaselineCompare = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
$oldBaselineCapture = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCapture->value,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
$recentBaselineCompare = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 3,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::BaselineCompare->value,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$oldBackup = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'version_number' => 4,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup->value,
|
||||
'captured_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:baseline-evidence:prune')->assertExitCode(0);
|
||||
|
||||
expect($oldBaselineCompare->refresh()->trashed())->toBeTrue();
|
||||
expect($oldBaselineCapture->refresh()->trashed())->toBeTrue();
|
||||
expect($recentBaselineCompare->refresh()->trashed())->toBeFalse();
|
||||
expect($oldBackup->refresh()->trashed())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
it('schedules baseline evidence policy version pruning daily without overlapping', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$event = collect($schedule->events())
|
||||
->first(fn ($event) => ($event->description ?? null) === 'tenantpilot:baseline-evidence:prune');
|
||||
|
||||
expect($event)->not->toBeNull();
|
||||
expect($event->withoutOverlapping)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user