在應用程式中,尤其是在統計的時候, 需要使用資料許可權來篩選資料行。 簡單的說,張三看張三部門的資料, 李四看李四部門的資料;或者員工只能看自己的資料, 經理可以看部門的資料。這個在微軟的文件中叫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語句,所以對欄位加索引應該可以進一步提高查詢的效能。