权限检查放在哪里:从入口到异步返回
卷中目录
很多人把权限做成一道门。门开了,后面的处理函数就默认可以改任何它拿得到的编号。演示时这条路很顺:登录拿到令牌,令牌里有用户编号,接口按编号读写。系统一旦变成多账号、多角色,并且发布动作要排队执行,漏洞会集中出现在两类缝里。缝的一侧是身份已经验证、动作却没有按资源授权。缝的另一侧是入口当时允许了,任务真正跑起来时,人或资源都已经变了。

权限描述的不是“这张票有效”,而是“这个主体此刻能否对这份资源做这个动作”。入口、用例、查询、异步执行、返回,每一段都可能把这个决定做丢。这篇手札顺着一条“发布笔记”的请求,从进门走到异步回写,说明每一处到底在证明什么,以及它证明不了什么。
入口适合证明身份,不适合证明能改哪一行
网关和中间件适合做有限的几件事:核对签名、核对过期时间、把主体放进请求上下文。主体上通常有用户编号、会话编号、签发时间,以及一组粗范围。这些字段回答“你是谁、这张票是否还在有效期内”。
它们回答不了“你能不能发布编号 1842 的笔记”。令牌在登录时签发,笔记可能在之后才创建。若把“可管理的账号列表”塞进令牌,列表会在会话中途变化:同事把你移出账号,旧票在过期之前仍带着旧列表。若逼着网关查询业务库来做细判断,网关的发布就和业务模型绑在一起,网关自己的缓存又会引出另一套过期语义。入口的超时、降级和缓存策略,不该悄悄变成权限的有效期。
入口仍然应该拒绝明显不该进来的票。内部路径不接受浏览器会话;声明里没有写入范围的票,不必进入业务函数。这种拒绝是廉价的前置过滤。过滤放行,不构成授权结论;过滤拒绝,日志里要能看出是票无效,还是范围不足。把两类原因写成同一个“未授权”,排障时会把密钥轮换和权限变更混成一锅。
管理后台的代理访问更容易混。运维持有自己的身份,界面上却在看某个创作者的草稿。若上下文里只放“当前看起来像谁”,业务会把运维记成草稿主人。上下文要同时留下操作者与被代理主体。授权分成两问:运维能否代理此人,被代理主体能否编辑这篇草稿。审计两列都落下来。少一列,事后无法区分本人与代理。代理关系也要有时效,不能因为上午批准过一次,晚上的令牌就永远带着那个人的草稿权。
决定要做在离资源最近的用例上
验过签的请求体仍然不可信。客户端可以改路径、查询和正文。一种常见写法拉开了两侧来源:路径上的笔记编号用来加载,正文里的账号编号用来做比较。攻击者加载一篇自己可见的笔记,正文里换成别人的账号。比较若写成“正文账号等于笔记归属”,比较会被绕开;比较若写成“正文账号等于当前用户”,更新又可能把笔记改挂到攻击者指定的账号上。比较的两侧都应来自服务端:一侧是会话里的主体,一侧是数据库里这条笔记此刻的归属。
加载和判定的顺序要固定。按主键取出笔记,再问主体能否执行该动作。失败时对外可以统一说找不到,避免用不同状态码告诉别人“这篇存在但你不能看”。对内事件要分开记,否则值班的人分不清是数据缺失还是策略拒绝。对外句子可以含糊,对内原因码必须具体。
角色名承载不了状态。编辑可以改自己账号下的草稿,对已排期的笔记只能撤回,不能改写标题。这是动作、状态和关系的组合。处理函数里不要散落“角色等于编辑”这种句子。收成一个函数:输入主体、笔记、动作和当前时间,输出允许或拒绝,以及稳定的原因码。原因码进入审计,不原样展示给用户,免得把策略结构泄露出去。
批量请求要逐条判定。一次发布十二篇,不能因为前两篇属于当前用户就放行整批。部分失败就在响应里逐条给原因。图省事的“有一条能过就全部放过”,会把批量接口变成越权入口。逐条判定也有代价:十二次查询。可以把归属集合预先取出,再在内存里对这十二个编号做判断,但集合必须来自关系表,不能来自客户端自报的数组。预先取出的集合只在这一次请求里有效,不要放进跨请求的全局变量,否则一个人的成员关系会串到下一个人的请求上。
查询条件本身就是权限
列表若先查出一大批再在内存里按作者过滤,会漏在三个点上。分页若发生在过滤之前,调用者能用偏移探测别人的记录在不在。排序若使用了别人的更新时间,时间的分布会暴露别人的活跃程度。计数若返回过滤前的总数,不看正文也能知道库里有多少草稿。
约束要写进查询。笔记列表至少满足:归属落在主体可管理的账号集合之内,状态属于该动作可见的集合。搜索只在已经约束的集合上做。导出不是“把列表接口再调一遍”,导出常常绕过界面过滤直接扫表,所以导出必须复用同一条约束,而不是复制一份看起来差不多的条件。两份条件差一个状态,就会把已撤回的正文送进文件。
更新语句的条件里除了主键,还要带归属。只在读取时检查、更新只按主键写,中间会有窗口:检查通过后、写入前,笔记被转移到另一个账号。带归属的更新若影响行数为零,按拒绝处理并重新读取,不要脱掉条件再写一次。这个窗口在单机演示里很难撞上,在两个人同时操作同一账号时就会出现。
删除和归档也是写。软删除若只改状态字段,条件同样要带归属。否则知道编号的人可以把别人的笔记收进自己的归档列表里,列表再按“我的归档”展示,数据就完成了一次无声的搬走。恢复归档是另一次写,同样要看此刻的归属,而不是看当初是谁点的归档。
异步执行时,入口的决定已经不在现场
用户点击发布,接口授权通过,任务进入队列,响应写着“已排队”。几分钟后工作器取出任务去调用外部平台。工作器没有浏览器会话。载荷里若只有笔记编号,工作器要么不检查,要么拿一个超级身份去检查。前者在成员被移出后仍会发出;后者让队列里的任意消息都能碰任意笔记。
载荷里要留下重新判断所需的事实,而不是留下一句“当时已经允许”。操作者、被代理主体、动作、资源编号、资源版本、策略版本、入队时间,都值得记。工作器执行前重新加载笔记和成员关系,再跑同一个授权函数。资源版本变了,说明排队期间正文被改过,任务应失败或进入复核,而不是带着旧稿发出。成员关系没了,任务取消,原因写成授权失效,而不是含糊的执行失败。
重新检查会使一些当时合法的任务后来失败。这是预期。排队不是授权的延长。若产品语义是“点下的那一刻算定”,就要把算定做成一张一次性许可:有编号,绑定资源版本,短时有效,只能消费一次。工作器消费许可,不相信“消息还在队列里”这件事本身。许可过期、成员撤销、版本不一致,是三种不同原因,日志里不要并成一种。
平台的异步回执是另一条进入系统的路径。回调若只校验共享密钥,密钥只能证明调用方是平台,不能证明这张回执可以改哪一条任务。回执里的外部编号要落到本系统任务,再确认任务正处于等待回执的状态,才允许迁移。密钥泄漏或旧回执被重放时,没有这一步,早已取消的任务会被改成已发布。回执处理本身也要幂等:同一张回执到两次,状态只迁移一次。
定时任务更容易被写成“没有用户,所以不用授权”。到点发布、过期撤回、批量重试,都仍然作用在某篇笔记上。触发器可以是时钟,主体不能是空的。至少要有一个系统身份,并且这个身份的动作范围写在任务定义里:它能发布“已批准且到点”的笔记,不能改写任意草稿。谁能修改任务定义,谁就在修改这个系统身份的权力,这项修改本身要走审核,而不是谁能改调度配置谁就拥有全部笔记。
响应、缓存和日志会把检查过的数据再送出去
授权函数返回允许之后,数据仍可能串到别人手里。反向代理按网址缓存读取结果,网址上没有用户维度,下一位访问者就会拿到上一位的详情。私有响应要标明不可被共享缓存复用。应用缓存的键必须包含主体,以及约束集合的版本。成员关系一变,版本就变,旧键自然失效。只把笔记编号当键,等于把私有详情做成了公共文档。
日志需要主体和资源编号,以便追查。日志不需要草稿正文、访问令牌和刷新票。排障若必须看正文,走带授权的查看入口,不要把正文留在默认采集里。给用户的句子写“不能发布这篇笔记”,给内部事件的原因码写 not_owner 或 state_locked。原因码稳定,句子可以改。句子里不要回显别人的标题,那也是一种泄露。
超时和取消同样算返回路径。客户端断开后,服务端若继续执行并写入,用户会看到“失败”,库里却是成功。对有副作用的动作,断开不能直接当成回滚。要么把动作做成可查询的任务,让客户端稍后按任务编号看结果;要么在写入前检查调用是否已经取消,并接受“可能已经发出”的不确定窗口。这个窗口要写进接口说明,不要假装断开等于没发生。
通知也是返回。站内信、邮件、机器人消息若把草稿摘要送给“该账号的全体成员”,成员列表必须在发送当时再取一次。用入队时复制的收件人列表,会把已经移出的人继续留在收件人里。收件人列表是另一份资源,授权不能停在触发通知的那个人身上。
检查点各自挡得住什么
入口过滤挡得住格式错误和明显缺范围的票,挡不住对象级越权。用例函数挡得住单次动作,挡不住有人绕过用例直接改库。查询约束挡得住列表泄露,挡不住已经发出的旧缓存。异步重判挡得住撤销之后的执行,挡不住策略函数本身写错。
所以策略要集中,并且能用例子测试。主人可以发布;同账号只读不可以;已移出的成员不可以;代理需要两列身份都成立;资源版本变化要拒绝;批量里混进一篇别人的笔记要部分失败。测试不要只走通一条快乐路径。策略函数保持纯一些:给定事实,给出允许或拒绝。加载事实的代码分开写。这样异步路径和同步路径才能真正共用它,而不是各抄一份,抄着抄着差一个条件。
内部任务没有人类会话,仍然要有主体。主体是系统身份,能做的动作写在任务定义里。系统身份可以发布“已经批准且到点”的笔记,不能因为方便就读取任意草稿的正文去打日志。把系统写成超级用户,队列就成了后门。谁能往队列投递,谁就拥有了这个超级用户。
只读副本会延迟。成员刚被删除,副本上可能还能看见关系。撤销类判断读主库,或者对成员关系使用很短的缓存并在写入时主动失效。不要为了省一次查询,让“移出账号”这件事在授权上拖到不可解释。延迟若客观存在,接口就要能说清:拒绝以主库上的关系为准。
跨服务调用要把主体传下去,而不是只传资源编号。下游若只凭编号行事,上游的检查就作废了。传递的是主体和动作,不是上游的结论布尔值。下游仍按自己的资源再判一次。布尔值在网络上无法被复核,事实才可以。上游传下来的主体仍要被下游验证是否真实签发,否则任意内网调用者都可以自称是某个用户。服务之间的身份和用户的身份是两层,不要压成一个字符串。
审计记录决定,但不代替决定
有人觉得日志记全了,权限就可以松一点,出事再查。审计是事后解释,不是事前阻止。越权发布若已经打到外部平台,日志只能帮助回收和道歉,不能让那次发布从未发生。所以审计记下的是谁在何时对哪份资源做了哪个决定,它不代替决定函数。
拒绝也要记。只记成功,会让“为什么这个人发不出”无从回答,运维就会临时把人加进一个范围过大的角色。拒绝记录包含主体、动作、资源、原因码,不包含正文。保留周期按排障需要,而不是把全部正文堆进热日志。能从审计还原一条请求的决定路径,权限设计才算闭环:入口的过滤、用例的判断、查询的约束、异步的重判、返回前的脱敏,任何一段缺失,审计里都会出现一段对不上的空白。
收束
沿着请求问五件事。入口是否只负责身份和粗范围。用例是否按服务端的资源做对象级判断。查询和更新是否带上同一条归属约束。队列执行时是否重判,或改为消费一次性许可。响应、缓存、日志、通知和回调是否还会把别人的数据交出去。有一件说不清,越权就常常埋在那里。权限不是进门时的一次盖章,而是每一次副作用发生之前,都能重新解释的决定。
见字如晤