Files
amrez-nova-eop-services-api/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs
Thanakarn Klangkasame 92e614674c Init Git
2025-09-30 11:01:02 +07:00

114 lines
4.4 KiB
C#

using System.Security.Claims;
using System.Text.RegularExpressions;
using AMREZ.EOP.API.Filters;
using AMREZ.EOP.Infrastructures.Tenancy;
namespace AMREZ.EOP.API.Middleware;
public sealed class StrictTenantGuardOptions
{
public string PlatformSlug { get; set; } = "public";
public string HeaderName { get; set; } = "X-Tenant";
public string ClaimName { get; set; } = "tenant"; // ปรับเป็น tid/org ได้ตามระบบ
public HashSet<(string method, string pathPrefix)> Allowlist { get; } = new()
{
("GET", "/health"),
("GET", "/swagger"),
("GET", "/swagger/index.html"),
("GET", "/swagger/"),
};
public Regex SlugRegex { get; set; } = new(@"^[a-z0-9\-]{1,64}$", RegexOptions.Compiled);
}
public sealed class StrictTenantGuardMiddleware : IMiddleware
{
private readonly ILogger<StrictTenantGuardMiddleware> _log;
private readonly StrictTenantGuardOptions _opts;
private readonly TenantMap _map;
public StrictTenantGuardMiddleware(
ILogger<StrictTenantGuardMiddleware> log,
StrictTenantGuardOptions opts,
TenantMap map)
{ _log = log; _opts = opts; _map = map; }
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
// 1) allowlist แบบเร็ว
var path = ctx.Request.Path.Value ?? string.Empty;
if (_opts.Allowlist.Any(p =>
ctx.Request.Method.Equals(p.method, StringComparison.OrdinalIgnoreCase) &&
path.StartsWith(p.pathPrefix, StringComparison.OrdinalIgnoreCase)))
{
await next(ctx);
return;
}
var endpoint = ctx.GetEndpoint();
if (endpoint?.Metadata?.GetMetadata<SkipTenantGuardAttribute>() is not null)
{
await next(ctx);
return;
}
// 3) ดึง header
var raw = ctx.Request.Headers[_opts.HeaderName].ToString()?.Trim();
if (string.IsNullOrWhiteSpace(raw))
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
await ctx.Response.WriteAsJsonAsync(new { code = "MISSING_TENANT", message = $"{_opts.HeaderName} required" });
return;
}
var slug = raw.ToLowerInvariant();
if (!_opts.SlugRegex.IsMatch(slug))
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
await ctx.Response.WriteAsJsonAsync(new { code = "INVALID_TENANT_FORMAT", message = "tenant slug invalid" });
return;
}
// 4) ตรวจ claim ให้ตรงกับ header
// var claim = GetTenantClaim(ctx.User, _opts.ClaimName);
// if (claim is null || !string.Equals(claim, slug, StringComparison.Ordinal))
// {
// ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
// await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_MISMATCH", message = "token tenant != header" });
// return;
// }
// 5) ตรวจว่ามีอยู่จริงในระบบ (TenantMap/meta)
if (!_map.TryGetBySlug(slug, out var ctxTenant))
{
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_NOT_FOUND", message = "unknown tenant" });
return;
}
// 6) สำหรับ /api/Tenancy/** → ใช้ platform DB แต่ยังต้องรู้ target tenant
var isControlPlane = path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase);
if (isControlPlane)
{
// เก็บ target tenant ให้ use case ใช้
ctx.Items["TargetTenantKey"] = slug;
// บังคับให้ resolver ใช้ platform (ภายหลัง)
ctx.Items["ForcePlatformContext"] = true;
}
else
{
// business-plane: ใช้ tenant context ปกติ (resolver จะอ่าน X-Tenant ตรง ๆ)
}
await next(ctx);
}
private static string? GetTenantClaim(ClaimsPrincipal user, string name)
{
if (user?.Identity?.IsAuthenticated != true) return null;
return user.FindFirstValue(name)
?? user.FindFirstValue("tid")
?? user.FindFirstValue("org")
?? user.FindFirstValue("org_id");
}
}