數據許可權篩選(RLS)的兩種實現介紹

 

在應用程式中,尤其是在統計的時候, 需要使用數據許可權來篩選數據行。 簡單的說,張三看張三部門的數據, 李四看李四部門的數據;或者員工只能看自己的數據, 經理可以看部門的數據。這個在微軟的文檔中叫Row Level Security,字面翻譯叫行級數據安全,簡稱RLS。

要實現RLS, 簡單的思路就是加Where條件語句來做數據篩選。但是必須是先Where, 也就是在其他Where條件和OrderBy、Fetch Rows 之前執行, 否則會對 排序、分頁查詢造成影響。這是一個難點。
另一個難點是如何對現有的業務程式碼侵入性降到最低——不影響現有查詢邏輯的寫法,甚至當需要的時候,可以關閉RLS。為了校驗數據, 必須保持RLS開關的靈活性,尤其是在開發階段。

下面介紹我在項目中使用過的兩種實現方式。

數據許可權篩選(RLS)的實現(一) — Security Policy方式實現
這個主要參考微軟的官文介紹實現, 分三個步驟, a. 定義Predicate函數, 根據user參數來篩選數據, b. 定義Security Policy, 使用前面指定的Predicate函數, c.在指定表上應用Security Policy。
其中的user, 一種是通過當前連接資料庫的登錄用戶來獲取,一種是通過exec sp_set_session_context @key=N’userId’, @value=@userId 來傳入用戶。後者更適合我們在應用查詢中使用統一的連接字元串。由於我們數據訪問層是通過EF來實現的, 所以我們統一在自定義的DbContext類型中做了改造:

 1 public abstract class RlsDbContext : DbContext
 2 {
 3 
 4     protected readonly IUserProvider userProvider;
 5     protected RlsDbContext(
 6     string connectionString,
 7     IUserProvider userProvider)
 8     : base(options)
 9     {
10         this.connectionString = connectionString;
11         this.userProvider = userProvider;
12     }
13 
14     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
15     {
16         connection = new SqlConnection(connectionString);
17         if (enableRLS)
18         {
19             connection.StateChange += Connection_StateChange;
20         }
21 
22         if (!enableMemoryDb)
23         {
24             optionsBuilder.UseSqlServer(connection);
25         }
26 
27         base.OnConfiguring(optionsBuilder);
28     }
29 
30     private void Connection_StateChange(object sender, System.Data.StateChangeEventArgs e)
31     {
32         if (e.CurrentState == ConnectionState.Open)
33         {
34             string userId = userProvider.CurrentUserId;
35             //此處判斷條件用於流程Hook介面未配置認證而獲取不到用戶的情況
36             if (!string.IsNullOrEmpty(userId))
37             {
38                 SqlCommand cmd = connection.CreateCommand();
39                 cmd.CommandText = @"exec sp_set_session_context @key=N'userId', @value=@userId";
40                 cmd.Parameters.AddWithValue("@userId", userId);
41                 cmd.ExecuteNonQuery();
42             }
43         }
44         else if (e.CurrentState == ConnectionState.Closed)
45         {
46             //暫時注釋:在分頁查詢場景下存在RLS獲取總數之前SQL連接關閉的情況
47             //connection.StateChange -= Connection_StateChange;
48         }
49     }
50 
51 }

 

這樣, 我們就能確保在訪問資料庫的適合, 傳入了當前用戶資訊

具體的示例, 可以參考《Row-Level Security
但是這個方式有個很大的問題, 就是性能不理想, 尤其是在判斷條件中有or邏輯的時候。 比如這個場景:每個部門只能看自己的數據,如果是數據管理員,不論在哪個部門, 可以看所有部門的數據。加了or邏輯後, 大概1w行數據查詢需要10s鍾,這超出了應用能接收的範圍。示例Predicate Function如下

 1 CREATE FUNCTION [dbo].[Predicate_MyFilter_RLS]
 2 (
 3     @orgId nvarchar(200)
 4 )
 5 RETURNS TABLE
 6     WITH SCHEMABINDING
 7 AS
 8 RETURN
 9    SELECT TOP 1 1 AS AccessPredicateResult
10    FROM dbo.[User] a
11    WHERE
12        a.UserId = SESSION_CONTEXT(N'UserId')
13     AND
14       (
15         a.OrgId = @orgId OR a.OrgId = '0000000000000000000000'
16       )
17 GO

 

關於性能問題的佐證,可以參考《Row-Level Security for Middle-Tier Apps – Using Disjunctions in the Predicate

由於性能問題的障礙, 所以我們放棄了這種實現方式。但是這種方式比較優雅的滿足了上述的兩個條件,即實現了底層數據先篩選的邏輯,也對業務查詢方法無侵入。在簡單的場景中,應該是一款適合的方案。

 

數據許可權篩選(RLS)的實現(二) — 後台RlsStrategy方式實現
另一種做法, 是我們自行研究的RlsStrategy的實現方式。首先我們了解下介面IRlsStragety

 1 public interface IRlsStragety<TEntity, TUserConstraintEntity>
 2 {
 3     Expression<Func<TUserConstraintEntity, bool>> UserPredicate
 4     {
 5         get;
 6     }
 7 
 8     Expression<Func<TEntity, object>> OuterKeySelector
 9     {
10         get;
11     }
12 
13     Expression<Func<TUserConstraintEntity, object>> InnerKeySelector
14     {
15         get;
16     }
17 
18     bool Skip();
19 }

 

這裡面提供了三個表達式和一個bool 方法判斷是否要略過RLS篩選。
下面是一個基本的實現:

 1 public class GenericUserOrgRlsStragety<TEntity, TOrgUser> : IRlsStragety<TEntity, TOrgUser>
 2 where TEntity : class, IUserId
 3 where TOrgUser : class, IOrgUser
 4 {
 5     private readonly IOrgProvider userOrgProvider;
 6     public GenericUserOrgRlsStragety(IOrgProvider userOrgProvider)
 7     {
 8         this.userOrgProvider = userOrgProvider;
 9     }
10 
11     public virtual Expression<Func<TOrgUser, bool>> UserPredicate
12     => user => user.OrgId == userOrgProvider.CurrentUserOrgId;
13 
14     public virtual Expression<Func<TEntity, object>> OuterKeySelector
15     => entry => entry.UserId;
16 
17     public virtual Expression<Func<TOrgUser, object>> InnerKeySelector
18     => user => user.UserId;
19 
20     public virtual bool Skip()
21     {
22         return false;
23     }
24 }

 

下面我來解釋下這個邏輯。 假設應用中有這樣兩張表
T_BizData(Id, BizAmount, Org) 和T_OrgUser(Org, User), 前者是業務表, 記錄了業務數據和所屬業務組織的機構,後者是機構人員表,記錄了人員和機構之間的關係。 根據這兩個表,我們可以實現OrgA的用戶可以查看OrgA的數據, OrgB的用戶可以查看OrgB的數據

如果不考慮RLS, 則查詢語句是 

Select * from T_BizData

 

如果考慮RLS, 則查詢語句是

Select a.* from T_BizData a
   inner join T_OrgUser b on a.Org=b.org
where b.User=@user

 

兩者比較,我們發現多了一個限制表和三處靈活點:
1 限制表就是 inner join T_OrgUser b,
2 靈活點 a) 取左表屬性; b)取右表屬性; c)取右表條件判斷

這三個靈活點就是我們介面定義的三個表達式, 限制表是作為泛型類型傳入進來的。

理解了這一點, 我們就可以看看下面這個程式碼

 1         public static IQueryable<TEntity> FilterByUser<TDbContext, TEntity, TUserConstraintEntity>(
 2                 this IQueryable<TEntity> queryable,
 3                 TDbContext dbContext,
 4                 IRlsStragety<TEntity, TUserConstraintEntity> rlsStragety
 5                 )
 6         where TDbContext : DbContext
 7         where TEntity : class
 8         where TUserConstraintEntity : class, IUserId
 9         {
10             if (dbContext is null)
11             {
12                 throw new System.ArgumentNullException(nameof(dbContext));
13             }
14 
15             if (rlsStragety == null
16                 || rlsStragety.UserPredicate == null
17                 || rlsStragety.OuterKeySelector == null
18                 || rlsStragety.InnerKeySelector == null
19                 || rlsStragety.Skip()
20                 )
21             {
22                 return queryable;
23             }
24 
25             
26             IQueryable<TEntity> result = queryable.Join(
27                        dbContext.Set<TUserConstraintEntity>()
28                                 .Where(rlsStragety.UserPredicate)
29                      , rlsStragety.OuterKeySelector
30                      , rlsStragety.InnerKeySelector
31                      , (p, q) => p
32                    );
33             return result;
34         }

 

我們都知道queryable 是EF實現查詢的對象,它描述了查詢的過程,所以我們在原queryable對象的基礎上擴充了join邏輯, 從而實現了類似sql 語句的兩表inner join查詢。 該過程是在分頁之前加入的,這樣才能保證查詢的結果。

 1         public virtual async Task<IPaged<TEntity>> GetPagedListAsync<TEntity>(object filter, CancellationToken cancellationToken = default) where TEntity : class
 2         {
 3             if (filter == null)
 4             {
 5                 filter = new object();
 6             }
 7             IPaged<TEntity> result = new Paged<TEntity>();
 8 
 9             IQueryable<TEntity> queryable = GetPagedQueryable<TEntity>(filter);
10             result.Rows = await queryable.ToListAsync(cancellationToken).ConfigureAwait(false);
11 
12             IQueryable<TEntity> queryableForCount = GetCountQueryable<TEntity>(filter);
13             result.Total = await queryableForCount.CountAsync(cancellationToken).ConfigureAwait(false);
14 
15             return result;
16         }

 

以上準備工作做好了, 在查詢的時候,就可以這樣寫了:

stragety =
serviceProvider.GetService<MyRlsStragety>();

var pageList = await rlsDataInquirer.GetPagedListAsync(filter, stragety);

 

最後, 補充下skip()方法的邏輯。

        public override bool Skip()
        {
            string orgId = userOrgProvider.CurrentUserOrgId;

            // 如果是資訊管理部則跳過關聯判斷
            return orgId.Equals(InfoSupervisorDepartmentOrgId, StringComparison.CurrentCultureIgnoreCase);
        }

 

我們看到,FilterByUser方法的第19行, 如果skip()返回為true, 則會跳過RLS的邏輯。這個主要是為了特殊處理高級管理許可權設計的。

 

總結:

     使用Security Policy 除了可以過濾用戶許可權數據外, 還可以用於更新和刪除數據時的許可權檢查; 而使用RlsStrategy則只能基於現有的框架來實現查詢數據行時的篩選,但是性能上要好很多,而且也比較靈活。同時,因為底層是轉換成了SQL語句,所以對欄位加索引應該可以進一步提高查詢的性能。

 

Tags: