Init Git
This commit is contained in:
98
AMREZ.EOP.Infrastructures/Tenancy/DefaultTenantResolver.cs
Normal file
98
AMREZ.EOP.Infrastructures/Tenancy/DefaultTenantResolver.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
public sealed class DefaultTenantResolver : ITenantResolver
|
||||
{
|
||||
private readonly TenantMap _map;
|
||||
private readonly ILogger<DefaultTenantResolver>? _log;
|
||||
|
||||
// แพลตฟอร์มสลัก (ปรับตามระบบจริงได้) — ใช้ "public" เป็นค่าเริ่ม
|
||||
private const string PlatformSlug = "public";
|
||||
|
||||
public DefaultTenantResolver(
|
||||
TenantMap map,
|
||||
ILogger<DefaultTenantResolver>? log = null
|
||||
)
|
||||
{
|
||||
_map = map;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public ITenantContext? Resolve(HttpContext http, object? hint = null)
|
||||
{
|
||||
var cid = http.TraceIdentifier;
|
||||
var path = http.Request.Path.Value ?? string.Empty;
|
||||
|
||||
// ✅ ทางลัด: ขอ platform ตรง ๆ ด้วย hint
|
||||
if (hint is string hs && string.Equals(hs, "@platform", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_map.TryGetBySlug(PlatformSlug, out var platform))
|
||||
{
|
||||
_log?.LogInformation("Resolved by hint=@platform -> {Slug} cid={Cid}", PlatformSlug, cid);
|
||||
return platform;
|
||||
}
|
||||
_log?.LogWarning("Platform slug '{Slug}' not found in TenantMap cid={Cid}", PlatformSlug, cid);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_map.TryGetBySlug(PlatformSlug, out var platform))
|
||||
{
|
||||
// เก็บ target tenant แยกไว้ให้ repo/usecase ใช้
|
||||
var headerTenant = http.Request.Headers["X-Tenant"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
http.Items["TargetTenantKey"] = headerTenant.Trim().ToLowerInvariant();
|
||||
|
||||
_log?.LogInformation("Resolved CONTROL-PLANE -> platform:{Slug} (path={Path}) cid={Cid}", PlatformSlug, path, cid);
|
||||
return platform;
|
||||
}
|
||||
_log?.LogWarning("CONTROL-PLANE path but platform slug '{Slug}' missing cid={Cid}", PlatformSlug, cid);
|
||||
return null;
|
||||
}
|
||||
|
||||
var header = http.Request.Headers["X-Tenant"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(header) && _map.TryGetBySlug(header, out var byHeader))
|
||||
{
|
||||
_log?.LogInformation("Resolved by header: {Tenant} cid={Cid}", header, cid);
|
||||
return byHeader;
|
||||
}
|
||||
|
||||
var host = http.Request.Host.Host;
|
||||
if (_map.TryGetByDomain(host, out var byDomain))
|
||||
{
|
||||
_log?.LogInformation("Resolved by domain: {Host} -> {TenantId} cid={Cid}", host, byDomain.Id, cid);
|
||||
return byDomain;
|
||||
}
|
||||
|
||||
var sub = GetSubdomain(host);
|
||||
if (sub != null && _map.TryGetBySlug(sub, out var bySub))
|
||||
{
|
||||
_log?.LogInformation("Resolved by subdomain: {Sub} cid={Cid}", sub, cid);
|
||||
return bySub;
|
||||
}
|
||||
|
||||
if (hint is LoginRequest body && !string.IsNullOrWhiteSpace(body.Tenant) &&
|
||||
_map.TryGetBySlug(body.Tenant!, out var byBody))
|
||||
{
|
||||
_log?.LogInformation("Resolved by body: {Tenant} cid={Cid}", body.Tenant, cid);
|
||||
return byBody;
|
||||
}
|
||||
|
||||
_log?.LogWarning("Resolve FAILED host={Host} header={Header} cid={Cid}",
|
||||
host, string.IsNullOrWhiteSpace(header) ? "<empty>" : header, cid);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetSubdomain(string host)
|
||||
{
|
||||
if (Regex.IsMatch(host, @"^\d{1,3}(\.\d{1,3}){3}$")) return null; // IP
|
||||
var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 3 ? parts[0] : null;
|
||||
}
|
||||
}
|
||||
35
AMREZ.EOP.Infrastructures/Tenancy/SearchPathInterceptor.cs
Normal file
35
AMREZ.EOP.Infrastructures/Tenancy/SearchPathInterceptor.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
public sealed class SearchPathInterceptor : DbConnectionInterceptor
|
||||
{
|
||||
private string _schema = "public";
|
||||
private bool _enabled;
|
||||
|
||||
public void Configure(string? schema, bool enabled)
|
||||
{
|
||||
_enabled = enabled;
|
||||
_schema = string.IsNullOrWhiteSpace(schema) ? "public" : schema.Trim();
|
||||
}
|
||||
|
||||
public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct = default)
|
||||
{
|
||||
if (_enabled)
|
||||
{
|
||||
var schema = string.IsNullOrWhiteSpace(_schema) ? "public" : _schema.Replace("\"", "\"\"");
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS \"{schema}\";";
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"SET LOCAL search_path TO \"{schema}\", pg_catalog;";
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
await base.ConnectionOpenedAsync(connection, eventData, ct);
|
||||
}
|
||||
}
|
||||
14
AMREZ.EOP.Infrastructures/Tenancy/TenantContext.cs
Normal file
14
AMREZ.EOP.Infrastructures/Tenancy/TenantContext.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.Domain.Shared.Tenancy;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
public class TenantContext : ITenantContext
|
||||
{
|
||||
public string TenantKey { get; init; }
|
||||
public string Id { get; init; } = default!;
|
||||
public string? Schema { get; init; }
|
||||
public string? ConnectionString { get; init; }
|
||||
public TenantMode Mode { get; init; } = TenantMode.Rls;
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
55
AMREZ.EOP.Infrastructures/Tenancy/TenantDbContextFactory.cs
Normal file
55
AMREZ.EOP.Infrastructures/Tenancy/TenantDbContextFactory.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.Domain.Shared.Tenancy;
|
||||
using AMREZ.EOP.Infrastructures.Data;
|
||||
using AMREZ.EOP.Infrastructures.Options;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
public sealed class TenantDbContextFactory : ITenantDbContextFactory
|
||||
{
|
||||
private readonly IOptions<AuthOptions> _opts;
|
||||
private readonly SearchPathInterceptor _searchPath;
|
||||
private readonly TenantRlsInterceptor _rls;
|
||||
|
||||
public TenantDbContextFactory(IOptions<AuthOptions> opts, SearchPathInterceptor searchPath, TenantRlsInterceptor rls)
|
||||
{ _opts = opts; _searchPath = searchPath; _rls = rls; }
|
||||
|
||||
public TContext Create<TContext>(ITenantContext t) where TContext : class
|
||||
{
|
||||
if (typeof(TContext) != typeof(AppDbContext))
|
||||
throw new NotSupportedException($"Unsupported context: {typeof(TContext).Name}");
|
||||
|
||||
var builder = new DbContextOptionsBuilder<AppDbContext>();
|
||||
|
||||
// ✅ การ์ด: ถ้า connection string ของ tenant ไม่ใช่รูปแบบ key=value ให้ fallback ไป DefaultConnection (platform)
|
||||
var raw = (t.ConnectionString ?? string.Empty).Trim();
|
||||
string conn = IsLikelyValidConnectionString(raw)
|
||||
? raw
|
||||
: (_opts.Value.DefaultConnection ?? string.Empty).Trim();
|
||||
|
||||
if (!IsLikelyValidConnectionString(conn))
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid connection string for tenant '{t.TenantKey}'. Provide a valid per-tenant or DefaultConnection.");
|
||||
|
||||
builder.UseNpgsql(conn);
|
||||
|
||||
// RLS per-tenant (ถ้า platform ควรกำหนดให้ Interceptor ทำงานในโหมด platform/disable ตามที่คุณนิยาม)
|
||||
_rls.Configure(t.Id);
|
||||
builder.AddInterceptors(_rls);
|
||||
|
||||
// ใช้ schema ต่อ tenant เมื่อเปิดโหมดนี้
|
||||
if (_opts.Value.UseSchemaPerTenant)
|
||||
{
|
||||
var schema = string.IsNullOrWhiteSpace(t.Schema) ? t.Id : t.Schema!.Trim();
|
||||
_searchPath.Configure(schema, enabled: true); // CREATE IF NOT EXISTS + SET LOCAL search_path
|
||||
builder.AddInterceptors(_searchPath);
|
||||
}
|
||||
|
||||
return (new AppDbContext(builder.Options) as TContext)!;
|
||||
}
|
||||
|
||||
private static bool IsLikelyValidConnectionString(string s)
|
||||
=> !string.IsNullOrWhiteSpace(s) && s.Contains('=');
|
||||
}
|
||||
57
AMREZ.EOP.Infrastructures/Tenancy/TenantMap.cs
Normal file
57
AMREZ.EOP.Infrastructures/Tenancy/TenantMap.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Concurrent;
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.Domain.Shared.Tenancy;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// ศูนย์รวม mapping แบบไดนามิก:
|
||||
/// - Tenants: slug/key -> TenantContext
|
||||
/// - DomainToTenant: custom-domain -> tenantKey
|
||||
/// - BaseDomains: ชุดโดเมนแพลตฟอร์มที่รองรับรูปแบบ *.basedomain (รองรับหลายอันได้)
|
||||
/// คุณสามารถเติม/แก้ไขระหว่างรันงานผ่าน Admin API หรือ job reload จาก DB ได้เลย
|
||||
/// </summary>
|
||||
public sealed class TenantMap
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TenantContext> _tenants = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _domainToTenant = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, byte> _baseDomains = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ====== Query ======
|
||||
public bool TryGetBySlug(string slug, out ITenantContext ctx)
|
||||
{
|
||||
if (_tenants.TryGetValue(slug, out var t))
|
||||
{
|
||||
ctx = t; return true;
|
||||
}
|
||||
ctx = null!; return false;
|
||||
}
|
||||
|
||||
public bool TryGetByDomain(string domain, out ITenantContext ctx)
|
||||
{
|
||||
if (_domainToTenant.TryGetValue(domain, out var key) && _tenants.TryGetValue(key, out var t))
|
||||
{ ctx = t; return true; }
|
||||
ctx = null!; return false;
|
||||
}
|
||||
|
||||
public bool TryGetBaseDomainFor(string host, out string? baseDomain)
|
||||
{
|
||||
foreach (var kv in _baseDomains.Keys)
|
||||
{
|
||||
var bd = kv;
|
||||
if (host.EndsWith(bd, StringComparison.OrdinalIgnoreCase) && !host.Equals(bd, StringComparison.OrdinalIgnoreCase))
|
||||
{ baseDomain = bd; return true; }
|
||||
}
|
||||
baseDomain = null; return false;
|
||||
}
|
||||
|
||||
// ====== Admin / Reload ======
|
||||
public void UpsertTenant(string key, TenantContext ctx) => _tenants[key] = ctx;
|
||||
public bool RemoveTenant(string key) => _tenants.TryRemove(key, out _);
|
||||
|
||||
public void MapDomain(string domain, string tenantKey) => _domainToTenant[domain.Trim().ToLowerInvariant()] = tenantKey;
|
||||
public bool UnmapDomain(string domain) => _domainToTenant.TryRemove(domain.Trim().ToLowerInvariant(), out _);
|
||||
|
||||
public void AddBaseDomain(string baseDomain) => _baseDomains[baseDomain.Trim().ToLowerInvariant()] = 1;
|
||||
public bool RemoveBaseDomain(string baseDomain) => _baseDomains.TryRemove(baseDomain.Trim().ToLowerInvariant(), out _);
|
||||
}
|
||||
83
AMREZ.EOP.Infrastructures/Tenancy/TenantMapLoader.cs
Normal file
83
AMREZ.EOP.Infrastructures/Tenancy/TenantMapLoader.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using AMREZ.EOP.Domain.Entities.Tenancy;
|
||||
using AMREZ.EOP.Domain.Shared.Tenancy;
|
||||
using AMREZ.EOP.Infrastructures.Data;
|
||||
using AMREZ.EOP.Infrastructures.Options;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// โหลดค่า Tenants / Domains ที่ IsActive=true จาก meta.* ใส่ TenantMap ตอนสตาร์ต
|
||||
/// และ bootstrap 'public' ถ้ายังไม่มี
|
||||
/// </summary>
|
||||
public sealed class TenantMapLoader : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
public TenantMapLoader(IServiceProvider sp) => _sp = sp;
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var map = scope.ServiceProvider.GetRequiredService<TenantMap>();
|
||||
var opt = scope.ServiceProvider.GetRequiredService<IOptions<AuthOptions>>().Value;
|
||||
|
||||
// 1) Load tenants (active เท่านั้น)
|
||||
var tenants = await db.Set<TenantConfig>()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
map.UpsertTenant(
|
||||
t.TenantKey,
|
||||
new TenantContext
|
||||
{
|
||||
Id = t.TenantKey,
|
||||
Schema = string.IsNullOrWhiteSpace(t.Schema) ? t.TenantKey : t.Schema,
|
||||
ConnectionString = string.IsNullOrWhiteSpace(t.ConnectionString) ? null : t.ConnectionString,
|
||||
Mode = t.Mode
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Load domains (active เท่านั้น)
|
||||
var domains = await db.Set<TenantDomain>()
|
||||
.AsNoTracking()
|
||||
.Where(d => d.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var d in domains)
|
||||
{
|
||||
if (d.IsPlatformBaseDomain)
|
||||
{
|
||||
map.AddBaseDomain(d.Domain);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(d.TenantKey))
|
||||
{
|
||||
map.MapDomain(d.Domain, d.TenantKey!);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Bootstrap 'public' ถ้ายังไม่มี (กัน chicken-and-egg)
|
||||
if (!map.TryGetBySlug("public", out _))
|
||||
{
|
||||
map.UpsertTenant(
|
||||
"public",
|
||||
new TenantContext
|
||||
{
|
||||
Id = "public",
|
||||
Schema = "public",
|
||||
ConnectionString = string.IsNullOrWhiteSpace(opt.DefaultConnection) ? null : opt.DefaultConnection,
|
||||
Mode = TenantMode.Rls
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
39
AMREZ.EOP.Infrastructures/Tenancy/TenantProvisioner.cs
Normal file
39
AMREZ.EOP.Infrastructures/Tenancy/TenantProvisioner.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||
using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
|
||||
using AMREZ.EOP.Infrastructures.Data;
|
||||
using AMREZ.EOP.Infrastructures.Options;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
public sealed class TenantProvisioner : ITenantProvisioner
|
||||
{
|
||||
private readonly TenantMap _map;
|
||||
private readonly ITenantDbContextFactory _factory;
|
||||
private readonly IOptions<AuthOptions> _conn;
|
||||
|
||||
public TenantProvisioner(TenantMap map, ITenantDbContextFactory factory, IOptions<AuthOptions> conn)
|
||||
{ _map = map; _factory = factory; _conn = conn; }
|
||||
|
||||
public async Task ProvisionAsync(string tenantKey, CancellationToken ct = default)
|
||||
{
|
||||
if (!_map.TryGetBySlug(tenantKey, out var ctx))
|
||||
{
|
||||
ctx = new TenantContext
|
||||
{
|
||||
Id = tenantKey,
|
||||
Schema = tenantKey,
|
||||
ConnectionString = _conn.Value.DefaultConnection,
|
||||
Mode = AMREZ.EOP.Domain.Shared.Tenancy.TenantMode.Rls
|
||||
};
|
||||
_map.UpsertTenant(tenantKey, (TenantContext)ctx);
|
||||
}
|
||||
|
||||
if (_conn.Value.UseSchemaPerTenant)
|
||||
{
|
||||
await using var db = _factory.Create<AppDbContext>(ctx);
|
||||
await db.Database.MigrateAsync(ct); // idempotent
|
||||
}
|
||||
}
|
||||
}
|
||||
27
AMREZ.EOP.Infrastructures/Tenancy/TenantRlsInterceptor.cs
Normal file
27
AMREZ.EOP.Infrastructures/Tenancy/TenantRlsInterceptor.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace AMREZ.EOP.Infrastructures.Tenancy;
|
||||
|
||||
public sealed class TenantRlsInterceptor : DbConnectionInterceptor
|
||||
{
|
||||
private string _tenant = "";
|
||||
public void Configure(string tenantId) => _tenant = tenantId?.Trim() ?? "";
|
||||
|
||||
public override async Task ConnectionOpenedAsync(
|
||||
DbConnection connection,
|
||||
ConnectionEndEventData eventData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ตั้งแค่ RLS session param เท่านั้น
|
||||
if (!string.IsNullOrWhiteSpace(_tenant))
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
var v = _tenant.Replace("'", "''");
|
||||
cmd.CommandText = $"SET LOCAL app.tenant_id = '{v}';";
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await base.ConnectionOpenedAsync(connection, eventData, cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user