群历史越权的 fail-closed 诊断¶
Bug 摘要¶
RedisHistoryAuthorizer 对群历史仅检查当前 GroupMembership::is_member。因此用户在
群建立后被加入时,仍可请求该群的全部 canonical history;当前成员关系无法表达
joined_at_conversation_seq,违反 docs/PLAN.md §7.5 与 §12.10 的可见区间语义。
已验证假设¶
- 假设:群历史授权只依赖当前成员资格。
- 证据:
crates/qim-store/src/message/mod.rs的群分支只调用GroupMembership::is_member,返回true即放行。 - 结果:确认。后加成员与初始成员没有可区分的授权事实。
- 假设:成员存储保存了可用于正确区间判断的加入/离开序号。
- 证据:
crates/qim-common/src/membership.rs仅保存当前成员、owner、版本与冻结快照; 没有joined_at_conversation_seq或left_at_conversation_seq。 - 结果:否定。不能通过当前 Redis 数据安全地推导历史边界。
- 假设:计划已定义正确的长期语义。
- 证据:
docs/PLAN.md§7.5 将历史可见性定义为以joined_at_conversation_seq与left_at_conversation_seq为界的开区间;§12.10 明确禁止以当前状态点查替代该序号边界。 - 结果:确认。完整状态尚未实现,不能假造时间或 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.rscrates/qim-common/tests/redis_backed.rscrates/qim-store/src/message/mod.rscrates/qim-store/tests/redis_message_store.rs
回归风险与发布状态¶
风险是兼容性收紧:旧群没有 cohort 元数据时,现有成员也会收到历史访问拒绝;这是避免
越权读取的预期 fail-closed 行为。直接会话、消息写入、fanout 与 MessageStore 的范围读取
接口不在本修复范围。
完整的 UserConversationState 与加入/离开序号事件仍是发布阻断项;本修复不能被表述为
群历史功能已完成,也不能替代 PLAN §7.5/§12.10 的区间可见性实现。