Bezpieczeństwo

Bezpieczeństwo danych: aplikacja multi tenant .Net Core api

integralność danych dzięki dedykowanym bazom danych w aplikacji .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.

.Net Core api i jedna baza danych

.net core api z jedną bazą danych

.Net Core api i kilka dedykowanych baz danych

Jedna aplikacja .net core api i kilka baz danych per klient.

.net core api i kilka baz danych

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ź:

integralność danych - AddDbContext w architekturze multi-tenant

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?

  1. Warto utworzyć warstwę pośrednią, aby sprawdzać czy zapytanie posiada odpowiedni nagłówek i zwracać wiadomość do użytkownika.
  2. Sprawdzać, czy mamy połączenie do bazy danych dla podanego nagłówka.
  3. Jeśli chcemy posiadać bazę danych Catalog, która trzyma informacje o klientach, warto użyć cache.
  4. 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.

baner

Wszystkie obrazki użyte (ewentualnie lekko zmodyfikowane) w tym artykule pochodzą z tej strony.

Zdjęcie główne artykułu pochodzi z unsplash.com.

 

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/bezpieczenstwo-danych-aplikacja-multi-tenant-net-core-api" order_type="social" width="100%" count_of_comments="8" ]