先说结论
很多开发者对 sudo() 有一个很自然、但很危险的直觉:
只要我把当前 recordset 提权了,后面通过
Command.create()、Command.update()、Command.link()去改 x2many 关系,框架就会按提权用户放行。
Odoo 19 明确不愿意让你这么想。
在 odoo/orm/fields_relational.py 里,x2many 写入链路会先跑 _check_sudo_commands():
def _check_sudo_commands(self, comodel):
if not comodel._allow_sudo_commands:
return comodel.sudo(False).with_user(comodel.env.transaction.default_env.uid)
return comodel
这段代码的含义非常直接:
- 如果目标模型允许 sudo 命令,保留当前环境;
- 如果目标模型 不允许 sudo 命令,框架会:
- 关闭
sudo(); - 把执行用户重置为事务起点的原始用户;
- 再继续执行那些
Command。
所以真正的结论不是“x2many 命令继承当前 env 权限”,而是:
x2many 命令是否能借提权穿透,取决于目标模型是否允许。
一、为什么 Odoo 要专门盯住 x2many Command
普通 write() 看起来已经有权限控制了,为什么还要单独防 Command?
因为 x2many Command 太强了。
它不是“改一个外键”这么简单,而是可以顺手触发:
- 新建目标记录;
- 修改目标记录;
- 删除目标记录;
- 断开关系;
- 重建整组关系。
也就是说,表面上你只是在写一条父记录,实际上你可能在对子模型做一整套高权限操作。
如果框架默认允许“只要父对象这边 sudo 了,子对象命令就一起跟着飞”,那很多敏感模型都会被绕过去。
这就是为什么这条防线不是写在业务模块里,而是下沉到了 ORM 的 relational field 层。
二、_allow_sudo_commands 真正控制的是“命令落地时谁说了算”
在 odoo/orm/models.py 里,模型默认有:
_allow_sudo_commands: bool = True
默认值是 True,说明 Odoo 并不是全面封杀这种能力。
但对于某些模型,官方认为风险太高,就会显式设成 False。
测试里最典型的是:
res.userstest_orm.group
为什么这类模型敏感?因为它们代表:
- 用户账号本身;
- 用户与组的关系;
- 权限提升入口;
- 身份绑定关系。
这些对象一旦允许你通过“父模型 x2many 命令 + 临时 sudo”去改,就很容易出现越权链路。
所以 _allow_sudo_commands 不是在说“这个模型能不能被 sudo”;
它真正表达的是:
这个模型能不能通过别人的 x2many Command,在提权环境下被顺带操作。
这两个问题非常不一样。
三、官方测试其实已经把风险场景写明了
test_sudo_commands() 这组测试很值得读,因为它不是抽象谈安全,而是把真实攻击面写成了用例。
例如对 res.partner.user_ids 这条 one2many,测试覆盖了:
Command.create():试图新建用户;Command.update():试图给自己加系统组;Command.delete():试图删用户;Command.unlink():试图拆掉绑定;Command.link():试图把管理员账号绑到自己的 partner 上;Command.clear()/Command.set():试图整体重写关系。
而且测试故意用了两种很多业务代码都会写出来的环境:
with_user(admin_user)sudo()
也就是说,官方明确承认现实里经常有人这样写:
- “临时借管理员身份做一步动作”;
- “为了省事直接 sudo 一下”。
问题在于,这样一来,命令里真正被改的子记录 往往已经超出了开发者当下脑内关注的那张表。
官方在测试注释里甚至点名了真实业务风格:
Documents.with_user(share.create_uid)sign.request.with_user(...).sudo()
意思很明显:
这不是理论问题,而是官方见过的真实代码形态。
四、关键细节:框架不是拒绝整次写入,而是先把 comodel 拉回原用户
这点非常值得注意。
_check_sudo_commands() 不是简单抛错;它做的是:
- 拿到 x2many 目标模型
comodel; - 如果该模型不允许 sudo 命令;
- 就对这个
comodel做sudo(False); - 再
with_user(transaction.default_env.uid); - 后续 create / update / unlink 都在这个“还原后的环境”里执行。
这意味着框架的思路不是:
- “一看到 sudo 命令就报错”;
而是:
- “你可以继续走这条写入链,但真正涉及敏感子模型时,得按事务原始用户重新验权。”
这种做法很聪明,因为它保留了普通场景的便利性,也把敏感命令的落地点重新拉回真实权限边界。
五、为什么 with_user(admin) 也被一起防了
很多人会误以为只有 sudo() 才危险,with_user(admin) 更“正规”。
但从安全边界看,这两者在这里都可能产生同一个问题:
- 当前代码块拿着一个更高权限的 env;
- 然后通过 x2many Command 去改另一个敏感模型;
- 事务真实发起者却并没有这些权限。
所以测试里对 with_user(admin_user) 和 sudo() 是一视同仁的。
这背后体现的是 Odoo 的一个态度:
不要因为“命令写在父对象字段里”,就把对目标对象的权限判断模糊掉。
六、对开发实践最重要的启发
1)不要把 x2many Command 当作“字段赋值语法糖”
[(0, 0, vals)]、[(1, id, vals)]、[(4, id)] 这些写法看起来像字段更新,
但本质上它们是:
- 代替你去操作另一张表;
- 并可能触发另一套访问控制与业务副作用。
2)父记录能 sudo,不代表子模型就应该顺带 sudo
很多 bug 的根子就在这里:
- 你想临时放宽父对象动作;
- 结果顺手放宽了子对象;
- 最后把账户、组、签署对象、共享对象一类敏感数据也一起暴露了。
3)如果确实需要高权限改子对象,最好显式拆开写
与其把敏感操作藏在 x2many Command 里,不如:
- 先明确找到目标记录;
- 明确说明为什么需要高权限;
- 在可审计的位置单独执行;
- 必要时再补业务级校验。
这样至少你知道自己提权改的是谁,而不是让一个看似普通的 write({'line_ids': [...]}) 偷偷带出一串副作用。
七、什么时候你最该想到这条规则
如果你遇到下面这些现象,就该立刻想到 _allow_sudo_commands:
- 父记录
sudo().write()能走进去,但 x2many 子命令报AccessError; with_user(admin)明明已经换了用户,还是改不了某些 x2many 目标;- 同样的命令,对普通模型有效,对用户/组/敏感对象无效;
- 你以为问题在 record rule,实际问题在 relational field 主动撤销了 sudo 命令环境。
这类问题如果只盯着当前 env.uid,很容易永远调不明白。
你真正要看的是:
- 目标 comodel 是谁;
- 它的
_allow_sudo_commands是不是False; - 命令最终在哪个用户环境里落地;
- 事务默认用户有没有相应访问权。
结语
这套设计很值得尊重,因为它处理的是一个很常见、但很容易被忽略的误区:
“我现在拿着高权限 env,所以我顺手塞进 x2many 的一切都应该高权限执行。”
Odoo 19 给出的答案是:不一定。
对于敏感模型,框架宁可在 relational field 层把命令执行环境拉回事务原始用户,也不愿意让 x2many Command 变成一条安静的越权隧道。
所以记住一句最实用的话:
sudo()提的是当前 recordset 的执行环境,不是所有 x2many 子命令的永久免检通行证。
DISCUSSION
评论区