feat: add Intune RBAC baseline compare support (#156)
## Summary - add Intune RBAC Role Definition baseline scope support, capture references, compare classification, findings evidence, and landing/detail UI labels - keep Intune Role Assignments explicitly excluded from baseline compare scope, summaries, findings, and restore messaging - add focused Pest coverage for baseline scope selection, capture, compare behavior, recurrence, isolation, findings rendering, inventory anchoring, and RBAC summaries ## Verification - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php tests/Unit/Baselines/BaselinePolicyVersionResolverTest.php tests/Unit/Baselines/BaselineScopeTest.php tests/Unit/IntuneRoleDefinitionNormalizerTest.php tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Filament/BaselineProfileFoundationScopeTest.php tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php tests/Feature/Filament/FindingViewRbacEvidenceTest.php tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/DriftStaleAutoResolveTest.php tests/Feature/Inventory/InventorySyncButtonTest.php tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php` - result: `71 passed (467 assertions)` ## Filament / Platform Notes - Livewire compliance: unchanged and compatible with Livewire v4.0+ - Provider registration: no panel/provider changes; `bootstrap/providers.php` remains the registration location - Global search: no new globally searchable resource added; existing global search behavior is unchanged - Destructive actions: no new destructive actions introduced; existing confirmed actions remain unchanged - Assets: no new Filament assets introduced; deploy asset handling remains unchanged, including `php artisan filament:assets` - Testing plan covered: baseline profile scope, snapshot detail, compare job, findings recurrence, findings detail, compare landing labels, inventory sync anchoring, and tenant isolation Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #156
This commit is contained in:
parent
c6e7591d19
commit
ef41c9193a
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -56,6 +56,7 @@ ## Active Technologies
|
||||
- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack (127-rbac-inventory-backup)
|
||||
- PostgreSQL for tenant-owned inventory, backup items, versions, verification outcomes, and operation runs (127-rbac-inventory-backup)
|
||||
- PostgreSQL via Laravel Sail (128-rbac-baseline-compare)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -75,8 +76,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 128-rbac-baseline-compare: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 127-rbac-inventory-backup: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Microsoft Graph provider stack
|
||||
- 126-filter-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables
|
||||
- 125-table-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -83,6 +83,9 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $evidenceGapsTopReasons = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $rbacRoleDefinitionSummary = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -133,6 +136,7 @@ public function refreshStats(): void
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -149,6 +153,8 @@ protected function getViewData(): array
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
@ -197,6 +203,7 @@ protected function getViewData(): array
|
||||
'evidenceGapsCountValue' => $evidenceGapsCountValue,
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
|
||||
@ -432,10 +432,10 @@ public static function policyTypeOptions(): array
|
||||
*/
|
||||
public static function foundationTypeOptions(): array
|
||||
{
|
||||
return collect(InventoryPolicyTypeMeta::foundations())
|
||||
return collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
|
||||
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
|
||||
(string) $row['type'] => InventoryPolicyTypeMeta::baselineCompareLabel((string) $row['type']) ?? (string) ($row['label'] ?? $row['type']),
|
||||
])
|
||||
->sort()
|
||||
->all();
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\User;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
@ -30,12 +31,7 @@ protected function mutateFormDataBeforeCreate(array $data): array
|
||||
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
|
||||
|
||||
if (isset($data['scope_jsonb'])) {
|
||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
|
||||
$data['scope_jsonb'] = [
|
||||
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
|
||||
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
|
||||
];
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@ -51,12 +52,7 @@ protected function mutateFormDataBeforeSave(array $data): array
|
||||
}
|
||||
|
||||
if (isset($data['scope_jsonb'])) {
|
||||
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
|
||||
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
|
||||
$data['scope_jsonb'] = [
|
||||
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
|
||||
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
|
||||
];
|
||||
$data['scope_jsonb'] = BaselineScope::fromJsonb(is_array($data['scope_jsonb']) ? $data['scope_jsonb'] : null)->toJsonb();
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
@ -225,6 +226,33 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Intune RBAC Role Definition References')
|
||||
->schema([
|
||||
RepeatableEntry::make('rbac_role_definition_references')
|
||||
->label('')
|
||||
->state(static fn (BaselineSnapshot $record): array => self::rbacRoleDefinitionReferences($record))
|
||||
->schema([
|
||||
TextEntry::make('display_name')
|
||||
->label('Role definition'),
|
||||
TextEntry::make('role_source')
|
||||
->label('Role source')
|
||||
->badge(),
|
||||
TextEntry::make('permission_blocks')
|
||||
->label('Permission blocks'),
|
||||
TextEntry::make('identity_strategy')
|
||||
->label('Identity')
|
||||
->badge(),
|
||||
TextEntry::make('policy_version_reference')
|
||||
->label('Baseline evidence'),
|
||||
TextEntry::make('observed_at')
|
||||
->label('Observed at')
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(static fn (BaselineSnapshot $record): bool => self::rbacRoleDefinitionReferences($record) !== [])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -319,6 +347,48 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
return self::gapsCount($snapshot) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* display_name: string,
|
||||
* role_source: string,
|
||||
* permission_blocks: string,
|
||||
* identity_strategy: string,
|
||||
* policy_version_reference: string,
|
||||
* observed_at: ?string
|
||||
* }>
|
||||
*/
|
||||
private static function rbacRoleDefinitionReferences(BaselineSnapshot $snapshot): array
|
||||
{
|
||||
return $snapshot->items()
|
||||
->where('policy_type', 'intuneRoleDefinition')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(static function (\App\Models\BaselineSnapshotItem $item): array {
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
$policyVersionId = data_get($meta, 'version_reference.policy_version_id');
|
||||
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count');
|
||||
$identityStrategy = (string) data_get($meta, 'identity.strategy', 'display_name');
|
||||
|
||||
return [
|
||||
'display_name' => (string) data_get($meta, 'display_name', '—'),
|
||||
'role_source' => match (data_get($meta, 'rbac.is_built_in')) {
|
||||
true => 'Built-in',
|
||||
false => 'Custom',
|
||||
default => 'Unknown',
|
||||
},
|
||||
'permission_blocks' => is_numeric($rolePermissionCount)
|
||||
? (string) ((int) $rolePermissionCount)
|
||||
: '—',
|
||||
'identity_strategy' => $identityStrategy === 'external_id' ? 'Role definition ID' : 'Display name',
|
||||
'policy_version_reference' => is_numeric($policyVersionId)
|
||||
? 'Policy version #'.((int) $policyVersionId)
|
||||
: 'Metadata only',
|
||||
'observed_at' => data_get($meta, 'evidence.observed_at'),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||
|
||||
@ -125,6 +125,12 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Finding')
|
||||
->schema([
|
||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||
TextEntry::make('drift_surface_label')
|
||||
->label('Drift surface')
|
||||
->badge()
|
||||
->color('gray')
|
||||
->state(fn (Finding $record): ?string => static::driftSurfaceLabel($record))
|
||||
->visible(fn (Finding $record): bool => static::driftSurfaceLabel($record) !== null),
|
||||
TextEntry::make('evidence_fidelity')
|
||||
->label('Fidelity')
|
||||
->badge()
|
||||
@ -162,7 +168,9 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}),
|
||||
TextEntry::make('subject_type')->label('Subject type'),
|
||||
TextEntry::make('subject_type')
|
||||
->label('Subject type')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)),
|
||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||
TextEntry::make('baseline_operation_run_id')
|
||||
->label('Baseline run')
|
||||
@ -267,6 +275,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(fn (Finding $record): string => static::driftDiffUnavailableMessage($record))
|
||||
->visible(fn (Finding $record): bool => ! static::canRenderDriftDiff($record))
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('rbac_role_definition_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.rbac-role-definition-diff')
|
||||
->state(fn (Finding $record): array => Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition', []))
|
||||
->visible(fn (Finding $record): bool => static::driftSummaryKind($record) === 'rbac_role_definition' && is_array(Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition')))
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('settings_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-diff')
|
||||
@ -363,6 +377,79 @@ private static function driftChangeType(Finding $record): string
|
||||
return is_string($changeType) ? trim($changeType) : '';
|
||||
}
|
||||
|
||||
private static function driftSummaryKind(Finding $record): string
|
||||
{
|
||||
$summaryKind = Arr::get($record->evidence_jsonb ?? [], 'summary.kind');
|
||||
|
||||
return is_string($summaryKind) ? trim($summaryKind) : '';
|
||||
}
|
||||
|
||||
private static function isRbacRoleDefinitionDrift(Finding $record): bool
|
||||
{
|
||||
return static::driftSummaryKind($record) === 'rbac_role_definition'
|
||||
|| (string) Arr::get($record->evidence_jsonb ?? [], 'policy_type') === 'intuneRoleDefinition';
|
||||
}
|
||||
|
||||
private static function driftSurfaceLabel(Finding $record): ?string
|
||||
{
|
||||
if (static::isRbacRoleDefinitionDrift($record)) {
|
||||
return __('findings.drift.rbac_role_definition');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function subjectTypeLabel(Finding $record, mixed $state): string
|
||||
{
|
||||
$policyType = Arr::get($record->evidence_jsonb ?? [], 'policy_type');
|
||||
|
||||
if (is_string($policyType) && $policyType !== '') {
|
||||
$translated = __('findings.subject_types.'.$policyType);
|
||||
|
||||
if ($translated !== 'findings.subject_types.'.$policyType) {
|
||||
return $translated;
|
||||
}
|
||||
}
|
||||
|
||||
$value = is_string($state) ? trim($state) : '';
|
||||
|
||||
return $value !== '' ? $value : '—';
|
||||
}
|
||||
|
||||
private static function driftContextDescription(Finding $record): ?string
|
||||
{
|
||||
if (! static::isRbacRoleDefinitionDrift($record)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [__('findings.drift.rbac_role_definition')];
|
||||
$diffKind = Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition.diff_kind');
|
||||
|
||||
if (is_string($diffKind) && $diffKind !== '') {
|
||||
$parts[] = __('findings.rbac.'.$diffKind);
|
||||
}
|
||||
|
||||
return implode(' | ', array_filter($parts, fn (?string $part): bool => is_string($part) && $part !== ''));
|
||||
}
|
||||
|
||||
public static function findingSubheading(Finding $record): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (static::isRbacRoleDefinitionDrift($record)) {
|
||||
$parts[] = __('findings.rbac.detail_heading');
|
||||
$parts[] = __('findings.rbac.detail_subheading');
|
||||
}
|
||||
|
||||
$integrity = static::redactionIntegrityNoteForRecord($record);
|
||||
|
||||
if (is_string($integrity) && trim($integrity) !== '') {
|
||||
$parts[] = $integrity;
|
||||
}
|
||||
|
||||
return $parts !== [] ? implode(' ', $parts) : null;
|
||||
}
|
||||
|
||||
private static function hasBaselinePolicyVersionReference(Finding $record): bool
|
||||
{
|
||||
return is_numeric(Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id'));
|
||||
@ -375,6 +462,10 @@ private static function hasCurrentPolicyVersionReference(Finding $record): bool
|
||||
|
||||
private static function canRenderDriftDiff(Finding $record): bool
|
||||
{
|
||||
if (static::driftSummaryKind($record) === 'rbac_role_definition') {
|
||||
return is_array(Arr::get($record->evidence_jsonb ?? [], 'rbac_role_definition'));
|
||||
}
|
||||
|
||||
return match (static::driftChangeType($record)) {
|
||||
'missing_policy' => static::hasBaselinePolicyVersionReference($record),
|
||||
'unexpected_policy' => static::hasCurrentPolicyVersionReference($record),
|
||||
@ -391,6 +482,10 @@ public static function redactionIntegrityNoteForRecord(Finding $record): ?string
|
||||
|
||||
private static function driftDiffUnavailableMessage(Finding $record): string
|
||||
{
|
||||
if (static::driftSummaryKind($record) === 'rbac_role_definition') {
|
||||
return 'RBAC evidence unavailable — normalized role definition evidence is missing.';
|
||||
}
|
||||
|
||||
return match (static::driftChangeType($record)) {
|
||||
'missing_policy' => 'Diff unavailable — missing baseline policy version reference.',
|
||||
'unexpected_policy' => 'Diff unavailable — missing current policy version reference.',
|
||||
@ -488,8 +583,13 @@ public static function table(Table $table): Table
|
||||
$fallback = is_string($fallback) ? trim($fallback) : null;
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
})
|
||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||
Tables\Columns\TextColumn::make('subject_type')
|
||||
->label('Subject type')
|
||||
->searchable()
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('due_at')
|
||||
->label('Due')
|
||||
->dateTime()
|
||||
|
||||
@ -23,6 +23,6 @@ protected function getHeaderActions(): array
|
||||
|
||||
public function getSubheading(): string|Htmlable|null
|
||||
{
|
||||
return FindingResource::redactionIntegrityNoteForRecord($this->getRecord());
|
||||
return FindingResource::findingSubheading($this->getRecord());
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ protected function getHeaderActions(): array
|
||||
->columnSpanFull(),
|
||||
Toggle::make('include_foundations')
|
||||
->label('Include foundation types')
|
||||
->helperText('Include scope tags, assignment filters, and notification templates.')
|
||||
->helperText('Include scope tags, assignment filters, notification templates, and Intune RBAC role definitions and assignments.')
|
||||
->default(true)
|
||||
->dehydrated()
|
||||
->rules(['boolean'])
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class RecentDriftFindings extends TableWidget
|
||||
{
|
||||
@ -39,7 +40,34 @@ public function table(Table $table): Table
|
||||
->label('Subject')
|
||||
->placeholder('—')
|
||||
->limit(40)
|
||||
->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null),
|
||||
->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;
|
||||
})
|
||||
->description(function (Finding $record): ?string {
|
||||
if (Arr::get($record->evidence_jsonb ?? [], 'summary.kind') !== 'rbac_role_definition') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return __('findings.drift.rbac_role_definition');
|
||||
})
|
||||
->tooltip(function (Finding $record): ?string {
|
||||
$displayName = $record->subject_display_name;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
|
||||
|
||||
return is_string($fallback) && trim($fallback) !== '' ? trim($fallback) : null;
|
||||
}),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->sortable()
|
||||
|
||||
@ -21,8 +21,8 @@
|
||||
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\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -110,6 +110,7 @@ public function handle(
|
||||
$inventoryResult = $this->collectInventorySubjects(
|
||||
sourceTenant: $sourceTenant,
|
||||
scope: $effectiveScope,
|
||||
identity: $identity,
|
||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||
);
|
||||
|
||||
@ -279,9 +280,12 @@ public function handle(
|
||||
* workspace_subject_external_id: string,
|
||||
* subject_key: string,
|
||||
* policy_type: string,
|
||||
* identity_strategy: string,
|
||||
* display_name: ?string,
|
||||
* category: ?string,
|
||||
* platform: ?string
|
||||
* platform: ?string,
|
||||
* is_built_in: ?bool,
|
||||
* role_permission_count: ?int
|
||||
* }>,
|
||||
* gaps: array<string, int>
|
||||
* }
|
||||
@ -289,6 +293,7 @@ public function handle(
|
||||
private function collectInventorySubjects(
|
||||
Tenant $sourceTenant,
|
||||
BaselineScope $scope,
|
||||
BaselineSnapshotIdentity $identity,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
@ -300,7 +305,7 @@ private function collectInventorySubjects(
|
||||
|
||||
$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 */
|
||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
||||
$inventoryByKey = [];
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
@ -323,11 +328,13 @@ private function collectInventorySubjects(
|
||||
|
||||
$query->orderBy('policy_type')
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey): void {
|
||||
->chunk(500, function ($inventoryItems) use (&$inventoryByKey, &$gaps, &$ambiguousKeys, &$subjectKeyToInventoryKey, $identity): 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);
|
||||
$policyType = (string) $inventoryItem->policy_type;
|
||||
$tenantSubjectExternalId = is_string($inventoryItem->external_id) ? $inventoryItem->external_id : null;
|
||||
$subjectKey = $identity->subjectKey($policyType, $displayName, $tenantSubjectExternalId);
|
||||
|
||||
if ($subjectKey === null) {
|
||||
$gaps['missing_subject_key'] = ($gaps['missing_subject_key'] ?? 0) + 1;
|
||||
@ -335,7 +342,6 @@ private function collectInventorySubjects(
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) $inventoryItem->policy_type;
|
||||
$logicalKey = $policyType.'|'.$subjectKey;
|
||||
|
||||
if (array_key_exists($logicalKey, $ambiguousKeys)) {
|
||||
@ -353,10 +359,13 @@ private function collectInventorySubjects(
|
||||
continue;
|
||||
}
|
||||
|
||||
$workspaceSafeId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
);
|
||||
$workspaceSafeId = $identity->workspaceSafeSubjectExternalId($policyType, $displayName, $tenantSubjectExternalId);
|
||||
|
||||
if (! is_string($workspaceSafeId) || $workspaceSafeId === '') {
|
||||
$gaps['missing_subject_external_reference'] = ($gaps['missing_subject_external_reference'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $policyType.'|'.(string) $inventoryItem->external_id;
|
||||
$subjectKeyToInventoryKey[$logicalKey] = $key;
|
||||
@ -366,9 +375,12 @@ private function collectInventorySubjects(
|
||||
'workspace_subject_external_id' => $workspaceSafeId,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'identity_strategy' => InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType),
|
||||
'display_name' => $displayName,
|
||||
'category' => is_string($inventoryItem->category) ? $inventoryItem->category : null,
|
||||
'platform' => is_string($inventoryItem->platform) ? $inventoryItem->platform : null,
|
||||
'is_built_in' => is_bool($metaJsonb['is_built_in'] ?? null) ? (bool) $metaJsonb['is_built_in'] : null,
|
||||
'role_permission_count' => is_numeric($metaJsonb['role_permission_count'] ?? null) ? (int) $metaJsonb['role_permission_count'] : null,
|
||||
];
|
||||
}
|
||||
});
|
||||
@ -397,9 +409,12 @@ private function collectInventorySubjects(
|
||||
* workspace_subject_external_id: string,
|
||||
* subject_key: string,
|
||||
* policy_type: string,
|
||||
* identity_strategy: string,
|
||||
* display_name: ?string,
|
||||
* category: ?string,
|
||||
* platform: ?string,
|
||||
* is_built_in: ?bool,
|
||||
* role_permission_count: ?int,
|
||||
* }> $inventoryByKey
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedEvidence
|
||||
* @param array<string, int> $gaps
|
||||
@ -434,6 +449,12 @@ private function buildSnapshotItems(
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) $inventoryItem['policy_type'] === 'intuneRoleDefinition' && ! is_numeric($evidence->meta['policy_version_id'] ?? null)) {
|
||||
$gaps['missing_role_definition_version_reference'] = ($gaps['missing_role_definition_version_reference'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$provenance = $evidence->provenance();
|
||||
unset($provenance['observed_operation_run_id']);
|
||||
|
||||
@ -455,6 +476,19 @@ private function buildSnapshotItems(
|
||||
'category' => $inventoryItem['category'],
|
||||
'platform' => $inventoryItem['platform'],
|
||||
'evidence' => $provenance,
|
||||
'identity' => [
|
||||
'strategy' => $inventoryItem['identity_strategy'],
|
||||
'subject_key' => $inventoryItem['subject_key'],
|
||||
'workspace_subject_external_id' => $inventoryItem['workspace_subject_external_id'],
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => is_numeric($evidence->meta['policy_version_id'] ?? null) ? (int) $evidence->meta['policy_version_id'] : null,
|
||||
'capture_purpose' => is_string($evidence->meta['capture_purpose'] ?? null) ? (string) $evidence->meta['capture_purpose'] : null,
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => $inventoryItem['is_built_in'],
|
||||
'role_permission_count' => $inventoryItem['role_permission_count'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
@ -424,6 +425,7 @@ public function handle(
|
||||
);
|
||||
$driftResults = $computeResult['drift'];
|
||||
$driftGaps = $computeResult['evidence_gaps'];
|
||||
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
|
||||
|
||||
$upsertResult = $this->upsertFindings(
|
||||
$tenant,
|
||||
@ -525,6 +527,7 @@ public function handle(
|
||||
...$coverageBreakdown,
|
||||
...$baselineCoverage,
|
||||
],
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
'fidelity' => $overallFidelity,
|
||||
'reason_code' => $reasonCode?->value,
|
||||
],
|
||||
@ -770,6 +773,7 @@ private function completeWithCoverageWarning(
|
||||
'policy_types_content' => [],
|
||||
'policy_types_meta_only' => [],
|
||||
],
|
||||
'rbac_role_definitions' => $this->emptyRbacRoleDefinitionSummary(),
|
||||
'fidelity' => 'meta',
|
||||
],
|
||||
);
|
||||
@ -841,15 +845,14 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
||||
->chunk(500, function ($snapshotItems) use (&$items, &$gaps, &$ambiguousKeys): void {
|
||||
foreach ($snapshotItems as $item) {
|
||||
$metaJsonb = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
$displayName = $metaJsonb['display_name'] ?? ($metaJsonb['displayName'] ?? null);
|
||||
$displayName = is_string($displayName) ? $displayName : null;
|
||||
|
||||
$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) ?? '';
|
||||
}
|
||||
$subjectKey = $this->normalizeSubjectKey(
|
||||
policyType: (string) $item->policy_type,
|
||||
storedSubjectKey: is_string($item->subject_key) ? $item->subject_key : null,
|
||||
displayName: $displayName,
|
||||
);
|
||||
|
||||
if ($subjectKey === '') {
|
||||
$gaps['missing_subject_key_baseline'] = ($gaps['missing_subject_key_baseline'] ?? 0) + 1;
|
||||
@ -935,7 +938,12 @@ private function loadCurrentInventory(
|
||||
->orderBy('external_id')
|
||||
->chunk(500, function ($inventoryItems) use (&$items, &$gaps, &$ambiguousKeys): void {
|
||||
foreach ($inventoryItems as $inventoryItem) {
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName(is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null) ?? '';
|
||||
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
|
||||
$subjectKey = $this->normalizeSubjectKey(
|
||||
policyType: (string) $inventoryItem->policy_type,
|
||||
displayName: is_string($inventoryItem->display_name) ? $inventoryItem->display_name : null,
|
||||
subjectExternalId: is_string($inventoryItem->external_id) ? $inventoryItem->external_id : null,
|
||||
);
|
||||
|
||||
if ($subjectKey === '') {
|
||||
$gaps['missing_subject_key_current'] = ($gaps['missing_subject_key_current'] ?? 0) + 1;
|
||||
@ -966,6 +974,8 @@ private function loadCurrentInventory(
|
||||
'display_name' => $inventoryItem->display_name,
|
||||
'category' => $inventoryItem->category,
|
||||
'platform' => $inventoryItem->platform,
|
||||
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
|
||||
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -1002,7 +1012,8 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
* @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, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
||||
* evidence_gaps: array<string, int>
|
||||
* evidence_gaps: array<string, int>,
|
||||
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
||||
* }
|
||||
*/
|
||||
private function computeDrift(
|
||||
@ -1023,7 +1034,9 @@ private function computeDrift(
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): array {
|
||||
$drift = [];
|
||||
$missingCurrentEvidence = 0;
|
||||
$evidenceGaps = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
$baselinePlaceholderProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
@ -1044,12 +1057,12 @@ private function computeDrift(
|
||||
|
||||
$policyType = (string) ($baselineItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
|
||||
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
||||
|
||||
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
|
||||
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
baselineItem: $baselineItem,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
||||
);
|
||||
@ -1061,6 +1074,12 @@ private function computeDrift(
|
||||
);
|
||||
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
@ -1082,9 +1101,28 @@ private function computeDrift(
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$evidence['summary']['kind'] = 'rbac_role_definition';
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: null,
|
||||
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
||||
currentMeta: [],
|
||||
diffKind: 'missing',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['missing']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'missing_policy',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? Finding::SEVERITY_HIGH
|
||||
: $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
@ -1101,29 +1139,59 @@ private function computeDrift(
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$missingCurrentEvidence++;
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
if ($baselineComparableHash !== $currentEvidence->hash) {
|
||||
$displayName = $currentItem['meta_jsonb']['display_name']
|
||||
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
||||
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
$roleDefinitionDiff = null;
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
|
||||
$summaryKind = $this->selectSummaryKind(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
hasher: $hasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
normalizer: $roleDefinitionNormalizer,
|
||||
);
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$summaryKind = $isRbacRoleDefinition
|
||||
? 'rbac_role_definition'
|
||||
: $this->selectSummaryKind(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
hasher: $hasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
);
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'different_version',
|
||||
@ -1143,9 +1211,25 @@ private function computeDrift(
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
||||
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
||||
diffKind: (string) $roleDefinitionDiff['diff_kind'],
|
||||
roleDefinitionDiff: $roleDefinitionDiff,
|
||||
);
|
||||
$rbacRoleDefinitionSummary['modified']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'different_version',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
|
||||
: $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
@ -1155,6 +1239,13 @@ private function computeDrift(
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['unchanged']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1163,19 +1254,26 @@ private function computeDrift(
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$missingCurrentEvidence++;
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
|
||||
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
||||
|
||||
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'unexpected_policy',
|
||||
policyType: $policyType,
|
||||
@ -1194,9 +1292,28 @@ private function computeDrift(
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$evidence['summary']['kind'] = 'rbac_role_definition';
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: null,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
baselineMeta: [],
|
||||
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
||||
diffKind: 'unexpected',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['unexpected']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? Finding::SEVERITY_MEDIUM
|
||||
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
@ -1211,9 +1328,8 @@ private function computeDrift(
|
||||
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => [
|
||||
'missing_current' => $missingCurrentEvidence,
|
||||
],
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
|
||||
@ -1256,11 +1372,17 @@ private function effectiveBaselineHash(
|
||||
|
||||
private function resolveBaselinePolicyVersionId(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
array $baselineItem,
|
||||
array $baselineProvenance,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
): ?int {
|
||||
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
||||
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
|
||||
|
||||
if (is_numeric($versionReferenceId)) {
|
||||
return (int) $versionReferenceId;
|
||||
}
|
||||
|
||||
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
||||
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
|
||||
|
||||
@ -1277,8 +1399,8 @@ private function resolveBaselinePolicyVersionId(
|
||||
|
||||
return $baselinePolicyVersionResolver->resolve(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
policyType: (string) ($baselineItem['policy_type'] ?? ''),
|
||||
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
|
||||
observedAt: $observedAt,
|
||||
);
|
||||
}
|
||||
@ -1449,6 +1571,161 @@ private function buildDriftEvidenceContract(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineMeta
|
||||
* @param array<string, mixed> $currentMeta
|
||||
* @param array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }|null $roleDefinitionDiff
|
||||
* @return array{
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
|
||||
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
|
||||
* }
|
||||
*/
|
||||
private function buildRoleDefinitionEvidencePayload(
|
||||
Tenant $tenant,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
array $baselineMeta,
|
||||
array $currentMeta,
|
||||
string $diffKind,
|
||||
?array $roleDefinitionDiff = null,
|
||||
): array {
|
||||
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
||||
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
||||
|
||||
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
|
||||
? $roleDefinitionDiff['baseline']
|
||||
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
|
||||
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
|
||||
? $roleDefinitionDiff['current']
|
||||
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
|
||||
|
||||
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
|
||||
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
|
||||
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
|
||||
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
|
||||
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
|
||||
: $this->roleDefinitionPermissionKeys($changedKeys);
|
||||
|
||||
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
|
||||
? (string) $roleDefinitionDiff['diff_kind']
|
||||
: $diffKind;
|
||||
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
|
||||
? (string) $roleDefinitionDiff['diff_fingerprint']
|
||||
: hash(
|
||||
'sha256',
|
||||
json_encode([
|
||||
'diff_kind' => $resolvedDiffKind,
|
||||
'changed_keys' => $changedKeys,
|
||||
'baseline' => $baselineNormalized,
|
||||
'current' => $currentNormalized,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
|
||||
return [
|
||||
'diff_kind' => $resolvedDiffKind,
|
||||
'diff_fingerprint' => $diffFingerprint,
|
||||
'changed_keys' => $changedKeys,
|
||||
'metadata_keys' => $metadataKeys,
|
||||
'permission_keys' => $permissionKeys,
|
||||
'baseline' => [
|
||||
'normalized' => $baselineNormalized,
|
||||
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
|
||||
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
|
||||
],
|
||||
'current' => [
|
||||
'normalized' => $currentNormalized,
|
||||
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
|
||||
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
|
||||
{
|
||||
if ($policyVersionId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($policyVersionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
||||
{
|
||||
if ($version instanceof PolicyVersion) {
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->flattenForDiff(
|
||||
is_array($version->snapshot) ? $version->snapshot : [],
|
||||
'intuneRoleDefinition',
|
||||
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
||||
);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$displayName = $meta['display_name'] ?? null;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
$normalized['Role definition > Display name'] = trim($displayName);
|
||||
}
|
||||
|
||||
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
|
||||
if (is_bool($isBuiltIn)) {
|
||||
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
|
||||
}
|
||||
|
||||
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
|
||||
if (is_numeric($rolePermissionCount)) {
|
||||
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineNormalized
|
||||
* @param array<string, mixed> $currentNormalized
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
|
||||
{
|
||||
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keys
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleDefinitionPermissionKeys(array $keys): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$keys,
|
||||
fn (string $key): bool => str_starts_with($key, 'Permission block ')
|
||||
));
|
||||
}
|
||||
|
||||
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
||||
{
|
||||
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
|
||||
@ -1462,6 +1739,79 @@ private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?i
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
private function normalizeSubjectKey(
|
||||
string $policyType,
|
||||
?string $storedSubjectKey = null,
|
||||
?string $displayName = null,
|
||||
?string $subjectExternalId = null,
|
||||
): string {
|
||||
$storedSubjectKey = is_string($storedSubjectKey) ? trim(mb_strtolower($storedSubjectKey)) : '';
|
||||
|
||||
if ($storedSubjectKey !== '') {
|
||||
return $storedSubjectKey;
|
||||
}
|
||||
|
||||
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }|null
|
||||
*/
|
||||
private function resolveRoleDefinitionDiff(
|
||||
Tenant $tenant,
|
||||
int $baselinePolicyVersionId,
|
||||
int $currentPolicyVersionId,
|
||||
IntuneRoleDefinitionNormalizer $normalizer,
|
||||
): ?array {
|
||||
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
||||
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalizer->classifyDiff(
|
||||
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
||||
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
|
||||
platform: is_string($currentVersion->platform ?? null)
|
||||
? (string) $currentVersion->platform
|
||||
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{diff_kind?: string}|null $roleDefinitionDiff
|
||||
*/
|
||||
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
|
||||
{
|
||||
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
|
||||
'metadata_only' => Finding::SEVERITY_LOW,
|
||||
default => Finding::SEVERITY_HIGH,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
||||
*/
|
||||
private function emptyRbacRoleDefinitionSummary(): array
|
||||
{
|
||||
return [
|
||||
'total_compared' => 0,
|
||||
'unchanged' => 0,
|
||||
'modified' => 0,
|
||||
'missing' => 0,
|
||||
'unexpected' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> ...$gaps
|
||||
* @return array<string, int>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
/**
|
||||
* Computes the snapshot_identity_hash for baseline snapshot content dedupe.
|
||||
@ -47,6 +48,16 @@ public function computeIdentity(array $items): string
|
||||
return hash('sha256', implode("\n", $normalized));
|
||||
}
|
||||
|
||||
public function subjectKey(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
|
||||
{
|
||||
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId);
|
||||
}
|
||||
|
||||
public function workspaceSafeSubjectExternalId(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
|
||||
{
|
||||
return BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy($policyType, $displayName, $subjectExternalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a stable content hash for a single inventory item's metadata.
|
||||
*
|
||||
|
||||
@ -106,7 +106,7 @@ private function buildIndex(int $tenantId, string $policyType): array
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('policy_type', $policyType)
|
||||
->get(['id', 'display_name']);
|
||||
->get(['id', 'display_name', 'external_id']);
|
||||
|
||||
/** @var array<string, int> $index */
|
||||
$index = [];
|
||||
@ -119,7 +119,11 @@ private function buildIndex(int $tenantId, string $policyType): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = BaselineSubjectKey::fromDisplayName($policy->display_name);
|
||||
$key = BaselineSubjectKey::forPolicy(
|
||||
$policyType,
|
||||
is_string($policy->display_name) ? $policy->display_name : null,
|
||||
is_string($policy->external_id) ? $policy->external_id : null,
|
||||
);
|
||||
|
||||
if ($key === null) {
|
||||
continue;
|
||||
|
||||
@ -255,6 +255,21 @@ private function buildResolvedEvidence(
|
||||
$capturePurpose = is_string($capturePurpose) ? trim($capturePurpose) : null;
|
||||
$capturePurpose = $capturePurpose !== '' ? $capturePurpose : null;
|
||||
|
||||
$meta = [
|
||||
'policy_version_id' => $policyVersionId,
|
||||
'operation_run_id' => is_numeric($operationRunId) ? (int) $operationRunId : null,
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'redaction_version' => $redactionVersion,
|
||||
];
|
||||
|
||||
if ($policyType === 'intuneRoleDefinition') {
|
||||
$meta['normalized_settings'] = $normalized;
|
||||
$meta['rbac'] = [
|
||||
'is_built_in' => is_bool($snapshot['isBuiltIn'] ?? null) ? $snapshot['isBuiltIn'] : null,
|
||||
'role_permission_count' => is_array($snapshot['rolePermissions'] ?? null) ? count($snapshot['rolePermissions']) : null,
|
||||
];
|
||||
}
|
||||
|
||||
return new ResolvedEvidence(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: $subjectExternalId,
|
||||
@ -263,12 +278,7 @@ private function buildResolvedEvidence(
|
||||
source: EvidenceProvenance::SourcePolicyVersion,
|
||||
observedAt: $observedAt,
|
||||
observedOperationRunId: $observedOperationRunId,
|
||||
meta: [
|
||||
'policy_version_id' => $policyVersionId,
|
||||
'operation_run_id' => is_numeric($operationRunId) ? (int) $operationRunId : null,
|
||||
'capture_purpose' => $capturePurpose,
|
||||
'redaction_version' => $redactionVersion,
|
||||
],
|
||||
meta: $meta,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,12 @@
|
||||
|
||||
class IntuneRoleDefinitionNormalizer implements PolicyTypeNormalizer
|
||||
{
|
||||
private const string MetadataOnly = 'metadata_only';
|
||||
|
||||
private const string None = 'none';
|
||||
|
||||
private const string PermissionChange = 'permission_change';
|
||||
|
||||
public function __construct(
|
||||
private readonly DefaultPolicyNormalizer $defaultNormalizer,
|
||||
) {}
|
||||
@ -102,6 +108,70 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }
|
||||
*/
|
||||
public function classifyDiff(?array $baselineSnapshot, ?array $currentSnapshot, ?string $platform = null): array
|
||||
{
|
||||
$baseline = $this->flattenForDiff($baselineSnapshot, 'intuneRoleDefinition', $platform);
|
||||
$current = $this->flattenForDiff($currentSnapshot, 'intuneRoleDefinition', $platform);
|
||||
|
||||
$keys = array_values(array_unique(array_merge(array_keys($baseline), array_keys($current))));
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
$changedKeys = [];
|
||||
$metadataKeys = [];
|
||||
$permissionKeys = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (($baseline[$key] ?? null) === ($current[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changedKeys[] = $key;
|
||||
|
||||
if ($this->isPermissionDiffKey($key)) {
|
||||
$permissionKeys[] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadataKeys[] = $key;
|
||||
}
|
||||
|
||||
$diffKind = match (true) {
|
||||
$permissionKeys !== [] => self::PermissionChange,
|
||||
$metadataKeys !== [] => self::MetadataOnly,
|
||||
default => self::None,
|
||||
};
|
||||
|
||||
return [
|
||||
'baseline' => $baseline,
|
||||
'current' => $current,
|
||||
'changed_keys' => $changedKeys,
|
||||
'metadata_keys' => $metadataKeys,
|
||||
'permission_keys' => $permissionKeys,
|
||||
'diff_kind' => $diffKind,
|
||||
'diff_fingerprint' => hash(
|
||||
'sha256',
|
||||
json_encode([
|
||||
'diff_kind' => $diffKind,
|
||||
'changed_keys' => $changedKeys,
|
||||
'baseline' => $baseline,
|
||||
'current' => $current,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{allowed: array<int, string>, denied: array<int, string>, conditions: array<int, string>, fingerprint: string}>
|
||||
*/
|
||||
@ -203,4 +273,10 @@ private function normalizedStringList(mixed $values): array
|
||||
static fn (?string $value): bool => $value !== null,
|
||||
));
|
||||
}
|
||||
|
||||
private function isPermissionDiffKey(string $key): bool
|
||||
{
|
||||
return $key === 'Role definition > Permission blocks'
|
||||
|| str_starts_with($key, 'Permission block ');
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
@ -361,6 +362,16 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
||||
]
|
||||
);
|
||||
|
||||
if ($this->supportsFoundationVersioning($policyType)) {
|
||||
$this->resolveFoundationPolicyAnchor(
|
||||
tenant: $tenant,
|
||||
foundationType: $policyType,
|
||||
sourceId: $externalId,
|
||||
platform: is_string($typeConfig['platform'] ?? null) ? $typeConfig['platform'] : null,
|
||||
displayName: $displayName,
|
||||
);
|
||||
}
|
||||
|
||||
$upserted++;
|
||||
|
||||
// Extract dependencies if requested in selection
|
||||
@ -435,6 +446,11 @@ private function shouldHydrateAssignments(string $policyType): bool
|
||||
return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||
}
|
||||
|
||||
private function supportsFoundationVersioning(string $foundationType): bool
|
||||
{
|
||||
return in_array($foundationType, ['intuneRoleDefinition', 'intuneRoleAssignment'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $warnings
|
||||
* @return null|array<int, mixed>
|
||||
@ -628,6 +644,39 @@ private function rbacMeta(string $policyType, array $policyData): array
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveFoundationPolicyAnchor(
|
||||
Tenant $tenant,
|
||||
string $foundationType,
|
||||
string $sourceId,
|
||||
?string $platform,
|
||||
?string $displayName,
|
||||
): Policy {
|
||||
$policy = Policy::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => $sourceId,
|
||||
'policy_type' => $foundationType,
|
||||
]);
|
||||
|
||||
$existingMetadata = is_array($policy->metadata) ? $policy->metadata : [];
|
||||
|
||||
$policy->fill([
|
||||
'platform' => $platform,
|
||||
'display_name' => is_string($displayName) && $displayName !== '' ? $displayName : $sourceId,
|
||||
'last_synced_at' => null,
|
||||
'metadata' => array_merge(
|
||||
$existingMetadata,
|
||||
[
|
||||
'foundation_anchor' => true,
|
||||
'foundation_type' => $foundationType,
|
||||
'capture_mode' => 'immutable_backup',
|
||||
],
|
||||
),
|
||||
]);
|
||||
$policy->save();
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||
{
|
||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
||||
|
||||
@ -42,6 +42,7 @@ private function __construct(
|
||||
public readonly ?string $fidelity = null,
|
||||
public readonly ?int $evidenceGapsCount = null,
|
||||
public readonly array $evidenceGapsTopReasons = [],
|
||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
||||
) {}
|
||||
|
||||
public static function forTenant(?Tenant $tenant): self
|
||||
@ -103,6 +104,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
|
||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
@ -127,6 +129,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
);
|
||||
}
|
||||
|
||||
@ -158,6 +161,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
);
|
||||
}
|
||||
|
||||
@ -211,6 +215,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
);
|
||||
}
|
||||
|
||||
@ -238,6 +243,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
);
|
||||
}
|
||||
|
||||
@ -262,6 +268,7 @@ public static function forTenant(?Tenant $tenant): self
|
||||
fidelity: $fidelity,
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
);
|
||||
}
|
||||
|
||||
@ -528,6 +535,32 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
||||
return [$count, array_slice($normalized, 0, 6, true)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}|null
|
||||
*/
|
||||
private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
$summary = is_array($baselineCompare) ? ($baselineCompare['rbac_role_definitions'] ?? null) : null;
|
||||
|
||||
if (! is_array($summary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_compared' => (int) ($summary['total_compared'] ?? 0),
|
||||
'unchanged' => (int) ($summary['unchanged'] ?? 0),
|
||||
'modified' => (int) ($summary['modified'] ?? 0),
|
||||
'missing' => (int) ($summary['missing'] ?? 0),
|
||||
'unexpected' => (int) ($summary['unexpected'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function empty(
|
||||
string $state,
|
||||
?string $message,
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
/**
|
||||
* Value object for baseline scope resolution.
|
||||
*
|
||||
@ -38,9 +40,12 @@ public static function fromJsonb(?array $scopeJsonb): self
|
||||
$policyTypes = $scopeJsonb['policy_types'] ?? [];
|
||||
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
|
||||
|
||||
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [];
|
||||
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [];
|
||||
|
||||
return new self(
|
||||
policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
|
||||
foundationTypes: is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
|
||||
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes),
|
||||
foundationTypes: self::normalizeFoundationTypes($foundationTypes),
|
||||
);
|
||||
}
|
||||
|
||||
@ -168,13 +173,7 @@ private static function supportedPolicyTypes(): array
|
||||
*/
|
||||
private static function supportedFoundationTypes(): array
|
||||
{
|
||||
$foundations = config('tenantpilot.foundation_types', []);
|
||||
|
||||
if (! is_array($foundations)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$types = collect($foundations)
|
||||
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
|
||||
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
|
||||
->map(fn (array $row): string => (string) $row['type'])
|
||||
->filter(fn (string $type): bool => $type !== '')
|
||||
|
||||
@ -4,8 +4,18 @@
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
final class BaselineSubjectKey
|
||||
{
|
||||
public static function forPolicy(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
|
||||
{
|
||||
return match (InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType)) {
|
||||
'external_id' => self::fromExternalId($policyType, $subjectExternalId),
|
||||
default => self::fromDisplayName($displayName),
|
||||
};
|
||||
}
|
||||
|
||||
public static function fromDisplayName(?string $displayName): ?string
|
||||
{
|
||||
if (! is_string($displayName)) {
|
||||
@ -27,8 +37,37 @@ public static function fromDisplayName(?string $displayName): ?string
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
public static function fromExternalId(string $policyType, ?string $subjectExternalId): ?string
|
||||
{
|
||||
if (! is_string($subjectExternalId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedId = trim(mb_strtolower($subjectExternalId));
|
||||
|
||||
if ($normalizedId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash('sha256', trim(mb_strtolower($policyType)).'|'.$normalizedId);
|
||||
}
|
||||
|
||||
public static function workspaceSafeSubjectExternalId(string $policyType, string $subjectKey): string
|
||||
{
|
||||
return hash('sha256', $policyType.'|'.$subjectKey);
|
||||
}
|
||||
|
||||
public static function workspaceSafeSubjectExternalIdForPolicy(string $policyType, ?string $displayName = null, ?string $subjectExternalId = null): ?string
|
||||
{
|
||||
$identityInput = match (InventoryPolicyTypeMeta::baselineCompareIdentityStrategy($policyType)) {
|
||||
'external_id' => is_string($subjectExternalId) ? trim(mb_strtolower($subjectExternalId)) : null,
|
||||
default => self::fromDisplayName($displayName),
|
||||
};
|
||||
|
||||
if (! is_string($identityInput) || $identityInput === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::workspaceSafeSubjectExternalId($policyType, $identityInput);
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,19 @@ public static function foundations(): array
|
||||
return is_array($foundations) ? $foundations : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function baselineSupportedFoundations(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
static::foundations(),
|
||||
static fn (array $row): bool => filled($row['type'] ?? null)
|
||||
&& is_array($row['baseline_compare'] ?? null)
|
||||
&& (bool) ($row['baseline_compare']['supported'] ?? false),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
@ -123,4 +136,43 @@ public static function isHighRisk(?string $type): bool
|
||||
|
||||
return is_string($risk) && str_contains($risk, 'high');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function baselineCompareMeta(?string $type): array
|
||||
{
|
||||
$meta = static::metaFor($type)['baseline_compare'] ?? null;
|
||||
|
||||
return is_array($meta) ? $meta : [];
|
||||
}
|
||||
|
||||
public static function isBaselineSupportedFoundation(?string $type): bool
|
||||
{
|
||||
if (! static::isFoundation($type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
|
||||
}
|
||||
|
||||
public static function baselineCompareIdentityStrategy(?string $type): string
|
||||
{
|
||||
$strategy = static::baselineCompareMeta($type)['identity_strategy'] ?? null;
|
||||
|
||||
return in_array($strategy, ['display_name', 'external_id'], true)
|
||||
? (string) $strategy
|
||||
: 'display_name';
|
||||
}
|
||||
|
||||
public static function baselineCompareLabel(?string $type): ?string
|
||||
{
|
||||
$label = static::baselineCompareMeta($type)['label'] ?? null;
|
||||
|
||||
if (is_string($label) && $label !== '') {
|
||||
return $label;
|
||||
}
|
||||
|
||||
return static::label($type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -302,6 +302,10 @@
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'low',
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'roleScopeTag',
|
||||
@ -312,26 +316,38 @@
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'low',
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleDefinition',
|
||||
'label' => 'Intune Role Definition',
|
||||
'label' => 'Intune RBAC Role Definition',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleDefinitions',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'external_id',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleAssignment',
|
||||
'label' => 'Intune Role Assignment',
|
||||
'label' => 'Intune RBAC Role Assignment',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'endpoint' => 'deviceManagement/roleAssignments',
|
||||
'backup' => 'full',
|
||||
'restore' => 'preview-only',
|
||||
'risk' => 'high',
|
||||
'baseline_compare' => [
|
||||
'supported' => false,
|
||||
'identity_strategy' => 'external_id',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'notificationMessageTemplate',
|
||||
@ -342,6 +358,10 @@
|
||||
'backup' => 'full',
|
||||
'restore' => 'enabled',
|
||||
'risk' => 'low',
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@ -60,6 +60,13 @@
|
||||
|
||||
// Findings section
|
||||
'findings_description' => 'The tenant configuration drifted from the baseline profile.',
|
||||
'rbac_summary_title' => 'Intune RBAC Role Definitions',
|
||||
'rbac_summary_description' => 'Role Assignments are not included in this baseline compare release.',
|
||||
'rbac_summary_compared' => 'Compared',
|
||||
'rbac_summary_unchanged' => 'Unchanged',
|
||||
'rbac_summary_modified' => 'Modified',
|
||||
'rbac_summary_missing' => 'Missing',
|
||||
'rbac_summary_unexpected' => 'Unexpected',
|
||||
|
||||
// No drift
|
||||
'no_drift_title' => 'No Drift Detected',
|
||||
|
||||
31
lang/en/findings.php
Normal file
31
lang/en/findings.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'drift' => [
|
||||
'rbac_role_definition' => 'Intune RBAC Role Definition drift',
|
||||
],
|
||||
'subject_types' => [
|
||||
'policy' => 'Policy',
|
||||
'intuneRoleDefinition' => 'Intune RBAC Role Definition',
|
||||
],
|
||||
'rbac' => [
|
||||
'detail_heading' => 'Intune RBAC Role Definition drift',
|
||||
'detail_subheading' => 'Role Assignments are not included. RBAC restore is not supported.',
|
||||
'metadata_only' => 'Metadata-only change',
|
||||
'permission_change' => 'Permission change',
|
||||
'missing' => 'Missing from current tenant',
|
||||
'unexpected' => 'Unexpected in current tenant',
|
||||
'changed_fields' => 'Changed fields',
|
||||
'baseline' => 'Baseline',
|
||||
'current' => 'Current',
|
||||
'absent' => 'Absent',
|
||||
'role_source' => 'Role source',
|
||||
'permission_blocks' => 'Permission blocks',
|
||||
'built_in' => 'Built-in',
|
||||
'custom' => 'Custom',
|
||||
'assignments_excluded' => 'Role Assignments are not included in this baseline compare release.',
|
||||
'restore_unsupported' => 'RBAC restore is not supported in this release.',
|
||||
],
|
||||
];
|
||||
@ -0,0 +1,118 @@
|
||||
@php
|
||||
$payload = $getState() ?? [];
|
||||
$changedKeys = is_array($payload['changed_keys'] ?? null) ? $payload['changed_keys'] : [];
|
||||
$baseline = is_array($payload['baseline'] ?? null) ? $payload['baseline'] : [];
|
||||
$current = is_array($payload['current'] ?? null) ? $payload['current'] : [];
|
||||
$baselineNormalized = is_array($baseline['normalized'] ?? null) ? $baseline['normalized'] : [];
|
||||
$currentNormalized = is_array($current['normalized'] ?? null) ? $current['normalized'] : [];
|
||||
$diffKind = is_string($payload['diff_kind'] ?? null) ? (string) $payload['diff_kind'] : 'permission_change';
|
||||
|
||||
$stringify = static function (mixed $value): string {
|
||||
if ($value === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return implode(', ', array_map(static fn (mixed $item): string => (string) $item, $value));
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
};
|
||||
|
||||
$roleSourceLabel = static function (mixed $isBuiltIn): string {
|
||||
return match ($isBuiltIn) {
|
||||
true => __('findings.rbac.built_in'),
|
||||
false => __('findings.rbac.custom'),
|
||||
default => '-',
|
||||
};
|
||||
};
|
||||
|
||||
$sideRows = static function (array $normalized, array $side) use ($roleSourceLabel): array {
|
||||
$rows = [];
|
||||
|
||||
foreach ($normalized as $key => $value) {
|
||||
if (! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[$key] = $value;
|
||||
}
|
||||
|
||||
if (! array_key_exists('Role definition > Role source', $rows)) {
|
||||
$rows['Role definition > Role source'] = $roleSourceLabel($side['is_built_in'] ?? null);
|
||||
}
|
||||
|
||||
if (! array_key_exists('Role definition > Permission blocks', $rows) && is_numeric($side['role_permission_count'] ?? null)) {
|
||||
$rows['Role definition > Permission blocks'] = (int) $side['role_permission_count'];
|
||||
}
|
||||
|
||||
ksort($rows);
|
||||
|
||||
return $rows;
|
||||
};
|
||||
|
||||
$baselineRows = $sideRows($baselineNormalized, $baseline);
|
||||
$currentRows = $sideRows($currentNormalized, $current);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
:heading="__('findings.rbac.detail_heading')"
|
||||
:description="__('findings.rbac.' . $diffKind)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="gray">
|
||||
{{ __('findings.rbac.' . $diffKind) }}
|
||||
</x-filament::badge>
|
||||
@if ($changedKeys !== [])
|
||||
<x-filament::badge color="warning">
|
||||
{{ __('findings.rbac.changed_fields') }}: {{ count($changedKeys) }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($changedKeys !== [])
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ __('findings.rbac.changed_fields') }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($changedKeys as $changedKey)
|
||||
<x-filament::badge color="gray">{{ $changedKey }}</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
@foreach ([
|
||||
['heading' => __('findings.rbac.baseline'), 'rows' => $baselineRows],
|
||||
['heading' => __('findings.rbac.current'), 'rows' => $currentRows],
|
||||
] as $section)
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $section['heading'] }}</div>
|
||||
|
||||
@if ($section['rows'] === [])
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">{{ __('findings.rbac.absent') }}</div>
|
||||
@else
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($section['rows'] as $label => $value)
|
||||
<div>
|
||||
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $stringify($value) }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700 dark:border-white/10 dark:bg-gray-950 dark:text-gray-300">
|
||||
{{ __('findings.rbac.assignments_excluded') }}
|
||||
{{ __('findings.rbac.restore_unsupported') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@ -112,6 +112,32 @@ class="w-fit"
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasRbacRoleDefinitionSummary)
|
||||
<x-filament::section :heading="__('baseline-compare.rbac_summary_title')">
|
||||
<x-slot name="description">
|
||||
{{ __('baseline-compare.rbac_summary_description') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<x-filament::badge color="gray">
|
||||
{{ __('baseline-compare.rbac_summary_compared') }}: {{ (int) ($rbacRoleDefinitionSummary['total_compared'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="success">
|
||||
{{ __('baseline-compare.rbac_summary_unchanged') }}: {{ (int) ($rbacRoleDefinitionSummary['unchanged'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ __('baseline-compare.rbac_summary_modified') }}: {{ (int) ($rbacRoleDefinitionSummary['modified'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ __('baseline-compare.rbac_summary_missing') }}: {{ (int) ($rbacRoleDefinitionSummary['missing'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="info">
|
||||
{{ __('baseline-compare.rbac_summary_unexpected') }}: {{ (int) ($rbacRoleDefinitionSummary['unexpected'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Coverage warnings banner --}}
|
||||
@if ($state === 'ready' && $hasCoverageWarnings)
|
||||
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||
|
||||
35
specs/128-rbac-baseline-compare/checklists/requirements.md
Normal file
35
specs/128-rbac-baseline-compare/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Intune RBAC Baseline Compare & Findings v1
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-09
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation completed in one pass.
|
||||
- No clarification markers were needed because the feature description included scope boundaries, severity rules, evidence requirements, and non-goals.
|
||||
286
specs/128-rbac-baseline-compare/contracts/openapi.yaml
Normal file
286
specs/128-rbac-baseline-compare/contracts/openapi.yaml
Normal file
@ -0,0 +1,286 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot Baseline Compare RBAC Planning Contract
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Planning artifact for Spec 128. Documents the existing workflow surfaces that
|
||||
will gain Intune RBAC Role Definition baseline support.
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
paths:
|
||||
/workspaces/{workspaceId}/baseline-profiles/{profileId}:
|
||||
patch:
|
||||
summary: Update baseline profile scope to include baseline-supported foundations
|
||||
operationId: updateBaselineProfileScope
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/ProfileId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineProfileScopeUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: Baseline profile updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineProfile'
|
||||
'403':
|
||||
description: In-scope member lacks workspace baseline management capability
|
||||
'404':
|
||||
description: Workspace or profile is outside authorized scope
|
||||
|
||||
/workspaces/{workspaceId}/baseline-profiles/{profileId}/captures:
|
||||
post:
|
||||
summary: Start baseline capture for the selected baseline profile
|
||||
operationId: startBaselineCapture
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/ProfileId'
|
||||
responses:
|
||||
'202':
|
||||
description: Baseline capture accepted and queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunQueued'
|
||||
'403':
|
||||
description: In-scope member lacks workspace baseline management capability
|
||||
'404':
|
||||
description: Workspace or profile is outside authorized scope
|
||||
|
||||
/tenants/{tenantId}/baseline-compares:
|
||||
post:
|
||||
summary: Start baseline compare for the tenant using the assigned baseline profile
|
||||
operationId: startBaselineCompare
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
baseline_snapshot_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'202':
|
||||
description: Baseline compare accepted and queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunQueued'
|
||||
'403':
|
||||
description: In-scope member lacks tenant sync capability
|
||||
'404':
|
||||
description: Tenant is outside authorized scope
|
||||
|
||||
/tenants/{tenantId}/baseline-compares/{runId}:
|
||||
get:
|
||||
summary: Read baseline compare run detail including RBAC Role Definition summary
|
||||
operationId: getBaselineCompareRun
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/RunId'
|
||||
responses:
|
||||
'200':
|
||||
description: Compare run detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineCompareRun'
|
||||
'403':
|
||||
description: In-scope member lacks required capability
|
||||
'404':
|
||||
description: Tenant or run is outside authorized scope
|
||||
|
||||
/tenants/{tenantId}/findings:
|
||||
get:
|
||||
summary: List tenant findings, including baseline.compare RBAC Role Definition drift
|
||||
operationId: listTenantFindings
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- name: source
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [baseline.compare]
|
||||
- name: policy_type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [intuneRoleDefinition]
|
||||
responses:
|
||||
'200':
|
||||
description: Findings list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
'403':
|
||||
description: In-scope member lacks findings-view capability
|
||||
'404':
|
||||
description: Tenant is outside authorized scope
|
||||
|
||||
components:
|
||||
parameters:
|
||||
WorkspaceId:
|
||||
name: workspaceId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
ProfileId:
|
||||
name: profileId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
RunId:
|
||||
name: runId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
|
||||
schemas:
|
||||
BaselineProfileScopeUpdate:
|
||||
type: object
|
||||
required: [scope_jsonb]
|
||||
properties:
|
||||
scope_jsonb:
|
||||
type: object
|
||||
properties:
|
||||
policy_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
foundation_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [intuneRoleDefinition]
|
||||
|
||||
BaselineProfile:
|
||||
type: object
|
||||
required: [id, scope_jsonb]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
scope_jsonb:
|
||||
type: object
|
||||
properties:
|
||||
policy_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
foundation_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
OperationRunQueued:
|
||||
type: object
|
||||
required: [id, type, status]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
enum: [baseline_capture, baseline_compare]
|
||||
status:
|
||||
type: string
|
||||
enum: [queued, running]
|
||||
|
||||
BaselineCompareRun:
|
||||
type: object
|
||||
required: [id, type, status, context]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
enum: [baseline_compare]
|
||||
status:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
context:
|
||||
type: object
|
||||
properties:
|
||||
baseline_compare:
|
||||
type: object
|
||||
properties:
|
||||
reason_code:
|
||||
type: string
|
||||
rbac_role_definitions:
|
||||
$ref: '#/components/schemas/RbacRoleDefinitionSummary'
|
||||
|
||||
RbacRoleDefinitionSummary:
|
||||
type: object
|
||||
required: [total_compared, unchanged, modified, missing, unexpected]
|
||||
properties:
|
||||
total_compared:
|
||||
type: integer
|
||||
minimum: 0
|
||||
unchanged:
|
||||
type: integer
|
||||
minimum: 0
|
||||
modified:
|
||||
type: integer
|
||||
minimum: 0
|
||||
missing:
|
||||
type: integer
|
||||
minimum: 0
|
||||
unexpected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
Finding:
|
||||
type: object
|
||||
required: [id, source, severity, evidence_jsonb]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
source:
|
||||
type: string
|
||||
enum: [baseline.compare]
|
||||
severity:
|
||||
type: string
|
||||
enum: [low, medium, high]
|
||||
evidence_jsonb:
|
||||
type: object
|
||||
properties:
|
||||
change_type:
|
||||
type: string
|
||||
enum: [modified, missing, unexpected]
|
||||
summary:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [intune_rbac_role_definition]
|
||||
baseline:
|
||||
type: object
|
||||
current:
|
||||
type: object
|
||||
183
specs/128-rbac-baseline-compare/data-model.md
Normal file
183
specs/128-rbac-baseline-compare/data-model.md
Normal file
@ -0,0 +1,183 @@
|
||||
# Data Model — Intune RBAC Baseline Compare & Findings v1
|
||||
|
||||
## Entities
|
||||
|
||||
### Foundation Type Baseline Metadata
|
||||
Config-defined metadata controlling whether a foundation type can participate in baseline compare.
|
||||
|
||||
- Source: `config/tenantpilot.php` foundation rows, exposed through `InventoryPolicyTypeMeta`
|
||||
- Required additions:
|
||||
- explicit baseline-compare support flag
|
||||
- optional compare identity strategy marker
|
||||
- optional summary kind or compare label metadata if needed for consistent rendering
|
||||
- Required behavior:
|
||||
- `intuneRoleDefinition` is baseline-supported
|
||||
- `intuneRoleAssignment` is baseline-unsupported
|
||||
|
||||
### Baseline Scope Entry
|
||||
The workspace-owned selection stored in `BaselineProfile.scope_jsonb`.
|
||||
|
||||
- Existing structure:
|
||||
- `policy_types[]`
|
||||
- `foundation_types[]`
|
||||
- New business rule:
|
||||
- `foundation_types[]` may contain `intuneRoleDefinition`
|
||||
- `foundation_types[]` must not contain `intuneRoleAssignment` through normal selection paths
|
||||
- Validation:
|
||||
- selected foundation types must exist in canonical metadata
|
||||
- only baseline-supported foundation types are accepted
|
||||
|
||||
### InventoryItem for Intune Role Definition
|
||||
Tenant-owned latest-observed RBAC state used as the current compare anchor.
|
||||
|
||||
- Existing model/table: `InventoryItem`
|
||||
- Ownership:
|
||||
- `workspace_id` NOT NULL
|
||||
- `tenant_id` NOT NULL
|
||||
- Identity:
|
||||
- `tenant_id + policy_type + external_id`
|
||||
- for this feature, `external_id` is the primary compare identity
|
||||
- Relevant fields:
|
||||
- `policy_type = intuneRoleDefinition`
|
||||
- `external_id`
|
||||
- `display_name`
|
||||
- `category = RBAC`
|
||||
- `platform = all`
|
||||
- `meta_jsonb.is_built_in`
|
||||
- `meta_jsonb.role_permission_count`
|
||||
- `last_seen_at`
|
||||
- `last_seen_operation_run_id`
|
||||
|
||||
### PolicyVersion for Intune Role Definition
|
||||
Immutable RBAC snapshot evidence reused for baseline references and diff rendering.
|
||||
|
||||
- Existing model/table: `PolicyVersion`
|
||||
- Relevant fields:
|
||||
- `tenant_id`
|
||||
- `policy_id`
|
||||
- `policy_type = intuneRoleDefinition`
|
||||
- `snapshot` with Role Definition payload
|
||||
- `captured_at`
|
||||
- `version_number`
|
||||
- Invariant:
|
||||
- enough data exists to normalize display name, description, built-in/custom state, and permissions without live Graph calls
|
||||
|
||||
### Baseline Snapshot Item for Intune Role Definition
|
||||
Workspace-owned approved baseline reference used during compare.
|
||||
|
||||
- Existing model/table: `BaselineSnapshotItem`
|
||||
- Relevant fields:
|
||||
- `baseline_snapshot_id`
|
||||
- `subject_type = policy` or a role-definition-specific variant if introduced narrowly
|
||||
- `subject_external_id` as workspace-safe reference
|
||||
- `subject_key` upgraded to support stable Role Definition identity semantics
|
||||
- `policy_type = intuneRoleDefinition`
|
||||
- `baseline_hash`
|
||||
- `meta_jsonb.display_name`
|
||||
- `meta_jsonb.evidence.*`
|
||||
- `meta_jsonb.identity.external_id` or equivalent explicit identity marker
|
||||
- `meta_jsonb.version_reference` or equivalent baseline PolicyVersion linkage
|
||||
- Validation:
|
||||
- baseline item must keep enough metadata to reconstruct evidence later
|
||||
- baseline snapshot item must not store tenant identifiers directly
|
||||
|
||||
### RBAC Role Definition Compare Result
|
||||
Tenant-scoped transient compare outcome created during baseline compare.
|
||||
|
||||
- Computed attributes:
|
||||
- `policy_type = intuneRoleDefinition`
|
||||
- `role_definition_id`
|
||||
- `classification = unchanged | modified | missing | unexpected`
|
||||
- `severity = low | medium | high`
|
||||
- `built_in_state`
|
||||
- `diff_kind = metadata_only | permission_change | missing | unexpected`
|
||||
- `baseline_hash`
|
||||
- `current_hash`
|
||||
- `baseline_policy_version_id` nullable
|
||||
- `current_policy_version_id` nullable
|
||||
|
||||
### RBAC Drift Finding
|
||||
Persistent tenant-owned finding generated through the existing baseline.compare pipeline.
|
||||
|
||||
- Existing model/table: `Finding`
|
||||
- Relevant fields:
|
||||
- `tenant_id`
|
||||
- `finding_type = drift`
|
||||
- `source = baseline.compare`
|
||||
- `scope_key = baseline_profile:{id}`
|
||||
- `fingerprint`
|
||||
- `recurrence_key`
|
||||
- `subject_type`
|
||||
- `subject_external_id`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `times_seen`
|
||||
- `evidence_jsonb`
|
||||
- `current_operation_run_id`
|
||||
- Invariant:
|
||||
- unchanged identical drift does not create duplicate findings
|
||||
- resolved recurrence reopens through the existing lifecycle rules
|
||||
|
||||
### Baseline Compare RBAC Summary
|
||||
Run-level summary stored in compare run context.
|
||||
|
||||
- Existing container: `OperationRun.context.baseline_compare`
|
||||
- New summary node:
|
||||
- `rbac_role_definitions.total_compared`
|
||||
- `rbac_role_definitions.unchanged`
|
||||
- `rbac_role_definitions.modified`
|
||||
- `rbac_role_definitions.missing`
|
||||
- `rbac_role_definitions.unexpected`
|
||||
- Constraint:
|
||||
- keep rich counts in `context`; do not add non-canonical summary keys to `summary_counts`
|
||||
|
||||
## Relationships
|
||||
|
||||
- A `BaselineProfile` belongs to one workspace and has one active scope definition.
|
||||
- A `BaselineProfile` has many `BaselineSnapshot` records.
|
||||
- A `BaselineSnapshot` has many `BaselineSnapshotItem` records, including `intuneRoleDefinition` items when selected.
|
||||
- A `Tenant` has many `InventoryItem` rows and many `PolicyVersion` rows for `intuneRoleDefinition`.
|
||||
- A `Tenant` has many baseline compare `OperationRun` rows and many `Finding` rows.
|
||||
- A Role Definition compare result links one baseline snapshot item to zero or one current inventory row and zero or one current `PolicyVersion`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `intuneRoleDefinition` is the only RBAC foundation type eligible for baseline compare in this release.
|
||||
- `intuneRoleAssignment` must never appear in baseline capture, compare summaries, or findings.
|
||||
- Role Definition identity is ID-based; same-name recreated objects with new IDs are drift.
|
||||
- Compare uses normalized governance-relevant content, not raw transport payload shape.
|
||||
- Metadata-only changes stay distinguishable from permission changes.
|
||||
- Baseline evidence remains reconstructable without UI-time provider calls.
|
||||
- Workspace-owned baseline artifacts must not persist tenant IDs.
|
||||
- Tenant-owned compare runs and findings must remain workspace- and tenant-scoped.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Baseline support state
|
||||
- unsupported
|
||||
- supported and selectable in baseline profile
|
||||
|
||||
### Role Definition compare classification
|
||||
- unchanged
|
||||
- modified
|
||||
- missing
|
||||
- unexpected
|
||||
|
||||
### RBAC finding lifecycle
|
||||
- new
|
||||
- reopened
|
||||
- resolved
|
||||
- closed
|
||||
|
||||
### Compare trust state
|
||||
- full coverage proven
|
||||
- partial coverage proven with suppression
|
||||
- coverage unproven and findings suppressed
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Baseline profile foundation selections must be a subset of explicitly baseline-supported foundation types.
|
||||
- Baseline snapshot items for `intuneRoleDefinition` must carry stable identity and evidence-ready references.
|
||||
- Role Definition compare must ignore transport-only noise and ordering differences in permission blocks.
|
||||
- Severity mapping must follow the approved RBAC rule set.
|
||||
- Assignment foundation type must fail closed for baseline-compare selection and result generation.
|
||||
252
specs/128-rbac-baseline-compare/plan.md
Normal file
252
specs/128-rbac-baseline-compare/plan.md
Normal file
@ -0,0 +1,252 @@
|
||||
# Implementation Plan: Intune RBAC Baseline Compare & Findings v1
|
||||
|
||||
**Branch**: `feat/128-rbac-baseline-compare` | **Date**: 2026-03-09 | **Spec**: `specs/128-rbac-baseline-compare/spec.md`
|
||||
**Input**: Feature specification from `specs/128-rbac-baseline-compare/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the existing baseline capture and compare engine to support one additional foundation type, `intuneRoleDefinition`, by introducing explicit baseline-support metadata, capturing Role Definition baseline references from the existing RBAC version history, and adding an ID-based normalized compare path that emits unified baseline.compare findings with RBAC-specific severity, evidence, and summaries. Keep `intuneRoleAssignment` explicitly excluded from baseline scope, compare, and findings.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL via Laravel Sail
|
||||
**Testing**: Pest v4 on PHPUnit 12
|
||||
**Target Platform**: Dockerized Laravel web application (Sail)
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: Baseline capture and compare stay chunked and DB-first, avoid UI-time Graph calls, and remain bounded by covered in-scope subjects
|
||||
**Constraints**: Existing Ops-UX `OperationRun` contract, numeric-only `summary_counts`, deny-as-not-found tenant/workspace isolation, no RBAC write path, and deterministic compare/finding identity
|
||||
**Scale/Scope**: Workspace-owned baseline profiles and snapshots plus tenant-scoped compare runs and findings across potentially large in-scope baseline subject sets
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — current state remains `InventoryItem` plus existing RBAC `PolicyVersion` evidence from Spec 127; baseline snapshots stay immutable.
|
||||
- Read/write separation: PASS — this release is read-only governance analysis; no restore or Graph write behavior is added.
|
||||
- Graph contract path: PASS — existing `intuneRoleDefinition` and `intuneRoleAssignment` contracts already exist in `config/graph_contracts.php`; compare and UI rendering remain DB-only at runtime.
|
||||
- Deterministic capabilities: PASS — scope eligibility can be derived from config metadata and helper filters with tests; no raw capability strings are needed.
|
||||
- RBAC-UX planes and isolation: PASS — workspace baseline management stays in the tenant/admin plane, compare and findings stay tenant-context, and cross-plane behavior is unchanged.
|
||||
- Workspace isolation: PASS — baseline profiles and snapshots remain workspace-owned; workspace membership enforcement remains unchanged.
|
||||
- Tenant isolation: PASS — compare runs, current inventory, and findings remain tenant-owned and deny-as-not-found for non-members.
|
||||
- Destructive confirmation: PASS — no new destructive RBAC action is introduced; existing baseline archive/capture surfaces keep their current confirmation semantics.
|
||||
- Global search: PASS — no new searchable resource is added, so the existing global-search rules are unaffected.
|
||||
- Run observability: PASS — baseline capture and compare already use `OperationRun` and queued jobs; this release only extends their scope and result payloads.
|
||||
- Ops-UX 3-surface feedback: PASS — baseline start surfaces already use queued-only toasts and canonical run links.
|
||||
- Ops-UX lifecycle: PASS — `OperationRun` transitions remain service-owned through `OperationRunService`.
|
||||
- Ops-UX summary counts: PASS — RBAC-specific counts can live in `context.baseline_compare` while `summary_counts` stay numeric-only.
|
||||
- Ops-UX guards: PASS — existing baseline compare guard tests can be extended for the new foundation type.
|
||||
- Ops-UX system runs: PASS — unchanged; initiator-null behavior remains handled by the existing operation framework.
|
||||
- Data minimization: PASS — inventory remains metadata-only; baseline evidence reuses existing version references and normalized snapshots without introducing secret-bearing payloads.
|
||||
- Badge semantics: PASS — severity and label additions can use the centralized badge and renderer system.
|
||||
- Filament Action Surface Contract: PASS — only existing Baseline Profile, Baseline Snapshot, Baseline Compare, and Findings surfaces are extended; no new resource or unreviewed action surface is introduced.
|
||||
- Filament UX-001: PASS — the feature only adjusts scope options, summaries, and evidence blocks inside established sectioned forms and detail screens.
|
||||
- Filament v5 / Livewire v4 compliance: PASS — no version or API changes are introduced.
|
||||
- Provider registration (`bootstrap/providers.php`): PASS — no new providers or panels are introduced.
|
||||
- Global search resource rule: PASS — no new globally searchable resource is added.
|
||||
- Asset strategy: PASS — no new panel or shared assets are needed; `filament:assets` deployment behavior is unchanged.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/128-rbac-baseline-compare/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── BaselineCompareLanding.php
|
||||
│ └── Resources/
|
||||
│ ├── BaselineProfileResource.php
|
||||
│ └── BaselineSnapshotResource.php
|
||||
├── Jobs/
|
||||
│ ├── CaptureBaselineSnapshotJob.php
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Models/
|
||||
│ ├── BaselineProfile.php
|
||||
│ ├── BaselineSnapshot.php
|
||||
│ ├── BaselineSnapshotItem.php
|
||||
│ ├── Finding.php
|
||||
│ ├── InventoryItem.php
|
||||
│ └── PolicyVersion.php
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCaptureService.php
|
||||
│ │ ├── BaselineCompareService.php
|
||||
│ │ ├── BaselineAutoCloseService.php
|
||||
│ │ ├── BaselineSnapshotIdentity.php
|
||||
│ │ └── Evidence/
|
||||
│ ├── Intune/
|
||||
│ │ ├── IntuneRoleDefinitionNormalizer.php
|
||||
│ │ └── PolicyNormalizer.php
|
||||
│ └── Inventory/
|
||||
│ └── InventorySyncService.php
|
||||
├── Support/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCompareReasonCode.php
|
||||
│ │ ├── BaselineScope.php
|
||||
│ │ └── BaselineSubjectKey.php
|
||||
│ ├── Badges/
|
||||
│ └── Inventory/
|
||||
│ └── InventoryPolicyTypeMeta.php
|
||||
config/
|
||||
├── graph_contracts.php
|
||||
└── tenantpilot.php
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Baselines/
|
||||
│ ├── Findings/
|
||||
│ ├── Filament/
|
||||
│ └── Inventory/
|
||||
└── Unit/
|
||||
└── IntuneRoleDefinitionNormalizerTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep all work in the existing Laravel application. The feature is a targeted extension of current baseline capture/compare services, baseline Filament surfaces, config-driven type metadata, and existing baseline/findings tests.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature.
|
||||
|
||||
## Phase 0 — Outline & Research (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/128-rbac-baseline-compare/research.md`
|
||||
|
||||
Key findings captured:
|
||||
- Baseline scope already supports `foundation_types`, but the current UI options list every configured foundation type without an explicit baseline-support gate.
|
||||
- Baseline capture already queries `scope->allTypes()` and can include foundations, but current subject identity is display-name-derived via `BaselineSubjectKey`, which is not acceptable for Role Definition compare.
|
||||
- Compare and finding upsert infrastructure already supports unified lifecycle, evidence provenance, coverage guards, and recurrence logic; this feature should plug into those seams rather than inventing a separate RBAC compare engine.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/128-rbac-baseline-compare/data-model.md`
|
||||
- `specs/128-rbac-baseline-compare/contracts/openapi.yaml`
|
||||
- `specs/128-rbac-baseline-compare/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Add explicit baseline-compare metadata on foundation types so `intuneRoleDefinition` is opt-in supported and `intuneRoleAssignment` remains explicitly unsupported.
|
||||
- Extend baseline capture and compare with a Role Definition ID identity strategy while leaving the existing display-name subject-key flow in place for previously supported policy types.
|
||||
- Reuse `IntuneRoleDefinitionNormalizer::flattenForDiff()` for normalized governance diffs and feed the result into the existing baseline evidence and finding contracts.
|
||||
|
||||
## Phase 1 — Agent Context Update (DONE)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
### Step 1 — Add explicit baseline-support metadata for foundation types
|
||||
|
||||
Goal: implement FR-128-01 through FR-128-04.
|
||||
|
||||
Changes:
|
||||
- Extend `config/tenantpilot.php` foundation-type rows with explicit baseline-compare support metadata instead of implicitly treating every foundation as baseline-eligible.
|
||||
- Mark `intuneRoleDefinition` as supported for baseline compare and `intuneRoleAssignment` as unsupported.
|
||||
- Add helper accessors in `App\Support\Inventory\InventoryPolicyTypeMeta` for baseline-supported foundations so scope selection, summaries, and tests read from one canonical source.
|
||||
- Update `BaselineProfileResource::foundationTypeOptions()` and related infolist formatting to show only eligible foundation types while making the RBAC Role Definition label explicit.
|
||||
|
||||
Tests:
|
||||
- Add or update focused tests proving `intuneRoleDefinition` is baseline-supported and `intuneRoleAssignment` is excluded.
|
||||
- Add or update baseline profile selection tests proving Role Definitions can be chosen and Assignments cannot leak into the saved scope.
|
||||
|
||||
### Step 2 — Introduce Role Definition ID identity for baseline capture and compare
|
||||
|
||||
Goal: implement FR-128-05 through FR-128-10.
|
||||
|
||||
Changes:
|
||||
- Extend the baseline subject identity model so `intuneRoleDefinition` can use stable external ID matching instead of the current display-name-derived `BaselineSubjectKey` flow.
|
||||
- Update `CaptureBaselineSnapshotJob::collectInventorySubjects()` and `buildSnapshotItems()` to preserve the tenant Role Definition ID, a workspace-safe external reference, and an explicit identity marker for Role Definition baseline items.
|
||||
- Update compare-side loaders in `CompareBaselineToTenantJob` so Role Definitions are loaded and matched by Role Definition ID while existing policy types keep their current behavior.
|
||||
- Ensure delete-and-recreate with a new ID resolves to `missing` + `unexpected`, not a silent rename match.
|
||||
|
||||
Tests:
|
||||
- Add capture tests proving baseline snapshot items for `intuneRoleDefinition` keep evidence-ready references and exclude `intuneRoleAssignment`.
|
||||
- Add compare tests proving same-name/different-ID Role Definitions do not match and instead produce missing/unexpected outcomes.
|
||||
|
||||
### Step 3 — Add normalized RBAC Role Definition diffing and classification
|
||||
|
||||
Goal: implement FR-128-11 through FR-128-19.
|
||||
|
||||
Changes:
|
||||
- Introduce a narrow Role Definition compare helper that uses `IntuneRoleDefinitionNormalizer::flattenForDiff()` as the governance-normalized diff surface.
|
||||
- Define classification logic for unchanged, modified, missing, and unexpected Role Definitions.
|
||||
- Split modified Role Definition diffs into metadata-only versus permission-impacting changes so severity can map to Low versus High.
|
||||
- Reuse existing coverage-guard and evidence-gap handling so provider or permission issues suppress false findings instead of inventing RBAC-only failure semantics.
|
||||
|
||||
Tests:
|
||||
- Add normalized diff tests that prove ordering noise in permission blocks is ignored.
|
||||
- Add compare classification tests for unchanged, modified, missing, and unexpected Role Definitions.
|
||||
- Add severity tests proving permission changes are High, missing is High, unexpected is Medium, and metadata-only is Low.
|
||||
|
||||
### Step 4 — Extend finding evidence, fingerprints, and run summaries for RBAC
|
||||
|
||||
Goal: implement FR-128-18 through FR-128-26.
|
||||
|
||||
Changes:
|
||||
- Implement the `intuneRoleDefinition` finding fingerprint composition explicitly in the compare job so the stable fingerprint includes baseline profile scope, Role Definition identity, change kind, and normalized diff fingerprint inputs.
|
||||
- Reuse baseline compare finding upsert and recurrence behavior, keeping fingerprints recurrence-stable and profile-scoped while adding `intuneRoleDefinition`-specific diff fingerprints as evidence inputs.
|
||||
- Extend the evidence contract builder to emit an RBAC-specific `summary.kind`, readable before/after normalized evidence, baseline and current version references, and built-in/custom visibility.
|
||||
- Extend compare-run context with an RBAC Role Definition summary bucket: total compared, unchanged, modified, missing, and unexpected.
|
||||
- Update label and presentation helpers so findings and run detail surfaces identify these records as Intune RBAC Role Definition drift, not generic policy drift.
|
||||
|
||||
Tests:
|
||||
- Add or update evidence contract tests for modified, missing, and unexpected RBAC Role Definition findings.
|
||||
- Add fingerprint/idempotency tests for repeated identical compare runs and recurrence tests for resolved-then-reappearing RBAC drift.
|
||||
- Add summary serialization tests for the RBAC run-level counts.
|
||||
|
||||
### Step 5 — Extend existing Filament surfaces without introducing RBAC restore semantics
|
||||
|
||||
Goal: implement FR-128-24 through FR-128-29 and the UX-001 layout and UI Action Matrix constraints already defined in the spec and constitution.
|
||||
|
||||
Changes:
|
||||
- Update existing baseline profile, baseline snapshot, compare landing or run detail, and findings detail surfaces to surface Role Definition scope, summary counts, RBAC-specific wording, and readable evidence blocks.
|
||||
- Keep action surfaces unchanged except for the new scope option and evidence presentation; no new destructive or restore actions are introduced.
|
||||
- Use existing badge and tag renderers for any new severity or compare-state display values.
|
||||
- Ensure no screen text implies Role Assignment coverage or executable RBAC restore.
|
||||
|
||||
Tests:
|
||||
- Add or update Filament tests asserting the baseline profile scope picker shows Intune Role Definition and not Intune Role Assignment.
|
||||
- Add or update UI tests for compare landing and finding detail labels so RBAC findings are clearly labeled, show readable evidence, and do not imply restore support.
|
||||
|
||||
### Step 6 — Preserve safe degradation, auditability, and isolation semantics
|
||||
|
||||
Goal: implement FR-128-28 through FR-128-30 and the failure-path test requirements.
|
||||
|
||||
Changes:
|
||||
- Reuse compare coverage and evidence-gap reason codes for RBAC so unavailable current-state data results in warning or partial-success outcomes instead of false drift.
|
||||
- Ensure RBAC compare audit events and `OperationRun.context` capture effective scope, RBAC compare counts, and suppression reasons without adding non-canonical `summary_counts` keys.
|
||||
- Confirm workspace and tenant scoping on compare queries, finding upserts, and UI read paths.
|
||||
|
||||
Tests:
|
||||
- Add isolation coverage ensuring one tenant’s Role Definition baseline items and findings cannot match another tenant’s current state.
|
||||
- Add failure-path tests proving provider or permission gaps emit zero false RBAC findings.
|
||||
- Keep existing baseline compare coverage-guard, run-authorization, and stale auto-close tests passing.
|
||||
|
||||
## Post-design Constitution Re-check
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance: unchanged and preserved because no new Filament panel or Livewire version changes are introduced.
|
||||
- Provider registration location: unchanged; no new providers or panel registration changes outside `bootstrap/providers.php`.
|
||||
- Globally searchable resources: unchanged; no new Resource is added, so no new Edit/View global-search requirement applies.
|
||||
- Destructive actions: unchanged; existing baseline archive actions remain confirmed and no new destructive RBAC action is added.
|
||||
- Asset strategy: unchanged; no new assets are introduced, so the existing deploy-time `php artisan filament:assets` behavior remains sufficient.
|
||||
- Testing plan: extend focused Pest coverage for baseline eligibility, scope selection, capture references, compare classification, evidence, severity, fingerprinting, assignment exclusion, isolation, failure paths, and unchanged baseline behavior for existing supported types.
|
||||
|
||||
56
specs/128-rbac-baseline-compare/quickstart.md
Normal file
56
specs/128-rbac-baseline-compare/quickstart.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Quickstart — Intune RBAC Baseline Compare & Findings v1
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that Intune Role Definitions can be selected into a baseline, captured as approved references, compared against current tenant state, and surfaced as unified drift findings without pulling in Role Assignments.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Laravel Sail services are running.
|
||||
- A workspace and tenant test fixture exist.
|
||||
- Spec 127 RBAC inventory and versioning support is already present.
|
||||
|
||||
## Development flow
|
||||
|
||||
1. Start Sail if needed.
|
||||
- `vendor/bin/sail up -d`
|
||||
2. Implement explicit baseline-support metadata for foundation types and filter baseline scope options to eligible foundations only.
|
||||
3. Extend baseline capture and compare identity handling so `intuneRoleDefinition` matches by Role Definition ID, not display name.
|
||||
4. Plug normalized Role Definition diffs into the existing baseline compare finding and evidence pipeline.
|
||||
5. Update existing Filament baseline and findings surfaces with RBAC-specific labeling and summaries.
|
||||
|
||||
## Focused verification
|
||||
|
||||
1. Eligibility and scope tests
|
||||
- confirm `intuneRoleDefinition` is selectable for baseline compare
|
||||
- confirm `intuneRoleAssignment` is not selectable
|
||||
2. Capture tests
|
||||
- confirm baseline snapshot items for Role Definitions keep evidence-ready references
|
||||
- confirm Role Assignments do not enter baseline snapshots
|
||||
3. Compare tests
|
||||
- unchanged Role Definition yields no finding
|
||||
- permission change yields a High-severity modified finding
|
||||
- metadata-only change yields a Low-severity modified finding
|
||||
- missing and unexpected Role Definitions yield the correct findings
|
||||
4. Findings tests
|
||||
- repeated identical compare runs do not duplicate findings
|
||||
- resolved RBAC findings reopen correctly on recurrence
|
||||
5. Safety tests
|
||||
- coverage or evidence gaps suppress false RBAC findings
|
||||
- tenant/workspace isolation remains intact
|
||||
|
||||
## Suggested test commands
|
||||
|
||||
- `vendor/bin/sail artisan test --compact tests/Unit/IntuneRoleDefinitionNormalizerTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Baselines`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
||||
- `vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Manual QA checklist
|
||||
|
||||
1. Open Baseline Profiles and verify only Intune Role Definition appears as an RBAC foundation compare option.
|
||||
2. Capture a baseline from a tenant with Intune RBAC inventory and verify the snapshot shows Role Definition references only.
|
||||
3. Run compare against a tenant with an intentionally changed custom Role Definition and verify RBAC summary counts and finding labels.
|
||||
4. Inspect finding detail and verify before/after evidence is readable, severity is correct, and no restore implication appears.
|
||||
5. Verify Role Assignment objects never appear in summary, findings, or baseline snapshot compare output.
|
||||
57
specs/128-rbac-baseline-compare/research.md
Normal file
57
specs/128-rbac-baseline-compare/research.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Research — Intune RBAC Baseline Compare & Findings v1
|
||||
|
||||
## Decision: Add explicit baseline-compare support metadata on foundation types
|
||||
|
||||
**Rationale**: `BaselineProfileResource::foundationTypeOptions()` currently lists all foundation types from `InventoryPolicyTypeMeta::foundations()`, which would make every configured foundation implicitly selectable for baseline compare. The spec requires intentional, type-level baseline eligibility metadata so only `intuneRoleDefinition` becomes eligible and `intuneRoleAssignment` stays excluded.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Filter foundation options with hard-coded type checks in Filament resource code: rejected because it hides eligibility rules in UI code and does not create a reusable source of truth.
|
||||
- Treat every foundation as baseline-eligible by default: rejected because the spec explicitly disallows broad foundation rollout.
|
||||
|
||||
## Decision: Reuse the existing RBAC Graph contracts and inventory/version evidence from Spec 127
|
||||
|
||||
**Rationale**: `config/graph_contracts.php` already contains inventory-grade contracts for `intuneRoleDefinition` and `intuneRoleAssignment`, and Spec 127 already established tenant-scoped inventory plus immutable `PolicyVersion` evidence for Role Definitions. The compare feature can therefore stay DB-first and does not need new live Graph calls for baseline rendering or run detail.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add new RBAC-specific Graph contracts for compare: rejected because current contracts already include the fields needed for normalized Role Definition diffs.
|
||||
- Re-hydrate live Graph payloads during compare: rejected because it would violate the existing DB-first compare and monitoring model and introduce misleading drift when live state is unavailable.
|
||||
|
||||
## Decision: Use Role Definition ID as the compare identity for this foundation type
|
||||
|
||||
**Rationale**: Current baseline capture and compare use display-name-derived `BaselineSubjectKey` matching. That is adequate for some policy surfaces but does not meet the spec requirement for Role Definition identity, where a delete-and-recreate with a new ID is meaningful drift. The capture and compare pipeline needs a targeted identity-model extension for `intuneRoleDefinition` so baseline items and current inventory match by stable external ID.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep display-name matching and treat renamed duplicates as ambiguous: rejected because it would hide recreated roles and violate the spec’s identity model.
|
||||
- Match by a composite of display name plus built-in/custom state: rejected because the object identity still changes when a role is recreated.
|
||||
|
||||
## Decision: Reuse `IntuneRoleDefinitionNormalizer::flattenForDiff()` as the normalized compare surface
|
||||
|
||||
**Rationale**: The existing normalizer already produces deterministic, order-insensitive permission-block output and readable summary blocks for Role Definitions. Reusing that normalization path keeps diff semantics aligned with the inventory and version-display model from Spec 127 and avoids a second RBAC diff representation.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hash raw snapshot payloads directly: rejected because transport noise and ordering differences would create unstable findings.
|
||||
- Build a separate RBAC-only diff normalizer: rejected because the existing normalizer already captures the governance-relevant fields required by the spec.
|
||||
|
||||
## Decision: Keep unified baseline.compare finding lifecycle and recurrence behavior
|
||||
|
||||
**Rationale**: `CompareBaselineToTenantJob::upsertFindings()` already provides profile-scoped recurrence keys, idempotent `times_seen` updates, reopen behavior, and baseline auto-close support. The RBAC feature should plug into that same lifecycle so findings behave consistently with the rest of the drift engine.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce an RBAC-specific finding table or source lifecycle: rejected because it would duplicate the compare engine and break existing findings surfaces.
|
||||
- Make recurrence snapshot-scoped for RBAC only: rejected because the current implementation and tests are profile-scoped, and the new spec only requires baseline profile participation in the fingerprint inputs.
|
||||
|
||||
## Decision: Extend the existing evidence contract instead of inventing an RBAC-only UI model
|
||||
|
||||
**Rationale**: Existing baseline compare evidence already carries summary kind, provenance, baseline and current version references, and diff-compatible structures consumed by findings and run-detail surfaces. RBAC compare should extend those contracts with an RBAC-specific summary kind and before/after normalized evidence so existing UI surfaces can render the new findings without a dedicated page.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Render RBAC changes from ad-hoc JSON blobs in the UI: rejected because it would bypass the unified finding evidence contract and create fragile presentation logic.
|
||||
- Create a new RBAC findings screen: rejected because the spec explicitly requires compatibility with existing baseline, drift, and findings surfaces.
|
||||
|
||||
## Decision: Reuse existing coverage-guard and partial-success semantics for RBAC safe degradation
|
||||
|
||||
**Rationale**: `CompareBaselineToTenantJob` already suppresses findings for uncovered types and records partial-success outcomes with coverage reason codes. RBAC compare should use the same mechanism so missing provider or permission coverage produces a clear run/report warning without emitting false drift.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Emit missing findings when current RBAC data is unavailable: rejected because it would create false drift.
|
||||
- Fail the entire compare run hard on any RBAC coverage gap: rejected because the current engine already supports safer partial-success semantics with explicit suppression reasons.
|
||||
183
specs/128-rbac-baseline-compare/spec.md
Normal file
183
specs/128-rbac-baseline-compare/spec.md
Normal file
@ -0,0 +1,183 @@
|
||||
# Feature Specification: Intune RBAC Baseline Compare & Findings v1
|
||||
|
||||
**Feature Branch**: `feat/128-rbac-baseline-compare`
|
||||
**Created**: 2026-03-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 128 — Intune RBAC Baseline Compare & Findings v1: Add baseline compare and drift findings for Intune RBAC Role Definitions"
|
||||
|
||||
## Spec Scope Fields
|
||||
|
||||
- **Scope**: workspace (baseline profile definition + baseline capture) + tenant (baseline compare monitoring and drift findings)
|
||||
- **Primary Routes**:
|
||||
- Workspace admin: Baseline Profiles create/edit scope, capture baseline, baseline snapshot detail
|
||||
- Tenant-context admin: Baseline Compare start/detail, Drift Findings landing/detail, existing baseline result surfaces
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: baseline profiles and baseline snapshots, including captured Intune Role Definition version references and evidence-ready baseline metadata
|
||||
- Tenant-scoped within a workspace: compare operation runs, compare summaries, and drift findings
|
||||
- Existing tenant-owned RBAC inventory and immutable version history remain the current-state evidence source for compare; baseline snapshot items must not persist tenant identifiers
|
||||
- **RBAC**:
|
||||
- Workspace: `workspace_baselines.view` to inspect profiles and snapshots; `workspace_baselines.manage` to edit scope and start baseline capture
|
||||
- Tenant: `tenant.sync` to start compare runs; `tenant_findings.view` to inspect drift findings and compare result detail
|
||||
- Non-members or users outside the active workspace or tenant scope must receive 404 deny-as-not-found behavior; in-scope members missing capability must receive 403
|
||||
|
||||
For canonical-view specs: not applicable. This feature extends workspace and tenant surfaces only.
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Capture an approved RBAC baseline (Priority: P1)
|
||||
|
||||
As a workspace admin, I want to include Intune Role Definitions in a baseline profile and capture them as an approved standard so the tenant's role model can be compared against a known-good baseline later.
|
||||
|
||||
**Why this priority**: Baseline compare has no value until Role Definitions can be deliberately selected and captured as a workspace-owned standard.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating or editing a baseline profile to include Intune Role Definitions, running baseline capture, and verifying that the resulting baseline snapshot stores Role Definition references and excludes Role Assignments.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline profile whose scope includes Intune Role Definitions, **When** a workspace admin captures a baseline, **Then** the baseline snapshot stores Role Definition version references and evidence-ready metadata for the approved set.
|
||||
2. **Given** a tenant that also has Intune Role Assignments, **When** the same baseline capture runs, **Then** Role Assignments are not added to the baseline snapshot or compare scope.
|
||||
3. **Given** a baseline profile that does not include Intune Role Definitions, **When** a capture runs, **Then** RBAC Role Definition references are absent from that baseline snapshot.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Detect RBAC drift deterministically (Priority: P1)
|
||||
|
||||
As a tenant admin, I want baseline compare to classify Intune Role Definitions as unchanged, modified, missing, or unexpected so I can detect governance drift in the tenant's permission model.
|
||||
|
||||
**Why this priority**: Deterministic compare output is the sellable product outcome for this release and the core reason to extend RBAC beyond inventory/history.
|
||||
|
||||
**Independent Test**: Can be fully tested by comparing a tenant with known Role Definition changes against a captured baseline and verifying classification counts, severity, evidence references, and assignment exclusion.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a captured baseline and matching current tenant Role Definitions, **When** a compare run completes successfully, **Then** the RBAC compare summary reports the items as unchanged and produces no RBAC drift findings.
|
||||
2. **Given** a baseline Role Definition whose normalized permissions differ from current state, **When** compare runs, **Then** the role is classified as modified and a high-severity RBAC finding is generated.
|
||||
3. **Given** a baseline Role Definition that no longer exists in current state, **When** compare runs with trusted current-state coverage, **Then** the role is classified as missing and a high-severity RBAC finding is generated.
|
||||
4. **Given** a current Role Definition that does not exist in the baseline, **When** compare runs, **Then** the role is classified as unexpected and an RBAC finding is generated with deterministic evidence.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Review actionable RBAC findings without assignment noise (Priority: P2)
|
||||
|
||||
As a security reviewer, I want RBAC drift to appear in the existing findings and baseline result surfaces with readable evidence and clear labels so I can act on governance changes without manual snapshot diffing or confusion about unsupported assignment drift.
|
||||
|
||||
**Why this priority**: Findings only become operationally useful when they are understandable, correctly labeled, and visibly limited to Role Definitions in this v1.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening RBAC drift findings in the existing findings and run-detail surfaces and verifying labels, before-versus-after evidence, severity, and the absence of assignment compare output or restore affordances.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a modified Intune Role Definition finding, **When** a reviewer opens the finding detail or related compare surface, **Then** the UI clearly labels it as Intune RBAC Role Definition drift and shows readable before-versus-after evidence.
|
||||
2. **Given** a metadata-only change to a Role Definition, **When** compare produces a finding, **Then** the finding is clearly readable and visibly lower severity than permission changes.
|
||||
3. **Given** an RBAC compare run, **When** a reviewer inspects the summary and findings, **Then** no Role Assignment objects appear and no UI element implies executable RBAC restore support.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A Role Definition deleted and recreated with the same name but a new ID must be treated as meaningful drift: the baseline role becomes missing and the new current role becomes unexpected.
|
||||
- A metadata-only change such as display name or description must remain visible, but it must not be escalated to the same severity as a permission-set change.
|
||||
- Built-in versus custom state must remain visible in compare evidence, including the rare case where an unexpected built-in role appears.
|
||||
- If current-state RBAC data is unavailable or untrusted because provider access, API results, or coverage proof is missing, compare must end with a clear warning or failure state and emit no misleading RBAC drift findings.
|
||||
- Re-running the same compare against unchanged state must not create duplicate findings or unstable fingerprints.
|
||||
- Role Assignments must stay absent from baseline selection, capture output, compare summaries, evidence, and findings throughout this release.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Constitution alignment (required):** This feature extends the existing baseline capture and compare architecture for a new foundation type while remaining read-only from an RBAC governance perspective. It reuses workspace-owned baseline profiles and snapshots, tenant-scoped compare runs, and the unified findings lifecycle. No RBAC write or restore behavior is introduced. Tenant isolation remains mandatory. Existing RBAC inventory and immutable version history from Spec 127 provide the evidence source; compare must not depend on transient UI-time provider calls to render historical evidence.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Baseline capture and compare already use `OperationRun`, and this feature must remain compliant with the three-surface feedback contract: queued-only toast intent, progress only in active-ops and run-detail surfaces, and a single initiator-only terminal DB notification. `OperationRun.status` and `OperationRun.outcome` remain service-owned via `OperationRunService`. Summary counts remain numeric-only and use canonical keys, while richer RBAC compare counts stay in run context. If a run has no initiator, no terminal DB notification is emitted. Regression coverage must be updated for Role Definition scope expansion, compare outcome semantics, and RBAC-specific summary or warning behavior.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects the Tenant/Admin plane and workspace baseline management surfaces only; no Platform-plane behavior changes. Access to baseline profile and snapshot management remains workspace-gated, while compare and findings remain tenant-context gated. Non-members or users outside the active workspace or tenant scope must receive 404 deny-as-not-found responses. In-scope members lacking capability must receive 403. Authorization remains server-side through Gates and Policies backed by the canonical capability registry; no raw capability strings or role-string checks may be introduced. No new global search resource is added, so global search behavior remains unchanged. Existing destructive baseline actions such as archive continue to require confirmation, while this feature introduces no new destructive RBAC action.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond the existing rule. This feature does not add auth-handshake behavior and must not introduce any synchronous outbound work on monitoring or findings pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Any new or changed severity, coverage, support, or drift-status badges for RBAC compare must stay centralized in existing badge or label mappers. RBAC Role Definition severity and labeling rules must not be implemented as ad-hoc UI mappings; regression tests must cover any new badge or label states.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** Existing Filament baseline and findings screens are extended, so the UI Action Matrix below documents the touched surfaces. The Action Surface Contract remains satisfied because this release adds scope options, summaries, labels, and evidence rendering to existing surfaces without adding a new CRUD resource or destructive row action.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** No new dedicated RBAC screen is introduced. Existing baseline profile forms, compare run detail, and finding detail surfaces retain their established layouts. The feature must fit Role Definition scope selection, compare summaries, labels, and evidence into the existing sectioned forms, lists, and detail views without introducing naked inputs, ambiguous empty states, or table regressions.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-128-01 Baseline support declaration**: The system must explicitly mark `intuneRoleDefinition` as baseline-supported in the canonical foundation-type metadata used by baseline profile scope selection.
|
||||
- **FR-128-02 Assignment exclusion declaration**: The system must explicitly keep `intuneRoleAssignment` baseline-unsupported in this release.
|
||||
- **FR-128-03 Baseline profile scope visibility**: Workspace admins must be able to include `intuneRoleDefinition` through the existing baseline profile `foundation_types` scope selection flow without introducing a separate RBAC-only scope field.
|
||||
- **FR-128-04 Scope clarity**: Baseline scope selection must label `intuneRoleDefinition` as supported for baseline compare in this release and must not offer `intuneRoleAssignment` as a selectable baseline scope option.
|
||||
- **FR-128-05 Baseline capture inclusion**: When a baseline profile includes Intune Role Definitions, baseline capture must include approved Role Definition snapshot or version references in the resulting workspace-owned baseline snapshot.
|
||||
- **FR-128-06 Reference fidelity**: Baseline snapshot items for Intune Role Definitions must retain sufficient references and metadata to reconstruct compare evidence later without relying on transient live provider state.
|
||||
- **FR-128-07 Compare participation**: Baseline compare must include Intune Role Definitions whenever the active baseline profile scope includes them.
|
||||
- **FR-128-08 Compare exclusion safety**: Role Assignments must never participate in RBAC baseline capture, compare summaries, evidence payloads, or findings in this release.
|
||||
- **FR-128-09 Match identity**: Compare must match baseline and current Intune Role Definitions by stable tenant-local Role Definition ID.
|
||||
- **FR-128-10 Recreated-role handling**: A Role Definition deleted and recreated with a new ID must be treated as material drift rather than silently matched by name.
|
||||
- **FR-128-11 Normalized compare surface**: Role Definition compare must operate on normalized governance-relevant content rather than raw transport payloads.
|
||||
- **FR-128-12 Minimum compare dimensions**: Normalized compare for Intune Role Definitions must consider display name, description, built-in versus custom state, and normalized permissions content.
|
||||
- **FR-128-13 Ignored noise**: Transport-only or otherwise non-material volatile fields must not generate RBAC drift findings.
|
||||
- **FR-128-14 Compare classification**: For each in-scope Intune Role Definition, compare must classify the result as unchanged, modified, missing, or unexpected.
|
||||
- **FR-128-15 Modified detection**: A Role Definition must be classified as modified when normalized governance-relevant content differs between baseline and current state.
|
||||
- **FR-128-16 Missing detection**: A baseline Role Definition absent from trusted current state must be classified as missing.
|
||||
- **FR-128-17 Unexpected detection**: A current Role Definition absent from the baseline must be classified as unexpected.
|
||||
- **FR-128-18 Finding generation**: Modified, missing, and unexpected Role Definition drift must generate findings through the existing unified findings pipeline using `source = baseline.compare` semantics.
|
||||
- **FR-128-19 Severity mapping**: RBAC Role Definition findings must assign severity consistently according to the approved rules: permission-set change and missing baseline role definition are High, unexpected additional role definition is Medium, and metadata-only change is Low.
|
||||
- **FR-128-20 Evidence payload**: RBAC findings must store evidence sufficient for existing drift and finding surfaces to render a readable should-versus-is comparison, including the summary kind, baseline reference, current reference, and normalized before-versus-after evidence shape.
|
||||
- **FR-128-21 Built-in versus custom visibility**: Compare summaries and finding evidence must preserve built-in versus custom state so reviewers can interpret RBAC governance impact correctly.
|
||||
- **FR-128-22 Stable fingerprinting**: Each RBAC Role Definition drift finding must use a stable fingerprint derived from tenant or provider context, baseline profile, source type `intuneRoleDefinition`, Role Definition identity, change kind, and normalized diff fingerprint so repeated unchanged drift does not create duplicates.
|
||||
- **FR-128-23 Recurrence behavior**: If an RBAC drift condition resolves and later reappears, the existing finding recurrence or reopen behavior must apply without special-case RBAC logic.
|
||||
- **FR-128-24 Compare summary output**: Baseline compare result output must include an Intune RBAC Role Definition summary with total compared, unchanged, modified, missing, and unexpected counts.
|
||||
- **FR-128-25 Existing-surface compatibility**: RBAC compare summaries, findings, and evidence must render inside existing baseline, drift, and findings surfaces without requiring a new dedicated RBAC resource page.
|
||||
- **FR-128-26 Clear labeling**: UI titles, subtitles, and summary text must identify these results as Intune RBAC Role Definition drift rather than generic policy drift.
|
||||
- **FR-128-27 No restore implication**: No compare, finding, or detail surface introduced or modified by this release may imply executable RBAC restore support.
|
||||
- **FR-128-28 Scope isolation**: Baseline capture, compare, summaries, and findings for Intune Role Definitions must preserve existing workspace and tenant isolation semantics with no cross-tenant leakage.
|
||||
- **FR-128-29 Safe degradation**: If required current-state RBAC data is unavailable or untrusted because provider access, API behavior, or permission coverage is insufficient, compare must fail clearly at the run or report level and must not emit misleading RBAC drift findings.
|
||||
- **FR-128-30 Regression safety**: Extending baseline compare to Intune Role Definitions must not regress baseline capture, compare, or finding behavior for already supported policy or foundation types.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Baseline compare for `intuneRoleAssignment`
|
||||
- Drift findings for Role Assignments
|
||||
- Restore for Intune Role Definitions or Role Assignments
|
||||
- Cross-tenant compare
|
||||
- Entra RBAC compare
|
||||
- New dedicated RBAC resource pages
|
||||
- Assignment-aware remediation or auto-approval workflows
|
||||
- Notification routing changes beyond existing finding behavior
|
||||
- Broad rollout of baseline compare to all remaining foundation types in this release
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Spec 127 already provides stable tenant-scoped Intune Role Definition inventory records, immutable snapshot history, and readable normalized Role Definition output suitable for baseline evidence.
|
||||
- Existing baseline profile, capture, compare, and findings infrastructure is the single canonical path and should be extended rather than duplicated.
|
||||
- Role Definition ID is the correct identity anchor for same-tenant governance comparison over time.
|
||||
- The current release should prefer high-signal governance detection over broader RBAC coverage, which is why Role Assignments remain intentionally excluded.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Spec 127 Intune RBAC inventory and immutable snapshot history
|
||||
- Existing baseline capture and compare pipeline
|
||||
- Existing unified findings lifecycle, recurrence handling, and evidence rendering
|
||||
- Existing baseline and drift UI surfaces for summaries, findings, and run detail
|
||||
- Existing tenant and workspace authorization model and capability registry
|
||||
|
||||
## UI Action Matrix
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline Profiles | Existing workspace-admin baseline profile screens | Create Baseline Profile | Existing profile inspection affordance remains unchanged | Edit, Archive (confirmed) | None | Existing single empty-state CTA remains | Capture Baseline, Edit, Archive (confirmed) | Save, Cancel | Yes | Scope picker adds Intune Role Definitions as an opt-in supported type and keeps Role Assignments absent. No new destructive action is introduced; archive remains confirmed. |
|
||||
| Baseline Snapshot Detail | Existing workspace-admin baseline snapshot/detail surfaces | None new | Existing linked snapshot detail remains unchanged | None new | None | None | Existing detail actions remain | N/A | Yes | Surface adds readable RBAC Role Definition reference context only. No new row or bulk action is introduced. |
|
||||
| Baseline Compare Run Detail | Existing tenant-context compare run detail | Run Compare or Re-run Compare where already allowed | Linked from runs list and findings | None new | None | Existing compare CTA remains if no runs exist | None new | N/A | Yes | Adds Intune RBAC Role Definition summary counts, warning states, and evidence links while preserving the existing action surface. |
|
||||
| Drift Findings Landing and Detail | Existing tenant-context findings surfaces | None new | Existing table inspection or linked detail remains | Existing view or lifecycle actions remain unchanged | None new | Existing empty-state CTA remains unchanged | Existing detail actions remain | N/A | Yes | Adds RBAC-specific titles, severity mapping, and evidence rendering. No Role Assignment findings appear, and no restore affordance is introduced. |
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Baseline Scope Entry**: A baseline profile selection that records Intune Role Definitions as an approved compare scope while leaving Role Assignments excluded.
|
||||
- **Baseline Role Definition Reference**: The workspace-owned reference inside a baseline snapshot that points to the approved Intune Role Definition version or snapshot evidence used for compare.
|
||||
- **Role Definition Compare Result**: The tenant-scoped compare outcome for one Intune Role Definition, including its identity, classification, severity, and readable normalized evidence.
|
||||
- **RBAC Drift Finding**: The recurring finding produced when a Role Definition is modified, missing, or unexpected relative to the approved baseline.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-128-01 Baseline readiness**: A workspace admin can include Intune Role Definitions through the existing baseline profile create-or-edit flow and complete baseline capture successfully without adding Role Assignments to the captured scope.
|
||||
- **SC-128-02 Deterministic compare**: For a fixed baseline snapshot and unchanged tenant RBAC state, repeated compare runs produce the same RBAC summary counts and no duplicate findings.
|
||||
- **SC-128-03 Drift detection quality**: In verified test scenarios, 100% of Role Definition drift cases are classified into the correct bucket of modified, missing, or unexpected.
|
||||
- **SC-128-04 Severity clarity**: In verified test scenarios, permission-set changes and missing baseline roles are always surfaced as High severity, unexpected additional roles as Medium severity, and metadata-only changes as Low severity.
|
||||
- **SC-128-05 Safe trust model**: When current-state RBAC evidence is unavailable or untrusted, the compare result warns or fails clearly and emits zero false RBAC drift findings.
|
||||
222
specs/128-rbac-baseline-compare/tasks.md
Normal file
222
specs/128-rbac-baseline-compare/tasks.md
Normal file
@ -0,0 +1,222 @@
|
||||
# Tasks: Intune RBAC Baseline Compare & Findings v1 (128)
|
||||
|
||||
**Input**: Design documents from `specs/128-rbac-baseline-compare/` (spec.md, plan.md, research.md, data-model.md, contracts/, quickstart.md)
|
||||
**Prerequisites**: `specs/128-rbac-baseline-compare/plan.md` (required), `specs/128-rbac-baseline-compare/spec.md` (required for user stories)
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo.
|
||||
**Operations**: Reuse the existing `OperationRun` flows for baseline capture and baseline compare; do not add ad-hoc operation feedback, status transitions, or notification patterns.
|
||||
**RBAC**: Preserve workspace and tenant authorization boundaries, deny-as-not-found 404 for non-members, 403 for in-scope members missing capability, and canonical capability registry usage only.
|
||||
**Filament UI**: No new Filament resource/page is added; existing baseline profile, snapshot, compare, and findings surfaces are extended only.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Reconfirm the exact baseline, RBAC, and findings seams before changing the compare engine.
|
||||
|
||||
- [X] T001 Review baseline scope option and profile form seams in `app/Filament/Resources/BaselineProfileResource.php` and `app/Support/Baselines/BaselineScope.php`
|
||||
- [X] T002 Review baseline capture and compare identity seams in `app/Jobs/CaptureBaselineSnapshotJob.php`, `app/Jobs/CompareBaselineToTenantJob.php`, and `app/Support/Baselines/BaselineSubjectKey.php`
|
||||
- [X] T003 [P] Review existing Intune RBAC Role Definition normalization and version evidence behavior in `app/Services/Intune/IntuneRoleDefinitionNormalizer.php`, `app/Services/Intune/PolicyNormalizer.php`, and `tests/Unit/IntuneRoleDefinitionNormalizerTest.php`
|
||||
- [X] T004 [P] Review baseline compare evidence, recurrence, and safety coverage in `tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php`, `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php`, and `tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared metadata and helper changes that must exist before baseline capture, compare, or UI work can proceed.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T005 Add explicit baseline-compare support metadata for RBAC foundation types in `config/tenantpilot.php`
|
||||
- [X] T006 Extend baseline-support helper accessors for foundation types in `app/Support/Inventory/InventoryPolicyTypeMeta.php`
|
||||
- [X] T007 Update eligible foundation scope options and scope rendering in `app/Filament/Resources/BaselineProfileResource.php`
|
||||
- [X] T008 [P] Add baseline-support metadata coverage for foundation helpers in `tests/Unit/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php`
|
||||
- [X] T009 [P] Add baseline profile foundation scope selection coverage in `tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
|
||||
|
||||
**Checkpoint**: Baseline-supported foundation metadata is explicit, and only eligible foundation types can enter the baseline profile scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Capture an approved RBAC baseline (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Workspace admins can select Intune Role Definitions in a baseline profile and capture baseline snapshot items that keep Role Definition evidence references while excluding Role Assignments.
|
||||
|
||||
**Independent Test**: Update a baseline profile to include `intuneRoleDefinition`, run baseline capture, and verify the snapshot contains Role Definition references with evidence-ready metadata while excluding `intuneRoleAssignment`.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T010 [P] [US1] Add baseline capture coverage for Role Definition inclusion and Role Assignment exclusion in `tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php`
|
||||
- [X] T011 [P] [US1] Add baseline snapshot detail coverage for captured RBAC Role Definition references in `tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Extend `CaptureBaselineSnapshotJob` to capture `intuneRoleDefinition` baseline items with explicit identity metadata and version references in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- [X] T013 [US1] Add Role Definition-aware baseline subject identity helpers in `app/Support/Baselines/BaselineSubjectKey.php` and `app/Services/Baselines/BaselineSnapshotIdentity.php`
|
||||
- [X] T014 [US1] Persist evidence-ready baseline metadata for RBAC Role Definition snapshot items in `app/Jobs/CaptureBaselineSnapshotJob.php` and `app/Models/BaselineSnapshotItem.php`
|
||||
- [X] T015 [US1] Surface captured RBAC Role Definition references in existing snapshot detail presentation via `app/Filament/Resources/BaselineSnapshotResource.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when baseline capture stores Role Definition references suitable for later compare and keeps Role Assignments out of the captured baseline scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Detect RBAC drift deterministically (Priority: P1)
|
||||
|
||||
**Goal**: Baseline compare classifies Intune Role Definitions as unchanged, modified, missing, or unexpected using Role Definition ID identity and normalized governance diffs.
|
||||
|
||||
**Independent Test**: Compare a tenant with prepared Role Definition changes against a captured baseline and verify classification, severity, assignment exclusion, and safe coverage behavior without relying on findings UI.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T016 [P] [US2] Add compare classification coverage for unchanged, modified, missing, and unexpected Role Definitions in `tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php`
|
||||
- [X] T017 [P] [US2] Extend Role Definition normalization coverage for metadata-only versus permission-impacting diffs in `tests/Unit/IntuneRoleDefinitionNormalizerTest.php`
|
||||
- [X] T018 [P] [US2] Add recreated same-name and different-ID Role Definition coverage in `tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php`
|
||||
- [X] T019 [P] [US2] Add partial-success and coverage-suppression coverage for RBAC compare in `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php` and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T020 [US2] Extend compare-side baseline and current item loading to match `intuneRoleDefinition` by Role Definition ID in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T021 [US2] Implement normalized RBAC Role Definition diff classification and severity mapping in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T022 [US2] Reuse `IntuneRoleDefinitionNormalizer::flattenForDiff()` for governance-normalized RBAC compare in `app/Services/Intune/IntuneRoleDefinitionNormalizer.php` and `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T023 [US2] Extend baseline compare evidence resolution for RBAC Role Definition baseline/current version references in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Services/Baselines/Evidence/ContentEvidenceProvider.php`
|
||||
- [X] T024 [US2] Add RBAC Role Definition summary counts and reason-code context to compare runs in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Support/Baselines/BaselineCompareReasonCode.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when compare deterministically classifies Role Definition drift by ID, suppresses false findings when coverage is unsafe, and records RBAC-specific summary counts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Review actionable RBAC findings without assignment noise (Priority: P2)
|
||||
|
||||
**Goal**: Existing findings and baseline compare surfaces render readable RBAC Role Definition drift evidence, correct labels, and recurrence-safe findings without implying assignment coverage or restore behavior.
|
||||
|
||||
**Independent Test**: Open RBAC findings detail and compare detail surfaces after a Role Definition drift run and verify titles, evidence, severity, recurrence behavior, and assignment exclusion.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Add RBAC evidence contract coverage for modified, missing, and unexpected Role Definition findings in `tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php`
|
||||
- [X] T026 [P] [US3] Add RBAC finding idempotency and recurrence coverage in `tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php` and `tests/Feature/Findings/FindingRecurrenceTest.php`
|
||||
- [X] T027 [P] [US3] Add Filament coverage for RBAC labels, readable evidence, and no-restore/no-assignment messaging in `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php` and `tests/Feature/Filament/FindingViewRbacEvidenceTest.php`
|
||||
- [X] T028 [P] [US3] Add tenant/workspace isolation coverage for RBAC baseline compare in `tests/Feature/Baselines/BaselineCompareCrossTenantMatchTest.php` and `tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T029 [US3] Extend baseline.compare finding evidence payloads for `intuneRoleDefinition` in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T030 [US3] Add RBAC-specific summary kinds, titles, and readable evidence labels in `app/Filament/Pages/BaselineCompareLanding.php`, `lang/en/baseline-compare.php`, and `lang/en/findings.php`
|
||||
- [X] T031 [US3] Render RBAC drift evidence and labels in existing findings surfaces via `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T032 [US3] Keep RBAC findings aligned with explicit fingerprint composition, unified auto-close, and recurrence behavior in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Services/Baselines/BaselineAutoCloseService.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when RBAC drift appears in the existing compare and findings surfaces with readable evidence, correct labels, stable recurrence behavior, and no assignment or restore leakage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final regression protection, formatting, and QA across all stories.
|
||||
|
||||
- [ ] T033 [P] Add or update focused regression coverage for unchanged baseline compare behavior on non-RBAC types in `tests/Feature/Baselines/BaselineCompareFindingsTest.php` and `tests/Feature/Guards/Spec116OneEngineGuardTest.php`
|
||||
- [ ] T034 [P] Add or update compare-run stats coverage for RBAC summary buckets in `tests/Feature/Baselines/BaselineCompareStatsTest.php`
|
||||
- [ ] T035 Run formatting for changed files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [ ] T036 Run focused Pest verification from `specs/128-rbac-baseline-compare/quickstart.md`
|
||||
- [ ] T037 Validate the manual QA checklist in `specs/128-rbac-baseline-compare/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from US1 because captured Role Definition baseline items become the approved compare input.
|
||||
- **User Story 3 (Phase 5)**: Depends on US2 because findings and UI evidence depend on compare outputs and recurrence-safe finding generation.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: First deliverable and MVP. No dependency on other user stories.
|
||||
- **US2 (P1)**: Depends on the same shared metadata gates as US1 and on captured baseline Role Definition references.
|
||||
- **US3 (P2)**: Depends on compare results and findings from US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests should be added before or alongside implementation and must fail before the story is considered complete.
|
||||
- Metadata and identity model changes precede compare and UI changes.
|
||||
- Compare behavior should be complete before evidence and label presentation work begins.
|
||||
- Story completion requires focused tests for that story to pass.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Setup review tasks `T003` and `T004` can run in parallel.
|
||||
- In Phase 2, helper and test tasks `T008` and `T009` can run in parallel after metadata changes begin.
|
||||
- In US1, `T010` and `T011` can run in parallel, then `T013` and `T015` can proceed in parallel once `T012` defines the capture shape.
|
||||
- In US2, test tasks `T016` to `T019` can run in parallel, and implementation tasks `T021` to `T024` can be split once `T020` establishes Role Definition ID matching.
|
||||
- In US3, test tasks `T025` to `T028` can run in parallel, and UI tasks `T030` and `T031` can proceed in parallel after evidence shape is defined in `T029`.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch US1 tests in parallel:
|
||||
T010 tests/Feature/Baselines/BaselineCaptureRbacRoleDefinitionsTest.php
|
||||
T011 tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php
|
||||
|
||||
# Launch US1 follow-up implementation in parallel after capture shape exists:
|
||||
T013 app/Support/Baselines/BaselineSubjectKey.php + app/Services/Baselines/BaselineSnapshotIdentity.php
|
||||
T015 app/Filament/Resources/BaselineSnapshotResource.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch US2 tests in parallel:
|
||||
T016 tests/Feature/Baselines/BaselineCompareRbacRoleDefinitionsTest.php
|
||||
T017 tests/Unit/IntuneRoleDefinitionNormalizerTest.php
|
||||
T019 tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php
|
||||
|
||||
# Launch US2 implementation in parallel after ID-based loading is in place:
|
||||
T021 app/Jobs/CompareBaselineToTenantJob.php
|
||||
T023 app/Services/Baselines/Evidence/ContentEvidenceProvider.php
|
||||
T024 app/Support/Baselines/BaselineCompareReasonCode.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch US3 tests in parallel:
|
||||
T025 tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php
|
||||
T026 tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php
|
||||
T027 tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php
|
||||
|
||||
# Launch US3 UI work in parallel after evidence payloads are defined:
|
||||
T030 app/Filament/Pages/BaselineCompareLanding.php + lang/en/baseline-compare.php + lang/en/findings.php
|
||||
T031 app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate baseline profile scope selection and baseline capture for `intuneRoleDefinition` independently.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to establish Role Definition baseline eligibility and capture references.
|
||||
2. Add US2 to deliver deterministic RBAC compare and finding generation.
|
||||
3. Add US3 to make findings readable and operationally safe in existing UI surfaces.
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = Phases 1 through 3, then run the focused capture tests from `specs/128-rbac-baseline-compare/quickstart.md`.
|
||||
|
||||
---
|
||||
|
||||
## Format Validation
|
||||
|
||||
- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`.
|
||||
- Setup, Foundational, and Polish phases intentionally omit story labels.
|
||||
- User story phases use `[US1]`, `[US2]`, and `[US3]` labels.
|
||||
- Parallel markers are applied only where tasks can proceed independently on separate files or clearly separable work.
|
||||
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
|
||||
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: [
|
||||
'intuneRoleAssignment' => 'succeeded',
|
||||
'intuneRoleDefinition' => 'succeeded',
|
||||
],
|
||||
foundationTypes: ['intuneRoleAssignment', 'intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'role-def-1',
|
||||
'display_name' => 'Security Reader',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'captured_at' => now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Security Reader',
|
||||
'description' => 'Security reporting role',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => ['Microsoft.Intune/managedDevices/read'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'role-def-1',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Security Reader',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
'etag' => 'E-RBAC-1',
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
'warnings' => [],
|
||||
],
|
||||
'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' => 'role-assignment-1',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'display_name' => 'Security Reader Assignment',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
||||
'etag' => 'E-RBAC-A1',
|
||||
'warnings' => [],
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->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' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CaptureBaselineSnapshotJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(InventoryMetaContract::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->sole();
|
||||
|
||||
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', (int) $snapshot->getKey())->count())->toBe(1);
|
||||
|
||||
$item = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->sole();
|
||||
|
||||
$expectedSubjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', 'Security Reader', 'role-def-1');
|
||||
$expectedExternalReference = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', 'Security Reader', 'role-def-1');
|
||||
|
||||
expect($item->policy_type)->toBe('intuneRoleDefinition');
|
||||
expect($item->subject_key)->toBe($expectedSubjectKey);
|
||||
expect($item->subject_external_id)->toBe($expectedExternalReference);
|
||||
expect($item->subject_external_id)->not->toBe('role-def-1');
|
||||
|
||||
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
|
||||
|
||||
expect(data_get($meta, 'identity.strategy'))->toBe('external_id');
|
||||
expect(data_get($meta, 'rbac.is_built_in'))->toBeFalse();
|
||||
expect(data_get($meta, 'rbac.role_permission_count'))->toBe(1);
|
||||
expect(data_get($meta, 'version_reference.policy_version_id'))->toBe((int) $version->getKey());
|
||||
expect(data_get($meta, 'evidence.source'))->toBe('policy_version');
|
||||
});
|
||||
@ -354,8 +354,12 @@
|
||||
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
|
||||
$supportedTypeCount = collect(config('tenantpilot.supported_policy_types', []))
|
||||
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
|
||||
->count();
|
||||
|
||||
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
|
||||
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
|
||||
expect((int) ($counts['errors_recorded'] ?? 0))->toBe($supportedTypeCount);
|
||||
expect((int) ($counts['total'] ?? -1))->toBe(0);
|
||||
|
||||
expect(
|
||||
@ -365,3 +369,116 @@
|
||||
->count()
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
it('suppresses intune role definition findings when RBAC foundation coverage is unproven', 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' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$deviceConfigurationDisplayName = 'Covered Device Configuration';
|
||||
$deviceConfigurationSubjectKey = BaselineSubjectKey::fromDisplayName($deviceConfigurationDisplayName);
|
||||
expect($deviceConfigurationSubjectKey)->not->toBeNull();
|
||||
|
||||
$deviceConfigurationWorkspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId(
|
||||
'deviceConfiguration',
|
||||
(string) $deviceConfigurationSubjectKey,
|
||||
);
|
||||
|
||||
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: 'deviceConfiguration',
|
||||
subjectExternalId: 'device-config-covered',
|
||||
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E-STABLE'],
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $deviceConfigurationWorkspaceSafeExternalId,
|
||||
'subject_key' => (string) $deviceConfigurationSubjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $deviceConfigurationDisplayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'source' => 'inventory',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
'intuneRoleDefinition' => 'failed',
|
||||
],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
attributes: [
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'device-config-covered',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $deviceConfigurationDisplayName,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E-STABLE'],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$compareRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe('coverage_unproven');
|
||||
expect(data_get($compareRun->context, 'baseline_compare.coverage.uncovered_types'))->toBe(['intuneRoleDefinition']);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.rbac_role_definitions'))->toBe([
|
||||
'total_compared' => 0,
|
||||
'unchanged' => 0,
|
||||
'modified' => 0,
|
||||
'missing' => 0,
|
||||
'unexpected' => 0,
|
||||
]);
|
||||
expect(
|
||||
Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->count()
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
@ -134,7 +134,7 @@
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect(['succeeded', 'partially_succeeded'])->toContain((string) $run->outcome);
|
||||
|
||||
expect(
|
||||
Finding::query()
|
||||
@ -143,3 +143,188 @@
|
||||
->count(),
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
it('does not match role definitions across tenants when the role definition id differs', function (): void {
|
||||
[$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' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $sourceTenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$displayName = 'Security Reader';
|
||||
$sourceExternalId = 'source-role-definition-id';
|
||||
$targetExternalId = 'target-role-definition-id';
|
||||
|
||||
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $sourceExternalId);
|
||||
$baselineSubjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $sourceExternalId);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
expect($baselineSubjectExternalId)->not->toBeNull();
|
||||
|
||||
$sourcePolicy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'external_id' => $sourceExternalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
$sourceVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'policy_id' => (int) $sourcePolicy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'captured_at' => now()->subMinute(),
|
||||
'snapshot' => [
|
||||
'displayName' => $displayName,
|
||||
'description' => 'Source tenant role',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => ['microsoft.intune/devices/read'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $baselineSubjectExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => app(\App\Services\Baselines\Evidence\ContentEvidenceProvider::class)
|
||||
->fromPolicyVersion($sourceVersion, (string) $baselineSubjectExternalId)
|
||||
->hash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $sourceVersion->captured_at?->toIso8601String(),
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $sourceVersion->getKey(),
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $targetTenant,
|
||||
statusByType: ['intuneRoleDefinition' => 'succeeded'],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$targetPolicy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'external_id' => $targetExternalId,
|
||||
'platform' => 'all',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'policy_id' => (int) $targetPolicy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'captured_at' => now(),
|
||||
'snapshot' => [
|
||||
'displayName' => $displayName,
|
||||
'description' => 'Target tenant role',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => ['microsoft.intune/devices/read'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $targetTenant->getKey(),
|
||||
'workspace_id' => (int) $targetTenant->workspace_id,
|
||||
'external_id' => $targetExternalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => [
|
||||
'etag' => 'E1',
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
'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' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect(['succeeded', 'partially_succeeded'])->toContain((string) $run->outcome);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $targetTenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->get();
|
||||
|
||||
expect($findings->count())->toBe(2)
|
||||
->and($findings->map(fn (Finding $finding): ?string => data_get($finding->evidence_jsonb, 'change_type'))->sort()->values()->all())->toBe([
|
||||
'missing_policy',
|
||||
'unexpected_policy',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -0,0 +1,411 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Tests\Support\AssertsDriftEvidenceContract;
|
||||
|
||||
function rbacContractSnapshot(
|
||||
string $displayName,
|
||||
string $description,
|
||||
bool $isBuiltIn,
|
||||
array $allowedActions,
|
||||
array $deniedActions = [],
|
||||
?string $condition = null,
|
||||
): array {
|
||||
return [
|
||||
'displayName' => $displayName,
|
||||
'description' => $description,
|
||||
'isBuiltIn' => $isBuiltIn,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
array_filter([
|
||||
'allowedResourceActions' => $allowedActions,
|
||||
'notAllowedResourceActions' => $deniedActions,
|
||||
'condition' => $condition,
|
||||
], static fn (mixed $value): bool => $value !== null),
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
}
|
||||
|
||||
function rbacContractPolicy(
|
||||
\App\Models\Tenant $tenant,
|
||||
string $externalId,
|
||||
string $displayName,
|
||||
): Policy {
|
||||
return Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
}
|
||||
|
||||
function rbacContractVersion(
|
||||
Policy $policy,
|
||||
CarbonImmutable $capturedAt,
|
||||
int $versionNumber,
|
||||
array $snapshot,
|
||||
): PolicyVersion {
|
||||
return PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $policy->tenant_id,
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => $versionNumber,
|
||||
'captured_at' => $capturedAt,
|
||||
'snapshot' => $snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
function rbacContractBaselineItem(
|
||||
BaselineSnapshot $snapshot,
|
||||
PolicyVersion $version,
|
||||
string $externalId,
|
||||
string $displayName,
|
||||
bool $isBuiltIn,
|
||||
int $rolePermissionCount = 1,
|
||||
): BaselineSnapshotItem {
|
||||
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $externalId);
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $externalId);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
expect($workspaceSafeExternalId)->not->toBeNull();
|
||||
|
||||
$hash = app(ContentEvidenceProvider::class)->fromPolicyVersion(
|
||||
version: $version,
|
||||
subjectExternalId: (string) $workspaceSafeExternalId,
|
||||
)->hash;
|
||||
|
||||
return BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => $hash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $version->captured_at?->toIso8601String(),
|
||||
],
|
||||
'identity' => [
|
||||
'strategy' => 'external_id',
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'workspace_subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
'capture_purpose' => 'baseline_capture',
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => $isBuiltIn,
|
||||
'role_permission_count' => $rolePermissionCount,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function rbacContractInventoryItem(
|
||||
\App\Models\Tenant $tenant,
|
||||
int $inventorySyncRunId,
|
||||
string $externalId,
|
||||
string $displayName,
|
||||
bool $isBuiltIn,
|
||||
int $rolePermissionCount = 1,
|
||||
): InventoryItem {
|
||||
return InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => $displayName,
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
'is_built_in' => $isBuiltIn,
|
||||
'role_permission_count' => $rolePermissionCount,
|
||||
],
|
||||
'last_seen_operation_run_id' => $inventorySyncRunId,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function rbacContractRun(
|
||||
\App\Models\Tenant $tenant,
|
||||
\App\Models\User $user,
|
||||
BaselineProfile $profile,
|
||||
BaselineSnapshot $snapshot,
|
||||
): \App\Models\OperationRun {
|
||||
return app(OperationRunService::class)->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
}
|
||||
|
||||
it('writes RBAC evidence for modified role definition drift with readable normalized before and after content', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$capturedAt = CarbonImmutable::parse('2026-03-08T10:00:00Z');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $capturedAt,
|
||||
]);
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['intuneRoleDefinition' => 'succeeded'],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$policy = rbacContractPolicy($tenant, 'rbac-modified-role', 'Security Reader');
|
||||
$baselineVersion = rbacContractVersion(
|
||||
policy: $policy,
|
||||
capturedAt: $capturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacContractSnapshot(
|
||||
displayName: 'Security Reader',
|
||||
description: 'Baseline role description',
|
||||
isBuiltIn: false,
|
||||
allowedActions: ['microsoft.intune/devices/read'],
|
||||
),
|
||||
);
|
||||
$currentVersion = rbacContractVersion(
|
||||
policy: $policy,
|
||||
capturedAt: $capturedAt->addMinutes(10),
|
||||
versionNumber: 2,
|
||||
snapshot: rbacContractSnapshot(
|
||||
displayName: 'Security Reader',
|
||||
description: 'Updated role description',
|
||||
isBuiltIn: false,
|
||||
allowedActions: ['microsoft.intune/devices/read'],
|
||||
),
|
||||
);
|
||||
|
||||
rbacContractBaselineItem(
|
||||
snapshot: $snapshot,
|
||||
version: $baselineVersion,
|
||||
externalId: 'rbac-modified-role',
|
||||
displayName: 'Security Reader',
|
||||
isBuiltIn: false,
|
||||
);
|
||||
|
||||
rbacContractInventoryItem(
|
||||
tenant: $tenant,
|
||||
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
externalId: 'rbac-modified-role',
|
||||
displayName: 'Security Reader',
|
||||
isBuiltIn: false,
|
||||
);
|
||||
|
||||
$run = rbacContractRun($tenant, $user, $profile, $snapshot);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
|
||||
AssertsDriftEvidenceContract::assertValid($evidence);
|
||||
|
||||
expect(data_get($evidence, 'summary.kind'))->toBe('rbac_role_definition')
|
||||
->and(data_get($evidence, 'rbac_role_definition.diff_kind'))->toBe('metadata_only')
|
||||
->and(data_get($evidence, 'rbac_role_definition.changed_keys'))->toContain('Role definition > Description')
|
||||
->and(data_get($evidence, 'rbac_role_definition.baseline.is_built_in'))->toBeFalse()
|
||||
->and(data_get($evidence, 'rbac_role_definition.current.is_built_in'))->toBeFalse()
|
||||
->and(data_get($evidence, 'rbac_role_definition.baseline.normalized.Role definition > Description'))->toBe('Baseline role description')
|
||||
->and(data_get($evidence, 'rbac_role_definition.current.normalized.Role definition > Description'))->toBe('Updated role description');
|
||||
});
|
||||
|
||||
it('writes RBAC evidence for missing role definition drift', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$capturedAt = CarbonImmutable::parse('2026-03-08T10:00:00Z');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $capturedAt,
|
||||
]);
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['intuneRoleDefinition' => 'succeeded'],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$policy = rbacContractPolicy($tenant, 'rbac-missing-role', 'Missing Role');
|
||||
$baselineVersion = rbacContractVersion(
|
||||
policy: $policy,
|
||||
capturedAt: $capturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacContractSnapshot(
|
||||
displayName: 'Missing Role',
|
||||
description: 'Baseline-only role',
|
||||
isBuiltIn: true,
|
||||
allowedActions: ['microsoft.intune/devices/read'],
|
||||
),
|
||||
);
|
||||
|
||||
rbacContractBaselineItem(
|
||||
snapshot: $snapshot,
|
||||
version: $baselineVersion,
|
||||
externalId: 'rbac-missing-role',
|
||||
displayName: 'Missing Role',
|
||||
isBuiltIn: true,
|
||||
);
|
||||
|
||||
$run = rbacContractRun($tenant, $user, $profile, $snapshot);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
|
||||
AssertsDriftEvidenceContract::assertValid($evidence);
|
||||
|
||||
expect(data_get($evidence, 'summary.kind'))->toBe('rbac_role_definition')
|
||||
->and(data_get($evidence, 'rbac_role_definition.diff_kind'))->toBe('missing')
|
||||
->and(data_get($evidence, 'rbac_role_definition.baseline.is_built_in'))->toBeTrue()
|
||||
->and(data_get($evidence, 'rbac_role_definition.current.normalized'))->toBe([])
|
||||
->and(data_get($evidence, 'rbac_role_definition.baseline.normalized.Role definition > Display name'))->toBe('Missing Role');
|
||||
});
|
||||
|
||||
it('writes RBAC evidence for unexpected role definition drift', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$capturedAt = CarbonImmutable::parse('2026-03-08T10:00:00Z');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $capturedAt,
|
||||
]);
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['intuneRoleDefinition' => 'succeeded'],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$policy = rbacContractPolicy($tenant, 'rbac-unexpected-role', 'Unexpected Role');
|
||||
rbacContractVersion(
|
||||
policy: $policy,
|
||||
capturedAt: $capturedAt->addMinutes(10),
|
||||
versionNumber: 1,
|
||||
snapshot: rbacContractSnapshot(
|
||||
displayName: 'Unexpected Role',
|
||||
description: 'Tenant-only role',
|
||||
isBuiltIn: false,
|
||||
allowedActions: ['microsoft.intune/devices/read'],
|
||||
deniedActions: ['microsoft.intune/devices/delete'],
|
||||
),
|
||||
);
|
||||
|
||||
rbacContractInventoryItem(
|
||||
tenant: $tenant,
|
||||
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
||||
externalId: 'rbac-unexpected-role',
|
||||
displayName: 'Unexpected Role',
|
||||
isBuiltIn: false,
|
||||
);
|
||||
|
||||
$run = rbacContractRun($tenant, $user, $profile, $snapshot);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||
|
||||
AssertsDriftEvidenceContract::assertValid($evidence);
|
||||
|
||||
expect(data_get($evidence, 'summary.kind'))->toBe('rbac_role_definition')
|
||||
->and(data_get($evidence, 'rbac_role_definition.diff_kind'))->toBe('unexpected')
|
||||
->and(data_get($evidence, 'rbac_role_definition.baseline.normalized'))->toBe([])
|
||||
->and(data_get($evidence, 'rbac_role_definition.current.is_built_in'))->toBeFalse()
|
||||
->and(data_get($evidence, 'rbac_role_definition.current.normalized.Role definition > Display name'))->toBe('Unexpected Role');
|
||||
});
|
||||
@ -157,3 +157,234 @@
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint);
|
||||
expect($finding->times_seen)->toBe(2);
|
||||
});
|
||||
|
||||
function rbacRecurrenceSnapshot(string $displayName, string $description, array $allowedActions): array
|
||||
{
|
||||
return [
|
||||
'displayName' => $displayName,
|
||||
'description' => $description,
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => $allowedActions,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
}
|
||||
|
||||
it('keeps intune role definition recurrence stable when the normalized diff fingerprint changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$capturedAt = now()->subMinutes(5);
|
||||
$displayName = 'Security Reader';
|
||||
$externalId = 'rbac-role-stable';
|
||||
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $externalId);
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $externalId);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
expect($workspaceSafeExternalId)->not->toBeNull();
|
||||
|
||||
$policy = \App\Models\Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
$baselineVersionOne = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => 1,
|
||||
'captured_at' => $capturedAt->copy(),
|
||||
'snapshot' => rbacRecurrenceSnapshot($displayName, 'Baseline v1', ['microsoft.intune/devices/read']),
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$baselineVersionTwo = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => 2,
|
||||
'captured_at' => $capturedAt->copy()->addMinute(),
|
||||
'snapshot' => rbacRecurrenceSnapshot($displayName, 'Baseline v2', ['microsoft.intune/devices/read']),
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$snapshot1 = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $capturedAt->copy(),
|
||||
]);
|
||||
|
||||
$snapshot2 = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $capturedAt->copy()->addMinute(),
|
||||
]);
|
||||
|
||||
$provider = app(\App\Services\Baselines\Evidence\ContentEvidenceProvider::class);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot1->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => $provider->fromPolicyVersion($baselineVersionOne, (string) $workspaceSafeExternalId)->hash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $baselineVersionOne->captured_at?->toIso8601String(),
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $baselineVersionOne->getKey(),
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot2->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => $provider->fromPolicyVersion($baselineVersionTwo, (string) $workspaceSafeExternalId)->hash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $baselineVersionTwo->captured_at?->toIso8601String(),
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $baselineVersionTwo->getKey(),
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = \App\Models\PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => 3,
|
||||
'captured_at' => $capturedAt->copy()->addMinutes(2),
|
||||
'snapshot' => rbacRecurrenceSnapshot($displayName, 'Current drifted role', [
|
||||
'microsoft.intune/devices/read',
|
||||
'microsoft.intune/devices/delete',
|
||||
]),
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['intuneRoleDefinition' => 'succeeded'],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 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' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run1))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
$fingerprint = (string) $finding->fingerprint;
|
||||
$firstDiffFingerprint = (string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint');
|
||||
|
||||
expect($finding->recurrence_key)->toBe($fingerprint)
|
||||
->and($firstDiffFingerprint)->not->toBe('');
|
||||
|
||||
$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' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run2))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint)
|
||||
->and($finding->times_seen)->toBe(2)
|
||||
->and((string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint'))->not->toBe($firstDiffFingerprint);
|
||||
});
|
||||
|
||||
@ -0,0 +1,444 @@
|
||||
<?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\Baselines\Evidence\ContentEvidenceProvider;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
function rbacRoleDefinitionSnapshot(
|
||||
string $displayName,
|
||||
string $description,
|
||||
bool $isBuiltIn,
|
||||
array $allowedActions,
|
||||
array $deniedActions = [],
|
||||
?string $condition = null,
|
||||
array $scopeTagIds = ['0'],
|
||||
): array {
|
||||
return [
|
||||
'displayName' => $displayName,
|
||||
'description' => $description,
|
||||
'isBuiltIn' => $isBuiltIn,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
array_filter([
|
||||
'allowedResourceActions' => $allowedActions,
|
||||
'notAllowedResourceActions' => $deniedActions,
|
||||
'condition' => $condition,
|
||||
], static fn (mixed $value): bool => $value !== null),
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => $scopeTagIds,
|
||||
];
|
||||
}
|
||||
|
||||
function createRoleDefinitionPolicy(Tenant $tenant, string $externalId, string $displayName): Policy
|
||||
{
|
||||
return Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
}
|
||||
|
||||
function createRoleDefinitionVersion(Policy $policy, CarbonImmutable $capturedAt, int $versionNumber, array $snapshot): PolicyVersion
|
||||
{
|
||||
return PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $policy->tenant_id,
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => $versionNumber,
|
||||
'captured_at' => $capturedAt,
|
||||
'snapshot' => $snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
function createBaselineRoleDefinitionSnapshotItem(
|
||||
BaselineSnapshot $snapshot,
|
||||
PolicyVersion $version,
|
||||
string $externalId,
|
||||
string $displayName,
|
||||
bool $isBuiltIn,
|
||||
int $rolePermissionCount = 1,
|
||||
): BaselineSnapshotItem {
|
||||
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', $displayName, $externalId);
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy('intuneRoleDefinition', $displayName, $externalId);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
expect($workspaceSafeExternalId)->not->toBeNull();
|
||||
|
||||
$hash = app(ContentEvidenceProvider::class)->fromPolicyVersion(
|
||||
version: $version,
|
||||
subjectExternalId: (string) $workspaceSafeExternalId,
|
||||
)->hash;
|
||||
|
||||
return BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => $hash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $version->captured_at?->toIso8601String(),
|
||||
],
|
||||
'identity' => [
|
||||
'strategy' => 'external_id',
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'workspace_subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
'capture_purpose' => 'baseline_capture',
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => $isBuiltIn,
|
||||
'role_permission_count' => $rolePermissionCount,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function createRoleDefinitionInventoryItem(
|
||||
Tenant $tenant,
|
||||
int $inventorySyncRunId,
|
||||
string $externalId,
|
||||
string $displayName,
|
||||
bool $isBuiltIn,
|
||||
int $rolePermissionCount = 1,
|
||||
): InventoryItem {
|
||||
return InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => $displayName,
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
'is_built_in' => $isBuiltIn,
|
||||
'role_permission_count' => $rolePermissionCount,
|
||||
],
|
||||
'last_seen_operation_run_id' => $inventorySyncRunId,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('classifies intune role definition drift as unchanged modified missing and unexpected with deterministic severity', 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' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$baselineCapturedAt = CarbonImmutable::parse('2026-03-08T10:00:00Z');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
'intuneRoleDefinition' => 'succeeded',
|
||||
],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$stablePolicy = createRoleDefinitionPolicy($tenant, 'role-stable', 'Stable Role');
|
||||
$stableBaselineVersion = createRoleDefinitionVersion(
|
||||
policy: $stablePolicy,
|
||||
capturedAt: $baselineCapturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Stable Role', 'Baseline stable role', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createRoleDefinitionVersion(
|
||||
policy: $stablePolicy,
|
||||
capturedAt: $baselineCapturedAt->addMinutes(10),
|
||||
versionNumber: 2,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Stable Role', 'Baseline stable role', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createBaselineRoleDefinitionSnapshotItem($snapshot, $stableBaselineVersion, 'role-stable', 'Stable Role', false);
|
||||
createRoleDefinitionInventoryItem($tenant, (int) $inventorySyncRun->getKey(), 'role-stable', 'Stable Role', false);
|
||||
|
||||
$metadataPolicy = createRoleDefinitionPolicy($tenant, 'role-meta', 'Metadata Role');
|
||||
$metadataBaselineVersion = createRoleDefinitionVersion(
|
||||
policy: $metadataPolicy,
|
||||
capturedAt: $baselineCapturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Metadata Role', 'Baseline description', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createRoleDefinitionVersion(
|
||||
policy: $metadataPolicy,
|
||||
capturedAt: $baselineCapturedAt->addMinutes(12),
|
||||
versionNumber: 2,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Metadata Role', 'Updated description', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createBaselineRoleDefinitionSnapshotItem($snapshot, $metadataBaselineVersion, 'role-meta', 'Metadata Role', false);
|
||||
createRoleDefinitionInventoryItem($tenant, (int) $inventorySyncRun->getKey(), 'role-meta', 'Metadata Role', false);
|
||||
|
||||
$permissionsPolicy = createRoleDefinitionPolicy($tenant, 'role-permissions', 'Permission Role');
|
||||
$permissionsBaselineVersion = createRoleDefinitionVersion(
|
||||
policy: $permissionsPolicy,
|
||||
capturedAt: $baselineCapturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Permission Role', 'Baseline permissions', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createRoleDefinitionVersion(
|
||||
policy: $permissionsPolicy,
|
||||
capturedAt: $baselineCapturedAt->addMinutes(14),
|
||||
versionNumber: 2,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Permission Role', 'Baseline permissions', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
'Microsoft.Intune/deviceConfigurations/delete',
|
||||
]),
|
||||
);
|
||||
createBaselineRoleDefinitionSnapshotItem($snapshot, $permissionsBaselineVersion, 'role-permissions', 'Permission Role', false);
|
||||
createRoleDefinitionInventoryItem($tenant, (int) $inventorySyncRun->getKey(), 'role-permissions', 'Permission Role', false);
|
||||
|
||||
$missingPolicy = createRoleDefinitionPolicy($tenant, 'role-missing', 'Missing Role');
|
||||
$missingBaselineVersion = createRoleDefinitionVersion(
|
||||
policy: $missingPolicy,
|
||||
capturedAt: $baselineCapturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Missing Role', 'Baseline missing role', false, [
|
||||
'Microsoft.Intune/deviceCompliancePolicies/read',
|
||||
]),
|
||||
);
|
||||
createBaselineRoleDefinitionSnapshotItem($snapshot, $missingBaselineVersion, 'role-missing', 'Missing Role', false);
|
||||
|
||||
$unexpectedPolicy = createRoleDefinitionPolicy($tenant, 'role-unexpected', 'Unexpected Role');
|
||||
createRoleDefinitionVersion(
|
||||
policy: $unexpectedPolicy,
|
||||
capturedAt: $baselineCapturedAt->addMinutes(16),
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Unexpected Role', 'Unexpected current role', true, [
|
||||
'Microsoft.Intune/deviceCompliancePolicies/read',
|
||||
]),
|
||||
);
|
||||
createRoleDefinitionInventoryItem($tenant, (int) $inventorySyncRun->getKey(), 'role-unexpected', 'Unexpected Role', true);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'assignment-noise',
|
||||
'policy_type' => 'intuneRoleAssignment',
|
||||
'display_name' => 'Assignment Noise',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleAssignment',
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$compareRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBeNull();
|
||||
expect(data_get($compareRun->context, 'baseline_compare.rbac_role_definitions'))->toBe([
|
||||
'total_compared' => 5,
|
||||
'unchanged' => 1,
|
||||
'modified' => 2,
|
||||
'missing' => 1,
|
||||
'unexpected' => 1,
|
||||
]);
|
||||
expect(data_get($compareRun->context, 'findings.counts_by_change_type'))->toBe([
|
||||
'different_version' => 2,
|
||||
'missing_policy' => 1,
|
||||
'unexpected_policy' => 1,
|
||||
]);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->get()
|
||||
->keyBy(fn (Finding $finding): string => (string) data_get($finding->evidence_jsonb, 'display_name'));
|
||||
|
||||
expect($findings)->toHaveCount(4);
|
||||
expect($findings->has('Stable Role'))->toBeFalse();
|
||||
expect($findings->has('Metadata Role'))->toBeTrue();
|
||||
expect($findings->has('Permission Role'))->toBeTrue();
|
||||
expect($findings->has('Missing Role'))->toBeTrue();
|
||||
expect($findings->has('Unexpected Role'))->toBeTrue();
|
||||
|
||||
expect($findings['Metadata Role']->severity)->toBe(Finding::SEVERITY_LOW);
|
||||
expect(data_get($findings['Metadata Role']->evidence_jsonb, 'change_type'))->toBe('different_version');
|
||||
|
||||
expect($findings['Permission Role']->severity)->toBe(Finding::SEVERITY_HIGH);
|
||||
expect(data_get($findings['Permission Role']->evidence_jsonb, 'change_type'))->toBe('different_version');
|
||||
|
||||
expect($findings['Missing Role']->severity)->toBe(Finding::SEVERITY_HIGH);
|
||||
expect(data_get($findings['Missing Role']->evidence_jsonb, 'change_type'))->toBe('missing_policy');
|
||||
|
||||
expect($findings['Unexpected Role']->severity)->toBe(Finding::SEVERITY_MEDIUM);
|
||||
expect(data_get($findings['Unexpected Role']->evidence_jsonb, 'change_type'))->toBe('unexpected_policy');
|
||||
});
|
||||
|
||||
it('treats a recreated same-name role definition with a new id as missing plus unexpected drift', 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' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$baselineCapturedAt = CarbonImmutable::parse('2026-03-08T11:00:00Z');
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $baselineCapturedAt,
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: [
|
||||
'deviceConfiguration' => 'succeeded',
|
||||
'intuneRoleDefinition' => 'succeeded',
|
||||
],
|
||||
foundationTypes: ['intuneRoleDefinition'],
|
||||
);
|
||||
|
||||
$baselinePolicy = createRoleDefinitionPolicy($tenant, 'role-old-id', 'Security Reader');
|
||||
$baselineVersion = createRoleDefinitionVersion(
|
||||
policy: $baselinePolicy,
|
||||
capturedAt: $baselineCapturedAt,
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Security Reader', 'Baseline role definition', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createBaselineRoleDefinitionSnapshotItem($snapshot, $baselineVersion, 'role-old-id', 'Security Reader', false);
|
||||
|
||||
$currentPolicy = createRoleDefinitionPolicy($tenant, 'role-new-id', 'Security Reader');
|
||||
createRoleDefinitionVersion(
|
||||
policy: $currentPolicy,
|
||||
capturedAt: $baselineCapturedAt->addMinutes(5),
|
||||
versionNumber: 1,
|
||||
snapshot: rbacRoleDefinitionSnapshot('Security Reader', 'Recreated role definition', false, [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
]),
|
||||
);
|
||||
createRoleDefinitionInventoryItem($tenant, (int) $inventorySyncRun->getKey(), 'role-new-id', 'Security Reader', false);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$compareRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($compareRun))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.rbac_role_definitions'))->toBe([
|
||||
'total_compared' => 2,
|
||||
'unchanged' => 0,
|
||||
'modified' => 0,
|
||||
'missing' => 1,
|
||||
'unexpected' => 1,
|
||||
]);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->get();
|
||||
|
||||
expect($findings)->toHaveCount(2);
|
||||
expect($findings->pluck('severity')->sort()->values()->all())->toBe([
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_MEDIUM,
|
||||
]);
|
||||
expect($findings->pluck('evidence_jsonb.change_type')->sort()->values()->all())->toBe([
|
||||
'missing_policy',
|
||||
'unexpected_policy',
|
||||
]);
|
||||
});
|
||||
@ -207,6 +207,192 @@
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
});
|
||||
|
||||
it('records no_drift_detected and unchanged RBAC summary counts when intune role definitions match baseline', 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' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$snapshotCapturedAt = CarbonImmutable::parse('2026-03-08T12:00:00Z');
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => $snapshotCapturedAt,
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => 'rbac-role-stable',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'display_name' => 'Stable RBAC Role',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => 1,
|
||||
'captured_at' => $snapshotCapturedAt,
|
||||
'snapshot' => [
|
||||
'displayName' => 'Stable RBAC Role',
|
||||
'description' => 'Stable role',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => ['Microsoft.Intune/deviceConfigurations/read'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'platform' => 'all',
|
||||
'version_number' => 2,
|
||||
'captured_at' => $snapshotCapturedAt->addMinutes(5),
|
||||
'snapshot' => $baselineVersion->snapshot,
|
||||
'assignments' => [],
|
||||
'scope_tags' => [],
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', 'Stable RBAC Role', 'rbac-role-stable');
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
|
||||
'intuneRoleDefinition',
|
||||
'Stable RBAC Role',
|
||||
'rbac-role-stable',
|
||||
);
|
||||
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
expect($workspaceSafeExternalId)->not->toBeNull();
|
||||
|
||||
$baselineHash = app(\App\Services\Baselines\Evidence\ContentEvidenceProvider::class)->fromPolicyVersion(
|
||||
version: $baselineVersion,
|
||||
subjectExternalId: (string) $workspaceSafeExternalId,
|
||||
)->hash;
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => $baselineHash,
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Stable RBAC Role',
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => $baselineVersion->captured_at?->toIso8601String(),
|
||||
],
|
||||
'identity' => [
|
||||
'strategy' => 'external_id',
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'workspace_subject_external_id' => (string) $workspaceSafeExternalId,
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $baselineVersion->getKey(),
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$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' => [
|
||||
'intuneRoleDefinition' => ['status' => 'succeeded'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'rbac-role-stable',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Stable RBAC Role',
|
||||
'category' => 'RBAC',
|
||||
'platform' => 'all',
|
||||
'meta_jsonb' => [
|
||||
'odata_type' => '#microsoft.graph.deviceAndAppManagementRoleDefinition',
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
expect($currentVersion->getKey())->toBeGreaterThan(0);
|
||||
|
||||
$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' => ['intuneRoleDefinition'],
|
||||
],
|
||||
],
|
||||
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);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.rbac_role_definitions'))->toBe([
|
||||
'total_compared' => 1,
|
||||
'unchanged' => 1,
|
||||
'modified' => 0,
|
||||
'missing' => 0,
|
||||
'unexpected' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('records no_drift_detected when full-content compare reuses an older identical version', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
|
||||
115
tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php
Normal file
115
tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows RBAC-specific baseline compare labels and assignment exclusion messaging', 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,
|
||||
'name' => 'RBAC Baseline',
|
||||
'scope_jsonb' => [
|
||||
'policy_types' => [],
|
||||
'foundation_types' => ['intuneRoleDefinition'],
|
||||
],
|
||||
]);
|
||||
|
||||
$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' => 'drift_detected',
|
||||
'rbac_role_definitions' => [
|
||||
'total_compared' => 4,
|
||||
'unchanged' => 1,
|
||||
'modified' => 1,
|
||||
'missing' => 1,
|
||||
'unexpected' => 1,
|
||||
],
|
||||
'coverage' => [
|
||||
'effective_types' => ['intuneRoleDefinition'],
|
||||
'covered_types' => ['intuneRoleDefinition'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
'fidelity' => 'content',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => 'baseline_profile:'.$profile->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'rbac-role-1',
|
||||
'evidence_fidelity' => 'content',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'different_version',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'subject_key' => hash('sha256', 'intuneRoleDefinition|rbac-role-1'),
|
||||
'display_name' => 'Security Reader',
|
||||
'summary' => [
|
||||
'kind' => 'rbac_role_definition',
|
||||
],
|
||||
'baseline' => ['policy_version_id' => 10],
|
||||
'current' => ['policy_version_id' => 11],
|
||||
'rbac_role_definition' => [
|
||||
'diff_kind' => 'permission_change',
|
||||
],
|
||||
'fidelity' => 'content',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'compare_operation_run_id' => 1,
|
||||
'inventory_sync_run_id' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('Intune RBAC Role Definitions')
|
||||
->assertSee('Compared')
|
||||
->assertSee('Modified')
|
||||
->assertSee('Missing')
|
||||
->assertSee('Unexpected')
|
||||
->assertSee('Role Assignments are not included')
|
||||
->assertDontSee('RBAC restore');
|
||||
});
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\CreateBaselineProfile;
|
||||
use App\Models\BaselineProfile;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows only baseline-supported foundation types in the baseline profile scope picker', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateBaselineProfile::class)
|
||||
->assertOk()
|
||||
->assertFormFieldExists('scope_jsonb.foundation_types', function (Select $field): bool {
|
||||
$options = $field->getOptions();
|
||||
|
||||
return $field->isMultiple()
|
||||
&& ($options['assignmentFilter'] ?? null) === 'Assignment Filter'
|
||||
&& ($options['intuneRoleDefinition'] ?? null) === 'Intune RBAC Role Definition'
|
||||
&& ! array_key_exists('intuneRoleAssignment', $options);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists baseline-supported foundation types on baseline profile create', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateBaselineProfile::class)
|
||||
->fillForm([
|
||||
'name' => 'RBAC baseline',
|
||||
'scope_jsonb.policy_types' => [],
|
||||
'scope_jsonb.foundation_types' => ['intuneRoleDefinition'],
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors()
|
||||
->assertNotified();
|
||||
|
||||
$profile = BaselineProfile::query()
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('name', 'RBAC baseline')
|
||||
->sole();
|
||||
|
||||
expect(data_get($profile->scope_jsonb, 'foundation_types'))
|
||||
->toBe(['intuneRoleDefinition']);
|
||||
});
|
||||
|
||||
it('rejects unsupported foundation types when baseline profile scope is submitted', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(CreateBaselineProfile::class)
|
||||
->fillForm([
|
||||
'name' => 'Invalid RBAC baseline',
|
||||
'scope_jsonb.policy_types' => [],
|
||||
'scope_jsonb.foundation_types' => ['intuneRoleAssignment'],
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['scope_jsonb.foundation_types.0' => ['in']]);
|
||||
|
||||
expect(BaselineProfile::query()->where('name', 'Invalid RBAC baseline')->exists())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
|
||||
it('shows captured intune rbac role definition references on the baseline snapshot detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => hash('sha256', 'role-def-1'),
|
||||
'subject_key' => hash('sha256', 'intuneRoleDefinition|role-def-1'),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => hash('sha256', 'rbac-content'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Security Reader',
|
||||
'evidence' => [
|
||||
'observed_at' => '2026-03-09T10:00:00+00:00',
|
||||
],
|
||||
'identity' => [
|
||||
'strategy' => 'external_id',
|
||||
],
|
||||
'version_reference' => [
|
||||
'policy_version_id' => 42,
|
||||
],
|
||||
'rbac' => [
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 2,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Intune RBAC Role Definition References')
|
||||
->assertSee('Security Reader')
|
||||
->assertSee('Custom')
|
||||
->assertSee('Role definition ID')
|
||||
->assertSee('Policy version #42')
|
||||
->assertSee('2');
|
||||
});
|
||||
150
tests/Feature/Filament/FindingViewRbacEvidenceTest.php
Normal file
150
tests/Feature/Filament/FindingViewRbacEvidenceTest.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders readable role definition evidence and no-restore messaging on the finding detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$subjectExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalIdForPolicy(
|
||||
'intuneRoleDefinition',
|
||||
'Security Reader',
|
||||
'rbac-role-1',
|
||||
);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $subjectExternalId,
|
||||
'evidence_fidelity' => 'content',
|
||||
'severity' => Finding::SEVERITY_LOW,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'different_version',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'subject_key' => hash('sha256', 'intuneRoleDefinition|rbac-role-1'),
|
||||
'display_name' => 'Security Reader',
|
||||
'summary' => [
|
||||
'kind' => 'rbac_role_definition',
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => 10,
|
||||
'hash' => 'baseline',
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => 11,
|
||||
'hash' => 'current',
|
||||
],
|
||||
'rbac_role_definition' => [
|
||||
'diff_kind' => 'metadata_only',
|
||||
'diff_fingerprint' => 'rbac-diff-1',
|
||||
'changed_keys' => ['Role definition > Description'],
|
||||
'metadata_keys' => ['Role definition > Description'],
|
||||
'permission_keys' => [],
|
||||
'baseline' => [
|
||||
'normalized' => [
|
||||
'Role definition > Display name' => 'Security Reader',
|
||||
'Role definition > Description' => 'Baseline description',
|
||||
'Role definition > Role source' => 'Custom',
|
||||
],
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
'current' => [
|
||||
'normalized' => [
|
||||
'Role definition > Display name' => 'Security Reader',
|
||||
'Role definition > Description' => 'Updated description',
|
||||
'Role definition > Role source' => 'Custom',
|
||||
],
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
],
|
||||
'fidelity' => 'content',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => 1,
|
||||
'baseline_snapshot_id' => 1,
|
||||
'compare_operation_run_id' => 1,
|
||||
'inventory_sync_run_id' => 1,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Intune RBAC Role Definition drift')
|
||||
->assertSee('Metadata-only change')
|
||||
->assertSee('Changed fields')
|
||||
->assertSee('Role definition > Description')
|
||||
->assertSee('Baseline description')
|
||||
->assertSee('Updated description')
|
||||
->assertSee('Role Assignments are not included')
|
||||
->assertSee('RBAC restore is not supported');
|
||||
});
|
||||
|
||||
it('shows RBAC labels and display-name fallback in the recent drift findings widget', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$subjectExternalId = 'rbac-role-1';
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => $subjectExternalId,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'display_name' => 'Security Reader',
|
||||
'meta_jsonb' => ['etag' => 'E1'],
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $subjectExternalId,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'missing_policy',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'subject_key' => hash('sha256', 'intuneRoleDefinition|rbac-role-1'),
|
||||
'display_name' => 'Security Reader',
|
||||
'summary' => [
|
||||
'kind' => 'rbac_role_definition',
|
||||
],
|
||||
'baseline' => ['policy_version_id' => 10],
|
||||
'current' => ['policy_version_id' => null],
|
||||
'rbac_role_definition' => [
|
||||
'diff_kind' => 'missing',
|
||||
],
|
||||
'fidelity' => 'mixed',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => 1,
|
||||
'baseline_snapshot_id' => 1,
|
||||
'compare_operation_run_id' => 1,
|
||||
'inventory_sync_run_id' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(RecentDriftFindings::class)
|
||||
->assertCanSeeTableRecords([$finding])
|
||||
->assertSee('Security Reader')
|
||||
->assertSee('Intune RBAC Role Definition drift');
|
||||
});
|
||||
@ -74,3 +74,58 @@
|
||||
|
||||
expect($seenFinding->status)->toBe(Finding::STATUS_NEW);
|
||||
});
|
||||
|
||||
it('auto-resolves stale intune role definition drift findings with the shared baseline auto-close flow', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
]);
|
||||
|
||||
$observedAt = CarbonImmutable::parse('2026-02-26T00:00:00Z');
|
||||
CarbonImmutable::setTestNow($observedAt);
|
||||
|
||||
$staleFinding = Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'fingerprint' => 'rbac-stale',
|
||||
'recurrence_key' => 'rbac-stale',
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'rbac-role-stale',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'missing_policy',
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'subject_key' => 'rbac-role-stale',
|
||||
'summary' => [
|
||||
'kind' => 'rbac_role_definition',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$resolvedCount = app(BaselineAutoCloseService::class)->resolveStaleFindings(
|
||||
tenant: $tenant,
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
seenFingerprints: [],
|
||||
currentOperationRunId: (int) $run->getKey(),
|
||||
);
|
||||
|
||||
expect($resolvedCount)->toBe(1);
|
||||
|
||||
$staleFinding->refresh();
|
||||
|
||||
expect($staleFinding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($staleFinding->resolved_reason)->toBe('no_longer_drifting')
|
||||
->and($staleFinding->resolved_at?->toIso8601String())->toBe($observedAt->toIso8601String());
|
||||
});
|
||||
|
||||
@ -83,6 +83,78 @@ function baselineCompareDriftItem(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function baselineCompareRbacDriftItem(
|
||||
int $baselineProfileId,
|
||||
int $compareOperationRunId,
|
||||
string $subjectExternalId,
|
||||
string $subjectKey,
|
||||
string $changeType = 'different_version',
|
||||
string $severity = Finding::SEVERITY_MEDIUM,
|
||||
string $diffFingerprint = 'rbac-diff-1',
|
||||
): array {
|
||||
return [
|
||||
'change_type' => $changeType,
|
||||
'severity' => $severity,
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $subjectExternalId,
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'baseline_hash' => 'baseline',
|
||||
'current_hash' => 'current',
|
||||
'evidence_fidelity' => 'content',
|
||||
'evidence' => [
|
||||
'change_type' => $changeType,
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'subject_key' => $subjectKey,
|
||||
'display_name' => 'Security Reader',
|
||||
'summary' => [
|
||||
'kind' => 'rbac_role_definition',
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => 10,
|
||||
'hash' => 'baseline',
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => 11,
|
||||
'hash' => 'current',
|
||||
],
|
||||
'rbac_role_definition' => [
|
||||
'diff_kind' => 'permission_change',
|
||||
'diff_fingerprint' => $diffFingerprint,
|
||||
'changed_keys' => ['Permission block 1 > Allowed actions'],
|
||||
'metadata_keys' => [],
|
||||
'permission_keys' => ['Permission block 1 > Allowed actions'],
|
||||
'baseline' => [
|
||||
'normalized' => [
|
||||
'Role definition > Display name' => 'Security Reader',
|
||||
'Permission block 1 > Allowed actions' => ['microsoft.intune/devices/read'],
|
||||
],
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
'current' => [
|
||||
'normalized' => [
|
||||
'Role definition > Display name' => 'Security Reader',
|
||||
'Permission block 1 > Allowed actions' => ['microsoft.intune/devices/read', 'microsoft.intune/devices/delete'],
|
||||
],
|
||||
'is_built_in' => false,
|
||||
'role_permission_count' => 1,
|
||||
],
|
||||
],
|
||||
'fidelity' => 'content',
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
'baseline_snapshot_id' => 1,
|
||||
'compare_operation_run_id' => $compareOperationRunId,
|
||||
'inventory_sync_run_id' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
it('reopens a resolved baseline compare drift finding on recurrence and resets due_at', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
@ -365,3 +437,90 @@ function baselineCompareDriftItem(
|
||||
->and($finding->times_seen)->toBe(2)
|
||||
->and($finding->due_at?->toIso8601String())->toBe($initialDueAt?->toIso8601String());
|
||||
});
|
||||
|
||||
it('reopens resolved intune role definition findings without changing their recurrence fingerprint', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$observedAt1 = CarbonImmutable::parse('2026-02-21T00:00:00Z');
|
||||
CarbonImmutable::setTestNow($observedAt1);
|
||||
|
||||
$run1 = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
]);
|
||||
|
||||
invokeBaselineCompareUpsertFindings(
|
||||
job: new CompareBaselineToTenantJob($run1),
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
scopeKey: $scopeKey,
|
||||
driftResults: [
|
||||
baselineCompareRbacDriftItem(
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
compareOperationRunId: (int) $run1->getKey(),
|
||||
subjectExternalId: 'rbac-role-1',
|
||||
subjectKey: 'rbac-role-1',
|
||||
severity: Finding::SEVERITY_HIGH,
|
||||
diffFingerprint: 'rbac-diff-a',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->firstOrFail();
|
||||
|
||||
$fingerprint = (string) $finding->fingerprint;
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
CarbonImmutable::setTestNow($observedAt2);
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
]);
|
||||
|
||||
$upsert = invokeBaselineCompareUpsertFindings(
|
||||
job: new CompareBaselineToTenantJob($run2),
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
scopeKey: $scopeKey,
|
||||
driftResults: [
|
||||
baselineCompareRbacDriftItem(
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
compareOperationRunId: (int) $run2->getKey(),
|
||||
subjectExternalId: 'rbac-role-1',
|
||||
subjectKey: 'rbac-role-1',
|
||||
severity: Finding::SEVERITY_HIGH,
|
||||
diffFingerprint: 'rbac-diff-b',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect($upsert['reopened_count'])->toBe(1);
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect((string) $finding->fingerprint)->toBe($fingerprint)
|
||||
->and((string) $finding->recurrence_key)->toBe($fingerprint)
|
||||
->and($finding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($finding->times_seen)->toBe(2)
|
||||
->and((string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint'))->toBe('rbac-diff-b');
|
||||
});
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Field;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -139,6 +141,32 @@
|
||||
expect((bool) ($context['include_foundations'] ?? false))->toBeTrue();
|
||||
});
|
||||
|
||||
it('describes RBAC items in the include foundations helper text', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ListInventoryItems::class)
|
||||
->mountAction('run_inventory_sync');
|
||||
|
||||
$method = new ReflectionMethod($component->instance(), 'getMountedActionForm');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$form = $method->invoke($component->instance());
|
||||
|
||||
$field = collect($form?->getFlatFields(withHidden: true) ?? [])
|
||||
->first(fn (Field $field): bool => $field->getName() === 'include_foundations');
|
||||
|
||||
$helperText = collect($field?->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
|
||||
->filter(fn (mixed $component): bool => $component instanceof Text)
|
||||
->map(fn (Text $component): string => (string) $component->getContent())
|
||||
->implode(' ');
|
||||
|
||||
expect($helperText)->toBe('Include scope tags, assignment filters, notification templates, and Intune RBAC role definitions and assignments.');
|
||||
});
|
||||
|
||||
it('persists include foundations toggle into the run selection payload', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
@ -302,6 +303,20 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
|
||||
|
||||
$coverage = $run->context['inventory']['coverage']['foundation_types'] ?? [];
|
||||
|
||||
$definitionPolicy = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', 'intuneRoleDefinition')
|
||||
->where('external_id', 'role-def-1')
|
||||
->first();
|
||||
|
||||
expect($definitionPolicy)->not->toBeNull();
|
||||
expect($definitionPolicy?->display_name)->toBe('Help Desk Operator');
|
||||
expect($definitionPolicy?->metadata)->toMatchArray([
|
||||
'foundation_anchor' => true,
|
||||
'foundation_type' => 'intuneRoleDefinition',
|
||||
'capture_mode' => 'immutable_backup',
|
||||
]);
|
||||
|
||||
expect($coverage['intuneRoleDefinition']['status'] ?? null)->toBe('succeeded');
|
||||
expect($coverage['intuneRoleAssignment']['status'] ?? null)->toBe('succeeded');
|
||||
});
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'Tenant A Scope',
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
@ -39,6 +40,7 @@
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'Tenant B Scope',
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenantA, true);
|
||||
@ -47,8 +49,8 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Policy sync')
|
||||
->assertDontSee('Inventory sync');
|
||||
->assertSee('Tenant A Scope')
|
||||
->assertDontSee('Tenant B Scope');
|
||||
});
|
||||
|
||||
test('operation run view is not accessible cross-workspace', function (): void {
|
||||
@ -138,3 +140,44 @@
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('baseline compare runs with RBAC context still require tenant entitlement', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'context' => [
|
||||
'baseline_profile_id' => 42,
|
||||
'baseline_compare' => [
|
||||
'rbac_role_definitions' => [
|
||||
'total_compared' => 3,
|
||||
'unchanged' => 1,
|
||||
'modified' => 1,
|
||||
'missing' => 1,
|
||||
'unexpected' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ final class AssertsDriftEvidenceContract
|
||||
'policy_snapshot',
|
||||
'policy_assignments',
|
||||
'policy_scope_tags',
|
||||
'rbac_role_definition',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -131,3 +131,36 @@
|
||||
->toBe((int) $versionLate->getKey())
|
||||
->and($resolved)->not->toBe((int) $versionEarly->getKey());
|
||||
});
|
||||
|
||||
test('resolves intune role definition versions by external-id identity', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_type' => 'intuneRoleDefinition',
|
||||
'external_id' => 'role-def-42',
|
||||
'display_name' => 'Security Reader',
|
||||
]);
|
||||
|
||||
$capturedAt = CarbonImmutable::parse('2026-03-08 12:00:00.123456');
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'version_number' => 1,
|
||||
'captured_at' => $capturedAt,
|
||||
]);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::forPolicy('intuneRoleDefinition', 'Security Reader', 'role-def-42');
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
$resolved = $this->resolver->resolve(
|
||||
tenant: $tenant,
|
||||
policyType: 'intuneRoleDefinition',
|
||||
subjectKey: (string) $subjectKey,
|
||||
observedAt: $capturedAt->toIso8601String(),
|
||||
);
|
||||
|
||||
expect($resolved)->toBe((int) $version->getKey());
|
||||
});
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
]);
|
||||
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
['type' => 'assignmentFilter', 'label' => 'Assignment Filter'],
|
||||
['type' => 'assignmentFilter', 'label' => 'Assignment Filter', 'baseline_compare' => ['supported' => true]],
|
||||
]);
|
||||
|
||||
$scope = BaselineScope::fromJsonb([
|
||||
@ -35,7 +35,7 @@
|
||||
]);
|
||||
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
['type' => 'assignmentFilter'],
|
||||
['type' => 'assignmentFilter', 'baseline_compare' => ['supported' => true]],
|
||||
]);
|
||||
|
||||
$scope = BaselineScope::fromJsonb([
|
||||
|
||||
@ -89,3 +89,48 @@
|
||||
expect($normalizer->flattenForDiff($firstSnapshot, 'intuneRoleDefinition', 'all'))
|
||||
->toBe($normalizer->flattenForDiff($secondSnapshot, 'intuneRoleDefinition', 'all'));
|
||||
});
|
||||
|
||||
it('classifies metadata-only role definition changes separately from permission changes', function (): void {
|
||||
$normalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
$baselineSnapshot = [
|
||||
'displayName' => 'Custom RBAC Role',
|
||||
'description' => 'Baseline description',
|
||||
'isBuiltIn' => false,
|
||||
'rolePermissions' => [
|
||||
[
|
||||
'resourceActions' => [
|
||||
[
|
||||
'allowedResourceActions' => [
|
||||
'Microsoft.Intune/deviceConfigurations/read',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'roleScopeTagIds' => ['0'],
|
||||
];
|
||||
|
||||
$metadataOnlySnapshot = $baselineSnapshot;
|
||||
$metadataOnlySnapshot['description'] = 'Updated description';
|
||||
|
||||
$permissionChangedSnapshot = $baselineSnapshot;
|
||||
$permissionChangedSnapshot['rolePermissions'][0]['resourceActions'][0]['allowedResourceActions'][] = 'Microsoft.Intune/deviceConfigurations/delete';
|
||||
|
||||
$metadataDiff = $normalizer->classifyDiff($baselineSnapshot, $metadataOnlySnapshot, 'all');
|
||||
$permissionDiff = $normalizer->classifyDiff($baselineSnapshot, $permissionChangedSnapshot, 'all');
|
||||
|
||||
expect($metadataDiff['diff_kind'])->toBe('metadata_only');
|
||||
expect($metadataDiff['changed_keys'])->toBe([
|
||||
'Role definition > Description',
|
||||
]);
|
||||
expect($metadataDiff['metadata_keys'])->toBe([
|
||||
'Role definition > Description',
|
||||
]);
|
||||
expect($metadataDiff['permission_keys'])->toBe([]);
|
||||
expect($metadataDiff['diff_fingerprint'])->not->toBe($permissionDiff['diff_fingerprint']);
|
||||
|
||||
expect($permissionDiff['diff_kind'])->toBe('permission_change');
|
||||
expect($permissionDiff['changed_keys'])->toContain('Permission block 1 > Allowed actions');
|
||||
expect($permissionDiff['permission_keys'])->toContain('Permission block 1 > Allowed actions');
|
||||
});
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
it('exposes baseline-supported foundation types and identity strategies from canonical metadata', function (): void {
|
||||
config()->set('tenantpilot.foundation_types', [
|
||||
[
|
||||
'type' => 'assignmentFilter',
|
||||
'label' => 'Assignment Filter',
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'display_name',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleDefinition',
|
||||
'label' => 'Intune RBAC Role Definition',
|
||||
'baseline_compare' => [
|
||||
'supported' => true,
|
||||
'identity_strategy' => 'external_id',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'intuneRoleAssignment',
|
||||
'label' => 'Intune RBAC Role Assignment',
|
||||
'baseline_compare' => [
|
||||
'supported' => false,
|
||||
'identity_strategy' => 'external_id',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect(collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())->pluck('type')->all())
|
||||
->toBe(['assignmentFilter', 'intuneRoleDefinition']);
|
||||
|
||||
expect(InventoryPolicyTypeMeta::isBaselineSupportedFoundation('intuneRoleDefinition'))->toBeTrue();
|
||||
expect(InventoryPolicyTypeMeta::isBaselineSupportedFoundation('intuneRoleAssignment'))->toBeFalse();
|
||||
expect(InventoryPolicyTypeMeta::baselineCompareIdentityStrategy('intuneRoleDefinition'))->toBe('external_id');
|
||||
expect(InventoryPolicyTypeMeta::baselineCompareIdentityStrategy('assignmentFilter'))->toBe('display_name');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user