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