Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to customise configuration binding in ASP.NET Core

I have a multi-tenant ASP.NET Core web application. The current tenancy model is every tenant has a separate web app and SQL database. I'm trying to rearchitect it so that multiple tenants will be served by a single web app (but maintaining a separate database per tenant). I've been following this series of blog posts but I've hit a bit of a roadblock with configuration.

The app makes heavy use of the ASP.NET Core configuration system, and has a custom EF Core provider that fetches config values from the database. I'd like to preserve this if possible, it would be an awful lot of work to rip out and replace with something else (dozens of config settings used in hundreds of places).

The existing code is very standard:

public class MyAppSettings
{
    public string FavouriteColour { get; set; }
    public int LuckyNumber { get; set; }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions();
        services.Configure<MyAppSettings>(Configuration.GetSection("MyAppSettings"));
        // etc....
    }
}

// custom EF Core config provider wired up in Program.Main, but that doesn't actually seem relevant

I've already updated our custom provider so that it fetches all configuration values from all known tenant databases, and adds them all to the configuration system, prefixed with a tenant identifier, so the list of all config values fetched from the n different databases might look something like this:

Key                                       Value
===============================================
TenantABC:MyAppSettings:FavouriteColour   Green
TenantABC:MyAppSettings:LuckyNumber       42
TenantDEF:MyAppsettings:FavouriteColour   Blue
TenantDEF:MyAppSettings:LuckyNumber       37
...
TenantXYZ:MyAppSettings:FavouriteColour   Yellow
TenantXYZ:MyAppSettings:LuckyNumber       88

What I'd like to be able to do is somehow customise the way that the configuration is bound so that it resolves the tenant for the current request, and then uses the appropriate values, e.g. a request on abc.myapp.com would observe config values "Green" and "42", etc, without having to change all the dependent places that inject IOptionsMonitor<AppSettings> (or IOptionsSnapshot, etc). The linked blog series has a post about configuration that covers some gotchas that I expect I'll eventually run into around caching etc, but it doesn't seem to cater for this scenario of using completely different settings for different tenants. Conceptually it seems simple enough, but I haven't been able to find the correct place to hook in. Please help!

like image 376
Jon Avatar asked Jul 06 '20 17:07

Jon


2 Answers

Here is an idea (not tested yet, however). You can save the default IConfiguration instance passed to the constructor of your Startup class and then register in DI your own implementation of IConfiguration that will use that default one and HttpContextAccessor (to get the current tenant).

So the code will look something like:


public class Startup 
{
    private IConfiguration _defaultConfig;

    public Startup(IConfiguration configuration, IWebHostEnvironment env)
    {
        _defaultConfig = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        .   .   .   .
        services.AddScoped<IConfiguration>(serviceProvider => {
            var httpContextAccessor = 
                  serviceProvider.GetService<IHttpContextAccessor>();
            return new MyConfig(_defaultConfig, httpContextAccessor);
        });
    }

    .   .   .   .
}

public class MyConfig : IConfiguration
{
    private readonly IConfiguration _defaultConfig;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public MyConfig(IConfiguration defaultConfig, IHttpContextAccessor httpContextAccessor) 
    {
        _defaultConfig = defaultConfig;
        _httpContextAccessor = httpContextAccessor;
    }

    public string this[string key] {
        get {
            var tenantId = GetTenantId();
            return _defaultConfig[tenantId + ":" + key];
        }
        set {
            var tenantId = GetTenantId();
            _defaultConfig[tenantId + ":" + key] = value;
        }
    }

    protected virtual string GetTenantId()
    { 
        //this is just an example that supposes that you have "TenantId" claim associated with each user
        return _httpContextAccessor.HttpContext.User.FindFirst("TenantId").Value; ;
    }

    public IEnumerable<IConfigurationSection> GetChildren()
    {
        return _defaultConfig.GetChildren();
    }

    public IChangeToken GetReloadToken()
    {
        return _defaultConfig.GetReloadToken();
    }

    public IConfigurationSection GetSection(string key)
    {
        var tenantId = GetTenantId();
        return _defaultConfig.GetSection(tenantId + ":" + key);
    }
}
like image 156
Sergiy Avatar answered Oct 21 '22 20:10

Sergiy


Here are 3 solutions that may be helpful. I don't recommend you the IOptionsMonitor<T> because the tenant value is extracted from HttpContext, makes no sense to use the IOptionsMonitor.

Shared code:

public static class Extensions
{
    public static string GetTenantName(this HttpContext context)
    {
        switch (context.Request.Host.Host)
        {
            case "abc.localhost.com":
                return "TenantABC";
            case "def.localhost.com":
                return "TenantDEF";
            default:
                throw new IndexOutOfRangeException("Invalid host requested");
        }
    }

    public static MyAppSettings GetAppSettingsByTenant(this IConfiguration config, string tenant)
    {
        return new MyAppSettings
        {
            LuckyNumber = int.Parse(config[$"{tenant}:MyAppSettings:LuckyNumber"]),
            FavouriteColour = config[$"{tenant}:MyAppSettings:FavouriteColour"]
        };
    }
}

Solution 1: Scoped MyAppSettings object.

Registration (Startup->ConfigureServices(IServiceCollection)`

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddScoped(sp =>
    {
        var contextAccessor = sp.GetService<IHttpContextAccessor>();
        var config = sp.GetService<IConfiguration>();
        return config.GetAppSettingsByTenant(contextAccessor.HttpContext.GetTenantName());
    });
...

Usage:

public class TestController : Controller
{
    private readonly MyAppSettings _settings;

    public TestController(MyAppSettings settings)
    {
        _settings = settings;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return Json(_settings);
    }
}

Solution 2: IOptions<MyAppSettings

Registration (Startup->ConfigureServices(IServiceCollection)`

public class MyAppSettingsOptions : IOptions<MyAppSettings>
{
    public MyAppSettingsOptions(IConfiguration configuration, IHttpContextAccessor contextAccessor)
    {
        var tenant = contextAccessor.HttpContext.GetTenantName();
        Value = configuration.GetAppSettingsByTenant(tenant);
    }

    public MyAppSettings Value { get; }
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddScoped<IOptions<MyAppSettings>, MyAppSettingsOptions>();
...

Usage

public class TestController : Controller
{
    private readonly IOptions<MyAppSettings> _options;
    public TestController(IOptions<MyAppSettings> options)
    {
        _options = options;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return Json(_options.Value);
    }
}

Solution 3: IOptionsMonitor<MyAppSettings

Registration (Startup->ConfigureServices(IServiceCollection)`

public class MyAppSettingsOptionsMonitor : IOptionsMonitor<MyAppSettings>
{
    public MyAppSettingsOptionsMonitor(IConfiguration configuration, IHttpContextAccessor contextAccessor)
    {
        var tenant = contextAccessor.HttpContext.GetTenantName();
        CurrentValue = configuration.GetAppSettingsByTenant(tenant);
    }

    public MyAppSettings Get(string name)
    {
        throw new NotSupportedException();
    }

    public IDisposable OnChange(Action<MyAppSettings, string> listener)
    {
        return null;
    }

    public MyAppSettings CurrentValue { get; }
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddScoped<IOptionsMonitor<MyAppSettings>, MyAppSettingsOptionsMonitor>();
...

Usage

public class TestController : Controller
{
    private readonly IOptionsMonitor<MyAppSettings> _options;
    public TestController(IOptionsMonitor<MyAppSettings> options)
    {
        _options = options;
    }

    [HttpGet]
    public IActionResult Index()
    {
        return Json(_options.CurrentValue);
    }
}
like image 29
dbvega Avatar answered Oct 21 '22 20:10

dbvega