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
196 lines
5.1 KiB
PHP
196 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Services\Directory\EntraGroupLabelResolver;
|
|
use Livewire\Component;
|
|
|
|
class PolicyVersionAssignmentsWidget extends Component
|
|
{
|
|
public PolicyVersion $version;
|
|
|
|
public function mount(PolicyVersion $version): void
|
|
{
|
|
$this->version = $version;
|
|
}
|
|
|
|
public function render(): \Illuminate\Contracts\View\View
|
|
{
|
|
return view('livewire.policy-version-assignments-widget', [
|
|
'version' => $this->version,
|
|
'compliance' => $this->complianceNotifications(),
|
|
'groupLabels' => $this->groupLabels(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function groupLabels(): array
|
|
{
|
|
$assignments = $this->version->assignments;
|
|
|
|
if (! is_array($assignments) || $assignments === []) {
|
|
return [];
|
|
}
|
|
|
|
$tenant = rescue(fn () => Tenant::current(), null);
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
$groupIds = [];
|
|
$sourceNames = [];
|
|
|
|
foreach ($assignments as $assignment) {
|
|
if (! is_array($assignment)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $assignment['target'] ?? null;
|
|
|
|
if (! is_array($target)) {
|
|
continue;
|
|
}
|
|
|
|
$groupId = $target['groupId'] ?? null;
|
|
|
|
if (! is_string($groupId) || $groupId === '') {
|
|
continue;
|
|
}
|
|
|
|
$groupIds[] = $groupId;
|
|
|
|
$displayName = $target['group_display_name'] ?? null;
|
|
|
|
if (is_string($displayName) && $displayName !== '') {
|
|
$sourceNames[$groupId] = $displayName;
|
|
}
|
|
}
|
|
|
|
$groupIds = array_values(array_unique($groupIds));
|
|
|
|
if ($groupIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$resolver = app(EntraGroupLabelResolver::class);
|
|
$cached = $resolver->lookupMany($tenant, $groupIds);
|
|
|
|
$labels = [];
|
|
|
|
foreach ($groupIds as $groupId) {
|
|
$cachedName = $cached[strtolower($groupId)] ?? null;
|
|
$labels[$groupId] = EntraGroupLabelResolver::formatLabel($cachedName ?? ($sourceNames[$groupId] ?? null), $groupId);
|
|
}
|
|
|
|
return $labels;
|
|
}
|
|
|
|
/**
|
|
* @return array{total:int,templates:array<int,string>,items:array<int,array{rule_name:?string,template_id:string,template_key:string}>}
|
|
*/
|
|
private function complianceNotifications(): array
|
|
{
|
|
if ($this->version->policy_type !== 'deviceCompliancePolicy') {
|
|
return [
|
|
'total' => 0,
|
|
'templates' => [],
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
$snapshot = $this->version->snapshot;
|
|
|
|
if (! is_array($snapshot)) {
|
|
return [
|
|
'total' => 0,
|
|
'templates' => [],
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
$scheduled = $snapshot['scheduledActionsForRule'] ?? null;
|
|
|
|
if (! is_array($scheduled)) {
|
|
return [
|
|
'total' => 0,
|
|
'templates' => [],
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
$items = [];
|
|
$templateIds = [];
|
|
|
|
foreach ($scheduled as $rule) {
|
|
if (! is_array($rule)) {
|
|
continue;
|
|
}
|
|
|
|
$ruleName = $rule['ruleName'] ?? null;
|
|
$configs = $rule['scheduledActionConfigurations'] ?? null;
|
|
|
|
if (! is_array($configs)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($configs as $config) {
|
|
if (! is_array($config)) {
|
|
continue;
|
|
}
|
|
|
|
if (($config['actionType'] ?? null) !== 'notification') {
|
|
continue;
|
|
}
|
|
|
|
$templateKey = $this->resolveNotificationTemplateKey($config);
|
|
|
|
if ($templateKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$templateId = $config[$templateKey] ?? null;
|
|
|
|
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
|
|
continue;
|
|
}
|
|
|
|
$items[] = [
|
|
'rule_name' => is_string($ruleName) ? $ruleName : null,
|
|
'template_id' => $templateId,
|
|
'template_key' => $templateKey,
|
|
];
|
|
$templateIds[] = $templateId;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total' => count($items),
|
|
'templates' => array_values(array_unique($templateIds)),
|
|
'items' => $items,
|
|
];
|
|
}
|
|
|
|
private function resolveNotificationTemplateKey(array $config): ?string
|
|
{
|
|
if (array_key_exists('notificationTemplateId', $config)) {
|
|
return 'notificationTemplateId';
|
|
}
|
|
|
|
if (array_key_exists('notificationMessageTemplateId', $config)) {
|
|
return 'notificationMessageTemplateId';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function isEmptyGuid(string $value): bool
|
|
{
|
|
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
|
|
}
|
|
}
|