Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog.

## [Unreleased]

### Added
- shared `IExecutionContext` in `MinimalCleanArch`
- `NullExecutionContext` fallback for non-HTTP and test scenarios
- HTTP-backed execution context registration in `MinimalCleanArch.Extensions`
- message-scope-aware execution context registration in `MinimalCleanArch.Messaging`
- execution-context-backed audit adapter in `MinimalCleanArch.Audit`
- non-breaking base DbContext constructor overloads that accept `IExecutionContext`
- `GetCurrentTenantId()` virtual hooks in DataAccess base DbContexts
- messaging options for local queue naming, queue prefixing, dead-letter expiration, and failure-policy hooks
- repository default interface implementations for `AnyAsync`, `SingleOrDefaultAsync`, and `CountAsync(ISpecification<T>)`

### Changed
- `AddAuditLogging()` now prefers `IExecutionContext` when one is registered, while preserving HTTP fallback behavior
- sample and generated DbContexts now use `DbContextBase` or `IdentityDbContextBase` as the preferred path for audit stamping and soft-delete filtering
- template and sample messaging setup now targets the updated MinimalCleanArch messaging defaults

### Fixed
- `UseMinimalCleanArchApiDefaults()` now applies middleware in the correct order: correlation ID, security headers, error handling, then rate limiting
- `IAuditContextProvider.GetTenantId()` is now non-breaking for existing implementations through a default interface implementation
- `MessagingOptions.SchemaName` documentation now correctly states that it applies to both SQL Server and PostgreSQL persistence

### Breaking Changes
- none intended in this batch; compatibility shims were added for the expanded repository and audit interfaces
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Preferred defaults:
- use `AddValidationFromAssemblyContaining<T>()` for validator registration
- use `AddMinimalCleanArchMessaging...` extensions instead of wiring Wolverine from scratch
- use Data Protection-based encryption for new development
- use `IExecutionContext` as the shared source for user, tenant, and correlation data across HTTP and message-handler flows

## Packages
| Package | Description |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MinimalCleanArch.DataAccess;
using MinimalCleanArch.Sample.Domain.Entities;
using MinimalCleanArch.Execution;
using MinimalCleanArch.Security.Encryption;
using MinimalCleanArch.Security.EntityEncryption;
using System.Security.Claims;
using System.Linq.Expressions;
using MinimalCleanArch.Domain.Entities;
using MinimalCleanArch.Audit.Configuration;
Expand All @@ -17,11 +17,10 @@ namespace MinimalCleanArch.Sample.Infrastructure.Data;
/// <summary>
/// Application DbContext with Identity API endpoints and MinimalCleanArch features
/// </summary>
public class ApplicationDbContext : IdentityDbContext<User, IdentityRole, string>
public class ApplicationDbContext : IdentityDbContextBase<User, IdentityRole, string>
{
private readonly IEncryptionService _encryptionService;
private readonly IServiceProvider _serviceProvider;
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly AuditOptions? _auditOptions;

/// <summary>
Expand All @@ -31,13 +30,12 @@ public ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options,
IEncryptionService encryptionService,
IServiceProvider serviceProvider,
IHttpContextAccessor? httpContextAccessor = null,
IExecutionContext? executionContext = null,
AuditOptions? auditOptions = null)
: base(options)
: base(options, executionContext)
{
_encryptionService = encryptionService;
_serviceProvider = serviceProvider;
_httpContextAccessor = httpContextAccessor;
_auditOptions = auditOptions;
}

Expand All @@ -54,9 +52,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

// Apply soft delete query filters
ApplySoftDeleteQueryFilters(modelBuilder);

// Configure Identity entities with custom table names
ConfigureIdentityEntities(modelBuilder);

Expand All @@ -73,25 +68,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
}
}

/// <summary>
/// Applies soft delete query filters to all entities that implement ISoftDelete
/// </summary>
private static void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType))
{
var parameter = Expression.Parameter(entityType.ClrType, "p");
var property = Expression.Property(parameter, nameof(ISoftDelete.IsDeleted));
var condition = Expression.Equal(property, Expression.Constant(false));
var lambda = Expression.Lambda(condition, parameter);

modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
}
}
}

private void ConfigureIdentityEntities(ModelBuilder modelBuilder)
{
// Configure the User entity
Expand Down Expand Up @@ -160,74 +136,7 @@ private void ConfigureIdentityEntities(ModelBuilder modelBuilder)
});
}

/// <summary>
/// Saves all changes made in this context to the database with audit information
/// </summary>
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ApplyAuditInfo();
return await base.SaveChangesAsync(cancellationToken);
}

/// <summary>
/// Saves all changes made in this context to the database with audit information
/// </summary>
public override int SaveChanges()
{
ApplyAuditInfo();
return base.SaveChanges();
}

/// <summary>
/// Applies audit information to entities that implement IAuditableEntity
/// </summary>
private void ApplyAuditInfo()
{
var entries = ChangeTracker.Entries()
.Where(e => e.Entity is IAuditableEntity &&
(e.State == EntityState.Added || e.State == EntityState.Modified))
.ToList();

if (!entries.Any())
return;

string? userId = GetCurrentUserId();
DateTime now = DateTime.UtcNow;

foreach (var entityEntry in entries)
{
if (entityEntry.Entity is IAuditableEntity auditableEntity)
{
if (entityEntry.State == EntityState.Added)
{
auditableEntity.CreatedAt = now;
auditableEntity.CreatedBy = userId;
}
else if (entityEntry.State == EntityState.Modified)
{
Entry(auditableEntity).Property(x => x.CreatedAt).IsModified = false;
Entry(auditableEntity).Property(x => x.CreatedBy).IsModified = false;
}

auditableEntity.LastModifiedAt = now;
auditableEntity.LastModifiedBy = userId;
}
}
}

/// <summary>
/// Gets the current user ID from the HTTP context
/// </summary>
private string? GetCurrentUserId()
{
var user = _httpContextAccessor?.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true)
{
return user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}

return "system";
}
protected override string? GetCurrentUserId() => base.GetCurrentUserId() ?? "system";
}

/// <summary>
Expand Down Expand Up @@ -282,4 +191,4 @@ public void Configure(EntityTypeBuilder<Todo> builder)
builder.HasIndex(t => t.CreatedBy);
builder.HasIndex(t => t.CreatedAt);
}
}
}
14 changes: 13 additions & 1 deletion src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using MinimalCleanArch.Audit.Entities;
using MinimalCleanArch.Audit.Interceptors;
using MinimalCleanArch.Audit.Services;
using MinimalCleanArch.Execution;

namespace MinimalCleanArch.Audit.Extensions;

Expand All @@ -27,7 +28,18 @@ public static IServiceCollection AddAuditLogging(
configure?.Invoke(options);

services.AddSingleton(options);
services.AddScoped<IAuditContextProvider, HttpContextAuditContextProvider>();
services.AddHttpContextAccessor();
services.AddScoped<IAuditContextProvider>(sp =>
{
var executionContext = sp.GetService<IExecutionContext>();
if (executionContext is not null)
{
return new ExecutionContextAuditContextProvider(executionContext);
}

return new HttpContextAuditContextProvider(
sp.GetRequiredService<Microsoft.AspNetCore.Http.IHttpContextAccessor>());
});
services.AddScoped<AuditSaveChangesInterceptor>();

return services;
Expand Down
4 changes: 4 additions & 0 deletions src/MinimalCleanArch.Audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Audit logging components for MinimalCleanArch.
- DI extensions to plug audit logging into your MinimalCleanArch app.
- Tenant-aware audit context support through `IAuditContextProvider`.
- Audit query service support for user, tenant, correlation ID, and flexible search queries.
- default audit-context bridging from `IExecutionContext` when available

## Usage
```bash
Expand All @@ -23,6 +24,8 @@ builder.Services.AddAuditLogging();
builder.Services.AddAuditLogService<AppDbContext>();
```

If `IExecutionContext` is registered, `AddAuditLogging()` uses it automatically. Existing `IAuditContextProvider` customizations still work.

Configure the DbContext:

```csharp
Expand Down Expand Up @@ -61,5 +64,6 @@ public sealed class AppAuditContextProvider : IAuditContextProvider
Notes:
- `TenantId` is a first-class field on `AuditLog`, so tenant filtering does not need to be pushed into metadata.
- `IAuditLogService` supports tenant-aware queries in addition to user and correlation-based lookups.
- `IAuditContextProvider.GetTenantId()` now defaults to `null`, so existing custom providers do not need to implement tenant support immediately.
- When using a local feed, add a `nuget.config` pointing to your local packages folder and keep `nuget.org` available unless your feed mirrors all external dependencies.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using MinimalCleanArch.Execution;

namespace MinimalCleanArch.Audit.Services;

internal sealed class ExecutionContextAuditContextProvider : IAuditContextProvider
{
private readonly IExecutionContext _executionContext;

public ExecutionContextAuditContextProvider(IExecutionContext executionContext)
{
_executionContext = executionContext;
}

public string? GetUserId() => _executionContext.UserId;

public string? GetUserName() => _executionContext.UserName;

public string? GetTenantId() => _executionContext.TenantId;

public string? GetCorrelationId() => _executionContext.CorrelationId;

public string? GetClientIpAddress() => _executionContext.ClientIpAddress;

public string? GetUserAgent() => _executionContext.UserAgent;

public IDictionary<string, object>? GetMetadata()
{
if (_executionContext.Metadata.Count == 0)
{
return null;
}

return _executionContext.Metadata.ToDictionary(
kvp => kvp.Key,
kvp => (object)kvp.Value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public interface IAuditContextProvider
/// <summary>
/// Gets the current tenant or organization identifier.
/// </summary>
string? GetTenantId();
string? GetTenantId() => null;

/// <summary>
/// Gets the current correlation ID for request tracing.
Expand Down
29 changes: 22 additions & 7 deletions src/MinimalCleanArch.DataAccess/DbContextBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using MinimalCleanArch.Domain.Entities;
using MinimalCleanArch.Execution;

namespace MinimalCleanArch.DataAccess;

Expand All @@ -10,13 +11,26 @@ namespace MinimalCleanArch.DataAccess;
/// </summary>
public abstract class DbContextBase : DbContext
{
private readonly IExecutionContext? _executionContext;

/// <summary>
/// Initializes a new instance of the <see cref="DbContextBase"/> class
/// </summary>
/// <param name="options">The options to be used by the DbContext</param>
protected DbContextBase(DbContextOptions options)
protected DbContextBase(DbContextOptions options)
: this(options, null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="DbContextBase"/> class
/// </summary>
/// <param name="options">The options to be used by the DbContext</param>
/// <param name="executionContext">The execution context for audit stamping.</param>
protected DbContextBase(DbContextOptions options, IExecutionContext? executionContext)
: base(options)
{
_executionContext = executionContext;
}

/// <summary>
Expand Down Expand Up @@ -97,10 +111,11 @@ private void ApplyAuditInfo()
/// Gets the current user ID from the application context
/// </summary>
/// <returns>The current user ID</returns>
protected virtual string? GetCurrentUserId()
{
// This should be overridden in derived classes to get the actual user ID
// from the application's authentication system
return null;
}
protected virtual string? GetCurrentUserId() => _executionContext?.UserId;

/// <summary>
/// Gets the current tenant ID from the application context
/// </summary>
/// <returns>The current tenant ID</returns>
protected virtual string? GetCurrentTenantId() => _executionContext?.TenantId;
}
Loading