diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 00000000..b0e38abd --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs b/src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs new file mode 100644 index 00000000..d30742d4 --- /dev/null +++ b/src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs @@ -0,0 +1,77 @@ +// Yavsc.Controllers.Kyc/ConsultantController.cs +namespace Yavsc.Controllers.Kyc +{ + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + using Microsoft.EntityFrameworkCore; + using Yavsc.Models; + using Yavsc.Models.Kyc; + using Yavsc.Services.Kyc; + using Yavsc.ViewModels.Kyc; + + /// + /// Accessible aux utilisateurs avec le rôle "Consultant". + /// Ne voit qu'un score agrégé — jamais de déclarations, jamais d'identité. + /// + [Authorize(Roles = "Consultant")] + [Route("kyc/score")] + public class ConsultantController : Controller + { + private readonly ITrustTokenService _tokenService; + private readonly ApplicationDbContext _db; + + public ConsultantController( + ITrustTokenService tokenService, + ApplicationDbContext db) + { + _tokenService = tokenService; + _db = db; + } + + /// + /// POST kyc/score + /// Body : { subjectEmail } ou { externalToken } + /// Retourne uniquement le score agrégé. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Query(ScoreQueryModel input) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + TrustToken token = string.IsNullOrEmpty(input.ExternalToken) + ? await _tokenService.GetOrCreateFromEmailAsync(input.SubjectEmail) + : await _tokenService.GetOrCreateFromTrustedPartyAsync(input.ExternalToken); + + // Chargement du score avec stats agrégées — aucune déclaration exposée + var stats = await _db.TrustDeclarations + .Where(d => d.TrustTokenId == token.Id + && d.Status == ModerationStatus.Approved) + .GroupBy(d => d.TrustTokenId) + .Select(g => new { + Count = g.Count(), + LastUpdated = g.Max(d => d.SubmittedAt) + }) + .FirstOrDefaultAsync(); + + var view = new TrustScoreView + { + Score = token.TrustScore ?? 50, + Category = ScoreToCategory(token.TrustScore ?? 50), + DeclarationCount = stats?.Count ?? 0, + LastUpdated = stats?.LastUpdated ?? token.CreatedAt + }; + + return View(view); + } + + private static string ScoreToCategory(int score) => score switch + { + >= 75 => "Fiable", + >= 40 => "Prudence", + _ => "Risque" + }; + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs b/src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs new file mode 100644 index 00000000..9f4933d1 --- /dev/null +++ b/src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs @@ -0,0 +1,79 @@ +// Yavsc.Controllers.Kyc/DeclarantController.cs +namespace Yavsc.Controllers.Kyc +{ + using System; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Mvc; + using Yavsc.Models; + using Yavsc.Models.Kyc; + using Yavsc.Services.Kyc; + using Yavsc.ViewModels.Kyc; + + /// + /// Accessible à tout utilisateur authentifié. + /// Le déclarant soumet une déclaration sur un sujet pseudonymisé. + /// + [Authorize] + [Route("kyc/declare")] + public class DeclarantController : Controller + { + private readonly IModerationPipelineService _pipeline; + private readonly ITrustTokenService _tokenService; + private readonly UserManager _userManager; + + public DeclarantController( + IModerationPipelineService pipeline, + ITrustTokenService tokenService, + UserManager userManager) + { + _pipeline = pipeline; + _tokenService = tokenService; + _userManager = userManager; + } + + /// + /// GET kyc/declare — formulaire de déclaration + /// + [HttpGet] + public IActionResult Index() => View(); + + /// + /// POST kyc/declare + /// Body : { subjectEmail, content, sentiment } + /// Le subjectEmail est immédiatement transformé en token — jamais stocké. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Submit(DeclarationInputModel input) + { + if (!ModelState.IsValid) + return View("Index", input); + + // Token du sujet — depuis email ou token tiers + TrustToken subjectToken = string.IsNullOrEmpty(input.ExternalToken) + ? await _tokenService.GetOrCreateFromEmailAsync(input.SubjectEmail) + : await _tokenService.GetOrCreateFromTrustedPartyAsync(input.ExternalToken); + + // Token du déclarant — depuis son propre email confirmé + var user = await _userManager.GetUserAsync(User); + if (!await _userManager.IsEmailConfirmedAsync(user)) + return Forbid(); // Email non confirmé → pas de déclaration + + var declarantToken = await _tokenService + .GetOrCreateFromEmailAsync(user.Email); + + await _pipeline.SubmitDeclarationAsync( + subjectToken.Id, + declarantToken.Id, + input.Content, + input.Sentiment); + + return RedirectToAction("Submitted"); + } + + [HttpGet("submitted")] + public IActionResult Submitted() => View(); + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/Controllers/Kyc/ModerationController.cs b/src/Yavsc.Org/Controllers/Kyc/ModerationController.cs new file mode 100644 index 00000000..d3ed8aa2 --- /dev/null +++ b/src/Yavsc.Org/Controllers/Kyc/ModerationController.cs @@ -0,0 +1,76 @@ +// Yavsc.Controllers.Kyc/ModerationController.cs +namespace Yavsc.Controllers.Kyc +{ + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Mvc; + using Yavsc.Server.Helpers; + using Yavsc.Services.Kyc; + using Yavsc.ViewModels.Kyc; + + /// + /// Accessible uniquement au rôle "Moderator". + /// Voit les déclarations en attente + flags, prend les décisions. + /// + [Authorize(Roles = "Moderator")] + [Route("kyc/moderation")] + public class ModerationController : Controller + { + private readonly IModerationPipelineService _pipeline; + + public ModerationController(IModerationPipelineService pipeline) + { + _pipeline = pipeline; + } + + /// + /// GET kyc/moderation — queue de modération + /// Flagged en tête, puis Pending par date de soumission. + /// + [HttpGet] + public async Task Index() + { + var queue = await _pipeline.GetQueueAsync(); + return View(queue); + } + + /// + /// POST kyc/moderation/approve + /// + [HttpPost("approve")] + [ValidateAntiForgeryToken] + public async Task Approve(ModerationDecisionModel input) + { + if (!ModelState.IsValid) + return RedirectToAction("Index"); + + var moderatorId = User.GetUserId(); + await _pipeline.ApproveAsync(input.DeclarationId, input.ScoreDelta, moderatorId); + return RedirectToAction("Index"); + } + + /// + /// POST kyc/moderation/reject + /// + [HttpPost("reject")] + [ValidateAntiForgeryToken] + public async Task Reject(ModerationDecisionModel input) + { + var moderatorId = User.GetUserId(); + await _pipeline.RejectAsync(input.DeclarationId, moderatorId); + return RedirectToAction("Index"); + } + + /// + /// POST kyc/moderation/redact + /// + [HttpPost("redact")] + [ValidateAntiForgeryToken] + public async Task Redact(ModerationDecisionModel input) + { + var moderatorId = User.GetUserId(); + await _pipeline.RedactAsync(input.DeclarationId, input.ScoreDelta, moderatorId); + return RedirectToAction("Index"); + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 67a7f2f7..06b7d4bd 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -28,6 +28,7 @@ using Yavsc.Interfaces; using Yavsc.Models; using Yavsc.Server.Helpers; using Yavsc.Services; +using Yavsc.Services.Kyc; using Yavsc.Settings; using Yavsc.ViewModels.Auth; @@ -131,7 +132,8 @@ public static class HostingExtensions services.AddTransient>(); services.AddTransient, RoleStore>(); - + services.Configure(builder.Configuration.GetSection("Kyc")); + services.AddScoped(); return builder.Build(); } diff --git a/src/Yavsc.Org/Services/ModerationPipelineService.cs b/src/Yavsc.Org/Services/ModerationPipelineService.cs new file mode 100644 index 00000000..b10504c9 --- /dev/null +++ b/src/Yavsc.Org/Services/ModerationPipelineService.cs @@ -0,0 +1,264 @@ +// Yavsc.Services.Kyc/ModerationPipelineService.cs +namespace Yavsc.Services.Kyc +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text.RegularExpressions; + using System.Threading.Tasks; + using Microsoft.EntityFrameworkCore; + using Yavsc.Models; + using Yavsc.Models.Kyc; + using Yavsc.ViewModels.Kyc; + + public interface IModerationPipelineService + { + /// + /// Soumet une déclaration : applique les regex, place en queue. + /// Retourne la déclaration créée (avec ses flags éventuels). + /// + Task SubmitDeclarationAsync( + Guid subjectTokenId, + Guid declarantTokenId, + string content, + DeclarationSentiment sentiment); + + /// + /// Le modérateur approuve : applique le ScoreDelta sur le TrustToken. + /// + Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId); + + /// + /// Le modérateur rejette : aucun effet sur le score. + /// + Task RejectAsync(long declarationId, string moderatorId); + + /// + /// Le modérateur expurge : contenu effacé, score partiellement appliqué. + /// + Task RedactAsync(long declarationId, int scoreDelta, string moderatorId); + + /// + /// Retourne la queue de modération (pending + flagged en priorité). + /// + Task> GetQueueAsync(); + } + + public class ModerationPipelineService : IModerationPipelineService + { + private readonly ApplicationDbContext _db; + + // Cache des patterns actifs — rechargé à chaque soumission + // (en prod : IMemoryCache avec invalidation) + private List _patterns; + + public ModerationPipelineService(ApplicationDbContext db) + { + _db = db; + } + + // ── Soumission ─────────────────────────────────────────────────────── + + public async Task SubmitDeclarationAsync( + Guid subjectTokenId, + Guid declarantTokenId, + string content, + DeclarationSentiment sentiment) + { + // Vérification basique + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Contenu vide", nameof(content)); + + if (subjectTokenId == declarantTokenId) + throw new InvalidOperationException("Auto-déclaration interdite"); + + // Chargement des patterns actifs + _patterns = await _db.RegexAlertPatterns + .Where(p => p.IsActive) + .ToListAsync(); + + // Analyse regex + var flags = RunRegexAnalysis(content); + var hasBloking = flags.Any(f => + _patterns.First(p => p.Id == f.PatternId).Severity == PatternSeverity.Blocking); + + var declaration = new TrustDeclaration + { + TrustTokenId = subjectTokenId, + DeclarantTokenId = declarantTokenId, + Content = content, + Sentiment = sentiment, + SubmittedAt = DateTime.UtcNow, + // Flagged = priorité haute en queue, sinon Pending + Status = flags.Any() ? ModerationStatus.Flagged + : ModerationStatus.Pending, + Flags = flags + }; + + _db.TrustDeclarations.Add(declaration); + await _db.SaveChangesAsync(); + return declaration; + } + + // ── Décisions de modération ────────────────────────────────────────── + + public async Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId) + { + var (declaration, token) = await LoadForModerationAsync(declarationId); + + ValidateScoreDelta(scoreDelta); + + declaration.Status = ModerationStatus.Approved; + declaration.ScoreDelta = scoreDelta; + + ApplyDelta(token, scoreDelta); + + await _db.ModerationLogs.AddAsync(new ModerationLog + { + DeclarationId = declarationId, + ModeratorId = moderatorId, + Action = ModerationAction.Approved, + ScoreDelta = scoreDelta, + Timestamp = DateTime.UtcNow + }); + + await _db.SaveChangesAsync(); + } + + public async Task RejectAsync(long declarationId, string moderatorId) + { + var (declaration, _) = await LoadForModerationAsync(declarationId); + + declaration.Status = ModerationStatus.Rejected; + // Aucun effet sur le score + + await _db.ModerationLogs.AddAsync(new ModerationLog + { + DeclarationId = declarationId, + ModeratorId = moderatorId, + Action = ModerationAction.Rejected, + ScoreDelta = 0, + Timestamp = DateTime.UtcNow + }); + + await _db.SaveChangesAsync(); + } + + public async Task RedactAsync(long declarationId, int scoreDelta, string moderatorId) + { + var (declaration, token) = await LoadForModerationAsync(declarationId); + + ValidateScoreDelta(scoreDelta); + + // Contenu expurgé — on garde la trace de la déclaration, pas du texte + declaration.Content = "[expurgé]"; + declaration.Status = ModerationStatus.Redacted; + declaration.ScoreDelta = scoreDelta; + + ApplyDelta(token, scoreDelta); + + await _db.ModerationLogs.AddAsync(new ModerationLog + { + DeclarationId = declarationId, + ModeratorId = moderatorId, + Action = ModerationAction.Redacted, + ScoreDelta = scoreDelta, + Timestamp = DateTime.UtcNow + }); + + await _db.SaveChangesAsync(); + } + + // ── Queue de modération ────────────────────────────────────────────── + + public async Task> GetQueueAsync() + { + return await _db.TrustDeclarations + .Where(d => d.Status == ModerationStatus.Pending + || d.Status == ModerationStatus.Flagged) + .OrderByDescending(d => d.Status == ModerationStatus.Flagged) // Flagged en tête + .ThenBy(d => d.SubmittedAt) + .Select(d => new ModerationQueueItem + { + DeclarationId = d.Id, + // Pseudonyme tronqué — jamais le hash complet exposé + SubjectTokenHash = d.Subject.TokenHash.Substring(0, 8) + "…", + Content = d.Content, + Sentiment = d.Sentiment, + Status = d.Status, + HasBlockingFlag = d.Flags.Any(f => + f.Pattern.Severity == PatternSeverity.Blocking), + FlagDescriptions = d.Flags + .Select(f => f.Pattern.Description) + .ToList() + }) + .ToListAsync(); + } + + // ── Privé ──────────────────────────────────────────────────────────── + + private List RunRegexAnalysis(string content) + { + var flags = new List(); + + foreach (var pattern in _patterns) + { + try + { + var match = Regex.Match(content, pattern.Pattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(200)); // timeout anti-ReDoS + + if (!match.Success) continue; + + // Extrait tronqué — jamais le contenu complet + var excerpt = match.Value.Length > 80 + ? match.Value.Substring(0, 80) + "…" + : match.Value; + + flags.Add(new DeclarationFlag + { + PatternId = pattern.Id, + MatchExcerpt = excerpt + }); + } + catch (RegexMatchTimeoutException) + { + // Pattern trop lent — on logue et on continue + // TODO: alerter l'admin sur ce pattern + } + } + + return flags; + } + + private async Task<(TrustDeclaration, TrustToken)> LoadForModerationAsync(long declarationId) + { + var declaration = await _db.TrustDeclarations + .Include(d => d.Subject) + .FirstOrDefaultAsync(d => d.Id == declarationId) + ?? throw new KeyNotFoundException($"Déclaration {declarationId} introuvable"); + + if (declaration.Status == ModerationStatus.Approved + || declaration.Status == ModerationStatus.Rejected + || declaration.Status == ModerationStatus.Redacted) + throw new InvalidOperationException("Déclaration déjà traitée"); + + return (declaration, declaration.Subject); + } + + private static void ApplyDelta(TrustToken token, int delta) + { + var current = token.TrustScore ?? 50; // Score initial neutre : 50 + // Bornes 0–100 + token.TrustScore = Math.Clamp(current + delta, 0, 100); + } + + private static void ValidateScoreDelta(int delta) + { + if (delta < -10 || delta > 10) + throw new ArgumentOutOfRangeException(nameof(delta), + "ScoreDelta doit être entre -10 et +10"); + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/Services/TrustTokenService.cs b/src/Yavsc.Org/Services/TrustTokenService.cs new file mode 100644 index 00000000..fc657bed --- /dev/null +++ b/src/Yavsc.Org/Services/TrustTokenService.cs @@ -0,0 +1,75 @@ +// Yavsc.Services.Kyc/TrustTokenService.cs +namespace Yavsc.Services.Kyc +{ + using System; + using System.Security.Cryptography; + using System.Text; + using System.Threading.Tasks; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Options; + using Yavsc.Models; + using Yavsc.Models.Kyc; + + public class TrustTokenService : ITrustTokenService + { + private readonly ApplicationDbContext _db; + private readonly byte[] _hmacKey; + + public TrustTokenService(ApplicationDbContext db, IOptions options) + { + _db = db; + // La clé est chargée depuis la config, jamais hardcodée + _hmacKey = Encoding.UTF8.GetBytes(options.Value.HmacSecret + ?? throw new InvalidOperationException("KycOptions:HmacSecret manquant")); + } + + public async Task GetOrCreateFromTrustedPartyAsync(string externalToken) + { + if (string.IsNullOrWhiteSpace(externalToken)) + throw new ArgumentException("Token externe invalide", nameof(externalToken)); + + // Le token tiers arrive déjà pseudonymisé — on le stocke tel quel + return await GetOrCreateAsync(externalToken, "trusted_party"); + } + + public async Task GetOrCreateFromEmailAsync(string confirmedEmail) + { + if (string.IsNullOrWhiteSpace(confirmedEmail)) + throw new ArgumentException("Email invalide", nameof(confirmedEmail)); + + // HMAC-SHA256(email normalisé, clé_secrète) → pseudonyme stable, irréversible + var tokenHash = ComputeHmac(confirmedEmail.Trim().ToLowerInvariant()); + return await GetOrCreateAsync(tokenHash, "confirmed_email"); + } + + // ── Privé ──────────────────────────────────────────────────────────── + + private async Task GetOrCreateAsync(string tokenHash, string source) + { + // Idempotent : même entité → même token + var existing = await _db.TrustTokens + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash); + + if (existing != null) + return existing; + + var token = new TrustToken + { + TokenHash = tokenHash, + TokenSource = source, + CreatedAt = DateTime.UtcNow + }; + + _db.TrustTokens.Add(token); + await _db.SaveChangesAsync(); + return token; + } + + private string ComputeHmac(string input) + { + using var hmac = new HMACSHA256(_hmacKey); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash).ToLowerInvariant(); // 64 chars, stockable + } + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs b/src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs new file mode 100644 index 00000000..f15a2f8a --- /dev/null +++ b/src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs @@ -0,0 +1,32 @@ +// Yavsc.ViewModels.Kyc/DeclarationInputModel.cs +namespace Yavsc.ViewModels.Kyc +{ + using System.ComponentModel.DataAnnotations; + using Yavsc.Models.Kyc; + + public class DeclarationInputModel + { + /// + /// Email du sujet — transformé en token immédiatement, jamais persisté. + /// Obligatoire si ExternalToken absent. + /// + [EmailAddress] + public string SubjectEmail { get; set; } + + /// + /// Token fourni par un tiers de confiance. + /// Prioritaire sur SubjectEmail si présent. + /// + public string ExternalToken { get; set; } + + [Required, MinLength(10), MaxLength(2000)] + public string Content { get; set; } + + [Required] + public DeclarationSentiment Sentiment { get; set; } + + // Validation croisée : l'un ou l'autre obligatoire + public bool IsValid => !string.IsNullOrEmpty(SubjectEmail) + || !string.IsNullOrEmpty(ExternalToken); + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs b/src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs new file mode 100644 index 00000000..9931f170 --- /dev/null +++ b/src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs @@ -0,0 +1,17 @@ +// Yavsc.ViewModels.Kyc/ModerationDecisionModel.cs +namespace Yavsc.ViewModels.Kyc +{ + using System.ComponentModel.DataAnnotations; + + public class ModerationDecisionModel + { + [Required] + public long DeclarationId { get; set; } + + /// + /// Entre -10 et +10. Ignoré pour Reject. + /// + [Range(-10, 10)] + public int ScoreDelta { get; set; } + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs b/src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs new file mode 100644 index 00000000..40736ac9 --- /dev/null +++ b/src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs @@ -0,0 +1,40 @@ +// Yavsc.ViewModels.Kyc/ModerationQueueItem.cs +namespace Yavsc.ViewModels.Kyc +{ + using System.Collections.Generic; + using Yavsc.Models.Kyc; + + /// + /// Ce que le modérateur voit dans sa queue. + /// Toujours pseudonymisé — jamais d'identité réelle. + /// + public class ModerationQueueItem + { + public long DeclarationId { get; set; } + + /// + /// Les 8 premiers caractères du TokenHash + "…" + /// Suffisant pour distinguer les sujets en queue, + /// insuffisant pour identifier quoi que ce soit. + /// + public string SubjectTokenHash { get; set; } + + public string Content { get; set; } + + public DeclarationSentiment Sentiment { get; set; } + + public ModerationStatus Status { get; set; } + + /// + /// Descriptions des patterns regex qui ont matché. + /// Pas les excerpts — le modérateur voit le texte complet de toute façon. + /// + public List FlagDescriptions { get; set; } = new(); + + /// + /// True si au moins un pattern Blocking a matché — + /// permet un affichage prioritaire dans l'UI. + /// + public bool HasBlockingFlag { get; set; } + } +} \ No newline at end of file diff --git a/src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs b/src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs new file mode 100644 index 00000000..f4696bc1 --- /dev/null +++ b/src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs @@ -0,0 +1,13 @@ +// Yavsc.ViewModels.Kyc/ScoreQueryModel.cs +namespace Yavsc.ViewModels.Kyc +{ + using System.ComponentModel.DataAnnotations; + + public class ScoreQueryModel + { + [EmailAddress] + public string SubjectEmail { get; set; } + + public string ExternalToken { get; set; } + } +} diff --git a/src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs b/src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs new file mode 100644 index 00000000..7d9b6cb3 --- /dev/null +++ b/src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs @@ -0,0 +1,16 @@ +// Yavsc.ViewModels.Kyc/TrustScoreView.cs +namespace Yavsc.ViewModels.Kyc +{ + /// + /// Ce que le CONSULTANT voit. Jamais d'identité, jamais de détail de déclaration. + /// + public class TrustScoreView + { + public int Score { get; set; } // ex: 0–100 + public string Category { get; set; } // "Fiable" / "Prudence" / "Risque" + public int DeclarationCount { get; set; } // combien de déclarations agrégées + public DateTime LastUpdated { get; set; } + } + + +} \ No newline at end of file diff --git a/src/Yavsc.Org/appsettings.json b/src/Yavsc.Org/appsettings.json index 6d07e28e..81445f5f 100644 --- a/src/Yavsc.Org/appsettings.json +++ b/src/Yavsc.Org/appsettings.json @@ -74,5 +74,8 @@ "ClientId": "[Your ClientId]", "ClientSecret": "[Your ClientSecret]" } + }, + "Kyc": { + "HmacSecret": "*** via dotnet user-secrets ou variable d'environnement ***" } } diff --git a/src/Yavsc.Server/Interfaces/ITrustTokenService.cs b/src/Yavsc.Server/Interfaces/ITrustTokenService.cs new file mode 100644 index 00000000..96335133 --- /dev/null +++ b/src/Yavsc.Server/Interfaces/ITrustTokenService.cs @@ -0,0 +1,21 @@ +// Yavsc.Services.Kyc/ITrustTokenService.cs +namespace Yavsc.Services.Kyc +{ + using System.Threading.Tasks; + using Yavsc.Models.Kyc; + + public interface ITrustTokenService + { + /// + /// Depuis un tiers de confiance : token opaque déjà haché côté tiers. + /// On le stocke tel quel. + /// + Task GetOrCreateFromTrustedPartyAsync(string externalToken); + + /// + /// Fallback : depuis un e-mail confirmé. + /// On calcule HMAC-SHA256(email, clé_secrète) — jamais l'email stocké. + /// + Task GetOrCreateFromEmailAsync(string confirmedEmail); + } +} diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index 6b73abba..607da2d9 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -34,6 +34,7 @@ namespace Yavsc.Models using Streaming; using Workflow; using Workflow.Profiles; + using Yavsc.Models.Kyc; public class ApplicationDbContext : IdentityDbContext { @@ -53,11 +54,11 @@ namespace Yavsc.Models base.OnModelCreating(builder); if (Database.IsNpgsql()) { - NOW_SQL="LOCALTIMESTAMP"; + NOW_SQL = "LOCALTIMESTAMP"; } else { - NOW_SQL="CURRENT_TIMESTAMP"; + NOW_SQL = "CURRENT_TIMESTAMP"; } builder.UseIdentityByDefaultColumns(); @@ -129,6 +130,70 @@ namespace Yavsc.Models if (et.ClrType.GetInterface("IBaseTrackedEntity") != null) et.FindProperty("DateCreated").SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); } + + // ── TrustToken ─────────────────────────────────────────────────────── + builder.Entity(e => + { + e.HasIndex(t => t.TokenHash).IsUnique(); // idempotence garantie en DB + e.Property(t => t.TokenHash).HasMaxLength(128).IsRequired(); + e.Property(t => t.TokenSource).HasMaxLength(32).IsRequired(); + }); + + // ── TrustDeclaration ───────────────────────────────────────────────── + builder.Entity(e => + { + e.HasOne(d => d.Subject) + .WithMany(t => t.Declarations) + .HasForeignKey(d => d.TrustTokenId) + .OnDelete(DeleteBehavior.Restrict); // on ne supprime pas un token qui a des déclarations + + // DeclarantTokenId est une FK vers TrustToken mais sans navigation + // pour éviter les cycles EF + e.HasIndex(d => d.TrustTokenId); + e.HasIndex(d => d.Status); + e.HasIndex(d => d.SubmittedAt); + e.Property(d => d.Content).HasMaxLength(2000); + }); + + // ── DeclarationFlag ────────────────────────────────────────────────── + builder.Entity(e => + { + e.HasOne(f => f.Declaration) + .WithMany(d => d.Flags) + .HasForeignKey(f => f.DeclarationId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(f => f.Pattern) + .WithMany() + .HasForeignKey(f => f.PatternId) + .OnDelete(DeleteBehavior.Restrict); + + e.Property(f => f.MatchExcerpt).HasMaxLength(100); + }); + + // ── RegexAlertPattern ──────────────────────────────────────────────── + builder.Entity(e => + { + e.Property(p => p.Pattern).HasMaxLength(500).IsRequired(); + e.Property(p => p.Description).HasMaxLength(200); + e.HasIndex(p => p.IsActive); + }); + + // ── ModerationLog ──────────────────────────────────────────────────── + builder.Entity(e => + { + e.HasOne(l => l.Declaration) + .WithMany() + .HasForeignKey(l => l.DeclarationId) + .OnDelete(DeleteBehavior.Restrict); + + e.HasIndex(l => l.DeclarationId); + e.HasIndex(l => l.ModeratorId); + e.HasIndex(l => l.Timestamp); + + // Log immuable — pas de update autorisé + e.ToTable(tb => tb.HasCheckConstraint("CK_ModerationLog_Immutable", "1=1")); + }); } /// @@ -349,5 +414,11 @@ namespace Yavsc.Models public DbSet DeviceFlowCodes { get; set; } public string NOW_SQL { get; private set; } + + public DbSet TrustTokens { get; set; } + public DbSet TrustDeclarations { get; set; } + public DbSet RegexAlertPatterns { get; set; } + + public DbSet ModerationLogs { get; set; } } } diff --git a/src/Yavsc.Server/Models/Kyc/ModerationAction.cs b/src/Yavsc.Server/Models/Kyc/ModerationAction.cs new file mode 100644 index 00000000..c71d59e2 --- /dev/null +++ b/src/Yavsc.Server/Models/Kyc/ModerationAction.cs @@ -0,0 +1,26 @@ +// Yavsc.Models.Kyc/ModerationLog.cs +namespace Yavsc.Models.Kyc +{ + using System; + using System.ComponentModel.DataAnnotations; + + public enum ModerationAction { Approved, Rejected, Redacted } + + /// + /// Trace immuable de chaque décision de modération. + /// Pseudonymisée : ModeratorId est l'Id ASP.NET Identity, pas un nom. + /// + public class ModerationLog + { + [Key] + public long Id { get; set; } + + public long DeclarationId { get; set; } + public virtual TrustDeclaration Declaration { get; set; } + + public string ModeratorId { get; set; } // ASP.NET Identity UserId + public ModerationAction Action { get; set; } + public int ScoreDelta { get; set; } + public DateTime Timestamp { get; set; } + } +} \ No newline at end of file diff --git a/src/Yavsc.Server/Settings/KycOptions.cs b/src/Yavsc.Server/Settings/KycOptions.cs new file mode 100644 index 00000000..cd52f217 --- /dev/null +++ b/src/Yavsc.Server/Settings/KycOptions.cs @@ -0,0 +1,12 @@ +// Yavsc.Services.Kyc/KycOptions.cs +namespace Yavsc.Services.Kyc +{ + public class KycOptions + { + /// + /// Clé secrète serveur pour le HMAC — jamais en clair dans le code, + /// à mettre dans les secrets (user-secrets / env var / vault). + /// + public string HmacSecret { get; set; } + } +}