Applicazioni multi-tenant con strumenti di database elastici e sicurezza a livello di riga

Si applica a:database SQL di Azure

Gli strumenti di database elastici e la sicurezza a livello di riga cooperano per consentire il ridimensionamento del livello dati di un'applicazione multi-tenant con un database SQL di Azure. La combinazione di queste tecnologie consente di creare un'applicazione con un livello dati estremamente scalabile. Il livello dati supporta partizioni multi-tenant e usa ADO.NET SqlClient o Entity Framework. Per altre informazioni, vedere Schemi progettuali per applicazioni SaaS multi-tenant con il database SQL di Azure.

  • Gli strumenti di database elastici consentono agli sviluppatori di aumentare il numero di istanze per il livello dati tramite procedure di partizionamento orizzontale standard, usando librerie .NET e modelli di servizio di Azure. La gestione delle partizioni mediante la libreria client dei database elastici consente di automatizzare e semplificare molte delle attività infrastrutturali generalmente associate al partizionamento orizzontale.
  • La sicurezza a livello di riga consente agli sviluppatori di archiviare in modo sicuro i dati di più tenant nello stesso database. I criteri di sicurezza a livello di riga escludono le righe che non appartengono al tenant che esegue una query. La centralizzazione della logica di filtro all'interno del database semplifica inoltre la manutenzione e riduce il rischio di errori di sicurezza. L'alternativa di affidarsi esclusivamente a codice client per applicare la sicurezza è troppo rischiosa.

L'utilizzo combinato di queste funzionalità consente a un'applicazione di memorizzare i dati di più tenant nello stesso database di partizionamento. Se i tenant condividono uno stesso database, inoltre, il costo per tenant è inferiore. La stessa applicazione può anche offrire ai tenant "premium" la possibilità di pagare solo per la partizione a singolo tenant dedicata. L'isolamento dei singoli tenant garantisce inoltre prestazioni più stabili. In un database a singolo tenant, infine, non sono presenti altri tenant con cui competere per l'acquisizione delle risorse.

L'obiettivo consiste nell'usare le API di routing dipendente dai dati della libreria client dei database elastici per connettere automaticamente ogni tenant al database di partizione corretto. Solo una partizione contiene il valore TenantId relativo al tenant specificato. Il TenantId corrisponde alla chiave di partizionamento orizzontale. Dopo aver stabilito la connessione, i criteri di sicurezza a livello di riga all'interno del database garantiscono che il tenant specificato possa accedere solo alle righe di dati contenenti il relativo TenantId.

Nota

L'identificatore del tenant può essere costituito da più di una colonna. Per praticità, si presuppone in questo caso che il TenantId sia costituito da una sola colonna.

Blogging app architecture

Scaricare il progetto di esempio

Prerequisiti

Questo progetto estende quello descritto in Strumenti di database elastici per SQL di Azure - Integrazione di Entity Framework aggiungendo il supporto per i database di partizionamento multi-tenant. Viene compilata una semplice applicazione console per la creazione di blog e post, con quattro tenant e due database di partizionamento multi-tenant. Questa configurazione è illustrata nel diagramma precedente.

Compilare ed eseguire l'applicazione. In questo modo, viene eseguito il bootstrap del manager mappa di partizionamento orizzontale degli strumenti di database elastico e vengono effettuati i test seguenti:

  1. Utilizzando Entity Framework e LINQ, creare un nuovo blog e quindi visualizzare tutti i blog per ciascun tenant
  2. Utilizzando ADO.NET SqlClient, visualizzare tutti i blog per un tenant
  3. Provare a inserire un blog per il tenant non corretto per verificare che venga generato un errore

Si noti che poiché RLS non è stata ancora abilitata nei database di partizionamento, ciascuno di questi test rivela un problema: i tenant sono in grado di visualizzare i blog non appartenenti ad essi, e all'applicazione non viene impedito di inserire un blog per il tenant errato. Nella parte restante di questo articolo viene descritto come risolvere questi problemi mediante l'isolamento dei tenant con RLS. Eseguire due passaggi:

  1. Livello applicazione: modificare il codice dell'applicazione per impostare sempre il TenantId corrente in SESSION_CONTEXT dopo l'apertura di una connessione. Il progetto di esempio imposta il TenantId in questo modo.
  2. Livello dati: creare un criterio di sicurezza RLS in ogni database di partizionamento per filtrare le righe in base al TenantId archiviato in SESSION_CONTEXT. Creare criteri per ogni database di partizionamento; in caso contrario, le righe nelle partizioni multi-tenant non verranno filtrate.

1. Livello applicazione: impostare il TenantId in SESSION_CONTEXT

Per prima cosa, connettersi a un database di partizionamento tramite le API di routing dipendente dai dati della libreria client dei database elastici. L'applicazione deve comunque indicare al database quale TenantId userà la connessione, mentre il TenantId indica ai criteri di sicurezza a livello di riga quali righe dovranno essere escluse in quanto appartenenti ad altri tenant. Archivia il TenantId corrente in SESSION_CONTEXT del collegamento.

In alternativa a SESSION_CONTEXT, è possibile usare CONTEXT_INFO, ma SESSION_CONTEXT è comunque preferibile. SESSION_CONTEXT è più facile da usare, restituisce NULL per impostazione predefinita e supporta coppie chiave-valore.

Entity Framework

Per le applicazioni che usano Entity Framework, l'approccio più semplice consiste nell'impostare SESSION_CONTEXT all'interno dell'override ElasticScaleContext descritto in Routing dipendente dai dati con DbContext di Entity Framework. Crea ed esegui un comando SqlCommand che imposta TenantId in SESSION_CONTEXT sull'oggetto shardingKey specificato per il collegamento. Restituire quindi la connessione negoziata tramite il routing dipendente dai dati. In questo modo, è sufficiente scrivere una sola volta il codice per impostare 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;
    }
}
// ...

Ora SESSION_CONTEXT viene impostato automaticamente sul TenantId specificato ogni volta che si richiama 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

Per le applicazioni che usano ADO.NET SqlClient, creare una funzione wrapper intorno al metodo ShardMap.OpenConnectionForKey e fai in modo che il wrapper imposti automaticamente il TenantId in SESSION_CONTEXT sul TenantId corrente prima di restituire un collegamento. Per garantire che SESSION_CONTEXT sia sempre impostato, è consigliabile aprire le connessioni usando solo questa funzione wrapper.

// 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. Livello dati: creare criteri di sicurezza a livello di riga

Creare i criteri di sicurezza per filtrare le righe accessibili a ogni tenant

Ora che l'applicazione imposta SESSION_CONTEXT sul TenantId corrente prima di eseguire la query, i criterio di sicurezza RLS possono filtrare le query ed escludere le righe che contengono un TenantId diverso.

La sicurezza a livello di riga viene implementata in Transact-SQL: una funzione definita dall'utente definisce la logica di accesso e i criteri di sicurezza associano questa funzione a un numero qualsiasi di tabelle. Per questo progetto,

  1. la funzione verifica che l'applicazione sia collegata al database e che il TenantId archiviato in SESSION_CONTEXT corrisponda al TenantId di una determinata riga.

    • In questo modo, viene connessa l'applicazione, non un altro utente SQL.
  2. Un predicato di FILTRO consente alle righe che soddisfano il filtro impostato dal TenantId di passare attraverso le query SELECT, UPDATE e DELETE.

    • Un predicato di BLOCCO impedisce l'inserimento e l'aggiornamento delle righe che non soddisfano le condizioni del filtro.
    • Se SESSION_CONTEXT non è stato impostato, verrà restituito NULL e non sarà possibile vedere o inserire alcuna riga.

Per abilitare la sicurezza a livello di riga su tutte le partizioni, eseguire l'istruzione T-SQL seguente usando Visual Studio (SSDT), SSMS o lo script di PowerShell incluso nel progetto. Se si usano processi di database elastici, è possibile automatizzare l'esecuzione di questa istruzione T-SQL in tutte le partizioni.

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

Suggerimento

In un progetto complesso in cui potrebbe essere necessario aggiungere il predicato in centinaia di tabelle, questa operazione può essere noiosa. In questo caso, è disponibile una stored procedure helper che genera automaticamente criteri di sicurezza e aggiunge un predicato in tutte le tabelle di uno schema. Per altre informazioni, vedere il posto di blog relativo all'applicazione della sicurezza a livello di riga a tutte le tabelle con lo script helper.

Se si esegue di nuovo l'applicazione di esempio, ora i tenant potranno visualizzare unicamente le righe appartenenti ad essi. L'applicazione, inoltre, non può inserire righe che appartengono a tenant diversi da quello attualmente connesso al database di partizionamento e non può aggiornare il TenantId in alcuna delle righe visibili. Se l'app tenta di effettuare una qualsiasi di queste due operazioni, viene generata un'eccezione DbUpdateException.

Se si aggiunge una nuova tabella in un secondo momento, MODIFICARE i criteri di sicurezza in modo da aggiungere predicati di FILTRO e di BLOCCO nella nuova tabella.

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

Aggiungere vincoli predefiniti per inserire automaticamente il TenantId per le operazioni INSERT

È possibile inserire un vincolo predefinito in ogni tabella per popolare automaticamente il TenantId con il valore attualmente archiviato in SESSION_CONTEXT durante l'inserimento di righe. Esempio:

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

A questo punto non è necessario che l'applicazione specifichi un TenantId durante l'inserimento di righe:

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();
    }
});

Nota

Se si usano i vincoli predefiniti per un progetto Entity Framework, è consigliabile NON includere la colonna TenantId nel modello di dati EF. Il motivo è che le query Entity Framework forniscono automaticamente valori predefiniti che sostituiranno i vincoli predefiniti creati in T-SQL che usano SESSION_CONTEXT. Per utilizzare i vincoli predefiniti nel progetto di esempio, ad esempio, è necessario rimuovere TenantId da DataClasses.cs (ed eseguire Add-Migration nella console di Gestione pacchetti) e utilizzare T-SQL per garantire che il campo esista unicamente nelle tabelle del database. In questo modo, Entity Framework non fornirà automaticamente valori predefiniti errati durante l'inserimento dei dati.

(Facoltativo) Abilitare un superuser per accedere a tutte le righe

È possibile che alcune applicazioni creino un superuser che possa accedere a tutte le righe, ad esempio per abilitare la creazione di report relativi a tutti i tenant di tutte le partizioni o per eseguire operazioni di suddivisione/unione in partizioni che comportano lo spostamento di righe dei tenant tra più database.

Per abilitare un superuser, creare un nuovo utente SQL (superuser in questo esempio) in ogni database di partizionamento. Modificare quindi i criteri di sicurezza con una nuova funzione di predicato che consenta a questo utente di accedere a tutte le righe. Di seguito è illustrata una funzione di questo tipo.

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

Gestione

  • Aggiunta di nuove partizioni: eseguire lo script T-SQL per abilitare RLS in tutte le nuove partizioni. In caso contrario, le query su tali partizioni non verranno filtrate.
  • Aggiunta di nuove tabelle: aggiungere un predicato di FILTRO e di BLOCCO ai criteri di sicurezza in tutte le partizioni ogni volta che si crea una nuova tabella. In caso contrario, le query sulla nuova tabella non verranno filtrate. Questa operazione può essere automatizzata tramite un trigger DDL, come descritto nel blog relativo all'applicazione automatica della sicurezza a livello di riga alle tabelle create di recente.

Riepilogo

Gli strumenti di database elastico e la sicurezza a livello di riga possono essere usati insieme per ridimensionare il numero di istanze per il livello dati di un'applicazione con supporto sia per le partizioni multi-tenant, sia per quelle con tenant singolo. Le partizioni multi-tenant possono essere usate per archiviare i dati in modo più efficiente, in particolar modo nei casi in cui un elevato numero di tenant dispone solo di poche righe di dati. Le partizioni a singolo tenant possono essere usate invece per supportare tenant "premium" con requisiti di prestazioni e isolamento più rigidi. Per altre informazioni, vedere Sicurezza a livello di riga.

Risorse aggiuntive

Domande e richieste di funzionalità

In caso di domande, contattarci sulla pagina Microsoft delle domande e risposte per il database SQL. Aggiungere una richiesta di funzionalità nel forum dei commenti e suggerimenti sul database SQL.