DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on • Updated on

[Parte 7] ASP.NET: Creando un Sistema Auditable

Introducción

Siguiendo con la serie de publicaciones sobre ASP.NET Core y sobre todo lo que se nos ocurra, hoy aprenderemos a crear sistemas auditables.

El código de este post lo encontrarás aquí.

Sistemas Auditables

En el mundo de los estándares y software corporativo, es demasiado importante poder registrar los movimientos que se hacen en un sistema.

ISO 27001 por ejemplo, te pide que debes de tener al menos 90 días de registros de los movimientos realizados en tu sistema para poder auditarlo, 90 días puede ser demasiado dependiendo de la cantidad de usuarios que usen el sistema y agregar este overhead en una aplicación crítica puede ser delicado si este recibe muchas solicitudes por segundo.

Por eso hay que pensar bien como implementamos este requerimiento.

Y ¿para qué auditar? siempre ocurren cosas desafortunadas y sí o sí debes de saber quién hizo ciertas operaciones delicadas en tu sistema.

En este post, veremos dos opciones para hacer nuestro sistema auditable (aunque yo suelo usar las dos). AuditableEntity y una librería Audit.NET.

Entities Auditables

Un Entity Auditable significa que todos los Entities de nuestra base de datos, podemos saber quién lo creó y quien lo editó.

Este concern realmente no nos debe de afectar al momento crear Commands, porque realmente es una tarea repetitiva, por lo que configuraremos nuestro DbContext para que haga esta tarea por nosotros.

Para hacer que nuestros entities sean auditables, crearemos un Entity base el cual todos los entities deberán heredar:

namespace MediatrExample.ApplicationCore.Domain;
public class BaseEntity
{
    public DateTime? CreatedAt { get; set; }
    public string? CreatedBy { get; set; }
    public DateTime? LastModifiedByAt { get; set; }
    public string? LastModifiedBy { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Y actualizamos nuestro único entity:

public class Product : BaseEntity // <----
{
   // código omitido
}
Enter fullscreen mode Exit fullscreen mode

El entity Product contará con 4 nuevas propiedades (que más adelante actualizaremos las migraciones y la DB)

En un Post anterior ya hemos implementado la autenticación y autorización de usuarios, y pues para poder auditar, necesitamos identificar a los usuarios individualmente.

Necesitamos actualizar el DbContext para guardar la información automáticamente cada vez que guardamos el contexto de la DB:

using MediatrExample.ApplicationCore.Common.Interfaces;
using MediatrExample.ApplicationCore.Domain;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace MediatrExample.ApplicationCore.Infrastructure.Persistence;

public class MyAppDbContext : IdentityDbContext<IdentityUser>
{
    private readonly CurrentUser _user;

    public MyAppDbContext(
        DbContextOptions<MyAppDbContext> options,
        ICurrentUserService currentUserService) : base(options)
    {
        _user = currentUserService.User;
    }

    public DbSet<Product> Products => Set<Product>();

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        foreach (var entry in ChangeTracker.Entries<BaseEntity>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedBy = _user.Id;
                    entry.Entity.CreatedAt = DateTime.UtcNow;
                    break;

                case EntityState.Modified:
                    entry.Entity.LastModifiedBy = _user.Id;
                    entry.Entity.LastModifiedByAt = DateTime.UtcNow;
                    break;
            }
        }

        return await base.SaveChangesAsync(cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

Aquí hay dos cosas importantes que notar:

  • Inyectamos ICurrentUserService para poder acceder al usuario actual que hace la operación
  • Sobrescribimos SaveChangesAsync para poder emplear este mecanismo para guardar la información de quien hace la operación

Dentro de SaveChangesAsync también ocurren cosas muy importantes (la magia):

  • El método ChangeTracker.Entries<BaseEntity> regresa todos los registros que fueron creados o modificados en el DbContext. Todos los Entities deben de heredar de BaseEntity para que esto funcione. Entonces dependiendo de la operación realizada (crear o editar) se actualizan los campos correspondientes del entity modificado.

Esto es genial, porque este proceso siempre será automático.

Para poder continuar y crear las respectivas migraciones, hay que actualizar la implementación de ICurrentUservice porque probablemente va romper todo si no cuidamos los posibles null al crear migraciones u otras cosas que no involucran un Http Request.

Nota 👀: Cualquier duda que tengas respecto al código, visita el repo con el código de este post

public CurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;

        // Probablemente se está inicializando la aplicación.
        if (_httpContextAccessor is null || _httpContextAccessor.HttpContext is null)
        {
            User = new CurrentUser(Guid.Empty.ToString(), string.Empty, false);
            return;
        }

        // El Http Request existe pero es un usuario no autenticado
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext!.User!.Identity!.IsAuthenticated == false)
        {
            User = new CurrentUser(Guid.Empty.ToString(), string.Empty, false);
            return;
        }

        var id = httpContext.User.Claims
            .FirstOrDefault(q => q.Type == ClaimTypes.Sid)!
            .Value;

        var userName = httpContext.User!.Identity!.Name ?? "Unknown";

        User = new CurrentUser(id, userName, true);
    }
Enter fullscreen mode Exit fullscreen mode

Se agregó una nueva propiedad al record CurrentUser para determinar si es un usuario válido (autenticado) o no. Se agregó esta opción porque el DbContext ahora accesa al usuario actual, pero cuando se inicializa el DbContext como por ejemplo por una migración (en modo desarrollo) el CurrentUser no va a existir, por lo que de igual forma inicializamos la propiedad User para que no existan problemas.

Nota 👀: Claro que pudimos revisar si usuario es null cuando se intentara usar en el DbContext, esto es a criterio de cada uno, lo importante es la idea de la implementación

Con esto, ya deberíamos poder crear migraciones desde el proyecto WebApi:

dotnet ef migrations add AddedBaseEntity -o Infrastructure/Persistence/Migrations -p ..\MediatrExample.ApplicationCore\
dotnet ef database update
Enter fullscreen mode Exit fullscreen mode

La forma en cómo hacemos la migración es diferente a otros posts, en el post 6 hicimos un refactor en la estructura del proyecto y lo convertimos algo más parecido a Vertical Slice Architecture.

Nota 👀: Cualquier pregunta, siempre puedes buscarme en mi twitter

Si tienes errores, lo más fácil será borrar el archivo de la base de datos de SQLite y ejecutar todo de nuevo.

Una vez ejecutada la migración, podemos crear o editar productos (agregué el comando para editar, puedes visitar el código fuente para revisarlo) y veremos cómo se guarda la información en la base de datos:

Tabla Products
De esta forma podemos cumplir con el requerimiento auditable de una manera muy sencilla. Cualquier creación o modificación, quedará registrado en cada entity.

Nota 👀: El CreatedBy con el Guid vacío fue creado por el método Seed dentro de Program.cs

Queda claro que esto no es ninguna bitácora de cambios o logs auditables, este es el primer paso para facilitar el acceso de esta información. Esta implementación es útil para cuando en la aplicación se quiere mostrar el usuario que creó cierto registro o el último en modificar. Siempre se pide esto por ejemplo en un catálogo de Clientes.

Implementando Audit.NET

Audit.NET es una librería que nos facilita implementar este requerimiento. Tiene bastantes extensiones y opciones para persistencia de los logs.

Nota 👀: Con Audit.NET puedes hacer integraciones con Web API, MVC, Entity Framework, SignalR, entre otros. También tiene para que la persistencia sea en SQL Server, MySQL, Azure Storage Tables, Azure Storage Blobs, Elastic Search, entre muchos más

Lo que vamos a hacer a continuación es un nuevo decorador de MediatR para ahí registrar en una bitácora la operación. La persistencia que usaremos es Azure Storage Accounts y Blobs, ya que estamos esperando una gran cantidad de logs y no queremos ser afectados en rendimiento o costos de almacenamiento.

Es importante pensar que vamos a querer consultar los logs según la operación realizada y según el usuario que la efectuó, es por eso que debemos diseñar bien la forma en como guardamos los logs, para que no sea imposible consultar la información si al final terminamos con millones de registros.

Algo que aprendí también, realmente no queremos guardar logs de todas las operaciones (Queries y Comandos) de un sistema, hace poco nos dimos cuenta de que este tipo de mecanismos afectaba el rendimiento en el sistema porque SIEMPRE registrábamos en la bitácora (sea Query o Comando) y hay Queries que son accesibles mucho más frecuentemente que ciertos comandos y no es necesario guardar en bitácora.

Para evitar este problema y solo guardar en los logs las operaciones que nos interesan auditar (que deberían de ser todos los Comandos) crearemos un Atributo para decorar los IRequest y que el Behavior revise si es necesario guardar en bitácora el IRequest que está por ejecutarse.

Instalando Audit.Net

Audit.NET incluye todo el mecanismo de recolección de información, como la duración de la operación y que datos fueron modificados. Es muy flexible y útil, si quieres saber que más puedes hacer, visita su repositorio en github.

El paquete de Azure Storage Blobs es el que nos ayudará a guardar los logs en blobs de un Storage Account. Para poder probar esto, debes de tener instalado el emulador del Storage, que generalmente viene junto con Visual Studio. Si no lo tienes o no sabes que es, coméntame y con gusto escribo respecto a este tema.

Para instalar estos paquetes, nos vamos al ApplicationCore y ejecutamos:

dotnet add package Audit.NET
dotnet add package Audit.NET.AzureStorageBlobs
Enter fullscreen mode Exit fullscreen mode

ApplicationCore -> Common -> Attributes -> AuditLogAttribute

namespace MediatrExample.ApplicationCore.Common.Attributes;

/// <summary>
/// Atributo para determinar que IRequest debe ser auditado
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
public class AuditLogAttribute : Attribute
{
}
Enter fullscreen mode Exit fullscreen mode

Esto es muy simple, ya que no requiere información adicional, solo necesitamos este tipo de atributo para detectar IRequests que queremos guardar en bitácora.

ApplicationCore -> Common -> Behaviours -> AuditLogsBehavior

Para guardar en bitácora, como mencioné antes, utilizaremos un Behavior de MediatR para llevar a cabo esta tarea.

using Audit.Core;
using MediatR;
using MediatrExample.ApplicationCore.Common.Attributes;
using MediatrExample.ApplicationCore.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Reflection;

namespace MediatrExample.ApplicationCore.Common.Behaviours;

public class AuditLogsBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
     where TRequest : IRequest<TResponse>
{
    private readonly ICurrentUserService _currentUserService;
    private readonly ILogger<AuditLogsBehavior<TRequest, TResponse>> _logger;
    private readonly IConfiguration _config;

    public AuditLogsBehavior(
        ICurrentUserService currentUserService,
        ILogger<AuditLogsBehavior<TRequest, TResponse>> logger,
        IConfiguration config)
    {
        _currentUserService = currentUserService;
        _logger = logger;
        _config = config;
    }

    public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        _logger.LogInformation("User {@User} with request {@Request}", _currentUserService.User, request);

        IAuditScope? scope = null;
        var auditLogAttributes = request.GetType().GetCustomAttributes<AuditLogAttribute>();
        if (auditLogAttributes.Any() && _config.GetValue<bool>("AuditLogs:Enabled"))
        {
            // El IRequest cuenta con el atributo [AuditLog] para ser auditado
            scope = AuditScope.Create(_ => _
                .EventType(typeof(TRequest).Name)
                .ExtraFields(new
                {
                    _currentUserService.User,
                    Request = request
                }));
        }

        var result = await next();

        if (scope is not null)
        {
            await scope.DisposeAsync();
        }

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

¿Qué está pasando aquí? aquí va el resumen:

  • _logger.LogInformation: Primero que nada, estamos mostrando la solicitud realizada para en modo desarrollo poder ver información adicional de cada Query o Command que se ejecuta por el mediador.
    • Este cuenta con un Template que es recomendable siempre hacerlo así, logging templates es útil cuando usamos herramientas como Serilog y un Sink a Elastic Search por ejemplo (posible post a futuro). Jamás debes de crear strings concatenados cuando mandas logs, siempre debes de usar log templates como lo estoy haciendo, tal vez ahorita no se vea bonito el log, pero cuando usemos Serilog (otro post) y un Sink a Application Insights (otro post) veremos lo útil que es poder guardar logs de esta forma.
  • Buscamos si el IRequest actual llega con el atributo [AuditLog] ya que como no queremos guardar logs auditables de todas las operaciones (solo los Comandos) debemos hacer esta condición.
  • Si sí contiene un [AuditLog] procedemos a usar los métodos de Audit.NET nos provee para auditar la operación.
  • Creamos un scope de Audit.NET para que todo lo que ocurra dentro de la creación y su DisposeAsync, Audit.NET contará el tiempo y entre otras cosas que se pueden agregar.

Hay que registrar este Behavior y agregar [AuditLog] en los comandos que queremos que sean auditables (por ejem. CreateProductCommand)

[AuditLog]
public class CreateProductCommand
 // Código omitido...
Enter fullscreen mode Exit fullscreen mode

Y el pipeline behavior dentro de ApplicationCore -> DependencyInjection (justo donde registramos el behavior anterior):

services.AddTransient(typeof(IPipelineBehavior<,>), typeof(AuditLogsBehavior<,>));
Enter fullscreen mode Exit fullscreen mode

También, en appsettings.json tendremos esta nueva sección:

"AuditLogs": {
  "Enabled":  true,
  "ConnectionString": "UseDevelopmentStorage=true"
}
Enter fullscreen mode Exit fullscreen mode

Es útil porque probablemente en modo desarrollo o staging, no vamos a querer estar guardando logs.

Con eso ya podemos correr la Web API y ver cómo se comporta, aunque no hemos configurado aun el Storage Account, por default se crean archivos json en la raiz del proyecto.

Ejecutando CreateProductCommand se genera el siguiente log:

{
    "Environment": {
        "UserName": "isaac",
        "MachineName": "DELL-G5",
        "DomainName": "DELL-G5",
        "CallingMethodName": "MediatrExample.ApplicationCore.Common.Behaviours.AuditLogsBehavior\u00602\u002B\u003CHandle\u003Ed__3.MoveNext()",
        "AssemblyName": "MediatrExample.ApplicationCore, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
        "Culture": "es-MX"
    },
    "EventType": "CreateProductCommand",
    "StartDate": "2022-04-09T19:01:34.3460697Z",
    "EndDate": "2022-04-09T19:01:34.6543085Z",
    "Duration": 308,
    "User": {
        "Id": "759aa503-f916-4962-96ed-be0b416b5632",
        "UserName": "test_user",
        "IsAuthenticated": true
    },
    "Request": {
        "Description": "Random product",
        "Price": 558
    }
}
Enter fullscreen mode Exit fullscreen mode

Como pueden ver, en el Audit Scope agregamos las propiedades que más nos interesan: el Request y el Usuario actual. Si un usuario realiza una operación, quedará guardada.

Esto es lo útil, y hasta aquí podríamos dar por terminado, pero necesitamos un mejor lugar en donde guardar todos estos logs. Primero, para que estén seguros y segundo, que no se pierdan.

Configurando el Azure Storage Account

El NuGet Package instalado previamente cuenta con los métodos para configurar la persistencia de una forma muy sencilla:

public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration)
{
    // omitido...

    Configuration.Setup()
        .UseAzureStorageBlobs(config => config
            .WithConnectionString(configuration["AuditLogs:ConnectionString"])
            .ContainerName(ev => $"mediatrlogs{DateTime.Today:yyyyMMdd}")
            .BlobName(ev =>
            {
                var currentUser = ev.CustomFields["User"] as CurrentUser;

                return $"{ev.EventType}/{currentUser?.Id}_{DateTime.UtcNow.Ticks}.json";
            })
        );

    return services;
}
Enter fullscreen mode Exit fullscreen mode

Estamos configurando como se van a guardar los logs en el storage account.

  • WithConnectionString: Se indica la cadena de conexión del Azure Storage Account.
  • ContainerName: Los archivos son guardados en contenedores, aquí haremos un contenedor diferente según el día (usando el formato mediatrlogs20220409 por ejemplo).
  • BlobName: La ruta donde se guardará en el contenedor, aquí agruparemos por carpetas según el nombre del Comando y el archivo incluirá el ID del usuario. Esto para tener la posibilidad de buscar por ID de usuario y poder ver todas las acciones que ha realizado

Si vemos como se ve desde el Azure Storage Explorer, se verá algo así:
Image description
Y si visitamos una carpeta:
Image description
Así, podemos buscar por ID, aunque podremos buscar por día, no podremos poner un rango de hora al buscar. Si se necesitan búsquedas de ese tipo, será mejor usar Azure Storage Tables o cualquier otro que pueda guardar mucha información y buscarla.

Nuevamente un ejemplo del JSON:

{
    "Environment": {
        "UserName": "isaac",
        "MachineName": "DELL-G5",
        "DomainName": "DELL-G5",
        "CallingMethodName": "MediatrExample.ApplicationCore.Common.Behaviours.AuditLogsBehavior\u00602\u002B\u003CHandle\u003Ed__4.MoveNext()",
        "AssemblyName": "MediatrExample.ApplicationCore, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
        "Culture": "es-MX"
    },
    "EventType": "UpdateProductCommand",
    "StartDate": "2022-04-10T00:00:58.8335969Z",
    "EndDate": "2022-04-10T00:00:58.8364306Z",
    "Duration": 3,
    "User": {
        "Id": "759aa503-f916-4962-96ed-be0b416b5632",
        "UserName": "test_user",
        "IsAuthenticated": true
    },
    "Request": {
        "ProductId": 1,
        "Description": "iPhone SE 2022",
        "Price": 11599
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusión

Espero que esta información te sea útil, estos dos mecanismos son buenas herramientas para crear sistemas auditables, realmente no es difícil.

Te recuerdo que aquí te dejó el código de este post.

Referencias

Top comments (8)

Collapse
 
jtempra profile image
Josep Temprà

Impresionante lo que se aprende por aquí!!!
Muchas gracias y no pares porfa! ;-)))

Collapse
 
mnvillamarin_20 profile image
MNVillamarin

Muy buenos los post! me sirven mucho...! No dejes de subir estos post, son muy interesantes. Gracias!

Collapse
 
nicolasbologna profile image
Nicolas Bologna

Gracias genio! Segui con estos posts que ayudan mucho

Collapse
 
arasagui profile image
Antonio Aguila

Gracias por el aporte de este tema, tengo una duda
¿como sería para los logs de inicio de sesión no poner la password de los usuarios en el log? como podemos decidir que propiedades poner o cuales no? ya que de momento pone la request completa incluyendo el password

Collapse
 
jecacarvajal profile image
JecaCarvajal

Excelente, aunque tengo un problema con la bd, que ni idea como solucionar doesn't reference Microsoft.EntityFrameworkCore.Design

Collapse
 
isaacojeda profile image
Isaac Ojeda • Edited

Sí hay que hacer referencia a ese paquete NuGet, se agrega a la Web API ya que desde ahí hacemos las migraciones.

<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">

¡Saludos!

Collapse
 
ramcode23 profile image
Raphy Mejia • Edited

Hola,tengo una duda como se haria si quiero saber el username creador de un producto con linq? me das un ejemplo cual seria la mejor forma en tu caso productos.

Collapse
 
isaacojeda profile image
Isaac Ojeda

¡Hola! Gracias por escribir.

Aquí te va un ejemplo de como obtener el nombre del usuario creador

    public Task<List<GetProductsQueryResponse>> Handle(GetProductsQuery request, CancellationToken cancellationToken) 
    {
        var query = 
            from product in _context.Products
            join user in _context.Users on product.CreatedBy equals user.Id
            select new GetProductsQueryResponse
            {
                Description = product.Description,
                ListDescription = $"{product.Description} - {product.Price:c}",
                Price = product.Price,
                ProductId = product.ProductId,
                CreatedBy = user.UserName
            };


        return query.ToListAsync(cancellationToken: cancellationToken);
    }
Enter fullscreen mode Exit fullscreen mode

Hacerlo de esta forma me resulta mas fácil de entender, hacerlo con linq basado en métodos (según copilot) es así :

        var query = _context.Products
            .Join(_context.Users, product => product.CreatedBy, user => user.Id, (product, user) => new GetProductsQueryResponse
            {
                Description = product.Description,
                ListDescription = $"{product.Description} - {product.Price:c}",
                Price = product.Price,
                ProductId = product.ProductId,
                CreatedBy = user.UserName
            });
Enter fullscreen mode Exit fullscreen mode

Pero en los dos casos el resultado es:

[
  {
    "productId": 12,
    "description": "Random product",
    "price": 558,
    "listDescription": "Random product - $558.00",
    "createdBy": "test_user"
  }
]
Enter fullscreen mode Exit fullscreen mode

Espero esto responda a tu pregunta

Saludos!