Files
pos-system/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs
Ho Ngoc Hai 6061164873 feat: add multi-tenant row-level security across 5 services and 96 FnB engine unit tests
Security (P0-5):
- Implement ITenantProvider + HttpContextTenantProvider per service (order, fnb, inventory, catalog, wallet)
- Add EF Core global query filters for tenant isolation (shop_id/user_id based)
- Add TenantMiddleware setting PostgreSQL session variables for RLS
- Create PostgreSQL RLS policies script (scripts/db/rls-policies.sql)
- Adapter pattern bridges API-layer to Infrastructure-layer (Clean Architecture)
- Bypass mechanisms for admin roles, service-to-service calls, and migrations

Testing (P1-12):
- Add 96 unit tests for fnb-engine (up from 3)
- 57 domain entity tests: Table(18), KitchenTicket(12), Session(8), Reservation(13), Recipe(6)
- 39 command handler tests: CRUD operations, status transitions, validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:40:34 +07:00

177 lines
6.1 KiB
C#

namespace WalletService.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using WalletService.Domain.AggregatesModel.PaymentAggregate;
using WalletService.Domain.AggregatesModel.PointAccountAggregate;
using WalletService.Domain.AggregatesModel.WalletAggregate;
using WalletService.Domain.SeedWork;
/// <summary>
/// EN: Tenant provider interface for WalletServiceContext global query filters.
/// Wallet service uses user_id as primary tenant key (wallets are per-user).
/// VI: Interface tenant provider cho global query filters của WalletServiceContext.
/// Wallet service sử dụng user_id làm tenant key chính (ví là per-user).
/// </summary>
public interface IWalletTenantProvider
{
Guid? GetCurrentUserId();
bool ShouldBypassTenantFilter();
}
/// <summary>
/// EN: Database context for Wallet Service with Unit of Work pattern and multi-tenant filtering.
/// VI: Database context cho Wallet Service với pattern Unit of Work và filtering đa tenant.
/// </summary>
public class WalletServiceContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
private readonly IWalletTenantProvider? _tenantProvider;
private IDbContextTransaction? _currentTransaction;
public DbSet<Wallet> Wallets { get; set; } = null!;
public DbSet<WalletItem> WalletItems { get; set; } = null!;
public DbSet<WalletTransaction> WalletTransactions { get; set; } = null!;
public DbSet<HoldItem> WalletHolds { get; set; } = null!;
public DbSet<Payment> Payments { get; set; } = null!;
public DbSet<PointAccount> PointAccounts { get; set; } = null!;
public DbSet<PointTransaction> PointTransactions { get; set; } = null!;
public bool HasActiveTransaction => _currentTransaction != null;
public WalletServiceContext(DbContextOptions<WalletServiceContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
/// <summary>
/// EN: Constructor with tenant provider for multi-tenant filtering.
/// VI: Constructor với tenant provider cho filtering đa tenant.
/// </summary>
public WalletServiceContext(
DbContextOptions<WalletServiceContext> options,
IMediator mediator,
IWalletTenantProvider tenantProvider)
: base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_tenantProvider = tenantProvider;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// EN: Apply all entity configurations
// VI: Áp dụng tất cả các cấu hình entity
modelBuilder.ApplyConfigurationsFromAssembly(typeof(WalletServiceContext).Assembly);
// EN: Global query filter for tenant isolation on Wallets (user-level).
// Wallets belong to a user, not a shop. Each user can only see their own wallet.
// VI: Global query filter cho cách ly tenant trên Wallets (cấp user).
// Wallets thuộc về user, không phải shop. Mỗi user chỉ thấy ví của mình.
modelBuilder.Entity<Wallet>().HasQueryFilter(w =>
_tenantProvider == null
|| _tenantProvider.ShouldBypassTenantFilter()
|| _tenantProvider.GetCurrentUserId() == null
|| w.UserId == _tenantProvider.GetCurrentUserId());
}
/// <summary>
/// EN: Save changes and dispatch domain events
/// VI: Lưu thay đổi và dispatch domain events
/// </summary>
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
{
// EN: Dispatch domain events after saving
// VI: Dispatch domain events sau khi lưu
await DispatchDomainEventsAsync();
// EN: Save changes to database
// VI: Lưu thay đổi vào database
var result = await base.SaveChangesAsync(cancellationToken);
return result > 0;
}
/// <summary>
/// EN: Begin a new transaction
/// VI: Bắt đầu transaction mới
/// </summary>
public async Task<IDbContextTransaction?> BeginTransactionAsync()
{
if (_currentTransaction != null) return null;
_currentTransaction = await Database.BeginTransactionAsync();
return _currentTransaction;
}
/// <summary>
/// EN: Commit the current transaction
/// VI: Commit transaction hiện tại
/// </summary>
public async Task CommitTransactionAsync(IDbContextTransaction transaction)
{
if (transaction == null) throw new ArgumentNullException(nameof(transaction));
if (transaction != _currentTransaction)
throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current");
try
{
await SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
RollbackTransaction();
throw;
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
/// <summary>
/// EN: Rollback the current transaction
/// VI: Rollback transaction hiện tại
/// </summary>
public void RollbackTransaction()
{
try
{
_currentTransaction?.Rollback();
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
private async Task DispatchDomainEventsAsync()
{
var domainEntities = ChangeTracker
.Entries<Entity>()
.Where(x => x.Entity.DomainEvents.Any())
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.Entity.DomainEvents)
.ToList();
domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent);
}
}
}