From 653322b26c0bcfb41e5e113680402ef910957de6 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 6 Mar 2026 16:22:08 +0700 Subject: [PATCH] fix: resolve 12 critical/high issues from code review across backend, frontend, and infra Backend (7 fixes): - wallet-service: remove conflicting EF Ignore() calls for mapped backing fields - fnb-engine: remove KitchenTicket short constructor that set productId=orderItemId - fnb-engine: replace fire-and-forget Task.Run with direct await for inventory deduction - TenantMiddleware: implement PostgreSQL RLS SET LOCAL in 4 services (wallet, fnb, inventory, catalog) - order-service: fix SQL injection pattern in TenantMiddleware with Guid.ToString("D") - order-service: add ValidateShopAccess() authorization check in SignalR PosHub - 4 services: register IDbConnection (NpgsqlConnection) in DI for RLS middleware Frontend (3 fixes): - PosDataService: return Success=false (not true) when PayOrder response parsing fails - QrPayment: add _disposed guard to prevent timer race condition after component disposal - BFF OrderController: add [Authorize] attribute to require JWT for all endpoints Infrastructure (3 fixes): - docker-compose: upgrade PostgreSQL 15-alpine to 16-alpine per project spec - init-databases.sh: add 4 missing marketing service databases (mkt_*) - Traefik routes: add wallet, catalog, booking routers and /api/v1/stock path Co-Authored-By: Claude Opus 4.6 --- .../Pages/Pos/Shared/Payment/QrPayment.razor | 9 ++- .../Services/PosDataService.cs | 2 +- .../Controllers/OrderController.cs | 2 + deployments/local/docker-compose.yml | 2 +- deployments/local/init-databases.sh | 32 +++++---- infra/traefik/dynamic/routes.yml | 61 +++++++++++++++- .../Middleware/TenantMiddleware.cs | 65 ++++++++++++++++- .../src/CatalogService.API/Program.cs | 11 +++ .../KitchenTicketServedDomainEventHandler.cs | 49 ++++++------- .../Middleware/TenantMiddleware.cs | 69 +++++++++++++++++-- .../src/FnbEngine.API/Program.cs | 11 +++ .../KitchenAggregate/KitchenTicket.cs | 11 ++- .../UpdateTicketStatusCommandHandlerTests.cs | 6 +- .../Domain/KitchenTicketTests.cs | 20 +++--- .../Middleware/TenantMiddleware.cs | 65 ++++++++++++++++- .../src/InventoryService.API/Program.cs | 11 +++ .../src/OrderService.API/Hubs/PosHub.cs | 35 ++++++++++ .../Middleware/TenantMiddleware.cs | 4 +- .../Middleware/TenantMiddleware.cs | 65 ++++++++++++++++- .../src/WalletService.API/Program.cs | 11 +++ .../PaymentEntityTypeConfiguration.cs | 15 +--- 21 files changed, 469 insertions(+), 87 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor index a1f3daa1..47e34be3 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/Shared/Payment/QrPayment.razor @@ -130,6 +130,7 @@ else private bool _isLoading = true; private bool _isProcessing; private string? _errorMessage; + private bool _disposed = false; // EN: QR providers / VI: Nhà cung cấp QR private readonly string[] _providers = { "VietQR", "MoMo", "ZaloPay" }; @@ -160,7 +161,7 @@ else { _countdownTimer = new Timer(_ => { - if (_timerSeconds > 0) + if (!_disposed && _timerSeconds > 0) { _timerSeconds--; InvokeAsync(StateHasChanged); @@ -227,5 +228,9 @@ else NavigationManager.NavigateTo($"/pos/{ShopId}/payment/method-select?orderId={_resolvedOrderId}"); } - public void Dispose() => _countdownTimer?.Dispose(); + public void Dispose() + { + _disposed = true; + _countdownTimer?.Dispose(); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index afe248c5..33e88893 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -806,7 +806,7 @@ public class PosDataService } return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOptions); } - catch { return new PayOrderResponse(true, null, null, null, null, null); } + catch { return new PayOrderResponse(false, null, null, null, null, "Không thể xử lý phản hồi từ máy chủ"); } } return new PayOrderResponse(false, null, null, null, null, "Payment failed"); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs index f63f676b..b9e56dd6 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/OrderController.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using WebClientTpos.Server.Infrastructure; using WebClientTpos.Server.Models; @@ -10,6 +11,7 @@ namespace WebClientTpos.Server.Controllers; /// VI: Controller đơn hàng — proxy đến OrderService cho đơn hàng, POS thanh toán, dashboard. /// [ApiController] +[Authorize] [Route("api/bff")] public class OrderController : ControllerBase { diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index b429bb06..0d8fa360 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -29,7 +29,7 @@ services: # PostgreSQL 16 - Shared Database Server postgres: - image: postgres:15-alpine + image: postgres:16-alpine container_name: postgres-local environment: - POSTGRES_USER=goodgo diff --git a/deployments/local/init-databases.sh b/deployments/local/init-databases.sh index 02fffe1a..4c6e584b 100755 --- a/deployments/local/init-databases.sh +++ b/deployments/local/init-databases.sh @@ -9,26 +9,30 @@ set -e DATABASES=( + "ads_analytics_service" + "ads_billing_service" + "ads_manager_service" + "ads_serving_service" + "ads_tracking_service" + "booking_service" + "catalog_service" + "chat_service" + "fnb_engine" "iam_service" - "storage_service" + "inventory_service" "membership_service" "merchant_service" - "wallet_service" - "chat_service" - "social_service" "mining_service" "mission_service" - "promotion_service" - "catalog_service" + "mkt_facebook_service" + "mkt_whatsapp_service" + "mkt_x_service" + "mkt_zalo_service" "order_service" - "inventory_service" - "fnb_engine" - "booking_service" - "ads_manager_service" - "ads_analytics_service" - "ads_serving_service" - "ads_billing_service" - "ads_tracking_service" + "promotion_service" + "social_service" + "storage_service" + "wallet_service" ) echo "=== GoodGo: Creating databases ===" diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml index 2359b479..d169fc53 100644 --- a/infra/traefik/dynamic/routes.yml +++ b/infra/traefik/dynamic/routes.yml @@ -127,7 +127,7 @@ http: # EN: Inventory Service - Stock Management & Deduction # VI: Inventory Service - Quản lý Tồn kho & Trừ kho inventory-service-router: - rule: "PathPrefix(`/api/v1/inventory`)" + rule: "PathPrefix(`/api/v1/inventory`) || PathPrefix(`/api/v1/stock`)" service: inventory-service priority: 100 middlewares: @@ -136,6 +136,42 @@ http: entryPoints: - web + # EN: Wallet Service - Wallet & Payment Management + # VI: Wallet Service - Quản lý Ví & Thanh toán + wallet-service-router: + rule: "PathPrefix(`/api/v1/wallets`) || PathPrefix(`/api/v1/payments`) || PathPrefix(`/api/v1/points`)" + service: wallet-service + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + + # EN: Catalog Service - Product & Category Management + # VI: Catalog Service - Quản lý Sản phẩm & Danh mục + catalog-service-router: + rule: "PathPrefix(`/api/v1/products`) || PathPrefix(`/api/v1/categories`)" + service: catalog-service + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + + # EN: Booking Service - Booking & Reservation Management + # VI: Booking Service - Quản lý Đặt lịch & Đặt chỗ + booking-service-router: + rule: "PathPrefix(`/api/v1/bookings`) || PathPrefix(`/api/v1/reservations`)" + service: booking-service + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + services: iam-service: loadBalancer: @@ -192,4 +228,25 @@ http: inventory-service: loadBalancer: servers: - - url: "http://inventory-service-net:8080" \ No newline at end of file + - url: "http://inventory-service-net:8080" + + # EN: Wallet Service + # VI: Wallet Service + wallet-service: + loadBalancer: + servers: + - url: "http://wallet-service-net:8080" + + # EN: Catalog Service + # VI: Catalog Service + catalog-service: + loadBalancer: + servers: + - url: "http://catalog-service-net:8080" + + # EN: Booking Service + # VI: Booking Service + booking-service: + loadBalancer: + servers: + - url: "http://booking-service-net:8080" \ No newline at end of file diff --git a/services/catalog-service-net/src/CatalogService.API/Middleware/TenantMiddleware.cs b/services/catalog-service-net/src/CatalogService.API/Middleware/TenantMiddleware.cs index cc350fc3..58cad899 100644 --- a/services/catalog-service-net/src/CatalogService.API/Middleware/TenantMiddleware.cs +++ b/services/catalog-service-net/src/CatalogService.API/Middleware/TenantMiddleware.cs @@ -22,10 +22,73 @@ public class TenantMiddleware _logger = logger; } - public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider) + public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection) { + var shopId = tenantProvider.GetCurrentShopId(); + var merchantId = tenantProvider.GetCurrentMerchantId(); + var isServiceCall = tenantProvider.IsServiceCall(); + var isAdmin = tenantProvider.IsAdmin(); + + // EN: Skip tenant SET for service-to-service calls and admin users + // VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users + if (!isServiceCall && !isAdmin) + { + if (shopId.HasValue) + { + await SetTenantContextAsync(dbConnection, shopId.Value, merchantId); + } + else + { + _logger.LogDebug( + "EN: No tenant context available for request {Path} / " + + "VI: Không có tenant context cho request {Path}", + context.Request.Path); + } + } + await _next(context); } + + /// + /// EN: Set PostgreSQL session variables for RLS enforcement. + /// VI: Đặt biến session PostgreSQL cho RLS enforcement. + /// + private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId) + { + try + { + if (dbConnection is NpgsqlConnection npgsqlConnection) + { + if (npgsqlConnection.State != ConnectionState.Open) + { + await npgsqlConnection.OpenAsync(); + } + + // EN: Set shop_id as the primary tenant identifier + // VI: Đặt shop_id làm tenant identifier chính + await using var cmd = npgsqlConnection.CreateCommand(); + cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + + if (merchantId.HasValue) + { + cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + } + + _logger.LogDebug( + "EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " + + "VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}", + shopId, merchantId); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "EN: Failed to set PostgreSQL tenant context / " + + "VI: Không thể đặt PostgreSQL tenant context"); + } + } } /// diff --git a/services/catalog-service-net/src/CatalogService.API/Program.cs b/services/catalog-service-net/src/CatalogService.API/Program.cs index a51e8c5f..bda56579 100644 --- a/services/catalog-service-net/src/CatalogService.API/Program.cs +++ b/services/catalog-service-net/src/CatalogService.API/Program.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using Asp.Versioning; using FluentValidation; using Hellang.Middleware.ProblemDetails; +using System.Data; +using Npgsql; using CatalogService.API.Application.Behaviors; using CatalogService.API.Infrastructure.Tenant; using CatalogService.API.Middleware; @@ -35,6 +37,15 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); + // EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS + builder.Services.AddTransient(sp => + { + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Database connection string is required"); + return new NpgsqlConnection(connectionString); + }); + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors builder.Services.AddMediatR(cfg => { diff --git a/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/EventHandlers/KitchenTicketServedDomainEventHandler.cs b/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/EventHandlers/KitchenTicketServedDomainEventHandler.cs index d1b80ae4..6f1b7100 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/EventHandlers/KitchenTicketServedDomainEventHandler.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Application/IntegrationEvents/EventHandlers/KitchenTicketServedDomainEventHandler.cs @@ -99,35 +99,36 @@ public class KitchenTicketServedDomainEventHandler : INotificationHandler + // EN: Call inventory service synchronously within the handler. + // Polly retry policy on InventoryServiceClient handles transient failures. + // If deduction fails, log error but don't throw to avoid blocking kitchen workflow. + // VI: Goi inventory service dong bo trong handler. + // Polly retry policy tren InventoryServiceClient xu ly loi tam thoi. + // Neu tru kho that bai, log loi nhung khong throw de tranh chan luong bep. + try { - try + var result = await _inventoryClient.DeductInventoryAsync(request, cancellationToken); + if (result) { - var result = await _inventoryClient.DeductInventoryAsync(request, cancellationToken); - if (result) - { - _logger.LogInformation( - "EN: Inventory deduction successful / VI: Tru kho thanh cong - TicketId: {TicketId}, Items: {ItemCount}", - ticket.Id, deductionItems.Count); - } - else - { - _logger.LogWarning( - "EN: Inventory deduction returned false / VI: Tru kho tra ve false - TicketId: {TicketId}", - ticket.Id); - } + _logger.LogInformation( + "EN: Inventory deduction successful / VI: Tru kho thanh cong - TicketId: {TicketId}, Items: {ItemCount}", + ticket.Id, deductionItems.Count); } - catch (Exception ex) + else { - // EN: Log but don't throw - inventory deduction should not block kitchen flow. - // VI: Log nhung khong throw - tru kho khong nen chan luong bep. - _logger.LogError(ex, - "EN: Failed to deduct inventory / VI: Tru kho that bai - TicketId: {TicketId}, Error: {Error}", - ticket.Id, ex.Message); + _logger.LogWarning( + "EN: Inventory deduction returned false / VI: Tru kho tra ve false - TicketId: {TicketId}", + ticket.Id); } - }, cancellationToken); + } + catch (Exception ex) + { + // EN: Log but don't throw - inventory deduction should not block kitchen flow. + // VI: Log nhung khong throw - tru kho khong nen chan luong bep. + _logger.LogError(ex, + "EN: Failed to deduct inventory / VI: Tru kho that bai - TicketId: {TicketId}, Error: {Error}", + ticket.Id, ex.Message); + } } catch (Exception ex) { diff --git a/services/fnb-engine-net/src/FnbEngine.API/Middleware/TenantMiddleware.cs b/services/fnb-engine-net/src/FnbEngine.API/Middleware/TenantMiddleware.cs index 201c69e2..53d55ea1 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Middleware/TenantMiddleware.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Middleware/TenantMiddleware.cs @@ -22,14 +22,73 @@ public class TenantMiddleware _logger = logger; } - public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider) + public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection) { - // EN: PostgreSQL RLS SET is handled by DbContext interceptor for FnbEngine - // since FnbEngine does not register IDbConnection directly. - // VI: PostgreSQL RLS SET được xử lý bởi DbContext interceptor cho FnbEngine - // vì FnbEngine không đăng ký IDbConnection trực tiếp. + var shopId = tenantProvider.GetCurrentShopId(); + var merchantId = tenantProvider.GetCurrentMerchantId(); + var isServiceCall = tenantProvider.IsServiceCall(); + var isAdmin = tenantProvider.IsAdmin(); + + // EN: Skip tenant SET for service-to-service calls and admin users + // VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users + if (!isServiceCall && !isAdmin) + { + if (shopId.HasValue) + { + await SetTenantContextAsync(dbConnection, shopId.Value, merchantId); + } + else + { + _logger.LogDebug( + "EN: No tenant context available for request {Path} / " + + "VI: Không có tenant context cho request {Path}", + context.Request.Path); + } + } + await _next(context); } + + /// + /// EN: Set PostgreSQL session variables for RLS enforcement. + /// VI: Đặt biến session PostgreSQL cho RLS enforcement. + /// + private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId) + { + try + { + if (dbConnection is NpgsqlConnection npgsqlConnection) + { + if (npgsqlConnection.State != ConnectionState.Open) + { + await npgsqlConnection.OpenAsync(); + } + + // EN: Set shop_id as the primary tenant identifier + // VI: Đặt shop_id làm tenant identifier chính + await using var cmd = npgsqlConnection.CreateCommand(); + cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + + if (merchantId.HasValue) + { + cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + } + + _logger.LogDebug( + "EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " + + "VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}", + shopId, merchantId); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "EN: Failed to set PostgreSQL tenant context / " + + "VI: Không thể đặt PostgreSQL tenant context"); + } + } } /// diff --git a/services/fnb-engine-net/src/FnbEngine.API/Program.cs b/services/fnb-engine-net/src/FnbEngine.API/Program.cs index 0e5d26f6..f43faa1c 100644 --- a/services/fnb-engine-net/src/FnbEngine.API/Program.cs +++ b/services/fnb-engine-net/src/FnbEngine.API/Program.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using FluentValidation; using Hellang.Middleware.ProblemDetails; +using System.Data; +using Npgsql; using FnbEngine.API.Application.Behaviors; using FnbEngine.API.Infrastructure.Tenant; using FnbEngine.API.Middleware; @@ -38,6 +40,15 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); + // EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS + builder.Services.AddTransient(sp => + { + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Database connection string is required"); + return new NpgsqlConnection(connectionString); + }); + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors builder.Services.AddMediatR(cfg => { diff --git a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs index 69894f77..c83befe9 100644 --- a/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs +++ b/services/fnb-engine-net/src/FnbEngine.Domain/AggregatesModel/KitchenAggregate/KitchenTicket.cs @@ -48,14 +48,11 @@ public class KitchenTicket : Entity, IAggregateRoot { } - public KitchenTicket(Guid sessionId, Guid orderItemId, string itemName, string? station = null, int priority = 0) - : this(sessionId, orderItemId, orderItemId, itemName, 1, station, priority) - { - } - /// - /// EN: Create a kitchen ticket with product ID and quantity. - /// VI: Tao phieu bep voi product ID va so luong. + /// EN: Create a kitchen ticket with explicit product ID and quantity. + /// Product ID is REQUIRED to ensure correct recipe lookup and inventory deduction. + /// VI: Tao phieu bep voi product ID tuong minh va so luong. + /// Product ID la BAT BUOC de dam bao tra cuu cong thuc va tru kho chinh xac. /// public KitchenTicket(Guid sessionId, Guid orderItemId, Guid productId, string itemName, int quantity = 1, string? station = null, int priority = 0) { diff --git a/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/Commands/UpdateTicketStatusCommandHandlerTests.cs b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/Commands/UpdateTicketStatusCommandHandlerTests.cs index 73d7b25d..4f21f0f5 100644 --- a/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/Commands/UpdateTicketStatusCommandHandlerTests.cs +++ b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Application/Commands/UpdateTicketStatusCommandHandlerTests.cs @@ -35,7 +35,7 @@ public class UpdateTicketStatusCommandHandlerTests { // Arrange var ticketId = Guid.NewGuid(); - var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), "Pho Bo"); + var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "Pho Bo"); _repoMock.Setup(r => r.GetByIdAsync(ticketId, It.IsAny())) .ReturnsAsync(ticket); @@ -74,7 +74,7 @@ public class UpdateTicketStatusCommandHandlerTests { // Arrange var ticketId = Guid.NewGuid(); - var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), "Pho Bo"); + var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "Pho Bo"); _repoMock.Setup(r => r.GetByIdAsync(ticketId, It.IsAny())) .ReturnsAsync(ticket); @@ -93,7 +93,7 @@ public class UpdateTicketStatusCommandHandlerTests { // Arrange var ticketId = Guid.NewGuid(); - var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), "Pho Bo"); + var ticket = new KitchenTicket(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "Pho Bo"); _repoMock.Setup(r => r.GetByIdAsync(ticketId, It.IsAny())) .ReturnsAsync(ticket); diff --git a/services/fnb-engine-net/tests/FnbEngine.UnitTests/Domain/KitchenTicketTests.cs b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Domain/KitchenTicketTests.cs index e808fc3e..a2f7896f 100644 --- a/services/fnb-engine-net/tests/FnbEngine.UnitTests/Domain/KitchenTicketTests.cs +++ b/services/fnb-engine-net/tests/FnbEngine.UnitTests/Domain/KitchenTicketTests.cs @@ -16,16 +16,16 @@ public class KitchenTicketTests private static readonly Guid ValidProductId = Guid.NewGuid(); [Fact] - public void Constructor_WithBasicParams_ShouldCreatePendingTicket() + public void Constructor_WithAllParams_ShouldCreatePendingTicket() { // Act - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo", "Kitchen", 1); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo", 1, "Kitchen", 1); // Assert ticket.Id.Should().NotBeEmpty(); ticket.SessionId.Should().Be(ValidSessionId); ticket.OrderItemId.Should().Be(ValidOrderItemId); - ticket.ProductId.Should().Be(ValidOrderItemId); // defaults to orderItemId + ticket.ProductId.Should().Be(ValidProductId); ticket.ItemName.Should().Be("Pho Bo"); ticket.Station.Should().Be("Kitchen"); ticket.Priority.Should().Be(1); @@ -72,7 +72,7 @@ public class KitchenTicketTests public void Constructor_WithNullStation_ShouldAllowNull() { // Act - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Drink"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Drink"); // Assert ticket.Station.Should().BeNull(); @@ -82,7 +82,7 @@ public class KitchenTicketTests public void MarkAsInProgress_ShouldChangeStatusToInProgress() { // Arrange - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo"); // Act ticket.MarkAsInProgress(); @@ -96,7 +96,7 @@ public class KitchenTicketTests public void MarkAsReady_ShouldChangeStatusAndSetCompletedAt() { // Arrange - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo"); // Act ticket.MarkAsReady(); @@ -111,7 +111,7 @@ public class KitchenTicketTests public void MarkAsServed_ShouldChangeStatusToServed() { // Arrange - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo"); // Act ticket.MarkAsServed(); @@ -124,7 +124,7 @@ public class KitchenTicketTests public void MarkAsServed_ShouldRaiseKitchenTicketServedDomainEvent() { // Arrange - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo"); // Act ticket.MarkAsServed(); @@ -140,7 +140,7 @@ public class KitchenTicketTests public void MarkAsServed_CalledTwice_ShouldRaiseTwoDomainEvents() { // Arrange - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo"); // Act ticket.MarkAsServed(); @@ -154,7 +154,7 @@ public class KitchenTicketTests public void StatusTransition_PendingToInProgressToReadyToServed_ShouldSucceed() { // Arrange - var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, "Pho Bo"); + var ticket = new KitchenTicket(ValidSessionId, ValidOrderItemId, ValidProductId, "Pho Bo"); // Act & Assert ticket.MarkAsInProgress(); diff --git a/services/inventory-service-net/src/InventoryService.API/Middleware/TenantMiddleware.cs b/services/inventory-service-net/src/InventoryService.API/Middleware/TenantMiddleware.cs index 37b2e0ed..c8c10931 100644 --- a/services/inventory-service-net/src/InventoryService.API/Middleware/TenantMiddleware.cs +++ b/services/inventory-service-net/src/InventoryService.API/Middleware/TenantMiddleware.cs @@ -22,10 +22,73 @@ public class TenantMiddleware _logger = logger; } - public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider) + public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection) { + var shopId = tenantProvider.GetCurrentShopId(); + var merchantId = tenantProvider.GetCurrentMerchantId(); + var isServiceCall = tenantProvider.IsServiceCall(); + var isAdmin = tenantProvider.IsAdmin(); + + // EN: Skip tenant SET for service-to-service calls and admin users + // VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users + if (!isServiceCall && !isAdmin) + { + if (shopId.HasValue) + { + await SetTenantContextAsync(dbConnection, shopId.Value, merchantId); + } + else + { + _logger.LogDebug( + "EN: No tenant context available for request {Path} / " + + "VI: Không có tenant context cho request {Path}", + context.Request.Path); + } + } + await _next(context); } + + /// + /// EN: Set PostgreSQL session variables for RLS enforcement. + /// VI: Đặt biến session PostgreSQL cho RLS enforcement. + /// + private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId) + { + try + { + if (dbConnection is NpgsqlConnection npgsqlConnection) + { + if (npgsqlConnection.State != ConnectionState.Open) + { + await npgsqlConnection.OpenAsync(); + } + + // EN: Set shop_id as the primary tenant identifier + // VI: Đặt shop_id làm tenant identifier chính + await using var cmd = npgsqlConnection.CreateCommand(); + cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + + if (merchantId.HasValue) + { + cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + } + + _logger.LogDebug( + "EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " + + "VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}", + shopId, merchantId); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "EN: Failed to set PostgreSQL tenant context / " + + "VI: Không thể đặt PostgreSQL tenant context"); + } + } } /// diff --git a/services/inventory-service-net/src/InventoryService.API/Program.cs b/services/inventory-service-net/src/InventoryService.API/Program.cs index 047d4c17..dea3326f 100644 --- a/services/inventory-service-net/src/InventoryService.API/Program.cs +++ b/services/inventory-service-net/src/InventoryService.API/Program.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using FluentValidation; using Hellang.Middleware.ProblemDetails; +using System.Data; +using Npgsql; using InventoryService.API.Application.Behaviors; using InventoryService.API.Infrastructure.Tenant; using InventoryService.API.Middleware; @@ -34,6 +36,15 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); + // EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS + builder.Services.AddTransient(sp => + { + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Database connection string is required"); + return new NpgsqlConnection(connectionString); + }); + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors builder.Services.AddMediatR(cfg => { diff --git a/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs b/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs index 9e8839b0..8aacacef 100644 --- a/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs +++ b/services/order-service-net/src/OrderService.API/Hubs/PosHub.cs @@ -100,6 +100,10 @@ public class PosHub : Hub var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated / User chưa xác thực"); + // EN: Validate that the authenticated user has access to the requested shop. + // VI: Xác thực rằng user đã xác thực có quyền truy cập shop được yêu cầu. + ValidateShopAccess(shopId); + _logger.LogInformation( "EN: User {UserId} joining shop {ShopId} / " + "VI: User {UserId} tham gia shop {ShopId}", @@ -119,6 +123,10 @@ public class PosHub : Hub var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated / User chưa xác thực"); + // EN: Validate that the authenticated user has access to the requested shop. + // VI: Xác thực rằng user đã xác thực có quyền truy cập shop được yêu cầu. + ValidateShopAccess(shopId); + _logger.LogInformation( "EN: User {UserId} joining KDS for shop {ShopId} / " + "VI: User {UserId} tham gia KDS cho shop {ShopId}", @@ -138,6 +146,10 @@ public class PosHub : Hub var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated / User chưa xác thực"); + // EN: Validate that the authenticated user has access to the requested shop. + // VI: Xác thực rằng user đã xác thực có quyền truy cập shop được yêu cầu. + ValidateShopAccess(shopId); + _logger.LogInformation( "EN: User {UserId} joining POS terminal for shop {ShopId} / " + "VI: User {UserId} tham gia thiết bị POS cho shop {ShopId}", @@ -200,4 +212,27 @@ public class PosHub : Hub } #endregion + + #region Authorization Helpers + + /// + /// EN: Validate that the authenticated user has access to the specified shop. + /// Prevents cross-tenant access where a user from shop A joins shop B's group. + /// VI: Xác thực rằng user đã xác thực có quyền truy cập shop được chỉ định. + /// Ngăn chặn truy cập cross-tenant khi user từ shop A tham gia group của shop B. + /// + private void ValidateShopAccess(Guid shopId) + { + var userShopClaim = Context.User?.FindFirst("shop_id")?.Value; + if (string.IsNullOrEmpty(userShopClaim) || !Guid.TryParse(userShopClaim, out var userShopId) || userShopId != shopId) + { + _logger.LogWarning( + "EN: Unauthorized shop access attempt - User tried to join shop {RequestedShopId} but belongs to shop {UserShopId} / " + + "VI: Truy cập shop trái phép - User cố tham gia shop {RequestedShopId} nhưng thuộc shop {UserShopId}", + shopId, userShopClaim ?? "none"); + throw new HubException("Unauthorized: You don't have access to this shop / Không có quyền: Bạn không có quyền truy cập shop này"); + } + } + + #endregion } diff --git a/services/order-service-net/src/OrderService.API/Middleware/TenantMiddleware.cs b/services/order-service-net/src/OrderService.API/Middleware/TenantMiddleware.cs index 031c00aa..7a98b097 100644 --- a/services/order-service-net/src/OrderService.API/Middleware/TenantMiddleware.cs +++ b/services/order-service-net/src/OrderService.API/Middleware/TenantMiddleware.cs @@ -71,12 +71,12 @@ public class TenantMiddleware // EN: Set shop_id as the primary tenant identifier // VI: Đặt shop_id làm tenant identifier chính await using var cmd = npgsqlConnection.CreateCommand(); - cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId}'"; + cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'"; await cmd.ExecuteNonQueryAsync(); if (merchantId.HasValue) { - cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value}'"; + cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'"; await cmd.ExecuteNonQueryAsync(); } diff --git a/services/wallet-service-net/src/WalletService.API/Middleware/TenantMiddleware.cs b/services/wallet-service-net/src/WalletService.API/Middleware/TenantMiddleware.cs index 49995af9..3e9c88d1 100644 --- a/services/wallet-service-net/src/WalletService.API/Middleware/TenantMiddleware.cs +++ b/services/wallet-service-net/src/WalletService.API/Middleware/TenantMiddleware.cs @@ -22,10 +22,73 @@ public class TenantMiddleware _logger = logger; } - public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider) + public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider, IDbConnection dbConnection) { + var shopId = tenantProvider.GetCurrentShopId(); + var merchantId = tenantProvider.GetCurrentMerchantId(); + var isServiceCall = tenantProvider.IsServiceCall(); + var isAdmin = tenantProvider.IsAdmin(); + + // EN: Skip tenant SET for service-to-service calls and admin users + // VI: Bỏ qua tenant SET cho cuộc gọi service-to-service và admin users + if (!isServiceCall && !isAdmin) + { + if (shopId.HasValue) + { + await SetTenantContextAsync(dbConnection, shopId.Value, merchantId); + } + else + { + _logger.LogDebug( + "EN: No tenant context available for request {Path} / " + + "VI: Không có tenant context cho request {Path}", + context.Request.Path); + } + } + await _next(context); } + + /// + /// EN: Set PostgreSQL session variables for RLS enforcement. + /// VI: Đặt biến session PostgreSQL cho RLS enforcement. + /// + private async Task SetTenantContextAsync(IDbConnection dbConnection, Guid shopId, Guid? merchantId) + { + try + { + if (dbConnection is NpgsqlConnection npgsqlConnection) + { + if (npgsqlConnection.State != ConnectionState.Open) + { + await npgsqlConnection.OpenAsync(); + } + + // EN: Set shop_id as the primary tenant identifier + // VI: Đặt shop_id làm tenant identifier chính + await using var cmd = npgsqlConnection.CreateCommand(); + cmd.CommandText = $"SET LOCAL app.current_shop_id = '{shopId.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + + if (merchantId.HasValue) + { + cmd.CommandText = $"SET LOCAL app.current_merchant_id = '{merchantId.Value.ToString("D")}'"; + await cmd.ExecuteNonQueryAsync(); + } + + _logger.LogDebug( + "EN: Tenant context set - ShopId: {ShopId}, MerchantId: {MerchantId} / " + + "VI: Tenant context đã đặt - ShopId: {ShopId}, MerchantId: {MerchantId}", + shopId, merchantId); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "EN: Failed to set PostgreSQL tenant context / " + + "VI: Không thể đặt PostgreSQL tenant context"); + } + } } /// diff --git a/services/wallet-service-net/src/WalletService.API/Program.cs b/services/wallet-service-net/src/WalletService.API/Program.cs index 27e646a5..888be071 100644 --- a/services/wallet-service-net/src/WalletService.API/Program.cs +++ b/services/wallet-service-net/src/WalletService.API/Program.cs @@ -4,6 +4,8 @@ using FluentValidation; using Hellang.Middleware.ProblemDetails; using Microsoft.OpenApi.Models; using WalletService.API.Application.Behaviors; +using System.Data; +using Npgsql; using WalletService.API.Infrastructure.Tenant; using WalletService.API.Middleware; using WalletService.Infrastructure; @@ -36,6 +38,15 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); + // EN: Add Dapper IDbConnection for TenantMiddleware RLS / VI: Thêm Dapper IDbConnection cho TenantMiddleware RLS + builder.Services.AddTransient(sp => + { + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Database connection string is required"); + return new NpgsqlConnection(connectionString); + }); + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors builder.Services.AddMediatR(cfg => { diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs index 11e68ba9..02750e93 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/PaymentEntityTypeConfiguration.cs @@ -66,20 +66,9 @@ public class PaymentEntityTypeConfiguration : IEntityTypeConfiguration builder.Property("_completedAt") .HasColumnName("completed_at"); - // EN: Ignore computed properties and domain events - // VI: Bo qua cac property tinh toan va domain events - builder.Ignore(p => p.OrderId); - builder.Ignore(p => p.Amount); - builder.Ignore(p => p.Currency); - builder.Ignore(p => p.GatewayName); - builder.Ignore(p => p.TransactionId); - builder.Ignore(p => p.PaymentUrl); - builder.Ignore(p => p.StatusId); + // EN: Ignore computed properties and domain events (NOT expression-bodied properties backed by mapped fields). + // VI: Bo qua cac property tinh toan va domain events (KHONG ignore cac property co backing field da map). builder.Ignore(p => p.Status); - builder.Ignore(p => p.ErrorCode); - builder.Ignore(p => p.ErrorMessage); - builder.Ignore(p => p.CreatedAt); - builder.Ignore(p => p.CompletedAt); builder.Ignore(p => p.DomainEvents); // EN: Indexes for common queries