This commit is contained in:
Thanakarn Klangkasame
2025-09-30 11:01:02 +07:00
commit 92e614674c
182 changed files with 9596 additions and 0 deletions

View 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;
}
}

View 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);
}
}

View 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; }
}

View 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('=');
}

View 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 _);
}

View 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;
}

View 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
}
}
}

View 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);
}
}