先说结论
Odoo 的 Portal Access Management 不是一个“给联系人加个 portal 组”这么简单的按钮。
从 addons/portal/wizard/portal_wizard.py 与 addons/portal/tests/test_portal_wizard.py 来看,这条链路至少同时处理了五件事:
- 选中的对象到底是公司、联系人,还是两者都要展开;
- 这个联系人是不是已经绑定现有用户;
- 这个用户到底是 public、portal 还是 internal;
- 发放访问权时要不要创建用户、准备 signup token、发送邀请;
- 撤销访问权时是删用户、改组,还是直接归档。
所以正确理解应该是:
Portal 向导管理的是“外部身份生命周期”,不是单纯的勾组权限。
这也是为什么很多项目里“客户怎么突然拿到入口了”“撤销了为什么账号还在”“为什么一个公司点授权会冒出一串联系人”,答案都在这条向导链路里。
一、为什么它会把一个 partner 展开成多个联系人
_default_partner_ids() 并不会只拿当前选中的 partner 本身。
源码会对每个 res.partner 做一轮展开:
- 把
child_ids里type in ('contact', 'other')的联系人一起纳入; - 再把当前 partner 自己也并进去。
这意味着什么?
如果你在客户公司级别点“Grant Portal Access”,系统心里的对象并不是“这家公司”一个抽象主体,而是:
- 公司本体;
- 它下面真正可能要收邀请邮件的联系人集合。
这就是为什么实务里经常会出现“我明明选了一个客户,向导里却出来多行联系人”。
它不是 UI 多余,而是 Odoo 在明确:
门户权限最终落在可登录身份上,而不是纯粹落在公司标签上。
二、为什么邮箱检查比很多人想的严格
portal.wizard.user 会计算一个 email_state,状态只有三种:
ok:可用;ko:邮箱格式无效;exist:跟现有用户撞邮箱。
这里关键不是“有邮箱就行”,而是它会先做标准化,再去查相似用户登录。
_assert_user_email_uniqueness() 甚至会直接拦下两类情况:
- 联系人邮箱本身不合法;
- 邮箱已经被系统里别的用户占用。
测试文件里也专门覆盖了这条逻辑:
- 无效邮箱必须报错;
- 与已有用户重复的邮箱必须报错;
email_state要正确变成ko或exist。
这说明 portal 向导不是“业务联系人留个邮箱备注”,而是在为真正的登录身份做预检。
三、为什么 public、portal、internal 不是一个连续刻度
_compute_group_details() 会把用户明确分成三类:
- internal 用户;
- portal 用户;
- 既非 internal 也非 portal 的其他状态,例如 public。
而 action_grant_access() 的逻辑也很明确:
- 如果已经是 internal 或 portal,就不允许再按普通 portal 流程处理;
- public 用户可以被提升成 portal;
- internal 用户不能通过这个向导降格或重配。
测试也印证了这个边界:
- public 用户授予 portal 后,应移除
base.group_public,加入base.group_portal; - internal 用户尝试 grant / revoke 都应该报错。
这背后的产品意图很清楚:
portal 是外部访问层,不是后台员工权限的轻量版。
所以你不能把 internal 用户当作“权限更高的 portal 用户”去管理;两者属于不同身份轨道。
四、grant access 真正在做什么
action_grant_access() 至少做了四步关键动作:
- 校验邮箱唯一性;
- 如有必要,回写 partner 邮箱;
- 如果还没有绑定用户,则按 partner 所属公司创建新用户;
- 把用户加入
group_portal、移出group_public,然后signup_prepare()并发邀请邮件。
这里最容易被忽视的,是 signup_prepare()。
它说明 portal 授权并不等于“密码已经设置好、可以立刻后台登录”,而是:
- 系统先准备一条受控的注册 / 设密链路;
- 然后通过邮件把入口发给外部人。
也就是说,grant access 处理的是“资格 + 邀请”两步组合。
所以如果邮件没发出去、模板被改坏,业务方常会误以为“授权失败”,其实可能只是邀请环节断了。
五、revoke access 为什么不是删用户,而是改组并归档
action_revoke_access() 的动作非常克制:
- 先把 partner 的
signup_type清空,避免旧 token 继续可用; - 再把用户从
group_portal移出,并补回group_public; - 最后把用户设为
active = False。
注意,它不是直接删除 res.users。
测试也明确写了:
- revoke 之后用户记录应该还在;
- 只是恢复到 public 组并被归档;
- 不再是 portal,也不是 internal。
这套设计很合理,因为删除用户会破坏历史引用:
- 邮件线程;
- 登录记录;
- 业务关联;
- partner 对应关系。
所以 Odoo 选择的是:
撤销访问资格,而不是抹掉身份痕迹。
六、为什么“联系人有 portal 权限”不等于“他能看所有公司数据”
Portal 向导本身做的,只是把某个 partner 绑定到一个 portal 用户身份上,并准备登录入口。
它并没有在这里直接定义:
- 这个用户能看哪些订单;
- 能看哪些项目;
- 能看哪些工单;
- 能不能跨公司、跨联系人查看对象。
那些对象级访问边界,仍然取决于对应模块自己的 portal 规则、token 机制和控制器检查。
所以项目里一个常见误区是:
- 给了 portal,就以为“整家公司都能看全部客户资料”;
- 或者撤了 portal,就以为“所有分享链接也同时失效”。
实际上:
- portal 身份生命周期是一层;
- 具体业务对象的公开 / 门户 / token 访问是另一层。
两层叠在一起,才是完整外部访问边界。
最后给实施方的 4 个提醒
1. 不要把公司 partner 当成唯一授权对象
很多实际能登录的人,都是它下面的联系人。
2. 不要忽视邮箱唯一性
门户邀请失败,很多时候不是 SMTP,而是邮箱已经被系统别的用户占了。
3. 不要试图用 portal 向导管理 internal 用户
这条向导是外部身份通道,不是后台账号治理工具。
4. revoke 后要连同对象分享链路一起审视
撤掉 portal 资格,不代表所有历史分享入口自动等于零风险。
参考源码
addons/portal/wizard/portal_wizard.pyaddons/portal/tests/test_portal_wizard.py
DISCUSSION
评论区