Udostępnij za pośrednictwem


Aplikacje wielodostępne z elastycznymi narzędziami bazy danych i zabezpieczeniami na poziomie wiersza

Dotyczy: Azure SQL Database

Elastyczne narzędzia bazy danych i zabezpieczenia na poziomie wiersza współpracują, aby umożliwić skalowanie warstwy danych aplikacji wielodostępnej za pomocą usługi Azure SQL Database. Razem te technologie ułatwiają tworzenie aplikacji, która ma wysoce skalowalną warstwę danych. Warstwa danych obsługuje fragmenty z wieloma dzierżawami i używa ADO.NET SqlClient lub Entity Framework. Aby uzyskać więcej informacji, zobacz Design Patterns for Multi-tenant SaaS Applications with Azure SQL Database (Wzorce projektowe dla aplikacji SaaS z wieloma dzierżawami w usłudze Azure SQL Database).

  • Elastyczne narzędzia bazy danych umożliwiają deweloperom skalowanie w poziomie warstwy danych przy użyciu standardowych praktyk fragmentowania przy użyciu bibliotek platformy .NET i szablonów usług platformy Azure. Zarządzanie fragmentami przy użyciu biblioteki klienta elastycznej bazy danych pomaga zautomatyzować i usprawnić wiele zadań infrastrukturalnych zwykle skojarzonych z fragmentowaniem.
  • Zabezpieczenia na poziomie wiersza umożliwiają deweloperom bezpieczne przechowywanie danych dla wielu dzierżaw w tej samej bazie danych. Zasady zabezpieczeń zabezpieczeń na poziomie wiersza filtrują wiersze, które nie należą do dzierżawy wykonującej zapytanie. Scentralizowanie logiki filtrowania wewnątrz bazy danych upraszcza konserwację i zmniejsza ryzyko wystąpienia błędu zabezpieczeń. Alternatywa polegania na całym kodzie klienta w celu wymuszania zabezpieczeń jest ryzykowna.

Dzięki tym funkcjom aplikacja może przechowywać dane dla wielu dzierżaw w tej samej bazie danych fragmentów. Kosztuje to mniej na dzierżawę, gdy dzierżawcy współużytkuje bazę danych. Jednak ta sama aplikacja może również oferować dzierżawcom premium możliwość płacenia za własny dedykowany fragment z jedną dzierżawą. Jedną z zalet izolacji pojedynczej dzierżawy jest gwarancji wydajności przedsiębiorstwa. W bazie danych z jedną dzierżawą nie ma innej dzierżawy konkurującej o zasoby.

Celem jest użycie elastycznych interfejsów API routingu zależnych od danych biblioteki klienta bazy danych w celu automatycznego łączenia każdej danej dzierżawy z poprawną bazą danych fragmentów. Tylko jeden fragment zawiera określoną wartość TenantId dla danej dzierżawy. TenantId to klucz fragmentowania. Po nawiązaniu połączenia zasady zabezpieczeń zabezpieczeń na poziomie wiersza w bazie danych zapewniają, że dana dzierżawa będzie mogła uzyskać dostęp tylko do tych wierszy danych, które zawierają jego identyfikator TenantId.

Uwaga

Identyfikator dzierżawy może zawierać więcej niż jedną kolumnę. Dla wygody jest to dyskusja, nieformalnie przyjmujemy jednokolumny identyfikator TenantId.

Architektura aplikacji rejestrowania

Pobieranie przykładowego projektu

Wymagania wstępne

Ten projekt rozszerza ten opisany w artykule Elastic DB Tools for Azure SQL — Entity Framework Integration (Narzędzia elastic DB tools for Azure SQL — Entity Framework Integration ), dodając obsługę wielodostępnych baz danych fragmentów. Projekt tworzy prostą aplikację konsolową do tworzenia blogów i wpisów. Projekt obejmuje cztery dzierżawy, a także dwie wielodostępne bazy danych fragmentów. Ta konfiguracja jest pokazana na powyższym diagramie.

Skompiluj i uruchom aplikację. Ten przebieg uruchamia menedżera map fragmentów narzędzi elastycznej bazy danych i wykonuje następujące testy:

  1. Za pomocą programu Entity Framework i LINQ utwórz nowy blog, a następnie wyświetl wszystkie blogi dla każdej dzierżawy
  2. Za pomocą ADO.NET SqlClient wyświetl wszystkie blogi dla dzierżawy
  3. Spróbuj wstawić blog dla nieprawidłowej dzierżawy, aby sprawdzić, czy został zgłoszony błąd

Zwróć uwagę, że ponieważ zabezpieczenia na poziomie wiersza nie zostały jeszcze włączone w bazach danych fragmentów, każdy z tych testów ujawnia problem: dzierżawy mogą zobaczyć blogi, które nie należą do nich, a aplikacja nie może wstawić bloga dla niewłaściwej dzierżawy. W pozostałej części tego artykułu opisano sposób rozwiązywania tych problemów przez wymuszanie izolacji dzierżawy z zabezpieczeniami na poziomie wiersza. Istnieją dwa kroki:

  1. Warstwa aplikacji: zmodyfikuj kod aplikacji, aby zawsze ustawić bieżący identyfikator TenantId w SESSION_CONTEXT po otwarciu połączenia. Przykładowy projekt ustawia już identyfikator TenantId w ten sposób.
  2. Warstwa danych: utwórz zasady zabezpieczeń zabezpieczeń na poziomie wiersza w każdej bazie danych fragmentów, aby filtrować wiersze na podstawie identyfikatora TenantId przechowywanego w SESSION_CONTEXT. Utwórz zasady dla każdej z baz danych fragmentów. W przeciwnym razie wiersze w fragmentach z wieloma dzierżawami nie są filtrowane.

1. Warstwa aplikacji: ustaw wartość TenantId w SESSION_CONTEXT

Najpierw połączysz się z bazą danych fragmentów przy użyciu interfejsów API routingu zależnego od danych biblioteki klienta elastycznej bazy danych. Aplikacja nadal musi poinformować bazę danych, której identyfikatora TenantId używa połączenia. TenantId informuje zasady zabezpieczeń zabezpieczeń na poziomie wiersza, które wiersze muszą być odfiltrowane jako należące do innych dzierżaw. Zapisz bieżący identyfikator TenantId w SESSION_CONTEXT połączenia.

Alternatywą dla SESSION_CONTEXT jest użycie CONTEXT_INFO. Ale SESSION_CONTEXT jest lepszą opcją. SESSION_CONTEXT jest łatwiejszy w użyciu, domyślnie zwraca wartość NULL i obsługuje pary klucz-wartość.

Entity Framework

W przypadku aplikacji korzystających z programu Entity Framework najprostszym rozwiązaniem jest ustawienie SESSION_CONTEXT w ramach zastąpienia ElasticScaleContext opisanego w artykule Routing zależny od danych przy użyciu interfejsu EF DbContext. Utwórz i wykonaj polecenie SqlCommand, które ustawia identyfikator TenantId w SESSION_CONTEXT na klucz fragmentowania określony dla połączenia. Następnie zwróć połączenie obsługiwane przez routing zależny od danych. W ten sposób wystarczy napisać kod tylko raz, aby ustawić SESSION_CONTEXT.

// ElasticScaleContext.cs
// Constructor for data-dependent routing.
// This call opens a validated connection that is routed to the
// proper shard by the shard map manager.
// Note that the base class constructor call fails for an open connection
// if migrations need to be done and SQL credentials are used.
// This is the reason for the separation of constructors.
// ...
public ElasticScaleContext(ShardMap shardMap, T shardingKey, string connectionStr)
    : base(
        OpenDDRConnection(shardMap, shardingKey, connectionStr),
        true)  // contextOwnsConnection
{
}

public static SqlConnection OpenDDRConnection(
    ShardMap shardMap,
    T shardingKey,
    string connectionStr)
{
    // No initialization.
    Database.SetInitializer<ElasticScaleContext<T>>(null);

    // Ask shard map to broker a validated connection for the given key.
    SqlConnection conn = null;
    try
    {
        conn = shardMap.OpenConnectionForKey(
            shardingKey,
            connectionStr,
            ConnectionOptions.Validate);

        // Set TenantId in SESSION_CONTEXT to shardingKey
        // to enable Row-Level Security filtering.
        SqlCommand cmd = conn.CreateCommand();
        cmd.CommandText =
            @"exec sp_set_session_context
                @key=N'TenantId', @value=@shardingKey";
        cmd.Parameters.AddWithValue("@shardingKey", shardingKey);
        cmd.ExecuteNonQuery();

        return conn;
    }
    catch (Exception)
    {
        if (conn != null)
        {
            conn.Dispose();
        }
        throw;
    }
}
// ...

Teraz SESSION_CONTEXT jest automatycznie ustawiana za pomocą określonego identyfikatora TenantId za każdym razem, gdy jest wywoływany element ElasticScaleContext:

// Program.cs
SqlDatabaseUtils.SqlRetryPolicy.ExecuteAction(() =>
{
    using (var db = new ElasticScaleContext<int>(
        sharding.ShardMap, tenantId, connStrBldr.ConnectionString))
    {
        var query = from b in db.Blogs
                    orderby b.Name
                    select b;

        Console.WriteLine("All blogs for TenantId {0}:", tenantId);
        foreach (var item in query)
        {
            Console.WriteLine(item.Name);
        }
    }
});

ADO.NET SqlClient

W przypadku aplikacji używających ADO.NET SqlClient utwórz funkcję otoki wokół metody ShardMap.OpenConnectionForKey. Aby otoka automatycznie ustawiła identyfikator TenantId w SESSION_CONTEXT na bieżący identyfikator TenantId przed zwróceniem połączenia. Aby upewnić się, że SESSION_CONTEXT jest zawsze ustawiona, należy otwierać połączenia tylko przy użyciu tej funkcji otoki.

// Program.cs
// Wrapper function for ShardMap.OpenConnectionForKey() that
// automatically sets SESSION_CONTEXT with the correct
// tenantId before returning a connection.
// As a best practice, you should only open connections using this method
// to ensure that SESSION_CONTEXT is always set before executing a query.
// ...
public static SqlConnection OpenConnectionForTenant(
    ShardMap shardMap, int tenantId, string connectionStr)
{
    SqlConnection conn = null;
    try
    {
        // Ask shard map to broker a validated connection for the given key.
        conn = shardMap.OpenConnectionForKey(
            tenantId, connectionStr, ConnectionOptions.Validate);

        // Set TenantId in SESSION_CONTEXT to shardingKey
        // to enable Row-Level Security filtering.
        SqlCommand cmd = conn.CreateCommand();
        cmd.CommandText =
            @"exec sp_set_session_context
                @key=N'TenantId', @value=@shardingKey";
        cmd.Parameters.AddWithValue("@shardingKey", tenantId);
        cmd.ExecuteNonQuery();

        return conn;
    }
    catch (Exception)
    {
        if (conn != null)
        {
            conn.Dispose();
        }
        throw;
    }
}

// ...

// Example query via ADO.NET SqlClient.
// If row-level security is enabled, only Tenant 4's blogs are listed.
SqlDatabaseUtils.SqlRetryPolicy.ExecuteAction(() =>
{
    using (SqlConnection conn = OpenConnectionForTenant(
        sharding.ShardMap, tenantId4, connStrBldr.ConnectionString))
    {
        SqlCommand cmd = conn.CreateCommand();
        cmd.CommandText = @"SELECT * FROM Blogs";

        Console.WriteLine(@"--
All blogs for TenantId {0} (using ADO.NET SqlClient):", tenantId4);

        SqlDataReader reader = cmd.ExecuteReader();
        while (reader.Read())
        {
            Console.WriteLine("{0}", reader["Name"]);
        }
    }
});

2. Warstwa danych: Tworzenie zasad zabezpieczeń na poziomie wiersza

Tworzenie zasad zabezpieczeń w celu filtrowania wierszy, do których może uzyskać dostęp każda dzierżawa

Teraz, gdy aplikacja ustawia SESSION_CONTEXT z bieżącym identyfikatorem TenantId przed wykonaniem zapytania, zasady zabezpieczeń zabezpieczeń zabezpieczeń na poziomie wiersza mogą filtrować zapytania i wykluczać wiersze, które mają inny identyfikator TenantId.

Zabezpieczenia na poziomie wiersza są implementowane w języku Transact-SQL. Funkcja zdefiniowana przez użytkownika definiuje logikę dostępu, a zasady zabezpieczeń wiążą tę funkcję z dowolną liczbą tabel. Dla tego projektu:

  1. Funkcja sprawdza, czy aplikacja jest połączona z bazą danych, a identyfikator TenantId przechowywany w SESSION_CONTEXT jest zgodny z identyfikatorem TenantId danego wiersza.

    • Aplikacja jest połączona, a nie inny użytkownik SQL.
  2. Predykat FILTER umożliwia wierszom spełniającym filtr TenantId przekazywanie dla zapytań SELECT, UPDATE i DELETE.

    • Predykat BLOKUJ uniemożliwia wiersze, które kończą się niepowodzeniem filtru inSERTed lub UPDATEd.
    • Jeśli SESSION_CONTEXT nie została ustawiona, funkcja zwraca wartość NULL i nie ma widocznych wierszy ani nie można ich wstawić.

Aby włączyć zabezpieczenia na poziomie wiersza we wszystkich fragmentach, wykonaj następujący kod T-SQL przy użyciu programu Visual Studio (SSDT), programu SSMS lub skryptu programu PowerShell dołączonego do projektu. Jeśli używasz zadań elastycznej bazy danych, możesz zautomatyzować wykonywanie tego języka T-SQL na wszystkich fragmentach.

CREATE SCHEMA rls; -- Separate schema to organize RLS objects.
GO

CREATE FUNCTION rls.fn_tenantAccessPredicate(@TenantId int)
    RETURNS TABLE
    WITH SCHEMABINDING
AS
    RETURN SELECT 1 AS fn_accessResult
        -- Use the user in your application's connection string.
        -- Here we use 'dbo' only for demo purposes!
        WHERE DATABASE_PRINCIPAL_ID() = DATABASE_PRINCIPAL_ID('dbo')
        AND CAST(SESSION_CONTEXT(N'TenantId') AS int) = @TenantId;
GO

CREATE SECURITY POLICY rls.tenantAccessPolicy
    ADD FILTER PREDICATE rls.fn_tenantAccessPredicate(TenantId) ON dbo.Blogs,
    ADD BLOCK  PREDICATE rls.fn_tenantAccessPredicate(TenantId) ON dbo.Blogs,
    ADD FILTER PREDICATE rls.fn_tenantAccessPredicate(TenantId) ON dbo.Posts,
    ADD BLOCK  PREDICATE rls.fn_tenantAccessPredicate(TenantId) ON dbo.Posts;
GO

Napiwek

W złożonym projekcie może być konieczne dodanie predykatu dla setek tabel, co może być żmudne. Istnieje procedura składowana pomocnika, która automatycznie generuje zasady zabezpieczeń i dodaje predykat we wszystkich tabelach w schemacie. Aby uzyskać więcej informacji, zobacz wpis w blogu Stosowanie zabezpieczeń na poziomie wiersza do wszystkich tabel — skrypt pomocnika (blog).

Teraz, jeśli ponownie uruchomisz przykładową aplikację, dzierżawcy zobaczą tylko wiersze, które należą do nich. Ponadto aplikacja nie może wstawić wierszy należących do dzierżaw innych niż ta, która jest obecnie połączona z bazą danych fragmentów. Ponadto aplikacja nie może zaktualizować identyfikatora TenantId w żadnych widocznych wierszach. Jeśli aplikacja próbuje wykonać jedną z tych czynności, zostanie zgłoszony wyjątek DbUpdateException.

Jeśli później dodasz nową tabelę, zmień zasady zabezpieczeń, aby dodać predykaty FILTER i BLOCK w nowej tabeli.

ALTER SECURITY POLICY rls.tenantAccessPolicy
    ADD FILTER PREDICATE rls.fn_tenantAccessPredicate(TenantId) ON dbo.MyNewTable,
    ADD BLOCK  PREDICATE rls.fn_tenantAccessPredicate(TenantId) ON dbo.MyNewTable;
GO

Dodawanie domyślnych ograniczeń w celu automatycznego wypełniania identyfikatora TenantId dla inSERTs

Możesz umieścić domyślne ograniczenie dla każdej tabeli, aby automatycznie wypełnić identyfikator TenantId wartością przechowywaną w SESSION_CONTEXT podczas wstawiania wierszy. Poniżej przedstawiono przykład.

-- Create default constraints to auto-populate TenantId with the
-- value of SESSION_CONTEXT for inserts.
ALTER TABLE Blogs
    ADD CONSTRAINT df_TenantId_Blogs
    DEFAULT CAST(SESSION_CONTEXT(N'TenantId') AS int) FOR TenantId;
GO

ALTER TABLE Posts
    ADD CONSTRAINT df_TenantId_Posts
    DEFAULT CAST(SESSION_CONTEXT(N'TenantId') AS int) FOR TenantId;
GO

Teraz aplikacja nie musi określać identyfikatora TenantId podczas wstawiania wierszy:

SqlDatabaseUtils.SqlRetryPolicy.ExecuteAction(() =>
{
    using (var db = new ElasticScaleContext<int>(
        sharding.ShardMap, tenantId, connStrBldr.ConnectionString))
    {
        // The default constraint sets TenantId automatically!
        var blog = new Blog { Name = name };
        db.Blogs.Add(blog);
        db.SaveChanges();
    }
});

Uwaga

Jeśli używasz domyślnych ograniczeń dla projektu platformy Entity Framework, zaleca się, aby nie uwzględnić kolumny TenantId w modelu danych ef. To zalecenie jest spowodowane tym, że zapytania platformy Entity Framework automatycznie dostarczają wartości domyślne, które zastępują domyślne ograniczenia utworzone w języku T-SQL, które używają SESSION_CONTEXT. Aby użyć domyślnych ograniczeń w przykładowym projekcie, na przykład należy usunąć identyfikator TenantId z DataClasses.cs (i uruchomić polecenie Add-Migration w konsoli Menedżer pakietów) i użyć języka T-SQL, aby upewnić się, że pole istnieje tylko w tabelach bazy danych. Dzięki temu program EF automatycznie podaje niepoprawne wartości domyślne podczas wstawiania danych.

(Opcjonalnie) Włącz administratora, aby uzyskać dostęp do wszystkich wierszy

Niektóre aplikacje mogą chcieć utworzyć administratora , który może uzyskać dostęp do wszystkich wierszy. Superużytkownik może włączyć raportowanie we wszystkich dzierżawach we wszystkich fragmentach. Lub superużytkownik może wykonywać operacje dzielenia-scalania na fragmentach, które obejmują przenoszenie wierszy dzierżawy między bazami danych.

Aby włączyć administratora, utwórz nowego użytkownika SQL (superuser w tym przykładzie) w każdej bazie danych fragmentów. Następnie zmień zasady zabezpieczeń za pomocą nowej funkcji predykatu, która umożliwia temu użytkownikowi dostęp do wszystkich wierszy. Taka funkcja jest podawana w następnej kolejności.

-- New predicate function that adds superuser logic.
CREATE FUNCTION rls.fn_tenantAccessPredicateWithSuperUser(@TenantId int)
    RETURNS TABLE
    WITH SCHEMABINDING
AS
    RETURN SELECT 1 AS fn_accessResult
        WHERE
        (
            DATABASE_PRINCIPAL_ID() = DATABASE_PRINCIPAL_ID('dbo') -- Replace 'dbo'.
            AND CAST(SESSION_CONTEXT(N'TenantId') AS int) = @TenantId
        )
        OR
        (
            DATABASE_PRINCIPAL_ID() = DATABASE_PRINCIPAL_ID('superuser')
        );
GO

-- Atomically swap in the new predicate function on each table.
ALTER SECURITY POLICY rls.tenantAccessPolicy
    ALTER FILTER PREDICATE rls.fn_tenantAccessPredicateWithSuperUser(TenantId) ON dbo.Blogs,
    ALTER BLOCK  PREDICATE rls.fn_tenantAccessPredicateWithSuperUser(TenantId) ON dbo.Blogs,
    ALTER FILTER PREDICATE rls.fn_tenantAccessPredicateWithSuperUser(TenantId) ON dbo.Posts,
    ALTER BLOCK  PREDICATE rls.fn_tenantAccessPredicateWithSuperUser(TenantId) ON dbo.Posts;
GO

Konserwacja

  • Dodawanie nowych fragmentów: wykonaj skrypt języka T-SQL, aby włączyć zabezpieczenia na poziomie wiersza na wszystkich nowych fragmentach. W przeciwnym razie zapytania dotyczące tych fragmentów nie są filtrowane.
  • Dodawanie nowych tabel: dodawanie predykatu FILTER i BLOCK do zasad zabezpieczeń we wszystkich fragmentach przy każdym utworzeniu nowej tabeli. W przeciwnym razie zapytania dotyczące nowej tabeli nie są filtrowane. Ten dodatek można zautomatyzować przy użyciu wyzwalacza DDL, zgodnie z opisem w temacie Stosowanie zabezpieczeń na poziomie wiersza automatycznie do nowo utworzonych tabel (blog).

Podsumowanie

Elastyczne narzędzia bazy danych i zabezpieczenia na poziomie wiersza mogą być używane razem do skalowania warstwy danych aplikacji z obsługą zarówno fragmentów wielodostępnych, jak i pojedynczych dzierżaw. Fragmenty z wieloma dzierżawami mogą służyć do wydajniejszego przechowywania danych. Ta wydajność jest widoczna, gdy duża liczba dzierżaw ma tylko kilka wierszy danych. Fragmenty z jedną dzierżawą mogą obsługiwać dzierżawy w warstwie Premium, które mają bardziej rygorystyczne wymagania dotyczące wydajności i izolacji. Aby uzyskać więcej informacji, zobacz Dokumentacja zabezpieczeń na poziomie wiersza.

Dodatkowe zasoby

Pytania i żądania funkcji

W przypadku pytań skontaktuj się z nami na stronie pytań i odpowiedzi firmy Microsoft dla usługi SQL Database. Dodaj wszystkie żądania funkcji do forum opinii usługi SQL Database.