十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用行级安全(RLS)按角色与时间窗口优雅实现数据“被遗忘”

用行级安全(RLS)按角色与时间窗口优雅实现数据“被遗忘” 你遇到过这种情况吗用户注销账号后业务方却反复叮嘱“别删数据删了我们就查不到历史了”可如果完全不删任何有查询权限的人都能随手翻出对方几年前的敏感记录。前一种要求是数据保留后一种要求是数据安全两者放在一起几乎像是要你造一个“薛定谔的删除”。我去年处理类似需求时最初想到的是加一个 deleted_at 字段后来又改成视图最后真正帮我解决问题的是关系型数据库本身的行级安全Row-Level Security简称 RLS。这篇文章就围绕一句话展开如何用 RLS 让过去的数据按照特定时间窗口从不同角色的眼前逐渐“消失”。1. 数据“被遗忘”不是删库是重新定义可见性“被遗忘”这个说法很容易让人误解。业务方说“让老数据消失”很多开发的第一反应是物理删除第二反应是软删除但真正合适的方案往往是第三种——权限层不可见。1.1 三种“删除”的语义差别物理删除最简单执行 DELETE 把行删掉表空间回收。问题也最直接历史没了审计没了如果后续要回溯某个时间段的业务细节你只能面对一个空洞。软删除是团队里最常见的做法加一个 deleted_at 字段默认查询条件带上AND deleted_at IS NULL。它解决了“恢复”问题但带来两个新问题一是所有查询入口都得记得这个条件一是漏写一次条件老数据就全出来了。更关键的是软删除做不到“选择性遗忘”它让所有能看到这张表的人都看不到被删行无论你是普通运营还是审计员。RLS 走的是完全不同的思路数据原封不动留在表里但数据库在返回结果之前自动把不符合策略的行拦掉。对普通用户来说三个月前的订单就像从来没存在过对分析师来说半年前的明细开始淡出视野对审计来说一切照旧。同样是“删除”需求RLS 给了你一个“按角色、按时间、按条件进行不同遗忘”的能力。三种方案的对比如下方案数据是否保留可恢复性按角色差异化运维复杂度绕过风险物理删除不保留不可恢复无意义低无软删除保留可恢复不支持中高漏条件就失效RLS 策略保留可恢复支持中高低策略由数据库强制1.2 为什么这个需求总在联调阶段才冒出来我复盘过几次项目发现“让历史数据不可见”的需求几乎从不在需求文档的前三页出现。产品经理会说“用户注销后要删掉他的数据”开发听成了“DELETE FROM users WHERE id ?”上线前做安全评审法务或运维才跳出来说不能真删审计要留底对方要申诉还得复核原始记录。这时候大家才开始寻找“既保留又不可见”的方案时间往往已经很紧。如果一开始就意识到所谓“删除”本质上是一个可见性控制问题就不会在项目末期陷入重写查询逻辑的被动局面。RLS 的价值正在于此它把规则内聚在数据库层业务代码不需要为“谁该看到什么”操心只需要约定好角色和时间窗口。2. RLS 到底做了什么一条查询背后的策略改写RLS 的机制可以用一句话概括你执行一条 SELECT 或 UPDATE数据库在真正扫描表之前会根据策略自动给你追加一层过滤条件。这层过滤不是应用层拼 SQL 拼出来的而是数据库强制执行的安全边界。2.1 行级安全的最小原理在 PostgreSQL 里行级安全的载体是策略Policy。一张表开启行级安全后所有访问都会经过策略判断策略的USING表达式负责过滤已有行WITH CHECK负责校验新插入或更新后的行。也就是说USING管你能看到什么WITH CHECK管你能写进来什么。策略还分PERMISSIVE和RESTRICTIVE两种默认是PERMISSIVE。这里有个容易混淆的点多个PERMISSIVE策略之间是 OR 关系也就是满足任意一个就能通过RESTRICTIVE策略之间以及和其他策略之间是 AND 关系必须全部满足。这个区别很关键我第 4 节会专门讲踩坑经历。另外表所有者默认不受 RLS 限制超级用户也不受限制。想让 owner 也被策略约束必须对表执行FORCE ROW LEVEL SECURITY。这个设计本身是合理的毕竟 DBA 总要能管理数据但也导致很多人测试时发现“策略没生效”其实是自己的账号绕过了策略。2.2 第一次实现完整可跑的最小演示只看定义不够直观我先把最简单的场景跑通。假设有一张订单表销售角色只想看最近 30 天的订单审计角色需要看全部。CREATE TABLE orders ( id bigint PRIMARY KEY, owner_name text NOT NULL, amount numeric NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO orders (id, owner_name, amount, created_at) SELECT n, user_ || (n % 5), random() * 1000, now() - (n || days)::interval FROM generate_series(1, 100) AS n;创建两个角色并授予查询权限CREATE ROLE sales; CREATE ROLE auditor; GRANT SELECT ON orders TO sales, auditor;开启行级安全并创建策略ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY orders_sales_policy ON orders FOR SELECT TO sales USING (created_at now() - interval 30 days); CREATE POLICY orders_auditor_policy ON orders FOR SELECT TO auditor USING (true);然后用SET ROLE模拟不同身份查询SET ROLE sales; SELECT count(*) FROM orders; RESET ROLE; SET ROLE auditor; SELECT count(*) FROM orders; RESET ROLE;正常情况sales 看到的行数大概是 30 左右auditor 看到的是 100。如果连这些数字都看不到先检查角色是否有USAGE权限访问当前 schema以及是否误用超级用户登录测试这两个问题我后面也会展开。2.3 为什么这比“在查询里自己加 WHERE”更可靠有人会说这跟应用层查询加个WHERE created_at now() - interval 30 days有什么区别区别非常大。应用层过滤依赖每个调用方都遵守约定但现实里数据出口远不止业务 API报表工具直连、临时数据导出、SQL 命令行排查、给外部系统开只读账号。任何一个入口漏了条件权限边界就破了。RLS 把规则放在数据库内部任何客户端、任何工具、任何拼 SQL 的方式都无法绕开策略。它像门禁系统而不是门卫——门卫会打盹门禁不会。对于“数据逐渐被遗忘”这类合规诉求强制性和统一性比什么都重要。3. 按角色和时间制造“遗忘”一个可运行的完整示例理解了最小机制接下来做一个更贴近真实业务的方案不同类型的角色对同一张表的数据有不同的“遗忘速度”。3.1 设定三档可见性我以一张操作日志表为例。普通前端用户能看到最近 30 天的日志业务分析师可以看到 180 天的审计角色保留全部历史。角色可见时间窗口语义app_user30 天以内30 天前的事在普通用户眼里已经“被遗忘”analyst180 天以内半年前的数据逐渐淡出分析视野auditor全部审计永远保留完整的历史痕迹“逐渐”这个词就落在这里随着时间推移同一行数据对不同角色而言会在不同时刻变得不可见。不是我手动去删某条记录而是时间本身推动策略生效。3.2 把规则写进策略函数为了让规则集中管理我先写一个策略函数再把策略指向它。注意函数里引用的是传入的时间参数而不是直接读取某一行数据CREATE OR REPLACE FUNCTION event_visible(p_event_time timestamptz) RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT CASE WHEN current_user auditor THEN true WHEN p_event_time now() - interval 180 days THEN false WHEN current_user analyst THEN true WHEN current_user app_user AND p_event_time now() - interval 30 days THEN false ELSE true END; $$;这个函数的判断顺序是审计无条件可见所有角色超过 180 天都不可见分析师在 180 天内可见普通用户只能看 30 天内。对普通用户来说超过 30 天但不足 180 天的数据在后面的分支会被拦住超过 180 天则在更早的分支被拦住结果是一致的。然后建表、插入模拟数据、挂策略CREATE TABLE event_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_time timestamptz NOT NULL DEFAULT now(), actor_id text NOT NULL, detail text NOT NULL ); INSERT INTO event_log (event_time, actor_id, detail) SELECT now() - (n || days)::interval, user_ || (n % 10), detail_ || n FROM generate_series(1, 400) AS n; ALTER TABLE event_log ENABLE ROW LEVEL SECURITY; CREATE POLICY event_log_time_policy ON event_log AS PERMISSIVE FOR SELECT TO app_user, analyst, auditor USING (event_visible(event_time)); GRANT SELECT ON event_log TO app_user, analyst, auditor; GRANT USAGE ON SCHEMA public TO app_user, analyst, auditor;注意最后两行授权少了 schema 的使用权限角色连表都碰不到策略设得再对也白搭。3.3 测试与隐藏收益测试方法跟前面的最小演示一样依次切换角色查询SET ROLE app_user; SELECT count(*) AS cnt, max(event_time) AS newest FROM event_log; RESET ROLE; SET ROLE analyst; SELECT count(*) FROM event_log; RESET ROLE; SET ROLE auditor; SELECT count(*) FROM event_log; RESET ROLE;我实际跑出来的结果是app_user 看到约 30 行analyst 看到约 180 行auditor 看到 400 行。每一类角色拿到的数据视图都不同但物理表只有一张。这套设计的隐藏收益有三个第一数据没有移动没有复制不存在多个副本之间的同步问题第二业务代码完全不用感知规则新接入的数据消费方天然继承策略第三时间窗口调整只是改函数或改参数不用做数据迁移。将来某天业务方说“普通用户改成 60 天”一行配置就能完成。4. 我踩过的坑策略叠加、函数索引与授权边界RLS 并不是配置完就万事大吉。我在这条路上踩过几个不小的坑每一个都花了几个小时排查写出来帮你绕开。4.1 两个 PERMISSIVE 策略不是“同时满足”而是“满足其一”第一次做多角色策略时我想当然地以为多个策略是“同时约束”结果不是。两个PERMISSIVE策略之间是 OR 关系只要命中其中一个行就会被放行。举个具体例子我想限制普通用户“只能看到自己的订单”且“只能看到 30 天内的订单”于是写了两条策略一条USING (owner_name current_user)一条USING (created_at now() - interval 30 days)。实际效果是用户能看到自己所有的历史订单因为第一条命中也能看到 30 天内所有人的订单因为第二条命中。这等于把权限放得更开了。要同时满足两个条件正确做法是把条件写进同一条策略的 USING 表达式用 AND 连接或者把其中一个做成RESTRICTIVE策略强制取交集。排查的时候如果发现“数据变多了”先检查是不是策略被 OR 合并了。4.2 策略函数与大表性能第二个坑是性能。RLS 的策略表达式会作用于表的每一行这意味着函数越重查询越慢。我第一次实现时图省事把策略函数写成了 PL/pgSQL 函数里面还有一段去查另一张配置表的逻辑。结果在 10 万行的表上一条本来几十毫秒的查询跑了几秒钟。根因是 PL/pgSQL 函数在不满足内联条件时无法被查询规划器展开只能一行一行调用。而纯 SQL 语言函数有机会被内联让event_time上的索引参与过滤。所以策略函数我强烈建议优先写成LANGUAGE sql的简单表达式不要在函数里做复杂的跨表查询。如果业务规则实在复杂提前在表上冗余一个“是否可见”字段并建索引让策略退化成简单的列比较这是我在生产环境里最常用的手段。4.3 超级用户和 owner 都“看不见”RLS第三个坑相当隐蔽。我用超级用户登录测试怎么查都能看到全部数据一度以为是策略没生效。折腾了半天才反应过来超级用户和表 owner 默认不受 RLS 约束。想验证策略是否生效最可靠的方式是创建普通角色SET ROLE切换过去再查询。如果确实需要约束 owner执行ALTER TABLE event_log FORCE ROW LEVEL SECURITY;让 owner 也必须走策略。这会在日常维护时增加一些不便但安全要求严格时值得。另外一个关联坑是备份工具比如pg_dump通常以超级用户运行会绕过 RLS 导出全量数据。策略能防住应用用户防不住有备份权限的运维这个问题要在权限评估时单独考虑。4.4 RLS 不解决列级脱敏RLS 只管“哪些行能看到”不管“哪些列能看到”。有些人误以为在策略里做点文章就能把某个敏感字段自动替换成脱敏值这是做不到的。列级脱敏要靠别的机制比如建一个脱敏视图配合列的GRANT权限或者用专门的数据脱敏中间件。我见过一个项目把身份证号、手机号放在同一张宽表里指望 RLS 把敏感字段藏起来最后只能另建视图解决。所以一开始设计表结构时就要想清楚行权限用 RLS列级敏感信息用视图或单独的字段权限二者是互补而不是替代。5. 不建 RLS 会怎样替代方案与它们的天花板在引入 RLS 之前我认真评估过几种常规方案。它们不是不能用只是各有天花板理解这些边界才能知道 RLS 到底解决了什么问题。5.1 视图过滤视图过滤是很多人第一时间想到的方案。建一个active_orders视图里面对应WHERE condition然后把基表权限收回让应用只能查视图。分层清晰实现简单规模小的时候够用。但视图方案有两个软肋。一是约束不强制只要某个角色仍然持有基表的 SELECT 权限就能绕过视图直接查底表权限管理稍一松懈就破防。二是维护成本高每个角色、每个可见性策略都要对应一套视图策略一旦变化视图跟着改时间久了视图数量会失控。严格来说这已经是在用应用层思路模拟 RLS只适合静态规则。5.2 应用层过滤应用层过滤是“一人一条 WHERE”的路子。在 ORM 里写好默认过滤条件或者在 DAO 层统一拼 SQL。小团队、单服务、数据出口少时这套方案工作效率最高排查问题也直观。问题出在数据出口变多之后。BI 报表直连、离线数仓同步、客服后台单独拉数、给第三方开的数据接口这些入口分散在不同系统里很难统一执行同一套过滤逻辑。任何一处的权限判断出现偏差老数据就从那个口子漏出去。应用层过滤本质上靠“每个人都不犯错”来维持安全这在权限治理上是最危险的假设。5.3 分区归档分区归档针对“时间遗忘”很自然。把表按月份做分区旧分区迁移到归档表回收查询权限过期的数据自然不可见。从数据库运维角度看这个方案思路清晰且性能好查询时还能借助分区裁剪。但它处理不了“同一批数据对不同角色有不同遗忘时间”的需求。普通用户只能看 30 天、分析师能看 180 天、审计看全部如果按分区回收权限权限粒度是“这个分区谁能读”根本表达不了 30 天和 180 天的差异。硬要做就得拆出多张视图或多套权限体系复杂度成倍上升已经失去分区方案原本简洁的优势。5.4 RLS 不擅长的场景说了这么多 RLS 的优势也要说清楚它的边界。低延迟高吞吐的写入场景策略函数会给每一条访问增加判断开销可能成为瓶颈。复杂跨表策略比如根据另一张统计表的实时数据判断当前行是否可见每次查询都会产生大量附加查询性能很难压下来。团队里如果没有熟悉数据库权限体系的角色策略出错时的排查成本也明显高于普通业务代码。所以 RLS 适合的是“规则集中、角色多样、数据出口多、可见性需要动态变化”的场景。反过来说如果只有单一应用在访问单一数据库写个视图就是最优解。6. 数据治理经验脱敏、审计与灰度发布最后聊几件 RLS 之外但必须一起做的事情。策略上了生产只是开始。6.1 给“遗忘”加一层保险我建议在任何启用 RLS 的表旁边单独建一张审计记录表记录策略变更、时间窗口调整、甚至是可疑的越权查询尝试。理由很简单RLS 让数据“看不见”以后业务方反而更关心“它到底还在不在”。如果没有审计将来回答不了“某个时间段谁能看到这笔数据”这类问题。审计表本身的权限同样要收紧最好只有审计角色和 DBA 能读否则记录越权行为的日志本身就变成新的泄露点。我在实践中是把审计表放在独立 schema 下的日常查询默认不开放。6.2 时间窗口怎么调“逐渐被遗忘”的窗口不是一成不变的。业务方可能今天说 30 天过两个月说要变成 90 天。直接在生产库改策略函数风险很高一旦写错要么数据提前暴露要么正常用户看不到该看的。我的习惯是在测试环境复制一份数据和角色用EXPLAIN ANALYZE验证新策略的执行计划再切到生产。生产上先让一个非核心角色生效观察几天查询反馈确认没有异常后逐步放开。如果你有专门的参数配置表也可以把时间窗口放到配置表里策略函数每次读取。但要注意这是以牺牲部分性能为代价的表数据量小可以考虑大表不建议。6.3 我的判断标准经过这一轮实践我现在判断一张表要不要上 RLS标准很简单能不能数出三个以上需要不同可见性规则的数据消费方这些消费方是否都直连同一个数据库如果两个回答都是肯定的RLS 就是最省心的选择如果只是“某张报表要用”写个视图就够了。这套机制上线后最让我安心的一点是业务方随时可以问“这个数据现在谁能看到”而我只需要把策略函数打出来给他们看。数据还是在原地但在不同角色的世界里它已经在按照时间线慢慢退场。这种“被遗忘”不是物理世界的删除却是权限世界里最接近遗忘的一种状态。
返回列表