脱敏说明:本文基于单体业务系统的真实代码结构整理。文中的“业务记录”“来源”“执行人员”“团队负责人”均为泛化称呼。

我这个数据权限不是项目一开始就被完整设计出来的,一开始业务非常简单,后续逐渐迭代才加上。

系统早期只有几类用户,最直接的做法通常是:在查询条件里加上“只能看自己”“只能看本团队”。随着业务扩张,数据又会按来源、区域、团队、成员授权、公开范围等维度分配;同一用户在不同页面、不同资源上,甚至需要不同的可见范围。此时,简单的角色判断很快会变成散落在 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 条件追加到原查询。

相对字符串替换,这种方式至少有两个价值:

  1. 保留原有 WHERE 条件的优先级,减少括号错误。
  2. 把“业务筛选条件”和“权限筛选条件”分开维护,避免每个列表 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 第四层:把“谁能看什么”配置为授权关系

系统使用两类授权关系支撑策略:

  1. 来源授权:某个运营/支撑账号被授予哪些业务来源的数据范围。
  2. 成员授权:某个账号可见或可管理哪些团队负责人、哪些成员。

成员授权还通过切面参与管理类操作校验,避免“看得见成员列表”被误解为“可以编辑所有成员”。这让查询权限和写操作权限至少具备了分层意识。


四、现有设计已经做对了什么

本人能力有限,没有大量阅读和接触工业级的数据权限设计与落地,所以必然存在不足,在讨论不足之前,需要先承认这套设计已经比常见的“在 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 外部调用必须默认拒绝,不能用占位逻辑放行

数据权限拦截器通常会遇到“当前线程没有登录用户”的情况,例如开放接口、定时任务、消息消费和内部服务调用。

真正成熟的做法是明确区分三种身份:

  1. 已登录的人类用户。
  2. 具有可验证身份和最小范围声明的系统调用方。
  3. 没有身份上下文的未知调用。

其中第三类必须默认拒绝。外部系统识别不能依赖一个临时的“默认允许”实现;它应该校验签名、凭证、调用方身份、资源范围和审计信息,并把系统身份放入与人类用户不同的安全上下文中。

5.4 SQL 条件由字符串拼接生成,表达能力和可维护性受限

策略最终生成 SQL 片段,再由 AST 解析器拼接到原查询。当前来源 ID 已做基本转义,但这种模式在后续迭代过程中仍有几个长期问题

  1. 权限策略与具体表字段、表别名强绑定,后续做解耦合与持续扩展能力有限。
  2. 子查询、复杂 JOIN、UNION、CTE 或方言 SQL 可能增加解析兼容成本,目前只支持Mysql,后续如果有信创或者迁移数据库会带来版本语法不兼容风险。
  3. 条件无法天然参数化,难以统一做 SQL 计划复用和审计解释。
  4. “授权表里的字段”和“实际生成过滤条件”可能逐渐不一致。

我调研成熟方案发现,他们通常会将策略编译为参数化条件、可组合的 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 层以不同方式执行结果。


七、分阶段改造展望

阶段一:先补齐安全基线

  1. 建立受保护资源清单:列表、详情、导出、统计、轨迹、异常、批处理任务。
  2. 对所有读取路径做数据范围覆盖率测试;未标注的受保护 Mapper 在测试环境直接失败。
  3. 外部调用默认拒绝,建立系统身份认证和显式授权范围。
  4. 为更新、删除、批量操作补充资源级操作校验。
  5. 为无授权、跨团队、过期授权、公开范围等边界写回归测试。

阶段二:统一权限模型和决策入口

  1. 定义统一授权请求:subject + action + resource + context
  2. 用枚举统一资源和动作,避免散落字符串。
  3. 把角色策略、来源授权、成员授权统一输出为 DataScopeAuthorizationDecision
  4. 让查询、详情、导出、统计和写操作都调用同一套决策逻辑。

阶段三:增强可审计性和可运营性

  1. 授权变更记录操作者、前后值、原因、有效期和审批信息。
  2. 支持“权限解释”:返回命中的策略和拒绝原因,但不要向无权用户泄露资源存在性。
  3. 增加授权版本号或缓存失效机制,避免权限变更后旧范围长期生效。
  4. 建立权限矩阵自动化测试,并把高风险查询纳入发布前检查。

阶段四:按复杂度选择 ABAC 或数据库级防护

当规则仍以组织、团队、来源为主时,应用层范围过滤足够实用;当敏感等级、时间窗口、地域、设备可信度和跨系统共享逐步出现时,再考虑:

  • 引入属性化策略表达。
  • 使用参数化策略编译器或 Specification。
  • 对极高敏感数据使用数据库视图、RLS 或独立数据服务做最后一道防线。

八、结语

数据权限不是给 SQL 追加一段 WHERE 条件,而是一套持续回答下面问题的能力:

  1. 在访问?
  2. 想对什么资源执行什么动作
  3. 基于哪些组织、来源、属性和时间条件允许?
  4. 这次决策能否被审计、解释和回放?

当前方案已经完成了从“散落角色判断”向“策略化行级过滤”的关键一步。下一步不必追求一步到位的权限平台,而应先做到:默认拒绝、覆盖完整、读写一致、决策可审计。我觉得只有这样,数据权限才能从项目里的辅助功能,演进为真正可信的基础设施。

下一篇