60.6 数据安全与权限
一句话
数据权限的本质是让查询引擎替你做过滤,而不是「让应用记得加 WHERE」;表权限只挡住入口,行/列权限才决定数据出得去多少。
权限有六个层次,不是一层
| 层 | 控制什么 | 实现位置 | 典型绕过 |
|---|---|---|---|
| 存储 / 文件 | 能不能直接读对象 / 文件 | bucket policy、KMS、临时凭证 | 预签名 URL 外泄、bucket 公开 |
| Catalog / 库 | 能不能建表、删表、切换库 | Metastore / Unity / Glue / Ranger | 有库权限就 CREATE TABLE AS SELECT 读全库 |
| 表 | 能不能 SELECT / INSERT / ALTER | 引擎 ACL、catalog grant | 表权限给了,文件仍可直读 |
| 列 | 能不能看这一列、看到的是不是明文 | 列级 grant、masking policy、视图 | 用 SELECT * 或函数包一层绕过 |
| 行 | 能看哪些行 | row filter、安全视图、RLS | 应用层漏写 WHERE、参数可篡改 |
| 导出 / 共享 | 能不能下载、外发、共享 | 导出审批、水印、共享 recipient | 有查询权就有批量导出权 |
🔑 表权限回答「能不能进这个房间」,行/列权限回答「进去后能看哪几个抽屉」。只做表权限的系统,一次越权就是整表泄露。
模型:RBAC + 数据范围,标签驱动策略
权限模型本身的主场在 38.3 授权 (RBAC / ABAC / 权限模型) 与 49.3 权限中心与权限矩阵,这里只讲数据平台的特殊性:
| 特殊性 | 后果 | 做法 |
|---|---|---|
| 资源是「表 + 列 + 行」 | 功能权限不够,必须数据权限 | 四维矩阵:主体 × 资源 × 操作 × 范围 |
| 数据有分级 | 同一张表不同列保护强度不同 | 分级标签驱动 mask / 审批 / 加密 |
| 查询是 Ad-hoc | 无法为每个查询预先授权 | 引擎级策略 + 安全视图 |
| 分析要聚合 | 聚合结果可能反推个体 | 小分组抑制、差分隐私(见 38.5 数据分级与脱敏) |
| 多租户共享表 | 行级隔离必须强制 | tenant_id 来自会话,不接受请求参数 |
⚠️ 「角色名判定」是数据权限腐化的起点:
if role == 'analyst'写死在代码里,新增一个「外部审计」角色就要改 N 个服务。判定点应该是can(subject, action, resource),变化落在策略数据里。
实现位置:五种做法,安全性差一个数量级
| 做法 | 安全性 | 性能 | 可维护 | 绕过风险 | 适用 |
|---|---|---|---|---|---|
应用层拼 WHERE | 低 | 好 | 差(N 处复制) | 漏一处、注入、聚合/Join 绕过 | 临时脚本,不用于生产 |
| 数据库安全视图 | 中高 | 中 | 中 | 用户仍能访问基表就失效 | 单库、只读分析 |
| 引擎 row filter / column mask | 高 | 中(谓词下推) | 好(集中策略) | 引擎漏洞、策略未覆盖 | Hive/Spark/Trino 生产首选 |
| 湖仓 catalog 策略 | 高 | 好(存储层裁剪) | 好 | 直读对象存储则失效 | 湖仓(UC / Lake Formation) |
| 查询网关统一改写 | 高 | 差(多一跳) | 中 | 网关被绕过 | 多引擎、统一审计 |
这类「拼接 + 未校验」的组合也是 SQL 注入的温床,攻击面见 38.6 常见攻击 (注入 / XSS / CSRF / SSRF / 越权 / 重放)。应用层拼接为什么不可靠?看下面的实测:租户条件 tenant_id='t1' 后面直接拼用户输入,传入 amount_cents > 0 OR 1=1,4 行全部返回——功能权限没出错,行权限被一个 OR 穿透。
代码:结构化查询 + 策略编译
#!/usr/bin/env python3
"""行级 + 列级权限:结构化查询 + 策略编译(SQLite,无第三方依赖)。"""
import sqlite3
from dataclasses import dataclass, field
DDL = """
CREATE TABLE orders (
order_id TEXT, tenant_id TEXT, user_id TEXT, amount_cents INTEGER,
phone TEXT, status TEXT, dt TEXT
);
INSERT INTO orders VALUES
('o1','t1','u1',10000,'13800000001','paid','2026-10-01'),
('o2','t1','u2',20000,'13800000002','paid','2026-10-01'),
('o3','t2','u3',30000,'13900000003','paid','2026-10-02'),
('o4','t2','u4',40000,'13900000004','refunded','2026-10-02');
"""
MASK = "substr(phone,1,3) || '****' || substr(phone,8,4)"
LAST4 = "'*******' || substr(phone,8,4)"
# 策略:行范围 + 允许的列 + 脱敏表达式
POLICIES = {
"analyst": {"row": "tenant", "cols": {"order_id", "dt", "amount_cents", "status"},
"masks": {"phone": MASK}},
"support": {"row": "tenant", "cols": {"order_id", "dt", "status", "phone"},
"masks": {"phone": LAST4}},
"finance": {"row": "tenant", "cols": {"order_id", "dt", "amount_cents", "status"},
"masks": {"phone": "NULL"}},
"admin": {"row": "all", "cols": {"order_id", "tenant_id", "dt", "amount_cents", "status"},
"masks": {"phone": MASK}},
}
EXPR = {"phone": MASK, "order_id": "order_id", "tenant_id": "tenant_id", "dt": "dt",
"amount_cents": "amount_cents", "status": "status"}
@dataclass
class Query:
columns: list[str]
filters: list[tuple[str, str, object]] = field(default_factory=list)
limit: int = 100
class Denied(Exception):
pass
class Engine:
def __init__(self, con):
self.con = con
self.audit = []
def compile(self, role, session_tenant, q: Query):
p = POLICIES.get(role)
if not p:
raise Denied(f"未知角色 {role}")
if not q.columns:
raise Denied("必须显式指定列(禁止 SELECT *)")
for c in q.columns:
if c not in p["cols"] and c not in p["masks"]:
raise Denied(f"角色 {role} 无权访问列 {c}")
for c, _, _ in q.filters:
# 只允许在「未脱敏的可见列」上过滤:按手机号等值过滤会成为探测预言机
if c not in p["cols"]:
raise Denied(f"角色 {role} 无权按列 {c} 过滤")
select = [p["masks"].get(c, EXPR[c]) + f" AS {c}" for c in q.columns]
sql = f"SELECT {', '.join(select)} FROM orders"
where, params = [], []
if p["row"] == "tenant":
where.append("tenant_id = ?")
params.append(session_tenant) # 租户来自会话,不接受调用方传参
for c, op, v in q.filters:
if op not in ("=", ">", "<", ">=", "<="):
raise Denied(f"不支持的操作符 {op}")
where.append(f"{c} {op} ?")
params.append(v)
else:
for c, op, v in q.filters:
if op not in ("=", ">", "<", ">=", "<="):
raise Denied(f"不支持的操作符 {op}")
where.append(f"{c} {op} ?")
params.append(v)
if where:
sql += " WHERE " + " AND ".join(where)
sql += " LIMIT ?"
params.append(min(q.limit, 1000))
return sql, params
def run(self, role, session_tenant, q: Query):
sql, params = self.compile(role, session_tenant, q)
rows = self.con.execute(sql, params).fetchall()
self.audit.append({"role": role, "tenant": session_tenant,
"rows": len(rows), "cols": q.columns, "sql": sql})
return rows
def main():
con = sqlite3.connect(":memory:")
con.executescript(DDL)
e = Engine(con)
print("=== A. 应用层拼接(错误示范)===")
user_input = "amount_cents > 0 OR 1=1"
leak = con.execute("SELECT order_id, tenant_id FROM orders WHERE tenant_id='t1' AND "
+ user_input).fetchall()
print(f" t1 分析师传入 {user_input!r} -> 返回 {len(leak)} 行:{leak}")
print("\n=== B. 策略编译:同样的过滤,值走参数绑定 ===")
rows = e.run("analyst", "t1", Query(["order_id", "amount_cents", "phone"],
[("amount_cents", ">", "0 OR 1=1")]))
print(f" 返回 {len(rows)} 行(注入串被当成字符串值)")
print("\n=== 12 条验收用例 ===")
cases = [
("analyst", "t1", Query(["order_id", "amount_cents", "phone"]), 2, "138****0001"),
("analyst", "t2", Query(["order_id", "amount_cents", "phone"]), 2, "139****0003"),
("analyst", "t1", Query(["order_id"], [("amount_cents", ">", 15000)]), 1, None),
("analyst", "t1", Query(["phone"]), 2, "138****0002"),
("support", "t1", Query(["order_id", "phone"]), 2, "*******0001"),
("finance", "t1", Query(["order_id", "amount_cents", "phone"]), 2, None),
("admin", "t1", Query(["order_id", "tenant_id", "phone"]), 4, "139****0004"),
("admin", "t1", Query(["order_id"], [("status", "=", "paid")]), 3, None),
("analyst", "t1", Query(["tenant_id"]), "DENY", None),
("analyst", "t1", Query(["*"]), "DENY", None),
("analyst", "t1", Query(["order_id"], [("phone", "=", "13800000001")]), "DENY", None),
("intern", "t1", Query(["order_id"]), "DENY", None),
]
passed = 0
for role, tenant, q, want, phone in cases:
try:
got = e.run(role, tenant, q)
n = len(got)
ok = (want != "DENY" and n == want and
(phone is None or any(r[-1] == phone for r in got)))
detail = f"{n} 行"
except Denied as ex:
ok = want == "DENY"
detail = f"拒绝:{ex}"
passed += ok
print(f" {'✅' if ok else '❌'} {role:<8} {str(q.columns):<38} 期望 {want} -> {detail}")
print(f" 通过 {passed}/{len(cases)}")
print("\n=== finance 角色看到的 phone(无权限列返回 NULL)===")
fin = e.run("finance", "t1", Query(["order_id", "phone"]))
print(f" {[r[1] for r in fin]}")
print("\n=== 生成的 SQL(analyst)===")
sql, params = e.compile("analyst", "t1", Query(
["order_id", "amount_cents", "phone"], [("amount_cents", ">", 15000)]))
print(" " + sql)
print(f" params={params}")
print("\n=== 审计日志 ===")
for a in e.audit[-4:]:
print(f" role={a['role']} tenant={a['tenant']} rows={a['rows']} cols={a['cols']}")
main()
本机实测(macOS,Apple M1,Python 3.14.8):
=== A. 应用层拼接(错误示范)===
t1 分析师传入 'amount_cents > 0 OR 1=1' -> 返回 4 行:[('o1', 't1'), ('o2', 't1'), ('o3', 't2'), ('o4', 't2')]
=== B. 策略编译:同样的过滤,值走参数绑定 ===
返回 0 行(注入串被当成字符串值)
=== 12 条验收用例 ===
✅ analyst ['order_id', 'amount_cents', 'phone'] 期望 2 -> 2 行
✅ analyst ['order_id', 'amount_cents', 'phone'] 期望 2 -> 2 行
✅ analyst ['order_id'] 期望 1 -> 1 行
✅ analyst ['phone'] 期望 2 -> 2 行
✅ support ['order_id', 'phone'] 期望 2 -> 2 行
✅ finance ['order_id', 'amount_cents', 'phone'] 期望 2 -> 2 行
✅ admin ['order_id', 'tenant_id', 'phone'] 期望 4 -> 4 行
✅ admin ['order_id'] 期望 3 -> 3 行
✅ analyst ['tenant_id'] 期望 DENY -> 拒绝:角色 analyst 无权访问列 tenant_id
✅ analyst ['*'] 期望 DENY -> 拒绝:角色 analyst 无权访问列 *
✅ analyst ['order_id'] 期望 DENY -> 拒绝:角色 analyst 无权按列 phone 过滤
✅ intern ['order_id'] 期望 DENY -> 拒绝:未知角色 intern
通过 12/12
=== finance 角色看到的 phone(无权限列返回 NULL)===
[None, None]
=== 生成的 SQL(analyst)===
SELECT order_id AS order_id, amount_cents AS amount_cents, substr(phone,1,3) || '****' || substr(phone,8,4) AS phone FROM orders WHERE tenant_id = ? AND amount_cents > ? LIMIT ?
params=['t1', 15000, 100]
=== 审计日志 ===
role=support tenant=t1 rows=2 cols=['order_id', 'phone']
role=finance tenant=t1 rows=2 cols=['order_id', 'amount_cents', 'phone']
role=admin tenant=t1 rows=4 cols=['order_id', 'tenant_id', 'phone']
role=admin tenant=t1 rows=3 cols=['order_id']
读数:
| 用例 | 结果 | 说明了什么 |
|---|---|---|
应用层 OR 1=1 | 返回 4 行(跨两个租户) | 行权限不能靠字符串拼接 |
| 参数化注入串 | 0 行 | 值被绑定为字符串,不参与 SQL 语法 |
| analyst 查 t2 | 2 行(只有 t2) | 租户来自会话,查谁只能由身份决定 |
| support 看手机号 | *******0001 | 列级脱敏在 SELECT 里下推,不是前端打码 |
| finance 看手机号 | NULL | 无权列直接不返回,而不是「返回后隐藏」 |
| 按手机号过滤 | 拒绝 | 掩码列做等值过滤会成为「号码是否存在」的探测预言机 |
SELECT * | 拒绝 | 星号会让新增敏感列自动泄露 |
🔑 三个决定值得抄:① 列必须显式声明,禁止
SELECT *(新增列不会自动泄露);② 脱敏表达式放在 SELECT 里(引擎执行,客户端拿不到明文);③ 掩码列不可用于过滤(否则可以二分/枚举探测原值)。
动态脱敏与静态脱敏
| 动态脱敏 | 静态脱敏 | |
|---|---|---|
| 数据落盘 | 明文(或加密) | 已脱敏 |
| 实现 | 视图 / masking policy / UDF | ETL 洗数、测试数据生成 |
| 可逆 | 通常不可逆(令牌化除外) | 不可逆 |
| 适用 | 生产查询、按角色展示 | 测试/开发/外部分析 |
| 风险 | 直读存储、导出、日志仍可能泄露 | 关联能力丢失,分布失真 |
| 性能 | 每行计算,聚合查询有开销 | 无额外开销 |
脱敏的强度与坑(哈希可枚举、k-匿名、l-diversity)在 38.5 数据分级与脱敏 有实测,这里只补数据平台特有的三条:
- 一致性:同一手机号在订单表、用户表、日志里必须得到同一个掩码,否则无法关联。做法是用带密钥的确定性 HMAC,而不是随机盐或裸 SHA。
- 聚合泄露:
GROUP BY 部门后某组只有 1 人,AVG(工资)就等于个人工资。小分组要抑制(HAVING count(*) >= k)或加噪。 - 日志与外发:查询日志、导出文件、BI 截图都是泄露面;脱敏只做在结果集上,等于没做。
湖仓:表权限不等于文件权限
湖仓的权限有两套边界:
| 边界 | 控制 | 风险 |
|---|---|---|
| catalog / 表 | 谁能 SELECT 这张表、看哪些列/行 | 策略没覆盖新表 |
| 存储 / 文件 | 谁能读 S3/OSS 上的 Parquet | 有 bucket 读权限就绕过表策略 |
最常见的越权:表上配了行过滤,但 analyst 的云账号还能直接 aws s3 cp s3://lake/orders/ ... 把 Parquet 拉走(湖仓的存储与表两套边界见 60.3 数据湖与湖仓一体)。防护是关闭直读:查询只能通过引擎,引擎用短期凭证访问存储;bucket 策略拒绝非引擎角色;预签名 URL 短 TTL + 审批 + 水印。
Ranger 的行过滤 + 列掩码(Hive/Spark 插件,JSON 字段示意,未验证):
{
"service": "hive",
"name": "dw_orders_tenant_filter",
"resources": {
"database": {"values": ["dw"]},
"table": {"values": ["orders"]},
"column": {"values": ["*"]}
},
"rowFilterPolicyItems": [{
"users": ["analyst"],
"accesses": [{"type": "select", "isAllowed": true}],
"rowFilterInfo": {"filterExpr": "tenant_id = get_session_tenant()"}
}],
"dataMaskPolicyItems": [{
"users": ["analyst"],
"accesses": [{"type": "select", "isAllowed": true}],
"dataMaskInfo": {"dataMaskType": "MASK_SHOW_LAST_4", "valueExpr": ""}
}]
}
Snowflake 的 masking + row access policy(语法示意,未验证;版本差异以官方文档为准):
CREATE MASKING POLICY phone_mask AS (val STRING) RETURNS STRING ->
CASE
WHEN CURRENT_ROLE() IN ('SUPPORT') THEN '*******' || RIGHT(val, 4)
WHEN CURRENT_ROLE() IN ('FINANCE') THEN NULL
ELSE LEFT(val, 3) || '****' || RIGHT(val, 4)
END;
ALTER TABLE orders MODIFY COLUMN phone SET MASKING POLICY phone_mask;
CREATE ROW ACCESS POLICY tenant_policy AS (tenant_id STRING) RETURNS BOOLEAN ->
tenant_id = CURRENT_ROLE(); -- 生产上用会话变量/映射表,别用角色名当租户
ALTER TABLE orders ADD ROW ACCESS POLICY tenant_policy ON (tenant_id);
⚠️ 行过滤谓词会参与分区裁剪。如果策略写成
tenant_id = current_setting('app.tenant'),优化器可能无法下推,导致全表扫描;生产上要把租户列放进分区/排序键,或让引擎支持策略谓词下推。安全策略的性能代价必须提前压测。
权限生命周期:只授不收,等于慢慢全员管理员
| 阶段 | 动作 | 可量化指标 |
|---|---|---|
| 申请 | 说明用途、范围、有效期,绑定数据分级 | 无审批单的授权数 = 0 |
| 审批 | 数据 owner + 安全双签(高危数据) | 高危授权双签率 100% |
| 授予 | 按最小范围、最短有效期授予 | 平均授权范围 / 有效期 |
| 使用 | 记录访问,异常检测 | 未使用权限占比 |
| 复核 | 每季度由 owner 确认,未确认自动回收 | 复核完成率 ≥ 95%,回收率 10–30% |
| 回收 | 离职、转岗、项目结束自动触发 | 离职账号残留 = 0 |
🔑 权限的自然演化方向是膨胀:人只会申请,不会主动退。只有定期复核(recertification)能真正减少权限,其他机制都只是在「防止继续增加」。
审计:没有审计的权限等于没有权限
| 必记字段 | 用途 |
|---|---|
| 主体(用户 / 角色 / 服务) | 谁 |
| 动作(select / export / grant) | 做了什么 |
| 资源(库.表.列 / 行范围) | 对什么 |
| 结果(行数 / 字节数 / 拒绝原因) | 看了多少 |
| 策略版本 | 当时生效的是哪条规则 |
| 客户端 IP / 应用 | 从哪来 |
| 时间 / 查询指纹 | 什么时候、什么模式 |
异常检测的四条规则(简单但有效):
- 跨租户:一个会话在短时间内命中多个
tenant_id; - 批量导出:单次返回行数 / 字节数超过该角色 P99 的 10 倍;
- 全表扫描:查询没有分区谓词,且扫描量超过阈值;
- 拒绝激增:同一主体连续被拒,可能是试探或配置错误。
审计日志要防篡改(WORM / 独立账号 / 哈希链),保留期按合规要求(常见 6 个月到 3 年)。审计本身的方案见 3.5 审计与取证。
多租户:越权测试要进 CI
| 测试 | 做法 | 通过标准 |
|---|---|---|
| 横向越权 | 用 t1 会话查 t2 的 ID | 0 行或 403 |
| 纵向越权 | analyst 调 admin 接口 | 403 |
| 参数篡改 | 改 tenant_id / dept_id 参数 | 服务端忽略客户端值 |
| 掩码绕过 | 用函数/CAST/LIKE 探测手机号 | 仍为掩码 |
| 导出绕过 | 分页拉全量、CSV 导出 | 行数上限 + 审批 |
| 直读存储 | 用云凭证读 Parquet | 拒绝 |
🔑 多租户的底线是:租户 ID 只能来自认证上下文,永远不能来自请求参数。租户隔离的三种模型(独立库 / 共享库 / 混合)见 49.4 多租户权限模型。这一条写进代码评审清单,比任何「注意越权」的培训都有效。
生产落地:指名 → 可抄 → 可验 → 可退
指名:Hadoop/Spark 生态用 Apache Ranger;Trino 用 access control + row filter(或 OPA);Databricks 用 Unity Catalog 的 row filter / column mask;Snowflake 用 masking policy + row access policy;AWS 湖仓用 Lake Formation LF-Tags。替代品按云选,不要把策略绑死在某个引擎的私有语法里。
可抄(本地可跑的策略编译,见本节代码;产品侧用上面的 Ranger / Snowflake 片段,均未验证)。
可验:本节 12 条用例全绿即最小验收;生产上加两组回归——① 「t1 会话查 t2 数据」必须 0 行;② 「EXPLAIN 看行过滤谓词是否下推」必须出现在扫描节点,否则全表扫描。可退:新策略先 shadow(只记录不放行),对比新旧判定差异;上线后保留 break-glass 角色(双人审批 + 全量审计),策略本身用「关闭开关 + 回滚到上一版」而不是回滚整次发布。
反模式
| 反模式 | 后果 |
|---|---|
应用层拼 WHERE tenant_id=... | 漏一处 / 一个 OR 1=1 就跨租户(本节实测 4 行泄露) |
允许 SELECT * | 新增敏感列自动泄露,策略永远追不上 schema |
| 只做表权限、不做行/列 | 一次授权等于整表 + 全列 |
| 在应用层做掩码,接口返回明文 | 抓包即拿到全量;日志/导出仍泄露 |
| 用角色名做数据范围判定 | 新角色要改 N 个服务,矩阵画不出来 |
| 超管万能且不审计 | 出事后无法定位,合规直接不通过 |
| 表上配了行过滤,对象存储仍可直读 | 绕过引擎拉 Parquet,策略形同虚设 |
| 权限只加不减,从不复核 | 三年后人人都是管理员;离职账号仍在 |
| 用无密钥哈希脱敏手机号 | 号段空间小,GPU 秒级枚举(见 38.5) |
自检
Q1 表权限、列权限、行权限各防什么?为什么只做表权限会出事故?
▸ 答案:表权限防「能不能查这张表」,列权限防「能不能看这一列/看到的是不是明文」,行权限防「能看哪些行」。只做表权限时,任何拿到 SELECT 的人都能看全表所有列与所有租户的行——一次授权就是整表泄露;而数据平台里「分析师需要看订单」通常只应看本租户、不含手机号明文。
Q2 为什么应用层拼 WHERE 不可靠?除了注入,还有哪些绕过方式?
▸ 答案:因为过滤逻辑分散在 N 个接口,漏一处就漏一片;用户输入直接进 SQL 会被 OR 1=1 穿透(本节实测 4 行跨租户)。此外还有:分页/聚合/Join 子查询绕过、导出接口另写一套 SQL、缓存命中他人数据、前端隐藏字段但接口返回全量。可靠做法是把策略放在引擎/网关,应用只传「结构化查询 + 身份」。
Q3 动态脱敏与静态脱敏怎么选?各自最大的风险是什么?
▸ 答案:动态脱敏适合生产查询(按角色实时掩码,数据仍是明文/密文),风险是直读存储、导出、日志仍可能泄露;静态脱敏适合测试/开发/外部分析(落盘即脱敏),风险是关联能力丢失、分布失真、无法回溯。选择判据是「业务是否需要真值/关联」与「是否存在绕过引擎的访问路径」。
Q4 为什么「按手机号等值过滤」要拒绝,即使返回的是掩码?
▸ 答案:因为掩码列上的等值过滤会变成探测预言机:攻击者可以枚举候选号码,观察「有行/无行」来确认某个手机号是否在库中,甚至二分缩小范围。掩码只保护「返回值」,不保护「是否存在」。正确做法是禁止在掩码列上过滤,或只允许基于令牌(token)的精确匹配。
Q5 湖仓里「表权限」和「文件权限」为什么要一起管?
▸ 答案:表权限由 catalog/引擎判定,文件权限由对象存储判定;两者是两套边界。只配表策略,用户仍可用云凭证直接读 Parquet,绕过行过滤与列掩码;只配文件权限,则无法表达「同表不同列/行」的细粒度策略。必须关闭直读、用短期凭证、让引擎成为唯一入口。
Q6 审计日志至少记哪些字段?怎样发现「爬取型越权」?
▸ 答案:主体、角色、动作、资源(库表列/行范围)、返回行数与字节数、拒绝原因、策略版本、客户端 IP、时间与查询指纹。爬取型越权的特征是:短时间访问大量不同资源 ID、返回行数远超该角色基线、跨多个租户、在非工作时间批量导出。用「单会话不同资源数 / 返回行数 / 跨租户数」三个指标做阈值告警。
Q7 多租户越权测试最少要覆盖哪几类?通过标准是什么?
▸ 答案:横向(t1 查 t2)、纵向(低权限调高权限)、参数篡改(改 tenant_id/dept_id)、掩码绕过(函数/CAST/LIKE)、导出绕过(分页/CSV)、直读存储。通过标准:跨租户 0 行或 403;服务端忽略客户端传入的租户/部门参数;掩码在接口与导出里一致;直读对象存储被拒绝。把这些用例放进 CI,每次改权限代码都跑。
关联
- 前置:38.3 授权 (RBAC / ABAC / 权限模型)、38.5 数据分级与脱敏
- 并列:49.3 权限中心与权限矩阵、49.4 多租户权限模型
- 下游:60.3 数据湖与湖仓一体、60.4 元数据管理与数据目录
- 待补:68.6 误杀与漏杀的权衡(未写,无链接)