Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,148 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using System.Security.Claims;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Resolves the current tenant's <c>CompanyId</c> from the active HTTP request principal.
/// Consumed by <c>ApplicationDbContext</c>'s global query filters to enforce company-level
/// data isolation: every EF query automatically appends <c>WHERE CompanyId = @currentCompanyId</c>
/// unless the caller passes <c>ignoreQueryFilters: true</c>.
/// SuperAdmins bypass isolation entirely (no company filter applied) unless they are
/// actively impersonating a company via the session-stored override.
/// </summary>
public class TenantContext : ITenantContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
/// <summary>
/// Constructs the context. All three dependencies are scoped per-request, matching
/// the scoped lifetime of this service and <c>ApplicationDbContext</c>.
/// </summary>
public TenantContext(
IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context)
{
_httpContextAccessor = httpContextAccessor;
_userManager = userManager;
_context = context;
}
/// <summary>
/// Returns the <c>CompanyId</c> of the currently authenticated user, or <c>null</c>
/// for unauthenticated requests. Resolution order:
/// <list type="number">
/// <item>SuperAdmin impersonation override from <c>ISession["ImpersonatingCompanyId"]</c>.</item>
/// <item>The <c>CompanyId</c> claim baked into the authentication cookie/JWT at login time
/// (fast path — no DB hit).</item>
/// <item>Synchronous database fallback via <see cref="UserManager{TUser}.Users"/> when the
/// claim is absent (e.g. user logged in before claim issuance was added). This path
/// blocks the thread and should be treated as a migration path — users re-logging in
/// will pick up the claim and avoid the DB call.</item>
/// </list>
/// </summary>
public int? GetCurrentCompanyId()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
// SuperAdmin impersonation override — checked before claims
if (user.IsInRole("SuperAdmin"))
{
var overrideId = _httpContextAccessor.HttpContext?.Session.GetInt32("ImpersonatingCompanyId");
if (overrideId.HasValue) return overrideId.Value;
}
// Try to get from claims first (performance optimization)
var companyIdClaim = user.FindFirst("CompanyId")?.Value;
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return companyId;
// Fallback: Get from database if claim is missing
// This can happen if user logged in before claims were set up
var userName = user.Identity.Name;
if (!string.IsNullOrEmpty(userName))
{
// Note: This is synchronous and may impact performance
// Consider requiring users to re-login to get claims
var appUser = _userManager.Users
.FirstOrDefault(u => u.UserName == userName);
if (appUser != null && appUser.CompanyId > 0)
return appUser.CompanyId;
}
return null;
}
/// <summary>
/// Asynchronously retrieves the full <see cref="PowderCoating.Core.Entities.Company"/> entity
/// for the currently authenticated user. Uses <see cref="UserManager{TUser}.GetUserAsync"/>
/// which reads from the Identity user cache when available. Returns <c>null</c> for
/// unauthenticated requests or when the user record cannot be found.
/// </summary>
public async Task<Company?> GetCurrentCompanyAsync()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
var appUser = await _userManager.GetUserAsync(user);
return appUser?.Company;
}
/// <summary>
/// Returns <c>true</c> when the current user is in the <c>SuperAdmin</c> ASP.NET Identity role.
/// SuperAdmins see all companies' data (no company filter) and have access to Platform
/// Management pages. Used by <c>ApplicationDbContext</c> to decide whether to suppress the
/// global <c>CompanyId</c> query filter entirely.
/// </summary>
public bool IsSuperAdmin()
{
var user = _httpContextAccessor.HttpContext?.User;
return user?.IsInRole("SuperAdmin") == true;
}
/// <summary>
/// Returns <c>true</c> when the current user is a SuperAdmin who is not actively scoped to
/// a specific tenant company. Platform admins have unrestricted access to all companies,
/// global settings, and the seeding UI. A SuperAdmin impersonating a tenant company (via the
/// session override in <see cref="GetCurrentCompanyId"/>) is treated as a company admin for
/// that session, not a platform admin.
/// </summary>
public bool IsPlatformAdmin()
{
if (!IsSuperAdmin()) return false;
var companyId = GetCurrentCompanyId();
// No company assigned or explicitly on company 1 = full platform admin
return companyId == null || companyId == 1;
}
/// <summary>
/// Returns <c>true</c> when the current company has opted into metric units (kg/m²) rather
/// than the default Imperial system (lb/ft²). Used by views and PDF generators to display
/// the correct unit labels and conversion factors. Defaults to <c>false</c> (Imperial) when
/// there is no company context or no preference row exists.
/// </summary>
public async Task<bool> UseMetricSystemAsync()
{
var companyId = GetCurrentCompanyId();
if (companyId == null)
return false; // Default to Imperial if no company context
var preferences = await _context.CompanyPreferences
.Where(p => p.CompanyId == companyId.Value)
.FirstOrDefaultAsync();
return preferences?.UseMetricSystem ?? false;
}
}