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
405 lines
26 KiB
PHP
405 lines
26 KiB
PHP
@php
|
|
$state = $getState() ?? [];
|
|
$isFoundationEntry = function ($item) {
|
|
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
|
};
|
|
|
|
if (is_array($state) && array_key_exists('items', $state)) {
|
|
$foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry);
|
|
$policyItems = collect($state['items'] ?? [])->values();
|
|
$results = $state;
|
|
} else {
|
|
$results = $state;
|
|
$foundationItems = collect($results)->filter($isFoundationEntry);
|
|
$policyItems = collect($results)->reject($isFoundationEntry);
|
|
}
|
|
|
|
$tenant = rescue(fn () => \App\Models\Tenant::current(), null);
|
|
$groupLabelResolver = $tenant ? app(\App\Services\Directory\EntraGroupLabelResolver::class) : null;
|
|
|
|
$formatGroupId = function ($groupId, $fallbackName = null) use ($tenant, $groupLabelResolver) {
|
|
if (! is_string($groupId) || $groupId === '') {
|
|
return null;
|
|
}
|
|
|
|
$cachedName = null;
|
|
|
|
if ($tenant && $groupLabelResolver) {
|
|
$cached = $groupLabelResolver->lookupMany($tenant, [$groupId]);
|
|
$cachedName = $cached[strtolower($groupId)] ?? null;
|
|
}
|
|
|
|
$name = is_string($fallbackName) && $fallbackName !== '' ? $fallbackName : null;
|
|
|
|
return \App\Services\Directory\EntraGroupLabelResolver::formatLabel($cachedName ?? $name, $groupId);
|
|
};
|
|
@endphp
|
|
|
|
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
|
|
<p class="text-sm text-gray-600">No results recorded.</p>
|
|
@else
|
|
@php
|
|
$needsAttention = $policyItems->contains(function ($item) {
|
|
$status = $item['status'] ?? null;
|
|
|
|
return in_array($status, ['partial', 'manual_required'], true);
|
|
});
|
|
@endphp
|
|
|
|
<div class="space-y-4">
|
|
@if ($foundationItems->isNotEmpty())
|
|
<div class="space-y-2">
|
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
|
|
@foreach ($foundationItems as $item)
|
|
@php
|
|
$decision = $item['decision'] ?? 'mapped_existing';
|
|
$decisionColor = match ($decision) {
|
|
'created' => 'text-green-700 bg-green-100 border-green-200',
|
|
'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200',
|
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
|
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
|
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
};
|
|
@endphp
|
|
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
|
<div class="flex items-center justify-between text-sm text-gray-800">
|
|
<span class="font-semibold">{{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }}</span>
|
|
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $decisionColor }}">
|
|
{{ $decision }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 text-xs text-gray-600">
|
|
{{ $item['type'] ?? 'foundation' }}
|
|
</div>
|
|
@if (! empty($item['targetName']))
|
|
<div class="mt-1 text-xs text-gray-600">
|
|
Target: {{ $item['targetName'] }}
|
|
</div>
|
|
@endif
|
|
@if (! empty($item['reason']))
|
|
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
|
{{ $item['reason'] }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
|
|
@if ($needsAttention)
|
|
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
|
Some settings could not be applied automatically. Review the per-setting details below.
|
|
</div>
|
|
@endif
|
|
|
|
@if ($policyItems->isNotEmpty())
|
|
<div class="space-y-3">
|
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
|
|
@foreach ($policyItems as $item)
|
|
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
|
<div class="flex items-center justify-between text-sm">
|
|
<div class="font-semibold text-gray-900">
|
|
{{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }}
|
|
<span class="ml-2 text-xs text-gray-500">{{ $item['policy_type'] ?? '' }}</span>
|
|
</div>
|
|
@php
|
|
$status = $item['status'] ?? 'unknown';
|
|
$restoreMode = $item['restore_mode'] ?? null;
|
|
$statusColor = match ($status) {
|
|
'applied' => 'text-green-700 bg-green-100 border-green-200',
|
|
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200',
|
|
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
|
|
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
|
|
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
};
|
|
@endphp
|
|
<div class="flex items-center gap-2">
|
|
@if ($restoreMode === 'preview-only')
|
|
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900">
|
|
preview-only
|
|
</span>
|
|
@endif
|
|
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
|
|
{{ $status }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
@php
|
|
$itemReason = $item['reason'] ?? null;
|
|
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
|
|
|
if ($itemReason === 'preview_only') {
|
|
$itemReason = 'Preview-only policy type; execution skipped.';
|
|
}
|
|
@endphp
|
|
|
|
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
|
<div class="mt-2 text-sm text-gray-800">
|
|
{{ $itemReason }}
|
|
</div>
|
|
@endif
|
|
|
|
@if (! empty($item['assignment_summary']) && is_array($item['assignment_summary']))
|
|
@php
|
|
$summary = $item['assignment_summary'];
|
|
$assignmentOutcomes = $item['assignment_outcomes'] ?? [];
|
|
$assignmentIssues = collect($assignmentOutcomes)
|
|
->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true))
|
|
->values();
|
|
@endphp
|
|
|
|
<div class="mt-2 text-xs text-gray-700">
|
|
Assignments: {{ (int) ($summary['success'] ?? 0) }} success •
|
|
{{ (int) ($summary['failed'] ?? 0) }} failed •
|
|
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
|
</div>
|
|
|
|
@if ($assignmentIssues->isNotEmpty())
|
|
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
|
<summary class="cursor-pointer font-semibold">Assignment details</summary>
|
|
<div class="mt-2 space-y-2">
|
|
@foreach ($assignmentIssues as $outcome)
|
|
@php
|
|
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
|
$outcomeColor = match ($outcomeStatus) {
|
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
|
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
};
|
|
$assignmentGroupId = $outcome['group_id']
|
|
?? ($outcome['assignment']['target']['groupId'] ?? null);
|
|
$assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null);
|
|
$mappedGroupId = $outcome['mapped_group_id'] ?? null;
|
|
$mappedGroupLabel = $formatGroupId(is_string($mappedGroupId) ? $mappedGroupId : null);
|
|
@endphp
|
|
|
|
<div class="rounded border border-amber-200 bg-white p-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold text-gray-900">
|
|
Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
|
|
</div>
|
|
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
|
{{ $outcomeStatus }}
|
|
</span>
|
|
</div>
|
|
|
|
@if (! empty($outcome['mapped_group_id']))
|
|
<div class="mt-1 text-[11px] text-gray-800">
|
|
Mapped to: {{ $mappedGroupLabel ?? $outcome['mapped_group_id'] }}
|
|
</div>
|
|
@endif
|
|
|
|
@php
|
|
$outcomeReason = $outcome['reason'] ?? null;
|
|
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
|
|
@endphp
|
|
|
|
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
|
|
<div class="mt-1 text-[11px] text-gray-800">
|
|
{{ $outcomeReason }}
|
|
</div>
|
|
@endif
|
|
|
|
@if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code']))
|
|
<div class="mt-1 text-[11px] text-amber-900">
|
|
<div>{{ $outcome['graph_error_message'] ?? 'Unknown error' }}</div>
|
|
@if (! empty($outcome['graph_error_code']))
|
|
<div class="mt-0.5 text-amber-800">Code: {{ $outcome['graph_error_code'] }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</details>
|
|
@endif
|
|
@endif
|
|
|
|
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
|
|
@php
|
|
$summary = $item['compliance_action_summary'];
|
|
$complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null)
|
|
? $item['compliance_action_outcomes']
|
|
: [];
|
|
$complianceEntries = collect($complianceOutcomes)->values();
|
|
@endphp
|
|
|
|
<div class="mt-2 text-xs text-gray-700">
|
|
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped •
|
|
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
|
</div>
|
|
|
|
@if ($complianceEntries->isNotEmpty())
|
|
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
|
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
|
|
<div class="mt-2 space-y-2">
|
|
@foreach ($complianceEntries as $outcome)
|
|
@php
|
|
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
|
$outcomeColor = match ($outcomeStatus) {
|
|
'mapped' => 'text-green-700 bg-green-100 border-green-200',
|
|
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
};
|
|
@endphp
|
|
<div class="rounded border border-amber-200 bg-white p-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold text-gray-900">
|
|
Template {{ $outcome['template_id'] ?? 'unknown' }}
|
|
</div>
|
|
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
|
{{ $outcomeStatus }}
|
|
</span>
|
|
</div>
|
|
@if (! empty($outcome['rule_name']))
|
|
<div class="mt-1 text-[11px] text-gray-700">
|
|
Rule: {{ $outcome['rule_name'] }}
|
|
</div>
|
|
@endif
|
|
@if (! empty($outcome['mapped_template_id']))
|
|
<div class="mt-1 text-[11px] text-gray-700">
|
|
Mapped to: {{ $outcome['mapped_template_id'] }}
|
|
</div>
|
|
@endif
|
|
@if (! empty($outcome['reason']))
|
|
<div class="mt-1 text-[11px] text-gray-800">
|
|
{{ $outcome['reason'] }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</details>
|
|
@endif
|
|
@endif
|
|
|
|
@if (! empty($item['created_policy_id']))
|
|
@php
|
|
$createdMode = $item['created_policy_mode'] ?? null;
|
|
$createdMessage = match ($createdMode) {
|
|
'metadata_only' => 'New policy created (metadata only). Apply settings manually.',
|
|
'created' => 'New policy created.',
|
|
default => 'New policy created (manual cleanup required).',
|
|
};
|
|
@endphp
|
|
<div class="mt-2 text-xs text-amber-800">
|
|
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}
|
|
</div>
|
|
@endif
|
|
|
|
@if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code']))
|
|
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
|
<div class="font-semibold">Graph error</div>
|
|
<div>{{ $item['graph_error_message'] ?? 'Unknown error' }}</div>
|
|
@if (! empty($item['graph_error_code']))
|
|
<div class="mt-1 text-[11px] text-amber-800">Code: {{ $item['graph_error_code'] }}</div>
|
|
@endif
|
|
@if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
|
|
<details class="mt-1">
|
|
<summary class="cursor-pointer text-[11px] font-semibold text-amber-800">Details</summary>
|
|
<div class="mt-1 space-y-0.5 text-[11px] text-amber-800">
|
|
@if (! empty($item['graph_method']))
|
|
<div>method: {{ $item['graph_method'] }}</div>
|
|
@endif
|
|
@if (! empty($item['graph_path']))
|
|
<div>path: {{ $item['graph_path'] }}</div>
|
|
@endif
|
|
@if (! empty($item['graph_request_id']))
|
|
<div>request-id: {{ $item['graph_request_id'] }}</div>
|
|
@endif
|
|
@if (! empty($item['graph_client_request_id']))
|
|
<div>client-request-id: {{ $item['graph_client_request_id'] }}</div>
|
|
@endif
|
|
</div>
|
|
</details>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
@if (! empty($item['settings_apply']) && is_array($item['settings_apply']))
|
|
@php
|
|
$apply = $item['settings_apply'];
|
|
$total = (int) ($apply['total'] ?? 0);
|
|
$applied = (int) ($apply['applied'] ?? 0);
|
|
$failed = (int) ($apply['failed'] ?? 0);
|
|
$manual = (int) ($apply['manual_required'] ?? 0);
|
|
$issues = $apply['issues'] ?? [];
|
|
@endphp
|
|
|
|
<div class="mt-2 text-xs text-gray-700">
|
|
Settings applied: {{ $applied }}/{{ $total }}
|
|
@if ($failed > 0 || $manual > 0)
|
|
• {{ $failed }} failed • {{ $manual }} manual
|
|
@endif
|
|
</div>
|
|
|
|
@if (! empty($issues))
|
|
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
|
<summary class="cursor-pointer font-semibold">Settings requiring attention</summary>
|
|
<div class="mt-2 space-y-2">
|
|
@foreach ($issues as $issue)
|
|
@php
|
|
$issueStatus = $issue['status'] ?? 'unknown';
|
|
$issueColor = match ($issueStatus) {
|
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
|
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
|
};
|
|
@endphp
|
|
<div class="rounded border border-amber-200 bg-white p-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold text-gray-900">
|
|
Setting {{ $issue['setting_id'] ?? 'unknown' }}
|
|
</div>
|
|
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $issueColor }}">
|
|
{{ $issueStatus }}
|
|
</span>
|
|
</div>
|
|
|
|
@if (! empty($issue['reason']))
|
|
<div class="mt-1 text-[11px] text-gray-800">
|
|
{{ $issue['reason'] }}
|
|
</div>
|
|
@endif
|
|
|
|
@if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code']))
|
|
<div class="mt-1 text-[11px] text-amber-900">
|
|
<div>{{ $issue['graph_error_message'] ?? 'Unknown error' }}</div>
|
|
@if (! empty($issue['graph_error_code']))
|
|
<div class="mt-0.5 text-amber-800">Code: {{ $issue['graph_error_code'] }}</div>
|
|
@endif
|
|
@if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id']))
|
|
<div class="mt-0.5 space-y-0.5 text-amber-800">
|
|
@if (! empty($issue['graph_request_id']))
|
|
<div>request-id: {{ $issue['graph_request_id'] }}</div>
|
|
@endif
|
|
@if (! empty($issue['graph_client_request_id']))
|
|
<div>client-request-id: {{ $issue['graph_client_request_id'] }}</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</details>
|
|
@endif
|
|
@endif
|
|
|
|
@if (! empty($item['platform']))
|
|
<div class="mt-2 text-[11px] text-gray-500">
|
|
Platform: {{ $item['platform'] }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|