본문 바로가기
ASP.NET Core

ASP.NET Core API] Audit Trail(API 로그)

by Fastlane 2023. 11. 1.
728x90
반응형

참조 : How to Implement Audit Trail in ASP.NET Core Web API - Code Maze (code-maze.com)

ASP.NET Core에서 audit trail을 구현하는 방법을 살펴보자. 

 

Audit Trail이란?

audit trail은 application에서 사용자의 동작에 의해 발생하는 모든 activities의 기록을 말한다. 누가 접속을 했는지 무엇을 변경했는지 등을 파악할때 사용한다. 

 

audit trail은 일반적으로 아래 정보를 포함한다. 

  • 수정한 사람
  • 수정일자와 시간
  • 수정 타입
  • 수정된 데이터 

audit trail 정보를 database, files, 저장서비스 등등에 저장할 수 있다. 가장 흔한 방법은 application database에 저장하는 것이다. 

 

application의 데이터 무결성을 위한 audit trail을 구현하는 것은 매우 중요하다. audit trail을 구현하므로 다음이 가능해진다. 

  • 데이터 변경 추적
  • 인증되지 않은 접속의 신원 확인
  • 보안 이슈 조사

카드나 보험 업계에서 사용하는 소프트위에에 audit trail을 구현하는 표준이 규정되어 있다. 

 

앱 설정

visual studio code에서 dotnet web api 명령어를 실행하여 API project를 생성한다. 

Product와 AuditLog model class를 추가한다. 

 

Product.cs

namespace AuditTrailApi.Models;


public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

AuditLog.cs

namespace AuditTrailApi.Models;


public class AuditLog
{
    public int Id { get; set; }
    public string UserEmail => "John.Doe@gmail.com";
    public required string EntityName { get; set; }
    public required string Action { get; set; }
    public DateTime Timestamp { get; set; }
    public required string Changes { get; set; }


}

authentication을 구현하지 않으므로 하드코딩된 이메일만 추가하였다. 

 

Product model의  CRUD가 포함된 ProductsController.cs를 추가한다. 

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using AuditTrailApi.Data;
using AuditTrailApi.Models;
using System.Text.Json;


namespace AuditTrailApi.Controllers;


[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
    private readonly ProductsDbContext _context;


    public ProductsController(ProductsDbContext context)
    {
        _context = context;
    }


    // GET: api/Products
    [HttpGet]
    public async Task<ActionResult<IEnumerable<Product>>> GetProduct()
    {
        if (_context.Products is null)
        {
            return NotFound();
        }


        return await _context.Products.ToListAsync();
    }


    // GET: api/Products/5
    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetProduct(int id)
    {
        if (_context.Products is null)
        {
            return NotFound();
        }


        var product = await _context.Products.FindAsync(id);
        if (product is null)
        {
            return NotFound();
        }


        return Ok(product);
    }


    // PUT: api/Products/5
    // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
    [HttpPut("{id}")]
    public async Task<IActionResult> PutProduct(int id, Product product)
    {
        if (id != product.Id)
        {
            return BadRequest();
        }


        var oldProduct = await _context.Products.FindAsync(id);
        if (oldProduct is null)
        {
            return NotFound();
        }


        _context.Entry(oldProduct).CurrentValues.SetValues(product);


        try
        {
            await _context.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!ProductExists(id))
            {
                return NotFound();
            }
            else
            {
                throw;
            }
        }


        return NoContent();
    }


    // POST: api/Products
    // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
    [HttpPost]
    public async Task<ActionResult<Product>> PostProduct(Product product)
    {
        if (_context.Products is null)
        {
            return Problem("Entity set 'ProductsDbContext.Product'  is null.");
        }


        _context.Products.Add(product);
        await _context.SaveChangesAsync();


        return CreatedAtAction("GetProduct", new { id = product.Id }, product);
    }


    // DELETE: api/Products/5
    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteProduct(int id)
    {
        if (_context.Products is null)
        {
            return NotFound();
        }
        var product = await _context.Products.FindAsync(id);
        if (product is null)
        {
            return NotFound();
        }


        _context.Products.Remove(product);
        await _context.SaveChangesAsync();


        return NoContent();
    }


    private bool ProductExists(int id)
    {
        return (_context.Products?.Any(e => e.Id == id)).GetValueOrDefault();
    }
}

 

1. EF Core Change Tracking을 이용한 Audit Trail

EF Core를 사용하기 위해 필요한 package를 추가한다. 

dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Relational

Entity Framework Core의 change tracking이 자동으로 모든 변경을 table에 저장하도록 구성할 수 있다. 

ProductsDbContext.cs

using System.Text;
using AuditTrailApi.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;


namespace AuditTrailApi.Data;


public class ProductsDbContext : DbContext
{
    public ProductsDbContext(DbContextOptions<ProductsDbContext> options) : base(options)
    {


    }


    public ProductsDbContext()
    {


    }


    public virtual DbSet<Product> Products { get; set; }
    public virtual DbSet<AuditLog> AuditLogs { get; set; }


    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        var modifiedEntities = ChangeTracker.Entries()
            .Where(e => e.State == EntityState.Added
            || e.State == EntityState.Modified
            || e.State == EntityState.Deleted)
            .ToList();


       
        foreach (var modifiedEntity in modifiedEntities)
        {
            var auditLog = new AuditLog
            {
                EntityName = modifiedEntity.Entity.GetType().Name,
                Action = modifiedEntity.State.ToString(),
                Timestamp = DateTime.UtcNow,
                Changes = GetChanges(modifiedEntity)
            };
           
            AuditLogs.Add(auditLog);
        }


        return base.SaveChangesAsync(cancellationToken);


    }


    private static string GetChanges(EntityEntry entity)
    {
        var changes = new StringBuilder();


        foreach(var property in entity.OriginalValues.Properties)
        {
            var originalValue = entity.OriginalValues[property];
            var currentValue = entity.CurrentValues[property];


            if (!Equals(originalValue, currentValue))
            {
                changes.AppendLine($"{property.Name}: From '{originalValue}' to '{currentValue}'");
            }
        }


        return changes.ToString();
    }
}

ProductsDbContext class의 SaveChangeAsync() 함수를 override한다. 

Change Tracker로 수정된 entities를 찾는다. 그 다음 수정된 entity별로 GetChanges()를 사용하여 수정된 properties를 찾는다. AuditLog table에 추가한다. 

 

create, update, delete entity를 할때마다 audit log table에 쌓인다. 

PUT 함수 호출 시, 변경된 properties만 저장될 수 있도록 했다. 

 

Database Migration한다. 

dotnet ef migrations add InitialCreate
dotnet ef update-database

Audit Trail 테스트

 

2. API Controllers에서 Audit Trail 설정하기

또 다른 방법은 API Controller level에서 구현하는 것이다. 

[HttpPost]
public async Task<ActionResult<Product>> PostProduct(Product product)
{
    if (_context.Products == null)
    {
        return Problem("Entity set 'ProductsDbContext.Product'  is null.");
    }

    _context.Products.Add(product); 

    var auditLog = new AuditLog
    {        
        Action = "Product Created",
        Timestamp = DateTime.UtcNow,
        EntityName = typeof(Product).Name,
        Changes = JsonSerializer.Serialize(product)
    };
    _context.AuditLogs.Add(auditLog);

    await _context.SaveChangesAsync();

    return CreatedAtAction("GetProduct", new { id = product.Id }, product);
}

 

3. Middleware에서 Audit Trail 설정하기 

ASP.NET Core pipeline에서 사용할 수 있는 middleware를 만들 수 있다. 

AuditLogMiddleware.cs를 만들어보자. 

using AuditTrailApi.Data;
using AuditTrailApi.Models;
using System.Text;


namespace AuditTrailApi.Middlewares;


public class AuditLogMiddleware
{
    private const string ControllerKey = "controller";
    private const string IdKey = "id";


    private readonly RequestDelegate _next;


    public AuditLogMiddleware(RequestDelegate next)
    {
        _next = next;
    }


    public async Task InvokeAsync(HttpContext context, ProductsDbContext dbContext)
    {
        await _next(context);


        var request = context.Request;


        if (request.Path.StartsWithSegments("/api"))
        {
            request.RouteValues.TryGetValue(ControllerKey, out var controllerValue);
            var controllerName = (string)(controllerValue ?? string.Empty);


            var changedValue = await GetChangedValues(request).ConfigureAwait(false);


            var auditLog = new AuditLog
            {
                EntityName = controllerName,
                Action = request.Method,
                Timestamp = DateTime.UtcNow,
                Changes = changedValue
            };


            dbContext.AuditLogs.Add(auditLog);
            await dbContext.SaveChangesAsync();
        }
    }


private static async Task<string> GetChangedValues(HttpRequest request)
{
    var changedValue = string.Empty;


    switch (request.Method)
    {
        case "POST":
        case "PUT":
            changedValue = await ReadRequestBody(request, Encoding.UTF8).ConfigureAwait(false);
            break;


        case "DELETE":
            request.RouteValues.TryGetValue(IdKey, out var idValueObj);
            changedValue = (string?)idValueObj ?? string.Empty;
            break;


        default:
            break;
    }


    return changedValue;
}


private static async Task<string> ReadRequestBody(HttpRequest request, Encoding? encoding = null)
{
    request.Body.Position = 0;
    var reader = new StreamReader(request.Body, encoding ?? Encoding.UTF8);
    var requestBody = await reader.ReadToEndAsync().ConfigureAwait(false);
    request.Body.Position = 0;


    return requestBody;
}
}

Program.cs 에 생성한  middleware를 추가하자. 

//request의 buffering을 할 수 있는 middleware를 추가한다.
app.Use(next => context => {
    context.Request.EnableBuffering();
    return next(context);
});


//AuditLogMiddleWare를 추가한다.
app.UseMiddleware<AuditLogMiddleware>();


app.MapControllers();


app.Run();

API를 호출해서 테스트를 해보자. 

 

Id 4부터가 Middleware를 사용하여 쌓을 Log이다. 

PUST, POST actions의 request body가 저장된 것을 볼 수 있다. DELETE에서는 Id를 저장한다. 

 

Middleware 실행 조건 추가하기

/api 로 request path가 시작하는 경우에만 AuditLog middleware를 실행한다. 

app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), appBuilder =>
{
    appBuilder.UseMiddleware<AuditLogMiddleware>();
});

 

Project 구조

 

728x90
반응형

댓글