diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e98ddf6..bf36a60 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
run: dotnet restore "./Atlas Balance/backend/GestionCaja.sln"
- name: Pull PostgreSQL test image
- run: docker pull postgres:16-alpine
+ run: docker pull postgres:16-alpine@sha256:4e6e670bb069649261c9c18031f0aded7bb249a5b6664ddec29c013a89310d50
- name: Test backend
run: dotnet test "./Atlas Balance/backend/GestionCaja.sln" -c Release --no-restore
@@ -46,6 +46,36 @@ jobs:
throw 'NuGet vulnerability audit failed.'
}
+ - name: Scan high-confidence secrets
+ shell: pwsh
+ run: |
+ $patterns = @(
+ '-----BEGIN (RSA|DSA|EC|OPENSSH|PRIVATE) KEY-----',
+ 'AKIA[0-9A-Z]{16}',
+ 'gh[pousr]_[A-Za-z0-9_]{36,}',
+ 'xox[baprs]-[A-Za-z0-9-]{20,}',
+ 'sk-live-[A-Za-z0-9]{20,}'
+ )
+ $files = (git -c core.quotepath=false ls-files -z) -split "`0" | Where-Object {
+ $_ -and
+ $_ -notmatch '(^|/)(Otros|Skills)/' -and
+ $_ -notmatch '(^|/)(bin|obj|node_modules|dist)/'
+ }
+ $findings = @()
+ foreach ($file in $files) {
+ if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue }
+ $content = Get-Content -LiteralPath $file -Raw -ErrorAction SilentlyContinue
+ foreach ($pattern in $patterns) {
+ if ($content -match $pattern) {
+ $findings += "$file matches $pattern"
+ }
+ }
+ }
+ if ($findings.Count -gt 0) {
+ $findings | ForEach-Object { Write-Error $_ }
+ throw 'Secret scan failed.'
+ }
+
- name: Install frontend dependencies
working-directory: "Atlas Balance/frontend"
run: npm ci
diff --git a/.gitignore b/.gitignore
index 05c909b..e9bd1e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,8 @@ logs/
.claude/
artifacts/
.codex-logs/
+.codex-runlogs/
+output/
.tmp*/
tmp*
test-results/
diff --git a/Atlas Balance/AGENTS.md b/Atlas Balance/AGENTS.md
index acec0e4..762b499 100644
--- a/Atlas Balance/AGENTS.md
+++ b/Atlas Balance/AGENTS.md
@@ -15,6 +15,7 @@
## Que es este proyecto
+Este proyecto pertenece a la empresa Atlas Labs y la aplicacion se llama Atlas Balance.
Aplicacion web on-premise para gestion de tesoreria multi-banco, multi-titular, multi-divisa. Corre en Windows Server, accesible por 4-8 usuarios en red local via navegador.
**Stack:**
@@ -139,51 +140,51 @@ Guardar toda la documentacion en `Documentacion`.
```
Atlas Balance/
-├── CLAUDE.md
-├── AGENTS.md
-├── .github/
-├── .gitignore
-├── .gitattributes
-├── Atlas Balance/
-│ ├── AGENTS.md
-│ ├── CLAUDE.md
-│ ├── VERSION
-│ ├── Directory.Build.props
-│ ├── docker-compose.yml
-│ ├── Atlas Balance Release/
-│ ├── backend/
-│ │ ├── GestionCaja.sln
-│ │ ├── src/
-│ │ │ ├── GestionCaja.API/
-│ │ │ │ ├── Program.cs
-│ │ │ │ ├── appsettings.json
-│ │ │ │ ├── appsettings.Development.json
-│ │ │ │ ├── Models/
-│ │ │ │ ├── Data/
-│ │ │ │ ├── DTOs/
-│ │ │ │ ├── Services/
-│ │ │ │ ├── Controllers/
-│ │ │ │ ├── Middleware/
-│ │ │ │ ├── Jobs/
-│ │ │ │ ├── Migrations/
-│ │ │ │ └── wwwroot/
-│ │ │ └── GestionCaja.Watchdog/
-│ │ └── tests/
-│ ├── frontend/
-│ │ ├── package.json
-│ │ ├── vite.config.ts
-│ │ ├── tsconfig.json
-│ │ ├── index.html
-│ │ └── src/
-│ ├── scripts/
-│ └── tests/
-├── Documentacion/
-│ ├── Versiones/
-│ ├── Diseno/
-│ ├── SPEC.md
-│ ├── documentacion.md
-│ └── DOCUMENTACION_CAMBIOS.md
-└── Otros/
++-- CLAUDE.md
++-- AGENTS.md
++-- .github/
++-- .gitignore
++-- .gitattributes
++-- Atlas Balance/
+¦ +-- AGENTS.md
+¦ +-- CLAUDE.md
+¦ +-- VERSION
+¦ +-- Directory.Build.props
+¦ +-- docker-compose.yml
+¦ +-- Atlas Balance Release/
+¦ +-- backend/
+¦ ¦ +-- GestionCaja.sln
+¦ ¦ +-- src/
+¦ ¦ ¦ +-- GestionCaja.API/
+¦ ¦ ¦ ¦ +-- Program.cs
+¦ ¦ ¦ ¦ +-- appsettings.json
+¦ ¦ ¦ ¦ +-- appsettings.Development.json
+¦ ¦ ¦ ¦ +-- Models/
+¦ ¦ ¦ ¦ +-- Data/
+¦ ¦ ¦ ¦ +-- DTOs/
+¦ ¦ ¦ ¦ +-- Services/
+¦ ¦ ¦ ¦ +-- Controllers/
+¦ ¦ ¦ ¦ +-- Middleware/
+¦ ¦ ¦ ¦ +-- Jobs/
+¦ ¦ ¦ ¦ +-- Migrations/
+¦ ¦ ¦ ¦ +-- wwwroot/
+¦ ¦ ¦ +-- GestionCaja.Watchdog/
+¦ ¦ +-- tests/
+¦ +-- frontend/
+¦ ¦ +-- package.json
+¦ ¦ +-- vite.config.ts
+¦ ¦ +-- tsconfig.json
+¦ ¦ +-- index.html
+¦ ¦ +-- src/
+¦ +-- scripts/
+¦ +-- tests/
++-- Documentacion/
+¦ +-- Versiones/
+¦ +-- Diseno/
+¦ +-- SPEC.md
+¦ +-- documentacion.md
+¦ +-- DOCUMENTACION_CAMBIOS.md
++-- Otros/
```
## Esquema de BD corregido
@@ -227,7 +228,7 @@ npm run build
# Release Windows x64
cd "Atlas Balance"
-powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05
# Conectar a PostgreSQL
psql -h localhost -p 5433 -U app_user -d atlas_balance
diff --git a/Atlas Balance/CLAUDE.md b/Atlas Balance/CLAUDE.md
index 707d447..97b7b65 100644
--- a/Atlas Balance/CLAUDE.md
+++ b/Atlas Balance/CLAUDE.md
@@ -15,6 +15,7 @@
## Que es este proyecto
+Este proyecto pertenece a la empresa Atlas Labs y la aplicacion se llama Atlas Balance.
Aplicacion web on-premise para gestion de tesoreria multi-banco, multi-titular, multi-divisa. Corre en Windows Server, accesible por 4-8 usuarios en red local via navegador.
**Stack:**
@@ -139,51 +140,51 @@ Guardar toda la documentacion en `Documentacion`.
```
Atlas Balance/
-├── CLAUDE.md
-├── AGENTS.md
-├── .github/
-├── .gitignore
-├── .gitattributes
-├── Atlas Balance/
-│ ├── AGENTS.md
-│ ├── CLAUDE.md
-│ ├── VERSION
-│ ├── Directory.Build.props
-│ ├── docker-compose.yml
-│ ├── Atlas Balance Release/
-│ ├── backend/
-│ │ ├── GestionCaja.sln
-│ │ ├── src/
-│ │ │ ├── GestionCaja.API/
-│ │ │ │ ├── Program.cs
-│ │ │ │ ├── appsettings.json
-│ │ │ │ ├── appsettings.Development.json.template
-│ │ │ │ ├── Constants/
-│ │ │ │ ├── Models/
-│ │ │ │ ├── Data/
-│ │ │ │ ├── DTOs/
-│ │ │ │ ├── Services/
-│ │ │ │ ├── Controllers/
-│ │ │ │ ├── Middleware/
-│ │ │ │ ├── Jobs/
-│ │ │ │ ├── Migrations/
-│ │ │ │ └── wwwroot/
-│ │ │ └── GestionCaja.Watchdog/
-│ │ └── tests/
-│ ├── frontend/
-│ │ ├── package.json
-│ │ ├── vite.config.ts
-│ │ ├── tsconfig.json
-│ │ ├── index.html
-│ │ └── src/
-│ └── scripts/
-├── Documentacion/
-│ ├── Versiones/
-│ ├── Diseno/
-│ ├── SPEC.md
-│ ├── documentacion.md
-│ └── DOCUMENTACION_CAMBIOS.md
-└── Otros/
++-- CLAUDE.md
++-- AGENTS.md
++-- .github/
++-- .gitignore
++-- .gitattributes
++-- Atlas Balance/
+¦ +-- AGENTS.md
+¦ +-- CLAUDE.md
+¦ +-- VERSION
+¦ +-- Directory.Build.props
+¦ +-- docker-compose.yml
+¦ +-- Atlas Balance Release/
+¦ +-- backend/
+¦ ¦ +-- GestionCaja.sln
+¦ ¦ +-- src/
+¦ ¦ ¦ +-- GestionCaja.API/
+¦ ¦ ¦ ¦ +-- Program.cs
+¦ ¦ ¦ ¦ +-- appsettings.json
+¦ ¦ ¦ ¦ +-- appsettings.Development.json.template
+¦ ¦ ¦ ¦ +-- Constants/
+¦ ¦ ¦ ¦ +-- Models/
+¦ ¦ ¦ ¦ +-- Data/
+¦ ¦ ¦ ¦ +-- DTOs/
+¦ ¦ ¦ ¦ +-- Services/
+¦ ¦ ¦ ¦ +-- Controllers/
+¦ ¦ ¦ ¦ +-- Middleware/
+¦ ¦ ¦ ¦ +-- Jobs/
+¦ ¦ ¦ ¦ +-- Migrations/
+¦ ¦ ¦ ¦ +-- wwwroot/
+¦ ¦ ¦ +-- GestionCaja.Watchdog/
+¦ ¦ +-- tests/
+¦ +-- frontend/
+¦ ¦ +-- package.json
+¦ ¦ +-- vite.config.ts
+¦ ¦ +-- tsconfig.json
+¦ ¦ +-- index.html
+¦ ¦ +-- src/
+¦ +-- scripts/
++-- Documentacion/
+¦ +-- Versiones/
+¦ +-- Diseno/
+¦ +-- SPEC.md
+¦ +-- documentacion.md
+¦ +-- DOCUMENTACION_CAMBIOS.md
++-- Otros/
```
## Esquema de BD corregido
@@ -227,7 +228,7 @@ npm run build
# Release Windows x64
cd "Atlas Balance"
-powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05
# Conectar a PostgreSQL
psql -h localhost -p 5433 -U app_user -d atlas_balance
diff --git a/Atlas Balance/Directory.Build.props b/Atlas Balance/Directory.Build.props
index 17b94e1..fc9160a 100644
--- a/Atlas Balance/Directory.Build.props
+++ b/Atlas Balance/Directory.Build.props
@@ -2,10 +2,10 @@
Atlas Labs
Atlas Balance
- 1.4.0
- 1.4.0.0
- 1.4.0.0
- V-01.04
+ 1.5.0
+ 1.5.0.0
+ 1.5.0.0
+ V-01.05
false
diff --git a/Atlas Balance/README_RELEASE.md b/Atlas Balance/README_RELEASE.md
index 14b5b4f..3088097 100644
--- a/Atlas Balance/README_RELEASE.md
+++ b/Atlas Balance/README_RELEASE.md
@@ -1,8 +1,8 @@
-# Atlas Balance V-01.04 - release Windows x64
+# Atlas Balance V-01.05 - release Windows x64
Este paquete es autonomo para servidor Windows: el frontend ya esta compilado, el backend y Watchdog van publicados self-contained y la base de datos se prepara desde el instalador.
-El ZIP `main` de GitHub no sirve como instalador. Usa `AtlasBalance-V-01.04-win-x64.zip`; dentro deben existir `api\GestionCaja.API.exe` y `watchdog\GestionCaja.Watchdog.exe`.
+El ZIP `main` de GitHub no sirve como instalador. Usa `AtlasBalance-V-01.05-win-x64.zip`; dentro deben existir `api\GestionCaja.API.exe` y `watchdog\GestionCaja.Watchdog.exe`.
## Scripts de un clic
@@ -40,10 +40,10 @@ En Windows Server 2019, instala PostgreSQL manualmente si `winget` falla o no es
El instalador genera passwords fuertes y guarda las credenciales iniciales en:
```text
-C:\AtlasBalance\INSTALL_CREDENTIALS_ONCE.txt
+C:\AtlasBalance\config\INSTALL_CREDENTIALS_ONCE.txt
```
-Guarda ese contenido en un gestor de passwords y borra el archivo despues del primer acceso. Dejarlo ahi es mala seguridad con sombrero.
+El directorio `config` queda restringido a Administrators/SYSTEM antes de escribir ese archivo. Guarda ese contenido en un gestor de passwords y borra el archivo despues del primer acceso. Dejarlo ahi es mala seguridad con sombrero.
Si ya tienes PostgreSQL y quieres usarlo:
@@ -59,6 +59,8 @@ Si reinstalas sobre una BD existente, las credenciales iniciales no se regeneran
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Reset-AdminPassword.ps1" -InstallPath C:\AtlasBalance -AdminEmail admin@atlasbalance.local -GeneratePassword
```
+Ejecuta el reset como Administrador. Si usas `-GeneratePassword`, la password temporal queda en `C:\AtlasBalance\config\RESET_ADMIN_CREDENTIALS_ONCE.txt`.
+
Health check recomendado:
```powershell
@@ -76,7 +78,19 @@ Desde la carpeta descomprimida de este paquete:
Si la instalacion ya tiene los scripts nuevos, tambien puedes lanzar desde la carpeta instalada apuntando al paquete:
```powershell
-C:\AtlasBalance\update.cmd -PackagePath C:\Temp\AtlasBalance-V-01.04-win-x64 -InstallPath C:\AtlasBalance
+C:\AtlasBalance\update.cmd -PackagePath C:\Temp\AtlasBalance-V-01.05-win-x64 -InstallPath C:\AtlasBalance
```
El actualizador crea backup previo, conserva configuracion, reemplaza API/Watchdog, actualiza scripts operativos instalados, actualiza `VERSION`/runtime y valida `/api/health` con `curl.exe -k`.
+
+## Actualizacion desde la app
+
+En `Configuracion > Sistema`, deja el repositorio:
+
+```text
+https://github.com/AtlasLabs797/AtlasBalance
+```
+
+`Verificar actualizacion` consulta el ultimo GitHub Release. `Actualizar ahora` descarga el asset `AtlasBalance-*-win-x64.zip`, lo valida, lo prepara en `C:\AtlasBalance\updates` y pide al Watchdog aplicar la API nueva.
+
+El Watchdog crea backup PostgreSQL previo, rollback de binarios y health check posterior. Si no puede crear backup o la API no responde despues de actualizar, revierte o rechaza la operacion.
diff --git a/Atlas Balance/VERSION b/Atlas Balance/VERSION
index f179ef7..de5476c 100644
--- a/Atlas Balance/VERSION
+++ b/Atlas Balance/VERSION
@@ -1 +1 @@
-V-01.04
+V-01.05
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs
index 0256966..c523329 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs
@@ -5,6 +5,9 @@ public static class AuditActions
public const string Login = "LOGIN";
public const string Logout = "LOGOUT";
public const string LoginFailed = "LOGIN_FAILED";
+ public const string LoginMfaRequired = "LOGIN_MFA_REQUIRED";
+ public const string MfaVerified = "MFA_VERIFIED";
+ public const string MfaEnabled = "MFA_ENABLED";
public const string AccountLocked = "ACCOUNT_LOCKED";
public const string PasswordChanged = "PASSWORD_CHANGED";
public const string PasswordReset = "PASSWORD_RESET";
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs
index 4fe41dc..befdd08 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs
@@ -35,7 +35,13 @@ public async Task Login([FromBody] LoginRequest request, Cancella
try
{
- var result = await _authService.LoginAsync(request.Email, request.Password, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
+ var trustedMfaToken = Request.Cookies["mfa_trusted"];
+ var result = await _authService.LoginAsync(
+ request.Email,
+ request.Password,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken,
+ trustedMfaToken);
return Ok(AttachCookiesAndBuildAuthResponse(result));
}
catch (AuthException ex)
@@ -60,6 +66,31 @@ public async Task RefreshToken(CancellationToken cancellationToke
}
}
+ [HttpPost("mfa/verify")]
+ [AllowAnonymous]
+ public async Task VerifyMfa([FromBody] VerifyMfaRequest request, CancellationToken cancellationToken)
+ {
+ if (request is null)
+ {
+ return BadRequest(new { error = "Request inválido" });
+ }
+
+ try
+ {
+ var result = await _authService.VerifyMfaAsync(
+ request.ChallengeId,
+ request.Code,
+ HttpContext.Connection.RemoteIpAddress?.ToString(),
+ cancellationToken);
+ return Ok(AttachCookiesAndBuildAuthResponse(result));
+ }
+ catch (AuthException ex)
+ {
+ return StatusCode(ex.StatusCode, new { error = ex.Message });
+ }
+ }
+
+
[HttpPost("logout")]
[AllowAnonymous]
public async Task Logout(CancellationToken cancellationToken)
@@ -70,6 +101,7 @@ public async Task Logout(CancellationToken cancellationToken)
DeleteCookie("access_token");
DeleteCookie("refresh_token");
DeleteCookie("csrf_token");
+ DeleteCookie("mfa_trusted");
var actorUserId = TryGetUserId(out var authenticatedUserId)
? authenticatedUserId
@@ -146,6 +178,18 @@ public async Task CambiarPassword([FromBody] ChangePasswordReques
private object AttachCookiesAndBuildAuthResponse(AuthResult result)
{
+ if (result.MfaRequired)
+ {
+ return new AuthResponse
+ {
+ MfaRequired = true,
+ MfaSetupRequired = result.MfaSetupRequired,
+ MfaChallengeId = result.MfaChallengeId,
+ MfaSecret = result.MfaSecret,
+ MfaOtpAuthUri = result.MfaOtpAuthUri
+ };
+ }
+
if (!string.IsNullOrWhiteSpace(result.AccessToken))
{
Response.Cookies.Append("access_token", result.AccessToken, BuildCookieOptions(TimeSpan.FromHours(1), httpOnly: true, secure: ShouldUseSecureCookie()));
@@ -156,6 +200,15 @@ private object AttachCookiesAndBuildAuthResponse(AuthResult result)
Response.Cookies.Append("refresh_token", result.RefreshToken, BuildCookieOptions(TimeSpan.FromDays(7), httpOnly: true, secure: ShouldUseSecureCookie()));
}
+ if (!string.IsNullOrWhiteSpace(result.TrustedMfaToken) && result.TrustedMfaTokenExpiresAt.HasValue)
+ {
+ var maxAge = result.TrustedMfaTokenExpiresAt.Value - DateTime.UtcNow;
+ if (maxAge > TimeSpan.Zero)
+ {
+ Response.Cookies.Append("mfa_trusted", result.TrustedMfaToken, BuildCookieOptions(maxAge, httpOnly: true, secure: ShouldUseSecureCookie()));
+ }
+ }
+
var csrfToken = _csrfService.GenerateToken();
Response.Cookies.Append("csrf_token", csrfToken, BuildCookieOptions(TimeSpan.FromDays(7), httpOnly: false, secure: ShouldUseSecureCookie()));
@@ -185,7 +238,7 @@ private void DeleteCookie(string name)
{
Response.Cookies.Delete(name, new CookieOptions
{
- HttpOnly = name is "access_token" or "refresh_token",
+ HttpOnly = name is "access_token" or "refresh_token" or "mfa_trusted",
Secure = ShouldUseSecureCookie(),
SameSite = SameSiteMode.Strict,
IsEssential = true
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs
index eb10905..cbfa4bd 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs
@@ -113,7 +113,7 @@ public async Task Listar(
.Take(pageSize)
.ToListAsync(cancellationToken);
- var plazoMap = await BuildPlazoFijoMapAsync(pageRows.Select(x => x.Cuenta.Id).ToList(), cancellationToken);
+ var plazoMap = await BuildPlazoFijoMapAsync(pageRows.Select(x => x.Cuenta.Id).ToList(), scope, cancellationToken);
var data = pageRows
.Select(x => new CuentaListItemResponse
{
@@ -176,7 +176,7 @@ public async Task Obtener(Guid id, [FromQuery] bool incluirElimin
.Where(t => t.Id == cuenta.TitularId)
.Select(t => new { t.Nombre, t.Tipo })
.FirstOrDefaultAsync(cancellationToken);
- var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], cancellationToken);
+ var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], scope, cancellationToken);
return Ok(new CuentaListItemResponse
{
@@ -257,7 +257,7 @@ public async Task Resumen(Guid id, [FromQuery] string periodo = "
.Select(e => (DateTime?)(e.FechaModificacion ?? e.FechaCreacion))
.FirstOrDefaultAsync(cancellationToken);
var tipoCuenta = ResolveTipoCuenta(new Cuenta { TipoCuenta = cuenta.TipoCuenta, EsEfectivo = cuenta.EsEfectivo });
- var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], cancellationToken);
+ var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], scope, cancellationToken);
return Ok(new CuentaResumenResponse
{
@@ -329,7 +329,7 @@ public async Task Crear([FromBody] SaveCuentaRequest request, Can
Iban = validation.Iban,
BancoNombre = validation.BancoNombre,
Divisa = validation.Divisa!,
- FormatoId = validation.TipoCuenta == TipoCuenta.NORMAL ? request.FormatoId : null,
+ FormatoId = SupportsFormatoImportacion(validation.TipoCuenta) ? request.FormatoId : null,
TipoCuenta = validation.TipoCuenta,
EsEfectivo = validation.TipoCuenta == TipoCuenta.EFECTIVO,
Activa = request.Activa,
@@ -396,7 +396,7 @@ public async Task Actualizar(Guid id, [FromBody] SaveCuentaReques
cuenta.Iban = validation.Iban;
cuenta.BancoNombre = validation.BancoNombre;
cuenta.Divisa = validation.Divisa!;
- cuenta.FormatoId = validation.TipoCuenta == TipoCuenta.NORMAL ? request.FormatoId : null;
+ cuenta.FormatoId = SupportsFormatoImportacion(validation.TipoCuenta) ? request.FormatoId : null;
cuenta.TipoCuenta = validation.TipoCuenta;
cuenta.EsEfectivo = validation.TipoCuenta == TipoCuenta.EFECTIVO;
cuenta.Activa = request.Activa;
@@ -597,7 +597,7 @@ private async Task ValidateCuentaRequestAsync(
return CuentaValidationResult.Fail("La divisa indicada no esta activa", tipoCuenta);
}
- if (tipoCuenta == TipoCuenta.NORMAL && request.FormatoId.HasValue)
+ if (SupportsFormatoImportacion(tipoCuenta) && request.FormatoId.HasValue)
{
var formato = await _dbContext.FormatosImportacion.FirstOrDefaultAsync(f => f.Id == request.FormatoId.Value, cancellationToken);
if (formato is null)
@@ -706,6 +706,11 @@ private static TipoCuenta ResolveTipoCuenta(Cuenta cuenta)
return cuenta.TipoCuenta;
}
+ private static bool SupportsFormatoImportacion(TipoCuenta tipoCuenta)
+ {
+ return tipoCuenta != TipoCuenta.PLAZO_FIJO;
+ }
+
private static TipoCuenta ResolveRequestedTipoCuenta(SaveCuentaRequest request)
{
if (request.TipoCuenta.HasValue)
@@ -744,23 +749,27 @@ request.CuentaReferenciaId is null &&
};
}
- private async Task> BuildPlazoFijoMapAsync(IReadOnlyList cuentaIds, CancellationToken cancellationToken)
+ private async Task> BuildPlazoFijoMapAsync(IReadOnlyList cuentaIds, UserAccessScope scope, CancellationToken cancellationToken)
{
if (cuentaIds.Count == 0)
{
return [];
}
+ var referenceAccounts = scope.IsAdmin
+ ? _dbContext.Cuentas.IgnoreQueryFilters()
+ : _userAccessService.ApplyCuentaScope(_dbContext.Cuentas, scope);
+
var rows = await (
from plazo in _dbContext.PlazosFijos
- join refCuenta in _dbContext.Cuentas.IgnoreQueryFilters() on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin
+ join refCuenta in referenceAccounts on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin
from cuentaReferencia in refJoin.DefaultIfEmpty()
where cuentaIds.Contains(plazo.CuentaId)
select new PlazoFijoResponse
{
Id = plazo.Id,
CuentaId = plazo.CuentaId,
- CuentaReferenciaId = plazo.CuentaReferenciaId,
+ CuentaReferenciaId = cuentaReferencia != null ? plazo.CuentaReferenciaId : null,
CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null,
FechaInicio = plazo.FechaInicio,
FechaVencimiento = plazo.FechaVencimiento,
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs
index b6e6b1a..34b8fa2 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs
@@ -300,18 +300,23 @@ public async Task ToggleCheck(Guid id, [FromBody] ToggleCheckedRe
[HttpPatch("{id:guid}/flag")]
public async Task ToggleFlag(Guid id, [FromBody] ToggleFlagRequest req, CancellationToken ct)
{
+ if (req is null) return BadRequest(new { error = "Solicitud invalida" });
if (!TryGetUser(out var actor)) return Unauthorized(new { error = "Usuario no autenticado" });
var ex = await _db.Extractos.FirstOrDefaultAsync(x => x.Id == id, ct);
if (ex is null) return NotFound(new { error = "Extracto no encontrado" });
var cuenta = await _db.Cuentas.FirstOrDefaultAsync(c => c.Id == ex.CuentaId, ct);
if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" });
var p = await GetPermission(actor, cuenta, ct);
- if (!p.CanEdit || (!CanEditColumn(p, "flagged") && !CanEditColumn(p, "flagged_nota"))) return Forbid();
+ if (!p.CanEdit) return Forbid();
var oldFlag = ex.Flagged;
var oldNote = ex.FlaggedNota;
+ var newNote = req.Flagged ? req.Nota?.Trim() : null;
+ if (oldFlag != req.Flagged && !CanEditColumn(p, "flagged")) return Forbid();
+ if (!string.Equals(oldNote, newNote, StringComparison.Ordinal) && !CanEditColumn(p, "flagged_nota")) return Forbid();
+
ex.Flagged = req.Flagged;
- ex.FlaggedNota = req.Flagged ? req.Nota?.Trim() : null;
+ ex.FlaggedNota = newNote;
ex.FlaggedAt = req.Flagged ? DateTime.UtcNow : null;
ex.FlaggedById = req.Flagged ? actor.Id : null;
ex.UsuarioModificacionId = actor.Id;
@@ -351,7 +356,10 @@ public async Task Restaurar(Guid id, CancellationToken ct)
if (!TryGetUser(out var actor)) return Unauthorized(new { error = "Usuario no autenticado" });
var ex = await _db.Extractos.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (ex is null) return NotFound(new { error = "Extracto no encontrado" });
- if (!await CanView(actor, ex.CuentaId, ct)) return Forbid();
+ var cuenta = await _db.Cuentas.FirstOrDefaultAsync(c => c.Id == ex.CuentaId, ct);
+ if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" });
+ var p = await GetPermission(actor, cuenta, ct);
+ if (!p.CanDelete) return Forbid();
ex.DeletedAt = null;
ex.DeletedById = null;
ex.UsuarioModificacionId = actor.Id;
@@ -399,7 +407,7 @@ public async Task GetCuentaResumen(Guid cuentaId, [FromQuery] str
var cuenta = await _db.Cuentas.Where(c => c.Id == cuentaId).Select(c => new { c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, c.TitularId, c.Notas }).FirstOrDefaultAsync(ct);
if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" });
var titular = await _db.Titulares.Where(t => t.Id == cuenta.TitularId).Select(t => t.Nombre).FirstOrDefaultAsync(ct);
- return Ok(await BuildSummary(cuenta.Id, cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.TipoCuenta, cuenta.TitularId, titular ?? string.Empty, cuenta.Notas, periodo, ct));
+ return Ok(await BuildSummary(actor, cuenta.Id, cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.TipoCuenta, cuenta.TitularId, titular ?? string.Empty, cuenta.Notas, periodo, ct));
}
[HttpGet("titulares/{titularId:guid}/cuentas")]
@@ -414,7 +422,7 @@ public async Task GetCuentasTitular(Guid titularId, [FromQuery] s
var summary = new List();
foreach (var c in cuentas)
{
- summary.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, titular.Id, titular.Nombre, c.Notas, periodo, ct));
+ summary.Add(await BuildSummary(actor, c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, titular.Id, titular.Nombre, c.Notas, periodo, ct));
}
return Ok(new TitularConCuentasResponse { TitularId = titular.Id, TitularNombre = titular.Nombre, Cuentas = summary });
}
@@ -432,7 +440,7 @@ public async Task GetTitularesResumen([FromQuery] string periodo
{
var tc = cuentas.Where(c => c.TitularId == t.Id).ToList();
var s = new List();
- foreach (var c in tc) s.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, t.Id, t.Nombre, c.Notas, periodo, ct));
+ foreach (var c in tc) s.Add(await BuildSummary(actor, c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, t.Id, t.Nombre, c.Notas, periodo, ct));
outData.Add(new TitularConCuentasResponse { TitularId = t.Id, TitularNombre = t.Nombre, Cuentas = s });
}
return Ok(outData);
@@ -481,7 +489,7 @@ public async Task SaveColumnasVisibles([FromBody] SaveColumnasVis
return Ok(new { message = "Preferencias guardadas" });
}
- private async Task BuildSummary(Guid cuentaId, string cuentaNombre, string divisa, bool esEfectivo, TipoCuenta tipoCuenta, Guid titularId, string titularNombre, string? notas, string periodo, CancellationToken ct)
+ private async Task BuildSummary(Actor actor, Guid cuentaId, string cuentaNombre, string divisa, bool esEfectivo, TipoCuenta tipoCuenta, Guid titularId, string titularNombre, string? notas, string periodo, CancellationToken ct)
{
var q = _db.Extractos.Where(e => e.CuentaId == cuentaId);
var latest = await q
@@ -498,14 +506,14 @@ private async Task BuildSummary(Guid cuentaId, string
var plazoFijo = tipoCuenta == TipoCuenta.PLAZO_FIJO
? await (
from plazo in _db.PlazosFijos
- join refCuenta in _db.Cuentas.IgnoreQueryFilters() on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin
+ join refCuenta in (actor.IsAdmin ? _db.Cuentas.IgnoreQueryFilters() : _db.Cuentas) on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin
from cuentaReferencia in refJoin.DefaultIfEmpty()
where plazo.CuentaId == cuentaId
select new PlazoFijoResponse
{
Id = plazo.Id,
CuentaId = plazo.CuentaId,
- CuentaReferenciaId = plazo.CuentaReferenciaId,
+ CuentaReferenciaId = cuentaReferencia != null ? plazo.CuentaReferenciaId : null,
CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null,
FechaInicio = plazo.FechaInicio,
FechaVencimiento = plazo.FechaVencimiento,
@@ -519,6 +527,12 @@ from cuentaReferencia in refJoin.DefaultIfEmpty()
.FirstOrDefaultAsync(ct)
: null;
+ if (!actor.IsAdmin && plazoFijo?.CuentaReferenciaId is Guid referenceId && !await CanView(actor, referenceId, ct))
+ {
+ plazoFijo.CuentaReferenciaId = null;
+ plazoFijo.CuentaReferenciaNombre = null;
+ }
+
return new CuentaResumenKpiResponse
{
CuentaId = cuentaId,
@@ -619,8 +633,9 @@ private async Task> GetAllowedAccountIds(Actor actor, Cancellation
return [.. await _db.Cuentas.Select(c => c.Id).ToListAsync(ct)];
}
- var ids = perms.Where(p => p.CuentaId.HasValue).Select(p => p.CuentaId!.Value).ToHashSet();
- var titularIds = perms.Where(p => p.TitularId.HasValue).Select(p => p.TitularId!.Value).ToList();
+ var accountPerms = perms.Where(GrantsAccountAccess).ToList();
+ var ids = accountPerms.Where(p => p.CuentaId.HasValue).Select(p => p.CuentaId!.Value).ToHashSet();
+ var titularIds = accountPerms.Where(p => p.TitularId.HasValue).Select(p => p.TitularId!.Value).ToList();
if (titularIds.Any()) ids.UnionWith(await _db.Cuentas.Where(c => titularIds.Contains(c.TitularId)).Select(c => c.Id).ToListAsync(ct));
return ids;
}
@@ -645,7 +660,7 @@ private async Task CanViewTitular(Actor actor, Guid titularId, Cancellatio
return true;
}
- if (perms.Any(p => p.TitularId == titularId))
+ if (perms.Any(p => p.TitularId == titularId && GrantsAccountAccess(p)))
{
return true;
}
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs
index fb68c09..1a992fc 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs
@@ -290,8 +290,8 @@ public async Task Extractos(
var usersById = await _dbContext.Usuarios
.IgnoreQueryFilters()
.Where(x => userIds.Contains(x.Id))
- .Select(x => new { x.Id, x.Email, IsDeleted = x.DeletedAt != null })
- .ToDictionaryAsync(x => x.Id, x => x.IsDeleted ? "usuario-eliminado" : x.Email, cancellationToken);
+ .Select(x => new { x.Id, x.NombreCompleto, IsDeleted = x.DeletedAt != null })
+ .ToDictionaryAsync(x => x.Id, x => x.IsDeleted ? "usuario-eliminado" : x.NombreCompleto, cancellationToken);
var cuentasById = cuentas.ToDictionary(x => x.CuentaId);
var extractos = rows.Select(x =>
@@ -692,7 +692,6 @@ public async Task Auditoria(
var cuentaIds = cuentas.Select(x => x.CuentaId).ToHashSet();
var extractosScope = _dbContext.Extractos
- .IgnoreQueryFilters()
.AsNoTracking()
.Where(x => cuentaIds.Contains(x.CuentaId))
.Select(x => new { x.Id, x.CuentaId });
@@ -759,8 +758,7 @@ public async Task Auditoria(
celda_referencia = x.CeldaReferencia,
columna_nombre = x.ColumnaNombre,
valor_anterior = x.ValorAnterior,
- valor_nuevo = x.ValorNuevo,
- ip_address = x.IpAddress != null ? x.IpAddress.ToString() : null
+ valor_nuevo = x.ValorNuevo
};
}).ToList();
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs
index d22700e..3e8c047 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs
@@ -171,6 +171,7 @@ public async Task GuardarPermisos(Guid id, [FromBody] IReadOnlyLi
var before = await LoadPermisosAuditSnapshotAsync(id, cancellationToken);
await UpsertPermisosAsync(id, permisos, cancellationToken);
+ var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
var after = await LoadPermisosAuditSnapshotAsync(id, cancellationToken);
@@ -180,7 +181,7 @@ await _auditService.LogAsync(
"USUARIOS",
id,
HttpContext,
- JsonSerializer.Serialize(new { before, after }),
+ JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }),
cancellationToken);
return Ok(new { message = "Permisos actualizados" });
@@ -251,6 +252,7 @@ public async Task GuardarPermisoCuenta(Guid id, Guid cuentaId, [F
await AddPermisoAsync(id, normalizedRequest);
AddPreferencia(id, normalizedRequest.CuentaId, normalizedRequest.ColumnasVisibles, normalizedRequest.ColumnasEditables);
+ var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
var after = await LoadPermisosAuditSnapshotAsync(id, cancellationToken);
@@ -260,7 +262,7 @@ await _auditService.LogAsync(
"USUARIOS",
id,
HttpContext,
- JsonSerializer.Serialize(new { before, after, cuenta_id = cuentaId }),
+ JsonSerializer.Serialize(new { before, after, cuenta_id = cuentaId, refresh_tokens_revocados = revokedRefreshTokens }),
cancellationToken);
return Ok(new { message = "Permiso de cuenta actualizado" });
@@ -320,6 +322,7 @@ public async Task CrearEmail(Guid id, [FromBody] SaveUsuarioEmail
};
_dbContext.UsuarioEmails.Add(email);
+ var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
var after = await LoadEmailsAsync(id, cancellationToken);
@@ -329,7 +332,7 @@ await _auditService.LogAsync(
"USUARIOS",
id,
HttpContext,
- JsonSerializer.Serialize(new { before, after }),
+ JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }),
cancellationToken);
return CreatedAtAction(nameof(ObtenerEmails), new { id }, new UsuarioEmailResponse
@@ -343,7 +346,8 @@ await _auditService.LogAsync(
[HttpDelete("{id:guid}/emails/{emailId:guid}")]
public async Task EliminarEmail(Guid id, Guid emailId, CancellationToken cancellationToken)
{
- if (!await UsuarioExisteAsync(id, includeDeleted: true, cancellationToken))
+ var usuario = await _dbContext.Usuarios.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
+ if (usuario is null)
{
return NotFound(new { error = "Usuario no encontrado" });
}
@@ -357,6 +361,7 @@ public async Task EliminarEmail(Guid id, Guid emailId, Cancellati
var before = await LoadEmailsAsync(id, cancellationToken);
var wasPrimary = email.EsPrincipal;
_dbContext.UsuarioEmails.Remove(email);
+ var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
if (wasPrimary)
@@ -371,7 +376,7 @@ await _auditService.LogAsync(
"USUARIOS",
id,
HttpContext,
- JsonSerializer.Serialize(new { before, after }),
+ JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }),
cancellationToken);
return Ok(new { message = "Email eliminado" });
@@ -522,6 +527,10 @@ public async Task Actualizar(Guid id, [FromBody] UpdateUsuarioReq
UserSessionState.RotateSecurityStamp(usuario);
revokedRefreshTokens = await RevokeActiveRefreshTokensAsync(usuario.Id, now, cancellationToken);
}
+ else
+ {
+ revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken);
+ }
var normalizedEmails = NormalizeEmails(request.Emails);
await UpsertEmailsAsync(usuario.Id, normalizedEmails, cancellationToken);
@@ -540,7 +549,7 @@ public async Task Actualizar(Guid id, [FromBody] UpdateUsuarioReq
};
await _auditService.LogAsync(GetCurrentUserId(), AuditActions.UpdateUsuario, "USUARIOS", usuario.Id, HttpContext,
- JsonSerializer.Serialize(new { before, after }), cancellationToken);
+ JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken);
if (passwordChanged)
{
@@ -562,7 +571,12 @@ await _auditService.LogAsync(
"USUARIOS",
usuario.Id,
HttpContext,
- JsonSerializer.Serialize(new { before = before.permisos, after = after.permisos }),
+ JsonSerializer.Serialize(new
+ {
+ before = before.permisos,
+ after = after.permisos,
+ refresh_tokens_revocados = revokedRefreshTokens
+ }),
cancellationToken);
}
@@ -631,7 +645,7 @@ public async Task Restaurar(Guid id, CancellationToken cancellati
usuario.DeletedAt = null;
usuario.DeletedById = null;
usuario.Activo = true;
- UserSessionState.RotateSecurityStamp(usuario);
+ var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
@@ -643,7 +657,7 @@ public async Task Restaurar(Guid id, CancellationToken cancellati
};
await _auditService.LogAsync(GetCurrentUserId(), AuditActions.RestoreUsuario, "USUARIOS", usuario.Id, HttpContext,
- JsonSerializer.Serialize(new { before, after }), cancellationToken);
+ JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken);
return Ok(new { message = "Usuario restaurado" });
}
@@ -733,6 +747,12 @@ private async Task RevokeActiveRefreshTokensAsync(Guid usuarioId, DateTime
return activeRefreshTokens.Count;
}
+ private async Task RotateAndRevokeSessionsAsync(Usuario usuario, DateTime revokedAt, CancellationToken cancellationToken)
+ {
+ UserSessionState.RotateSecurityStamp(usuario);
+ return await RevokeActiveRefreshTokensAsync(usuario.Id, revokedAt, cancellationToken);
+ }
+
private async Task> LoadPermisosAsync(Guid usuarioId, CancellationToken cancellationToken)
{
var permisos = await _dbContext.PermisosUsuario
diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs
index 005a779..cf69313 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs
@@ -12,6 +12,12 @@ public sealed class ChangePasswordRequest
public string PasswordNueva { get; set; } = string.Empty;
}
+public sealed class VerifyMfaRequest
+{
+ public string ChallengeId { get; set; } = string.Empty;
+ public string Code { get; set; } = string.Empty;
+}
+
public sealed class AuthUsuarioResponse
{
public Guid Id { get; set; }
@@ -20,6 +26,7 @@ public sealed class AuthUsuarioResponse
public string Rol { get; set; } = string.Empty;
public bool Activo { get; set; }
public bool PrimerLogin { get; set; }
+ public bool MfaEnabled { get; set; }
public DateTime FechaCreacion { get; set; }
public DateTime? FechaUltimaLogin { get; set; }
}
@@ -27,8 +34,13 @@ public sealed class AuthUsuarioResponse
public sealed class AuthResponse
{
public string CsrfToken { get; set; } = string.Empty;
- public AuthUsuarioResponse Usuario { get; set; } = new();
+ public AuthUsuarioResponse? Usuario { get; set; }
public IReadOnlyList Permisos { get; set; } = [];
+ public bool MfaRequired { get; set; }
+ public bool MfaSetupRequired { get; set; }
+ public string? MfaChallengeId { get; set; }
+ public string? MfaSecret { get; set; }
+ public string? MfaOtpAuthUri { get; set; }
}
public sealed class PermisoUsuarioResponse
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs
index fad0886..62face9 100644
--- a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs
+++ b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs
@@ -53,8 +53,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.HasIndex(e => e.Email).IsUnique();
entity.HasIndex(e => e.Rol);
entity.HasIndex(e => e.Activo);
+ entity.HasIndex(e => e.MfaEnabled);
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.SecurityStamp).HasMaxLength(64).IsRequired();
+ entity.Property(e => e.MfaSecret).HasMaxLength(2048);
});
modelBuilder.Entity(entity =>
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs
new file mode 100644
index 0000000..2161b39
--- /dev/null
+++ b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs
@@ -0,0 +1,42 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace GestionCaja.API.Data;
+
+public static class RlsContextSigner
+{
+ public static string BuildPayload(
+ string authMode,
+ string userId,
+ string integrationTokenId,
+ string isAdmin,
+ string system,
+ string requestScope) =>
+ string.Join(
+ "|",
+ authMode,
+ userId,
+ integrationTokenId,
+ isAdmin,
+ system,
+ requestScope);
+
+ public static string Sign(
+ string secret,
+ string authMode,
+ string userId,
+ string integrationTokenId,
+ string isAdmin,
+ string system,
+ string requestScope)
+ {
+ if (string.IsNullOrWhiteSpace(secret))
+ {
+ return string.Empty;
+ }
+
+ var payload = BuildPayload(authMode, userId, integrationTokenId, isAdmin, system, requestScope);
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
+ return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
+ }
+}
diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs
new file mode 100644
index 0000000..e0531c7
--- /dev/null
+++ b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs
@@ -0,0 +1,205 @@
+using System.Data.Common;
+using System.Security.Claims;
+using GestionCaja.API.Middleware;
+using GestionCaja.API.Models;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace GestionCaja.API.Data;
+
+public sealed class RlsDbCommandInterceptor : DbCommandInterceptor
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly string _contextSecret;
+
+ public RlsDbCommandInterceptor(IHttpContextAccessor httpContextAccessor, IConfiguration configuration)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ _contextSecret = configuration["Security:RlsContextSecret"]
+ ?? configuration["JwtSettings:Secret"]
+ ?? string.Empty;
+ }
+
+ public override InterceptionResult ReaderExecuting(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult result)
+ {
+ ApplyRlsContext(command);
+ return result;
+ }
+
+ public override async ValueTask> ReaderExecutingAsync(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ await ApplyRlsContextAsync(command, cancellationToken);
+ return result;
+ }
+
+ public override InterceptionResult NonQueryExecuting(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult result)
+ {
+ ApplyRlsContext(command);
+ return result;
+ }
+
+ public override async ValueTask> NonQueryExecutingAsync(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ await ApplyRlsContextAsync(command, cancellationToken);
+ return result;
+ }
+
+ public override InterceptionResult