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
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
try
{
// Get all categories with their items
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
return Json(new List<object>());
}
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
var search = searchTerm.ToLower();
var items = allItems
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
/// </summary>
private async Task PopulateCategoryDropdown()
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
// The full path gives Claude the coating-type context it needs — an item in
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
.ToDictionary(c => c.Id);
// Load company operating costs