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):
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(); | |
}); | |
} | |
} |
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.
Porque no los dos?
Trzymanie Id tenanta w adresie ma wiele zalet: Może się zdarzyć, że klient będzie chciał udostępnić jakieś zasoby niezalogowanym użytkownikom
Pozwala też przekazywać linki między użytkownikami, bez fragmentu z Tenantem istnieje ryzyko że odbiorca pracuje w kontekście innego tenanta i zobaczy inny dokument niż chciałby nadawca.
Z drugiej strony token powinien zawierać listę tenantów tak żeby uniknąć ryzyka że nieuprawniony użytkownik zobaczy treść dla niego nie przeznaczony.
Oczywiście można wykorzystać oba podejście na raz. Wszystko tak naprawdę zależy od tego jakie sytuacje mamy w aplikacji.