149 lines
6.3 KiB
PHP
149 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Graph;
|
|
|
|
use App\Models\DirectoryGroup;
|
|
use App\Models\DirectoryUser;
|
|
use App\Models\Tenant;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Réplique utilisateurs et groupes Entra dans la base locale.
|
|
* - Utilisateurs : requête delta (incrémentale), complète si --full ou si le deltaLink a expiré.
|
|
* - Groupes : ceux dont le nom commence par le préfixe configuré, avec appartenance transitive.
|
|
*/
|
|
class DirectorySync
|
|
{
|
|
private const USER_SELECT = 'id,displayName,givenName,surname,jobTitle,department,companyName,officeLocation,businessPhones,mobilePhone,mail,userPrincipalName,streetAddress,postalCode,city,accountEnabled';
|
|
|
|
public function run(Tenant $tenant, bool $full = false): array
|
|
{
|
|
$graph = new GraphClient($tenant->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];
|
|
}
|
|
}
|