數據防泄與最小可見:ABP 統一封裝行級安全(RLS)+ 列級脫敏
TL;DR:把“誰能看到哪些行、字段可見到哪一位”下沉到數據庫強制層(PostgreSQL:RLS + 安全視圖;SQL Server:RLS + DDM),應用層(ABP + EF Core)只做會話/事務上下文注入、兜底過濾與審計。本版在此前基礎上繼續打磨:
PG ?SELECT/INSERT/UPDATE/DELETE
全覆蓋策略、customers
同樣啟/強制 RLS、security_barrier + security_invoker
安全視圖、SchemaUSAGE
最小授權;
MSSQL ?Orders
+Customers
過濾+寫阻斷(BEFORE/AFTER)、SESSION_CONTEXT
統一只讀;
EF/ABP ?AsyncLocal
訪問器 + 雙攔截器(會話兜底 / 事務限定)+TagWith/TagWithCallSite
審計打標;
📚 目錄
- 數據防泄與最小可見:ABP 統一封裝行級安全(RLS)+ 列級脫敏
- 0)版本與約定 🧾
- 1)問題與目標 🎯
- 總體思路一圖看懂 🗺?
- 2)職責與架構 🏗?
- 3)策略 DSL(可選)🧬
- 4)PostgreSQL 實現(RLS + 安全視圖)🐘
- 4.1 建表與 **RLS(讀/寫/刪全覆蓋)**
- 4.2 PG 請求執行流程 🧭
- 4.3 角色匹配(健壯化)與脫敏函數(STABLE)
- 4.4 **安全視圖**(最小暴露面 + Schema 權限)
- 4.5 索引與 SARGable 示例
- 5)SQL Server 實現(RLS + DDM)🧱
- 5.1 建表與 DDM
- 5.2 RLS:**Orders + Customers** 過濾 + **寫阻斷(BEFORE/AFTER)**
- 5.3 RLS/DDM 工作示意 🧩
- 5.4 會話上下文注入(統一只讀)
- 6)ABP / EF Core 集成(生產級)??
- 6.1 **異步上下文訪問器**(避免單例捕獲作用域對象)
- 6.2 連接攔截器(會話級兜底)
- 6.3 事務攔截器(事務級強約束)
- 6.4 注冊與中間件
- 6.5 查詢打標與兜底過濾
- 7)“誰看過什么”審計 🔎
- 8)性能與容量評測 ?
- 9)灰度上線與回滾 🧰
- 10)目錄結構(落盤)📁
- 11)摘錄 🧪
0)版本與約定 🧾
- 框架:.NET 8、ABP v8+、EF Core 8
- 數據庫:PostgreSQL 15+(支持
security_invoker
視圖);SQL Server 2019+(datetime()
脫敏僅 SQL Server 2022+) - 驅動:
Npgsql
8.x、Microsoft.Data.SqlClient
5.x - 術語:RLS = Row-Level Security;DDM = Dynamic Data Masking
1)問題與目標 🎯
-
反模式:僅在應用層
WHERE TenantId=...
—— 一旦腳本直連/批處理/服務異常,即可能繞過。 -
目標:
- DB 原生強制:PG 用 RLS + 自定義 GUC + 安全視圖;MSSQL 用 RLS(過濾/阻斷)+ DDM。
- 應用統一注入:ABP/EF 攔截器在連接/事務階段統一注入租戶/用戶/角色上下文。
- 全鏈路可觀測、可評測、可灰度與快速回滾。
總體思路一圖看懂 🗺?
2)職責與架構 🏗?
-
數據庫層(主防線)
- PG:
ENABLE RLS
+FORCE RLS
;SELECT/INSERT/UPDATE/DELETE
全覆蓋策略(讀USING
,寫WITH CHECK
);STABLE 函數 + 安全視圖(security_barrier
,security_invoker
);最小授權(僅授視圖SELECT
+ SchemaUSAGE
)。 - MSSQL:RLS = TVF + Security Policy(過濾 + 寫阻斷 BEFORE/AFTER);敏感列 DDM;
sp_set_session_context
只讀。
- PG:
-
應用層(ABP + EF)
- DbConnectionInterceptor 會話級注入(兜底)。
- DbTransactionInterceptor 事務級注入
set_config(..., true)
(避免連接池“串味”)。 - EF 全局過濾器僅作兜底(軟刪/租戶),不替代 RLS。
- 審計:
TagWith
/TagWithCallSite
打標 + 采樣事件(User/Tenant/Entity/Key/TraceId)。
3)策略 DSL(可選)🧬
# policies/tenants.yml
tenants:- id: "t1"roles:- name: "auditor"rls:Orders: "tenant_id = current_tenant()"masking:Customers.phone: "last4"Customers.email: "email"- name: "csr"rls:Orders: "tenant_id = current_tenant() AND region = current_region()"masking:Customers.phone: "partial(0,'****-',4)"
生成器輸出:PG 的
CREATE POLICY
/函數/視圖與 MSSQL 的 TVF/CREATE SECURITY POLICY
/ALTER ... MASKED
,并生成 ABP 常量 & 遷移。
4)PostgreSQL 實現(RLS + 安全視圖)🐘
4.1 建表與 RLS(讀/寫/刪全覆蓋)
CREATE EXTENSION IF NOT EXISTS pgcrypto;CREATE TABLE public.customers(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,name text NOT NULL,phone text NOT NULL,email text NOT NULL
);CREATE TABLE public.orders(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,customer_id uuid NOT NULL REFERENCES public.customers(id),region text,amount numeric(12,2) NOT NULL
);-- 兩表啟用并強制 RLS(避免直查/誤授)
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customers FORCE ROW LEVEL SECURITY;
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.orders FORCE ROW LEVEL SECURITY;-- customers:讀策略(若允許寫/刪,照 orders 同步補齊 WITH CHECK/DELETE)
CREATE POLICY customers_select_tenant
ON public.customers
AS PERMISSIVE
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);-- orders:讀策略
CREATE POLICY orders_select_tenant
ON public.orders
AS PERMISSIVE
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);-- orders:寫策略(INSERT)
CREATE POLICY orders_insert_tenant
ON public.orders
AS PERMISSIVE
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);-- orders:寫策略(UPDATE)
CREATE POLICY orders_update_tenant
ON public.orders
AS PERMISSIVE
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);-- orders:刪策略(DELETE)
CREATE POLICY orders_delete_tenant
ON public.orders
AS PERMISSIVE
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
4.2 PG 請求執行流程 🧭
4.3 角色匹配(健壯化)與脫敏函數(STABLE)
-- 角色精確匹配:忽略大小寫與空白
CREATE OR REPLACE FUNCTION public.has_role(role_name text)
RETURNS boolean
LANGUAGE sql STABLE
AS $$SELECT lower(role_name) = ANY (regexp_split_to_array(coalesce(lower(current_setting('app.roles', true)), ''),'\s*,\s*'))
$$;CREATE OR REPLACE FUNCTION public.mask_phone(p text)
RETURNS text
LANGUAGE sql STABLE
AS $$SELECT CASE WHEN public.has_role('auditor') THEN pELSE '****-' || right(p, 4) END
$$;
4.4 安全視圖(最小暴露面 + Schema 權限)
-- PG15+:security_barrier 防謂詞下推探查;security_invoker 以調用者權限/RLS 評估
CREATE OR REPLACE VIEW public.v_customers
WITH (security_barrier = true, security_invoker = true) AS
SELECT id, name, mask_phone(phone) AS phone_masked, email
FROM public.customers;-- 權限最小化:僅授予視圖讀取,回收底表 & 授予 schema USAGE
REVOKE ALL ON TABLE public.customers FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO app_reader;
GRANT SELECT ON public.v_customers TO app_reader;
4.5 索引與 SARGable 示例
-- 典型多租戶復合索引(忽略已存在校驗/CONCURRENTLY 視運維)
CREATE INDEX idx_orders_tenant_region ON public.orders(tenant_id, region);
5)SQL Server 實現(RLS + DDM)🧱
5.1 建表與 DDM
CREATE SCHEMA sec;CREATE TABLE dbo.Customers(Id uniqueidentifier NOT NULL DEFAULT NEWID() PRIMARY KEY,Tenant_Id uniqueidentifier NOT NULL,Name nvarchar(200) NOT NULL,Phone nvarchar(32) NOT NULL MASKED WITH (FUNCTION='partial(0,"****-",4)'),Email nvarchar(256) NOT NULL MASKED WITH (FUNCTION='email()')-- SQL Server 2022+:可對 datetime 列用 FUNCTION='datetime()'
);CREATE TABLE dbo.Orders(Id uniqueidentifier NOT NULL DEFAULT NEWID() PRIMARY KEY,Tenant_Id uniqueidentifier NOT NULL,Customer_Id uniqueidentifier NOT NULL REFERENCES dbo.Customers(Id),Region nvarchar(64) NULL,Amount decimal(12,2) NOT NULL
);
5.2 RLS:Orders + Customers 過濾 + 寫阻斷(BEFORE/AFTER)
-- 通用過濾謂詞(讀可見性)
CREATE OR ALTER FUNCTION sec.fn_tenant_filter(@tenant_id uniqueidentifier)
RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 AS fn_result
WHERE @tenant_id = TRY_CONVERT(uniqueidentifier, SESSION_CONTEXT(N'tenant_id'));-- 通用阻斷謂詞(防跨租戶寫入)
CREATE OR ALTER FUNCTION sec.fn_tenant_block(@tenant_id uniqueidentifier)
RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 AS fn_result
WHERE @tenant_id = TRY_CONVERT(uniqueidentifier, SESSION_CONTEXT(N'tenant_id'));-- 單個策略統一管控兩張表(Add 多條謂詞)
CREATE SECURITY POLICY sec.tenant_policy
ADD FILTER PREDICATE sec.fn_tenant_filter(Tenant_Id) ON dbo.Orders,
ADD FILTER PREDICATE sec.fn_tenant_filter(Tenant_Id) ON dbo.Customers,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Orders AFTER INSERT,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Orders AFTER UPDATE,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Orders BEFORE UPDATE,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Orders BEFORE DELETE,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Customers AFTER INSERT,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Customers AFTER UPDATE,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Customers BEFORE UPDATE,
ADD BLOCK PREDICATE sec.fn_tenant_block(Tenant_Id) ON dbo.Customers BEFORE DELETE
WITH (STATE = ON);
5.3 RLS/DDM 工作示意 🧩
5.4 會話上下文注入(統一只讀)
EXEC sys.sp_set_session_context @key=N'tenant_id', @value=@TenantId, @read_only=1;
EXEC sys.sp_set_session_context @key=N'roles', @value=@CsvRoles, @read_only=1;
限制:帶 Security Policy 的表不能創建索引視圖。匯總/分析用列存/覆蓋索引或只讀物化層(ETL/CDC)。
6)ABP / EF Core 集成(生產級)??
6.1 異步上下文訪問器(避免單例捕獲作用域對象)
public interface ITenantContextAccessor
{string? TenantId { get; }string? UserId { get; }IReadOnlyList<string> Roles { get; }IDisposable Use(string? tenantId, string? userId, IEnumerable<string> roles);
}public sealed class TenantContextAccessor : ITenantContextAccessor
{private sealed class Holder{public string? TenantId { get; init; }public string? UserId { get; init; }public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();}private static readonly AsyncLocal<Holder?> _current = new();public string? TenantId => _current.Value?.TenantId;public string? UserId => _current.Value?.UserId;public IReadOnlyList<string> Roles => _current.Value?.Roles ?? Array.Empty<string>();public IDisposable Use(string? tenantId, string? userId, IEnumerable<string> roles){var prev = _current.Value;_current.Value = new Holder { TenantId = tenantId, UserId = userId, Roles = roles.ToArray() };return new DisposableAction(() => _current.Value = prev);}private sealed class DisposableAction : IDisposable{private readonly Action _a; public DisposableAction(Action a) => _a = a;public void Dispose() => _a();}
}
中間件在請求首/尾
using accessor.Use(...)
設置/清理上下文,使單例攔截器安全讀取。
6.2 連接攔截器(會話級兜底)
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Npgsql;public sealed class TenantSessionContextInterceptor : DbConnectionInterceptor
{private readonly ITenantContextAccessor _ctx;public TenantSessionContextInterceptor(ITenantContextAccessor ctx) => _ctx = ctx;public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken token = default){if (_ctx.TenantId is null) return;if (connection is NpgsqlConnection){await using var cmd = connection.CreateCommand();cmd.CommandText = @"SET app.tenant_id = @tenant;SET app.user_id = @user;SET app.roles = @roles;";var p1 = cmd.CreateParameter(); p1.ParameterName = "tenant"; p1.Value = _ctx.TenantId!;var p2 = cmd.CreateParameter(); p2.ParameterName = "user"; p2.Value = _ctx.UserId ?? "";var p3 = cmd.CreateParameter(); p3.ParameterName = "roles"; p3.Value = string.Join(',', _ctx.Roles);cmd.Parameters.Add(p1); cmd.Parameters.Add(p2); cmd.Parameters.Add(p3);await cmd.ExecuteNonQueryAsync(token);}else // SQL Server{await using var cmd = connection.CreateCommand();cmd.CommandText ="EXEC sys.sp_set_session_context @key=N'tenant_id', @value=@TenantId, @read_only=1; " +"EXEC sys.sp_set_session_context @key=N'roles', @value=@Roles, @read_only=1;";var p1 = cmd.CreateParameter(); p1.ParameterName = "TenantId"; p1.Value = _ctx.TenantId!;var p2 = cmd.CreateParameter(); p2.ParameterName = "Roles"; p2.Value = string.Join(',', _ctx.Roles);cmd.Parameters.Add(p1); cmd.Parameters.Add(p2);await cmd.ExecuteNonQueryAsync(token);}}public override async Task ConnectionClosingAsync(DbConnection connection, ConnectionEventData eventData, CancellationToken token = default){if (connection is NpgsqlConnection){await using var cmd = connection.CreateCommand();cmd.CommandText = "RESET app.tenant_id; RESET app.user_id; RESET app.roles;";await cmd.ExecuteNonQueryAsync(token);}}
}
6.3 事務攔截器(事務級強約束)
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Npgsql;public sealed class TenantTransactionContextInterceptor : DbTransactionInterceptor
{private readonly ITenantContextAccessor _ctx;public TenantTransactionContextInterceptor(ITenantContextAccessor ctx) => _ctx = ctx;public override async Task TransactionStartedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default){if (transaction.Connection is NpgsqlConnection && _ctx.TenantId is not null){await using var cmd = transaction.Connection.CreateCommand();cmd.Transaction = transaction;cmd.CommandText = @"SELECTset_config('app.tenant_id', @tenant, true),set_config('app.user_id', @user, true),set_config('app.roles', @roles, true);";var p1 = cmd.CreateParameter(); p1.ParameterName = "tenant"; p1.Value = _ctx.TenantId!;var p2 = cmd.CreateParameter(); p2.ParameterName = "user"; p2.Value = _ctx.UserId ?? "";var p3 = cmd.CreateParameter(); p3.ParameterName = "roles"; p3.Value = string.Join(',', _ctx.Roles);cmd.Parameters.Add(p1); cmd.Parameters.Add(p2); cmd.Parameters.Add(p3);await cmd.ExecuteNonQueryAsync(cancellationToken);}}
}
6.4 注冊與中間件
// Program.cs / Module.ConfigureServices
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
services.AddSingleton<TenantSessionContextInterceptor>();
services.AddSingleton<TenantTransactionContextInterceptor>();services.AddAbpDbContext<AppDbContext>((sp, options) =>
{options.AddInterceptors(sp.GetRequiredService<TenantSessionContextInterceptor>(),sp.GetRequiredService<TenantTransactionContextInterceptor>());
});// 中間件(示意):從 ABP/Claims 取租戶/用戶/角色并設置異步上下文
app.Use(async (http, next) =>
{var accessor = http.RequestServices.GetRequiredService<ITenantContextAccessor>();var tenantId = /* 從 ABP IMultiTenancy/Claims 取 */;var userId = /* 從 ClaimsPrincipal 取 */;var roles = /* 從 Claims/ABP Role 取 */;using (accessor.Use(tenantId, userId, roles)){await next();}
});
6.5 查詢打標與兜底過濾
var items = await _db.Orders.TagWith("PII:Customers").TagWithCallSite() // 可選:自動帶上調用文件與行號,利于溯源.Where(x => x.TenantId == CurrentTenantId) // 兜底;生產以 RLS 為準.ToListAsync();
7)“誰看過什么”審計 🔎
- PG:啟用
pgaudit
(shared_preload_libraries='pgaudit'
),pgaudit.log='read,write'
。 - MSSQL:配置
SERVER AUDIT
+DATABASE AUDIT SPECIFICATION
,記錄SELECT
/對象訪問到文件/安全日志。 - 應用采樣:記錄
(User,Tenant,Entity,Key,Purpose,TraceId,Time)
;后臺提供檢索/導出/告警。
8)性能與容量評測 ?
-
謂詞可搜索(SARGable):RLS 表達式命中索引,避免在列上包函數/計算。
-
索引策略:
- PG/MSSQL:為
tenant_id
、常用維度(如region
)建復合索引; - MSSQL:不要在帶 RLS 的表上建索引視圖(不兼容);改用列存/覆蓋索引或只讀物化層。
- PG/MSSQL:為
-
視圖代價:
security_barrier
限制謂詞下推,熱點查詢可為可信后端提供直查接口(嚴格授權)。 -
基準建議:
- PG:
pgbench
對比 RLS ON/OFF; - MSSQL:Extended Events + Query Store,關注計劃重用、回表率、p50/p95/p99、CPU。
- PG:
9)灰度上線與回滾 🧰
- CI 門禁:DSL/SQL golden tests(允許/拒絕矩陣)+ 靜態檢查禁止對含
SECURITY POLICY
的表創建索引視圖。
10)目錄結構(落盤)📁
abp-data-min-visibility/modules/Abp.DataVisibility/ # ABP 模塊:攔截器、審計擴展、策略加載db/pg/rls/*.sql # PG:RLS/視圖/函數mssql/rls/*.sql # MSSQL:TVF/Policy/DDMtests/integration/ # xUnit:允許/拒絕用例(golden)dashboards/audit-app/ # ABP 后臺審計面板tools/policyc/ # DSL → SQL/ABP 常量 生成器(可選)
11)摘錄 🧪
PostgreSQL
BEGIN;
SELECT set_config('app.tenant_id','00000000-0000-0000-0000-000000000001', true);
SELECT set_config('app.roles','csr', true);-- 僅返回本租戶
SELECT * FROM public.orders;-- 跨租戶寫:應被 WITH CHECK 攔截
INSERT INTO public.orders(tenant_id, customer_id, amount)
VALUES ('00000000-0000-0000-0000-000000000002', gen_random_uuid(), 10.00); -- 期望失敗-- 跨租戶刪:應被 DELETE USING 策略攔截
DELETE FROM public.orders WHERE tenant_id='00000000-0000-0000-0000-000000000002'; -- 期望失敗-- 視圖脫敏
SELECT id, phone_masked FROM public.v_customers;
COMMIT;
SQL Server
DECLARE @TenantId uniqueidentifier = '00000000-0000-0000-0000-000000000001';
EXEC sys.sp_set_session_context @key=N'tenant_id', @value=@TenantId, @read_only=1;
EXEC sys.sp_set_session_context @key=N'roles', @value=N'csr', @read_only=1;-- 僅本租戶訂單/客戶可見
SELECT * FROM dbo.Orders;
SELECT * FROM dbo.Customers;-- 跨租戶寫:應被 BLOCK PREDICATE 攔截(AFTER/BEFORE)
INSERT INTO dbo.Orders (Tenant_Id, Customer_Id, Amount)
VALUES ('00000000-0000-0000-0000-000000000002', NEWID(), 10.00); -- 期望失敗-- DDM 脫敏效果
SELECT Phone, Email FROM dbo.Customers;