SQL Server 2016 On-Premise with Row-Level Security with Active Directory (Part II)

In the previous article, I introduced a simple way to implement Row-Level-Security implementation for Microsoft SQL Server 2016 database with Active Directory.

Here, I’ll show you a more efficient way to implement RLS when you deal with millions of records in your database and thousands of users in your active directory.

Below is the original implementation of a RLS security predicate:

CREATE FUNCTION RLS.GeographyAccessPredicate(@GeographyKey AS INT)

RETURNS TABLE

WITH SCHEMABINDING

AS

RETURN SELECT 1 AS AccessResult

FROM dbo.DimGeography G

WHERE

G.GeographyKey = @GeographyKey AND

(

(

G.CountryRegionCode = 'US' AND IS_MEMBER('DOMAIN\AW United States') = 1

)

OR

(

G.CountryRegionCode = 'GB' AND IS_MEMBER('DOMAIN\AW United Kingdom') = 1

)

OR

(

G.CountryRegionCode = 'DE' AND IS_MEMBER('DOMAIN\AW Germany') = 1

)

OR

(

G.CountryRegionCode = 'FR' AND IS_MEMBER('DOMAIN\AW France') = 1

)

OR

(

G.CountryRegionCode = 'CA' AND IS_MEMBER('DOMAIN\AW Canada') = 1

)

OR

(

G.CountryRegionCode = 'AU' AND IS_MEMBER('DOMAIN\AW Australia') = 1

)

)

There are two performance issues in this filter predicate. First, there is a WHERE condition that slows down the query performance. The GeographyAccessPredicate function contains OR conditions for each country, which it will denied the use of indexes in the tables.

Second, we use a function called IS_MEMBER, which evaluate whether the current user is a member of the specified Active Directory group. This function will execute poorly when we query tables with millions of rows and we have thousands of active directory users and groups in our domain.

In order to improve the performance, instead of calling IS_MEMBER function, I’m going to create a table in the database to keep Active Directory users and groups. This Active Directory user and group table will allow me to join the data with the table and remove the function IS_MEMBER from the security policy predicate. It will simplify and improve the performance of the query execution.

CREATE TABLE [dbo].[DimActiveDirectoryUser](

[LoginName] [varchar](50) NOT NULL,

[GroupName] [varchar](150) NOT NULL,

CONSTRAINT [PK_DimActiveDirectoryUser] PRIMARY KEY CLUSTERED

(

[LoginName] ASC,

[GroupName] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]

) ON [PRIMARY]

In order to populate this table, we can execute a Power Shell script or create a ADSI linked server. In this article, I’ll use ADSI linked server:

USE [master]

GO

EXEC master.dbo.sp_addlinkedserver @server = N'ADSI', @srvproduct=N'Active Directory Service Interfaces', @provider=N'ADSDSOObject', @datasrc=N'adsdatasource'

EXEC master.dbo.sp_addlinkedsrvlogin @rmtsrvname=N'ADSI',@useself=N'False',@locallogin=NULL,@rmtuser=N'domain\YourUser',@rmtpassword='Your Password'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'collation compatible', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'data access', @optvalue=N'true'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'dist', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'pub', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'rpc', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'rpc out', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'sub', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'connect timeout', @optvalue=N'0'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'collation name', @optvalue=null

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'lazy schema validation', @optvalue=N'false'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'query timeout', @optvalue=N'0'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'use remote collation', @optvalue=N'true'

GO

EXEC master.dbo.sp_serveroption @server=N'ADSI', @optname=N'remote proc transaction promotion', @optvalue=N'true'

GO

 

Be sure to replace the values for the following parameters @rmtuser=N’domain\YourUser’,@rmtpassword=’Your Password’.

The following is the script used to synchronize our active directory with the new table DimActiveDirectoryUser in the AdventureWorks database. Notice that I only synchronize the groups that start with the wildcard: “AW*”, since they are the only groups used in my application.

USE AdventureWorksDW2014

GO

TRUNCATE TABLE DimActiveDirectoryUser

GO

DECLARE @GroupName VARCHAR(50)

DECLARE db_cursor CURSOR FOR

SELECT CN

FROM OpenQuery(ADSI,

'SELECT cn, ADsPath FROM ''LDAP://DC=domain,DC=com'' WHERE objectCategory=''group'' AND cn=''AW*'''

)

OPEN db_cursor

FETCH NEXT FROM db_cursor INTO @GroupName

WHILE @@FETCH_STATUS = 0

BEGIN

DECLARE @OPENQUERY nvarchar(4000), @TSQL nvarchar(4000), @LinkedServer nvarchar(4000)

SET @LinkedServer = 'ADSI'

SET @OPENQUERY = 'INSERT INTO DimActiveDirectoryUser(LoginName, GroupName) '

SET @OPENQUERY = @OPENQUERY + ' SELECT sAMAccountName, ''' + @GroupName + ''' FROM OPENQUERY('+ @LinkedServer + ','''

--SET @OPENQUERY = 'SELECT sAMAccountName as Login, CN as Name, GivenName as FirstName,SN as LastName, DisplayName as FullName, UserAccountControl FROM OPENQUERY('+ @LinkedServer + ','''

SET @TSQL = 'SELECT sAMAccountname,givenname,sn,displayname,useraccountcontrol,CN, department FROM ''''LDAP://domain.com/DC=domain,DC=com'''''

SET @TSQL = @TSQL + ' WHERE objectCategory=''''person'''' '

SET @TSQL = @TSQL + ' AND objectClass=''''user'''''

SET @TSQL = @TSQL + ' AND memberOf=''''CN=' + @GroupName + ',OU=AdventureWorks,DC=domain,DC=com'''' '

SET @TSQL = @TSQL + ' '')'

PRINT @TSQL

EXEC (@OPENQUERY+@TSQL)

PRINT @TSQL

FETCH NEXT FROM db_cursor INTO @GroupName

END

CLOSE db_cursor

DEALLOCATE db_cursor

GO

-- Update values to Match Predicate Conditions

UPDATE DimActiveDirectoryUser SET LoginName = 'DOMAIN\' + LoginName, GroupName = REPLACE(GroupName,'AW ','')

 

Notice that the last update to the LoginName and GroupName facilitates a better match in our security predicate.

Finally, the security predicate is simplified. No more OR conditions or IS_MEMBER function in the security predicate. GeographyAccessPredicate is a join between the DimGeography and the DimActiveDirectoryUser tables.

DROP SECURITY POLICY RLS.AWSecurityPolicy

go

DROP FUNCTION RLS.GeographyAccessPredicate

go

CREATE FUNCTION RLS.GeographyAccessPredicate(@GeographyKey AS INT)

RETURNS TABLE

WITH SCHEMABINDING

AS

RETURN SELECT 1 AS AccessResult

FROM dbo.DimGeography G

INNER JOIN dbo.DimActiveDirectoryUser U

ON G.GeographyKey = @GeographyKey AND

U.LoginName = SUSER_NAME()

AND G.EnglishCountryRegionName = U.GroupName

GO

CREATE SECURITY POLICY RLS.AWSecurityPolicy

ADD FILTER PREDICATE RLS.GeographyAccessPredicate(GeographyKey) ON dbo.DimCustomer

The performance was improved 2X compared with initial version of the security predicate. You’ll notice this difference when dealing with millions of records and thousand users in your active directory. It also depends of the SQL Server configuration.

The following is the query execution plan with the RLS security.

-- Show Plan with Security

ALTER SECURITY POLICY RLS.AWSecurityPolicy WITH (STATE=ON)

GO

SET SHOWPLAN_ALL ON

GO

SELECT COUNT(*) FROM DimCustomer

GO

SET SHOWPLAN_ALL OFF

When implementing RLS keep in mind that there is always an overhead in performance when querying a secured table with RLS. The performance of the queries results depends on how you implement the WHERE condition logic and this logic can be optimized using indexes in the joined fields and avoiding complex functions in your RLS security policy.

Leave a Reply