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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,24 @@ A Clean Architecture toolkit for Minimal APIs on .NET 9 and .NET 10.
- Soft delete and auditing support
- EF Core integration with specification evaluation

## Recommended Package Sets
- Core domain and repository abstractions: `MinimalCleanArch`
- EF Core repositories and specifications: `MinimalCleanArch.DataAccess`
- Minimal API bootstrap, error mapping, OpenAPI, rate limiting: `MinimalCleanArch.Extensions`
- FluentValidation registration: `MinimalCleanArch.Validation`
- Domain events and Wolverine integration: `MinimalCleanArch.Messaging`
- Audit interception and audit queries: `MinimalCleanArch.Audit`
- Encrypted EF properties and encryption services: `MinimalCleanArch.Security`
- Project scaffolding: `MinimalCleanArch.Templates`

## Versions
- Stable packages/templates: `0.1.14`
- Next preview line: `0.1.15-preview`
- Next preview line: `0.1.16-preview`

## Local Validation
- Template validation uses two package sources by default: the local `MinimalCleanArch` feed and `nuget.org`.
- This is required because generated projects reference both `MinimalCleanArch.*` packages and pinned third-party packages.
- Use local-feed-only validation only if your feed mirrors every external dependency used by the templates.

## Try It Fast

Expand All @@ -27,13 +42,28 @@ Then open `https://localhost:<port>/scalar/v1`.
For auth + OpenIddict + Scalar password flow:

```bash
dotnet new mca -n QuickAuth --single-project --auth --tests --mcaVersion 0.1.14
dotnet new mca -n QuickAuth --single-project --auth --tests --mcaVersion 0.1.16-preview
cd QuickAuth
dotnet run
```

Use the auth walkthrough in [`templates/README.md`](templates/README.md).

## Preferred Integration Path
For new applications, the recommended order is:
1. Model entities, repository contracts, and specifications with `MinimalCleanArch`.
2. Add EF Core repositories and unit of work with `MinimalCleanArch.DataAccess`.
3. Add API bootstrap with `MinimalCleanArch.Extensions`.
4. Register application validators with `MinimalCleanArch.Validation`.
5. Add `MinimalCleanArch.Messaging`, `MinimalCleanArch.Audit`, and `MinimalCleanArch.Security` only when the app actually needs them.

Preferred defaults:
- use specifications through `IRepository<TEntity, TKey>`
- use `AddMinimalCleanArchApi(...)` as the main API bootstrap method
- use `AddValidationFromAssemblyContaining<T>()` for validator registration
- use `AddMinimalCleanArchMessaging...` extensions instead of wiring Wolverine from scratch
- use Data Protection-based encryption for new development

## Packages
| Package | Description |
| :-- | :-- |
Expand Down
3 changes: 2 additions & 1 deletion samples/MinimalCleanArch.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using MinimalCleanArch.Sample.Infrastructure.Services;
using MinimalCleanArch.Security.Configuration;
using MinimalCleanArch.Security.Extensions;
using MinimalCleanArch.Validation.Extensions;
using Scalar.AspNetCore;
using Serilog;

Expand Down Expand Up @@ -171,7 +172,7 @@
}

// Add validation services
builder.Services.AddValidatorsFromAssemblyContaining<Todo>();
builder.Services.AddValidationFromAssemblyContaining<Todo>();

// Add caching services (in-memory by default)
builder.Services.AddMinimalCleanArchCaching(options =>
Expand Down
7 changes: 7 additions & 0 deletions samples/MinimalCleanArch.Sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ MinimalCleanArch.Sample/
1. `Infrastructure/Specifications/` for filtering logic.
1. `Infrastructure/Seeders/` for startup data seeding.

## What the sample is meant to show
- specifications composed in application-facing query paths
- validators registered once and reused by endpoints
- result-to-HTTP mapping without controller-specific plumbing
- encrypted properties and audit support without leaking infrastructure into the domain model
- a concrete reference for how the packages fit together in one app

## Notes

- SQLite is used by default (`Data Source=minimalcleanarch.db`).
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<WarningsAsErrors />

<!-- Package Information -->
<PackageVersion>0.1.14</PackageVersion>
<PackageVersion>0.1.16-preview</PackageVersion>
<Authors>Abdullah D.</Authors>
<Company>Waanfeetan</Company>
<Product>MinimalCleanArch</Product>
Expand Down
5 changes: 5 additions & 0 deletions src/MinimalCleanArch.Audit/Entities/AuditLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public class AuditLog
/// </summary>
public string? UserName { get; set; }

/// <summary>
/// Optional tenant or organization identifier associated with the change.
/// </summary>
public string? TenantId { get; set; }

/// <summary>
/// UTC timestamp when the change occurred.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ public static ModelBuilder UseAuditLog(
entity.Property(e => e.UserName)
.HasMaxLength(256);

entity.Property(e => e.TenantId)
.HasMaxLength(256);

entity.Property(e => e.CorrelationId)
.HasMaxLength(64);

Expand All @@ -136,6 +139,8 @@ public static ModelBuilder UseAuditLog(
entity.HasIndex(e => e.EntityType);
entity.HasIndex(e => new { e.EntityType, e.EntityId });
entity.HasIndex(e => e.UserId);
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => new { e.TenantId, e.Timestamp });
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.CorrelationId);
entity.HasIndex(e => e.Operation);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ private bool ShouldSkipEntry(EntityEntry entry)
Timestamp = timestamp,
UserId = _contextProvider.GetUserId(),
UserName = _contextProvider.GetUserName(),
TenantId = _contextProvider.GetTenantId(),
CorrelationId = _contextProvider.GetCorrelationId()
};

Expand Down Expand Up @@ -332,6 +333,7 @@ internal class AuditEntry
public DateTime Timestamp { get; set; }
public string? UserId { get; set; }
public string? UserName { get; set; }
public string? TenantId { get; set; }
public string? CorrelationId { get; set; }
public string? ClientIpAddress { get; set; }
public string? UserAgent { get; set; }
Expand Down Expand Up @@ -365,6 +367,7 @@ public AuditLog ToAuditLog()
Timestamp = Timestamp,
UserId = UserId,
UserName = UserName,
TenantId = TenantId,
CorrelationId = CorrelationId,
ClientIpAddress = ClientIpAddress,
UserAgent = UserAgent,
Expand Down
53 changes: 50 additions & 3 deletions src/MinimalCleanArch.Audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,63 @@
Audit logging components for MinimalCleanArch.

## Version
- 0.1.14 (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.14 and companions.
- 0.1.16-preview (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.16-preview and companions.

## What's included
- Audit logging services and helpers.
- 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.

## Usage
```bash
dotnet add package MinimalCleanArch.Audit --version 0.1.14
dotnet add package MinimalCleanArch.Audit --version 0.1.16-preview
```

When using a local feed, add a `nuget.config` pointing to your local packages folder (e.g., `artifacts/nuget`) before restoring.
Register services:

```csharp
builder.Services.AddAuditLogging();
builder.Services.AddAuditLogService<AppDbContext>();
```

Configure the DbContext:

```csharp
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlite("Data Source=app.db");
options.UseAuditInterceptor(sp);
});
```

Configure the audit model:

```csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.UseAuditLog();
}
```

Implement a custom context provider when you need tenant-aware auditing:

```csharp
public sealed class AppAuditContextProvider : IAuditContextProvider
{
public string? GetUserId() => "...";
public string? GetUserName() => "...";
public string? GetTenantId() => "...";
public string? GetCorrelationId() => "...";
public string? GetClientIpAddress() => "...";
public string? GetUserAgent() => "...";
public IDictionary<string, object>? GetMetadata() => null;
}
```

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.
- 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.

18 changes: 18 additions & 0 deletions src/MinimalCleanArch.Audit/Services/AuditLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ public async Task<IReadOnlyList<AuditLog>> GetByUserAsync(
.ToListAsync(cancellationToken);
}

/// <inheritdoc />
public async Task<IReadOnlyList<AuditLog>> GetByTenantAsync(
string tenantId,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default)
{
return await AuditLogs
.Where(a => a.TenantId == tenantId)
.OrderByDescending(a => a.Timestamp)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}

/// <inheritdoc />
public async Task<IReadOnlyList<AuditLog>> GetByDateRangeAsync(
DateTime from,
Expand Down Expand Up @@ -157,6 +172,9 @@ private IQueryable<AuditLog> BuildQuery(AuditLogQuery query)
if (!string.IsNullOrEmpty(query.UserId))
queryable = queryable.Where(a => a.UserId == query.UserId);

if (!string.IsNullOrEmpty(query.TenantId))
queryable = queryable.Where(a => a.TenantId == query.TenantId);

if (query.Operation.HasValue)
queryable = queryable.Where(a => a.Operation == query.Operation.Value);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ public HttpContextAuditContextProvider(IHttpContextAccessor httpContextAccessor)
?? user.Identity?.Name;
}

/// <inheritdoc />
public string? GetTenantId()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;

return user.FindFirst("tenant_id")?.Value
?? user.FindFirst("business_id")?.Value
?? user.FindFirst("organization_id")?.Value;
}

/// <inheritdoc />
public string? GetCorrelationId()
{
Expand Down
5 changes: 5 additions & 0 deletions src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public interface IAuditContextProvider
/// </summary>
string? GetUserName();

/// <summary>
/// Gets the current tenant or organization identifier.
/// </summary>
string? GetTenantId();

/// <summary>
/// Gets the current correlation ID for request tracing.
/// </summary>
Expand Down
19 changes: 19 additions & 0 deletions src/MinimalCleanArch.Audit/Services/IAuditLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ Task<IReadOnlyList<AuditLog>> GetByUserAsync(
int take = 50,
CancellationToken cancellationToken = default);

/// <summary>
/// Gets audit logs for a specific tenant or organization.
/// </summary>
/// <param name="tenantId">The tenant or organization identifier.</param>
/// <param name="skip">Number of records to skip.</param>
/// <param name="take">Number of records to take.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of audit log entries for the tenant.</returns>
Task<IReadOnlyList<AuditLog>> GetByTenantAsync(
string tenantId,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default);

/// <summary>
/// Gets audit logs within a date range.
/// </summary>
Expand Down Expand Up @@ -136,6 +150,11 @@ public class AuditLogQuery
/// </summary>
public string? UserId { get; set; }

/// <summary>
/// Filter by tenant or organization identifier.
/// </summary>
public string? TenantId { get; set; }

/// <summary>
/// Filter by operation type.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,28 @@ public static IServiceCollection AddMinimalCleanArch<TContext, TUnitOfWork>(

return services;
}
}

/// <summary>
/// Adds MinimalCleanArch services with a specific DbContext and custom Unit of Work implementation.
/// Use this overload when the DbContext configuration needs access to the service provider.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="optionsAction">The DbContext options action with IServiceProvider access.</param>
/// <typeparam name="TContext">The type of the DbContext.</typeparam>
/// <typeparam name="TUnitOfWork">The type of the Unit of Work implementation.</typeparam>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMinimalCleanArch<TContext, TUnitOfWork>(
this IServiceCollection services,
Action<IServiceProvider, DbContextOptionsBuilder> optionsAction)
where TContext : DbContext
where TUnitOfWork : class, IUnitOfWork
{
services.AddDbContext<TContext>((sp, options) => optionsAction(sp, options));
services.AddScoped<DbContext>(provider => provider.GetRequiredService<TContext>());
services.AddScoped<IUnitOfWork, TUnitOfWork>();
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

return services;
}
}
Loading