entra_tenant_id); $stats = ['users' => 0, 'removed' => 0, 'groups' => 0, 'memberships' => 0]; try { [$stats['users'], $stats['removed']] = $this->syncUsers($tenant, $graph, $full); [$stats['groups'], $stats['memberships']] = $this->syncGroups($tenant, $graph); $tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'ok']); } catch (Throwable $e) { $tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'erreur : '.mb_substr($e->getMessage(), 0, 200)]); Log::error('Synchro annuaire en échec', ['tenant' => $tenant->id, 'error' => $e->getMessage()]); throw $e; } return $stats; } private function syncUsers(Tenant $tenant, GraphClient $graph, bool $full): array { $start = (! $full && $tenant->users_delta_link) ? $tenant->users_delta_link : '/users/delta?$select='.self::USER_SELECT; try { [$items, $deltaLink] = $graph->all($start); } catch (Throwable $e) { if ($full || ! $tenant->users_delta_link) { throw $e; } // deltaLink expiré (410) ou invalide : on repart d'une synchro complète. [$items, $deltaLink] = $graph->all('/users/delta?$select='.self::USER_SELECT); $full = true; } $now = now(); $upserted = 0; $removed = 0; $seen = []; foreach ($items as $u) { if (isset($u['@removed'])) { $removed += DirectoryUser::where('tenant_id', $tenant->id)->where('entra_oid', $u['id'])->delete(); continue; } // En delta, seules les propriétés modifiées sont renvoyées : on ne met à jour que celles-là. $attrs = array_filter([ 'display_name' => $u['displayName'] ?? null, 'given_name' => $u['givenName'] ?? null, 'surname' => $u['surname'] ?? null, 'job_title' => $u['jobTitle'] ?? null, 'department' => $u['department'] ?? null, 'company_name' => $u['companyName'] ?? null, 'office_location' => $u['officeLocation'] ?? null, 'business_phone' => array_key_exists('businessPhones', $u) ? ($u['businessPhones'][0] ?? null) : null, 'mobile_phone' => $u['mobilePhone'] ?? null, 'mail' => isset($u['mail']) ? strtolower($u['mail']) : null, 'user_principal_name' => isset($u['userPrincipalName']) ? strtolower($u['userPrincipalName']) : null, 'street_address' => $u['streetAddress'] ?? null, 'postal_code' => $u['postalCode'] ?? null, 'city' => $u['city'] ?? null, ], fn ($v, $k) => array_key_exists($this->graphKey($k), $u), ARRAY_FILTER_USE_BOTH); if (array_key_exists('accountEnabled', $u)) { $attrs['account_enabled'] = (bool) $u['accountEnabled']; } $attrs['synced_at'] = $now; $user = DirectoryUser::withTrashed()->firstOrNew(['tenant_id' => $tenant->id, 'entra_oid' => $u['id']]); $user->fill($attrs); $user->deleted_at = null; $user->save(); $seen[] = $u['id']; $upserted++; } if ($full) { // Synchro complète : tout ce qui n'a pas été vu n'existe plus dans Entra. $removed += DirectoryUser::where('tenant_id', $tenant->id)->whereNotIn('entra_oid', $seen)->delete(); } $tenant->users_delta_link = $deltaLink; $tenant->save(); return [$upserted, $removed]; } private function syncGroups(Tenant $tenant, GraphClient $graph): array { $prefix = str_replace("'", "''", config('signature.graph.group_prefix')); [$groups] = $graph->all("/groups?\$filter=startswith(displayName,'{$prefix}')&\$select=id,displayName"); $memberships = 0; $keep = []; foreach ($groups as $g) { $group = DirectoryGroup::updateOrCreate( ['tenant_id' => $tenant->id, 'entra_oid' => $g['id']], ['display_name' => $g['displayName'], 'synced_at' => now()], ); $keep[] = $group->id; [$members] = $graph->all( "/groups/{$g['id']}/transitiveMembers/microsoft.graph.user?\$select=id&\$count=true&\$top=999", ['ConsistencyLevel' => 'eventual'], ); $userIds = DirectoryUser::where('tenant_id', $tenant->id) ->whereIn('entra_oid', array_column($members, 'id')) ->pluck('id')->all(); DB::transaction(fn () => $group->members()->sync($userIds)); $memberships += count($userIds); } DirectoryGroup::where('tenant_id', $tenant->id)->whereNotIn('id', $keep)->delete(); return [count($groups), $memberships]; } private function graphKey(string $column): string { return [ 'display_name' => 'displayName', 'given_name' => 'givenName', 'surname' => 'surname', 'job_title' => 'jobTitle', 'department' => 'department', 'company_name' => 'companyName', 'office_location' => 'officeLocation', 'business_phone' => 'businessPhones', 'mobile_phone' => 'mobilePhone', 'mail' => 'mail', 'user_principal_name' => 'userPrincipalName', 'street_address' => 'streetAddress', 'postal_code' => 'postalCode', 'city' => 'city', ][$column]; } }