Scope all controller account lookups by CompanyId (defense-in-depth sweep)

Completes the read-path defense-in-depth pass flagged in the accounting audit:
every Accounts lookup in a controller now carries an explicit CompanyId predicate,
matching the standing rule in CLAUDE.md ("every FindAsync/GetAllAsync must include
an explicit CompanyId"). ~19 lookups across 12 controllers:

  - Tier 1 (write-path): AccountsController duplicate account-number check (Create/Edit)
  - Tier 2 (dropdowns/lists): Accounts (Index/year-end/parent), BankReconciliations,
    Bills (bank list + receipt scan + suggest), Budgets, CatalogItems, Expenses,
    FixedAssets, Inventory, JournalEntries chart dropdown, Vendors
  - Tier 3 (accountIds.Contains display maps): JournalEntries/Reports/VendorCredits
    detail views, scoped via the in-scope entity's CompanyId for uniformity

companyId source per controller: _tenantContext where available, else the in-scope
entity's CompanyId, else the current user. Build clean; 291 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 20:49:16 -04:00
parent df194bd64b
commit 08a5cd39d4
13 changed files with 47 additions and 30 deletions
@@ -453,10 +453,11 @@ public class BillsController : Controller
// Payment form defaults
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.AccountSubType == AccountSubType.Cash ||
a => a.CompanyId == bill.CompanyId &&
(a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings ||
a.AccountSubType == AccountSubType.CreditCard))
a.AccountSubType == AccountSubType.CreditCard)))
.OrderBy(a => a.AccountNumber)
.ToList();
@@ -1077,7 +1078,8 @@ public class BillsController : Controller
return Json(new { success = false, error = "File must be under 10 MB." });
// Load expense accounts for matching
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
var expenseAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
@@ -1097,7 +1099,6 @@ public class BillsController : Controller
var imageBytes = ms.ToArray();
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
return Json(result);
@@ -1124,7 +1125,8 @@ public class BillsController : Controller
// Load expense accounts if not supplied
if (!request.AvailableAccounts.Any())
{
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
request.AvailableAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||