Harden multi-tenant isolation across all user-facing controllers

Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 18:04:22 -04:00
parent 485f0b69c8
commit 8acbc8605d
23 changed files with 569 additions and 192 deletions
@@ -160,7 +160,8 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
@@ -1106,7 +1107,8 @@ public class InventoryController : Controller
// Build a set of SKUs already in this company's inventory so we can exclude them.
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
@@ -1182,7 +1184,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
@@ -1369,11 +1371,11 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var categories = allCategories
.Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder)
@@ -1738,7 +1740,8 @@ public class InventoryController : Controller
DateTime? dateTo,
string? typeFilter)
{
var allItems = await _unitOfWork.InventoryItems.GetAllAsync();
var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
var itemList = allItems
.Where(i => i.IsActive || i.QuantityOnHand > 0)
.OrderBy(i => i.Name)