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>
132 lines
5.8 KiB
C#
132 lines
5.8 KiB
C#
using MediatR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
|
using FnbEngine.Domain.AggregatesModel.TableAggregate;
|
|
using FnbEngine.Domain.AggregatesModel.SessionAggregate;
|
|
using FnbEngine.Domain.AggregatesModel.KitchenAggregate;
|
|
using FnbEngine.Domain.AggregatesModel.RecipeAggregate;
|
|
using FnbEngine.Domain.AggregatesModel.ReservationAggregate;
|
|
using FnbEngine.Domain.SeedWork;
|
|
using FnbEngine.Infrastructure.EntityConfigurations;
|
|
|
|
namespace FnbEngine.Infrastructure;
|
|
|
|
/// <summary>
|
|
/// EN: Tenant provider interface for FnbContext global query filters.
|
|
/// VI: Interface tenant provider cho global query filters của FnbContext.
|
|
/// </summary>
|
|
public interface IFnbTenantProvider
|
|
{
|
|
Guid? GetCurrentShopId();
|
|
bool ShouldBypassTenantFilter();
|
|
}
|
|
|
|
public class FnbContext : DbContext, IUnitOfWork
|
|
{
|
|
private readonly IMediator _mediator;
|
|
private readonly IFnbTenantProvider? _tenantProvider;
|
|
private IDbContextTransaction? _currentTransaction;
|
|
|
|
public DbSet<Table> Tables => Set<Table>();
|
|
public DbSet<Session> Sessions => Set<Session>();
|
|
public DbSet<KitchenTicket> KitchenTickets => Set<KitchenTicket>();
|
|
public DbSet<Recipe> Recipes => Set<Recipe>();
|
|
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
|
public DbSet<Reservation> Reservations => Set<Reservation>();
|
|
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
|
|
public bool HasActiveTransaction => _currentTransaction != null;
|
|
|
|
public FnbContext(DbContextOptions<FnbContext> 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 FnbContext(
|
|
DbContextOptions<FnbContext> options,
|
|
IMediator mediator,
|
|
IFnbTenantProvider tenantProvider) : base(options)
|
|
{
|
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
_tenantProvider = tenantProvider;
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.ApplyConfiguration(new TableEntityTypeConfiguration());
|
|
modelBuilder.ApplyConfiguration(new TableStatusEntityTypeConfiguration());
|
|
modelBuilder.ApplyConfiguration(new SessionEntityTypeConfiguration());
|
|
modelBuilder.ApplyConfiguration(new KitchenTicketEntityTypeConfiguration());
|
|
modelBuilder.ApplyConfiguration(new RecipeEntityTypeConfiguration());
|
|
modelBuilder.ApplyConfiguration(new RecipeIngredientEntityTypeConfiguration());
|
|
modelBuilder.ApplyConfiguration(new ReservationEntityTypeConfiguration());
|
|
|
|
// EN: Global query filters for tenant isolation (shop-level).
|
|
// Tables, Sessions, and Reservations have shop_id.
|
|
// KitchenTickets are accessed via Sessions (indirect isolation).
|
|
// Recipes are shared across shops (no tenant filter).
|
|
// VI: Global query filters cho cách ly tenant (cấp shop).
|
|
// Tables, Sessions, và Reservations có shop_id.
|
|
// KitchenTickets được truy cập qua Sessions (cách ly gián tiếp).
|
|
// Recipes được chia sẻ giữa các shop (không có tenant filter).
|
|
modelBuilder.Entity<Table>().HasQueryFilter(t =>
|
|
_tenantProvider == null
|
|
|| _tenantProvider.ShouldBypassTenantFilter()
|
|
|| _tenantProvider.GetCurrentShopId() == null
|
|
|| t.ShopId == _tenantProvider.GetCurrentShopId());
|
|
|
|
modelBuilder.Entity<Session>().HasQueryFilter(s =>
|
|
_tenantProvider == null
|
|
|| _tenantProvider.ShouldBypassTenantFilter()
|
|
|| _tenantProvider.GetCurrentShopId() == null
|
|
|| s.ShopId == _tenantProvider.GetCurrentShopId());
|
|
|
|
modelBuilder.Entity<Reservation>().HasQueryFilter(r =>
|
|
_tenantProvider == null
|
|
|| _tenantProvider.ShouldBypassTenantFilter()
|
|
|| _tenantProvider.GetCurrentShopId() == null
|
|
|| r.ShopId == _tenantProvider.GetCurrentShopId());
|
|
}
|
|
|
|
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await DispatchDomainEventsAsync();
|
|
await base.SaveChangesAsync(cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
public async Task<IDbContextTransaction?> BeginTransactionAsync()
|
|
{
|
|
if (_currentTransaction != null) return null;
|
|
_currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);
|
|
return _currentTransaction;
|
|
}
|
|
|
|
public async Task CommitTransactionAsync(IDbContextTransaction transaction)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(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; } }
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|