Bezpieczeństwo danych: aplikacja multi tenant .Net Core api
W naszym projekcie jednym z wymagań biznesowych było zapisywanie i trzymanie danych klientów w oddzielnych bazach danych. Potrzebowaliśmy oddzielić je ze względu na integralność danych i bezpieczeństwo. Jak poradziliśmy sobie z tym problemem? Wykorzystując architekturę multi-tenant.
Marcin Czajński. Lead developer w KPMG. Programista od ponad 10 lat. Zaczął od front-end-u i z biegiem lat swoje zainteresowanie przenosił na kolejne obszary IT, bazy danych, technologie backend-owe (głównie Microsoft). Trwa to nadal i w tym momencie skupia się na rozwiązaniach chmurowych – Azure. W firmie KPMG ma kilka ról. Główna to Lead developer dla grupy zdolnych juniorów. Dodatkowo Scrum Master, Architect i na końcu deweloper.
Takie podejście już wielokrotnie było opisywane w Internecie, np. na stronie Microsoftu. Microsoft dostarcza bibliotekę właśnie do takiego rozwiązania. Niestety dokumentacja nie jest przejrzysta i wymaga przejścia przez wiele przykładów. Mimo wszystko warto się z nią zapoznać.
Po analizie kodu i dokumentacji, zrobieniu kilku testowych koncepcji, zdecydowaliśmy się na napisanie tej funkcjonalności od nowa. Jak się okazało nie było to trudne i czasochłonne, z niewielką ilością nowego kodu. Efekt zamierzony został osiągnięty – bezpieczne dane, które nie mieszają się w ramach jednej aplikacji u kilku klientów.
Spis treści
.Net Core api i jedna baza danych
.Net Core api i kilka dedykowanych baz danych
Jedna aplikacja .net core api i kilka baz danych per klient.
Implementacja
Zgodnie z tym tutorialem dodajemy db context
do naszego projektu. Nasza metoda ConfigureServices
w klasie Startup
wygląda teraz następująco:
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<TodoContext>(options => { options.UseSqlServer( Configuration.GetConnectionString("TodoContext"), provideOptions => provideOptions.EnableRetryOnFailure() ); }); services.AddControllers(); }
Jeśli najedziesz wskaźnikiem myszy na metodę AddDbContext
wyświetli się następująca podpowiedź:
Według informacji, czas życia obiektu db context
jest wskazany w scope. Co to znaczy dla nas? Tworzymy db context
obiekt dla każdego zapytania http. Możesz więcej o tym poczytać tutaj. W tym miejscu wprowadzimy potrzebne dla nas zmiany.
Zmodyfikujmy następująco powyższy kod:
public void ConfigureServices(IServiceCollection services) { services.AddScoped(s => { DbContextOptions<TodoContext> dbContextOptions = new DbContextOptionsBuilder<TodoContext>() .UseSqlServer( Configuration.GetConnectionString("TodoContext"), provideOptions => provideOptions.EnableRetryOnFailure()) .Options; return new TodoContext(dbContextOptions); }); services.AddControllers(); }
Jest to takie samo podejście napisane inaczej. Jak widzisz jest użyta metoda AddScoped
, która akceptuje funkcje. Dzięki temu mamy więcej kontroli nad tym, co się tutaj dzieje.
DbContext
akceptuje obiekt DbContextOptions
, gdzie dostarczamy połączenie do bazy danych pobierane z konfiguracji, pliku app settings.json
. Wiedząc to możemy dynamicznie to połączenie zmieniać w zależności od tego, co znajdziemy w zapytaniu http, np. w nagłówku.
Nasz plik konfiguracyjny może wyglądać następująco:
{ "ConnectionStrings": { "TodoContext1": "Server=(localdb)\mssqllocaldb;Database=sqldb-tenant-1;Trusted_Connection=True;MultipleActiveResultSets=true;", "TodoContext2": "Server=(localdb)\mssqllocaldb;Database=sqldb-tenant-2;Trusted_Connection=True;MultipleActiveResultSets=true;", "TodoContext3": "Server=(localdb)\mssqllocaldb;Database=sqldb-tenant-3;Trusted_Connection=True;MultipleActiveResultSets=true;" } }
Teraz mamy zdefiniowane trzy połączenia do bazy danych, dla każdego klienta. Dodajmy klasę TodoContextOptionsBuilder
, która dziedziczy po interfejsie ITodoContextOptionsBuilder
.
public interface ITodoContextOptionsBuilder { DbContextOptions<TodoContext> CurrentContextOptions { get; } } public class TodoContextOptionsBuilder : ITodoContextOptionsBuilder { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IConfiguration _configuration; public TodoContextOptionsBuilder(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) { _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); } public DbContextOptions<TodoContext> CurrentContextOptions { get { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) { return null; } string tenantStr = httpContext.Request.Headers["Tenant-Id"]; return new DbContextOptionsBuilder<TodoContext>() .UseSqlServer( _configuration.GetConnectionString("TodoContext" + tenantStr), provideOptions => provideOptions.EnableRetryOnFailure()) .Options; } } }
Interfejs ITodoContextOptionsBuilder
ma tylko jedną właściwość do zaimplementowania. Kod znajdziemy w klasie TodoContextOptionsBuilder
. Ta klasa zwraca opcje połączenia bazodanowego w zależności od informacji jakie mamy w nagłówku Tenant-Id
zapytania http.
Na podstawie nagłówka Tenant-Id
możemy znaleźć odpowiednie połączenie do bazy danych w pliku konfiguracyjnym.
Metoda ConfigurationService
w klasie Startup
wygląda teraz następująco:
public void ConfigureServices(IServiceCollection services) { services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddScoped<ITodoContextOptionsBuilder, TodoContextOptionsBuilder>(); services.AddScoped(serviceProvider => { var todoConnectionStringBuilder = serviceProvider.GetService<ITodoContextOptionsBuilder>(); return new TodoContext(todoConnectionStringBuilder.CurrentContextOptions); }); services.AddControllers(); }
Jak widzisz obiekt typu TodoContextOptionsBuilder
jest również tworzony jako scoped.
Podsumowanie
Jak widać, powyższy sposób przyniósł pożądany przez nas efekt. Dla przypomnienia: było nim zapisywanie i trzymanie danych klientów w oddzielnych bazach danych. Co można zrobić więcej?
- Warto utworzyć warstwę pośrednią, aby sprawdzać czy zapytanie posiada odpowiedni nagłówek i zwracać wiadomość do użytkownika.
- Sprawdzać, czy mamy połączenie do bazy danych dla podanego nagłówka.
- Jeśli chcemy posiadać bazę danych Catalog, która trzyma informacje o klientach, warto użyć cache.
- Jeśli korzystasz z Azure SQL Database przydatnym rozwiązaniem będzie elastic pools.
https://docs.microsoft.com/en-us/azure/azure-sql/database/elastic-pool-overview.
Wszystkie obrazki użyte (ewentualnie lekko zmodyfikowane) w tym artykule pochodzą z tej strony.
Zdjęcie główne artykułu pochodzi z unsplash.com.