这一章的关键不是记住多少 XML 属性,而是判断每个机制到底在“执行前阻止”,还是只在“界面上不显示”。
7.1 菜单权限 ir.ui.menu
重要字段定义于 odoo/addons/base/models/ir_ui_menu.py:19-44:
| 字段 | 作用 |
|---|---|
name |
菜单名称 |
active |
是否启用 |
sequence |
排序 |
parent_id / parent_path |
菜单树 |
groups_id |
哪些组可看到入口 |
action |
菜单打开的动作 |
web_icon |
应用图标 |
菜单加载的大致链路:
/web/webclient/load_menus
-> Web controller
-> web 扩展模型 load_web_menus()
-> base ir.ui.menu.load_menus()
-> search()/search_fetch()
-> _filter_visible_menus()
-> _visible_menu_ids()
_visible_menu_ids() 的逻辑在 ir_ui_menu.py:76-130:
- 先 sudo 读取菜单元数据。
- 若菜单配置
groups_id,用户拥有其中任一组才保留。 - 非 debug 会话排除
base.group_no_one。 - 对窗口、报表、服务器动作,检查动作目标模型的 read ACL。
- 保留可见叶子菜单及其祖先文件夹。
菜单过滤只检查模型 ACL,不检查记录规则是否最终能返回任何记录。菜单可见也不代表某个具体文档可读。
XML 示例:
<menuitem id="menu_demo_document"
name="Documents"
parent="menu_demo_root"
action="action_demo_document"
groups="demo.group_user,demo.group_manager"/>
groups="a,b" 是任一组即可。菜单转换器中的 -group_xmlid 语法表示从 Many2many 关系中解除这个组,不是通用的“属于该组就排除”条件,见 odoo/tools/convert.py:263-320。不要把视图节点的 !group 语义机械套到 <menuitem>。
安全结论:菜单是导航。用户即使看不到菜单,也可能通过收藏 URL、消息链接、动作 ID、RPC 或自定义控制器到达同一功能;目标模型和方法仍必须自带授权。
7.2 动作的元数据配置权与业务使用权
普通用户通常没有通用 ORM 权限读取或维护完整动作、视图元数据。Web 控制器会通过专用入口 sudo 读取元数据,再返回白名单字段。例如 /web/action/load 位于 addons/web/controllers/action.py:22-51,窗口动作还通过 _get_action_dict() 清洗,见 ir_actions.py:218-252。
所以:
没有 ir.actions.* 元数据 read ACL
≠
不能使用一个由 Web 专用入口安全加载的业务动作
反过来也一样:能加载动作元数据不等于能读动作指向的业务记录。
动作基类的重要字段在 odoo/addons/base/models/ir_actions.py:52-73:
| 字段 | 作用 |
|---|---|
name / type |
动作名称和类型 |
path |
Web URL 路径 |
binding_model_id |
将动作绑定到哪个模型工具栏 |
binding_type |
Action 或 Report |
binding_view_types |
在 list/form 等哪些视图显示 |
binding_* 决定工具栏入口,不是执行授权。
7.3 窗口动作 ir.actions.act_window
重要字段见 ir_actions.py:302-327:
| 字段 | 作用 |
|---|---|
res_model |
目标模型 |
view_mode / view_id / views |
视图选择 |
domain |
默认筛选 domain |
context |
默认上下文 |
res_id |
直接打开的记录 |
target |
当前窗口、弹窗等 |
groups_id |
绑定动作可见组 |
search_view_id |
搜索视图 |
groups_id 会在模型工具栏的 get_bindings() 中过滤,源码 ir_actions.py:149-215。但是 /web/action/load 对窗口动作采用 sudo 加载,并不把该字段作为通用的直达执行授权;菜单可见算法也不统一检查窗口动作自己的 groups_id。
domain 也不是安全规则。它只定义从这个入口打开时的默认过滤,用户可以直接 RPC 搜索同一模型并省略该 domain。最终仍由模型 ACL 和 read 记录规则约束。
正确定位:窗口动作定义“从哪个角度打开哪个工作台”,不定义“用户在数据库中到底能访问什么”。
7.4 服务器动作 ir.actions.server
服务器动作是普通动作中的重要例外。关键字段位于 ir_actions.py:503-588:
model_id/model_namestatecodechild_idscrud_model_idgroups_id- 更新字段等配置
code 字段本身受 base.group_system 字段组保护。run() 的执行链在 ir_actions.py:936-1011:
- sudo 读取动作配置。
- 若配置
groups_id,显式检查当前用户是否在允许组。 - 若没配置组,要求目标模型 write ACL。
- 若有活动记录,对这些记录调用
check_access('write'),包括记录规则。 - 通过后才执行 runner。
因此服务器动作的 groups 不只是显示属性。但仍要注意:
- 配了组时会走组分支,不能据此忽略动作内部代码审计。
- 没有 active records 的代码型动作可能没有记录级检查对象。
- 动作代码中的
sudo()、原始 SQL、跨模型写入仍可能扩张权限。
7.5 报表动作 ir.actions.report
重要字段位于 odoo/addons/base/models/ir_actions_report.py:141-176:
| 字段 | 作用 |
|---|---|
model |
文档模型 |
report_type |
QWeb HTML/PDF 等 |
report_name / report_file |
模板和文件标识 |
groups_id |
Print 入口的可见组 |
multi |
是否用于多记录 |
paperformat_id |
纸张格式 |
print_report_name |
动态文件名 |
attachment_use / attachment |
附件缓存 |
domain |
报表入口的适用性 |
groups_id 会过滤 Print 绑定入口,但标准报表路由 /report/<converter>/<reportname>/<docids> 只要求 auth='user',然后按报表名称 sudo 查元数据,相关源码为:
addons/web/controllers/report.py:23-50ir_actions_report.py:623-657
标准模板中的 docs 通常以当前用户环境 browse,真正读取字段时会触发业务模型 ACL 和记录规则,见 ir_actions_report.py:1082-1097;但报表 groups_id 本身不是可靠的直达路由授权。自定义 report model、模板中的 sudo 和自定义控制器都需要单独审计。
PDF 缓存更需要警惕:准备 PDF stream 时先取得 sudo 的 report,再由它查找并读取已存在的附件,见 ir_actions_report.py:248-261,774-814。若目标 docid 的 PDF 已全部命中缓存,路径可能不再渲染 docs 来间接触发文档读取。因此授权检查必须发生在读取缓存之前。
敏感报表增强的建议入口逻辑:
report = request.env["ir.actions.report"]._get_report_from_name(report_name)
if not report:
raise NotFound()
if report.groups_id and not report.groups_id & request.env.user.groups_id:
raise AccessError("Not allowed to print this report")
records = request.env[report.model].browse(docids)
records.check_access("read")
# 通过检查后,才进入渲染或读取缓存附件。
实际实现还要处理空 docids、上下文报表、自定义 abstract report、token 和 HTTP 错误映射。
7.6 URL、Client 和嵌入式动作
ir.actions.act_url和ir.actions.client没有统一的通用业务授权语义,主要依赖菜单、按钮和实际目标服务。- Odoo 18 的
ir.embedded.actions.groups_ids/domain在_compute_is_visible中计算可见性,见odoo/addons/base/models/ir_embedded_actions.py:8-29,71-91;最终业务调用仍要由模型和方法保护。
把所有动作总结成一张表:
| 动作类型 | 组字段主要作用 | 直达执行是否统一强制组 | 最终依赖 |
|---|---|---|---|
| Window | 绑定入口可见 | 否 | 目标模型 ACL/规则 |
| Report | Print 入口可见 | 否 | 文档访问、报表入口显式校验 |
| Server | 入口 + run() 检查 |
配置组时强制组;空组退回模型 write | 活动记录 write、动作代码 |
| URL/Client | 外围入口 | 无统一语义 | 目标 URL/客户端调用的服务端授权 |
| Embedded | 可见性 | 否 | 后续动作与模型授权 |
7.7 视图记录 ir.ui.view
重要字段见 odoo/addons/base/models/ir_ui_view.py:143-206:
| 字段 | 作用 |
|---|---|
model / type |
目标模型与 form/list/search 等类型 |
priority |
匹配和继承顺序 |
arch_db |
XML 架构 |
inherit_id |
继承的父视图 |
mode |
primary 或 extension |
groups_id |
视图记录组条件,适用范围有限 |
active |
是否启用 |
扩展视图记录禁止设置 groups_id,约束明确要求把 groups 放到 XML 节点,见 ir_ui_view.py:422-428:
<record id="view_demo_form_manager_extension" model="ir.ui.view">
<field name="inherit_id" ref="demo.view_demo_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='details']" position="inside">
<field name="manager_note" groups="demo.group_manager"/>
</xpath>
</field>
</record>
普通后台的视图组合会 sudo 选择和合并元数据,再针对当前用户后处理。当前源码中 ir.ui.view.groups_id 的 _check_view_access() 主要出现在 public asset 和 website restricted visibility 路径,并不是普通后台的通用数据安全边界。后台差异优先用架构节点 groups,数据安全仍由 ORM 保证。
7.8 视图节点 groups
最终架构后处理 _postprocess_access_rights() 位于 ir_ui_view.py:989-1060:
- 继承架构合并完成后,
_postprocess_view()把节点组条件编码为__groups_key__。 - 缓存保留包含所有组分支的后处理架构。
- 返回给具体用户前,根据有效组删除不匹配节点。
- 根据模型 ACL 给适用的视图根节点及嵌套关系视图补
create=False、edit=False、delete=False。 - 给关系字段补
can_create、can_write提示。
组表达式编码见 ir_ui_view.py:1062-1131,全组版本缓存见 ir_ui_view.py:2706-2711,按用户删除节点见 ir_ui_view.py:989-1015。
节点语法:
<!-- 任一正组满足,并且不属于任何负组 -->
<button name="action_sensitive"
type="object"
groups="demo.group_manager,!demo.group_auditor"/>
base.group_no_one 只有开发者模式会话才有效,不是业务权限。视图过滤见 ir_ui_view.py:1000-1015,用户组判断见 res_users.py:1142-1190。
根节点自动关闭 create/edit/delete 是客户端能力提示。如果手工写 create="1",也不能给没有 create ACL 的用户授予权限;反过来 create="0" 只隐藏这个视图的创建入口,不能阻止直接 RPC create。
7.9 按钮权限
type="object" 按钮的大致调用链:
用户点击按钮
-> Web client POST /web/dataset/call_button
-> controller 检查方法名
-> call_kw(model, method, args, kwargs)
-> 调用公开 Python 方法
关键源码:
- 控制器:
addons/web/controllers/dataset.py:32-43。 - 方法名检查:
odoo/models.py:151-158。 call_kw:odoo/api.py:496-524。
方法名检查只禁止下划线开头的方法和 init。它不会回头读取按钮 XML,也不会检查那个节点上的 groups、invisible 或 confirm。因此知道模型名和公开方法名的用户可以直接发 RPC。
type="action" 按钮只是加载动作 ID,窗口动作或报表动作的显示组同样不能代替目标模型授权。
不安全示例:
def action_approve(self):
self.sudo().write({"state": "approved"})
它既没有检查组、状态、记录范围,也用 sudo 抹掉了调用者边界。
较完整的公开方法模板:
def action_approve(self):
self.ensure_one()
self.check_access("write")
if not self.env.user.has_group("demo.group_approver"):
raise AccessError(_("Only approvers can approve."))
if self.company_id not in self.env.companies:
raise AccessError(_("The company is not enabled."))
if self.state != "submitted":
raise UserError(_("Only submitted records can be approved."))
if self.owner_id == self.env.user:
raise AccessError(_("You cannot approve your own document."))
return self._set_state("approved")
批量方法不要盲目 ensure_one();应明确是整批原子失败、过滤允许子集,还是逐条报告。安全行为必须成为方法契约,而不是偶然结果。
7.10 QWeb、前端组判断和 Controller
服务端 QWeb 节点的 groups/t-groups 会编译为 env.user.has_groups(...) 条件,见 odoo/addons/base/models/ir_qweb.py:1847-1864。它能避免渲染某段 HTML,但不能阻止另一 API 读取同一数据。
顶层 <template groups="..."> 是另一种机制:XML 导入时会把它转换为 ir.ui.view.groups_id,见 odoo/tools/convert.py:450-514,尤其是 :496-500。它不会作为普通 QWeb 节点条件原样留在 arch 中;其记录级适用边界受前述 _check_view_access() 调用路径限制,不能与节点 t-groups 混为一谈。
前端 user.hasGroup() 会 RPC 到 res.users.has_group 并缓存结果,源码 addons/web/static/src/core/user.js:51-78,106-110。这只是前端决策。
Controller 的 auth 也不是业务权限:
auth |
含义 |
|---|---|
user |
必须登录,以当前用户环境执行 |
public |
可匿名;未登录时使用 Public user |
none |
可在没有数据库的阶段调用 |
@route(auth='user') 不支持自动声明 Odoo 组,也不会自动检查 docid。Controller 必须显式做组、公司、记录或 token 校验。
Portal/token 的正确顺序是:先尝试当前用户的非 sudo 记录访问;若失败,再恒时比较有效 token;只有授权通过后,才以必要范围 sudo 返回记录。参考 addons/portal/controllers/portal.py:373-392。
CSRF 防止跨站请求伪造,不是对象权限。HTTP 路由的 CSRF 默认开启、JSON 路由默认关闭,仍然必须做业务授权。
路由的 readonly=True 只是选择只读事务/副本,不是授权声明,也不会自动检查记录。
7.11 界面层安全复核清单
看到任何 groups= 或 invisible= 时,继续问:
- 用户能否直接调用公开方法?
- 动作 ID 能否被直接加载?
- 报表 URL 能否直达?
- Controller 是否只验证登录,没有验证 docid?
- 目标模型是否有 ACL 和记录规则?
- 关联行、附件、分析模型是否也有规则?
- 方法内部是否过早 sudo?
老赵解读
菜单、动作、视图和按钮首先服务于“让谁看到什么工作台”,它们很重要,但不能成为唯一防线。评审任何按钮时都要追问:方法能否通过 RPC 直接调用?报表 URL 能否绕过菜单直达?控制器是否重新检查目标记录?只要答案涉及“可以直接访问”,安全检查就必须在服务端落地。
目前没有任何评论。