This commit is contained in:
mrKamoo
2026-09-30 16:27:32 +02:00
commit 310724708f
147 changed files with 21327 additions and 0 deletions
@@ -0,0 +1,148 @@
<?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];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Graph;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Client Microsoft Graph minimal (flux client credentials, permissions « application »).
* Permissions requises, avec consentement administrateur : User.Read.All, GroupMember.Read.All.
*/
class GraphClient
{
public function __construct(private readonly string $tenantId) {}
public function token(): string
{
return Cache::remember("graph-token:{$this->tenantId}", now()->addMinutes(50), function () {
$res = Http::asForm()->timeout(config('signature.graph.timeout'))
->post("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", [
'client_id' => config('signature.graph.client_id'),
'client_secret' => config('signature.graph.client_secret'),
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
]);
if (! $res->successful()) {
throw new RuntimeException('Jeton Graph refusé : '.$res->json('error_description', $res->body()));
}
return $res->json('access_token');
});
}
/** GET sur une URL relative (/users…) ou absolue (@odata.nextLink). */
public function get(string $url, array $headers = []): array
{
$full = str_starts_with($url, 'https://') ? $url : config('signature.graph.base_url').$url;
$res = Http::withToken($this->token())
->withHeaders($headers)
->timeout(config('signature.graph.timeout'))
// Nouvel essai sur limitation (429) ou indisponibilité temporaire de Graph.
->retry(3, 2000, fn ($e) => $e instanceof RequestException && in_array($e->response->status(), [429, 503, 504], true), throw: false)
->get($full);
if (! $res->successful()) {
throw new RuntimeException("Graph {$res->status()} sur {$url} : ".$res->body());
}
return $res->json();
}
/**
* Parcourt toutes les pages d'une collection.
* Retourne [éléments, deltaLink|null].
*/
public function all(string $url, array $headers = []): array
{
$items = [];
$delta = null;
$next = $url;
while ($next) {
$page = $this->get($next, $headers);
array_push($items, ...($page['value'] ?? []));
$next = $page['@odata.nextLink'] ?? null;
$delta = $page['@odata.deltaLink'] ?? $delta;
}
return [$items, $delta];
}
}