1. 程式人生 > 其它 >資料許可權篩選(RLS)的兩種實現介紹

資料許可權篩選(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語句,所以對欄位加索引應該可以進一步提高查詢的效能。