Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user