Java EE 企业应用 25:角色许可为何不能代替对象权限
审批员有角色,却不能批这张单
租户 tenant-one 的审批员有 APPROVER 角色。一张申请属于 tenant-two,另一张在 tenant-one 却超出其 5000 元审批上限。两张都应被拒。角色回答“可以发起哪类动作”,对象检查回答“可以对哪一张申请在当前状态做动作”。第 24 篇要求先有可验证 principal;现有 LabRequestsResource.approve 仅从 X-Lab-Tenant 获取自报租户,没有认证/角色/额度检查。本章是待接入的授权设计,不把教学端点的状态转换结果冒充合法审批。
最小权限矩阵先固定主体与动作:匿名对采购操作拒绝;申请人只创建/提交自己未提交的草稿;审批员在已授权租户及额度内批准或拒绝已提交申请;订单操作员仅对批准单下单;管理员负责组织管理,不默认绕过财务分离职责。当前 schema 没有 created_by、审批额度配置或不可变的决策者字段,故“只提交自己的申请”在现有工程无法验证。示例引入这些列与关联关系是迁移设计,不是已运行的数据库结构。以主体 ID 而不是显示名关联权限;额度单位、币种和变更生效时点需在业务约定中明确。
两道闸门不是重复校验
对于受管会话 Bean,可用 @RolesAllowed("APPROVER") 在方法入口拒绝无角色调用;REST 路由还需选定同一身份/角色的安全约束,避免另一条直达方法的入口漏检。受管拦截只能覆盖经过容器的调用,不能保护任意 new 出来的对象或未加约束的实验接口。服务方法再读可信主体/租户、对象状态、金额和额度,不能根据请求体中的 role 决策。业务事务内条件更新保持申请只能由一个决策获胜;若先检查权限再长时间排队,批准时的组织关联、金额或额度可能已变,需要锁定或条件更新/版本检查,并定义重新授权时点。
目标 Java 入口是受管 bean 的 approve;受管入口从当前认证主体解析 AuthenticatedActor,不得把 Web 请求中的角色、用户或租户原样传入。所依赖的主体解析器和 DAO 均尚未实现,片段不能在当前工程中编译。
1 | |
此处以 @Stateless 会话 Bean 的受管调用为前提,容器管理事务默认为 REQUIRED;ActorDirectory 是拟议的受信目录查找接口,不是已实现身份能力,匿名 principal 时也须默认拒绝。部署时还须核对角色映射、异常回滚和 Web 层入口保护。实际入口须防止绕过受管调用,且拒绝事件的事务策略留待第 28 篇;代码中的运行时异常不是全部错误的 HTTP 状态映射。
1 | |
入口约定为 POST /api/requests/{id}/approve,接收的只有申请 ID 和预期版本;服务器从认证主体解析授权租户和额度。一次更新返回 1 行才表示该版本的审批状态改变;0 行可能是记录不存在、跨租户、状态不对、额度不足或竞争冲突,对外不应暴露“别的租户存在这张单”。可在内部用受约束只读查询分辨 403/404/409,但绝不能先无租户查询再返回对象细节。若采用独立审批记录及审计,必须在同一数据库事务写入,并核对异常后的行集合。现有 JdbcRequestStore.transition 虽有 id + tenant_id + version + status 条件,但没有审批角色和额度条件,也没有上述 approver_grant 表;不能直接声称已经实现此 SQL。
预期验收和现状
后续部署前先实施可信身份与 schema 迁移,再写受管入口。正常用例:APPROVER 对自己租户、额度以内、SUBMITTED 且版本匹配的申请,HTTP 按契约成功,版本递增 1;失败用例:匿名、BUYER、另一租户审批员、超额度审批员、旧版本审批员分别拒绝,申请、审批记录、订单均不产生非法变化。特别检查相同 ID 交错审批时条件 UPDATE 的行数和事务回滚,不能由单次 204 推断并发正确。403/404 的具体映射需在安全策略确定后冻结;拒绝跨租户时建议统一 404,且日志只保留脱敏 ID 与拒绝原因码。
**当前没有 POST /api/requests/{id}/approve 的受保护实现,也没有审批授权表;所有上述正常/失败 HTTP、SQL 实验均 NOT_RUN。**已有 /api/lab/requests/{id}/approve 只在本机隔离的 JAVAEE_DEMO_MODE=true 下演示业务状态迁移,X-Lab-Tenant 可伪造,不应用它做权限矩阵测试。可用 rg -n 'approve|X-Lab-Tenant' examples/javaee-enterprise/webapp/src/main/java 定位现有入口,但这是静态检查,不是安全验收。
为什么角色许可不能放进唯一一条审批 SQL
@RolesAllowed("APPROVER") 在容器受管方法入口处处理的是“这个主体能不能调用审批类动作”。它既不知道目标申请属于哪个租户,也不知道金额是否超过此人授权额度,更不知道申请状态是否还是 SUBMITTED。反过来,SQL 中查询 approver_grant 即便能查出同租户额度,也不能在没有可信 principal 的情况下授予匿名者容器角色。安全路径必须按顺序建立身份、验证方法角色、取得受信租户和授权记录、在单次业务事务中针对具体对象执行条件迁移。少了前两步,后面的 user_id 可能只是客户端自报字段;少了最后一步,管理员或同角色审批员就可能越过对象范围。
角色与对象许可也不是静态的两张白名单。APPROVER 可以表示“被授予审批职责”,而财务分离要求“申请人不得审批自己创建的单”。因此新增的 created_by 要与受信主体 ID 比对,而不是与用户显示名比对;若当前库没有此列,不能凭业务口头要求宣称已有四眼原则。可在拟议 UPDATE 中增加 AND request.created_by <> ?,参数从认证主体而来;申请创建时就必须把创建者写入可信列。若订单员被授予 APPROVER 角色,也不意味着可以给自己制造的申请补批,角色集合只是必要条件而非充分条件。管理员是否可以代批也必须明示审批流程与审计责任,不应因为角色名叫 ADMIN 就默认跳过校验。
条件更新与授权记录同时变化怎么办
文章现有 SQL 把申请状态、旧版本、租户、金额和有效授权记录放到同一条 PostgreSQL 16 UPDATE 中,消除了“先单独查金额、再无条件改状态”这一段竞态。但如果另一事务刚好撤销 approver_grant,语句中的 EXISTS 是否始终反映最新撤销,需要结合隔离级别和授权行的锁定时机判断。PG16 默认 READ COMMITTED 以语句快照读取其他行;授权撤销若在审批语句开始后提交,审批可能仍按旧快照判断并提交。不能把“授权关联写在 EXISTS 里”说成“任何并发撤销都立即阻断审批”。
应先确定业务规则的线性化点:例如规定撤销生效前已经开始的审批可以完成,或规定撤销提交之后不得再批准。在后一合同下,审批和撤销必须对相同授权行建立共同序列化点,候选方案是在事务中先按 (user_id, tenant_id) 锁住授权行并核对启用状态、额度,再做带申请状态和版本条件的更新;撤销事务更新同一行时会等待或先于审批被观察到。两端需要同样的锁顺序,避免授权行与申请行被反序持有形成死锁。若角色映射本身来自容器会话缓存,也要定义禁用/撤销怎样刷新受管角色。上述锁顺序、角色同步、额度变更协议未实装,均 NOT_RUN。
额度规则也要明确是单张申请总额还是一天内合计限额。示意 SQL 的 request.total <= approval_limit 只验证单张上限,不能保护每日累计审批预算;两张 4000 元申请各由同一人批准,单笔 5000 元限额仍允许二者通过。累计额度应另有共享余额行或 PG16 可序列化的读—判断—写事务,不得从单行条件外推出跨行约束。币种不同必须先按冻结的金额换算规则得到可比较值;不能把 5000 美元与 5000 人民币以数值相同就认作相等。源数据的单位和决定时汇率若需审计,要随决策记录版本。
给零行结果确定安全的外部语义
UPDATE ... RETURNING 未返回 id,可能是对象不可见、申请早已拒绝、金额超限、主体权限被撤销或两个审批员争用旧版本。要让合法用户知道“版本旧了,请刷新”,可以在同一可信租户和主体授权范围内另做一次受约束查询;不可先按全局 id 读申请详情,再根据错误类型公开金额或另一个租户的存在性。匿名应在方法入口被认证机制挑战;已认证却不具有角色的人应被角色约束拒绝;有角色但访问另租户对象时可按统一“不可见”策略响应。具体 403/404/409 映射需要在安全策略里冻结并验证,数据库 0 行本身不指定 HTTP 状态。
成功路径也要同时观察两个层级:HTTP 请求通过角色约束,只说明容器允许进入方法;SQL 返回一行并且受管事务提交后新连接看见 APPROVED 与版本加一,才说明业务决定生效。如果在写入审计时抛错,同一事务应把这次审批撤销,不应返回 204;第 28 篇给出审计边界。客户端发两次相同审批请求,第二次 0 行不应被悄悄改写成新的审批;冲突处理要把已经提交的决定呈给有权主体重新判断,而不是服务器自动升级旧版本参数。
何时才能执行正常与失败矩阵
在隔离数据库先按迁移建立两个租户、各两位合成主体、三张 SUBMITTED 申请:同租户额度内、同租户超额度、另一租户额度内;为每条设置明确 version 和创建人。部署启用的受管角色映射与批准路由后,再用独立认证会话逐一请求。具体命令属于迁移后才存在的接口合同,例如受保护方案采用会话 Cookie 时:
1 | |
若浏览器 Cookie 会话已启用,第 27 篇的 CSRF 令牌亦须携带;缺令牌本身可导致拒绝,不能把它误认成“额度保护成功”。命令里的 ID、端口、测试证书和会话文件都只是待实现环境占位,现有 WAR 没有该受保护路由与 cookie jar,此命令不是当前可运行的通过记录。失败矩阵在每次请求前后由新连接查询 purchase_request 和审批记录,给出未授权者无法修改对方行的事实;额度、旧版本和组织撤销还要分别做受控交错,而不是依次发送几个顺序请求就推断并发安全。
授权关联还要处理人员调岗与会话持续时间。一次审批在方法入口有 APPROVER,但期间组织成员关系被撤销,若业务规定撤销立即生效,须使容器角色、授权行及业务事务共享可核查的生效顺序;不能只改授权行,却让旧会话带着长期缓存的角色继续进入审批。反之,若允许已经开始的事务按旧规则完成,也要写入审计中可追溯的策略版本,不能事后用新额度回头判断这笔决定“从未有权”。金额可能以服务端目录固定价计算,不能让客户端提交一个更小的 total 以绕过额度;审批 SQL 所依据的 request.total 应与当前明细及金额版本一致。上述重算、冻结与调岗并发合同都依赖尚未迁移的授权表和主体映射,暂不宣称容器已执行。
可把成功用例与五类拒绝做成一张可复跑矩阵:每格先固定主体、角色、租户、申请状态、版本、额度,再保存请求、容器是否进入用例、条件 UPDATE 行数和第三连接的申请状态。对于角色拒绝,SQL 不应被调用;对于对象拒绝,可能进入用例但必须更新 0 行;对于乐观冲突,要记录另一个事务提交的版本。若表格只填“403”或“204”,就无法指出拦截器与 SQL 哪个层级在起作用。真实身份入口和审批迁移尚未落地,矩阵按 NOT_RUN 留空而不是用演示头凑数。
审批主体、对象与额度的正常/失败条件及证据字段见授权矩阵实验卡;正式身份与受管入口尚未落地。
两道有解的练习
练习一: 某用户 isUserInRole("APPROVER") 返回 true,批准另一租户的单也返回 204,错在哪里?
解: 角色只放行审批动作;要由认证主体查询可信租户关联,SQL 在同一张目标申请上约束 id + tenant_id + state + version + amount,并核对受信额度。不能信请求头中的租户或角色。0 行不能自动解释为审批成功;现有演示端点即使成功也不能充当安全测试。
练习二: 在事务外先查申请金额为 4900 元,审批限额 5000 元,随后另一个事务改额为 6000 元,当前事务仍执行不带金额条件的 UPDATE。补什么?
解: 在写入的原子边界重新检查当前金额和额度,例如将限额/状态/版本约束合入条件 UPDATE;权限关联或限额可并发变更时还需按隔离级别锁定/版本化授权记录,明确“以哪一时刻的额度为准”。期待 0 行拒绝并检查最终数据库行;该交错场景未运行。
版本与依据
运行目标沿用 JDK 21、Jakarta EE 11、Open Liberty 26.0.0.5、PostgreSQL 16;本章未迁移 schema 或执行 SQL。规范入口:Jakarta Enterprise Beans 4.0 核心规范、Jakarta Security 4.0、Jakarta Transactions 2.0;条件更新为 PostgreSQL 16 SQL 设计,RETURNING 应以 PostgreSQL 16 UPDATE 核对。JPA 乐观锁对照只引用已存在的JPA 事务与冲突;本章主线是 JDBC、SQL 和受管事务,不要求把 DAO 改为 EntityManager。






