As a running up to the new release of SQL 2016 I would like to share some research I did about the new features that will be available. Last time I talked about Dynamic Data Masking. This time I will discuss another very interesting new feature: Row Level Security. We know Row Level Security on Analysis Service level, but Microsoft now also implements it on Database Service level.
With the security we know in the current SQL versions (everything before SQL 2016) it is possible to grant or deny users access to select tables, schemas or to execute stored procedures, … What was not possible is to hide certain rows for a certain user. At least not without any joins to other tables. With this new feature in SQL it is possible. And through this post I would like to share how you can do this practically. Of course more detailed info can be found on Books Online. Personally I find this new feature very interesting because it can decrease your development time for reports. Because you don’t need to implement all the security each time in all your queries that you are using. You only implement the security once on database level. So my example is all about a DWH that stores the data of a supermarket. We will focus on a table that contains aggregated data.
So we have 2 dimensions (Departments & Sales Regions), 1 fact table (Sales) and 1 table that keeps track of who can see what (UserRights).
Below you can find the script to generate the tables. Also 3 logins are created to test our functions. As a personal best practice I like to keep everything that has to do with security in a separate Schema.
--1. Schema Creation
CREATE SCHEMA DWH;
GO
CREATE SCHEMA [Security];
GO
--2. Table Creation
IF OBJECT_ID('DWH.DimDepartment') IS NOT NULL DROP TABLE DWH.DimDepartment
CREATE TABLE DWH.DimDepartment(
DepartmentID INT IDENTITY PRIMARY KEY,
DepartmentName NVARCHAR(50)
)
IF OBJECT_ID('DWH.DimSalesRegion') IS NOT NULL DROP TABLE DWH.DimSalesRegion
CREATE TABLE DWH.DimSalesRegion(
SalesRegionID INT IDENTITY PRIMARY KEY,
SalesRegionName NVARCHAR(50)
)
IF OBJECT_ID('DWH.FactSales') IS NOT NULL DROP TABLE DWH.FactSales
CREATE TABLE DWH.FactSales(
SalesID INT IDENTITY PRIMARY KEY,
DepartmentID INT,
SalesRegionID INT,
SalesDateID INT,
SalesAmount DECIMAL(18,8),
CONSTRAINT FK_FactSales_DepartmentID FOREIGN KEY (DepartmentID) REFERENCES DWH.DimDepartment(DepartmentID),
CONSTRAINT FK_FactSales_SalesRegionID FOREIGN KEY (SalesRegionID) REFERENCES DWH.DimSalesREgion(SalesRegionID)
)
IF OBJECT_ID('Security.UserRights') IS NOT NULL DROP TABLE Security.UserRights
CREATE TABLE Security.UserRights(
UserRightsID INT IDENTITY PRIMARY KEY,
UserLogin NVARCHAR(50),
DepartmentID INT,
SalesRegionID INT
)
--3. Table Data
INSERT INTO DWH.DimDepartment VALUES
('Ice Cream'),
('Soft Drinks'),
('Healthy Snacks')
INSERT INTO DWH.DimSalesRegion VALUES
('Central Europe'),
('Eastern Europe'),
('Northern Europe'),
('Southern Europe'),
('Western Europe')
INSERT INTO DWH.FactSales (DepartmentID, SalesRegionID, SalesDateID, SalesAmount)
SELECT DepartmentID, SalesRegionID, DateID, ABS(CAST(NEWID() AS binary(6)) %1000) AS SalesAmount
FROM DWH.DimDepartment
CROSS JOIN DWH.DimSalesRegion
CROSS JOIN (SELECT TOP 100 CONVERT(NVARCHAR, DATEADD(DAY,- ABS(CAST(NEWID() AS binary(6)) %1000) + 1, GETDATE()), 112) AS DateID FROM sys.columns) Dates
ORDER BY DateID
INSERT INTO Security.UserRights VALUES
('User1', 1, NULL),
('User2', NULL, 1),
('User3', 1, 1)
--4. Create Users & Grant permissions
CREATE USER User1 WITHOUT LOGIN;
CREATE USER User2 WITHOUT LOGIN;
CREATE USER User3 WITHOUT LOGIN;
GRANT SELECT ON DWH.FactSales TO User1;
GRANT SELECT ON DWH.FactSales TO User2;
GRANT SELECT ON DWH.FactSales TO User3;
GRANT SELECT ON DWH.DimDepartment TO User1;
GRANT SELECT ON DWH.DimDepartment TO User2;
GRANT SELECT ON DWH.DimDepartment TO User3;
GRANT SELECT ON DWH.DimSalesRegion TO User1;
GRANT SELECT ON DWH.DimSalesRegion TO User2;
GRANT SELECT ON DWH.DimSalesRegion TO User3;
Our first step is to create a function that will tell us if the user can see the data (yes/no).
CREATE FUNCTION [Security].RowLevelSecurityPredicate(@DepartmentId INT, @SalesRegionID INT)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT TOP 1 1 RowLevelSecurityPredicateResult
FROM Security.UserRights
WHERE (DepartmentID = @DepartmentID OR SalesRegionID = @SalesRegionID)
AND UserLogin = USER_NAME();
GO
This function has 2 parameters, our department and sales region. By making use of these together with our logged on user, we filter our UserRights table. If this query returns data, it means the user can see the info. So 1 row is more than enough.
Next step is the new feature: implementing the security. Because the function alone, will not do anything.
CREATE SECURITY POLICY [Security].UserFilter
ADD FILTER PREDICATE [Security].RowLevelSecurityPredicate(DepartmentID, SalesRegionID)
ON DWH.FactSales,
ADD FILTER PREDICATE [Security].RowLevelSecurityPredicate(DepartmentID, NULL)
ON DWH.DimDepartment,
ADD FILTER PREDICATE [Security].RowLevelSecurityPredicate(NULL, SalesRegionID)
ON DWH.DimSalesRegion
WITH (STATE = ON);
GO
First line is like for any sql object telling what we want to make. For row-level-security this is the SECURITY POLICY object. Lines 2-3, 4-5 and 5-6 is your policy definition. For each table we want to filter we add a FILTER PREDICATE to tell which function can tell us what data can be shown or not. Your function accepts the columns from the table that you want to filter.
You can add as much PREDICATE’s as you want. The last is to enable or disable a security policy. This can be done with a separate statement too:
ALTER SECURITY POLICY [Security].UserFilter
WITH (STATE = OFF);
ALTER SECURITY POLICY [Security].UserFilter
WITH (STATE = ON);
EXECUTE AS USER = 'User1';
SELECT DepartmentId, SalesRegionId, SUM(SalesAmount) AS SalesAmount FROM DWH.FactSales GROUP BY DepartmentId, SalesRegionId;
SELECT * FROM DWH.DimDepartment;
SELECT * FROM DWH.DimSalesRegion;
REVERT;
EXECUTE AS USER = 'User2';
SELECT DepartmentId, SalesRegionId, SUM(SalesAmount) AS SalesAmount FROM DWH.FactSales GROUP BY DepartmentId, SalesRegionId;
SELECT * FROM DWH.DimDepartment;
SELECT * FROM DWH.DimSalesRegion;
REVERT;
EXECUTE AS USER = 'User3';
SELECT DepartmentId, SalesRegionId, SUM(SalesAmount) AS SalesAmount FROM DWH.FactSales GROUP BY DepartmentId, SalesRegionId;
SELECT * FROM DWH.DimDepartment;
SELECT * FROM DWH.DimSalesRegion;
REVERT;
So as you can see, it works nicely! The user can only see the data he is allowed to see. What is even cooler about all of this, is that it not only works for SELECT statements but also for UPDATE. Personally I’m really looking forward to implement this new feature into the solutions I will develop at client side.
Cookie | Duration | Description |
---|---|---|
ARRAffinity | session | ARRAffinity cookie is set by Azure app service, and allows the service to choose the right instance established by a user to deliver subsequent requests made by that user. |
ARRAffinitySameSite | session | This cookie is set by Windows Azure cloud, and is used for load balancing to make sure the visitor page requests are routed to the same server in any browsing session. |
cookielawinfo-checkbox-advertisement | 1 year | Set by the GDPR Cookie Consent plugin, this cookie records the user consent for the cookies in the "Advertisement" category. |
cookielawinfo-checkbox-analytics | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Analytics". |
cookielawinfo-checkbox-functional | 11 months | The cookie is set by GDPR cookie consent to record the user consent for the cookies in the category "Functional". |
cookielawinfo-checkbox-necessary | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookies is used to store the user consent for the cookies in the category "Necessary". |
cookielawinfo-checkbox-others | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Other. |
cookielawinfo-checkbox-performance | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Performance". |
CookieLawInfoConsent | 1 year | CookieYes sets this cookie to record the default button state of the corresponding category and the status of CCPA. It works only in coordination with the primary cookie. |
elementor | never | The website's WordPress theme uses this cookie. It allows the website owner to implement or change the website's content in real-time. |
viewed_cookie_policy | 11 months | The cookie is set by the GDPR Cookie Consent plugin and is used to store whether or not user has consented to the use of cookies. It does not store any personal data. |
Cookie | Duration | Description |
---|---|---|
__cf_bm | 30 minutes | Cloudflare set the cookie to support Cloudflare Bot Management. |
pll_language | 1 year | Polylang sets this cookie to remember the language the user selects when returning to the website and get the language information when unavailable in another way. |
Cookie | Duration | Description |
---|---|---|
_ga | 1 year 1 month 4 days | Google Analytics sets this cookie to calculate visitor, session and campaign data and track site usage for the site's analytics report. The cookie stores information anonymously and assigns a randomly generated number to recognise unique visitors. |
_ga_* | 1 year 1 month 4 days | Google Analytics sets this cookie to store and count page views. |
_gat_gtag_UA_* | 1 minute | Google Analytics sets this cookie to store a unique user ID. |
_gid | 1 day | Google Analytics sets this cookie to store information on how visitors use a website while also creating an analytics report of the website's performance. Some of the collected data includes the number of visitors, their source, and the pages they visit anonymously. |
ai_session | 30 minutes | This is a unique anonymous session identifier cookie set by Microsoft Application Insights software to gather statistical usage and telemetry data for apps built on the Azure cloud platform. |
CONSENT | 2 years | YouTube sets this cookie via embedded YouTube videos and registers anonymous statistical data. |
vuid | 1 year 1 month 4 days | Vimeo installs this cookie to collect tracking information by setting a unique ID to embed videos on the website. |
Cookie | Duration | Description |
---|---|---|
ai_user | 1 year | Microsoft Azure sets this cookie as a unique user identifier cookie, enabling counting of the number of users accessing the application over time. |
VISITOR_INFO1_LIVE | 5 months 27 days | YouTube sets this cookie to measure bandwidth, determining whether the user gets the new or old player interface. |
YSC | session | Youtube sets this cookie to track the views of embedded videos on Youtube pages. |
yt-remote-connected-devices | never | YouTube sets this cookie to store the user's video preferences using embedded YouTube videos. |
yt-remote-device-id | never | YouTube sets this cookie to store the user's video preferences using embedded YouTube videos. |
yt.innertube::nextId | never | YouTube sets this cookie to register a unique ID to store data on what videos from YouTube the user has seen. |
yt.innertube::requests | never | YouTube sets this cookie to register a unique ID to store data on what videos from YouTube the user has seen. |
Cookie | Duration | Description |
---|---|---|
WFESessionId | session | No description available. |