上周出过一档子事。运营小王在手机上问「我们楼这月收了多少」,系统回了。隔壁的财务老张也问了句「全园营收多少」,也回了。俩人问的是同一套库,可小王不该看全园,老张能看。我们当时没做权限闸门,靠的是「反正只读不写,看看也出不了大事」。这话现在回头看挺后怕的。
这篇用 ADR 的方式记一下我们的决策,ADR-017,讲清楚问数平台只读不写到底该怎么兜底数据安全。给后面接平台的同事一个参照。
先说结论,只读不写不等于随便看。我们在问数层前面加了一道权限闸门,做行列级隔离。不同角色进来的问法,落地到 SQL 之前先按权限把能查的行和列砍掉。底下给 DDL、配置和踩坑。
背景这事得从头捋。智慧园区系统管发生,四端一体的业务在那跑,数据在那沉淀,原业务表我们一动不动。数智安全问数平台管看懂,只读不写,只加一个自然语言问数入口,回你结论加图加明细加来源四件套。它拿的是只读账号,连的是业务库的只读副本。
问题出在「只读」被我们理解窄了。只读是不改数据,但看的范围没划。小王是单楼栋运营,按规矩他只该看自己楼栋的合同和收缴。系统把所有楼栋都对他开了,他问「全园」也能拿到。这不是写操作,可信息已经越界了。
所以决策很直接,权限必须前置到问数层,不能指望「反正不写就安全」。
决策我们定了三条。第一,权限模型放在问数平台侧,不碰业务库。业务库还是只给只读账号,权限闸门是问数层自己加的一层过滤。第二,做行列级,不只是库级。行按组织归属切,列按敏感等级切。第三,权限和问法解耦,同一个问法不同人问,落地的 SQL 不一样。
方案落地分两块,权限定义和查询改写。
权限定义我们用两张表存角色能看到哪些行规则、哪些列标签。这是线上在跑的建表。
sql
CREATE TABLE qa_row_policy ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL, biz_scope VARCHAR(64) NOT NULL, -- 楼栋/片区/全园 effect VARCHAR(8) NOT NULL DEFAULT 'allow', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_role (role_code) ) COMMENT='行级权限:角色能看哪些业务归属'; CREATE TABLE qa_col_policy ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL, col_tag VARCHAR(32) NOT NULL, -- 合同金额/手机号/身份证 等敏感列 mask_type VARCHAR(16) NOT NULL DEFAULT 'deny', -- deny 直接不可见 hidden 脱敏 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_role_col (role_code, col_tag) ) COMMENT='列级权限:角色对敏感列是拦还是脱敏';列这块我们分两档,拦和脱敏。合同金额这种核心数,运营看不见就直接 deny。手机号身份证这种,不拦但脱敏成掩码,分析的时候还能用。
查询改写问法过了 NL2SQL 拿到原始 SQL,不直接跑,先按当前角色的权限做重写。行权限拼成 WHERE biz_scope IN (角色允许的归属),列权限把 deny 的列从 SELECT 里摘掉,hidden 的列换成脱敏函数。这段逻辑用配置驱动,不写死。
yaml
rewrite_rules: row: inject_where: "biz_scope IN ({allowed_scopes})" source: qa_row_policy.role_code = :current_role col: deny: drop_from_select # 直接移除该列 hidden: wrap_mask_func # 包一层脱敏函数 mask_func: "mask_privacy(col)" default_deny: true # 没在白名单里的列默认不可见有个坑提前说,默认策略一定用 deny。我们一开始是默认放行,结果新加的敏感列忘了配,运营又能看了。改成默认 deny 之后,漏配顶多看不见,不会泄。
查当前角色实际生效了哪些权限,这条 SQL 每天跑很多次,做审计和排障都靠它。
sql
-- 看某角色当前生效的行权限和列权限 SELECT p.biz_scope, c.col_tag, c.mask_type FROM qa_row_policy p LEFT JOIN qa_col_policy c ON c.role_code = p.role_code WHERE p.role_code = 'role_op_single';会话隔离那层也接上。权限跟着 session 走,A 的会话不会继承 B 的角色。只读账号只查不改,权限表也在这个只读库里,写不回业务表。
踩坑表,这几个是我们真踩过的。
| 坑 | 现象 | 怎么填 |
|---|---|---|
| 默认放行漏配 | 新敏感列忘了配,运营又能看 | default_deny 改成 true,漏配顶多看不见 |
| 行权限硬写死 | 换了片区规则得改代码 | 行规则全进 qa_row_policy 表,不改代码 |
| 脱敏只做展示 | 导出明细还是明文 | 脱敏在 SQL 层做,不只是前端遮 |
| 角色串会话 | A 看到 B 的归属 | 权限绑 session,补全前校验角色归属 |
| 只读库也被读爆 | 权限表没索引被扫全表 | role_code 加索引,审计 SQL 限量 |
| 临时提权没回收 | 排查时开的全园权限忘关 | 提权带 ttl 自动过期,不靠人记得关 |
说到这,这道闸门其实不复杂,就是把「只读不写」补全成「只读、按角色看、按角色改不了」。智慧园区系统管发生,业务在那跑数据在那沉淀。问数平台管看懂,只读不写只加一个问数入口,把大白话接住、按权限砍完、答出来。一个管发生,一个管看懂,俩不抢活。加完权限这层,最直观的还是三句少一次,少等一次取数、少做一次重复工作、少一次反复解释。安全这关过了,老板才敢让全员都用。