Summary Adds a tenant-scoped Entra Groups “Directory Cache” to enable DB-only group name resolution across the app (no render-time Graph calls), plus sync runs + observability. What’s included • Entra Groups cache • New entra_groups storage (tenant-scoped) for group metadata (no memberships). • Retention semantics: groups become stale / retained per spec (no hard delete on first miss). • Group Sync Runs • New “Group Sync Runs” UI (list + detail) with tenant isolation (403 on cross-tenant access). • Manual “Sync Groups” action: creates/reuses a run, dispatches job, DB notification with “View run” link. • Scheduled dispatcher command wired in console.php. • DB-only label resolution (US3) • Shared EntraGroupLabelResolver with safe fallback Unresolved (…last8) and UUID guarding. • Refactors to prefer cached names (no typeahead / no live Graph) in: • Tenant RBAC group selects • Policy version assignments widget • Restore results + restore wizard group mapping labels Safety / Guardrails • No render-time Graph calls: fail-hard guard test verifies UI paths don’t call GraphClientInterface during page render. • Tenant isolation & authorization: policies + scoped queries enforced (cross-tenant access returns 403, not 404). • Data minimization: only group metadata is cached (no membership/owners). Tests / Verification • Added/updated tests under tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups: • Start sync → run record + job dispatch + upserts • Retention purge semantics • Scheduled dispatch wiring • Render-time Graph guard • UI/resource access isolation • Ran: • ./vendor/bin/pint --dirty • ./vendor/bin/sail artisan test tests/Feature/DirectoryGroups • ./vendor/bin/sail artisan test tests/Unit/DirectoryGroups Notes / Follow-ups • UI polish remains (picker/lookup UX, consistent progress widget/toasts across modules, navigation grouping). • pr-gate checklist still has non-blocking open items (mostly UX/ops polish); requirements gate is green. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #57
176 lines
8.8 KiB
PHP
176 lines
8.8 KiB
PHP
<div class="space-y-4">
|
|
@php
|
|
$scopeTags = $version->scope_tags['names'] ?? [];
|
|
@endphp
|
|
@if(!empty($scopeTags))
|
|
<x-filament::section heading="Scope Tags">
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach($scopeTags as $tag)
|
|
<span class="inline-flex items-center rounded-md bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 ring-1 ring-inset ring-primary-700/10 dark:bg-primary-400/10 dark:text-primary-400 dark:ring-primary-400/30">
|
|
{{ $tag }}
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@if($version->assignments && count($version->assignments) > 0)
|
|
<x-filament::section
|
|
heading="Assignments"
|
|
:description="'Captured with this version on ' . $version->captured_at->format('M d, Y H:i')"
|
|
>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Summary</h4>
|
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
{{ count($version->assignments) }} assignment(s)
|
|
@php
|
|
$hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false;
|
|
@endphp
|
|
@if($hasOrphaned)
|
|
<span class="text-warning-600 dark:text-warning-400">(includes orphaned groups)</span>
|
|
@endif
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
|
|
<div class="mt-2 space-y-2">
|
|
@foreach($version->assignments as $assignment)
|
|
@php
|
|
$target = $assignment['target'] ?? [];
|
|
$type = $target['@odata.type'] ?? '';
|
|
$typeKey = strtolower((string) $type);
|
|
$intent = $assignment['intent'] ?? 'apply';
|
|
|
|
$typeName = match (true) {
|
|
str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group',
|
|
str_contains($typeKey, 'groupassignmenttarget') => 'Include group',
|
|
str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users',
|
|
str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices',
|
|
default => 'Unknown',
|
|
};
|
|
|
|
$groupId = $target['groupId'] ?? null;
|
|
$groupName = $target['group_display_name'] ?? null;
|
|
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
|
|
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
|
$filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'));
|
|
$filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none';
|
|
$filterName = $target['assignment_filter_name'] ?? null;
|
|
$filterLabel = $filterName ?? $filterId;
|
|
@endphp
|
|
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<span class="text-gray-600 dark:text-gray-400">•</span>
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
|
|
|
|
@if($groupId)
|
|
@php
|
|
$groupLabel = $groupLabels[$groupId] ?? \App\Services\Directory\EntraGroupLabelResolver::formatLabel(
|
|
is_string($groupName) ? $groupName : null,
|
|
(string) $groupId,
|
|
);
|
|
@endphp
|
|
<span class="text-gray-600 dark:text-gray-400">:</span>
|
|
@if($groupOrphaned)
|
|
<span class="text-warning-600 dark:text-warning-400">
|
|
⚠️ {{ $groupLabel }}
|
|
</span>
|
|
@elseif($groupLabel)
|
|
<span class="text-gray-700 dark:text-gray-300">
|
|
{{ $groupLabel }}
|
|
</span>
|
|
@else
|
|
<span class="text-gray-700 dark:text-gray-300">
|
|
{{ $groupId }}
|
|
</span>
|
|
@endif
|
|
@endif
|
|
|
|
@if($filterLabel)
|
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
|
Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
|
|
</span>
|
|
@endif
|
|
|
|
<span class="ml-auto text-xs text-gray-500 dark:text-gray-500">({{ $intent }})</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</x-filament::section>
|
|
@else
|
|
<x-filament::section heading="Assignments">
|
|
@php
|
|
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
|
|
$assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false;
|
|
$assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null;
|
|
@endphp
|
|
|
|
@if($assignmentsFetchFailed)
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
Assignments could not be fetched from Microsoft Graph.
|
|
</p>
|
|
@if($assignmentsFetchError)
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
|
{{ $assignmentsFetchError }}
|
|
</p>
|
|
@endif
|
|
@elseif($assignmentsFetched)
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
No assignments found for this version.
|
|
</p>
|
|
@else
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
Assignments were not captured for this version.
|
|
</p>
|
|
@endif
|
|
|
|
@php
|
|
$hasBackupItem = $version->policy->backupItems()
|
|
->whereNotNull('assignments')
|
|
->where('created_at', '<=', $version->captured_at)
|
|
->exists();
|
|
@endphp
|
|
@if($hasBackupItem)
|
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
💡 Assignment data may be available in related backup items.
|
|
</p>
|
|
@endif
|
|
</x-filament::section>
|
|
@endif
|
|
|
|
@php
|
|
$complianceTotal = $compliance['total'] ?? 0;
|
|
$complianceTemplates = $compliance['templates'] ?? [];
|
|
@endphp
|
|
@if($complianceTotal > 0)
|
|
<x-filament::section
|
|
heading="Compliance notifications"
|
|
:description="$complianceTotal . ' action(s) • ' . count($complianceTemplates) . ' template(s)'"
|
|
>
|
|
<div class="space-y-2">
|
|
@foreach($compliance['items'] ?? [] as $item)
|
|
@php
|
|
$ruleName = $item['rule_name'] ?? null;
|
|
$templateId = $item['template_id'] ?? null;
|
|
@endphp
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<span class="text-gray-600 dark:text-gray-400">•</span>
|
|
<span class="font-medium text-gray-900 dark:text-white">
|
|
{{ $ruleName ?: 'Default rule' }}
|
|
</span>
|
|
@if($templateId)
|
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
|
Template: {{ $templateId }}
|
|
</span>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</x-filament::section>
|
|
@endif
|
|
</div>
|