diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 1f79f2a..eed8956 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -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 diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php index 1c80b02..ab491d3 100644 --- a/app/Filament/Pages/BaselineCompareLanding.php +++ b/app/Filament/Pages/BaselineCompareLanding.php @@ -83,6 +83,9 @@ class BaselineCompareLanding extends Page /** @var array|null */ public ?array $evidenceGapsTopReasons = null; + /** @var array|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, diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index dd43e36..abb84fe 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -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(); diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php index f297d7f..d41642f 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php @@ -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; diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php index 03c44e6..0a51672 100644 --- a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php +++ b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php @@ -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; diff --git a/app/Filament/Resources/BaselineSnapshotResource.php b/app/Filament/Resources/BaselineSnapshotResource.php index c1d1808..b915b26 100644 --- a/app/Filament/Resources/BaselineSnapshotResource.php +++ b/app/Filament/Resources/BaselineSnapshotResource.php @@ -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 + */ + 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'; diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 51c34f4..aebceba 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -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() diff --git a/app/Filament/Resources/FindingResource/Pages/ViewFinding.php b/app/Filament/Resources/FindingResource/Pages/ViewFinding.php index c797ea9..f1375f9 100644 --- a/app/Filament/Resources/FindingResource/Pages/ViewFinding.php +++ b/app/Filament/Resources/FindingResource/Pages/ViewFinding.php @@ -23,6 +23,6 @@ protected function getHeaderActions(): array public function getSubheading(): string|Htmlable|null { - return FindingResource::redactionIntegrityNoteForRecord($this->getRecord()); + return FindingResource::findingSubheading($this->getRecord()); } } diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index f28b127..661a2bc 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -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']) diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php index 56afba8..3c5eb58 100644 --- a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -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() diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php index 040a5f9..bdd8a8c 100644 --- a/app/Jobs/CaptureBaselineSnapshotJob.php +++ b/app/Jobs/CaptureBaselineSnapshotJob.php @@ -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 * } @@ -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 $inventoryByKey */ + /** @var array $inventoryByKey */ $inventoryByKey = []; /** @var array $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 $resolvedEvidence * @param array $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'], + ], ], ]; } diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php index 6885617..3001915 100644 --- a/app/Jobs/CompareBaselineToTenantJob.php +++ b/app/Jobs/CompareBaselineToTenantJob.php @@ -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 $severityMapping * @return array{ * drift: array}>, - * evidence_gaps: array + * evidence_gaps: array, + * 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 $baselineMeta + * @param array $currentMeta + * @param array{ + * baseline: array, + * current: array, + * changed_keys: list, + * metadata_keys: list, + * permission_keys: list, + * diff_kind: string, + * diff_fingerprint: string + * }|null $roleDefinitionDiff + * @return array{ + * diff_kind: string, + * diff_fingerprint: string, + * changed_keys: list, + * metadata_keys: list, + * permission_keys: list, + * baseline: array{normalized: array, is_built_in: mixed, role_permission_count: mixed}, + * current: array{normalized: array, 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 $meta + * @return array + */ + 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 $baselineNormalized + * @param array $currentNormalized + * @return list + */ + 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 $keys + * @return list + */ + 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, + * current: array, + * changed_keys: list, + * metadata_keys: list, + * permission_keys: list, + * 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 ...$gaps * @return array diff --git a/app/Services/Baselines/BaselineSnapshotIdentity.php b/app/Services/Baselines/BaselineSnapshotIdentity.php index 30ca6e6..a0b02ff 100644 --- a/app/Services/Baselines/BaselineSnapshotIdentity.php +++ b/app/Services/Baselines/BaselineSnapshotIdentity.php @@ -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. * diff --git a/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php b/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php index 81f61b5..b40cb12 100644 --- a/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php +++ b/app/Services/Baselines/Evidence/BaselinePolicyVersionResolver.php @@ -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 $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; diff --git a/app/Services/Baselines/Evidence/ContentEvidenceProvider.php b/app/Services/Baselines/Evidence/ContentEvidenceProvider.php index 8b189a6..a56af62 100644 --- a/app/Services/Baselines/Evidence/ContentEvidenceProvider.php +++ b/app/Services/Baselines/Evidence/ContentEvidenceProvider.php @@ -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, ); } diff --git a/app/Services/Intune/IntuneRoleDefinitionNormalizer.php b/app/Services/Intune/IntuneRoleDefinitionNormalizer.php index 22a1cb4..f0ac65c 100644 --- a/app/Services/Intune/IntuneRoleDefinitionNormalizer.php +++ b/app/Services/Intune/IntuneRoleDefinitionNormalizer.php @@ -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, + * current: array, + * changed_keys: list, + * metadata_keys: list, + * permission_keys: list, + * 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, denied: array, conditions: array, 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 '); + } } diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 85b2f6d..9f57ea6 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -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> $warnings * @return null|array @@ -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); diff --git a/app/Support/Baselines/BaselineCompareStats.php b/app/Support/Baselines/BaselineCompareStats.php index 907f373..7c61cdb 100644 --- a/app/Support/Baselines/BaselineCompareStats.php +++ b/app/Support/Baselines/BaselineCompareStats.php @@ -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, diff --git a/app/Support/Baselines/BaselineScope.php b/app/Support/Baselines/BaselineScope.php index fb79670..d348efe 100644 --- a/app/Support/Baselines/BaselineScope.php +++ b/app/Support/Baselines/BaselineScope.php @@ -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 !== '') diff --git a/app/Support/Baselines/BaselineSubjectKey.php b/app/Support/Baselines/BaselineSubjectKey.php index 919941c..777175a 100644 --- a/app/Support/Baselines/BaselineSubjectKey.php +++ b/app/Support/Baselines/BaselineSubjectKey.php @@ -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); + } } diff --git a/app/Support/Inventory/InventoryPolicyTypeMeta.php b/app/Support/Inventory/InventoryPolicyTypeMeta.php index 6f61892..f6aad42 100644 --- a/app/Support/Inventory/InventoryPolicyTypeMeta.php +++ b/app/Support/Inventory/InventoryPolicyTypeMeta.php @@ -43,6 +43,19 @@ public static function foundations(): array return is_array($foundations) ? $foundations : []; } + /** + * @return array> + */ + 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> */ @@ -123,4 +136,43 @@ public static function isHighRisk(?string $type): bool return is_string($risk) && str_contains($risk, 'high'); } + + /** + * @return array + */ + 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); + } } diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 9facd97..33422cc 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -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', + ], ], ], diff --git a/lang/en/baseline-compare.php b/lang/en/baseline-compare.php index 4c16cd9..b8b7757 100644 --- a/lang/en/baseline-compare.php +++ b/lang/en/baseline-compare.php @@ -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', diff --git a/lang/en/findings.php b/lang/en/findings.php new file mode 100644 index 0000000..6bf2d66 --- /dev/null +++ b/lang/en/findings.php @@ -0,0 +1,31 @@ + [ + '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.', + ], +]; diff --git a/resources/views/filament/infolists/entries/rbac-role-definition-diff.blade.php b/resources/views/filament/infolists/entries/rbac-role-definition-diff.blade.php new file mode 100644 index 0000000..006ac01 --- /dev/null +++ b/resources/views/filament/infolists/entries/rbac-role-definition-diff.blade.php @@ -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 + +
+ +
+ + {{ __('findings.rbac.' . $diffKind) }} + + @if ($changedKeys !== []) + + {{ __('findings.rbac.changed_fields') }}: {{ count($changedKeys) }} + + @endif +
+ + @if ($changedKeys !== []) +
+
{{ __('findings.rbac.changed_fields') }}
+
+ @foreach ($changedKeys as $changedKey) + {{ $changedKey }} + @endforeach +
+
+ @endif + +
+ @foreach ([ + ['heading' => __('findings.rbac.baseline'), 'rows' => $baselineRows], + ['heading' => __('findings.rbac.current'), 'rows' => $currentRows], + ] as $section) +
+
{{ $section['heading'] }}
+ + @if ($section['rows'] === []) +
{{ __('findings.rbac.absent') }}
+ @else +
+ @foreach ($section['rows'] as $label => $value) +
+
{{ $label }}
+
{{ $stringify($value) }}
+
+ @endforeach +
+ @endif +
+ @endforeach +
+ +
+ {{ __('findings.rbac.assignments_excluded') }} + {{ __('findings.rbac.restore_unsupported') }} +
+
+
diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php index 86525d7..55ccbd5 100644 --- a/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -112,6 +112,32 @@ class="w-fit" @endif + @if ($hasRbacRoleDefinitionSummary) + + + {{ __('baseline-compare.rbac_summary_description') }} + + +
+ + {{ __('baseline-compare.rbac_summary_compared') }}: {{ (int) ($rbacRoleDefinitionSummary['total_compared'] ?? 0) }} + + + {{ __('baseline-compare.rbac_summary_unchanged') }}: {{ (int) ($rbacRoleDefinitionSummary['unchanged'] ?? 0) }} + + + {{ __('baseline-compare.rbac_summary_modified') }}: {{ (int) ($rbacRoleDefinitionSummary['modified'] ?? 0) }} + + + {{ __('baseline-compare.rbac_summary_missing') }}: {{ (int) ($rbacRoleDefinitionSummary['missing'] ?? 0) }} + + + {{ __('baseline-compare.rbac_summary_unexpected') }}: {{ (int) ($rbacRoleDefinitionSummary['unexpected'] ?? 0) }} + +
+
+ @endif + {{-- Coverage warnings banner --}} @if ($state === 'ready' && $hasCoverageWarnings)