Multi tenant – określenie tenanta

Wprowadzenie

Trzy tygodnie temu opublikowałem pierwszy wpis z mini serii poświęconej tworzeniu aplikacji multi tenant. Tamten wpis dotyczył wprowadzenie w temat, gdzie głównie skupiłem się na plusach i minusach tego rozwiązania. W tym natomiast zajmiemy się pierwszym problemem, który musimy rozwiązać, a jest nim sposób określenia, z jakim tenantem aktualnie mamy do czynienia.

Jak określić tenanta?

Jednym z pierwszych problemów, jaki musimy rozwiązać, jest sposób określenia tenanta. Skąd będziemy wiedzieli, że aktualnie procesowane żądanie jest realizowane w ramach tej, a nie innej organizacji?

Jest kilka sposobów, aby to zrealizować. Jednym z najczęściej używanych podejść jest bazowanie na adresie url, w którym zawarta jest nazwa tenanta. Może to być na przykład subdomena:

lub wykorzystanie pierwszej sekcji w adresie url tuż po domenie:

W obu przypadkach już po samym adresie url widać, z jakim tenantem pracujemy.

Innym podejściem jest zapisanie tenanta w ramach tokenu w formie claima. Tak jak to widać na zrzucie poniżej, gdzie w tokenie JWT znajduje się nazwa tenanta (może to być również i jego id):

Nazwa tenanta w tokenie JWT

Pierwsze podejście bardzo często wykorzystuję w momencie migracji z aplikacji single tenant do multi tenant. W połączeniu z podejściem dedykowanej bazy danych dla tenanta, taka migracja nie musi być czymś czasochłonnym i problematycznym, co pokażę w kolejnych wpisach z tej mini serii.

Drugie podejście z zapisaniem tenanta w tokenie wykorzystuje się, gdy użytkownik może korzystać z wielu tenantów. Wtedy na ogół po zalogowaniu się z listy jego tenantów może wybrać tenanta, w ramach którego chce aktualnie pracować. Wtedy generuje token z odpowiednio ustawionym tenantem.

Oczywiście w zależności od potrzeb możesz wykorzystać jedno lub drugie podejście.

Z subdomenami prawdopodobnie pojawi się jeszcze dodatkowy element po strony infrastruktury, który będzie podpinał na przykład custom domains w App Service, aby żądania były odpowiednio przekazywane do aplikacji.

Nazwa tenant w adresie url

Implementację zaczniemy od podejścia, w którym nazwa tenanta będzie znajdować się w adresie url. W testowej aplikacji wykorzystam podejście, w którym pierwszy człon w adresie określa nazwę tenanta. Wybrałem to rozwiązanie, ponieważ nie wymaga ono wykonania dodatkowych czynności w systemie, aby można było je przetestować. W przypadku podejścia z subdomenami w celu przetestowania działania tego podejścia trzeba by na przykład w pliku hosts dodać testowe domeny, które wskazywałyby na localhost.

Podejście to widać w klasie Startup w konfiguracji routingu. W przykładzie znajdują się tam dwie reguły (poniżej tylko istotny fragment klasy Startup):

public class Startup
{
....
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
....
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "home",
pattern: "/",
defaults: new { controller = "Home", action = "Index" });
endpoints.MapControllerRoute(
name: "default",
pattern: "{tenant}/{controller=Tenants}/{action=Index}/{id?}");
endpoints.MapRazorPages();
});
}
}
view raw Startup.cs hosted with ❤ by GitHub

Pierwsza reguła jest dla strony głównej, która w przykładzie nie będzie powiązana z żadnym tenantem, a na niej będzie lista dostępnych tenantów, aby łatwo można było przeklikać aplikację.

Natomiast druga reguła jest zmodyfikowaną domyślną regułą, w której dodałem dodatkowy człon ({tenant}) na początku adresu url, określający nazwę tenant, więc adresy w testowej aplikacji będą wyglądać na przykład tak:

https://localhost:5001/tenant1/Products/Edit/d978431e-7e8e-45be-842a-24ff4a372997

Gdzie tenant1 to nazwa tenanta, a później już klasycznie nazwa kontrolera, nazwa akcji oraz identyfikator obiektu.

Do rozwiązywania nazw tenantów przygotowałem interfejs ITenantResolutionStrategy:

public interface ITenantResolutionStrategy
{
Task<string> GetTenantIdentifierAsync();
string GetTenantIdentifier();
}

Znajduje się tutaj tak naprawdę jedne metoda w dwóch wersjach synchronicznej i asynchronicznej. Natomiast implementacja interfejsu wygląda tak:

public class PathResolutionStrategy : ITenantResolutionStrategy
{
private readonly IHttpContextAccessor _httpContextAccessor;
public PathResolutionStrategy(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<string> GetTenantIdentifierAsync()
{
return Task.FromResult(GetTenantIdentifier());
}
public string GetTenantIdentifier()
{
if (_httpContextAccessor.HttpContext?.Request.RouteValues.ContainsKey("tenant") == false)
{
return null;
}
return _httpContextAccessor.HttpContext?.Request.RouteValues["tenant"]?.ToString();
}
}

Wstrzykujemy tutaj IHttpContextAccessor, który posłuży do dostępu do aktualnego żądania. Sprawdzamy, czy w RouteValues znajduje się wartość pod kluczem tenant (czyli dodany wcześniej nowy człon w adresie url). Jeśli tak, to zwracamy ją, a przeciwnym razie zwracamy wartość null, które zostanie później odpowiednio obsłużona.

Przykład demo1 z repozytorium zawiera implementację tego podejścia. W kolejnych wpisach bardziej szczegółowo opiszę oba przygotowane przykłady.

Nazwa tenant w claims

W drugim przykładzie, który przygotowałem dla tej mini serii, jest rozwiązanie, gdzie tym razem identyfikator tenanta znajduje się w tokenie w formie claima. W przypadku adresu url lepiej było wykorzystać nazwę tenanta, natomiast w przypadku tokenu można już przechować sam identyfikator.

Tutaj również znajduje się interfejs ITenantResolutionStrategy, a jego implementacja wygląda tak:

public class ClaimResolutionStrategy : ITenantResolutionStrategy
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ClaimResolutionStrategy(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Task<Guid> GetTenantIdentifierAsync()
{
return Task.FromResult(GetTenantIdentifier());
}
public Guid GetTenantIdentifier()
{
if (_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true)
{
return _httpContextAccessor.HttpContext.User.Identity.GetTenant();
}
return Guid.Empty;
}
}

Podobnie jak wcześniej wstrzykujemy tutaj IHttpContextAccessor, ale tym razem skorzystamy z właściwości User, która określa aktualnie zalogowanego użytkownika. Gdy użytkownik nie jest zalogowany, to oznacza, że nie działamy w ramach konkretnego tenanta i zwracamy pustego guida (tak jak pisałem wyżej, tutaj pracujemy na identyfikatorze tenanta). Natomiast gdy użytkownik jest zalogowany, to korzystamy z extension method o nazwie GetTenant wywołanej na właściwości Identity o typie IIdentity. Metodę tę możemy wykorzystać później w innych częściach aplikacji.

Sama metoda wygląda tak:

public static class IIdentityExtensions
{
public static Guid GetTenant(this IIdentity identity)
{
var claimIdentity = identity as ClaimsIdentity;
var userTenant = claimIdentity?.FindFirst("tenant")?.Value;
if (userTenant == null)
{
return Guid.Empty;
}
return Guid.Parse(userTenant);
}
}

Rzutujemy tutaj identity na ClaimsIdentity, a następnie szukamy claima o nazwie tenant. Jeśli istnieje, to zwracamy jego wartość jako guid, a jeśli nie, to ponownie zwracamy pustego guida, który później zostanie odpowiednio obsłużony.

Aby ta funkcjonalność działała poprawnie, musimy jeszcze dodawać identyfikator tenanta do tokenu podczas logowania użytkownika. W przykładzie wygenerowałem domyślne widoki dla obsługi uwierzytelniania i zmodyfikowałem kod widoku Login. W przykładzie szukam pierwszego tenanta przypisanego do użytkownika. Jak pisałem wyżej, można tutaj dodać jakiś dodatkowy widok, gdzie użytkownik ma możliwość wyboru tenanta, w ramach którego chce aktualnie pracować.

Zmodyfikowany fragment pliku Login.cshtml.cs wygląda tak:

public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
var user = await _userManager.FindByNameAsync(Input.Email);
if (user != null)
{
if (await _userManager.CheckPasswordAsync(user, Input.Password))
{
var claims = new List<Claim>();
var tenant = user.Tenants.FirstOrDefault();
if (tenant != null)
{
claims.Add(new Claim("tenant", tenant.Id.ToString()));
}
await _signInManager.SignInWithClaimsAsync(user, true, claims);
_logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
}
...
}

Po weryfikacji loginu i hasła przygotowuję listę dodatkowych claimów (tutaj tylko identyfikator pierwszego przypisanego tenanta), a następnie przekazuję tę listę do metody SignInWithClaimsAsync, który wygeneruje odpowiednio token z dodatkowym claimem. Cały plik jest dostępny w repozytorium – https://github.com/danielplawgo/MultiTenant/blob/main/demo2/MultiTenantDemo2/MultiTenantDemo2/Areas/Identity/Pages/Account/Login.cshtml.cs

Przykłady

W repozytorium https://github.com/danielplawgo/MultiTenant znajdują się oba przykłady przygotowane na potrzeby mini serii wpisów o aplikacji multi tenant. Same przykłady będą się jeszcze zmieniały wraz z rozwojem pracy nad tą mini serią.

Podczas ostatniej migracji systemu single tenant do multi tenant wykorzystałem część rozwiązań z wpisów https://michael-mckenna.com/multi-tenant-asp-dot-net-core-application-tenant-resolution, więc pojawiać się będą one również i w moim przykładach. Zachęcam do zapoznania się również i z tymi wpisami.

Podsumowanie

Jednym z problemów, które musimy rozwiązać podczas pracy nad aplikacją multi tenant, jest sposób określenia tenanta, w ramach którego będziemy obsługiwali żądanie. W tym wpisie pokazałem dwa najczęściej wykorzystywane podejścia: nazwa tenanta w adresie url oraz jego identyfikator w tokenie.

W kolejnych wpisach zajmiemy się sposobami przechowywana danych w aplikacji multi tenant.

Szkolenie modularny monolit w .NET 5

Szkolenie modularny monolit w .NET 5

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie o modularnym monolicie w .NET.

4 thoughts on “Multi tenant – określenie tenanta

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.