First Commit

This commit is contained in:
Jorge Luis caudillo esparza
2026-05-09 16:36:57 -07:00
parent 96ad4c0d3e
commit d0c284d9c0
131 changed files with 4009 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Http;
namespace ADS.Helpers.MultiTenant.Abstractions;
public interface ITenantResolver
{
string? Resolve(HttpContext context);
}

View File

@@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ADS.Helpers.MultiTenant.Context;
namespace ADS.Helpers.MultiTenant.Abstractions;
public interface ITenantStore
{
Task<TenantContext?> GetAsync(string tenantKey);
}

View File

@@ -0,0 +1,11 @@
namespace ADS.Helpers.MultiTenant.Configuration;
public sealed class TenantOptions
{
public string HeaderName { get; set; } = "X-Tenant";
public string? DefaultTenantId { get; set; }="demo";
public bool RequireTenant { get; set; } = false;
// Para no romper swagger
public string[] BypassPaths { get; set; } = ["/swagger"];
}

View File

@@ -0,0 +1,7 @@
namespace ADS.Helpers.MultiTenant.Context;
public interface ITenantContextAccessor
{
TenantContext? Current { get; }
void SetCurrent(TenantContext? context);
}

View File

@@ -0,0 +1,17 @@
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Context;
namespace ADS.Helpers.MultiTenant.Stores;
public sealed class InMemoryTenantStore : ITenantStore
{
private readonly Dictionary<string, TenantContext> _tenants = new(StringComparer.OrdinalIgnoreCase)
{
["demo"] = new TenantContext { TenantId = "demo", EmpresaId = 172, ClienteId = 1 }
};
public Task<TenantContext?> GetAsync(string tenantKey)
{
_tenants.TryGetValue(tenantKey, out var tenant);
return Task.FromResult<TenantContext?>(tenant);
}
}

View File

@@ -0,0 +1,22 @@
namespace ADS.Helpers.MultiTenant.Context;
public sealed class TenantContext
{
public int EmpresaId { get; set; }
public int ClienteId { get; set; }
public string TenantId { get; set; } = string.Empty;
public string Culture { get; set; } = "es-MX";
public string Theme { get; set; } = string.Empty;
public DatabaseConfig Database { get; set; } = new();
// Settings no tipados (solo si quieres algo rápido para cosas raras)
public Dictionary<string, string> CustomSettings { get; set; } = new();
}
public sealed class DatabaseConfig
{
public string Provider { get; set; } = "SqlServer"; // SqlServer | Postgres
public string ConnectionString { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
namespace ADS.Helpers.MultiTenant.Context;
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<TenantContext?> _current = new();
public TenantContext? Current => _current.Value;
public void SetCurrent(TenantContext? context) => _current.Value = context;
}

View File

@@ -0,0 +1,8 @@
using System.Data;
namespace ADS.Helpers.MultiTenant.Data;
public interface IDbConnectionFactory
{
IDbConnection Create();
}

View File

@@ -0,0 +1,25 @@
using System.Data;
using ADS.Helpers.MultiTenant.InternalRouting;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.SqlClient;
namespace ADS.Helpers.MultiTenant.Data;
public sealed class TenantDbConnectionFactory : IDbConnectionFactory
{
private readonly IHttpContextAccessor _http;
public TenantDbConnectionFactory(IHttpContextAccessor http) => _http = http;
public IDbConnection Create()
{
var ctx = _http.HttpContext ?? throw new InvalidOperationException("HTTP_CONTEXT_NOT_AVAILABLE");
if (!ctx.Items.TryGetValue(InternalTenantDbHeaders.ItemConn, out var csObj) ||
csObj is not string cs ||
string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("INTERNAL_CONN_MISSING");
return new SqlConnection(cs);
}
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Builder;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class ApplicationBuilderExtensions
{
// MICRO: valida headers internos antes de controllers
public static IApplicationBuilder UseInternalTenantDb(this IApplicationBuilder app)
=> app.UseMiddleware<InternalTenantDbMiddleware>();
}

View File

@@ -0,0 +1,24 @@
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class InternalHeaders
{
public const string Tenant = "X-Internal-Tenant";
public const string Conn = "X-Internal-Conn";
public const string Timestamp = "X-Internal-Ts";
public const string Signature = "X-Internal-Sig";
// NUEVOS (lo que faltaba para no “inventar” en GPS)
public const string EmpresaId = "X-Internal-EmpresaId";
public const string ClienteId = "X-Internal-ClienteId";
// Opcionales
public const string Culture = "X-Internal-Culture";
public const string Theme = "X-Internal-Theme";
public const string DbProvider= "X-Internal-DbProvider";
public const string CveUser = "X-Internal-CveUser";
public const string CveEmpresa = "X-Internal-CveEmpresa";
public const string CveCliente = "X-Internal-CveCliente";
public const string User_Timestamp = "X-Internal-User-Timestamp";
public const string User_Signature = "X-Internal-User-Signature";
}

View File

@@ -0,0 +1,21 @@
namespace ADS.Helpers.MultiTenant.InternalRouting;
public sealed class InternalRoutingOptions
{
public string SharedSecret { get; set; } = default!;
public int MaxSkewSeconds { get; set; } = 120;
public string HeaderTenant { get; set; } = InternalHeaders.Tenant;
public string HeaderConn { get; set; } = InternalHeaders.Conn;
public string HeaderTs { get; set; } = InternalHeaders.Timestamp;
public string HeaderSig { get; set; } = InternalHeaders.Signature;
// NUEVOS
public string HeaderEmpresaId { get; set; } = InternalHeaders.EmpresaId;
public string HeaderClienteId { get; set; } = InternalHeaders.ClienteId;
// Opcionales
public string HeaderCulture { get; set; } = InternalHeaders.Culture;
public string HeaderTheme { get; set; } = InternalHeaders.Theme;
public string HeaderDbProvider{ get; set; } = InternalHeaders.DbProvider;
}

View File

@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using System.Text;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class InternalSignature
{
public static string Compute(
string secret,
string tenantId,
string conn,
int empresaId,
int clienteId,
string provider,
string culture,
string theme,
long tsUnix)
{
var payload = $"{tenantId}\n{conn}\n{empresaId}\n{clienteId}\n{provider}\n{culture}\n{theme}\n{tsUnix}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hash); // HEX uppercase
}
// Backward compatible (por si algún host viejo sigue firmando con el formato anterior)
public static string Compute(string secret, string tenantId, string conn, long tsUnix)
=> Compute(secret, tenantId, conn, 0, 0, "SqlServer", "es-MX", "", tsUnix);
public static bool FixedTimeEqualsHex(string expectedHex, string providedHex)
{
if (expectedHex.Length != providedHex.Length) return false;
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expectedHex),
Encoding.ASCII.GetBytes(providedHex));
}
public static bool IsTimestampValid(long tsUnix, int maxSkewSeconds)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var diff = Math.Abs(now - tsUnix);
return diff <= maxSkewSeconds;
}
}

View File

@@ -0,0 +1,46 @@
using ADS.Helpers.MultiTenant.Context;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public interface IInternalTenantDbHeaderWriter
{
long AddInternalHeaders(IDictionary<string, string> headers, TenantContext tenant);
}
public sealed class InternalTenantDbHeaderWriter : IInternalTenantDbHeaderWriter
{
private readonly InternalRoutingOptions _opt;
public InternalTenantDbHeaderWriter(IOptions<InternalRoutingOptions> opt) => _opt = opt.Value;
public long AddInternalHeaders(IDictionary<string, string> headers, TenantContext tenant)
{
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var tenantId = tenant.TenantId ?? "";
var conn = tenant.Database?.ConnectionString ?? "";
var empresaId = tenant.EmpresaId;
var clienteId = tenant.ClienteId;
var provider = tenant.Database?.Provider ?? "SqlServer";
var culture = tenant.Culture ?? "es-MX";
var theme = tenant.Theme ?? "";
var sig = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts);
headers[_opt.HeaderTenant] = tenantId;
headers[_opt.HeaderConn] = conn;
headers[_opt.HeaderEmpresaId] = empresaId.ToString();
headers[_opt.HeaderClienteId] = clienteId.ToString();
headers[_opt.HeaderDbProvider] = provider;
headers[_opt.HeaderCulture] = culture;
headers[_opt.HeaderTheme] = theme;
headers[_opt.HeaderTs] = ts.ToString();
headers[_opt.HeaderSig] = sig;
return ts;
}
}

View File

@@ -0,0 +1,7 @@
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class InternalTenantDbHeaders
{
public const string ItemTenantId = "Internal.TenantId";
public const string ItemConn = "Internal.Conn";
}

View File

@@ -0,0 +1,134 @@
using ADS.Helpers.MultiTenant.Context;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public sealed class InternalTenantDbMiddleware
{
private readonly RequestDelegate _next;
private readonly InternalRoutingOptions _opt;
private readonly ITenantContextAccessor _tenantAccessor;
public InternalTenantDbMiddleware(
RequestDelegate next,
IOptions<InternalRoutingOptions> opt,
ITenantContextAccessor tenantAccessor)
{
_next = next;
_opt = opt.Value;
_tenantAccessor = tenantAccessor;
}
public async Task InvokeAsync(HttpContext ctx)
{
if (!TryRead(ctx,
out var tenantId,
out var conn,
out var empresaId,
out var clienteId,
out var provider,
out var culture,
out var theme,
out var ts,
out var sig))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
if (!InternalSignature.IsTimestampValid(ts, _opt.MaxSkewSeconds))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
// Firma NUEVA (con empresa/cliente/etc)
var expected = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts);
// Backward compatible: si el Host todavía firma “viejo”, cae al compute viejo
if (!InternalSignature.FixedTimeEqualsHex(expected, sig))
{
var expectedOld = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, ts);
if (!InternalSignature.FixedTimeEqualsHex(expectedOld, sig))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
// backward compatible items
ctx.Items[InternalTenantDbHeaders.ItemTenantId] = tenantId;
ctx.Items[InternalTenantDbHeaders.ItemConn] = conn;
_tenantAccessor.SetCurrent(new TenantContext
{
TenantId = tenantId,
EmpresaId = empresaId,
ClienteId = clienteId,
Culture = culture,
Theme = theme,
Database = new DatabaseConfig
{
Provider = provider,
ConnectionString = conn
}
});
try
{
await _next(ctx);
}
finally
{
_tenantAccessor.SetCurrent(null);
}
}
private bool TryRead(
HttpContext ctx,
out string tenantId,
out string conn,
out int empresaId,
out int clienteId,
out string provider,
out string culture,
out string theme,
out long ts,
out string sig)
{
tenantId = conn = sig = "";
empresaId = 0;
clienteId = 0;
provider = "SqlServer";
culture = "es-MX";
theme = "";
ts = 0;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTenant, out var t)) return false;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderConn, out var c)) return false;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTs, out var tsStr)) return false;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderSig, out var s)) return false;
tenantId = t.ToString();
conn = c.ToString();
sig = s.ToString();
// NUEVOS obligatorios para Sicrep (si no vienen, dejamos 0 y el SP puede trabajar con -1)
if (ctx.Request.Headers.TryGetValue(_opt.HeaderEmpresaId, out var eStr))
int.TryParse(eStr.ToString(), out empresaId);
if (ctx.Request.Headers.TryGetValue(_opt.HeaderClienteId, out var clStr))
int.TryParse(clStr.ToString(), out clienteId);
// Opcionales
if (ctx.Request.Headers.TryGetValue(_opt.HeaderDbProvider, out var p)) provider = p.ToString();
if (ctx.Request.Headers.TryGetValue(_opt.HeaderCulture, out var cul)) culture = cul.ToString();
if (ctx.Request.Headers.TryGetValue(_opt.HeaderTheme, out var th)) theme = th.ToString();
return !string.IsNullOrWhiteSpace(tenantId)
&& !string.IsNullOrWhiteSpace(conn)
&& long.TryParse(tsStr.ToString(), out ts)
&& !string.IsNullOrWhiteSpace(sig);
}
}

View File

@@ -0,0 +1,29 @@
using ADS.Helpers.MultiTenant.Context;
using ADS.Helpers.MultiTenant.Data;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class ServiceCollectionExtensions
{
// MICRO: registra middleware options + HttpContextAccessor + DB factory
public static IServiceCollection AddInternalTenantDb(this IServiceCollection services, IConfiguration cfg)
{
services.AddHttpContextAccessor();
services.Configure<InternalRoutingOptions>(cfg.GetSection("InternalRouting"));
services.AddSingleton<IDbConnectionFactory, TenantDbConnectionFactory>();
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
services.AddSingleton<IDbConnectionFactory, TenantDbConnectionFactory>();
return services;
}
// HOST: registra solo el writer (para agregar headers internos)
public static IServiceCollection AddInternalTenantDbHeaderWriter(this IServiceCollection services, IConfiguration cfg)
{
services.Configure<InternalRoutingOptions>(cfg.GetSection("InternalRouting"));
services.AddSingleton<IInternalTenantDbHeaderWriter, InternalTenantDbHeaderWriter>();
return services;
}
}

View File

@@ -0,0 +1,25 @@
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.MultiTenant.Resolvers;
public sealed class HeaderTenantResolver : ITenantResolver
{
private readonly TenantOptions _opt;
public HeaderTenantResolver(IOptions<TenantOptions> opt) => _opt = opt.Value;
public string? Resolve(HttpContext context)
{
if (context.Request.Headers.TryGetValue(_opt.HeaderName, out var value))
return value.ToString();
if (!string.IsNullOrWhiteSpace(_opt.DefaultTenantId))
return _opt.DefaultTenantId;
return null;
}
}

View File

@@ -0,0 +1,22 @@
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Context;
using Microsoft.Extensions.Configuration;
namespace ADS.Helpers.MultiTenant.Stores;
public sealed class ConfigurationTenantStore : ITenantStore
{
private readonly IConfiguration _cfg;
public ConfigurationTenantStore(IConfiguration cfg) => _cfg = cfg;
public Task<TenantContext?> GetAsync(string tenantKey)
{
var section = _cfg.GetSection($"Tenants:{tenantKey}");
if (!section.Exists()) return Task.FromResult<TenantContext?>(null);
var tenant = section.Get<TenantContext>() ?? new TenantContext();
tenant.TenantId = tenantKey; // importante
return Task.FromResult<TenantContext?>(tenant);
}
}