DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on • Updated on

Creando Sistemas Auditables con ASP.NET Core - Parte 7: Auditoría con Audit.NET

Introducción

En nuestra serie de publicaciones sobre ASP.NET Core, continuamos explorando diversos aspectos del desarrollo, y en esta ocasión, nos adentraremos en la creación de sistemas auditables.

Si deseas revisar el código de este artículo, lo tienes disponible aquí.

Sistemas Auditables

En el mundo del desarrollo de software, especialmente en entornos corporativos y bajo ciertos estándares como ISO 27001, es esencial poder registrar las acciones realizadas en un sistema. En muchos casos, se requiere mantener un historial de al menos 90 días de todas las operaciones efectuadas en el sistema para fines de auditoría. No obstante, el período de 90 días puede resultar excesivo en situaciones con alta concurrencia de usuarios o en aplicaciones críticas con una elevada carga de solicitudes.

Por lo tanto, es fundamental abordar este requisito de manera inteligente. ¿Por qué debemos auditar? Porque en ocasiones suceden eventos desafortunados y es necesario identificar quién ejecutó operaciones críticas en el sistema.

En este artículo, exploraremos dos enfoques para implementar la auditoría en nuestro sistema, y cabe destacar que a menudo combino ambos enfoques: AuditableEntity y la biblioteca Audit.NET.

Entidades Auditables

Un Entity Auditable se refiere a la capacidad de rastrear quién creó y editó todas las entidades de nuestra base de datos. Esta funcionalidad no debería agregar una carga significativa al crear comandos, ya que es una tarea repetitiva. Por lo tanto, configuraremos nuestro DbContext para que se encargue de esta tarea por nosotros.

Para hacer que nuestras entidades sean auditables, crearemos una entidad base que todas las demás entidades heredarán:

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

Luego, actualizaremos nuestra única entidad de ejemplo de la siguiente manera:

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

Este cambio en la entidad Product agrega cuatro nuevas propiedades, las cuales se actualizarán posteriormente durante las migraciones y en la base de datos.

En una publicación anterior, ya implementamos la autenticación y autorización de usuarios. Para llevar a cabo la auditoría, necesitamos identificar a los usuarios de manera individual.

Para lograr esto, actualizaremos nuestro DbContext para que almacene automáticamente la información cada vez que guardemos el contexto de la base de datos. El siguiente fragmento de código muestra cómo hacerlo:

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

En este código, destacamos dos aspectos importantes:

  • Hemos inyectado ICurrentUserService para acceder al usuario actual que realiza la operación.
  • Hemos sobrescrito SaveChangesAsync para guardar automáticamente la información del usuario que realiza la operación.

Dentro de SaveChangesAsync ocurre algo especial:

  • El método ChangeTracker.Entries<BaseEntity> nos proporciona todos los registros que se han creado o modificado en el DbContext. Es importante destacar que todas las entidades deben heredar de BaseEntity para que esto funcione. Dependiendo de la operación realizada (crear o modificar), se actualizan automáticamente los campos correspondientes de la entidad modificada.

Este proceso automatizado es realmente beneficioso, ya que no requerirá una intervención manual constante.

Para poder continuar y crear las migraciones necesarias, debemos actualizar la implementación de ICurrentUserService, ya que podría causar problemas si no manejamos adecuadamente los posibles valores null al crear migraciones u otras operaciones que no implican una solicitud HTTP.

Nota 👀: Si tienes alguna pregunta sobre el código, te animo a visitar el repositorio con el código de esta publicación.

public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
    _httpContextAccessor = httpContextAccessor;

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

    // Existe una solicitud HTTP, pero el usuario no está 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

Hemos agregado una nueva propiedad al registro CurrentUser para determinar si el usuario está autenticado o no. Esto se ha hecho para evitar problemas cuando el DbContext accede al usuario actual, ya que al inicializar el DbContext, como sucede en las migraciones en modo de desarrollo, el CurrentUser podría no existir. De esta manera, siempre se inicializa la propiedad User para evitar inconvenientes.

Nota 👀: Cabe destacar que podríamos haber verificado si el usuario es nulo al usarlo en el DbContext. La elección de esta implementación dependerá de tus preferencias, pero lo importante es comprender el concepto de la implementación.

Con estas modificaciones, ya podemos crear migraciones desde el proyecto WebApi utilizando los siguientes comandos:

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

Es importante destacar que la forma en la que realizamos la migración difiere de otros artículos. En el artículo 6, reestructuramos el proyecto para adoptar una arquitectura de Vertical Slice, lo que ha influido en el enfoque actual.

Nota 👀: Si experimentas errores, la solución más sencilla suele ser eliminar el archivo de la base de datos SQLite y volver a ejecutar los comandos anteriores.

Una vez que se haya ejecutado la migración, podremos crear o editar productos (se ha añadido el comando para editar, que puedes consultar en el código fuente). Observarás cómo se guarda la información en la base de datos, como se muestra en la tabla de productos a continuación:

Tabla Products

De esta manera, cumplimos con el requisito de auditoría de manera sencilla. Cada creación o modificación quedará registrada en cada entidad.

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

Es importante tener en cuenta que esta no es una bitácora completa de cambios o registros auditables, sino el primer paso para facilitar el acceso a esta información. Esta implementación es útil cuando se necesita mostrar quién creó un registro o quién lo modificó, como suele ser necesario en un catálogo de clientes.

Implementando Audit.NET

Audit.NET es una biblioteca que simplifica la implementación de requisitos de auditoría. Ofrece una variedad de extensiones y opciones para la persistencia de registros de auditoría.

Nota 👀: Con Audit.NET, puedes integrarlo con Web API, MVC, Entity Framework, SignalR, entre otros. También proporciona opciones de persistencia en SQL Server, MySQL, Azure Storage Tables, Azure Storage Blobs, Elastic Search, y muchas otras.

A continuación, crearemos un nuevo decorador de MediatR para registrar las operaciones en un registro de auditoría. Utilizaremos Azure Storage Accounts y Blobs para la persistencia de registros, ya que esperamos manejar una gran cantidad de registros y no queremos que afecte al rendimiento o los costos de almacenamiento.

Es fundamental diseñar cómo guardamos los registros de auditoría, de modo que sea posible consultar la información, ya sea por tipo de operación o por el usuario que la realizó. Debemos tener en cuenta que, en última instancia, podríamos acumular millones de registros.

Otra lección importante que hemos aprendido es que no queremos guardar registros de auditoría de todas las operaciones (consultas y comandos) en un sistema. Recientemente, nos dimos cuenta de que este tipo de mecanismos puede afectar el rendimiento del sistema, ya que registramos SIEMPRE en la bitácora, ya sea una consulta o un comando. Algunas consultas son mucho más frecuentes que ciertos comandos, por lo que no es necesario registrar todo en la bitácora.

Para evitar este problema y solo registrar en los registros de auditoría las operaciones que nos interesa auditar (que deberían ser todos los comandos), crearemos un atributo para decorar los IRequest y permitiremos que el Behavior determine si es necesario registrar la operación en la bitácora antes de ejecutarla.

Instalación de Audit.NET

Audit.NET proporciona un completo mecanismo de recolección de información, incluyendo datos como la duración de la operación y los cambios realizados. Es altamente flexible y útil. Para obtener más información sobre sus capacidades, puedes visitar su repositorio en GitHub.

El paquete de Azure Storage Blobs es esencial para almacenar los registros en blobs de una cuenta de almacenamiento de Azure. Para probar esto, es necesario tener instalado el emulador de Azure Storage, que suele estar incluido en Visual Studio. Si no lo tienes o no estás seguro de qué se trata, no dudes en preguntar y con gusto te proporcionaré más información sobre el tema.

Para instalar estos paquetes, debemos dirigirnos al proyecto ApplicationCore y ejecutar los siguientes comandos:

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

ApplicationCore -> Common -> Attributes -> AuditLogAttribute

En esta sección, hemos creado un atributo simple para determinar qué IRequest debe ser auditado. No requiere información adicional, ya que su único propósito es identificar los comandos que queremos guardar en la bitácora.

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

ApplicationCore -> Common -> Behaviours -> AuditLogsBehavior

Para llevar a cabo la auditoría, hemos creado un decorador (Behavior) de MediatR. Este decorador se encargará de registrar las operaciones en un registro de auditoría.

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

Aquí está el resumen de lo que ocurre en este comportamiento:

  • _logger.LogInformation: En primer lugar, estamos registrando la solicitud realizada. Esto es útil en modo de desarrollo para obtener información adicional sobre cada consulta o comando que se ejecuta a través del mediador. Utilizamos un log template, que es una práctica recomendada para el registro de eventos. El uso de log templates facilita la implementación de soluciones de registro avanzadas, como Serilog y el envío de registros a Elastic Search (tema que podría abordarse en un artículo futuro). Es importante evitar concatenar cadenas de texto al registrar eventos y utilizar templates como se muestra aquí.
  • Buscamos si el IRequest actual contiene el atributo [AuditLog]. Como mencioné antes, no queremos guardar registros de auditoría de todas las operaciones (solo de los comandos), por lo que esta condición es esencial.
  • Si el atributo [AuditLog] está presente y la configuración indica que los registros de auditoría están habilitados, utilizamos los métodos proporcionados por Audit.NET para auditar la operación.
  • Creamos un scope de auditoría de Audit.NET para medir el tiempo transcurrido y realizar otras acciones que se pueden agregar.

Además, hemos registrado este Behavior y hemos agregado el atributo [AuditLog] a los comandos que deseamos auditar, como por ejemplo, CreateProductCommand.

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

También hemos registrado el pipeline behavior en ApplicationCore -> DependencyInjection, donde habíamos registrado el comportamiento anterior:

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

Por último, en appsettings.json, hemos añadido una nueva sección para la configuración:

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

Esta sección es útil, ya que en modo de desarrollo o en otros entornos, es posible que no deseemos almacenar registros de auditoría. Esto nos proporciona un control sobre la habilitación de los registros.

Con esto, ya podemos ejecutar la Web API y observar su comportamiento. Aunque aún no hemos configurado la cuenta de almacenamiento, Audit.NET generará archivos JSON en la raíz del proyecto de manera predeterminada.

Al ejecutar el comando CreateProductCommand, se generará un registro similar al siguiente:

{
    "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

Este registro contiene información detallada sobre la operación auditada, incluyendo la duración, el usuario, y los datos de la solicitud.

Como puedes observar, en el Audit Scope hemos incluido las propiedades que consideramos más importantes: la solicitud (Request) y el usuario actual. De esta forma, cualquier operación realizada por un usuario quedará registrada de manera adecuada.

Sin embargo, para garantizar la seguridad y la integridad de estos registros, es necesario configurar un lugar adecuado donde almacenarlos. Aquí es donde entra en juego la configuración del Azure Storage Account.

El paquete NuGet que instalamos previamente proporciona métodos sencillos para configurar la persistencia de registros de auditoría. En el código a continuación, se muestra cómo configurar el almacenamiento en Azure Storage Blobs:

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

Aquí se configuran los siguientes aspectos:

  • WithConnectionString: Se proporciona la cadena de conexión del Azure Storage Account.
  • ContainerName: Los archivos de registro se almacenan en contenedores, y se crea un contenedor diferente para cada día, utilizando el formato mediatrlogs20220409, por ejemplo.
  • BlobName: Se establece la ruta en la que se guardarán en el contenedor. Los registros se agrupan por carpetas según el nombre del comando, y el nombre del archivo incluye el ID del usuario. Esto facilita la búsqueda de registros por ID de usuario y permite ver todas las acciones realizadas por ese usuario.

Cuando visualices esto en el Azure Storage Explorer, se verá de la siguiente manera:

Imagen

Si exploras una carpeta en particular:

Imagen

De esta manera, puedes buscar registros por ID de usuario. Sin embargo, ten en cuenta que, aunque puedes buscar por día, no es posible definir un rango de horas al realizar búsquedas. Si necesitas realizar búsquedas más detalladas, podría ser preferible utilizar Azure Storage Tables u otra solución que permita almacenar y buscar una gran cantidad de información.

Un ejemplo del contenido de un registro en formato JSON es el siguiente:

{
    "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

En resumen, esta implementación de auditoría con Audit.NET y Azure Storage Blobs es una herramienta valiosa para crear sistemas auditables de manera efectiva. A pesar de la complejidad de los sistemas de auditoría, las herramientas actuales hacen que sea relativamente sencillo implementar registros de auditoría. Espero que esta información te haya resultado útil. Puedes acceder al código de este post aquí.

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!