Premier commit

This commit is contained in:
2026-09-11 14:55:41 +02:00
commit 8ed04aae89
134 changed files with 36627 additions and 0 deletions
+189
View File
@@ -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;
}