企业版签署安全

Odoo 企业版 Sign 为什么不是“发个签署链接”而已:共享签署、过期校验、拒签复制与日志完整性链路讲透

很多人把 Odoo 企业版 Sign 理解成“给签署人发一个 URL”。但从 `sign` 源码看,官方真正处理的是共享请求与实名请求的分流、邮件链接的过期签名、拒签时为何复制 shared 请求、签署人改派后的 token 轮换,以及签署日志如何串成不可篡改的证据链。

企业 协同办公
进阶 开发者 3 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多人第一次接触 Odoo 企业版 Sign,对它的理解都很自然:

  • 发起签署请求;
  • 系统发一封邮件;
  • 对方点一个链接;
  • 签完就结束。

如果只看界面,这个理解当然成立。

但你一旦认真读 /home/ubuntu/odoo-temp/enterprise/sign,就会发现官方真正要解决的远不止“生成一个可点击 URL”。它要同时处理的是:

  1. 实名一对一签署共享链接签署 不是同一套安全模型;
  2. 邮件中的签署链接不能永久有效;
  3. 签署人如果被改派,旧链接不能继续用;
  4. shared 请求里有人拒签时,不能把整条共享入口直接毁掉;
  5. 最终的签署结果要能产出可回溯、难篡改的证据链。

所以 Odoo 企业版 Sign 的关键,不是“把 PDF 发出去”,而是“把签署访问、状态流转与审计证据绑成一条可控链路”。


一、先分清两种请求:sentshared 根本不是同一类链接

sign.requeststate 里,除了常见的:

  • sent
  • signed
  • canceled
  • expired

还专门有一个:

  • shared

这不是普通状态名,而是在告诉你:

sent

表示系统知道“这封请求是发给谁的”,每个 sign.request.item 都对应一个明确 signer。

shared

表示这是共享签署入口,不预先绑定具体签署人,或者至少不是按普通一对一邮件那样走。

这个差异在 controller 里体现得很明显。

controllers/main.py/sign/document/mail/<request_id>/<token> 路由里:

  • 系统先找到 sign.request
  • 再用 token 匹配当前 sign.request.item
  • 若请求已过有效期则 404
  • 但真正的过期签名校验只对 state != 'shared' 的请求执行

源码写得很直白:

if sign_request.state != 'shared' and not current_request_item._validate_expiry(...):

这说明官方从一开始就没把 shared 链接和实名 mail link 当成一回事。

实名请求靠“签署人项 + 邮件时效签名”双重收口;共享请求则走另一套更宽但也更受控的路径。


二、为什么邮件签署链接还要再带 timestamp + exp,而不是只靠 access token

很多系统做到 access_token 就收工了。

sign.request.item 里还有:

  • _generate_expiry_link_timestamp()
  • _generate_expiry_signature()
  • _validate_expiry()

生成邮件链接时,_get_sign_and_cancel_links() 会把 URL 参数拼成:

  • timestamp
  • exp

exp 不是随便的随机串,而是:

  • sign_expiration 这个用途名;
  • 结合 timestampsign_request_item_id
  • 通过 HMAC 算出来的签名值。

再配合 controller 校验逻辑,最终效果是:

1)链接不是永久有效

即使 token 没变,只要时间超过配置值(默认取 sign.link_expiry_duration,源码里默认 360 小时),链接也会失效。

2)链接不能随便改时间戳续命

因为 exp 要和 timestamp + item_id 一起重新算,用户自己改 URL 里的时间没有用。

3)链接不是只跟请求绑定,而是跟具体 signer item 绑定

即使同一个 sign.request 下有多个签署人,也不是一条通用链接打天下。

这比“有 token 就能签”更稳。

Odoo 在这里防的不是一般的未登录访问,而是“老邮件长期有效”“链接被转发后长期复用”“用户自行篡改 URL 延寿”这类签署链常见风险。


三、为什么旧邮件链接缺少时间参数时,官方宁可 404

sign_document_from_mail() 里还有一段很值得注意:

  • 如果请求本来应该校验过期;
  • 但 URL 里缺了 timestampexp
  • 则不是展示“链接过期”页,而是直接 deleted_sign_request 404。

源码注释写得很清楚:

The sign request should be evaluated but the timestamp has been removed from the parameter.

这个处理很讲究。

因为如果有人拿到一条原本需要时效校验的链接,然后手工删掉时间参数,系统如果还给出友好兜底,很容易让人继续试探别的绕过方式。

官方这里选择:

  • 参数不完整,不按“普通过期”处理;
  • 直接当成不合法入口。

这类设计在签署系统里是对的。

因为“过期”是合法但失效;“少参数”更像篡改后的非法访问尝试。


四、为什么改派签署人后必须轮换 access_token

sign.request.item.write() 里有一段非常关键的安全动作:

  • 如果 partner_id 被改成了另一个人;
  • 且这条签署项之前已经发过邮件;
  • 在写入完成后,系统会重置新的 access_token
  • 同时把 is_mail_sent 重新置为 False

官方还在写入前做了一堆限制:

  • 不是 sent 状态不能改派;
  • 整个请求不是 sent 状态不能改派;
  • role 不允许变更联系人时不能改派;
  • shared 请求也不能按这条路径改派。

为什么 token 轮换这么重要?

因为如果只改收件人,不换 token,就意味着:

  • 老收件人邮箱里的旧链接依然可用;
  • 新收件人拿到的是同一条访问凭证;
  • 谁先点进去,系统都难区分。

所以官方做法非常正确:

签署对象一旦换人,旧 token 立即作废,签署链路重新发放。

这不是体验细节,而是安全边界。


五、shared 请求为什么在拒签时要“复制一份再取消”,而不是直接改原请求

sign.request.item._refuse() 里最有意思的一段,是 _refuse_shared()

对于普通 sent 请求,拒签流程比较直观:

  • 写拒签日志;
  • 当前请求项置为取消;
  • 在原请求上发消息;
  • 整个请求进入拒签处理。

但对 shared 请求,官方不是直接把原请求改坏,而是:

  1. 先把原请求 copy_data() 一份;
  2. 新副本仍置成 shared
  3. 在这份副本上把当前请求项记成取消;
  4. 再把拒签者身份绑定到这份副本;
  5. 原共享请求得以继续保留给其他潜在签署人使用。

这套逻辑初看有点绕,但其实特别合理。

因为 shared 链接的语义不是“某一个具体人必须签”,而更像:

  • 一条公开但受控的签署入口;
  • 允许不同具体人进入并决定签或拒。

如果某个用户拒签就把原共享请求直接取消,那剩余用户就再也没有入口了。

所以官方的做法本质上是在做:

共享入口保活,个体拒签留痕。

这是 shared 与 sent 两条链最本质的差别之一。


六、共享入口如何转成实名请求:不是直接把 shared 改成 sent,而是复制新请求

controller 里的 /sign/send_public/<request_id>/<token> 也说明了这一点。

当 shared 请求要识别出具体公共用户时,系统会:

  • 根据 name / email 找或建 partner;
  • 用原 shared 请求为蓝本复制一个新请求;
  • 新请求改成 state='sent'
  • 新建带具体 partner 的 request_item_ids
  • 返回新请求 ID、新请求 token、新 signer access token。

这说明 shared 入口在官方设计里更像一个“模板化入口壳”,而不是最终受法律与审计约束的实名请求本体。

实名化发生时,官方倾向于:

  • 复制出一条真正的 sent 请求
  • 再在这个副本上走后续签署、邮件、拒签、证书链

这样 shared 请求本身就不会背太多状态负担。


七、签署日志为什么不是普通日志表,而是哈希串起来的证据链

sign.log 是整个模块里最值得重视的审计模型之一。

它不只是记录:

  • create
  • open
  • save
  • sign
  • refuse
  • cancel
  • update

更关键的是 create() 里会生成 log_hash,而 _get_or_check_hash() 的逻辑是:

当 action = create

  • 取模板原始 PDF 的二进制内容作为第一段基础体;
  • 计算首个 hash。

当 action = sign

  • 取同一请求上一条 create/sign 的 hash 作为 previous_hash
  • 再把本次动作相关字段与当前 signer 可见的 item values 串起来;
  • 继续做 sha256。

也就是一条很典型的链式结构:

  • 第一笔锚定原始 PDF
  • 后续每次签署都接在上一笔 hash 后面

这就是为什么 sign.log.write() 和删除都被禁止。

官方不是只想保留“操作记录”,而是想保留:

从原始文档到每一步签署动作都能顺序校验的不可篡改轨迹。


八、完成件为什么还要塞进 certificate 与 final_log_hash

sign.request._generate_completed_documents()sign.completed.document._generate_completed_document() 连起来看,会发现官方在完成件阶段做了两件事:

  1. 生成签完的 PDF;
  2. 再额外生成一份 Certificate of completion。

其中 sign.completed.document 在渲染最终 PDF 时,会取:

final_log_hash = record.sign_request_id._get_final_signature_log_hash()

sign.request._get_final_signature_log_hash() 会从 sign.log 里取最后一笔 create/signlog_hash

如果启用了 certificate_reference,这个 final hash 就会参与最终完成件渲染。

这说明官方不是把日志和 PDF 各存各的,而是在尝试把“最终文件”与“审计链末端哈希”关联起来。

再加上 _handle_log_download() 能导出证书 PDF,整个设计就完整了:

  • 有签完的业务文件;
  • 有证书;
  • 有链式日志;
  • 最终完成件还能嵌入最终日志哈希。

这已经不是普通协同工具的“签完给你发个 PDF”,而是明显朝着证据化链路去的。


九、为什么 signed 请求不能删,只能归档

sign.request 上还有一条非常重要的约束:

if any(r.state == 'signed' for r in self):
    raise UserError(... archive them instead.)

也就是:

  • 已签完的请求不能删除;
  • 只能归档。

这和上面的日志不可改、日志不可删、完成件可下载,放在一起看才完整。

因为官方显然把已签请求当成:

  • 有法律效力或审计价值的对象;
  • 生命周期可以退出日常界面;
  • 但不应被普通业务动作彻底抹掉。

同理,shared 且过期的请求可以被 autovacuum 清理,是因为它们更像入口壳;而真正 signed 的请求则进入更强保护区。


十、最容易误解的 6 个点

误区 1:邮件里有 token 就够了

不够。

实名邮件链接还叠加了 timestamp + HMAC exp 的过期校验。

误区 2:shared 和 sent 只是两个展示状态

不是。

它们对应的是两套不同的身份绑定与拒签处理逻辑。

误区 3:换签署人只需要改 partner

不行。

源码明确会轮换 token,并要求重新发送访问入口。

误区 4:shared 请求里有人拒签,就说明整条共享请求该取消

也不对。

官方会复制副本留痕,尽量不破坏原共享入口。

误区 5:签署日志只是审计展示

远不止。

它本质上是把原始 PDF 与后续签署动作串成了哈希链。

误区 6:签署完成后删掉请求也没关系,反正 PDF 还在

不行。

源码明确禁止删除 signed 请求,说明“请求对象 + 日志 + 完成件”是一整套证据对象。


十一、二开时最值得抄的 5 条经验

如果你在 Odoo 里做任何“外部签署 / 外部确认 / 安全链接访问”能力,sign 模块最值得抄的是这五条:

  1. 访问 token 不等于长期有效凭证,邮件链接要再叠加时效签名。
  2. 共享入口与实名请求分层建模,不要混成一个状态机。
  3. 参与人一旦改派,旧入口必须立即失效。
  4. 拒签要区分“影响单个参与者”还是“销毁整个共享入口”。
  5. 审计日志不要只做展示,要能跟最终完成件形成证据链关联。

这五条每一条都能直接减少真实实施里的争议和风险。


结语

Odoo 企业版 Sign 真正做得深的地方,从来不是“能发链接签 PDF”,而是把:

  • 访问入口
  • 过期校验
  • token 轮换
  • 拒签分流
  • 完成件证书
  • 日志哈希链

串成了一条比较完整的签署安全与审计链。

所以这套源码真正解决的,不是“怎么把合同发出去签”,而是“怎么让签署入口、签署动作与签后证据在同一条可验证链路里闭环”。

理解了这一点,你以后再看门户签署、供应商确认、公共表单签字、外部客户拒签时,就不会只问“链接发没发出去”,而会先问:

  • 这条链接多久失效?
  • 改派后旧人还能不能点?
  • 共享入口被拒签一次后还能不能继续?
  • 最终 PDF 与日志证据怎么挂上?

这些,才是 sign 模块真正值钱的地方。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。