using System.Net.Http.Headers; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SIFinance.Shared; using SIFinance.Shared.Entities; namespace SIFinance.Api; // Synchronise périodiquement les membres du groupe Entra ID (AzureAd:GroupId) dans // core.utilisateurs : création des nouveaux membres (Profil par défaut "DEMANDEUR", // à ajuster manuellement en base pour les profils Finance), mise à jour des noms/ // emails, désactivation de ceux qui ont quitté le groupe. Remplace la résolution // "just-in-time" à la connexion — un utilisateur doit être membre du groupe pour // pouvoir se connecter (voir EntraAuthEndpoints). public class EntraSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, EntraGraphTokenProvider tokenProvider, IOptions options, ILogger logger) : BackgroundService { private static readonly TimeSpan Interval = TimeSpan.FromMinutes(30); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await SyncOnce(stoppingToken); using var timer = new PeriodicTimer(Interval); while (await timer.WaitForNextTickAsync(stoppingToken)) { await SyncOnce(stoppingToken); } } private async Task SyncOnce(CancellationToken ct) { var opts = options.Value; if (string.IsNullOrWhiteSpace(opts.GroupId) || string.IsNullOrWhiteSpace(opts.ClientSecret)) { logger.LogWarning("Synchro Entra ID ignorée — configuration AzureAd incomplète (GroupId/ClientSecret manquants)."); return; } try { var token = await tokenProvider.GetTokenAsync(ct); var http = httpClientFactory.CreateClient(); var membres = new List(); string? url = $"https://graph.microsoft.com/v1.0/groups/{opts.GroupId}/members" + "?$select=id,givenName,surname,mail,userPrincipalName,accountEnabled&$top=999"; while (url is not null) { var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await http.SendAsync(request, ct); response.EnsureSuccessStatusCode(); using var stream = await response.Content.ReadAsStreamAsync(ct); var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct); membres.AddRange(json.RootElement.GetProperty("value").EnumerateArray().Select(e => e.Clone())); url = json.RootElement.TryGetProperty("@odata.nextLink", out var next) ? next.GetString() : null; } var valides = membres.Where(EstMembreValide).ToList(); var emailsActifs = valides.Select(m => GetStr(m, "mail")!.Trim().ToLowerInvariant()).ToHashSet(); using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var existants = await db.Utilisateurs.ToDictionaryAsync(u => u.Email.Trim().ToLowerInvariant(), ct); var crees = 0; var mis = 0; foreach (var m in valides) { var email = GetStr(m, "mail")!.Trim().ToLowerInvariant(); var oid = GetStr(m, "id") ?? ""; var prenom = GetStr(m, "givenName") ?? ""; var nom = GetStr(m, "surname") ?? ""; if (existants.TryGetValue(email, out var existant)) { existant.AzureOid = oid; if (!string.IsNullOrWhiteSpace(nom)) existant.Nom = nom; if (!string.IsNullOrWhiteSpace(prenom)) existant.Prenom = prenom; existant.Actif = true; mis++; } else { db.Utilisateurs.Add(new Utilisateur { AzureOid = oid, Nom = nom, Prenom = prenom, Email = email, Profil = "DEMANDEUR", Actif = true, DateCreation = DateTime.UtcNow, }); crees++; } } var desactives = 0; foreach (var (email, existant) in existants) { if (existant.Actif && !emailsActifs.Contains(email)) { existant.Actif = false; desactives++; } } // SaveChanges ici pour que les nouveaux utilisateurs aient un Id avant la // résolution des liens responsable→collaborateur juste après (impossible de // référencer un Utilisateur pas encore enregistré en base). await db.SaveChangesAsync(ct); var responsablesResolus = await ResoudreResponsablesAsync(db, valides, token, http, ct); logger.LogInformation( "Synchro Entra ID terminée : {Total} membres valides, {Crees} créés, {Mis} mis à jour, {Desactives} désactivés, {Responsables} liens responsable résolus.", valides.Count, crees, mis, desactives, responsablesResolus); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Échec de la synchro Entra ID."); } } // Interroge le manager Entra ID de chaque membre (GET /users/{id}/manager) et // résout son Id interne via l'email — le manager doit lui-même déjà exister comme // Utilisateur (généralement le cas, les managers étant aussi membres du groupe // synchronisé). Un appel par membre plutôt qu'un batch Graph : plus simple/robuste, // le coût (~une centaine d'appels toutes les 30 min, en tâche de fond) est négligeable. private async Task ResoudreResponsablesAsync(SIFinanceDbContext db, List valides, string token, HttpClient http, CancellationToken ct) { var utilisateurs = await db.Utilisateurs.ToDictionaryAsync(u => u.Email.Trim().ToLowerInvariant(), ct); var resolus = 0; foreach (var m in valides) { var oid = GetStr(m, "id"); var email = GetStr(m, "mail")?.Trim().ToLowerInvariant(); if (oid is null || email is null || !utilisateurs.TryGetValue(email, out var collaborateur)) continue; try { var request = new HttpRequestMessage(HttpMethod.Get, $"https://graph.microsoft.com/v1.0/users/{oid}/manager?$select=id,mail,userPrincipalName"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await http.SendAsync(request, ct); if (!response.IsSuccessStatusCode) continue; // pas de manager (404) ou erreur ponctuelle — on laisse ResponsableId tel quel using var managerStream = await response.Content.ReadAsStreamAsync(ct); using var managerDoc = await JsonDocument.ParseAsync(managerStream, cancellationToken: ct); var managerEmail = (GetStr(managerDoc.RootElement, "mail") ?? GetStr(managerDoc.RootElement, "userPrincipalName"))?.Trim().ToLowerInvariant(); if (managerEmail is null || !utilisateurs.TryGetValue(managerEmail, out var responsable)) continue; if (responsable.Id == collaborateur.Id) continue; // garde-fou, ne devrait pas arriver if (collaborateur.ResponsableId != responsable.Id) { collaborateur.ResponsableId = responsable.Id; resolus++; } } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Échec de résolution du manager pour {Email}.", email); } } await db.SaveChangesAsync(ct); return resolus; } private static bool EstMembreValide(JsonElement m) { var mail = GetStr(m, "mail"); if (string.IsNullOrWhiteSpace(mail)) return false; // accountEnabled absent (utilisateur non hydraté) est traité comme actif ; // seule une valeur explicite "false" exclut le membre. return !m.TryGetProperty("accountEnabled", out var p) || p.ValueKind != JsonValueKind.False; } private static string? GetStr(JsonElement e, string name) => e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; }