190 lines
8.6 KiB
C#
190 lines
8.6 KiB
C#
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<AzureAdOptions> options,
|
|
ILogger<EntraSyncService> 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<JsonElement>();
|
|
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<SIFinanceDbContext>();
|
|
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<int> ResoudreResponsablesAsync(SIFinanceDbContext db, List<JsonElement> 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;
|
|
}
|