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 _log; private readonly StrictTenantGuardOptions _opts; private readonly TenantMap _map; public StrictTenantGuardMiddleware( ILogger 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() 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"); } }