From ff2867d916374ad277464959c02df5d6a404c72f Mon Sep 17 00:00:00 2001 From: Pedro Date: Mon, 8 Jun 2026 12:58:30 +0200 Subject: [PATCH] denuncias --- .../ApiDenuncias/ApiDenuncias.csproj | 2 + .../Configuration/KeyVaultOptions.cs | 2 + .../Configuration/ManualPurgeOptions.cs | 20 + .../Controllers/AuthController.cs | 4 +- .../Controllers/ConfigurationController.cs | 50 ++ .../Controllers/DenunciasController.cs | 73 +- .../Controllers/GestionaController.cs | 4 + .../Controllers/InboxController.cs | 81 +- .../Controllers/PurgeController.cs | 71 ++ .../Helpers/GlobalLeaksJsonEnricher.cs | 15 +- .../ApiDenuncias/Helpers/ReportParser.cs | 180 +++++ Antifraude.Net/ApiDenuncias/Program.cs | 22 +- .../gestiondenuncias_envelope_encryption.sql | 281 +++++++ .../Scripts/gestiondenuncias_schema.sql | 11 + .../Services/AppConfigurationService.cs | 87 ++ .../Services/DenunciaInboxService.cs | 107 ++- .../Services/EncryptedDenunciaStore.cs | 248 +++++- .../Services/EncryptionKeyExceptions.cs | 31 + .../Services/EncryptionKeyMaterial.cs | 12 + .../Services/EnvelopeEncryptionKeyProvider.cs | 321 ++++++++ .../GestionaDocumentWorkflowService.cs | 151 ++-- .../ApiDenuncias/Services/GestionaService.cs | 66 +- .../Services/GlobalLeaksClient.cs | 167 ++-- .../IEnvelopeEncryptionKeyProvider.cs | 8 + .../Services/IFilteredDenunciaStore.cs | 19 + .../Services/InboxTrackingService.cs | 39 +- .../Services/ManualPurgeService.cs | 252 ++++++ .../Services/MySqlConnectionStringProvider.cs | 59 +- .../Services/MySqlDenunciaStore.cs | 749 ++++++++++++++---- .../Services/PendingGlobalLeaksLoginStore.cs | 4 +- Antifraude.Net/ApiDenuncias/appsettings.json | 9 + .../Models/ApiDenunciasDtos.cs | 14 +- .../Models/DenunciaListScope.cs | 10 + .../Models/DenunciasGestiona.cs | 23 +- .../Models/FicherosDenuncias.cs | 10 + .../Models/ImportSummary.cs | 3 +- .../Models/ThirdPartyIdentityData.cs | 34 +- .../Services/IDenunciaStore.cs | 1 + .../Components/Layout/MainLayout.razor | 3 + .../Components/Layout/NavMenu.razor | 8 + .../Components/Pages/Actualizaciones.razor | 386 +++++++-- .../Components/Pages/Configuracion.razor | 320 ++++++++ .../Components/Pages/GestionZip.razor | 76 +- .../Components/Pages/Gestiona.razor | 72 +- .../Components/Pages/Instrucciones.razor | 260 +++--- .../Components/Pages/Pendientes.razor | 268 +++++-- .../Components/Pages/Rechazados.razor | 26 +- Antifraude.Net/GestionaDenunciasAN/Program.cs | 44 +- .../Services/ApiDenunciaStore.cs | 14 +- .../Services/ApiDenunciasClient.cs | 60 +- .../Components/Pages/InfoPersonas.razor | 1 - 51 files changed, 4012 insertions(+), 766 deletions(-) create mode 100644 Antifraude.Net/ApiDenuncias/Configuration/ManualPurgeOptions.cs create mode 100644 Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs create mode 100644 Antifraude.Net/ApiDenuncias/Controllers/PurgeController.cs create mode 100644 Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_envelope_encryption.sql create mode 100644 Antifraude.Net/ApiDenuncias/Services/AppConfigurationService.cs create mode 100644 Antifraude.Net/ApiDenuncias/Services/EncryptionKeyExceptions.cs create mode 100644 Antifraude.Net/ApiDenuncias/Services/EncryptionKeyMaterial.cs create mode 100644 Antifraude.Net/ApiDenuncias/Services/EnvelopeEncryptionKeyProvider.cs create mode 100644 Antifraude.Net/ApiDenuncias/Services/IEnvelopeEncryptionKeyProvider.cs create mode 100644 Antifraude.Net/ApiDenuncias/Services/IFilteredDenunciaStore.cs create mode 100644 Antifraude.Net/ApiDenuncias/Services/ManualPurgeService.cs create mode 100644 Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciaListScope.cs create mode 100644 Antifraude.Net/GestionaDenunciasAN/Components/Pages/Configuracion.razor diff --git a/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj b/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj index accfbb5..6a31dc5 100644 --- a/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj +++ b/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj @@ -8,6 +8,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/Antifraude.Net/ApiDenuncias/Configuration/KeyVaultOptions.cs b/Antifraude.Net/ApiDenuncias/Configuration/KeyVaultOptions.cs index 3a53654..902677c 100644 --- a/Antifraude.Net/ApiDenuncias/Configuration/KeyVaultOptions.cs +++ b/Antifraude.Net/ApiDenuncias/Configuration/KeyVaultOptions.cs @@ -11,4 +11,6 @@ public sealed class KeyVaultOptions public string EncryptionKeySecretName { get; set; } = "denuncias-encryption-key"; public bool AllowLocalEncryptionKeyFallback { get; set; } + + public int EncryptionKeyTimeoutSeconds { get; set; } = 25; } diff --git a/Antifraude.Net/ApiDenuncias/Configuration/ManualPurgeOptions.cs b/Antifraude.Net/ApiDenuncias/Configuration/ManualPurgeOptions.cs new file mode 100644 index 0000000..7985122 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Configuration/ManualPurgeOptions.cs @@ -0,0 +1,20 @@ +namespace ApiDenuncias.Configuration; + +public sealed class ManualPurgeOptions +{ + public const string SectionName = "ManualPurge"; + + public string BaseUrl { get; set; } = ""; + + public string FunctionUrl { get; set; } = "https://func-keymgmt-pre.azurewebsites.net/api/manual_purge"; + + public string ForceRotateUrl { get; set; } = "https://func-keymgmt-pre.azurewebsites.net/api/force_rotate"; + + public string FunctionKeySecretName { get; set; } = "purge-function-key"; + + public bool ReplaceOnManualPurge { get; set; } = true; + + public bool RecoverPartialReplaceFailure { get; set; } = true; + + public int TimeoutSeconds { get; set; } = 30; +} diff --git a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs index 715c74e..c5a8664 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs @@ -80,7 +80,8 @@ public sealed class AuthController : ControllerBase var pending = _pendingLoginStore.Create( prepared.Username, request.Password, - prepared.FinalPassword); + prepared.FinalPassword, + prepared.TokenAnswer); return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc)); } @@ -149,6 +150,7 @@ public sealed class AuthController : ControllerBase var session = await _globalLeaksClient.CompleteLoginAsync( pending.Username, pending.FinalPassword, + pending.TokenAnswer, request.Authcode.Trim(), loginCancellation.Token); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs b/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs new file mode 100644 index 0000000..cd91269 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Controllers/ConfigurationController.cs @@ -0,0 +1,50 @@ +using System.Globalization; +using ApiDenuncias.Services; +using GestionaDenuncias.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ApiDenuncias.Controllers; + +[ApiController] +[Authorize] +[Route("api/configuration")] +public sealed class ConfigurationController : ControllerBase +{ + private readonly AppConfigurationService _configurationService; + + public ConfigurationController(AppConfigurationService configurationService) + { + _configurationService = configurationService; + } + + [HttpGet] + public async Task> Get(CancellationToken cancellationToken) + { + return Ok(await _configurationService.GetAsync(cancellationToken)); + } + + [HttpPut("external-update-cutoff")] + public async Task> SetExternalUpdateCutoff( + UpdateExternalUpdateCutoffRequest request, + CancellationToken cancellationToken) + { + DateOnly? date = null; + if (!string.IsNullOrWhiteSpace(request.Date)) + { + if (!DateOnly.TryParseExact( + request.Date.Trim(), + ["yyyy-MM-dd", "dd/MM/yyyy"], + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed)) + { + return BadRequest(new ApiError("Debes indicar una fecha valida con formato YYYY-MM-DD o DD/MM/AAAA.")); + } + + date = parsed; + } + + return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken)); + } +} diff --git a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs index f2d7ce2..6e2c475 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs @@ -1,6 +1,5 @@ using ApiDenuncias.Services; using GestionaDenuncias.Shared.Models; -using ApiDenuncias.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,11 +11,16 @@ namespace ApiDenuncias.Controllers; public sealed class DenunciasController : ControllerBase { private readonly IDenunciaStore _denunciaStore; + private readonly IFilteredDenunciaStore _filteredDenunciaStore; private readonly UserComplaintAccessService _accessService; - public DenunciasController(IDenunciaStore denunciaStore, UserComplaintAccessService accessService) + public DenunciasController( + IDenunciaStore denunciaStore, + IFilteredDenunciaStore filteredDenunciaStore, + UserComplaintAccessService accessService) { _denunciaStore = denunciaStore; + _filteredDenunciaStore = filteredDenunciaStore; _accessService = accessService; } @@ -28,11 +32,17 @@ public sealed class DenunciasController : ControllerBase } [HttpGet] - public async Task>> GetAll(CancellationToken cancellationToken) + public async Task>> GetAll( + [FromQuery] DenunciaListScope scope, + CancellationToken cancellationToken) { var allowedIds = await GetAllowedIdsAsync(cancellationToken); - var denuncias = await _denunciaStore.GetAllDenunciasAsync(cancellationToken); - return Ok(denuncias.Where(d => allowedIds.Contains(d.Id_Denuncia)).ToList()); + if (allowedIds.Count == 0) + { + return Ok(new List()); + } + + return Ok(await _filteredDenunciaStore.GetDenunciasByIdsAsync(allowedIds, scope, cancellationToken)); } [HttpGet("{denunciaId:int}")] @@ -62,8 +72,12 @@ public sealed class DenunciasController : ControllerBase public async Task>> GetAllFicheros(CancellationToken cancellationToken) { var allowedIds = await GetAllowedIdsAsync(cancellationToken); - var ficheros = await _denunciaStore.GetAllFicherosAsync(cancellationToken); - return Ok(ficheros.Where(f => allowedIds.Contains(f.Id_Denuncia)).ToList()); + if (allowedIds.Count == 0) + { + return Ok(new List()); + } + + return Ok(await _filteredDenunciaStore.GetFicherosByDenunciaIdsAsync(allowedIds, cancellationToken)); } [HttpGet("{denunciaId:int}/ficheros")] @@ -77,6 +91,37 @@ public sealed class DenunciasController : ControllerBase return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken)); } + [HttpGet("{denunciaId:int}/ficheros/content")] + public async Task GetFicheroContent( + int denunciaId, + [FromQuery] string fileName, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return BadRequest(new ApiError("Nombre de fichero obligatorio.")); + } + + if (!await CanAccessAsync(denunciaId, cancellationToken)) + { + return NotFound(); + } + + var ficheros = await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken); + var fichero = ficheros.FirstOrDefault(file => + string.Equals(file.NombreFichero, fileName, StringComparison.Ordinal)); + + if (fichero?.Fichero is not { Length: > 0 } bytes) + { + return NotFound(); + } + + return File( + bytes, + GetAttachmentContentType(fichero.NombreFichero), + enableRangeProcessing: true); + } + [HttpPost("ficheros")] public async Task UpsertFicheros(UpsertFicherosRequest request, CancellationToken cancellationToken) { @@ -132,4 +177,18 @@ public sealed class DenunciasController : ControllerBase private string GetUsername() => User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado."); + + private static string GetAttachmentContentType(string? fileName) + { + return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch + { + ".pdf" => "application/pdf", + ".txt" => "text/plain", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".zip" => "application/zip", + _ => "application/octet-stream" + }; + } } diff --git a/Antifraude.Net/ApiDenuncias/Controllers/GestionaController.cs b/Antifraude.Net/ApiDenuncias/Controllers/GestionaController.cs index 9c268de..0c3b9e9 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/GestionaController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/GestionaController.cs @@ -101,6 +101,10 @@ public sealed class GestionaController : ControllerBase await _gestiona.AsegurarTerceroYEnlazarAsync(request.FileUrl, request.ThirdParty); return Ok(new { ok = true }); } + catch (ArgumentException ex) + { + return BadRequest(new ApiError(ex.Message)); + } catch (InvalidOperationException ex) { return BadRequest(new ApiError(ex.Message)); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs index e224219..cb9c535 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs @@ -11,6 +11,7 @@ namespace ApiDenuncias.Controllers; public sealed class InboxController : ControllerBase { private readonly GlobalLeaksSessionStore _sessionStore; + private readonly PendingGlobalLeaksLoginStore _pendingLoginStore; private readonly GlobalLeaksClient _globalLeaksClient; private readonly DenunciaInboxService _inboxService; private readonly IInboxTrackingService _trackingService; @@ -18,12 +19,14 @@ public sealed class InboxController : ControllerBase public InboxController( GlobalLeaksSessionStore sessionStore, + PendingGlobalLeaksLoginStore pendingLoginStore, GlobalLeaksClient globalLeaksClient, DenunciaInboxService inboxService, IInboxTrackingService trackingService, ILogger logger) { _sessionStore = sessionStore; + _pendingLoginStore = pendingLoginStore; _globalLeaksClient = globalLeaksClient; _inboxService = inboxService; _trackingService = trackingService; @@ -37,6 +40,44 @@ public sealed class InboxController : ControllerBase return Ok(ToDto(session)); } + [HttpPost("session/renew/prepare")] + public async Task> PrepareRenewSession(CancellationToken cancellationToken) + { + var username = GetUsername(); + var current = await _sessionStore.GetAsync(username, cancellationToken); + if (current is null || string.IsNullOrWhiteSpace(current.Password)) + { + return BadRequest(new ApiError("No hay credenciales guardadas para este usuario. Cierra sesion y vuelve a entrar.")); + } + + try + { + var prepared = await _globalLeaksClient.PrepareLoginAsync( + current.Username, + current.Password, + cancellationToken); + + var pending = _pendingLoginStore.Create( + prepared.Username, + current.Password, + prepared.FinalPassword, + prepared.TokenAnswer); + + return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc)); + } + catch (GlobalLeaksValidationException ex) + { + return StatusCode(ex.StatusCode, new ApiError(ex.Message)); + } + catch (Exception ex) + { + _logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username); + return StatusCode( + StatusCodes.Status500InternalServerError, + new ApiError($"No se ha podido preparar la renovacion: {ex.GetType().Name}: {ex.Message}")); + } + } + [HttpPost("session/renew")] public async Task> RenewSession( RenewGlobalLeaksSessionRequest request, @@ -56,11 +97,32 @@ public sealed class InboxController : ControllerBase try { - var session = await _globalLeaksClient.LoginAsync( - current.Username, - current.Password, - request.Authcode.Trim(), - cancellationToken); + GlSession session; + if (!string.IsNullOrWhiteSpace(request.PendingLoginId)) + { + var pending = _pendingLoginStore.Get(request.PendingLoginId); + if (!string.Equals(pending.Username, current.Username, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new ApiError("La preparacion del login no corresponde al usuario actual.")); + } + + session = await _globalLeaksClient.CompleteLoginAsync( + pending.Username, + pending.FinalPassword, + pending.TokenAnswer, + request.Authcode.Trim(), + cancellationToken); + + _pendingLoginStore.Remove(pending.Id); + } + else + { + session = await _globalLeaksClient.LoginAsync( + current.Username, + current.Password, + request.Authcode.Trim(), + cancellationToken); + } await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, cancellationToken); var stored = await _sessionStore.GetAsync(username, cancellationToken); @@ -70,6 +132,10 @@ public sealed class InboxController : ControllerBase { return StatusCode(ex.StatusCode, new ApiError(ex.Message)); } + catch (InvalidOperationException ex) + { + return BadRequest(new ApiError(ex.Message)); + } catch (Exception ex) { _logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username); @@ -99,7 +165,7 @@ public sealed class InboxController : ControllerBase try { var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken); - var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken); + var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts); var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken); var state = await _trackingService.GetUserStateAsync(username, cancellationToken); @@ -189,6 +255,7 @@ public sealed class InboxController : ControllerBase [HttpGet("reports/{reportId}/detail")] public async Task> GetReportDetail( string reportId, + [FromQuery] string? lastAccess, CancellationToken cancellationToken) { var username = GetUsername(); @@ -200,7 +267,7 @@ public sealed class InboxController : ControllerBase try { - return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, cancellationToken)); + return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken)); } catch (GlobalLeaksSessionExpiredException) { diff --git a/Antifraude.Net/ApiDenuncias/Controllers/PurgeController.cs b/Antifraude.Net/ApiDenuncias/Controllers/PurgeController.cs new file mode 100644 index 0000000..2ca1e79 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Controllers/PurgeController.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using ApiDenuncias.Services; +using GestionaDenuncias.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ApiDenuncias.Controllers; + +[ApiController] +[Authorize] +[Route("api/purge")] +public sealed class PurgeController : ControllerBase +{ + private readonly ManualPurgeService _manualPurgeService; + private readonly ILogger _logger; + + public PurgeController( + ManualPurgeService manualPurgeService, + ILogger logger) + { + _manualPurgeService = manualPurgeService; + _logger = logger; + } + + [HttpPost("manual/current")] + public Task> ExecuteCurrentManualPurge(CancellationToken cancellationToken) + => ExecuteManualPurgeCore(DateOnly.FromDateTime(DateTime.UtcNow), cancellationToken); + + [HttpPost("manual")] + public async Task> ExecuteManualPurge( + ManualPurgeRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Date) || + !DateOnly.TryParseExact( + request.Date.Trim(), + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var purgeDate)) + { + return BadRequest(new ApiError("Debes indicar una fecha valida con formato YYYY-MM-DD.")); + } + + return await ExecuteManualPurgeCore(purgeDate, cancellationToken); + } + + private async Task> ExecuteManualPurgeCore( + DateOnly purgeDate, + CancellationToken cancellationToken) + { + try + { + var result = await _manualPurgeService.ExecuteAsync(purgeDate, cancellationToken); + return Ok(result); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return StatusCode( + StatusCodes.Status504GatewayTimeout, + new ApiError("La Function App de purga no ha respondido dentro del tiempo configurado.")); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "No se ha podido ejecutar la purga manual para {Date}.", purgeDate); + return StatusCode( + StatusCodes.Status502BadGateway, + new ApiError(ex.Message)); + } + } +} diff --git a/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs b/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs index 1388ea9..1029080 100644 --- a/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs +++ b/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs @@ -245,7 +245,13 @@ public static class GlobalLeaksJsonEnricher SetIfMissing(() => denuncia.RazonSocial, value => denuncia.RazonSocial = value, answers, "razon social"); SetIfMissing(() => denuncia.Sexo, value => denuncia.Sexo = value, answers, "sexo"); SetIfMissing(() => denuncia.PaisOrigen, value => denuncia.PaisOrigen = value, answers, "pais de origen"); - SetIfMissing(() => denuncia.Dni, value => denuncia.Dni = value, answers, "dni", "nif", "nie", "cif", "otro documento identificativo"); + SetIfMissing(() => denuncia.Dni, value => denuncia.Dni = value, answers, "nif dni nie", "dni", "nif", "nie", "cif", "otro documento identificativo"); + if (!string.IsNullOrWhiteSpace(denuncia.Dni) && + string.IsNullOrWhiteSpace(denuncia.TipoDocumentoIdentificativo)) + { + denuncia.TipoDocumentoIdentificativo = "NIF (DNI, NIE)"; + } + SetIfMissing(() => denuncia.A_Quien_Denuncia, value => denuncia.A_Quien_Denuncia = value, answers, "a quien denuncia"); SetIfMissing(() => denuncia.DenunciadoDetalle, value => denuncia.DenunciadoDetalle = value, answers, "especifique a quien denuncia"); SetIfMissing(() => denuncia.Descripcion_Denuncia, value => denuncia.Descripcion_Denuncia = value, answers, "describa su denuncia", "descripcion de la denuncia"); @@ -261,7 +267,7 @@ public static class GlobalLeaksJsonEnricher SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online"); SetIfMissing(() => denuncia.NotificacionPostal, value => denuncia.NotificacionPostal = value, answers, "autorizo recibir notificaciones via correo postal"); SetIfMissing(() => denuncia.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email"); - SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "telefono", "telefono movil"); + SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil"); SetIfMissing(() => denuncia.Direccion, value => denuncia.Direccion = value, answers, "nombre de la via", "direccion", "domicilio"); SetIfMissing(() => denuncia.DireccionTipoVia, value => denuncia.DireccionTipoVia = value, answers, "tipo de via"); SetIfMissing(() => denuncia.DireccionNumero, value => denuncia.DireccionNumero = value, answers, "numero", "numero km"); @@ -383,6 +389,11 @@ public static class GlobalLeaksJsonEnricher continue; } + if (character is 'º' or 'ª') + { + continue; + } + builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' '); } diff --git a/Antifraude.Net/ApiDenuncias/Helpers/ReportParser.cs b/Antifraude.Net/ApiDenuncias/Helpers/ReportParser.cs index 7630e39..1a3f253 100644 --- a/Antifraude.Net/ApiDenuncias/Helpers/ReportParser.cs +++ b/Antifraude.Net/ApiDenuncias/Helpers/ReportParser.cs @@ -7,6 +7,60 @@ namespace ApiDenuncias.Helpers; public static class ReportParser { + private static readonly HashSet FlatReportSections = NormalizeAll( + "Datos del denunciante", + "Descripción", + "Preferencias de notificación", + "Condiciones y reglas de uso", + "Comments"); + + private static readonly HashSet FlatReportLabels = NormalizeAll( + "Indique si actúa como persona física o en representación de una persona jurídica.", + "Nombre", + "1º Apellido", + "2º Apellido", + "SEXO", + "CONTACTO TELEFÓNICO", + "País de Origen", + "NIF (DNI, NIE)", + "Razón social", + "CIF", + "Otro documento identificativo", + "Asunto", + "¿A quién denuncia?", + "Especifique a quién denuncia", + "Describa su denuncia", + "¿Ha denunciado estos hechos ante otras instituciones u órganos?", + "POR FAVOR. INDIQUE EL ORGANISMO O LA INSTITUCION DONDE HA DENUNCIADO LOS HECHOS", + "¿Solicita medidas concretas de protección?", + "DESCRIBA LAS MEDIDAS DE PROTECCIÓN SOLICITADAS", + "Lugar en el que ocurrieron los hechos que denuncia", + "Fecha de los hechos que denuncia", + "Autorización para remitir su denuncia", + "En tal caso, ¿desea que su denuncia se remita anonimizada (sin sus datos personales)?", + "En tal caso, ¿desea que su denuncia se remita anonimizada (sin datos personales)?", + "Preferencia de notificación", + "Notificaciones Electrónicas", + "Correo electrónico", + "Seguimiento Online", + "Autorizo recibir notificaciones vía Correo Postal", + "Provincia", + "Tipo de vía", + "Nombre de la vía", + "Código Postal", + "Localidad", + "Municipio", + "Número/Km", + "Número", + "Bloque", + "Escalera", + "Planta", + "Piso", + "Puerta", + "Extra", + "Condiciones y reglas de uso del buzón de denuncias", + "TRATAMIENTO DE DATOS PERSONALES"); + public static DenunciasGestiona ParseReport(string reportText) { var lines = NormalizeLines(reportText); @@ -187,6 +241,96 @@ public static class ReportParser i = j - 1; } + comments = commentBuilder.ToString().Trim(); + if (fields.Count == 0) + { + fields = ParseFlatFormFields(lines, out comments); + } + + return fields; + } + + private static List ParseFlatFormFields(string[] lines, out string comments) + { + var fields = new List(); + var commentBuilder = new StringBuilder(); + var currentSection = string.Empty; + var order = 0; + + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].Trim(); + if (ShouldSkipFlatLine(trimmed) || IsMetadataLine(trimmed)) + { + continue; + } + + if (IsMessagesStart(trimmed)) + { + for (var j = i + 1; j < lines.Length; j++) + { + var commentLine = lines[j].Trim(); + if (ShouldSkipFlatLine(commentLine)) + { + continue; + } + + if (commentBuilder.Length > 0) + { + commentBuilder.AppendLine(); + } + + commentBuilder.Append(commentLine); + } + + break; + } + + if (IsFlatSection(trimmed)) + { + currentSection = trimmed; + continue; + } + + if (!IsFlatLabel(trimmed)) + { + continue; + } + + var valueLines = new List(); + var nextIndex = i + 1; + while (nextIndex < lines.Length) + { + var nextTrimmed = lines[nextIndex].Trim(); + if (ShouldSkipFlatLine(nextTrimmed)) + { + nextIndex++; + continue; + } + + if (IsMetadataLine(nextTrimmed) || + IsMessagesStart(nextTrimmed) || + IsFlatSection(nextTrimmed) || + IsFlatLabel(nextTrimmed)) + { + break; + } + + valueLines.Add(nextTrimmed); + nextIndex++; + } + + fields.Add(new ReportFieldEntry + { + Order = ++order, + Section = currentSection, + Label = trimmed, + Value = string.Join(Environment.NewLine, valueLines) + }); + + i = nextIndex - 1; + } + comments = commentBuilder.ToString().Trim(); return fields; } @@ -415,6 +559,37 @@ public static class ReportParser line.StartsWith("Estado:", StringComparison.OrdinalIgnoreCase); } + private static HashSet NormalizeAll(params string[] values) + => values + .Select(Normalize) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.Ordinal); + + private static bool IsFlatSection(string value) + => FlatReportSections.Contains(Normalize(value)); + + private static bool IsFlatLabel(string value) + => FlatReportLabels.Contains(Normalize(value)); + + private static bool IsMessagesStart(string value) + { + var normalized = Normalize(value); + return normalized == "messages" || + normalized == "comments" || + normalized.StartsWith("comments ", StringComparison.Ordinal); + } + + private static bool ShouldSkipFlatLine(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + return Regex.IsMatch(value, @"^\d+/\d+$", RegexOptions.CultureInvariant) || + Regex.IsMatch(value, @"^REPORT\s+\d+\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + private static int CountIndentation(string value) { var indent = 0; @@ -456,6 +631,11 @@ public static class ReportParser continue; } + if (character is 'º' or 'ª') + { + continue; + } + builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' '); } diff --git a/Antifraude.Net/ApiDenuncias/Program.cs b/Antifraude.Net/ApiDenuncias/Program.cs index b7fca4e..d5717c3 100644 --- a/Antifraude.Net/ApiDenuncias/Program.cs +++ b/Antifraude.Net/ApiDenuncias/Program.cs @@ -19,6 +19,7 @@ builder.Services.Configure(builder.Configuration.GetSection(Key builder.Services.Configure(builder.Configuration.GetSection("Gestiona")); builder.Services.Configure(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(ManualPurgeOptions.SectionName)); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); @@ -34,11 +35,16 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); builder.Services.AddHttpClient((sp, client) => { @@ -111,6 +117,20 @@ app.UseExceptionHandler(errorApp => logger.LogError(feature.Error, "Error no controlado en {Path}", context.Request.Path); } + if (feature?.Error is EncryptedDataPurgedException purgedException) + { + context.Response.StatusCode = StatusCodes.Status410Gone; + await context.Response.WriteAsJsonAsync(new ApiError(purgedException.Message)); + return; + } + + if (feature?.Error is EncryptionKeyUnavailableException keyException) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync(new ApiError(keyException.Message)); + return; + } + var detailedErrors = context.RequestServices .GetRequiredService() .GetValue("DetailedApiErrors", false); diff --git a/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_envelope_encryption.sql b/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_envelope_encryption.sql new file mode 100644 index 0000000..81bce3d --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_envelope_encryption.sql @@ -0,0 +1,281 @@ +-- ============================================================ +-- Migracion de base de datos - ApiDenuncias +-- Envelope Encryption adaptado al esquema real de la aplicacion +-- Entorno previsto: PRE / MySQL Flexible Server +-- BD prevista: gestiona +-- ============================================================ +-- +-- OBJETIVO +-- Incorporar el modelo de claves diarias propuesto por infraestructura +-- +-- MODELO REAL DE LA APP +-- complaints +-- Guarda la denuncia, metadatos tecnicos, estado, expediente Gestiona +-- y payload sensible cifrado en columnas existentes como: +-- - raw_report_text +-- - form_fields_json +-- - campos textuales derivados del report +-- +-- complaint_attachments +-- Guarda adjuntos y report, con hash en claro para deduplicacion: +-- - content +-- - description +-- - notes +-- - content_sha256 +-- +-- inbox_reports / user_inbox_reports / app_users +-- Trazabilidad de bandeja, usuario propietario y control de descargas. +-- No almacenan el contenido sensible de la denuncia. +-- +-- ESTRATEGIA +-- 1. Crear encryption_keys, gestionada por Function App / infraestructura. +-- 2. Anadir key_date a complaints y complaint_attachments. +-- 3. Mantener key_date nullable para no romper datos legacy o entornos +-- con registros previos. La API debera rellenarlo en nuevas escrituras. +-- 4. No se crean columnas descripcion_cifrada/iv/auth_tag separadas porque +-- la API cifra el payload/contenido en el propio campo con AES-256-GCM, +-- empaquetando nonce + auth_tag + ciphertext en el valor persistido. +-- +-- IMPORTANTE +-- Ejecutar dentro de la base de datos correcta: +-- USE gestiona; +-- ============================================================ + +USE gestiona; + +-- ------------------------------------------------------------ +-- TABLA: encryption_keys +-- Gestionada por la Function App de claves. +-- La API la lee para obtener la eDEK diaria y hacer unwrapKey en Key Vault. +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS encryption_keys ( + id INT AUTO_INCREMENT PRIMARY KEY, + key_date DATE NOT NULL COMMENT 'Fecha de validez de la KEK/DEK diaria. Una fila por dia.', + key_name VARCHAR(100) NOT NULL COMMENT 'Nombre de la clave en Azure Key Vault. Ej: key-2026-05-19.', + key_version VARCHAR(500) NOT NULL COMMENT 'URI completa con version de la KEK en Key Vault. Se usa para unwrapKey.', + edek TEXT NOT NULL COMMENT 'DEK cifrada en Base64. La API hace unwrapKey y mantiene la DEK solo en memoria.', + status ENUM('active','purged') NOT NULL DEFAULT 'active' COMMENT 'active: operativa | purged: KEK deshabilitada y datos ilegibles.', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Timestamp de creacion de la clave.', + purged_at DATETIME NULL COMMENT 'Timestamp de purga criptografica. NULL mientras status = active.', + CONSTRAINT uq_encryption_keys_date UNIQUE (key_date), + INDEX ix_encryption_keys_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ------------------------------------------------------------ +-- Helpers idempotentes para MySQL. +-- Evitamos ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS porque +-- no son seguros en todas las versiones/configuraciones de MySQL usadas. +-- ------------------------------------------------------------ + +DROP PROCEDURE IF EXISTS add_column_if_missing; +DROP PROCEDURE IF EXISTS add_index_if_missing; +DROP PROCEDURE IF EXISTS add_fk_if_missing; + +DELIMITER // + +CREATE PROCEDURE add_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND COLUMN_NAME = p_column_name + ) THEN + SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN ', p_column_definition); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// + +CREATE PROCEDURE add_index_if_missing( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_index_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND INDEX_NAME = p_index_name + ) THEN + SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_index_definition); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// + +CREATE PROCEDURE add_fk_if_missing( + IN p_table_name VARCHAR(64), + IN p_constraint_name VARCHAR(64), + IN p_constraint_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND CONSTRAINT_NAME = p_constraint_name + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + ) THEN + SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', p_constraint_name, '` ', p_constraint_definition); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END// + +DELIMITER ; + +-- ------------------------------------------------------------ +-- MODIFICACION: complaints +-- key_date indica con que DEK diaria se cifro la denuncia/payload sensible. +-- Queda nullable para permitir registros anteriores a la migracion. +-- ------------------------------------------------------------ + +CALL add_column_if_missing( + 'complaints', + 'key_date', + '`key_date` DATE NULL COMMENT ''FK a encryption_keys.key_date. Indica la clave diaria usada para cifrar los datos sensibles de la denuncia.'' AFTER `is_rejected`' +); + +CALL add_column_if_missing( + 'complaints', + 'encryption_scheme', + '`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT ''none'' COMMENT ''Formato de cifrado aplicado. Ej: aes-256-gcm-envelope-v1.'' AFTER `key_date`' +); + +CALL add_column_if_missing( + 'complaints', + 'encrypted_at_utc', + '`encrypted_at_utc` DATETIME(6) NULL COMMENT ''Fecha UTC en la que la API cifro o recifro el payload sensible.'' AFTER `encryption_scheme`' +); + +CALL add_index_if_missing( + 'complaints', + 'ix_complaints_key_date', + 'INDEX `ix_complaints_key_date` (`key_date`)' +); + +CALL add_fk_if_missing( + 'complaints', + 'fk_complaints_key_date', + 'FOREIGN KEY (`key_date`) REFERENCES `encryption_keys` (`key_date`)' +); + +-- ------------------------------------------------------------ +-- MODIFICACION: complaint_attachments +-- key_date indica con que DEK diaria se cifro cada adjunto/report. +-- El hash content_sha256 permanece en claro para deduplicar actualizaciones. +-- ------------------------------------------------------------ + +CALL add_column_if_missing( + 'complaint_attachments', + 'key_date', + '`key_date` DATE NULL COMMENT ''FK a encryption_keys.key_date. Indica la clave diaria usada para cifrar el contenido del adjunto.'' AFTER `content_sha256`' +); + +CALL add_column_if_missing( + 'complaint_attachments', + 'encryption_scheme', + '`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT ''none'' COMMENT ''Formato de cifrado aplicado. Ej: aes-256-gcm-envelope-v1.'' AFTER `key_date`' +); + +CALL add_column_if_missing( + 'complaint_attachments', + 'encrypted_at_utc', + '`encrypted_at_utc` DATETIME(6) NULL COMMENT ''Fecha UTC en la que la API cifro o recifro el adjunto.'' AFTER `encryption_scheme`' +); + +CALL add_index_if_missing( + 'complaint_attachments', + 'ix_attachments_key_date', + 'INDEX `ix_attachments_key_date` (`key_date`)' +); + +CALL add_fk_if_missing( + 'complaint_attachments', + 'fk_attachments_key_date', + 'FOREIGN KEY (`key_date`) REFERENCES `encryption_keys` (`key_date`)' +); + +-- ------------------------------------------------------------ +-- Limpieza de helpers. +-- ------------------------------------------------------------ + +DROP PROCEDURE IF EXISTS add_column_if_missing; +DROP PROCEDURE IF EXISTS add_index_if_missing; +DROP PROCEDURE IF EXISTS add_fk_if_missing; + +-- ============================================================ +-- CONSULTAS DE VERIFICACION +-- ============================================================ + +SELECT + TABLE_NAME, + ENGINE, + TABLE_COLLATION +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('encryption_keys', 'complaints', 'complaint_attachments') +ORDER BY TABLE_NAME; + +SELECT + COLUMN_NAME, + DATA_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'encryption_keys' +ORDER BY ORDINAL_POSITION; + +SELECT + TABLE_NAME, + COLUMN_NAME, + DATA_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('complaints', 'complaint_attachments') + AND COLUMN_NAME IN ('key_date', 'encryption_scheme', 'encrypted_at_utc') +ORDER BY TABLE_NAME, ORDINAL_POSITION; + +SELECT + CONSTRAINT_NAME, + TABLE_NAME, + COLUMN_NAME, + REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME +FROM information_schema.KEY_COLUMN_USAGE +WHERE TABLE_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME = 'encryption_keys' +ORDER BY TABLE_NAME, CONSTRAINT_NAME; + +SELECT + TABLE_NAME, + INDEX_NAME, + COLUMN_NAME, + NON_UNIQUE +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('encryption_keys', 'complaints', 'complaint_attachments') + AND INDEX_NAME IN ('ix_encryption_keys_status', 'ix_complaints_key_date', 'ix_attachments_key_date') +ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX; + +-- ============================================================ +-- FIN +-- ============================================================ diff --git a/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql b/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql index 8007e14..44d47db 100644 --- a/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql +++ b/Antifraude.Net/ApiDenuncias/Scripts/gestiondenuncias_schema.sql @@ -156,3 +156,14 @@ CREATE TABLE IF NOT EXISTS complaint_attachments ( FOREIGN KEY (complaint_id) REFERENCES complaints(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS complaint_attachment_chunks ( + attachment_id BIGINT NOT NULL, + chunk_index INT NOT NULL, + content LONGBLOB NOT NULL, + created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (attachment_id, chunk_index), + CONSTRAINT fk_attachment_chunks_attachment + FOREIGN KEY (attachment_id) REFERENCES complaint_attachments(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/Antifraude.Net/ApiDenuncias/Services/AppConfigurationService.cs b/Antifraude.Net/ApiDenuncias/Services/AppConfigurationService.cs new file mode 100644 index 0000000..38e9a63 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/AppConfigurationService.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using GestionaDenuncias.Shared.Models; +using MySqlConnector; + +namespace ApiDenuncias.Services; + +public sealed class AppConfigurationService +{ + private const string ExternalUpdateCutoffDateKey = "external_update_cutoff_date"; + + private readonly MySqlConnectionStringProvider _connectionStringProvider; + + public AppConfigurationService(MySqlConnectionStringProvider connectionStringProvider) + { + _connectionStringProvider = connectionStringProvider; + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenConnectionAsync(cancellationToken); + await EnsureTableAsync(connection, cancellationToken); + + await using var command = new MySqlCommand( + """ + SELECT setting_value + FROM app_settings + WHERE setting_key = @settingKey + LIMIT 1; + """, + connection); + command.Parameters.AddWithValue("@settingKey", ExternalUpdateCutoffDateKey); + + var value = await command.ExecuteScalarAsync(cancellationToken); + var dateText = value is null or DBNull + ? null + : Convert.ToString(value, CultureInfo.InvariantCulture); + + return new AppConfigurationDto(string.IsNullOrWhiteSpace(dateText) ? null : dateText); + } + + public async Task SetExternalUpdateCutoffDateAsync( + DateOnly? date, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenConnectionAsync(cancellationToken); + await EnsureTableAsync(connection, cancellationToken); + + var dateText = date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + await using var command = new MySqlCommand( + """ + INSERT INTO app_settings (setting_key, setting_value, updated_at_utc) + VALUES (@settingKey, @settingValue, UTC_TIMESTAMP(6)) + ON DUPLICATE KEY UPDATE + setting_value = VALUES(setting_value), + updated_at_utc = UTC_TIMESTAMP(6); + """, + connection); + command.Parameters.AddWithValue("@settingKey", ExternalUpdateCutoffDateKey); + command.Parameters.AddWithValue("@settingValue", string.IsNullOrWhiteSpace(dateText) ? DBNull.Value : dateText); + + await command.ExecuteNonQueryAsync(cancellationToken); + return new AppConfigurationDto(dateText); + } + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken); + var connection = new MySqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } + + private static async Task EnsureTableAsync(MySqlConnection connection, CancellationToken cancellationToken) + { + await using var command = new MySqlCommand( + """ + CREATE TABLE IF NOT EXISTS app_settings ( + setting_key VARCHAR(128) NOT NULL, + setting_value TEXT NULL, + updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (setting_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + """, + connection); + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/Antifraude.Net/ApiDenuncias/Services/DenunciaInboxService.cs b/Antifraude.Net/ApiDenuncias/Services/DenunciaInboxService.cs index 1418984..9da75e8 100644 --- a/Antifraude.Net/ApiDenuncias/Services/DenunciaInboxService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/DenunciaInboxService.cs @@ -9,6 +9,51 @@ public sealed class DenunciaInboxService { private const string RootPath = @"C:\ZipsDenuncias"; + private static readonly HashSet BlockedAttachmentExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".ade", + ".adp", + ".apk", + ".app", + ".appx", + ".bat", + ".cmd", + ".com", + ".cpl", + ".dll", + ".exe", + ".gadget", + ".hta", + ".ins", + ".iso", + ".jar", + ".js", + ".jse", + ".lnk", + ".msc", + ".msi", + ".msp", + ".mst", + ".pif", + ".ps1", + ".ps1xml", + ".ps2", + ".ps2xml", + ".psc1", + ".psc2", + ".reg", + ".scr", + ".sh", + ".sys", + ".vb", + ".vbe", + ".vbs", + ".ws", + ".wsc", + ".wsf", + ".wsh" + }; + private readonly IGestionaService _gestionaService; private readonly IDenunciaStore _denunciaStore; private readonly ILogger _logger; @@ -60,6 +105,7 @@ public sealed class DenunciaInboxService .ToList(); var errors = new List(); + var warnings = new List(); var importedCount = 0; var complaintIds = new List(); @@ -68,10 +114,11 @@ public sealed class DenunciaInboxService try { var zipBytes = await File.ReadAllBytesAsync(zipPath, cancellationToken); - var complaintId = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken); + var result = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken); File.Delete(zipPath); importedCount++; - complaintIds.Add(complaintId); + complaintIds.Add(result.ComplaintId); + warnings.AddRange(result.Warnings.Select(warning => $"{Path.GetFileName(zipPath)}: {warning}")); } catch (Exception ex) { @@ -80,7 +127,7 @@ public sealed class DenunciaInboxService } } - return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds); + return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds, warnings); } public async Task ImportFromGlobalLeaksAsync( @@ -100,8 +147,8 @@ public sealed class DenunciaInboxService try { - var complaintId = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken); - return new ImportSummary(1, 1, [], [complaintId]); + var result = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken); + return new ImportSummary(1, 1, [], [result.ComplaintId], result.Warnings); } catch (Exception ex) { @@ -110,7 +157,7 @@ public sealed class DenunciaInboxService } } - private async Task ProcessZipAsync( + private async Task ProcessZipAsync( byte[] zipBytes, string sourceName, string? globalLeaksJson, @@ -177,18 +224,19 @@ public sealed class DenunciaInboxService denuncia.Expediente_Gestiona = "Pendiente"; } - var nuevosFicheros = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken); + var readFilesResult = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken); await MergeComplaintAsync(denuncia, cancellationToken); - await MergeFilesAsync(nuevosFicheros, cancellationToken); - return denuncia.Id_Denuncia; + await MergeFilesAsync(readFilesResult.Files, cancellationToken); + return new ProcessZipResult(denuncia.Id_Denuncia, readFilesResult.Warnings); } - private async Task> ReadFilesFromArchiveAsync( + private async Task ReadFilesFromArchiveAsync( ZipArchive archive, ZipArchiveEntry reportEntry, int denunciaId, CancellationToken cancellationToken) { + var warnings = new List(); var files = new List { new( @@ -206,6 +254,12 @@ public sealed class DenunciaInboxService foreach (var entry in archive.Entries.Where(entry => IsSupportedAttachmentEntry(entry) && !IsSameEntry(entry, reportEntry))) { + if (IsBlockedAttachmentEntry(entry)) + { + warnings.Add(BuildBlockedAttachmentWarning(entry)); + continue; + } + files.Add(new FicherosDenuncias( id_Fichero: 0, id_Tipo: 1, @@ -219,12 +273,23 @@ public sealed class DenunciaInboxService fichero: await ReadEntryBytesAsync(entry, cancellationToken))); } - return files; + return new ReadFilesResult(files, warnings); } private async Task MergeComplaintAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken) { - var existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken); + DenunciasGestiona? existing = null; + try + { + existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken); + } + catch (EncryptedDataPurgedException ex) + { + _logger.LogInformation( + ex, + "La denuncia {DenunciaId} existe con datos purgados. Se reimportara como nueva copia cifrada con la clave activa.", + denuncia.Id_Denuncia); + } if (existing is not null) { @@ -409,6 +474,20 @@ public sealed class DenunciaInboxService return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients"); } + private static bool IsBlockedAttachmentEntry(ZipArchiveEntry entry) + { + var extension = Path.GetExtension(entry.Name); + return !string.IsNullOrWhiteSpace(extension) && + BlockedAttachmentExtensions.Contains(extension); + } + + private static string BuildBlockedAttachmentWarning(ZipArchiveEntry entry) + { + var fileName = Path.GetFileName(entry.FullName); + var extension = Path.GetExtension(entry.Name); + return $"Se ha detectado y eliminado el archivo '{fileName}' porque la extension '{extension}' no esta permitida."; + } + private static ZipArchiveEntry? FindReportEntry(ZipArchive archive) { return archive.Entries.FirstOrDefault(IsReportEntry); @@ -587,5 +666,9 @@ public sealed class DenunciaInboxService builder.AppendLine(); } + + private sealed record ProcessZipResult(int ComplaintId, IReadOnlyList Warnings); + + private sealed record ReadFilesResult(List Files, IReadOnlyList Warnings); } diff --git a/Antifraude.Net/ApiDenuncias/Services/EncryptedDenunciaStore.cs b/Antifraude.Net/ApiDenuncias/Services/EncryptedDenunciaStore.cs index 7e84f5a..1f6a1f4 100644 --- a/Antifraude.Net/ApiDenuncias/Services/EncryptedDenunciaStore.cs +++ b/Antifraude.Net/ApiDenuncias/Services/EncryptedDenunciaStore.cs @@ -8,12 +8,13 @@ using Microsoft.AspNetCore.DataProtection; namespace ApiDenuncias.Services; -public sealed class EncryptedDenunciaStore : IDenunciaStore +public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaStore { private const string DataProtectionStringPrefix = "enc:v1:"; private const string KeyVaultStringPrefix = "enc:v2:"; private static readonly byte[] DataProtectionBytesPrefix = Encoding.ASCII.GetBytes("enc:v1:"); private static readonly byte[] KeyVaultBytesPrefix = Encoding.ASCII.GetBytes("enc:v2:"); + private static readonly byte[] KeyVaultRawBytesPrefix = Encoding.ASCII.GetBytes("enc:v3:"); private const int AesGcmNonceSize = 12; private const int AesGcmTagSize = 16; private static readonly PropertyInfo[] ComplaintProperties = typeof(DenunciasGestiona) @@ -22,17 +23,23 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore .ToArray(); private readonly MySqlDenunciaStore _inner; - private readonly IEncryptionKeyProvider _encryptionKeyProvider; + private readonly IEnvelopeEncryptionKeyProvider _envelopeKeyProvider; + private readonly IEncryptionKeyProvider _legacyKeyProvider; private readonly IDataProtector _protector; + private readonly ILogger _logger; public EncryptedDenunciaStore( MySqlDenunciaStore inner, - IEncryptionKeyProvider encryptionKeyProvider, - IDataProtectionProvider dataProtectionProvider) + IEnvelopeEncryptionKeyProvider envelopeKeyProvider, + IEncryptionKeyProvider legacyKeyProvider, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) { _inner = inner; - _encryptionKeyProvider = encryptionKeyProvider; + _envelopeKeyProvider = envelopeKeyProvider; + _legacyKeyProvider = legacyKeyProvider; _protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1"); + _logger = logger; } public Task EnsureSchemaAsync(CancellationToken cancellationToken = default) @@ -40,44 +47,121 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore public async Task> GetAllDenunciasAsync(CancellationToken cancellationToken = default) { - var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken); - return (await _inner.GetAllDenunciasAsync(cancellationToken)) - .Select(denuncia => UnprotectComplaint(denuncia, key)) - .ToList(); + var denuncias = await _inner.GetAllDenunciasAsync(cancellationToken); + return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); + } + + public async Task> GetDenunciasByScopeAsync( + DenunciaListScope scope, + CancellationToken cancellationToken = default) + { + var denuncias = await _inner.GetDenunciasByScopeAsync(scope, cancellationToken); + return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); + } + + public async Task> GetDenunciasByIdsAsync( + IReadOnlyCollection denunciaIds, + CancellationToken cancellationToken = default) + { + var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, cancellationToken); + return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); + } + + public async Task> GetDenunciasByIdsAsync( + IReadOnlyCollection denunciaIds, + DenunciaListScope scope, + CancellationToken cancellationToken = default) + { + var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, scope, cancellationToken); + return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken); + } + + private async Task> UnprotectComplaintsAsync( + List denuncias, + bool skipPurgedRows, + CancellationToken cancellationToken) + { + var result = new List(denuncias.Count); + var requestKeyCache = new Dictionary(); + foreach (var denuncia in denuncias) + { + try + { + result.Add(await UnprotectComplaintAsync(denuncia, requestKeyCache, cancellationToken)); + } + catch (EncryptedDataPurgedException ex) when (skipPurgedRows) + { + _logger.LogWarning( + "Se omite la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.", + denuncia.Id_Denuncia, + ex.KeyDate); + } + } + + return result; } public async Task> GetAllFicherosAsync(CancellationToken cancellationToken = default) { - var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken); - return (await _inner.GetAllFicherosAsync(cancellationToken)) - .Select(fichero => UnprotectAttachment(fichero, key)) - .ToList(); + var ficheros = await _inner.GetAllFicherosAsync(cancellationToken); + return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken); + } + + public async Task> GetFicherosByDenunciaIdsAsync( + IReadOnlyCollection denunciaIds, + CancellationToken cancellationToken = default) + { + var ficheros = await _inner.GetFicherosByDenunciaIdsAsync(denunciaIds, cancellationToken); + return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken); } public async Task> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default) { - var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken); - return (await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken)) - .Select(fichero => UnprotectAttachment(fichero, key)) - .ToList(); + var ficheros = await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken); + return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken); + } + + private async Task> UnprotectAttachmentsAsync( + List ficheros, + bool skipPurgedRows, + CancellationToken cancellationToken) + { + var result = new List(ficheros.Count); + var requestKeyCache = new Dictionary(); + foreach (var fichero in ficheros) + { + try + { + result.Add(await UnprotectAttachmentAsync(fichero, requestKeyCache, cancellationToken)); + } + catch (EncryptedDataPurgedException ex) when (skipPurgedRows) + { + _logger.LogWarning( + "Se omite el adjunto {AttachmentId} de la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.", + fichero.Id_Fichero, + fichero.Id_Denuncia, + ex.KeyDate); + } + } + + return result; } public async Task GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default) { - var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken); var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken); - return denuncia is null ? null : UnprotectComplaint(denuncia, key); + return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken); } public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default) { - var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken); + var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken); await _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia, key), cancellationToken); } public async Task UpsertFicherosAsync(IEnumerable ficheros, CancellationToken cancellationToken = default) { - var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken); + var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken); await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken); } @@ -88,13 +172,30 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore CancellationToken cancellationToken = default) => _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken); - private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, byte[] key) - => TransformComplaint(ToPersistentComplaint(source), value => ProtectString(value, key)); - - private DenunciasGestiona UnprotectComplaint(DenunciasGestiona source, byte[] key) + private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, EncryptionKeyMaterial key) { - var decrypted = TransformComplaint(source, value => UnprotectString(value, key)); - return RebuildComplaintFromPayload(decrypted); + var protectedComplaint = TransformComplaint( + ToPersistentComplaint(source), + value => ProtectString(value, key.Key)); + + protectedComplaint.KeyDate = key.KeyDate; + protectedComplaint.EncryptionScheme = key.Scheme; + protectedComplaint.EncryptedAtUtc = DateTime.UtcNow; + return protectedComplaint; + } + + private async Task UnprotectComplaintAsync( + DenunciasGestiona source, + Dictionary requestKeyCache, + CancellationToken cancellationToken) + { + var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken); + var decrypted = TransformComplaint(source, value => UnprotectString(value, key, source.KeyDate)); + var rebuilt = RebuildComplaintFromPayload(decrypted); + rebuilt.KeyDate = source.KeyDate; + rebuilt.EncryptionScheme = source.EncryptionScheme; + rebuilt.EncryptedAtUtc = source.EncryptedAtUtc; + return rebuilt; } private static DenunciasGestiona ToPersistentComplaint(DenunciasGestiona source) @@ -209,7 +310,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore return target; } - private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, byte[] key) + private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, EncryptionKeyMaterial key) { var content = source.Fichero ?? []; var hash = string.IsNullOrWhiteSpace(source.ContentSha256) @@ -220,36 +321,67 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore { Id_Fichero = source.Id_Fichero, Id_Tipo = source.Id_Tipo, - Descripcion = ProtectString(source.Descripcion ?? string.Empty, key), + Descripcion = ProtectString(source.Descripcion ?? string.Empty, key.Key), Fecha = source.Fecha, - Observaciones = ProtectString(source.Observaciones ?? string.Empty, key), + Observaciones = ProtectString(source.Observaciones ?? string.Empty, key.Key), Id_Denuncia = source.Id_Denuncia, NombreFichero = source.NombreFichero, - Fichero = ProtectBytes(content, key), + Fichero = ProtectBytes(content, key.Key), Subido = source.Subido, FechaSubida = source.FechaSubida, - ContentSha256 = hash + ContentSha256 = hash, + KeyDate = key.KeyDate, + EncryptionScheme = key.Scheme, + EncryptedAtUtc = DateTime.UtcNow }; } - private FicherosDenuncias UnprotectAttachment(FicherosDenuncias source, byte[] key) + private async Task UnprotectAttachmentAsync( + FicherosDenuncias source, + Dictionary requestKeyCache, + CancellationToken cancellationToken) { + var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken); return new FicherosDenuncias { Id_Fichero = source.Id_Fichero, Id_Tipo = source.Id_Tipo, - Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key), + Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key, source.KeyDate), Fecha = source.Fecha, - Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key), + Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key, source.KeyDate), Id_Denuncia = source.Id_Denuncia, NombreFichero = source.NombreFichero, - Fichero = UnprotectBytes(source.Fichero ?? [], key), + Fichero = UnprotectBytes(source.Fichero ?? [], key, source.KeyDate), Subido = source.Subido, FechaSubida = source.FechaSubida, - ContentSha256 = source.ContentSha256 + ContentSha256 = source.ContentSha256, + KeyDate = source.KeyDate, + EncryptionScheme = source.EncryptionScheme, + EncryptedAtUtc = source.EncryptedAtUtc }; } + private async Task ResolveReadKeyAsync( + DateOnly? keyDate, + Dictionary requestKeyCache, + CancellationToken cancellationToken) + { + if (keyDate.HasValue) + { + if (requestKeyCache.TryGetValue(keyDate.Value, out var cached)) + { + return cached; + } + + var key = await _envelopeKeyProvider.GetKeyForDateAsync(keyDate.Value, cancellationToken); + var copy = key.CopyKey(); + requestKeyCache[keyDate.Value] = copy; + return copy; + } + + return await _legacyKeyProvider.GetKeyAsync(cancellationToken); + } + private string ProtectString(string value, byte[] key) { if (string.IsNullOrWhiteSpace(value) || @@ -263,7 +395,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore return KeyVaultStringPrefix + Convert.ToBase64String(encrypted); } - private string UnprotectString(string value, byte[] key) + private string UnprotectString(string value, byte[] key, DateOnly? keyDate = null) { if (string.IsNullOrWhiteSpace(value)) { @@ -277,8 +409,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore var encrypted = Convert.FromBase64String(value[KeyVaultStringPrefix.Length..]); return Encoding.UTF8.GetString(DecryptBytes(encrypted, key)); } - catch + catch (Exception ex) { + if (keyDate.HasValue) + { + throw CreatePurgedDataException(keyDate.Value, ex); + } + return value; } } @@ -302,23 +439,40 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore { if (value.Length == 0 || StartsWith(value, KeyVaultBytesPrefix) || + StartsWith(value, KeyVaultRawBytesPrefix) || StartsWith(value, DataProtectionBytesPrefix)) { return value; } var encrypted = EncryptBytes(value, key); - var base64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(encrypted)); - return [.. KeyVaultBytesPrefix, .. base64Bytes]; + return [.. KeyVaultRawBytesPrefix, .. encrypted]; } - private byte[] UnprotectBytes(byte[] value, byte[] key) + private byte[] UnprotectBytes(byte[] value, byte[] key, DateOnly? keyDate = null) { if (value.Length == 0) { return value; } + if (StartsWith(value, KeyVaultRawBytesPrefix)) + { + try + { + return DecryptBytes(value[KeyVaultRawBytesPrefix.Length..], key); + } + catch (Exception ex) + { + if (keyDate.HasValue) + { + throw CreatePurgedDataException(keyDate.Value, ex); + } + + return value; + } + } + if (StartsWith(value, KeyVaultBytesPrefix)) { try @@ -326,8 +480,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore var base64 = Encoding.ASCII.GetString(value, KeyVaultBytesPrefix.Length, value.Length - KeyVaultBytesPrefix.Length); return DecryptBytes(Convert.FromBase64String(base64), key); } - catch + catch (Exception ex) { + if (keyDate.HasValue) + { + throw CreatePurgedDataException(keyDate.Value, ex); + } + return value; } } @@ -398,4 +557,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore private static string ComputeSha256Hex(byte[] content) => Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); + + private static EncryptedDataPurgedException CreatePurgedDataException(DateOnly keyDate, Exception innerException) + => new(keyDate, innerException); } diff --git a/Antifraude.Net/ApiDenuncias/Services/EncryptionKeyExceptions.cs b/Antifraude.Net/ApiDenuncias/Services/EncryptionKeyExceptions.cs new file mode 100644 index 0000000..d9bf786 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/EncryptionKeyExceptions.cs @@ -0,0 +1,31 @@ +namespace ApiDenuncias.Services; + +public sealed class EncryptionKeyUnavailableException : Exception +{ + public EncryptionKeyUnavailableException(string message) + : base(message) + { + } + + public EncryptionKeyUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public sealed class EncryptedDataPurgedException : Exception +{ + public EncryptedDataPurgedException(DateOnly keyDate) + : base($"Denuncia no disponible: datos purgados para la clave diaria {keyDate:yyyy-MM-dd}.") + { + KeyDate = keyDate; + } + + public EncryptedDataPurgedException(DateOnly keyDate, Exception innerException) + : base($"Denuncia no disponible: datos purgados para la clave diaria {keyDate:yyyy-MM-dd}.", innerException) + { + KeyDate = keyDate; + } + + public DateOnly KeyDate { get; } +} diff --git a/Antifraude.Net/ApiDenuncias/Services/EncryptionKeyMaterial.cs b/Antifraude.Net/ApiDenuncias/Services/EncryptionKeyMaterial.cs new file mode 100644 index 0000000..f537528 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/EncryptionKeyMaterial.cs @@ -0,0 +1,12 @@ +namespace ApiDenuncias.Services; + +public sealed record EncryptionKeyMaterial( + DateOnly KeyDate, + byte[] Key, + string KeyVersion, + string Scheme) +{ + public const string EnvelopeScheme = "aes-256-gcm-envelope-v1"; + + public byte[] CopyKey() => Key.ToArray(); +} diff --git a/Antifraude.Net/ApiDenuncias/Services/EnvelopeEncryptionKeyProvider.cs b/Antifraude.Net/ApiDenuncias/Services/EnvelopeEncryptionKeyProvider.cs new file mode 100644 index 0000000..145c720 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/EnvelopeEncryptionKeyProvider.cs @@ -0,0 +1,321 @@ +using System.Security.Cryptography; +using System.Text; +using System.Collections.Concurrent; +using ApiDenuncias.Configuration; +using Azure; +using Azure.Identity; +using Azure.Security.KeyVault.Keys.Cryptography; +using Microsoft.Extensions.Options; +using MySqlConnector; + +namespace ApiDenuncias.Services; + +public sealed class EnvelopeEncryptionKeyProvider : IEnvelopeEncryptionKeyProvider +{ + private readonly KeyVaultOptions _keyVaultOptions; + private readonly IConfiguration _configuration; + private readonly MySqlConnectionStringProvider _connectionStringProvider; + private readonly ILogger _logger; + private readonly SemaphoreSlim _unwrapGate = new(1, 1); + private readonly ConcurrentDictionary _cache = []; + + public EnvelopeEncryptionKeyProvider( + IOptions keyVaultOptions, + IConfiguration configuration, + MySqlConnectionStringProvider connectionStringProvider, + ILogger logger) + { + _keyVaultOptions = keyVaultOptions.Value; + _configuration = configuration; + _connectionStringProvider = connectionStringProvider; + _logger = logger; + } + + public async ValueTask GetCurrentKeyAsync(CancellationToken cancellationToken = default) + { + if (!_keyVaultOptions.Enabled) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + return CreateLocalDevelopmentKey(today); + } + + using var timeout = CreateKeyOperationTimeout(cancellationToken); + try + { + var row = await LoadCurrentKeyRowAsync(timeout.Token); + if (!string.Equals(row.Status, "active", StringComparison.OrdinalIgnoreCase)) + { + throw new EncryptionKeyUnavailableException( + $"No hay clave activa para hoy ({row.KeyDate:yyyy-MM-dd}). Estado actual: {row.Status}."); + } + + return await GetOrUnwrapAsync(row, timeout.Token); + } + catch (OperationCanceledException ex) + { + throw new EncryptionKeyUnavailableException( + $"Timeout cargando la clave diaria activa desde encryption_keys/Key Vault tras {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.", + ex); + } + } + + public async ValueTask GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default) + { + if (!_keyVaultOptions.Enabled) + { + return CreateLocalDevelopmentKey(keyDate); + } + + using var timeout = CreateKeyOperationTimeout(cancellationToken); + try + { + var row = await LoadKeyRowAsync(keyDate, timeout.Token); + if (string.Equals(row.Status, "purged", StringComparison.OrdinalIgnoreCase)) + { + _cache.TryRemove(keyDate, out _); + throw new EncryptedDataPurgedException(keyDate); + } + + if (!string.Equals(row.Status, "active", StringComparison.OrdinalIgnoreCase)) + { + throw new EncryptionKeyUnavailableException( + $"La clave diaria {keyDate:yyyy-MM-dd} no esta activa. Estado actual: {row.Status}."); + } + + return await GetOrUnwrapAsync(row, timeout.Token); + } + catch (OperationCanceledException ex) + { + throw new EncryptionKeyUnavailableException( + $"Timeout cargando la clave diaria {keyDate:yyyy-MM-dd} desde encryption_keys/Key Vault tras {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.", + ex); + } + } + + private CancellationTokenSource CreateKeyOperationTimeout(CancellationToken cancellationToken) + { + var timeoutSeconds = Math.Clamp(_keyVaultOptions.EncryptionKeyTimeoutSeconds, 5, 120); + var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + return timeout; + } + + private async Task LoadCurrentKeyRowAsync(CancellationToken cancellationToken) + { + const string sql = """ + SELECT key_date, key_version, edek, status + FROM encryption_keys + WHERE key_date = CURDATE() + LIMIT 1; + """; + + return await LoadKeyRowCoreAsync(sql, keyDate: null, cancellationToken); + } + + private async Task LoadKeyRowAsync(DateOnly keyDate, CancellationToken cancellationToken) + { + const string sql = """ + SELECT key_date, key_version, edek, status + FROM encryption_keys + WHERE key_date = @keyDate + LIMIT 1; + """; + + return await LoadKeyRowCoreAsync(sql, keyDate, cancellationToken); + } + + private async Task LoadKeyRowCoreAsync( + string sql, + DateOnly? keyDate, + CancellationToken cancellationToken) + { + await using var connection = await OpenConnectionAsync(cancellationToken); + await using var command = new MySqlCommand(sql, connection); + if (keyDate.HasValue) + { + command.Parameters.AddWithValue("@keyDate", keyDate.Value.ToDateTime(TimeOnly.MinValue)); + } + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + var dateMessage = keyDate.HasValue + ? $"para {keyDate.Value:yyyy-MM-dd}" + : "para hoy"; + throw new EncryptionKeyUnavailableException( + $"No existe fila en encryption_keys {dateMessage}. Ejecutar la Function App de claves antes de arrancar la API."); + } + + return new EncryptionKeyRow( + DateOnly.FromDateTime(reader.GetDateTime("key_date")), + reader.GetString("key_version"), + reader.GetString("edek"), + reader.GetString("status")); + } + + private async Task OpenConnectionAsync(CancellationToken cancellationToken) + { + var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken); + var connection = new MySqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection); + await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken); + + return connection; + } + + private async Task GetOrUnwrapAsync( + EncryptionKeyRow row, + CancellationToken cancellationToken) + { + var edekHash = ComputeSha256Hex(row.Edek); + + try + { + await _unwrapGate.WaitAsync(cancellationToken); + } + catch (OperationCanceledException ex) + { + throw new EncryptionKeyUnavailableException( + $"No se ha podido obtener el bloqueo de carga de clave en {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.", + ex); + } + + try + { + if (_cache.TryGetValue(row.KeyDate, out var cached) && + string.Equals(cached.KeyVersion, row.KeyVersion, StringComparison.Ordinal) && + string.Equals(cached.EdekHash, edekHash, StringComparison.Ordinal)) + { + return cached.ToMaterial(); + } + + _logger.LogInformation( + "Cargando DEK diaria desde Key Vault. KeyDate={KeyDate}; KeyVersion={KeyVersion}", + row.KeyDate, + row.KeyVersion); + + var dek = await UnwrapDekAsync(row, cancellationToken); + var cachedKey = new CachedEncryptionKey( + row.KeyDate, + row.KeyVersion, + edekHash, + dek); + _cache[row.KeyDate] = cachedKey; + + _logger.LogInformation( + "DEK diaria cargada en memoria desde encryption_keys. KeyDate={KeyDate}; KeyVersion={KeyVersion}", + row.KeyDate, + row.KeyVersion); + + return cachedKey.ToMaterial(); + } + finally + { + _unwrapGate.Release(); + } + } + + private static async Task UnwrapDekAsync( + EncryptionKeyRow row, + CancellationToken cancellationToken) + { + byte[] encryptedDek; + try + { + encryptedDek = Convert.FromBase64String(row.Edek.Trim()); + } + catch (FormatException ex) + { + throw new EncryptionKeyUnavailableException( + $"La eDEK de encryption_keys para {row.KeyDate:yyyy-MM-dd} no es Base64 valida.", + ex); + } + + try + { + var cryptographyClient = new CryptographyClient(new Uri(row.KeyVersion), new DefaultAzureCredential()); + var response = await cryptographyClient.UnwrapKeyAsync( + KeyWrapAlgorithm.RsaOaep, + encryptedDek, + cancellationToken); + + var dek = response.Key; + if (dek.Length != 32) + { + throw new EncryptionKeyUnavailableException( + $"La DEK de {row.KeyDate:yyyy-MM-dd} no tiene 32 bytes tras unwrapKey."); + } + + return dek.ToArray(); + } + catch (RequestFailedException ex) + { + throw new EncryptionKeyUnavailableException( + $"Key Vault no ha podido hacer unwrapKey para {row.KeyDate:yyyy-MM-dd}. Status={ex.Status}; Error={ex.ErrorCode}.", + ex); + } + catch (OperationCanceledException ex) + { + throw new EncryptionKeyUnavailableException( + $"Timeout obteniendo la DEK diaria {row.KeyDate:yyyy-MM-dd} desde encryption_keys/Key Vault. Revisa acceso a Key Vault, permisos unwrapKey y conectividad.", + ex); + } + } + + private EncryptionKeyMaterial CreateLocalDevelopmentKey(DateOnly keyDate) + { + var configuredLocalKey = _configuration["Encryption:LocalDevelopmentKey"]; + if (string.IsNullOrWhiteSpace(configuredLocalKey)) + { + throw new EncryptionKeyUnavailableException( + "Key Vault esta deshabilitado y no se ha configurado Encryption:LocalDevelopmentKey."); + } + + return new EncryptionKeyMaterial( + keyDate, + NormalizeKey(configuredLocalKey), + "local-development", + EncryptionKeyMaterial.EnvelopeScheme); + } + + private static byte[] NormalizeKey(string secretValue) + { + var trimmed = secretValue.Trim(); + + try + { + var base64Key = Convert.FromBase64String(trimmed); + if (base64Key.Length is 16 or 24 or 32) + { + return base64Key.Length == 32 ? base64Key : SHA256.HashData(base64Key); + } + } + catch (FormatException) + { + // Si no es base64, derivamos una clave estable desde el valor textual. + } + + return SHA256.HashData(Encoding.UTF8.GetBytes(trimmed)); + } + + private static string ComputeSha256Hex(string value) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private sealed record EncryptionKeyRow( + DateOnly KeyDate, + string KeyVersion, + string Edek, + string Status); + + private sealed record CachedEncryptionKey( + DateOnly KeyDate, + string KeyVersion, + string EdekHash, + byte[] Key) + { + public EncryptionKeyMaterial ToMaterial() + => new(KeyDate, Key.ToArray(), KeyVersion, EncryptionKeyMaterial.EnvelopeScheme); + } +} diff --git a/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs b/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs index 1abb992..f13a7ea 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs @@ -2,13 +2,18 @@ using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; namespace ApiDenuncias.Services; public sealed class GestionaDocumentWorkflowService { + private const string CircuitTemplateFileDocContentType = + "application/vnd.gestiona.circuits.template-filedoc+json; version=7"; + + private const string FileDocumentContentType = + "application/vnd.gestiona.file-document+json; version=4"; + private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; @@ -52,14 +57,12 @@ public sealed class GestionaDocumentWorkflowService metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); metaReq.Headers.Accept.Clear(); - metaReq.Headers.Accept.Add( - MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4")); metaReq.Content = new StringContent(metaJson, Encoding.UTF8); - metaReq.Content.Headers.ContentType = - MediaTypeHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"); + metaReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(FileDocumentContentType); using var metaResp = await CreateRawHttp().SendAsync(metaReq); + LogDeprecatedHeaders(metaResp, "POST documento Gestiona"); var body = await metaResp.Content.ReadAsStringAsync(); if (!metaResp.IsSuccessStatusCode) @@ -105,96 +108,78 @@ public sealed class GestionaDocumentWorkflowService public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null) { - var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase); - var payload = BuildConfiguredCircuitPayload(docUrlAbs, assignedGroupHref, complaintId); - string? templateNameForLog = "configurada"; - string? templateHrefForLog = GetConfiguredTemplateHref(docUrlAbs); + _ = assignedGroupHref; - if (payload is null) + var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase); + var templateHref = GetConfiguredTemplateHref(docUrlAbs); + + if (string.IsNullOrWhiteSpace(templateHref)) { throw new InvalidOperationException( - "Faltan Gestiona:CircuitTemplateId o Gestiona:CircuitSignerStampHref. No se listan plantillas para evitar campos deprecated."); + "Falta Gestiona:CircuitTemplateId. No se listan plantillas para evitar campos deprecated."); } - var json = payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var payload = await GetCircuitTemplatePayloadAsync(templateHref); + var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload); + if (success) + { + _logger.LogInformation( + "Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}.", + docUrlAbs, + templateHref, + complaintId); + return; + } - using var req = new HttpRequestMessage(HttpMethod.Post, $"{docUrlAbs.TrimEnd('/')}/circuit"); + _logger.LogError( + "Fallo al tramitar documento {DocumentUrl} con plantilla configurada ({TemplateHref}). Status: {StatusCode}. Body: {Body}", + docUrlAbs, + templateHref, + (int)statusCode, + body); + + throw new InvalidOperationException( + $"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}"); + } + + private async Task GetCircuitTemplatePayloadAsync(string templateHref) + { + using var req = new HttpRequestMessage(HttpMethod.Get, templateHref); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); - req.Headers.TryAddWithoutValidation("Accept", "application/json"); - req.Content = new StringContent(json, Encoding.UTF8); - req.Content.Headers.ContentType = - MediaTypeHeaderValue.Parse("application/vnd.gestiona.circuits.template-filedoc+json; version=7"); + req.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse(CircuitTemplateFileDocContentType)); using var resp = await CreateRawHttp().SendAsync(req); + LogDeprecatedHeaders(resp, "GET plantilla circuito Gestiona"); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { - _logger.LogError( - "Fallo al tramitar documento {DocumentUrl} con plantilla {TemplateName} ({TemplateHref}). Status: {StatusCode}. Body: {Body}", - docUrlAbs, - templateNameForLog, - templateHrefForLog, - (int)resp.StatusCode, - body); - throw new InvalidOperationException( - $"TramitarDocumentoAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); + $"GetCircuitTemplatePayloadAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); } - _logger.LogInformation( - "Documento {DocumentUrl} enviado a circuito {TemplateName} ({TemplateHref}) para denuncia {ComplaintId}.", - docUrlAbs, - templateNameForLog, - templateHrefForLog, - complaintId); + if (string.IsNullOrWhiteSpace(body)) + { + throw new InvalidOperationException("Gestiona ha devuelto vacia la plantilla de circuito configurada."); + } + + return body; } - private JsonObject? BuildConfiguredCircuitPayload(string documentUrl, string assignedGroupHref, int? complaintId) + private async Task<(bool Success, System.Net.HttpStatusCode StatusCode, string Body)> TryPostCircuitAsync( + string documentUrl, + string payload) { - _ = assignedGroupHref; - _ = complaintId; + using var req = new HttpRequestMessage(HttpMethod.Post, $"{documentUrl.TrimEnd('/')}/circuit"); + req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); + req.Headers.TryAddWithoutValidation("Accept", "application/json"); + req.Content = new StringContent(payload, Encoding.UTF8); + req.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(CircuitTemplateFileDocContentType); - var templateHref = GetConfiguredTemplateHref(documentUrl); - var signerHref = _configuration["Gestiona:CircuitSignerStampHref"]; - if (string.IsNullOrWhiteSpace(templateHref) || string.IsNullOrWhiteSpace(signerHref)) - { - return null; - } - - var payload = new JsonObject - { - ["block_edit"] = true, - ["send_alerts"] = true, - ["version"] = _configuration["Gestiona:CircuitVersion"] ?? "2", - ["signers"] = new JsonArray - { - JsonSerializer.SerializeToNode(new - { - rel = "signer-stamp", - href = signerHref, - title = _configuration["Gestiona:CircuitSignerStampTitle"] ?? "oaaf-complaints-tramit" - }) - }, - ["links"] = new JsonArray - { - JsonSerializer.SerializeToNode(new { rel = "self", href = templateHref }) - } - }; - - var recipientGroupHref = _configuration["Gestiona:CircuitRecipientGroupHref"]; - if (!string.IsNullOrWhiteSpace(recipientGroupHref)) - { - payload["recipients"] = new JsonArray - { - JsonSerializer.SerializeToNode(new - { - rel = "group", - href = recipientGroupHref - }) - }; - } - - return payload; + using var resp = await CreateRawHttp().SendAsync(req); + LogDeprecatedHeaders(resp, "POST circuito Gestiona"); + var body = await resp.Content.ReadAsStringAsync(); + return (resp.IsSuccessStatusCode, resp.StatusCode, body); } private string? GetConfiguredTemplateHref(string documentUrl) @@ -211,10 +196,12 @@ public sealed class GestionaDocumentWorkflowService { using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads"); createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); + createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); createReq.Headers.Accept.Add( MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json")); using var createResp = await CreateRawHttp().SendAsync(createReq); + LogDeprecatedHeaders(createResp, "POST upload Gestiona"); var createBody = await createResp.Content.ReadAsStringAsync(); if (!createResp.IsSuccessStatusCode) { @@ -236,6 +223,7 @@ public sealed class GestionaDocumentWorkflowService putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); using var putResp = await CreateRawHttp().SendAsync(putReq); + LogDeprecatedHeaders(putResp, "PUT upload Gestiona"); var infoJson = await putResp.Content.ReadAsStringAsync(); if (!putResp.IsSuccessStatusCode) { @@ -291,4 +279,15 @@ public sealed class GestionaDocumentWorkflowService return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } + private void LogDeprecatedHeaders(HttpResponseMessage response, string operation) + { + if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated)) + { + _logger.LogWarning( + "Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}", + operation, + string.Join(" | ", deprecated)); + } + } + } diff --git a/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs b/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs index bf577bb..b4a2403 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs @@ -119,10 +119,12 @@ namespace ApiDenuncias.Services var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId); using var req = new HttpRequestMessage(HttpMethod.Post, url); req.Headers.Accept.Clear(); - req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-opening+json; version=1")); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); using var resp = await _http.SendAsync(req); + LogDeprecatedHeaders(resp, "POST create-file"); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); @@ -187,6 +189,7 @@ namespace ApiDenuncias.Services AddTokenAndAccept(req, "application/json"); using var resp = await _http.SendAsync(req); + LogDeprecatedHeaders(resp, "POST file-open"); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); @@ -205,9 +208,11 @@ namespace ApiDenuncias.Services Content = content }; req.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); - AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1"); + req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); + req.Headers.Accept.Clear(); using var resp = await _http.SendAsync(req); + LogDeprecatedHeaders(resp, "POST file-folder"); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateFolderAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); @@ -225,10 +230,12 @@ namespace ApiDenuncias.Services { using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads"); createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); + createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); createReq.Headers.Accept.Add( MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json")); using var createResp = await _http.SendAsync(createReq); + LogDeprecatedHeaders(createResp, "POST /rest/uploads"); var createBody = await createResp.Content.ReadAsStringAsync(); if (!createResp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}"); @@ -254,6 +261,7 @@ namespace ApiDenuncias.Services putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); using var putResp = await _http.SendAsync(putReq); + LogDeprecatedHeaders(putResp, "PUT upload content"); var infoJson = await putResp.Content.ReadAsStringAsync(); if (!putResp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}"); @@ -294,10 +302,9 @@ namespace ApiDenuncias.Services metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); metaReq.Headers.Accept.Clear(); - metaReq.Headers.Accept.Add( - MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4")); using var metaResp = await _http.SendAsync(metaReq); + LogDeprecatedHeaders(metaResp, "POST file document"); var body = await metaResp.Content.ReadAsStringAsync(); if (!metaResp.IsSuccessStatusCode) throw new InvalidOperationException($"UploadDocumentAsync: {(int)metaResp.StatusCode} {metaResp.StatusCode}\n{body}"); @@ -513,6 +520,14 @@ namespace ApiDenuncias.Services if (string.IsNullOrEmpty(encontrado.SelfHref)) { + if (!CanCreateThirdParty(thirdParty)) + { + _logger.LogWarning( + "Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.", + fileUrl); + return; + } + encontrado = await CrearTerceroAsync(thirdParty); } else if (thirdParty.Address?.HasAnyValue == true) @@ -529,7 +544,27 @@ namespace ApiDenuncias.Services { if (!thirdParty.IsAnonymous) { - return thirdParty; + var documentId = (thirdParty.DocumentId ?? string.Empty).Trim().ToUpperInvariant(); + var firstName = (thirdParty.FirstName ?? string.Empty).Trim(); + var lastName = (thirdParty.LastName ?? string.Empty).Trim(); + var businessName = (thirdParty.BusinessName ?? string.Empty).Trim(); + var isLegalEntity = thirdParty.IsLegalEntity || + LooksLikeLegalEntityDocument(documentId) || + (!string.IsNullOrWhiteSpace(businessName) && + string.IsNullOrWhiteSpace(firstName)); + + return new ThirdPartyIdentityData + { + IsAnonymous = false, + IsLegalEntity = isLegalEntity, + DocumentId = documentId, + FirstName = isLegalEntity ? string.Empty : firstName, + LastName = isLegalEntity ? string.Empty : lastName, + BusinessName = businessName, + Email = (thirdParty.Email ?? string.Empty).Trim(), + CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(), + Address = thirdParty.Address + }; } return new ThirdPartyIdentityData @@ -546,6 +581,25 @@ namespace ApiDenuncias.Services }; } + private static bool CanCreateThirdParty(ThirdPartyIdentityData thirdParty) + { + if (string.IsNullOrWhiteSpace(thirdParty.DocumentId)) + { + return false; + } + + return thirdParty.IsLegalEntity + ? !string.IsNullOrWhiteSpace(thirdParty.BusinessName) + : !string.IsNullOrWhiteSpace(thirdParty.FirstName) && + !string.IsNullOrWhiteSpace(thirdParty.LastName); + } + + private static bool LooksLikeLegalEntityDocument(string documentId) + { + var value = (documentId ?? string.Empty).Trim().ToUpperInvariant(); + return Regex.IsMatch(value, @"^[ABCDEFGHJKLMNPQRSUVW]\d{7}[A-Z0-9]$"); + } + // --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) --- private async Task GetFilesAsync(object? filter = null) @@ -559,7 +613,7 @@ namespace ApiDenuncias.Services if (filter is not null) { var json = JsonSerializer.Serialize(filter); - req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files"); + req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files+json"); } using var resp = await _http.SendAsync(req); diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs index 6573089..8586d03 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Globalization; using System.Net; using System.Net.Http.Headers; @@ -13,7 +13,7 @@ using Microsoft.Extensions.Options; namespace ApiDenuncias.Services; -public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword); +public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer); public sealed class GlobalLeaksClient { @@ -54,7 +54,12 @@ public sealed class GlobalLeaksClient CancellationToken cancellationToken) { var prepared = await PrepareLoginAsync(username, password, cancellationToken); - return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken); + return await CompleteLoginAsync( + prepared.Username, + prepared.FinalPassword, + prepared.TokenAnswer, + authcode, + cancellationToken); } public async Task PrepareLoginAsync( @@ -90,12 +95,14 @@ public sealed class GlobalLeaksClient username, passwordWatch.ElapsedMilliseconds); + var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken); + _logger.LogInformation( - "GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms", + "GlobalLeaks login: credenciales y proof-of-work preparados para {Username}. Tiempo total={ElapsedMs} ms", username, loginWatch.ElapsedMilliseconds); - return new PreparedGlobalLeaksCredentials(username, finalPassword); + return new PreparedGlobalLeaksCredentials(username, finalPassword, tokenAnswer); } public async Task CompleteLoginAsync( @@ -103,13 +110,87 @@ public sealed class GlobalLeaksClient string finalPassword, string authcode, CancellationToken cancellationToken) + { + var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken); + return await CompleteLoginAsync(username, finalPassword, tokenAnswer, authcode, cancellationToken); + } + + public async Task CompleteLoginAsync( + string username, + string finalPassword, + string tokenAnswer, + string authcode, + CancellationToken cancellationToken) { var loginWatch = Stopwatch.StartNew(); _logger.LogInformation( - "GlobalLeaks login: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}", + "GlobalLeaks login: enviando autenticacion final para {Username}. AuthcodeLength={AuthcodeLength}", username, authcode?.Length ?? 0); + var currentTokenAnswer = tokenAnswer; + for (var attempt = 0; attempt < 2; attempt++) + { + using var authRequest = CreateRequest( + HttpMethod.Post, + $"/api/auth/authentication?token={Uri.EscapeDataString(currentTokenAnswer)}"); + authRequest.Content = CreateJsonContent(new + { + tid = 1, + username, + password = finalPassword, + authcode = authcode?.Trim() ?? string.Empty, + }); + authRequest.Headers.TryAddWithoutValidation("X-Token", currentTokenAnswer); + + using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken); + if (authResponse.IsSuccessStatusCode) + { + var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); + var session = ParseAuthSession(authBody, username); + _logger.LogInformation( + "Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms", + session.Username, + session.Role ?? "(sin rol)", + loginWatch.ElapsedMilliseconds); + return session; + } + + var body = await ReadBodySafeAsync(authResponse, cancellationToken); + if (attempt == 0 && IsMissingTokenOrSessionError(body)) + { + _logger.LogWarning( + "GlobalLeaks rechazo el token preparado para {Username}; se generara un token nuevo y se reintentara la autenticacion.", + username); + currentTokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken); + continue; + } + + throw authResponse.StatusCode switch + { + HttpStatusCode.Unauthorized => new GlobalLeaksValidationException( + "Credenciales incorrectas o codigo 2FA invalido.", + StatusCodes.Status401Unauthorized), + (HttpStatusCode)429 => new GlobalLeaksValidationException( + "Demasiados intentos en GlobalLeaks. Espera unos minutos.", + StatusCodes.Status429TooManyRequests), + HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) => + new GlobalLeaksValidationException( + "Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.", + StatusCodes.Status406NotAcceptable), + _ => new GlobalLeaksValidationException( + string.IsNullOrWhiteSpace(body) + ? $"Login fallido (codigo {(int)authResponse.StatusCode})." + : $"Login fallido (codigo {(int)authResponse.StatusCode}): {body}", + (int)authResponse.StatusCode), + }; + } + + throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502); + } + + private async Task PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken) + { using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token"); using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken); await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken); @@ -123,51 +204,7 @@ public sealed class GlobalLeaksClient "GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms", username, proofWatch.ElapsedMilliseconds); - - using var authRequest = CreateRequest( - HttpMethod.Post, - $"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}"); - authRequest.Content = CreateJsonContent(new - { - tid = 1, - username, - password = finalPassword, - authcode = authcode?.Trim() ?? string.Empty, - }); - authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer); - - using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken); - if (!authResponse.IsSuccessStatusCode) - { - var body = await ReadBodySafeAsync(authResponse, cancellationToken); - throw authResponse.StatusCode switch - { - HttpStatusCode.Unauthorized => new GlobalLeaksValidationException( - "Credenciales incorrectas o código 2FA inválido.", - StatusCodes.Status401Unauthorized), - (HttpStatusCode)429 => new GlobalLeaksValidationException( - "Demasiados intentos en GlobalLeaks. Espera unos minutos.", - StatusCodes.Status429TooManyRequests), - HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) => - new GlobalLeaksValidationException( - "Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.", - StatusCodes.Status406NotAcceptable), - _ => new GlobalLeaksValidationException( - string.IsNullOrWhiteSpace(body) - ? $"Login fallido (código {(int)authResponse.StatusCode})." - : $"Login fallido (código {(int)authResponse.StatusCode}): {body}", - (int)authResponse.StatusCode), - }; - } - - var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); - var session = ParseAuthSession(authBody, username); - _logger.LogInformation( - "Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms", - session.Username, - session.Role ?? "(sin rol)", - loginWatch.ElapsedMilliseconds); - return session; + return tokenAnswer; } public async Task> GetContextsAsync( @@ -187,7 +224,8 @@ public sealed class GlobalLeaksClient string? filter, string? dateFrom, string? dateTo, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IReadOnlyList? contexts = null) { filter ??= "all"; @@ -197,8 +235,8 @@ public sealed class GlobalLeaksClient var tips = ParseReports(body); _logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count); - var contexts = await GetContextsAsync(sessionId, cancellationToken); - var contextNames = contexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase); + var availableContexts = contexts ?? await GetContextsAsync(sessionId, cancellationToken); + var contextNames = availableContexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase); IEnumerable filtered = tips; @@ -263,21 +301,11 @@ public sealed class GlobalLeaksClient public async Task GetReportDetailAsync( string sessionId, string reportId, + string? lastAccess, CancellationToken cancellationToken) { ValidateUuid(reportId); - using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId); - using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken); - var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken); - var metadata = ParseReports(listBody) - .FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase)); - - if (metadata is null) - { - throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404); - } - using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken); @@ -291,7 +319,7 @@ public sealed class GlobalLeaksClient } using var document = JsonDocument.Parse(content); - return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement); + return ParseReportDetail(reportId, lastAccess, document.RootElement); } public async Task DownloadReportZipAsync( @@ -887,6 +915,13 @@ public sealed class GlobalLeaksClient body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase); } + private static bool IsMissingTokenOrSessionError(string body) + { + return !string.IsNullOrWhiteSpace(body) && + (body.Contains("No token and no session", StringComparison.OrdinalIgnoreCase) || + body.Contains("Invalid request: No token", StringComparison.OrdinalIgnoreCase)); + } + private sealed record TokenResponse(string Id, string Salt); private sealed record AuthTypeResponse(string Type, string Salt); diff --git a/Antifraude.Net/ApiDenuncias/Services/IEnvelopeEncryptionKeyProvider.cs b/Antifraude.Net/ApiDenuncias/Services/IEnvelopeEncryptionKeyProvider.cs new file mode 100644 index 0000000..321a8fd --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/IEnvelopeEncryptionKeyProvider.cs @@ -0,0 +1,8 @@ +namespace ApiDenuncias.Services; + +public interface IEnvelopeEncryptionKeyProvider +{ + ValueTask GetCurrentKeyAsync(CancellationToken cancellationToken = default); + + ValueTask GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default); +} diff --git a/Antifraude.Net/ApiDenuncias/Services/IFilteredDenunciaStore.cs b/Antifraude.Net/ApiDenuncias/Services/IFilteredDenunciaStore.cs new file mode 100644 index 0000000..abf8859 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/IFilteredDenunciaStore.cs @@ -0,0 +1,19 @@ +using GestionaDenuncias.Shared.Models; + +namespace ApiDenuncias.Services; + +public interface IFilteredDenunciaStore +{ + Task> GetDenunciasByIdsAsync( + IReadOnlyCollection denunciaIds, + CancellationToken cancellationToken = default); + + Task> GetDenunciasByIdsAsync( + IReadOnlyCollection denunciaIds, + DenunciaListScope scope, + CancellationToken cancellationToken = default); + + Task> GetFicherosByDenunciaIdsAsync( + IReadOnlyCollection denunciaIds, + CancellationToken cancellationToken = default); +} diff --git a/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs b/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs index d5e02b2..07a1a36 100644 --- a/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Data; using GestionaDenuncias.Shared.Models; using MySqlConnector; @@ -84,10 +85,11 @@ public sealed class InboxTrackingService : IInboxTrackingService } catch { - await transaction.RollbackAsync(cancellationToken); + await SafeRollbackAsync(transaction, cancellationToken); throw; } + await EnsureConnectionOpenAsync(connection, cancellationToken); var metadata = await LoadMetadataAsync(connection, userId, reportList.Select(r => r.Id).ToList(), cancellationToken); return reportList @@ -132,10 +134,11 @@ public sealed class InboxTrackingService : IInboxTrackingService } catch { - await transaction.RollbackAsync(cancellationToken); + await SafeRollbackAsync(transaction, cancellationToken); throw; } + await EnsureConnectionOpenAsync(connection, cancellationToken); var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken); if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser) { @@ -254,7 +257,7 @@ public sealed class InboxTrackingService : IInboxTrackingService } catch { - await transaction.RollbackAsync(cancellationToken); + await SafeRollbackAsync(transaction, cancellationToken); throw; } } @@ -386,6 +389,8 @@ public sealed class InboxTrackingService : IInboxTrackingService List reportIds, CancellationToken cancellationToken) { + await EnsureConnectionOpenAsync(connection, cancellationToken); + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); if (reportIds.Count == 0) { @@ -462,6 +467,34 @@ public sealed class InboxTrackingService : IInboxTrackingService return connection; } + private static async Task EnsureConnectionOpenAsync( + MySqlConnection connection, + CancellationToken cancellationToken) + { + if (connection.State == ConnectionState.Open) + { + return; + } + + await connection.OpenAsync(cancellationToken); + await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection); + await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task SafeRollbackAsync( + MySqlTransaction transaction, + CancellationToken cancellationToken) + { + try + { + await transaction.RollbackAsync(cancellationToken); + } + catch + { + // No dejamos que un rollback sobre una conexion ya cerrada oculte el error real. + } + } + private static object ToDbString(string? value) { return string.IsNullOrWhiteSpace(value) ? DBNull.Value : value; diff --git a/Antifraude.Net/ApiDenuncias/Services/ManualPurgeService.cs b/Antifraude.Net/ApiDenuncias/Services/ManualPurgeService.cs new file mode 100644 index 0000000..019b8c8 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/ManualPurgeService.cs @@ -0,0 +1,252 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using ApiDenuncias.Configuration; +using Azure; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Options; + +namespace ApiDenuncias.Services; + +public sealed class ManualPurgeService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly HttpClient _httpClient; + private readonly KeyVaultOptions _keyVaultOptions; + private readonly ManualPurgeOptions _manualPurgeOptions; + private readonly ILogger _logger; + + public ManualPurgeService( + HttpClient httpClient, + IOptions keyVaultOptions, + IOptions manualPurgeOptions, + ILogger logger) + { + _httpClient = httpClient; + _keyVaultOptions = keyVaultOptions.Value; + _manualPurgeOptions = manualPurgeOptions.Value; + _logger = logger; + } + + public async Task ExecuteAsync(DateOnly purgeDate, CancellationToken cancellationToken) + { + var configuredFunctionKey = Environment.GetEnvironmentVariable("KEYMGMT_FUNCTION_KEY"); + var hasDirectFunctionKey = !string.IsNullOrWhiteSpace(configuredFunctionKey); + + if (!_keyVaultOptions.Enabled && !hasDirectFunctionKey) + { + throw new InvalidOperationException("Key Vault debe estar habilitado o KEYMGMT_FUNCTION_KEY debe estar configurada para ejecutar una purga manual."); + } + + if (!hasDirectFunctionKey && string.IsNullOrWhiteSpace(_keyVaultOptions.VaultUrl)) + { + throw new InvalidOperationException("KeyVault:VaultUrl no esta configurado."); + } + + var functionUri = ResolveEndpointUri( + _manualPurgeOptions.FunctionUrl, + "manual_purge", + "ManualPurge:FunctionUrl"); + + Uri? forceRotateUri = null; + if (_manualPurgeOptions.ReplaceOnManualPurge && _manualPurgeOptions.RecoverPartialReplaceFailure) + { + forceRotateUri = ResolveEndpointUri( + _manualPurgeOptions.ForceRotateUrl, + "force_rotate", + "ManualPurge:ForceRotateUrl"); + } + + if (!hasDirectFunctionKey && string.IsNullOrWhiteSpace(_manualPurgeOptions.FunctionKeySecretName)) + { + throw new InvalidOperationException("ManualPurge:FunctionKeySecretName no esta configurado."); + } + + var timeoutSeconds = Math.Clamp(_manualPurgeOptions.TimeoutSeconds, 5, 120); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + var dateText = purgeDate.ToString("yyyy-MM-dd"); + var functionKey = await ReadFunctionKeyAsync(timeout.Token); + + using var request = new HttpRequestMessage(HttpMethod.Post, functionUri); + request.Headers.TryAddWithoutValidation("x-functions-key", functionKey); + request.Content = JsonContent.Create( + new { date = dateText, replace = _manualPurgeOptions.ReplaceOnManualPurge }, + options: JsonOptions); + + _logger.LogWarning( + "Ejecutando purga manual mediante Function App. Date={Date}; Replace={Replace}; FunctionUrl={FunctionUrl}; SecretName={SecretName}", + dateText, + _manualPurgeOptions.ReplaceOnManualPurge, + functionUri, + _manualPurgeOptions.FunctionKeySecretName); + + using var response = await _httpClient.SendAsync(request, timeout.Token); + var body = await response.Content.ReadAsStringAsync(timeout.Token); + + if (!response.IsSuccessStatusCode) + { + if (forceRotateUri is not null && IsPartialPurgeFailure(body)) + { + return await RecoverWithForceRotateAsync( + forceRotateUri, + dateText, + functionKey, + body, + timeout.Token); + } + + throw new InvalidOperationException( + $"La Function App de purga ha respondido con {(int)response.StatusCode} ({response.ReasonPhrase}). Body: {TrimBody(body)}"); + } + + _logger.LogWarning( + "Purga manual completada por Function App. Date={Date}; StatusCode={StatusCode}", + dateText, + (int)response.StatusCode); + + return new ManualPurgeResponse( + dateText, + Success: true, + StatusCode: (int)response.StatusCode, + ResponseBody: body); + } + + private async Task RecoverWithForceRotateAsync( + Uri forceRotateUri, + string dateText, + string functionKey, + string purgeBody, + CancellationToken cancellationToken) + { + _logger.LogWarning( + "La purga manual se completo, pero fallo la creacion de la nueva clave. Reintentando force_rotate. Date={Date}; ForceRotateUrl={ForceRotateUrl}", + dateText, + forceRotateUri); + + using var request = new HttpRequestMessage(HttpMethod.Post, forceRotateUri); + request.Headers.TryAddWithoutValidation("x-functions-key", functionKey); + request.Content = JsonContent.Create(new { date = dateText }, options: JsonOptions); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Conflict) + { + throw new InvalidOperationException( + "La purga se completo, pero no se ha podido crear la nueva clave con force_rotate. " + + $"Status={(int)response.StatusCode} ({response.ReasonPhrase}). Body: {TrimBody(body)}. " + + $"Respuesta original manual_purge: {TrimBody(purgeBody)}"); + } + + _logger.LogWarning( + "Recuperacion force_rotate completada tras purga manual parcial. Date={Date}; StatusCode={StatusCode}", + dateText, + (int)response.StatusCode); + + var recoveryBody = JsonSerializer.Serialize( + new + { + message = "manual_purge purgo la clave, pero fallo al crear la nueva. La API ha reintentado force_rotate para la misma fecha.", + manual_purge_body = TrimBody(purgeBody), + force_rotate_status = (int)response.StatusCode, + force_rotate_body = TrimBody(body) + }, + JsonOptions); + + return new ManualPurgeResponse( + dateText, + Success: true, + StatusCode: (int)response.StatusCode, + ResponseBody: recoveryBody); + } + + private async Task ReadFunctionKeyAsync(CancellationToken cancellationToken) + { + var configuredFunctionKey = Environment.GetEnvironmentVariable("KEYMGMT_FUNCTION_KEY"); + if (!string.IsNullOrWhiteSpace(configuredFunctionKey)) + { + return configuredFunctionKey.Trim(); + } + + try + { + var client = new SecretClient(new Uri(_keyVaultOptions.VaultUrl), new DefaultAzureCredential()); + var secret = await client.GetSecretAsync( + _manualPurgeOptions.FunctionKeySecretName.Trim(), + cancellationToken: cancellationToken); + + if (string.IsNullOrWhiteSpace(secret.Value.Value)) + { + throw new InvalidOperationException( + $"El secreto '{_manualPurgeOptions.FunctionKeySecretName}' de Key Vault esta vacio."); + } + + return secret.Value.Value.Trim(); + } + catch (RequestFailedException ex) + { + throw new InvalidOperationException( + $"No se ha podido leer el secreto '{_manualPurgeOptions.FunctionKeySecretName}' de Key Vault. Status={ex.Status}; Error={ex.ErrorCode}.", + ex); + } + } + + private static string TrimBody(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return "(sin cuerpo)"; + } + + return body.Length <= 1200 + ? body + : body[..1200] + "..."; + } + + private static bool IsPartialPurgeFailure(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(body); + return document.RootElement.TryGetProperty("purged", out var purged) && + purged.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } + + private Uri ResolveEndpointUri(string configuredUrl, string endpointName, string optionName) + { + var baseUrl = FirstNonWhiteSpace( + Environment.GetEnvironmentVariable("KEYMGMT_BASE_URL"), + _manualPurgeOptions.BaseUrl); + + var candidateUrl = string.IsNullOrWhiteSpace(baseUrl) + ? configuredUrl + : $"{baseUrl.Trim().TrimEnd('/')}/{endpointName}"; + + if (!Uri.TryCreate(candidateUrl, UriKind.Absolute, out var uri)) + { + var sourceName = string.IsNullOrWhiteSpace(baseUrl) + ? optionName + : "KEYMGMT_BASE_URL/ManualPurge:BaseUrl"; + throw new InvalidOperationException($"{sourceName} no contiene una URL valida."); + } + + return uri; + } + + private static string? FirstNonWhiteSpace(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); +} diff --git a/Antifraude.Net/ApiDenuncias/Services/MySqlConnectionStringProvider.cs b/Antifraude.Net/ApiDenuncias/Services/MySqlConnectionStringProvider.cs index 1846d1d..8ddc569 100644 --- a/Antifraude.Net/ApiDenuncias/Services/MySqlConnectionStringProvider.cs +++ b/Antifraude.Net/ApiDenuncias/Services/MySqlConnectionStringProvider.cs @@ -12,6 +12,8 @@ public sealed class MySqlConnectionStringProvider private readonly ComplaintStorageOptions _storageOptions; private readonly KeyVaultOptions _keyVaultOptions; private readonly ILogger _logger; + private readonly SemaphoreSlim _loadGate = new(1, 1); + private string? _cachedConnectionString; public MySqlConnectionStringProvider( IOptions storageOptions, @@ -25,10 +27,28 @@ public sealed class MySqlConnectionStringProvider public async ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) { - return await LoadConnectionStringAsync().WaitAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(_cachedConnectionString)) + { + return _cachedConnectionString; + } + + await _loadGate.WaitAsync(cancellationToken); + try + { + if (string.IsNullOrWhiteSpace(_cachedConnectionString)) + { + _cachedConnectionString = await LoadConnectionStringAsync(cancellationToken); + } + + return _cachedConnectionString; + } + finally + { + _loadGate.Release(); + } } - private async Task LoadConnectionStringAsync() + private async Task LoadConnectionStringAsync(CancellationToken cancellationToken) { if (!_storageOptions.UseKeyVault || !_keyVaultOptions.Enabled) { @@ -48,12 +68,12 @@ public sealed class MySqlConnectionStringProvider } var client = new SecretClient(new Uri(_keyVaultOptions.VaultUrl), new DefaultAzureCredential()); - var host = await GetRequiredSecretAsync(client, _storageOptions.HostSecretName); - var user = await GetRequiredSecretAsync(client, _storageOptions.UserSecretName); - var password = await GetRequiredSecretAsync(client, _storageOptions.PasswordSecretName); - var database = await GetRequiredSecretAsync(client, _storageOptions.DatabaseSecretName); - var port = await GetOptionalUIntSecretAsync(client, _storageOptions.PortSecretName, _storageOptions.DefaultPort); - var sslMode = await GetOptionalSecretAsync(client, _storageOptions.SslModeSecretName, _storageOptions.DefaultSslMode); + var host = await GetRequiredSecretAsync(client, _storageOptions.HostSecretName, cancellationToken); + var user = await GetRequiredSecretAsync(client, _storageOptions.UserSecretName, cancellationToken); + var password = await GetRequiredSecretAsync(client, _storageOptions.PasswordSecretName, cancellationToken); + var database = await GetRequiredSecretAsync(client, _storageOptions.DatabaseSecretName, cancellationToken); + var port = await GetOptionalUIntSecretAsync(client, _storageOptions.PortSecretName, _storageOptions.DefaultPort, cancellationToken); + var sslMode = await GetOptionalSecretAsync(client, _storageOptions.SslModeSecretName, _storageOptions.DefaultSslMode, cancellationToken); var builder = new MySqlConnectionStringBuilder { @@ -76,9 +96,12 @@ public sealed class MySqlConnectionStringProvider return builder.ConnectionString; } - private static async Task GetRequiredSecretAsync(SecretClient client, string secretName) + private static async Task GetRequiredSecretAsync( + SecretClient client, + string secretName, + CancellationToken cancellationToken) { - var value = await GetOptionalSecretAsync(client, secretName, null); + var value = await GetOptionalSecretAsync(client, secretName, null, cancellationToken); if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException($"El secreto obligatorio '{secretName}' de Key Vault no existe o esta vacio."); @@ -87,7 +110,11 @@ public sealed class MySqlConnectionStringProvider return value.Trim(); } - private static async Task GetOptionalSecretAsync(SecretClient client, string secretName, string? fallback) + private static async Task GetOptionalSecretAsync( + SecretClient client, + string secretName, + string? fallback, + CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(secretName)) { @@ -96,7 +123,7 @@ public sealed class MySqlConnectionStringProvider try { - var secret = await client.GetSecretAsync(secretName.Trim()); + var secret = await client.GetSecretAsync(secretName.Trim(), cancellationToken: cancellationToken); return string.IsNullOrWhiteSpace(secret.Value.Value) ? fallback ?? string.Empty : secret.Value.Value.Trim(); @@ -107,9 +134,13 @@ public sealed class MySqlConnectionStringProvider } } - private static async Task GetOptionalUIntSecretAsync(SecretClient client, string secretName, uint fallback) + private static async Task GetOptionalUIntSecretAsync( + SecretClient client, + string secretName, + uint fallback, + CancellationToken cancellationToken) { - var value = await GetOptionalSecretAsync(client, secretName, null); + var value = await GetOptionalSecretAsync(client, secretName, null, cancellationToken); return uint.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback; diff --git a/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs b/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs index 4768b0c..497b5b4 100644 --- a/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs +++ b/Antifraude.Net/ApiDenuncias/Services/MySqlDenunciaStore.cs @@ -1,6 +1,7 @@ using System.Data; using System.Globalization; using System.Security.Cryptography; +using System.Text; using ApiDenuncias.Configuration; using GestionaDenuncias.Shared.Models; using Microsoft.Extensions.Options; @@ -10,6 +11,113 @@ namespace ApiDenuncias.Services; public sealed class MySqlDenunciaStore : IDenunciaStore { + private const int AttachmentChunkSizeBytes = 8 * 1024 * 1024; + private const int AttachmentChunkThresholdBytes = 32 * 1024 * 1024; + + private static readonly byte[] ChunkedAttachmentPrefix = Encoding.ASCII.GetBytes("chunked:v1:"); + + private const string AttachmentChunksTableSql = """ + CREATE TABLE IF NOT EXISTS complaint_attachment_chunks ( + attachment_id BIGINT NOT NULL, + chunk_index INT NOT NULL, + content LONGBLOB NOT NULL, + created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (attachment_id, chunk_index), + CONSTRAINT fk_attachment_chunks_attachment + FOREIGN KEY (attachment_id) REFERENCES complaint_attachments(id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """; + + private const string ComplaintSelectColumns = """ + external_registry_id, + external_report_id, + report_date_utc, + gestiona_file_url, + gestiona_file_code, + gestiona_person_id, + tag, + status_name, + complaint_type, + reporter_kind, + is_legal_entity, + reporter_first_name, + reporter_first_surname, + reporter_second_surname, + reporter_last_name, + reporter_business_name, + reporter_gender, + reporter_document_id, + reporter_document_type, + reporter_origin_country, + subject, + accused_party, + accused_party_details, + complaint_description, + reported_to_institution, + reported_institution_details, + requested_protection, + requested_protection_details, + information_mode, + facts_location, + facts_date_utc, + forwarding_authorization, + forwarding_personal_data_preference, + notification_preference, + electronic_notification, + online_tracking_preference, + postal_notification_preference, + email, + sms_notification, + accepted_terms, + comments, + phone, + address_line, + address_road_type, + address_number, + address_floor, + address_door, + address_block, + address_stair, + address_extra, + municipality, + province, + postal_code, + country_code, + form_fields_json, + raw_report_text, + is_confidential, + is_update, + gestiona_procedure_id, + gestiona_group_id, + display_name, + workflow_status, + selected_document_name, + gestiona_uploaded_at_utc, + is_in_gestiona, + is_rejected, + key_date, + encryption_scheme, + encrypted_at_utc + """; + + private const string AttachmentSelectColumns = """ + a.id, + a.attachment_type_id, + a.description, + a.attachment_date_utc, + a.notes, + c.external_report_id, + a.original_file_name, + a.content, + a.content_sha256, + a.uploaded_to_gestiona, + a.uploaded_at_utc, + a.key_date, + a.encryption_scheme, + a.encrypted_at_utc + """; + private static readonly (string Table, string Column, string Definition)[] SchemaColumnsToEnsure = [ ("complaints", "gestiona_file_code", "`gestiona_file_code` VARCHAR(128) NOT NULL DEFAULT ''"), @@ -32,12 +140,21 @@ public sealed class MySqlDenunciaStore : IDenunciaStore ("complaints", "address_extra", "`address_extra` VARCHAR(256) NOT NULL DEFAULT ''"), ("complaints", "form_fields_json", "`form_fields_json` LONGTEXT NULL"), ("complaints", "raw_report_text", "`raw_report_text` LONGTEXT NULL"), + ("complaints", "key_date", "`key_date` DATE NULL"), + ("complaints", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"), + ("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"), ("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"), + ("complaint_attachments", "key_date", "`key_date` DATE NULL"), + ("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"), + ("complaint_attachments", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"), ]; private static readonly (string Table, string IndexName, string Definition)[] SchemaIndexesToEnsure = [ ("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"), + ("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"), + ("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"), + ("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"), ]; private static readonly (string Table, string Definition)[] SchemaEncryptedColumnsToEnsure = @@ -123,85 +240,79 @@ public sealed class MySqlDenunciaStore : IDenunciaStore } public async Task> GetAllDenunciasAsync(CancellationToken cancellationToken = default) + { + return await GetDenunciasAsync(null, DenunciaListScope.All, cancellationToken); + } + + public async Task> GetDenunciasByScopeAsync( + DenunciaListScope scope, + CancellationToken cancellationToken = default) + { + return await GetDenunciasAsync(null, scope, cancellationToken); + } + + public async Task> GetDenunciasByIdsAsync( + IReadOnlyCollection denunciaIds, + CancellationToken cancellationToken = default) + { + var ids = NormalizeIds(denunciaIds); + if (ids.Count == 0) + { + return []; + } + + return await GetDenunciasAsync(ids, DenunciaListScope.All, cancellationToken); + } + + public async Task> GetDenunciasByIdsAsync( + IReadOnlyCollection denunciaIds, + DenunciaListScope scope, + CancellationToken cancellationToken = default) + { + var ids = NormalizeIds(denunciaIds); + if (ids.Count == 0) + { + return []; + } + + return await GetDenunciasAsync(ids, scope, cancellationToken); + } + + private async Task> GetDenunciasAsync( + IReadOnlyList? denunciaIds, + DenunciaListScope scope, + CancellationToken cancellationToken) { await EnsureSchemaReadyAsync(cancellationToken); - const string sql = """ + await using var connection = await OpenConnectionAsync(cancellationToken); + await using var command = connection.CreateCommand(); + var whereClauses = new List(); + if (denunciaIds is { Count: > 0 }) + { + var parameterNames = AddIntParameters(command, "id", denunciaIds); + whereClauses.Add($"external_report_id IN ({string.Join(", ", parameterNames)})"); + } + + var scopeWhereClause = GetScopeWhereClause(scope); + if (!string.IsNullOrWhiteSpace(scopeWhereClause)) + { + whereClauses.Add(scopeWhereClause); + } + + var whereSql = whereClauses.Count == 0 + ? string.Empty + : $"WHERE {string.Join(" AND ", whereClauses)}"; + + command.CommandText = $""" SELECT - external_registry_id, - external_report_id, - report_date_utc, - gestiona_file_url, - gestiona_file_code, - gestiona_person_id, - tag, - status_name, - complaint_type, - reporter_kind, - is_legal_entity, - reporter_first_name, - reporter_first_surname, - reporter_second_surname, - reporter_last_name, - reporter_business_name, - reporter_gender, - reporter_document_id, - reporter_document_type, - reporter_origin_country, - subject, - accused_party, - accused_party_details, - complaint_description, - reported_to_institution, - reported_institution_details, - requested_protection, - requested_protection_details, - information_mode, - facts_location, - facts_date_utc, - forwarding_authorization, - forwarding_personal_data_preference, - notification_preference, - electronic_notification, - online_tracking_preference, - postal_notification_preference, - email, - sms_notification, - accepted_terms, - comments, - phone, - address_line, - address_road_type, - address_number, - address_floor, - address_door, - address_block, - address_stair, - address_extra, - municipality, - province, - postal_code, - country_code, - form_fields_json, - raw_report_text, - is_confidential, - is_update, - gestiona_procedure_id, - gestiona_group_id, - display_name, - workflow_status, - selected_document_name, - gestiona_uploaded_at_utc, - is_in_gestiona, - is_rejected + {ComplaintSelectColumns} FROM complaints + {whereSql} ORDER BY COALESCE(gestiona_uploaded_at_utc, report_date_utc) DESC, external_report_id DESC; """; - await using var connection = await OpenConnectionAsync(cancellationToken); - await using var command = new MySqlCommand(sql, connection); await using var reader = await command.ExecuteReaderAsync(cancellationToken); - var result = new List(); while (await reader.ReadAsync(cancellationToken)) { @@ -215,19 +326,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore { await EnsureSchemaReadyAsync(cancellationToken); - const string sql = """ + var sql = $""" SELECT - a.id, - a.attachment_type_id, - a.description, - a.attachment_date_utc, - a.notes, - c.external_report_id, - a.original_file_name, - a.content, - a.content_sha256, - a.uploaded_to_gestiona, - a.uploaded_at_utc + {AttachmentSelectColumns} FROM complaint_attachments a INNER JOIN complaints c ON c.id = a.complaint_id ORDER BY c.external_report_id DESC, a.original_file_name ASC; @@ -235,14 +336,56 @@ public sealed class MySqlDenunciaStore : IDenunciaStore await using var connection = await OpenConnectionAsync(cancellationToken); await using var command = new MySqlCommand(sql, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); var result = new List(); - while (await reader.ReadAsync(cancellationToken)) + await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) { - result.Add(MapAttachment(reader)); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(MapAttachment(reader)); + } } + await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken); + + return result; + } + + public async Task> GetFicherosByDenunciaIdsAsync( + IReadOnlyCollection denunciaIds, + CancellationToken cancellationToken = default) + { + await EnsureSchemaReadyAsync(cancellationToken); + + var ids = NormalizeIds(denunciaIds); + if (ids.Count == 0) + { + return []; + } + + await using var connection = await OpenConnectionAsync(cancellationToken); + await using var command = connection.CreateCommand(); + var parameterNames = AddIntParameters(command, "id", ids); + command.CommandText = $""" + SELECT + {AttachmentSelectColumns} + FROM complaint_attachments a + INNER JOIN complaints c ON c.id = a.complaint_id + WHERE c.external_report_id IN ({string.Join(", ", parameterNames)}) + ORDER BY c.external_report_id DESC, a.original_file_name ASC; + """; + + var result = new List(); + await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(MapAttachment(reader)); + } + } + + await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken); + return result; } @@ -252,19 +395,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore { await EnsureSchemaReadyAsync(cancellationToken); - const string sql = """ + var sql = $""" SELECT - a.id, - a.attachment_type_id, - a.description, - a.attachment_date_utc, - a.notes, - c.external_report_id, - a.original_file_name, - a.content, - a.content_sha256, - a.uploaded_to_gestiona, - a.uploaded_at_utc + {AttachmentSelectColumns} FROM complaint_attachments a INNER JOIN complaints c ON c.id = a.complaint_id WHERE c.external_report_id = @denunciaId @@ -275,13 +408,17 @@ public sealed class MySqlDenunciaStore : IDenunciaStore await using var command = new MySqlCommand(sql, connection); command.Parameters.AddWithValue("@denunciaId", denunciaId); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); var result = new List(); - while (await reader.ReadAsync(cancellationToken)) + await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) { - result.Add(MapAttachment(reader)); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(MapAttachment(reader)); + } } + await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken); + return result; } @@ -291,74 +428,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore { await EnsureSchemaReadyAsync(cancellationToken); - const string sql = """ + var sql = $""" SELECT - external_registry_id, - external_report_id, - report_date_utc, - gestiona_file_url, - gestiona_file_code, - gestiona_person_id, - tag, - status_name, - complaint_type, - reporter_kind, - is_legal_entity, - reporter_first_name, - reporter_first_surname, - reporter_second_surname, - reporter_last_name, - reporter_business_name, - reporter_gender, - reporter_document_id, - reporter_document_type, - reporter_origin_country, - subject, - accused_party, - accused_party_details, - complaint_description, - reported_to_institution, - reported_institution_details, - requested_protection, - requested_protection_details, - information_mode, - facts_location, - facts_date_utc, - forwarding_authorization, - forwarding_personal_data_preference, - notification_preference, - electronic_notification, - online_tracking_preference, - postal_notification_preference, - email, - sms_notification, - accepted_terms, - comments, - phone, - address_line, - address_road_type, - address_number, - address_floor, - address_door, - address_block, - address_stair, - address_extra, - municipality, - province, - postal_code, - country_code, - form_fields_json, - raw_report_text, - is_confidential, - is_update, - gestiona_procedure_id, - gestiona_group_id, - display_name, - workflow_status, - selected_document_name, - gestiona_uploaded_at_utc, - is_in_gestiona, - is_rejected + {ComplaintSelectColumns} FROM complaints WHERE external_report_id = @denunciaId LIMIT 1; @@ -445,7 +517,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore selected_document_name, gestiona_uploaded_at_utc, is_in_gestiona, - is_rejected + is_rejected, + key_date, + encryption_scheme, + encrypted_at_utc ) VALUES ( @externalRegistryId, @externalReportId, @@ -512,7 +587,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore @selectedDocumentName, @gestionaUploadedAtUtc, @isInGestiona, - @isRejected + @isRejected, + @keyDate, + @encryptionScheme, + @encryptedAtUtc ) ON DUPLICATE KEY UPDATE external_registry_id = VALUES(external_registry_id), @@ -580,6 +658,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc), is_in_gestiona = VALUES(is_in_gestiona), is_rejected = VALUES(is_rejected), + key_date = VALUES(key_date), + encryption_scheme = VALUES(encryption_scheme), + encrypted_at_utc = VALUES(encrypted_at_utc), updated_at_utc = UTC_TIMESTAMP(6); """; @@ -652,6 +733,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona)); command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona); command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada); + command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate)); + command.Parameters.AddWithValue("@encryptionScheme", denuncia.EncryptionScheme ?? string.Empty); + command.Parameters.AddWithValue("@encryptedAtUtc", ToDbDate(denuncia.EncryptedAtUtc)); await command.ExecuteNonQueryAsync(cancellationToken); } @@ -680,7 +764,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore content_mime_type, content_sha256, uploaded_to_gestiona, - uploaded_at_utc + uploaded_at_utc, + key_date, + encryption_scheme, + encrypted_at_utc ) VALUES ( ( SELECT c.id @@ -697,9 +784,13 @@ public sealed class MySqlDenunciaStore : IDenunciaStore @contentMimeType, @contentSha256, @uploadedToGestiona, - @uploadedAtUtc + @uploadedAtUtc, + @keyDate, + @encryptionScheme, + @encryptedAtUtc ) ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), attachment_type_id = @attachmentTypeId, description = @description, attachment_date_utc = @attachmentDateUtc, @@ -717,6 +808,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc ELSE @uploadedAtUtc END, + key_date = @keyDate, + encryption_scheme = @encryptionScheme, + encrypted_at_utc = @encryptedAtUtc, updated_at_utc = CURRENT_TIMESTAMP(6); """; @@ -729,6 +823,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore { await using var command = new MySqlCommand(sql, connection, (MySqlTransaction)transaction); var content = fichero.Fichero ?? []; + var useChunks = content.Length > AttachmentChunkThresholdBytes; + var contentToStore = useChunks + ? CreateChunkedAttachmentMarker(content.LongLength, GetChunkCount(content.Length)) + : content; var sha256 = string.IsNullOrWhiteSpace(fichero.ContentSha256) ? ComputeSha256Hex(content) : fichero.ContentSha256.Trim().ToLowerInvariant(); @@ -738,25 +836,151 @@ public sealed class MySqlDenunciaStore : IDenunciaStore command.Parameters.AddWithValue("@attachmentDateUtc", ToDbDate(fichero.Fecha)); command.Parameters.AddWithValue("@notes", fichero.Observaciones ?? string.Empty); command.Parameters.AddWithValue("@originalFileName", fichero.NombreFichero ?? string.Empty); - command.Parameters.AddWithValue("@content", content); + command.Parameters.Add("@content", MySqlDbType.LongBlob).Value = contentToStore; command.Parameters.AddWithValue("@contentMimeType", DetectMimeType(fichero.NombreFichero)); command.Parameters.AddWithValue("@contentSha256", sha256); command.Parameters.AddWithValue("@uploadedToGestiona", fichero.Subido); command.Parameters.AddWithValue("@uploadedAtUtc", ToDbDate(fichero.FechaSubida)); + command.Parameters.AddWithValue("@keyDate", ToDbDate(fichero.KeyDate)); + command.Parameters.AddWithValue("@encryptionScheme", fichero.EncryptionScheme ?? string.Empty); + command.Parameters.AddWithValue("@encryptedAtUtc", ToDbDate(fichero.EncryptedAtUtc)); command.Parameters.AddWithValue("@externalReportId", fichero.Id_Denuncia); await command.ExecuteNonQueryAsync(cancellationToken); + + var attachmentId = command.LastInsertedId; + if (attachmentId <= 0) + { + attachmentId = await GetAttachmentIdAsync( + connection, + (MySqlTransaction)transaction, + fichero.Id_Denuncia, + fichero.NombreFichero ?? string.Empty, + cancellationToken); + } + + if (useChunks) + { + await ReplaceAttachmentChunksAsync( + connection, + (MySqlTransaction)transaction, + attachmentId, + content, + cancellationToken); + } + else + { + await DeleteAttachmentChunksAsync( + connection, + (MySqlTransaction)transaction, + attachmentId, + cancellationToken); + } } await transaction.CommitAsync(cancellationToken); } catch { - await transaction.RollbackAsync(cancellationToken); + try + { + await transaction.RollbackAsync(cancellationToken); + } + catch (Exception rollbackException) + { + _logger.LogWarning( + rollbackException, + "No se ha podido deshacer la transaccion de adjuntos porque la conexion ya no estaba disponible."); + } + throw; } } + private static async Task GetAttachmentIdAsync( + MySqlConnection connection, + MySqlTransaction transaction, + int externalReportId, + string originalFileName, + CancellationToken cancellationToken) + { + const string sql = """ + SELECT a.id + FROM complaint_attachments a + INNER JOIN complaints c ON c.id = a.complaint_id + WHERE c.external_report_id = @externalReportId + AND a.original_file_name = @originalFileName + LIMIT 1; + """; + + await using var command = new MySqlCommand(sql, connection, transaction); + command.Parameters.AddWithValue("@externalReportId", externalReportId); + command.Parameters.AddWithValue("@originalFileName", originalFileName); + + var result = await command.ExecuteScalarAsync(cancellationToken); + if (result is null || result is DBNull) + { + throw new InvalidOperationException( + $"No se ha encontrado el adjunto '{originalFileName}' de la denuncia #{externalReportId} tras guardarlo."); + } + + return Convert.ToInt64(result, CultureInfo.InvariantCulture); + } + + private static async Task ReplaceAttachmentChunksAsync( + MySqlConnection connection, + MySqlTransaction transaction, + long attachmentId, + byte[] content, + CancellationToken cancellationToken) + { + await DeleteAttachmentChunksAsync(connection, transaction, attachmentId, cancellationToken); + + const string sql = """ + INSERT INTO complaint_attachment_chunks ( + attachment_id, + chunk_index, + content + ) VALUES ( + @attachmentId, + @chunkIndex, + @content + ); + """; + + var chunkIndex = 0; + for (var offset = 0; offset < content.Length; offset += AttachmentChunkSizeBytes) + { + var length = Math.Min(AttachmentChunkSizeBytes, content.Length - offset); + var chunk = new byte[length]; + Buffer.BlockCopy(content, offset, chunk, 0, length); + + await using var command = new MySqlCommand(sql, connection, transaction); + command.Parameters.AddWithValue("@attachmentId", attachmentId); + command.Parameters.AddWithValue("@chunkIndex", chunkIndex); + command.Parameters.Add("@content", MySqlDbType.LongBlob).Value = chunk; + + await command.ExecuteNonQueryAsync(cancellationToken); + chunkIndex++; + } + } + + private static async Task DeleteAttachmentChunksAsync( + MySqlConnection connection, + MySqlTransaction transaction, + long attachmentId, + CancellationToken cancellationToken) + { + const string sql = """ + DELETE FROM complaint_attachment_chunks + WHERE attachment_id = @attachmentId; + """; + + await using var command = new MySqlCommand(sql, connection, transaction); + command.Parameters.AddWithValue("@attachmentId", attachmentId); + await command.ExecuteNonQueryAsync(cancellationToken); + } + public async Task MarkFicherosAsUploadedAsync( int denunciaId, IEnumerable fileNames, @@ -802,6 +1026,39 @@ public sealed class MySqlDenunciaStore : IDenunciaStore await command.ExecuteNonQueryAsync(cancellationToken); } + private static List NormalizeIds(IEnumerable denunciaIds) + { + return denunciaIds + .Where(id => id > 0) + .Distinct() + .ToList(); + } + + private static string GetScopeWhereClause(DenunciaListScope scope) + { + return scope switch + { + DenunciaListScope.Pending => "is_in_gestiona = 0 AND is_rejected = 0 AND is_update = 0", + DenunciaListScope.Updates => "is_update = 1", + DenunciaListScope.InGestiona => "is_in_gestiona = 1", + DenunciaListScope.Rejected => "is_rejected = 1", + _ => string.Empty + }; + } + + private static List AddIntParameters(MySqlCommand command, string prefix, IReadOnlyList ids) + { + var parameterNames = new List(ids.Count); + for (var i = 0; i < ids.Count; i++) + { + var parameterName = $"@{prefix}{i}"; + command.Parameters.AddWithValue(parameterName, ids[i]); + parameterNames.Add(parameterName); + } + + return parameterNames; + } + private async Task EnsureSchemaReadyAsync(CancellationToken cancellationToken) { if (SchemaEnsured) @@ -867,6 +1124,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore MySqlConnection connection, CancellationToken cancellationToken) { + await EnsureAttachmentChunksTableAsync(connection, cancellationToken); + foreach (var (table, column, definition) in SchemaColumnsToEnsure) { if (!await ColumnExistsAsync(connection, table, column, cancellationToken)) @@ -899,6 +1158,15 @@ public sealed class MySqlDenunciaStore : IDenunciaStore } } + private static async Task EnsureAttachmentChunksTableAsync( + MySqlConnection connection, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = AttachmentChunksTableSql; + await command.ExecuteNonQueryAsync(cancellationToken); + } + private static string ExtractColumnName(string definition) { var first = definition.IndexOf('`'); @@ -1008,6 +1276,124 @@ public sealed class MySqlDenunciaStore : IDenunciaStore return Path.Combine(AppContext.BaseDirectory, "Scripts", "gestiondenuncias_schema.sql"); } + private static async Task HydrateChunkedAttachmentsAsync( + MySqlConnection connection, + IEnumerable attachments, + CancellationToken cancellationToken) + { + foreach (var attachment in attachments) + { + var content = attachment.Fichero; + if (content is null || + !TryParseChunkedAttachmentMarker(content, out var totalLength, out var chunkCount)) + { + continue; + } + + attachment.Fichero = await LoadAttachmentChunksAsync( + connection, + attachment.Id_Fichero, + totalLength, + chunkCount, + cancellationToken); + } + } + + private static async Task LoadAttachmentChunksAsync( + MySqlConnection connection, + long attachmentId, + long totalLength, + int expectedChunkCount, + CancellationToken cancellationToken) + { + if (totalLength < 0 || totalLength > int.MaxValue) + { + throw new InvalidOperationException( + $"El adjunto {attachmentId} no puede reconstruirse en memoria. Tamano declarado: {totalLength} bytes."); + } + + if (expectedChunkCount <= 0) + { + throw new InvalidOperationException( + $"El adjunto {attachmentId} tiene un marcador de chunks invalido."); + } + + const string sql = """ + SELECT chunk_index, content + FROM complaint_attachment_chunks + WHERE attachment_id = @attachmentId + ORDER BY chunk_index ASC; + """; + + var result = new byte[(int)totalLength]; + var offset = 0; + var chunksRead = 0; + + await using var command = new MySqlCommand(sql, connection); + command.Parameters.AddWithValue("@attachmentId", attachmentId); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var chunkIndex = GetInt32(reader, "chunk_index"); + if (chunkIndex != chunksRead) + { + throw new InvalidOperationException( + $"El adjunto {attachmentId} tiene chunks incompletos o desordenados."); + } + + var chunk = GetBytes(reader, "content"); + if (offset + chunk.Length > result.Length) + { + throw new InvalidOperationException( + $"El adjunto {attachmentId} tiene mas datos de chunks que los declarados."); + } + + Buffer.BlockCopy(chunk, 0, result, offset, chunk.Length); + offset += chunk.Length; + chunksRead++; + } + + if (chunksRead != expectedChunkCount || offset != result.Length) + { + throw new InvalidOperationException( + $"El adjunto {attachmentId} no tiene todos los chunks esperados."); + } + + return result; + } + + private static byte[] CreateChunkedAttachmentMarker(long totalLength, int chunkCount) + { + return Encoding.ASCII.GetBytes( + $"chunked:v1:{totalLength.ToString(CultureInfo.InvariantCulture)}:{chunkCount.ToString(CultureInfo.InvariantCulture)}"); + } + + private static bool TryParseChunkedAttachmentMarker( + byte[] content, + out long totalLength, + out int chunkCount) + { + totalLength = 0; + chunkCount = 0; + + if (!content.AsSpan().StartsWith(ChunkedAttachmentPrefix)) + { + return false; + } + + var marker = Encoding.ASCII.GetString(content); + var parts = marker.Split(':'); + return parts.Length == 4 && + long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out totalLength) && + int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out chunkCount); + } + + private static int GetChunkCount(int length) + { + return (int)(((long)length + AttachmentChunkSizeBytes - 1) / AttachmentChunkSizeBytes); + } + private static DenunciasGestiona MapComplaint(IDataRecord record) { return new DenunciasGestiona @@ -1078,6 +1464,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"), EnGestiona = GetBoolean(record, "is_in_gestiona"), EnRechazada = GetBoolean(record, "is_rejected"), + KeyDate = GetNullableDateOnly(record, "key_date"), + EncryptionScheme = GetString(record, "encryption_scheme"), + EncryptedAtUtc = GetNullableDateTime(record, "encrypted_at_utc"), }; } @@ -1096,6 +1485,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore ContentSha256 = GetString(record, "content_sha256"), Subido = GetBoolean(record, "uploaded_to_gestiona"), FechaSubida = GetNullableDateTime(record, "uploaded_at_utc"), + KeyDate = GetNullableDateOnly(record, "key_date"), + EncryptionScheme = GetString(record, "encryption_scheme"), + EncryptedAtUtc = GetNullableDateTime(record, "encrypted_at_utc"), }; } @@ -1109,6 +1501,11 @@ public sealed class MySqlDenunciaStore : IDenunciaStore return value is null || value == DateTime.MinValue ? DBNull.Value : value.Value; } + private static object ToDbDate(DateOnly? value) + { + return value is null ? DBNull.Value : value.Value.ToDateTime(TimeOnly.MinValue); + } + private static object ToDbGuid(Guid value) { return value == Guid.Empty ? DBNull.Value : value.ToString(); @@ -1160,6 +1557,12 @@ public sealed class MySqlDenunciaStore : IDenunciaStore return record.IsDBNull(ordinal) ? null : record.GetDateTime(ordinal); } + private static DateOnly? GetNullableDateOnly(IDataRecord record, string columnName) + { + var ordinal = record.GetOrdinal(columnName); + return record.IsDBNull(ordinal) ? null : DateOnly.FromDateTime(record.GetDateTime(ordinal)); + } + private static Guid GetGuid(IDataRecord record, string columnName) { var ordinal = record.GetOrdinal(columnName); diff --git a/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs b/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs index 86e30c5..bdc6669 100644 --- a/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs +++ b/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs @@ -8,6 +8,7 @@ public sealed record PendingGlobalLeaksLogin( string Username, string Password, string FinalPassword, + string TokenAnswer, DateTimeOffset ExpiresAtUtc); public sealed class PendingGlobalLeaksLoginStore @@ -15,7 +16,7 @@ public sealed class PendingGlobalLeaksLoginStore private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); private readonly ConcurrentDictionary _items = new(StringComparer.Ordinal); - public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword) + public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword, string tokenAnswer) { CleanupExpired(); @@ -25,6 +26,7 @@ public sealed class PendingGlobalLeaksLoginStore username, password, finalPassword, + tokenAnswer, DateTimeOffset.UtcNow.Add(Lifetime)); _items[id] = pending; diff --git a/Antifraude.Net/ApiDenuncias/appsettings.json b/Antifraude.Net/ApiDenuncias/appsettings.json index e0e8bf3..cc57554 100644 --- a/Antifraude.Net/ApiDenuncias/appsettings.json +++ b/Antifraude.Net/ApiDenuncias/appsettings.json @@ -21,6 +21,15 @@ "EncryptionKeySecretName": "denuncias-encryption-key", "AllowLocalEncryptionKeyFallback": true }, + "ManualPurge": { + "BaseUrl": "https://func-keymgmt-pre.azurewebsites.net/api", + "FunctionUrl": "https://func-keymgmt-pre.azurewebsites.net/api/manual_purge", + "ForceRotateUrl": "https://func-keymgmt-pre.azurewebsites.net/api/force_rotate", + "FunctionKeySecretName": "purge-function-key", + "ReplaceOnManualPurge": true, + "RecoverPartialReplaceFailure": true, + "TimeoutSeconds": 30 + }, "Encryption": { "LocalDevelopmentKey": "presentacion-pre-denuncias-encryption-key-cambiar-antes-de-produccion" }, diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs index 15f455b..55d7f6d 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs @@ -12,7 +12,7 @@ public sealed record ApiGlobalLeaksSessionDto( bool HasActiveSession, DateTimeOffset? UpdatedAt); -public sealed record RenewGlobalLeaksSessionRequest(string Authcode); +public sealed record RenewGlobalLeaksSessionRequest(string Authcode, string? PendingLoginId = null); public sealed record InboxSnapshotResponse( IReadOnlyList Contexts, @@ -82,3 +82,15 @@ public sealed record GestionaTramitarDocumentoRequest( string DocumentUrl, string AssignedGroupHref, int? ComplaintId); + +public sealed record ManualPurgeRequest(string Date); + +public sealed record ManualPurgeResponse( + string Date, + bool Success, + int StatusCode, + string ResponseBody); + +public sealed record AppConfigurationDto(string? ExternalUpdateCutoffDate); + +public sealed record UpdateExternalUpdateCutoffRequest(string? Date); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciaListScope.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciaListScope.cs new file mode 100644 index 0000000..6b3c99a --- /dev/null +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciaListScope.cs @@ -0,0 +1,10 @@ +namespace GestionaDenuncias.Shared.Models; + +public enum DenunciaListScope +{ + All = 0, + Pending = 1, + Updates = 2, + InGestiona = 3, + Rejected = 4 +} diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs index 774f567..8f35e0b 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/DenunciasGestiona.cs @@ -81,6 +81,15 @@ public class DenunciasGestiona public bool EnGestiona { get; set; } public bool EnRechazada { get; set; } + [JsonIgnore] + public DateOnly? KeyDate { get; set; } + + [JsonIgnore] + public string EncryptionScheme { get; set; } = string.Empty; + + [JsonIgnore] + public DateTime? EncryptedAtUtc { get; set; } + [JsonIgnore] public string NombreResuelto => ResolveValue(Nombre, "nombre"); @@ -273,10 +282,20 @@ public class DenunciasGestiona { if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) { - builder.Append(char.ToLowerInvariant(ch)); + if (ch is 'º' or 'ª') + { + continue; + } + + builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : ' '); } } - return builder.ToString().Normalize(NormalizationForm.FormC).Trim(); + return string.Join( + ' ', + builder + .ToString() + .Normalize(NormalizationForm.FormC) + .Split(' ', StringSplitOptions.RemoveEmptyEntries)); } } diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/FicherosDenuncias.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/FicherosDenuncias.cs index 9a7515c..2b3c795 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/FicherosDenuncias.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/FicherosDenuncias.cs @@ -1,5 +1,6 @@ // Models/FicherosDenuncias.cs using System; +using System.Text.Json.Serialization; namespace GestionaDenuncias.Shared.Models { @@ -38,6 +39,15 @@ namespace GestionaDenuncias.Shared.Models // Hash SHA-256 del contenido, para evitar re-subir adjuntos repetidos. public string ContentSha256 { get; set; } = string.Empty; + [JsonIgnore] + public DateOnly? KeyDate { get; set; } + + [JsonIgnore] + public string EncryptionScheme { get; set; } = string.Empty; + + [JsonIgnore] + public DateTime? EncryptedAtUtc { get; set; } + public bool EsReport { get diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ImportSummary.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ImportSummary.cs index c5c9405..2947e60 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ImportSummary.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ImportSummary.cs @@ -4,4 +4,5 @@ public sealed record ImportSummary( int TotalCandidates, int ImportedCount, IReadOnlyList Errors, - IReadOnlyList? ImportedComplaintIds = null); + IReadOnlyList? ImportedComplaintIds = null, + IReadOnlyList? Warnings = null); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ThirdPartyIdentityData.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ThirdPartyIdentityData.cs index acef45c..fd6b60f 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ThirdPartyIdentityData.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ThirdPartyIdentityData.cs @@ -26,22 +26,31 @@ public sealed class ThirdPartyIdentityData { ArgumentNullException.ThrowIfNull(denuncia); + var documentId = (denuncia.DocumentoResuelto ?? string.Empty).Trim().ToUpperInvariant(); + var firstName = denuncia.NombreResuelto.Trim(); + var lastName = BuildLastName(denuncia); + var businessName = denuncia.RazonSocialResuelta.Trim(); var isAnonymous = denuncia.EsAnonima || string.Equals( - denuncia.DocumentoResuelto?.Trim(), + documentId, "00000000T", StringComparison.OrdinalIgnoreCase); + var isLegalEntity = !isAnonymous && + (denuncia.EsPersonaJuridica || + LooksLikeLegalEntityDocument(documentId) || + (!string.IsNullOrWhiteSpace(businessName) && + string.IsNullOrWhiteSpace(firstName))); return new ThirdPartyIdentityData { IsAnonymous = isAnonymous, - IsLegalEntity = !isAnonymous && denuncia.EsPersonaJuridica, + IsLegalEntity = isLegalEntity, DocumentId = isAnonymous ? "00000000T" - : (denuncia.DocumentoResuelto ?? string.Empty).Trim().ToUpperInvariant(), - FirstName = isAnonymous ? "Anonimo" : denuncia.NombreResuelto.Trim(), - LastName = isAnonymous ? "-" : BuildLastName(denuncia), - BusinessName = isAnonymous ? string.Empty : denuncia.RazonSocialResuelta.Trim(), + : documentId, + FirstName = isAnonymous ? "Anonimo" : isLegalEntity ? string.Empty : firstName, + LastName = isAnonymous ? "-" : isLegalEntity ? string.Empty : lastName, + BusinessName = isAnonymous ? string.Empty : businessName, Email = (denuncia.Correo_Electronico ?? string.Empty).Trim(), CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais, Address = isAnonymous ? null : ThirdPartyAddressData.FromComplaint(denuncia) @@ -60,4 +69,17 @@ public sealed class ThirdPartyIdentityData ? denuncia.ApellidosResueltos.Trim() : separated; } + + private static bool LooksLikeLegalEntityDocument(string documentId) + { + var value = (documentId ?? string.Empty).Trim().ToUpperInvariant(); + if (value.Length != 9) + { + return false; + } + + const string legalEntityPrefixes = "ABCDEFGHJKLMNPQRSUVW"; + return legalEntityPrefixes.Contains(value[0]) && + value.Skip(1).Take(7).All(char.IsDigit); + } } diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Services/IDenunciaStore.cs b/Antifraude.Net/GestionaDenuncias.Shared/Services/IDenunciaStore.cs index a11917c..cce667f 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Services/IDenunciaStore.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Services/IDenunciaStore.cs @@ -6,6 +6,7 @@ public interface IDenunciaStore { Task EnsureSchemaAsync(CancellationToken cancellationToken = default); Task> GetAllDenunciasAsync(CancellationToken cancellationToken = default); + Task> GetDenunciasByScopeAsync(DenunciaListScope scope, CancellationToken cancellationToken = default); Task> GetAllFicherosAsync(CancellationToken cancellationToken = default); Task> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default); Task GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default); diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor index ac38fc1..05ff174 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor @@ -116,6 +116,9 @@ "buscador" => ( "Buscador de terceros", "Localiza terceros y expedientes relacionados para validar identidades antes de tramitar."), + "configuracion" => ( + "Configuracion", + "Ajusta criterios de actualizaciones externas y ejecuta operaciones tecnicas controladas."), "instrucciones" => ( "Instrucciones", "Referencia rapida de uso para el equipo gestor y para las operaciones mas frecuentes."), diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/NavMenu.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/NavMenu.razor index aee4960..41eecdb 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/NavMenu.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/NavMenu.razor @@ -65,6 +65,14 @@ Consulta identidades y expedientes vinculados + + + + + Configuracion + Fecha de corte y purga manual + +