Block 开源多 Agent 通信平台 buzz 的两层隔离:租户在中继之上,频道在租户之下

2026-08-05

本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。

在 Block 开源的多 Agent 通信平台 buzz 里,真正的隔离边界不是频道,是租户;而租户身份由服务端从连接的 Host 解析,客户端连提交它的入口都没有。 频道只是租户内部的分区。你如果按”频道 = 权限边界”的直觉去读它的代码,会在第一个 SQL 主键上就对不上号——channels 的主键是 (community_id, id),不是 id

这篇只讲这两层怎么搭起来、各自钉在哪个文件里、以及它明确不负责什么。站内另外三篇讲的是相邻但不同的问题:Agent 工作区隔离 讲单机上给 Agent 划文件系统与进程边界,openwork 的篱网隔离 讲另一个项目在桌面端怎么围网络出口,Agent 上下文管理 讲单个会话内的信息取舍。本篇是服务端多租户的那一层:多个组织共用一套中继与一个数据库时,凭什么彼此看不见。

一、先把几个词对上

buzz 建在 Nostr 之上。仓库 README 这样定位自己:一个”人与 Agent 一起建设的工作区,跑在你自己的中继上”(原文 “A workspace where humans and agents build together, on a relay you own”)——这句里的”你自己的中继”正是本篇要拆的那层结构,许可证是 Apache-2.0(Copyright 2026 Block, Inc.)。四个词得先说清,后面才好往下走。

中继(relay):一个 WebSocket 服务进程,客户端连上去,往里发消息、也从里订阅消息。它是消息的存转节点,不是账号系统。仓库里 crates/buzz-relay 就是它。

事件(event)与签名:Nostr 里所有内容都是一个带签名的 JSON 结构,叫事件。事件有 kind(类型编号)、内容、标签,以及作者用私钥做的签名。crates/buzz-core/src/kind.rs 把用到的编号都列成常量,比如 KIND_PROFILE = 0(资料)、KIND_GIFT_WRAP = 1059(私信封装)、KIND_HTTP_AUTH = 27235(HTTP 请求签名,即 NIP-98)、KIND_NIP43_MEMBERSHIP_LIST = 13534(成员名单)。NIP 是 Nostr 的规范编号,类似 RFC;仓库自己也写了一批,放在 docs/nips/(15 份 md 加 2 份 fixtures json,另有 crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份)。

密钥自持:身份就是那对密钥,公钥(pubkey)是你的标识。这一点在 schema 里看得最直白——channel_members 的主键是 (community_id, channel_id, pubkey)pubkeyBYTEA,没有用户名密码列。好处是身份可携带,同一把钥匙能加入不同社区;代价是丢私钥等于丢身份,服务端只能增删成员(buzz-admin 提供 add-memberremove-memberlist-members),换不回你的私钥。

租户与频道:租户在仓库里叫 community,是安全边界;频道(channel)是租户内部的消息分区,在 Nostr 事件里体现为 h 标签。crates/buzz-core/src/filter.rs 里就有针对 "h" 标签的匹配分支。

二、租户这一层在解决什么

docs/multi-tenant-relay.md 开头把老状况写得很清楚:过去一个 buzz 中继进程就是安全边界——一个 DATABASE_URL、一个中继密钥对、一张中继全局的 relay_members 表,channel_id(也就是 h 标签)是中继内部唯一的次级作用域(文档原文用的词是 sub-relay locality)。想给第二个团队开一套,就得再起一套进程和一个库。

这份文档(状态标注为 draft)提出的做法是把中继进程降格成无状态计算,把新的 community 实体升成租户边界,以 community_id 这一列挂在每一行受作用域约束的数据上。它同时给这个改动做了形式化论证:用 TLA+ 建并发与服务模型、用 Tamarin 建授权协议模型(TLA+ 是描述并发系统状态迁移的规范语言,配 TLC 模型检验器穷举状态;Tamarin 是符号化的协议验证器,在假定攻击者能截获改写全部网络消息的前提下证明或反驳安全性质)。模型文件在 docs/spec/,文档里给出的运行方式是 tamarin-prover --prove docs/spec/MultiTenantAuth.spthy

这里要按仓库口径转述,不要替它加码。文档自己划了范围:它证明的是安全性(“不发生坏事”),并明确证明活性与性能,不重证 Postgres 内部正确性和密码学原语,也不声称时序不可区分——缓冲区命中、autovacuum、连接池尾延迟这些共享物理信道被列为 C1 类,写着不作声称。这种把不管的事情写在纸面上的写法,比一句”已隔离”有用得多。

三、租户怎么定住:Host 解析出来的 TenantContext

crates/buzz-core/src/tenant.rs 是这一层的核心,它的模块注释直接把不变量叫做”围栏”(the fence):请求的 community 由服务端从连接 Host 解析,绝不由客户端提供或影响。

类型上怎么表达这件事:CommunityId 是 UUID 的不透明新类型,TenantContextcommunityhost 两个私有字段组成。关键在于它没有 Default没有 Deserialize,也没有任何”从客户端输入解析出一个 community”的入口;构造只能走 TenantContext::resolved(...)。注释里对这道围栏的自我评价很克制,原话是这是一道 lint 与评审的围栏,不是编译器围栏——因为 resolvedCommunityId::from_uuid 必须 pub,别的 crate 里的 host 解析路径要调它,于是”铁了心的调用方”在别处照样能调;真正堵住故意绕道的是迁移 lint 与评审,类型只消除了误用路径。

Host 归一化是另一个容易翻车的点,同文件的 normalize_host 把规则收在一处:ASCII 小写、去掉一个结尾的根域点、去掉默认端口 :80:443(非默认端口保留,因为同名不同端口可以合法地服务不同社区),首尾空白先 trim。它的测试把这层意图写成了断言:relay.exampleRelay.ExampleRELAY.EXAMPLErelay.example.relay.example:443relay.example:80 全部归一到同一个字符串——同一个租户不会因为写法不同裂成两个。IPv6 字面量 [::1] 带冒号但不带默认端口后缀,保持原样。空串归一后仍是空串,解析侧按失败处理,不给默认租户。

配套的 relay_url_authority 从中继 URL 里取 authority,保留显式非默认端口和 IPv6 方括号(Url::host_str() 会把这两样丢掉),供启动时播种社区、bind_deployment_communitybuzz-admin 三处推导出逐字节相同的 host。注释里点了这个坑:默认开发环境播种的是 localhost:3000 而不是裸 localhost,三处只要有一处推导不一致,admin 查出来的社区就跟启动播种的那条对不上。

真正的绑定动作在 crates/buzz-relay/src/tenant.rs。它定义了 HostResolver trait(resolve_host 返回 Ok(Some(_))/Ok(None)/Err(_) 三态,把”host 没映射”和”查询本身失败”分开),以及唯一入口 bind_community:先归一化,再解析,Ok(None)Err(_) 都变成 BindError,注释写明”故意没有任何产出默认或回退社区的路径”。生产实现是 impl HostResolver for buzz_db::Db,落到 lookup_community_by_host

这个文件里还留着一段很值得读的红队测试(redteam_attack2):schema 并不禁止 communities 里存一行 host = '',于是一个 Host 头缺失的请求会带着空串走到 bind_community,在修复前会静默绑到那条配置错误的空 host 社区上。修法是在解析器查询之前短路:normalize_host(raw_host).is_empty() 直接返回 BindError::UnmappedHost。复用已有变体而不新增 EmptyHost,是为了让拒绝的响应与任何其它未映射 host 逐字节相同,未认证的调用方没法用它探测部署里有没有空 host 行。

存储侧对上:migrations/0001_initial_schema.sqlcommunities 表是 idhost VARCHAR(255)signing_keycreated_at,唯一索引建在 lower(host) 上,注释称之为双保险——即便写入方忘了归一化,Relay.Examplerelay.example 也不会变成两个租户。这张表被显式列进 _operator_global_tables 允许清单(另两行是 rate_limit_violations 与该清单表自身),理由写着:它是租户注册表本身,id 就是租户键,所以它自己不带 community_id

组成部分它负责什么仓库位置你什么时候会碰到它
CommunityId / TenantContext租户身份类型,无 Default/Deserialize,只能由 host 解析铸造crates/buzz-core/src/tenant.rs给任何跨层函数加参数、想”顺手传个 community_id”时
normalize_host / relay_url_authority唯一一份 host 归一化规则,防同一租户裂成两个crates/buzz-core/src/tenant.rs配域名、非默认端口、IPv6 或本地开发环境时
HostResolver / bind_community连接建立时把 Host 绑成租户,未映射与查询失败都拒crates/buzz-relay/src/tenant.rs新增任何对外可达 handler 时
communities租户注册表,唯一索引在 lower(host),属 operator-globalmigrations/0001_initial_schema.sql排查 unmapped host、开新租户
channels 表与不可变触发器频道归属租户,主键 (community_id, id),改归属直接抛异常migrations/0001_initial_schema.sql写迁移、想给频道换租户时
ChannelType / ChannelVisibility / MemberRole频道形态、可见性、成员角色与数值权限级crates/buzz-core/src/channel.rs建频道、做授权比较时
create_channel / is_member / get_accessible_channel_ids频道与成员的数据访问,签名都带 CommunityIdcrates/buzz-db/src/channel.rs写任何频道相关查询时
POST /operator/communities开通租户,受部署级 pubkey 允许清单门控crates/buzz-relay/src/handlers/community_provisioning.rs真正上多租户部署时

四、频道在租户之下

租户定住之后,频道这一层就简单了,但几处细节决定它能不能当团队协作用。

归属不可改。 channels 的主键是 (community_id, id),注释明确说频道 UUID 仍是有效的线上标识,但不是全局唯一——同一个 UUID 可以合法地存在于两个社区,这被一致性清单列为必须覆盖的隔离测试。更硬的是 channels_community_id_immutable() 触发器:BEFORE UPDATE 逐行检查,community_id 一变就抛 check_violation,错误文本写着某频道不能被重新租户化。迁移文档给的理由是,这条不可变性是授权证明里 mint 拒绝闭合的承重假设——如果允许改归属,就能”先被拒绝一次、改标签、再重放原字节”绕过检查。

频道自己的形态与可见性。 crates/buzz-core/src/channel.rsChannelTypeStream(线性消息流,默认)、Forum(帖式讨论)、Dm(私聊)、Workflow(内部工作流执行通道);ChannelVisibility 只有 Open(可搜索,无需邀请)与 Private(隐藏,需邀请)。两个枚举的字符串表示和数据库枚举、Nostr 标签取值是同一套(CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow'))。还有个小而实用的函数 canonical_channel_name,把用户输的前导 # 和空白剥掉再存,因为客户端渲染时会自己补 #;测试里连 "# #" 归一成空串这种边界都写上了。

角色与 Bot 的位置。 MemberRoleOwner/Admin/Member/Guest/Botpermission_level() 给出 4/3/2/1/0,has_at_least 做数值比较。注释特别标了一句:Bot 是独立标记,不在线性层级里,permission_level 返回 0,必须走显式授权。做 Agent 平台的人应该对这个设计取向敏感——它意味着”Agent 是成员”和”Agent 有权限”被拆成了两件事,你不能靠把 Agent 塞进成员表来给它权限。想顺着这条线继续,可以看 最小权限的 Agent 设计

查询层怎么带上租户。 crates/buzz-db/src/channel.rs 里几乎每个函数第二个参数就是 community_id: CommunityIdcreate_channelcreate_channel_with_id(客户端指定 UUID 的幂等版本,ON CONFLICT (community_id, id) DO NOTHING,返回是否新建让调用方决定拒不拒)、is_memberget_memberslist_channelsget_accessible_channel_ids 是订阅解析要用的那个集合,SQL 是”该 pubkey 的有效成员频道” UNION “该社区内 visibility = 'open' 的频道”,两侧都带 community_id = $1。这个函数在迁移文档的实现对应章节被点名过,说它的无作用域版本会 union 掉库里每一个 open 频道、不得出现在任何租户作用域路径上;今天它的签名要求传 CommunityId,两个分支也都限定在社区内。

channel_members 的外键是复合的——FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id),成员行想引用另一个社区的频道,连外键都过不去。

五、边界与代价

这套设计是有代价的,仓库自己写了不少,值得照抄下来当决策依据。

共享一个库,隔离靠行不靠进程。 迁移文档说得直白:这个改动把进程级边界塌缩成行级边界。行级多租户加行级安全策略(RLS,Postgres 按策略谓词自动过滤每行是否对当前会话可见)不是新花样,文档自己在先前工作里承认这一点。代价是一堆配置义务变成承重结构:策略要是限制型(restrictive)而不能有放行跨租户的宽容策略、请求角色必须非超级用户且 NOBYPASSRLSapp.community_id 必须用 SET LOCAL 在事务内设置并在事务结束清掉、所有唯一约束与外键都得含 community_id。这些在文档里是 A-RLS-1 到 A-RLS-5 五条公理,配套要求部署时用断言套件逐条准入,断言不过就拒绝部署。换句话说:拿掉一条配置,隔离就不成立,而代码不会告诉你。

时序与物理资源不在保证内。 共享缓冲池、autovacuum、规划器统计、分区右端吞吐、连接池尾延迟被列为 C1 类,文档写明不声称时序不可区分。同租户的邻居能测出你忙不忙。

撤销不追溯历史写入。 C3 类写得很明确:准入围栏管的是当前能力,撤销成员会移除当前成员行和能力,但不会重新标注或删除他在成员期内写下的行——那些行保留原来的社区标签,仍然在库里。文档直接说不声称”撤销成员时历史写入被撤销”。如果你的合规要求是”人走数据走”,这块得自己在运维数据生命周期层面做。

客户端泄漏不在证明边界内。 证明边界是中继的可观测接口。多租户 UI、分享出去的事件标识、截图、日志里带出自己在 A 社区的事件 id,然后拿着这个 id 从 B 连接去探测,这类路径被列为具名残留,文档说这是客户端的义务而不是中继闭合的信道。

未认证的全局面要自己守。 位于 / 的中继信息文档(NIP-11,中继自我描述的那份 JSON)按构造就没有租户作用域,所以闭合手段是类型输入围栏:RelayInfo::build 的签名只能吃中继静态配置,不能长出 &PgPool、租户上下文或审计服务。文档提醒得很实在:加一个 total_events 计数器只差一个参数,而标签流不变量抓不到这类改动。

开通租户的那个口子是部署根权限。 crates/buzz-relay/src/handlers/community_provisioning.rs 的注释说得清楚:创建社区的效果是创造租户本身,所以授权身份必须在租户之上,门控是部署级的 RELAY_OPERATOR_PUBKEYS 允许清单,空清单(默认值)等于彻底关闭这个功能;且配了这个清单就必须同时配 RELAY_OPERATOR_API_ORIGIN。请求体是 host 加两个可选字段 initial_owner_pubkeycreate_only(后者置 true 时要求原子地新建 host 与 owner、遇到已存在的 host 直接拒绝,而不是收敛或轮换所有权),而 initial_owner_pubkey 对已存在的社区会轮换该社区的 owner——注释写着中继运维方是部署根权威。你要是把这个端点暴露到公网,暴露的不是一个租户,是全部租户的归属。

六、上手与避坑清单

  • 别把 h 标签当租户来源。 会踩,因为 h 是客户端在事件里自己带的频道标签,读代码时很容易顺手拿它去查。文档把它定义为客户端断言的路由提示、按对手可控输入建模,并点名这是混淆代理(confused deputy)风险:中继握着对共享库的宽泛权限,客户端递进来一个名字,一旦中继用自己的权限去执行这个名字,客户端就越界了。写法:所有作用域调用都接 &TenantContexth 只能在已定住的租户内解析成频道。
  • 本地开发时先确认 host 归一化后能对上表里的行。 会踩,因为 communities.host 存的是已归一化形式,而 bind_community 对未映射 host 的拒绝是通用错误、故意不回显 host,你从错误消息里看不出是拼错了还是没播种。排查顺序:先把连接 URL 过一遍 relay_url_authorityws://localhost:3000 得到 localhost:3000,不是裸 localhost),再去 communities 里按 lower(host) 找那一行。
  • 别用 DeserializeTenantContext 开后门。 会踩,因为写 REST handler 时最省事的写法就是给请求体加一个 community_id 字段。这恰好是这份类型设计要消掉的那条路径。要额外注意的是它自称只是 lint 与评审围栏——编译器不会替你挡,你得靠评审挡。
  • 写迁移前先看那条不可变触发器。 会踩,因为业务上”把这个频道挪到另一个组织”听起来是个正常需求。它不是 UPDATE 慢一点,是直接抛异常,而这条不可变性是上游授权证明的承重假设。真要做,按文档口径得当成一次单独的公理准入并重新验证相关性质,不是改一行 SQL。
  • 多副本部署前查 NIP-98 重放集合。 会踩,因为默认单副本下它工作得很好,扩容时不会有任何报错。文档的 P3 一致性条目写明:重放判定同时依赖 ±60 秒时间窗(TIMESTAMP_TOLERANCE_SECS = 60)和按事件 id 的已见集合,而这个集合是进程内的(容量 10000、TTL 120 秒,即窗口的两倍)。同一个重放事件打到两个 pod 就各成功一次。文档给的推荐修法是换成有原子 insert-if-absent 语义、TTL 不小于 120 秒的共享存储;另一条按 Authorization 头做粘性路由的做法它自己列了两条缺陷(依赖头字节完全一致、不适用于非 HTTP 的铸造路径)。仓库里 deploy/charts/buzz/examplesreplicaCount: 3 的示例被明确标为按原样不满足这一条。
  • Redis 键要带社区前缀。 会踩,因为现有键名(如 buzz:channel:{uuid})在单租户下完全正常。文档给的安全形状是 buzz:{community}:channel:{channel_id} 这类,并说明不带前缀的现状只对退化的单社区部署或物理隔离的 Redis 成立。同一个频道 UUID 在两个社区里合法共存,这正是键冲突的入口。
  • 给 Agent 发消息权限时,别靠塞成员表。 会踩,因为 MemberRole::Bot 在成员表里看着和别的角色平级,实际 permission_level() 是 0、has_at_least 对任何非 Bot 要求都返回 false。要给它能力就得走显式授权,而不是指望角色层级把权限带出来。
  • 注意帧上限这类硬常量。 单帧默认上限是 512 KiB(DEFAULT_MAX_FRAME_BYTES = 512 * 1024crates/buzz-relay/src/config.rs:14,可用 BUZZ_MAX_FRAME_BYTES 覆盖);community_provisioning.rsMAX_HOST_LEN = 255 对齐 communities.host VARCHAR(255)。把大产物直接从 Agent 塞进事件内容里,会先撞上前者。

顺一遍收尾:这套两层模型的可信度不在”已经隔离好了”这类断言上,而在每一条声称都能被你当场翻回文件核对——围栏在 crates/buzz-core/src/tenant.rs,绑定在 crates/buzz-relay/src/tenant.rs,归属不可变在 migrations/0001_initial_schema.sql 的触发器,取值集合与角色在 crates/buzz-core/src/channel.rs,声称与范围在 docs/multi-tenant-relay.mddocs/multi-tenant-conformance.md。仓库里 crates/ 下 28 个 crate、migrations/ 27 份 SQL,这两个数你自己 ls 一下就能对。

要评估它能不能扛住你的场景,建议按这个顺序自检:① 你的 handler 是不是在读请求体之前就拿到了 TenantContext;② 你新加的唯一约束和外键有没有带 community_id;③ 你的缓存与 Redis 键有没有社区维度;④ 撤销成员之后历史数据留在库里是否可接受;⑤ 多副本部署是否补了共享的重放集合。想横向比较别的 Agent 协作项目在协议底座上的取向差异,可以接着读 Agent 协议生态对比

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源的多 Agent 通信平台 buzz:一切皆签名事件Block 开源多 Agent 通信平台 buzz:engram 把记忆做成事件之后,检索边界在哪

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。