脱敏说明:本文基于单体业务系统的真实代码结构整理。文中的“业务记录”“来源”“执行人员”“团队负责人”均为泛化称呼。
我这个数据权限不是项目一开始就被完整设计出来的,一开始业务非常简单,后续逐渐迭代才加上。
系统早期只有几类用户,最直接的做法通常是:在查询条件里加上“只能看自己”“只能看本团队”。随着业务扩张,数据又会按来源、区域、团队、成员授权、公开范围等维度分配;同一用户在不同页面、不同资源上,甚至需要不同的可见范围。此时,简单的角色判断很快会变成散落在 Controller、Service 和 Mapper 中的大量 if 条件。
本文复盘一个已有数据权限体系的设计:它已经通过角色策略、授权关系表和 MyBatis 拦截器解决了不少真实问题;同时也暴露出成熟权限体系在覆盖率、默认安全、审计和可演进性上的典型差距。
一、先区分三个容易混淆的概念
很多项目把下面三件事统称为“权限”,但它们解决的问题不同:
| 概念 | 要回答的问题 | 示例 |
|---|---|---|
| 功能权限 | 能不能访问某个接口或按钮? | 能否进入数据看板 |
| 数据权限 | 能看到哪些记录? | 能否看到其他团队的业务记录 |
| 操作权限 | 能否修改某条记录或管理某个成员? | 能否调整成员授权范围 |
本文讨论的重点是数据权限,但一个可用的数据权限体系必须与功能权限、操作权限协同:用户即使能打开列表页,也未必能看到所有数据;用户即使看得到成员,也未必能修改该成员的配置。
二、当前方案的业务目标
我负责这个系统的核心资源具有明显的组织协作特征:
- 一线执行人员通常只处理本人负责的记录。
- 团队负责人需要查看自己及下属的工作范围。
- 运营或支撑角色按被授权的“业务来源”查看数据。
- 少数敏感场景需要比日常列表更严格的团队隔离。
- 部分任务属于公开协作范围,但公开不代表完全无边界。
因此,权限规则不能只写成“角色 A 看全部、角色 B 看自己”。它至少要综合身份、组织关系、来源授权、区域/团队归属和资源属性。

三、现有架构:注解、拦截器、策略和授权表四层协作
当前我落地实现并不是把权限条件直接写死在某个列表 SQL 中,而是形成了四层协作。

3.1 第一层:用注解声明“这条查询需要数据范围”
系统使用 @DataScope 的 Mapper 方法注解表达数据范围(这个是抄若依框架)。注解携带三个关键信息:
- 表别名:让拦截器知道权限条件应追加到哪张资源表。
- 策略名称:在少数敏感场景下,为同一角色指定更严格的过滤策略。
- 公开范围豁免:仅放开某个明确的来源条件,不放开团队或人员归属条件。
这种设计的优点是权限规则离 SQL 很近,开发者可以看见某个 Mapper 查询是否需要行级过滤;同时,复杂 SQL 不必在 XML 中重复拼接相同的组织条件。
脱敏后的最小示例如下。日常列表使用默认策略;敏感查询可以显式指定更严格的策略:
@DataScope(alias = "r", publicGrabExempt = true)
Page<RecordRow> selectRecordPage(Page<?> page, RecordQuery query);
@DataScope(alias = "r", strategy = "managerStrict")
List<TrackRow> selectSensitiveTrack(TrackQuery query);
注解并不直接决定“谁能看什么”,它只声明:这条 SQL 是受保护资源,执行前必须补上数据范围。
3.2 第二层:MyBatis 拦截器统一改写查询
查询执行前,拦截器通过 MappedStatement 找到 Mapper 方法上的注解,根据当前登录用户选择数据范围策略,并利用 SQL AST 解析器把权限表达式以 AND 条件追加到原查询。
相对字符串替换,这种方式至少有两个价值:
- 保留原有
WHERE条件的优先级,减少括号错误。 - 把“业务筛选条件”和“权限筛选条件”分开维护,避免每个列表 SQL 都复制组织范围逻辑。
下面是拦截器核心流程的脱敏简化版。重点在于:先解析注解,再根据当前用户得到策略条件,最后通过 SQL AST 合并条件,而不是直接拼接字符串到 SQL 尾部。
DataScope scope = findDataScope(mappedStatement);
if (scope == null || scope.ignore()) {
return;
}
LoginUser user = LoginUserUtils.getCurrentLoginUser();
String predicate = scopeHandler.resolve(user, scope).toSql(scope.alias());
// 原条件:r.status = 1
// 权限条件:r.owner_id = '当前用户'
// 合并结果:(r.status = 1) AND (r.owner_id = '当前用户')
appendBySqlAst(boundSql, predicate);
3.3 第三层:按角色拆分数据范围策略
不同角色通过策略接口生成不同的过滤片段。当前策略大致可以概括为:
| 角色类型 | 可见范围的主要依据 |
|---|---|
| 一线执行人员 | 本人负责的数据 |
| 小组负责人 | 本人及下属负责的数据 |
| 团队负责人 | 本团队数据、满足来源授权的共享数据、符合区域条件的待分配数据 |
| 运营支撑角色 | 后台配置的来源授权范围 |
| 高敏感场景负责人 | 仅限本人直接负责的团队数据 |
策略模式在这里很合适:新增角色或调整某类角色的可见范围时,不需要在一个巨大的 if-else 中修改所有分支。
例如,“一线执行人员只能看本人负责记录”的策略可以收敛为一个独立实现。下面省略了项目中的具体字段和角色名称:
@Component
public class ExecutorScopeHandler implements DataPermissionHandler {
@Override
public String getType() {
return "executor";
}
@Override
public String getSqlSegment(LoginUser user, String alias) {
return String.format("%s.owner_id = '%s'", alias, user.getId());
}
}
这个例子也能解释后文的改造方向:策略接口的分层是正确的,但以 SQL 字符串作为策略输出,仍会让权限规则与字段名、表别名紧耦合。
3.4 第四层:把“谁能看什么”配置为授权关系
系统使用两类授权关系支撑策略:
- 来源授权:某个运营/支撑账号被授予哪些业务来源的数据范围。
- 成员授权:某个账号可见或可管理哪些团队负责人、哪些成员。
成员授权还通过切面参与管理类操作校验,避免“看得见成员列表”被误解为“可以编辑所有成员”。这让查询权限和写操作权限至少具备了分层意识。
四、现有设计已经做对了什么
本人能力有限,没有大量阅读和接触工业级的数据权限设计与落地,所以必然存在不足,在讨论不足之前,需要先承认这套设计已经比常见的“在 Controller 里判断角色”成熟很多。
但也仅仅是成熟一点,在落地实践,运营迭代过程中也暴露出很多问题。
4.1 将行级过滤下沉到了查询执行层
对已标注的 Mapper 查询而言,调用方不需要每次手动传入团队 ID 或来源 ID。这样能减少 Service 漏加条件的概率,也让分页、列表和复杂联表查询共享同一条权限规则。
4.2 支持“同角色不同场景”的严格程度
日常列表可能允许负责人查看授权来源的协作数据;轨迹、风险或异常记录则需要严格限定在直属团队内。通过策略覆盖,而不是复制一套新角色,是一个实用的折中。
4.3 无授权时采用空结果,而不是放大范围
来源授权策略在未配置授权时会构造“匹配不到数据”的条件,而不是默认返回全部记录。这体现了最小权限原则:授权缺失应减少可见性,而不是扩大可见性。
4.4 公开协作仍保留组织边界
公开范围的处理不是简单跳过全部数据权限,而是只豁免某个来源条件,人员、团队或区域条件仍然存在。这比“公开即全员可见”更符合真实协作系统的风险控制需求。
五、与成熟数据权限体系相比,还差在哪里
下面的差距不代表当前实现不可用,而是说明当前系统仍处于“面向核心资源的工程化方案”,尚未演进为平台级授权能力,很多接口和数据还未全覆盖数据权限。
5.1 覆盖方式是显式接入,天然存在旁路风险
拦截器只会处理带有 @DataScope 的查询。未标注的方法会直接执行原始 SQL。
这是我当时设计的简化开发,减少编码量,但它意味着:新增列表、导出、统计、报表、批处理查询时,开发者必须主动记得加注解。只要漏掉一次,数据权限就可能被旁路。
尤其要关注统计和看板:它们通常不是标准分页列表,SQL 复杂、资源维度多、性能压力大,最容易被当作“只读查询”而忘记继承数据范围。
成熟方案的做法:建立资源级默认策略。只要查询命中受保护资源,就默认需要数据范围;未声明范围应在开发或测试阶段失败,而不是静默放行。
5.2 读取拦截较集中,写入权限仍需逐处保证
当前拦截器主要作用于查询。更新、删除、批量操作和异步任务不能只依赖查询过滤:一个用户即使看不到某条数据,也要保证无法通过猜测 ID、批量接口或后台任务修改它。
成熟体系会把“可读”“可更新”“可删除”“可授权”作为不同动作建模,例如:
resource: business_record
actions: read, update, delete, export, assign, grant
然后让所有写操作在领域服务或资源仓储层执行统一的授权决策。
5.3 外部调用必须默认拒绝,不能用占位逻辑放行
数据权限拦截器通常会遇到“当前线程没有登录用户”的情况,例如开放接口、定时任务、消息消费和内部服务调用。
真正成熟的做法是明确区分三种身份:
- 已登录的人类用户。
- 具有可验证身份和最小范围声明的系统调用方。
- 没有身份上下文的未知调用。
其中第三类必须默认拒绝。外部系统识别不能依赖一个临时的“默认允许”实现;它应该校验签名、凭证、调用方身份、资源范围和审计信息,并把系统身份放入与人类用户不同的安全上下文中。
5.4 SQL 条件由字符串拼接生成,表达能力和可维护性受限
策略最终生成 SQL 片段,再由 AST 解析器拼接到原查询。当前来源 ID 已做基本转义,但这种模式在后续迭代过程中仍有几个长期问题:
- 权限策略与具体表字段、表别名强绑定,后续做解耦合与持续扩展能力有限。
- 子查询、复杂 JOIN、UNION、CTE 或方言 SQL 可能增加解析兼容成本,目前只支持Mysql,后续如果有信创或者迁移数据库会带来版本语法不兼容风险。
- 条件无法天然参数化,难以统一做 SQL 计划复用和审计解释。
- “授权表里的字段”和“实际生成过滤条件”可能逐渐不一致。
我调研成熟方案发现,他们通常会将策略编译为参数化条件、可组合的 Specification,或在数据库侧通过安全视图/行级安全策略实现。对于单体系统,不一定要立即上数据库 RLS,但至少要避免策略逻辑无序增长为字符串模板库,但是目前看已经有这种趋势。
5.5 当前更接近 RBAC + 数据范围,尚未形成 ABAC
现有方案已经具备角色和关系授权,可归类为 RBAC + Data Scope:角色决定采用哪类策略,组织/来源关系决定可见记录。
成熟场景通常还会引入 ABAC(基于属性的访问控制):
- 用户属性:所属组织、岗位、值班状态、授信等级。
- 资源属性:敏感等级、所属区域、创建时间、当前阶段、归属团队。
- 环境属性:访问时间、设备可信度、网络位置、调用来源。
- 操作属性:查看、导出、修改、转交、授权。
但是,我想的是,不是所有系统都需要完整 ABAC,我上面列举的那些很多系统压根没有这么复杂庞大的权限,但当规则开始出现“某角色仅在某时段、某地区、某状态下可操作某类数据”时,继续叠加角色判断会很快失控。
5.6 缺少“为什么允许/拒绝”的可解释审计
当前系统记录了不少业务日志,但成熟的授权体系还应能回答:
某个用户在某个时间,通过什么授权关系、命中了哪条策略,因此能看到或不能看到这条数据?
这需要授权变更审计、策略版本、决策日志和查询追踪。它不仅用于安全排查,也能帮助业务人员解释“为什么我看不到这条数据”。
5.7 写操作不能只依赖查询过滤
列表查询做了行级过滤,并不等于修改接口天然安全。一个常见漏洞是:用户无法在列表中看到某条记录,却可以构造或猜测记录 ID,直接请求更新接口。
写操作至少需要在领域服务或资源服务入口再做一次动作授权;若涉及并发更新,还应把版本条件带入 SQL:
public void updateRecord(LoginUser actor, UpdateRecordCommand command) {
authorization.require(actor, Action.UPDATE, ResourceType.RECORD, command.recordId());
int updated = recordMapper.updateByIdAndVersion(
command.recordId(), command.version(), command.payload());
if (updated == 0) {
throw new BizException("记录已被其他操作修改,或当前用户无权操作");
}
}
这里要注意:对外不能通过错误信息确认“目标记录是否存在”,避免把权限校验本身变成枚举资源的通道。
六、一个更可演进的目标架构
目前项目以及接近生命周期的中后期,我个人评估后觉得没必要为了数据权限单独引入复杂的微服务,为什么会提微服务,因为目前市面上很多人会拿这个作为解耦合,三高比较好的解决方案。
但是我觉得更适合当前阶段的方向,是在单体内部形成一个统一的授权决策模块。

这里的核心不是增加一个名词,而是把职责分开:
- PDP(Policy Decision Point):根据用户、动作、资源和上下文作出允许/拒绝及范围决策。
- PEP(Policy Enforcement Point):Mapper 拦截器、Service 守卫、导出任务等执行决策。
- 策略仓库:保存角色、成员关系、来源范围、特殊授权及其有效期。
- 审计模块:记录授权被授予、撤销和被命中的过程。
在现有项目中,MyBatis 拦截器可以继续充当查询侧 PEP;重点是将散落的策略和字符串 SQL,逐步收敛到一个可测试、可解释的授权决策服务。
目标形态下,业务代码不再直接判断角色或手工拼接范围,而是向统一服务提出一次授权请求:
AuthorizationRequest request = AuthorizationRequest.builder()
.subject(currentUser)
.action(Action.READ)
.resource(ResourceType.RECORD)
.context(Map.of("scene", "dashboard"))
.build();
AuthorizationDecision decision = policyDecisionPoint.decide(request);
if (!decision.allowed()) {
throw new AccessDeniedException("无权访问该资源");
}
return recordQueryRepository.query(query, decision.dataScope());
这样,查询、导出、统计和写操作可以共享 AuthorizationRequest 的语义,只是在 PEP 层以不同方式执行结果。
七、分阶段改造展望
阶段一:先补齐安全基线
- 建立受保护资源清单:列表、详情、导出、统计、轨迹、异常、批处理任务。
- 对所有读取路径做数据范围覆盖率测试;未标注的受保护 Mapper 在测试环境直接失败。
- 外部调用默认拒绝,建立系统身份认证和显式授权范围。
- 为更新、删除、批量操作补充资源级操作校验。
- 为无授权、跨团队、过期授权、公开范围等边界写回归测试。
阶段二:统一权限模型和决策入口
- 定义统一授权请求:
subject + action + resource + context。 - 用枚举统一资源和动作,避免散落字符串。
- 把角色策略、来源授权、成员授权统一输出为
DataScope或AuthorizationDecision。 - 让查询、详情、导出、统计和写操作都调用同一套决策逻辑。
阶段三:增强可审计性和可运营性
- 授权变更记录操作者、前后值、原因、有效期和审批信息。
- 支持“权限解释”:返回命中的策略和拒绝原因,但不要向无权用户泄露资源存在性。
- 增加授权版本号或缓存失效机制,避免权限变更后旧范围长期生效。
- 建立权限矩阵自动化测试,并把高风险查询纳入发布前检查。
阶段四:按复杂度选择 ABAC 或数据库级防护
当规则仍以组织、团队、来源为主时,应用层范围过滤足够实用;当敏感等级、时间窗口、地域、设备可信度和跨系统共享逐步出现时,再考虑:
- 引入属性化策略表达。
- 使用参数化策略编译器或 Specification。
- 对极高敏感数据使用数据库视图、RLS 或独立数据服务做最后一道防线。
八、结语
数据权限不是给 SQL 追加一段 WHERE 条件,而是一套持续回答下面问题的能力:
- 谁在访问?
- 想对什么资源执行什么动作?
- 基于哪些组织、来源、属性和时间条件允许?
- 这次决策能否被审计、解释和回放?
当前方案已经完成了从“散落角色判断”向“策略化行级过滤”的关键一步。下一步不必追求一步到位的权限平台,而应先做到:默认拒绝、覆盖完整、读写一致、决策可审计。我觉得只有这样,数据权限才能从项目里的辅助功能,演进为真正可信的基础设施。