Premier commit
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user