a scoring model
This commit is contained in:
parent
bdfc8d4671
commit
8a46341755
17 changed files with 832 additions and 3 deletions
5
dotnet-tools.json
Normal file
5
dotnet-tools.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {}
|
||||
}
|
||||
77
src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs
Normal file
77
src/Yavsc.Org/Controllers/Kyc/ConsultantController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Accessible aux utilisateurs avec le rôle "Consultant".
|
||||
/// Ne voit qu'un score agrégé — jamais de déclarations, jamais d'identité.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST kyc/score
|
||||
/// Body : { subjectEmail } ou { externalToken }
|
||||
/// Retourne uniquement le score agrégé.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
79
src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs
Normal file
79
src/Yavsc.Org/Controllers/Kyc/DeclarantController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Accessible à tout utilisateur authentifié.
|
||||
/// Le déclarant soumet une déclaration sur un sujet pseudonymisé.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[Route("kyc/declare")]
|
||||
public class DeclarantController : Controller
|
||||
{
|
||||
private readonly IModerationPipelineService _pipeline;
|
||||
private readonly ITrustTokenService _tokenService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public DeclarantController(
|
||||
IModerationPipelineService pipeline,
|
||||
ITrustTokenService tokenService,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_pipeline = pipeline;
|
||||
_tokenService = tokenService;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET kyc/declare — formulaire de déclaration
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public IActionResult Index() => View();
|
||||
|
||||
/// <summary>
|
||||
/// POST kyc/declare
|
||||
/// Body : { subjectEmail, content, sentiment }
|
||||
/// Le subjectEmail est immédiatement transformé en token — jamais stocké.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
76
src/Yavsc.Org/Controllers/Kyc/ModerationController.cs
Normal file
76
src/Yavsc.Org/Controllers/Kyc/ModerationController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Accessible uniquement au rôle "Moderator".
|
||||
/// Voit les déclarations en attente + flags, prend les décisions.
|
||||
/// </summary>
|
||||
[Authorize(Roles = "Moderator")]
|
||||
[Route("kyc/moderation")]
|
||||
public class ModerationController : Controller
|
||||
{
|
||||
private readonly IModerationPipelineService _pipeline;
|
||||
|
||||
public ModerationController(IModerationPipelineService pipeline)
|
||||
{
|
||||
_pipeline = pipeline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET kyc/moderation — queue de modération
|
||||
/// Flagged en tête, puis Pending par date de soumission.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var queue = await _pipeline.GetQueueAsync();
|
||||
return View(queue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST kyc/moderation/approve
|
||||
/// </summary>
|
||||
[HttpPost("approve")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Approve(ModerationDecisionModel input)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return RedirectToAction("Index");
|
||||
|
||||
var moderatorId = User.GetUserId();
|
||||
await _pipeline.ApproveAsync(input.DeclarationId, input.ScoreDelta, moderatorId);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST kyc/moderation/reject
|
||||
/// </summary>
|
||||
[HttpPost("reject")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Reject(ModerationDecisionModel input)
|
||||
{
|
||||
var moderatorId = User.GetUserId();
|
||||
await _pipeline.RejectAsync(input.DeclarationId, moderatorId);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST kyc/moderation/redact
|
||||
/// </summary>
|
||||
[HttpPost("redact")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Redact(ModerationDecisionModel input)
|
||||
{
|
||||
var moderatorId = User.GetUserId();
|
||||
await _pipeline.RedactAsync(input.DeclarationId, input.ScoreDelta, moderatorId);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RoleManager<IdentityRole>>();
|
||||
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();
|
||||
|
||||
services.Configure<KycOptions>(builder.Configuration.GetSection("Kyc"));
|
||||
services.AddScoped<ITrustTokenService, TrustTokenService>();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
|
|
|
|||
264
src/Yavsc.Org/Services/ModerationPipelineService.cs
Normal file
264
src/Yavsc.Org/Services/ModerationPipelineService.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Soumet une déclaration : applique les regex, place en queue.
|
||||
/// Retourne la déclaration créée (avec ses flags éventuels).
|
||||
/// </summary>
|
||||
Task<TrustDeclaration> SubmitDeclarationAsync(
|
||||
Guid subjectTokenId,
|
||||
Guid declarantTokenId,
|
||||
string content,
|
||||
DeclarationSentiment sentiment);
|
||||
|
||||
/// <summary>
|
||||
/// Le modérateur approuve : applique le ScoreDelta sur le TrustToken.
|
||||
/// </summary>
|
||||
Task ApproveAsync(long declarationId, int scoreDelta, string moderatorId);
|
||||
|
||||
/// <summary>
|
||||
/// Le modérateur rejette : aucun effet sur le score.
|
||||
/// </summary>
|
||||
Task RejectAsync(long declarationId, string moderatorId);
|
||||
|
||||
/// <summary>
|
||||
/// Le modérateur expurge : contenu effacé, score partiellement appliqué.
|
||||
/// </summary>
|
||||
Task RedactAsync(long declarationId, int scoreDelta, string moderatorId);
|
||||
|
||||
/// <summary>
|
||||
/// Retourne la queue de modération (pending + flagged en priorité).
|
||||
/// </summary>
|
||||
Task<List<ModerationQueueItem>> GetQueueAsync();
|
||||
}
|
||||
|
||||
public class ModerationPipelineService : IModerationPipelineService
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
|
||||
// Cache des patterns actifs — rechargé à chaque soumission
|
||||
// (en prod : IMemoryCache avec invalidation)
|
||||
private List<RegexAlertPattern> _patterns;
|
||||
|
||||
public ModerationPipelineService(ApplicationDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// ── Soumission ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<TrustDeclaration> 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<List<ModerationQueueItem>> 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<DeclarationFlag> RunRegexAnalysis(string content)
|
||||
{
|
||||
var flags = new List<DeclarationFlag>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/Yavsc.Org/Services/TrustTokenService.cs
Normal file
75
src/Yavsc.Org/Services/TrustTokenService.cs
Normal file
|
|
@ -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<KycOptions> 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<TrustToken> 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<TrustToken> 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<TrustToken> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs
Normal file
32
src/Yavsc.Org/ViewModels/Kyc/DeclarationInputModel.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Yavsc.ViewModels.Kyc/DeclarationInputModel.cs
|
||||
namespace Yavsc.ViewModels.Kyc
|
||||
{
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Yavsc.Models.Kyc;
|
||||
|
||||
public class DeclarationInputModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Email du sujet — transformé en token immédiatement, jamais persisté.
|
||||
/// Obligatoire si ExternalToken absent.
|
||||
/// </summary>
|
||||
[EmailAddress]
|
||||
public string SubjectEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Token fourni par un tiers de confiance.
|
||||
/// Prioritaire sur SubjectEmail si présent.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
17
src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs
Normal file
17
src/Yavsc.Org/ViewModels/Kyc/ModerationDecisionModel.cs
Normal file
|
|
@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Entre -10 et +10. Ignoré pour Reject.
|
||||
/// </summary>
|
||||
[Range(-10, 10)]
|
||||
public int ScoreDelta { get; set; }
|
||||
}
|
||||
}
|
||||
40
src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs
Normal file
40
src/Yavsc.Org/ViewModels/Kyc/ModerationQueueItem.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Yavsc.ViewModels.Kyc/ModerationQueueItem.cs
|
||||
namespace Yavsc.ViewModels.Kyc
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Yavsc.Models.Kyc;
|
||||
|
||||
/// <summary>
|
||||
/// Ce que le modérateur voit dans sa queue.
|
||||
/// Toujours pseudonymisé — jamais d'identité réelle.
|
||||
/// </summary>
|
||||
public class ModerationQueueItem
|
||||
{
|
||||
public long DeclarationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Les 8 premiers caractères du TokenHash + "…"
|
||||
/// Suffisant pour distinguer les sujets en queue,
|
||||
/// insuffisant pour identifier quoi que ce soit.
|
||||
/// </summary>
|
||||
public string SubjectTokenHash { get; set; }
|
||||
|
||||
public string Content { get; set; }
|
||||
|
||||
public DeclarationSentiment Sentiment { get; set; }
|
||||
|
||||
public ModerationStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Descriptions des patterns regex qui ont matché.
|
||||
/// Pas les excerpts — le modérateur voit le texte complet de toute façon.
|
||||
/// </summary>
|
||||
public List<string> FlagDescriptions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True si au moins un pattern Blocking a matché —
|
||||
/// permet un affichage prioritaire dans l'UI.
|
||||
/// </summary>
|
||||
public bool HasBlockingFlag { get; set; }
|
||||
}
|
||||
}
|
||||
13
src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs
Normal file
13
src/Yavsc.Org/ViewModels/Kyc/ScoreQuerModel.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
16
src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs
Normal file
16
src/Yavsc.Org/ViewModels/Kyc/TrustScoreViewModel.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Yavsc.ViewModels.Kyc/TrustScoreView.cs
|
||||
namespace Yavsc.ViewModels.Kyc
|
||||
{
|
||||
/// <summary>
|
||||
/// Ce que le CONSULTANT voit. Jamais d'identité, jamais de détail de déclaration.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -74,5 +74,8 @@
|
|||
"ClientId": "[Your ClientId]",
|
||||
"ClientSecret": "[Your ClientSecret]"
|
||||
}
|
||||
},
|
||||
"Kyc": {
|
||||
"HmacSecret": "*** via dotnet user-secrets ou variable d'environnement ***"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
src/Yavsc.Server/Interfaces/ITrustTokenService.cs
Normal file
21
src/Yavsc.Server/Interfaces/ITrustTokenService.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Yavsc.Services.Kyc/ITrustTokenService.cs
|
||||
namespace Yavsc.Services.Kyc
|
||||
{
|
||||
using System.Threading.Tasks;
|
||||
using Yavsc.Models.Kyc;
|
||||
|
||||
public interface ITrustTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Depuis un tiers de confiance : token opaque déjà haché côté tiers.
|
||||
/// On le stocke tel quel.
|
||||
/// </summary>
|
||||
Task<TrustToken> GetOrCreateFromTrustedPartyAsync(string externalToken);
|
||||
|
||||
/// <summary>
|
||||
/// Fallback : depuis un e-mail confirmé.
|
||||
/// On calcule HMAC-SHA256(email, clé_secrète) — jamais l'email stocké.
|
||||
/// </summary>
|
||||
Task<TrustToken> GetOrCreateFromEmailAsync(string confirmedEmail);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ namespace Yavsc.Models
|
|||
using Streaming;
|
||||
using Workflow;
|
||||
using Workflow.Profiles;
|
||||
using Yavsc.Models.Kyc;
|
||||
|
||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
{
|
||||
|
|
@ -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<TrustToken>(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<TrustDeclaration>(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<DeclarationFlag>(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<RegexAlertPattern>(e =>
|
||||
{
|
||||
e.Property(p => p.Pattern).HasMaxLength(500).IsRequired();
|
||||
e.Property(p => p.Description).HasMaxLength(200);
|
||||
e.HasIndex(p => p.IsActive);
|
||||
});
|
||||
|
||||
// ── ModerationLog ────────────────────────────────────────────────────
|
||||
builder.Entity<ModerationLog>(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"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -349,5 +414,11 @@ namespace Yavsc.Models
|
|||
public DbSet<DeviceFlowCodes> DeviceFlowCodes { get; set; }
|
||||
|
||||
public string NOW_SQL { get; private set; }
|
||||
|
||||
public DbSet<TrustToken> TrustTokens { get; set; }
|
||||
public DbSet<TrustDeclaration> TrustDeclarations { get; set; }
|
||||
public DbSet<RegexAlertPattern> RegexAlertPatterns { get; set; }
|
||||
|
||||
public DbSet<ModerationLog> ModerationLogs { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/Yavsc.Server/Models/Kyc/ModerationAction.cs
Normal file
26
src/Yavsc.Server/Models/Kyc/ModerationAction.cs
Normal file
|
|
@ -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 }
|
||||
|
||||
/// <summary>
|
||||
/// Trace immuable de chaque décision de modération.
|
||||
/// Pseudonymisée : ModeratorId est l'Id ASP.NET Identity, pas un nom.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
12
src/Yavsc.Server/Settings/KycOptions.cs
Normal file
12
src/Yavsc.Server/Settings/KycOptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Yavsc.Services.Kyc/KycOptions.cs
|
||||
namespace Yavsc.Services.Kyc
|
||||
{
|
||||
public class KycOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Clé secrète serveur pour le HMAC — jamais en clair dans le code,
|
||||
/// à mettre dans les secrets (user-secrets / env var / vault).
|
||||
/// </summary>
|
||||
public string HmacSecret { get; set; }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue