跳转至

群历史越权的 fail-closed 诊断

Bug 摘要

RedisHistoryAuthorizer 对群历史仅检查当前 GroupMembership::is_member。因此用户在 群建立后被加入时,仍可请求该群的全部 canonical history;当前成员关系无法表达 joined_at_conversation_seq,违反 docs/PLAN.md §7.5 与 §12.10 的可见区间语义。

已验证假设

  1. 假设:群历史授权只依赖当前成员资格。
  2. 证据:crates/qim-store/src/message/mod.rs 的群分支只调用 GroupMembership::is_member,返回 true 即放行。
  3. 结果:确认。后加成员与初始成员没有可区分的授权事实。
  4. 假设:成员存储保存了可用于正确区间判断的加入/离开序号。
  5. 证据:crates/qim-common/src/membership.rs 仅保存当前成员、owner、版本与冻结快照; 没有 joined_at_conversation_seq 或 left_at_conversation_seq。
  6. 结果:否定。不能通过当前 Redis 数据安全地推导历史边界。
  7. 假设:计划已定义正确的长期语义。
  8. 证据:docs/PLAN.md §7.5 将历史可见性定义为以 joined_at_conversation_seq 与 left_at_conversation_seq 为界的开区间;§12.10 明确禁止以当前状态点查替代该序号边界。
  9. 结果:确认。完整状态尚未实现,不能假造时间或 boundary。

根因

  • 直接原因:Redis 历史授权器把“当前成员”误当成“可读全部历史”。
  • 根本原因:当前 GroupMembership 数据模型没有用户会话状态的加入/离开序号,无法落实 计划定义的逐条可见区间。

最小安全修复

增加仅用于过渡期的“初始 cohort”资格集合:建群 Lua 原子写入创建者和初始 invitees; 后续 add_members 不写入;remove_member 无条件撤销。授权器通过一个 Redis Lua 调用 原子确认“当前成员且仍在初始 cohort”。资格键不存在时拒绝,类型损坏或 Redis 调用失败时 映射为 HistoryUnavailable,绝不降级为放行。

该策略只允许从群创建时刻起连续在群内的初始成员读取全部历史。它是安全的 fail-closed 兼容措施,不是 joined_at_conversation_seq / left_at_conversation_seq 区间实现:后加成员的群历史会被整体拒绝,初始成员退群再加也不会恢复资格。

影响文件

  • crates/qim-common/src/membership.rs
  • crates/qim-common/tests/redis_backed.rs
  • crates/qim-store/src/message/mod.rs
  • crates/qim-store/tests/redis_message_store.rs

回归风险与发布状态

风险是兼容性收紧:旧群没有 cohort 元数据时,现有成员也会收到历史访问拒绝;这是避免 越权读取的预期 fail-closed 行为。直接会话、消息写入、fanout 与 MessageStore 的范围读取 接口不在本修复范围。

完整的 UserConversationState 与加入/离开序号事件仍是发布阻断项;本修复不能被表述为 群历史功能已完成,也不能替代 PLAN §7.5/§12.10 的区间可见性实现。