Block 开源 buzz 多 Agent 平台:多租户隔离线要画三遍

2026-08-05

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

一条隔离线只画一遍,等于没画。 Block 开源的多 Agent 通信平台 buzz 把「社区(community)就是租户」这条线,在核心类型层、中继连接绑定层、数据库键与分区层各画了一遍:类型层让客户端根本没有语法途径说出自己属于哪个租户,中继层在读到第一个协议帧之前就把租户钉死,数据库层把租户键压进主键、外键和每条索引的最左列。三层缺任何一层,另外两层都会被绕过——而绕过的表现不是报错,是数据串门。

一、先把底座讲清楚:Nostr、中继、社区

buzz 建在 Nostr 协议之上。对做 AI 工程但没碰过去中心化协议的人,先解释掉四个词:

  • 事件(event):Nostr 里一切都是事件,一个带类型(kind)、作者公钥、时间戳、标签、正文的 JSON 对象。它的 id 是对规范化内容做的 sha256,签名是作者私钥对这个 id 的签名。
  • 中继(relay):接收、存储、按订阅条件转发事件的服务器。它不是区块链节点,没有共识,就是一台带订阅语义的消息服务器。
  • 密钥自持:身份是一对密钥,私钥在用户或 Agent 手里,不在服务器上,服务器只验签。丢私钥等于丢身份,没有找回流程——把 Agent 私钥塞进容器环境变量之前得先想清楚这点。
  • 社区(community):buzz 自己加的一层。原本一个中继进程就是一个安全边界(一个数据库、一套成员表);多租户模型把中继进程降级成无状态计算,把 community 提升为租户边界,作为 community_id 挂在每一条受作用域约束的行上。

这个改动的分量在于:它把进程级边界塌缩成了行级边界。 进程级边界靠”你连的是另一台机器”天然成立;行级边界靠每一处代码都记得带上那个列才成立。docs/multi-tenant-relay.md 就是为这次塌缩写的规范,用 TLA+ 建并发与服务模型、用 Tamarin 在 Dolev-Yao 攻击者假设下建授权协议模型(形式化验证的意思是:把性质写成机器能穷举或推导的命题,让工具去找反例,而不是靠人读代码相信)。这份文档头部自己标着 draft,其中的定位与结论是项目自己的说法。

几个你 ls 一下就能数出来的结构事实:crates/ 下 28 个 crate,migrations/ 27 份 SQL,docs/ 53 个文件(其中 docs/nips/ 15 份 NIP 规范 md 加 2 份 fixtures json),根目录 8 份 VISION*.md,许可证 Apache-2.0(Copyright 2026 Block, Inc.)。那 8 份 VISION 写的是愿景不是已实现清单。根目录同时放着 AGENTS.mdCLAUDE.md——这是个给 Agent 读的仓库。

站内相邻几篇讲的是不同层:OpenWork 的立网隔离是另一个项目在网络与连接维度的切法,Agent 工作区隔离讲单机上给 Agent 划文件与执行边界,AI 数据安全风险讲数据交给模型这条链上的风险面。这篇只管一件事:同一条租户线在一个真实开源仓库的三层里各画一遍的具体形状。

二、第一遍:类型层让客户端没法命名租户

crates/buzz-core/src/tenant.rs 只有两个类型和两个函数,却承担了整条隔离线的形状。

CommunityId 是包住 Uuid 的 newtype,TenantContext 里装着 communityhost 两个私有字段。关键不在它有什么,在它没有什么:没有 Default,没有 Deserialize,没有任何从客户端输入解析出一个 community 的入口。构造只有一条路——TenantContext::resolved(community, host),模块文档写明它只允许 host 解析路径调用。

这里有一处值得学的诚实。文件里自己写着:这是 lint-and-review fence,不是 compiler fence。因为 TenantContext::resolvedCommunityId::from_uuid 都得是 pub(host 解析路径在另一个 crate 里),别处的调用者理论上也能调。类型系统消掉的是意外路径(反序列化出一个客户端选的 community),故意的那条路由 lint 与评审关掉。它没有把这层说成保证——pub API 给不了的东西就不吹,这比”我们做了类型安全”有用,因为它告诉你剩下的窟窿在哪、由谁看着。

同一个文件里还有一条容易被当成小工具、其实是隔离线一部分的函数:normalize_host。规则三条——ASCII 小写、去掉一个结尾的根域点、去掉默认端口后缀(:80 / :443,非默认端口保留)。为什么这也算隔离?因为 communities.host 列存的就是已规范化的形式,请求侧用同一个函数规范化后再查,两边由构造保证一致Relay.Examplerelay.example.relay.example:443 只会落到同一个租户,不会裂成三个。单测名字就叫 normalize_host_collapses_tenant_split_variants

配套的 relay_url_authority 管另一个方向:从配置的中继 URL 抽出 authority,保留显式非默认端口、保留 IPv6 方括号。注释点明原因:直接用 Url::host_str() 会把端口和括号都丢掉,ws://localhost:3000 会被解析成 localhost,而启动播种的社区在 localhost:3000 下——两边对不上,管理端就查不到那个社区。

三、第二遍:中继层在读第一帧之前钉死租户

crates/buzz-relay/src/tenant.rs 管的是这条线的时机,文件开头把不变式写成一行:req.community = resolve_host(connection.host),绑定发生在连接建立时。三个点值得抄:

解析被抽成 trait,不绑数据库。 HostResolver 只要求一个 resolve_host(&self, normalized_host) -> Result<Option<CommunityId>, Self::Error>。注意返回类型的三态:Ok(Some(_)) 命中,Ok(None) 是”输入合法但这台部署上没有这个 host”,Err(_) 是”查都查不了”。生产实现是给 buzz_db::Db 写的 impl,薄薄一层转接到 lookup_community_by_host;测试里用基于 HashMapMapResolver,绑定逻辑不用数据库就能测。

两种失败都失败关闭,而且长得一样。 BindError 只有 UnmappedHostLookup(E) 两个变体,注释明确要求调用方把它们都转成泛化错误:不回显 host、不区分”没映射”和”查询失败”,免得未认证的调用者拿它当探针枚举这台部署上有哪些社区。没有任何一条路径给默认租户。

空 host 在查库之前就被挡掉。 这条是红队打出来的:

if host.is_empty() {
    return Err(BindError::UnmappedHost);
}

原因写在旁边:schema 并不禁止 communities 里存在一行 host = ''。运维配错或迁移有 bug 造出这行之后,Host 头缺失或不可读的请求就会静悄悄绑到这个错配社区上。redteam_attack2 测试模块留了三个用例,把”空 host""纯空白 host""非空但未映射 host”三种形状钉住,并且特意复用 UnmappedHost 而不新增变体——响应字节级一致,攻击者探不出这台部署有没有那行空 host。

调用点在 crates/buzz-relay/src/router.rs:绑定发生在 WebSocketUpgrade 之前,注释写着这样”就不会有任何帧在未绑定的连接上被读到”。全仓 crates/tenant::bind_community( 出现 15 处,从 REST 桥接、媒体、邀请、NIP-05、音频到 git Smart HTTP 传输,每个外部入口各绑一次。压根没有请求 Host 头的服务器内部路径(git 传输、本地钩子回调、工作流执行落地、启动任务)另有 bind_deployment_community,走同一条 bind_community,只是 host 来自配置的中继 URL——注释专门声明这不是默认租户。

一个故意开的口子:NIP-11(中继信息文档)在绑定之前服务,而且 fail-open——未映射的 host 也拿得到文档,只是 host 相关字段(比如工作区图标)缺省不出现。注释解释了取向:如果未映射 host 直接被拒,这个未认证端点本身就成了”哪些 host 是有效租户”的枚举器。这是拿一个信息泄露换一个更小的,是判断不是漏洞,但你自建时得知道它在这儿。

组成部分它负责什么仓库位置你什么时候会碰到它
CommunityId / TenantContext租户身份的唯一命名方式,无 Default、无 Deserializecrates/buzz-core/src/tenant.rs给要读写租户数据的函数写签名时
normalize_host / relay_url_authority唯一的 host 规范化规则,两侧共用,防租户裂开crates/buzz-core/src/tenant.rs配反向代理、改端口、写运维脚本时
HostResolver / bind_community / BindError把连接 host 解析成租户上下文,失败关闭crates/buzz-relay/src/tenant.rs新增任何外部可达的处理器时
row zero 调用点在 WebSocket 升级之前完成绑定crates/buzz-relay/src/router.rs排查无社区 404、Host 头被改写时
communities 表与 lower(host) 唯一索引租户注册表本身,运维平面全局migrations/0001_initial_schema.sql开新租户、排查 host 冲突时
复合主键与不可变触发器channels 主键 (community_id, id)events 主键 (community_id, created_at, id)migrations/0001_initial_schema.sql写任何唯一约束或外键时
月度分区管理器eventsdelivery_log 按月建分区,与租户无关crates/buzz-db/src/partition.rs容量规划、跨月上线、排查写入失败时
lookup_community_by_host唯一的 host→社区查询,带 archived_at IS NULLcrates/buzz-db/src/lib.rs归档社区后请求突然打不通时
Redis 键构造buzz:{community}:channel:{id} 这类带租户段的键crates/buzz-pubsub/src/topic.rs共享 Redis、跨节点扇出时
规范与合规清单模型、公理、逐面核对表docs/multi-tenant-relay.mddocs/multi-tenant-conformance.md部署前自证、评审新表新键时

四、第三遍:数据库层——键里带租户,分区管的是时间

第三遍画在 migrations/0001_initial_schema.sql。它的头部注释直接列了迁移 lint 的四条义务:每张受作用域约束的表都有 community_id NOT NULL;任何 UNIQUE / 主键 / 外键都不能跨社区可观测(各自以 community_id 领头,或子行通过携带社区元组的联合外键继承父行社区);channels.community_id 插入后不可变;运维平面全局表必须在显式白名单里列名,不靠推断。

  • communities 表是运维平面全局的:它是租户注册表,自己不带 community_id(它的 id 就是租户键),host 上建的是 CREATE UNIQUE INDEX ... ON communities (lower(host))。注释把这个 lower() 叫”belt-and-suspenders”:即便某个写入方忘了规范化,Relay.Examplerelay.example 也不可能变成两个租户。
  • channels 主键是 (community_id, id) 而非 id。注释写得很清楚:channel 的 UUID 仍是合法协议标识符,但不是全局唯一的——同一个 UUID 可以合法地在两个社区各存一份,合规清单把这种碰撞列成必需的隔离测试项。不可变性不靠约定靠触发器:channels_community_id_immutable()BEFORE UPDATE,改 community_id 直接抛异常,报错文案带着”cannot be re-tenanted”。
  • events 主键是 (community_id, created_at, id),这一条是存在性预言机的封口。插入走 ON CONFLICT DO NOTHING(见 crates/buzz-db/src/event.rs),若唯一键只有 id,B 社区写入时看到”零行受影响”就学到了”某个租户已经写过这个 id”——一条隐蔽的跨租户信道。键做成复合的之后,B 在 A 已占的 id 上写入拿到的是一个全新的键,受影响行数只是 B 自身状态的函数。
  • 索引全部以 community_id 领头。注释解释了那条看着多余的 idx_events_community_id:分区键 created_at 挡在 community_idid 之间,没有它,WHERE community_id=$ AND id=$ 会退化成扫分区。

现在说最容易被误读的地方。crates/buzz-db/src/partition.rs 叫 partition,但它和租户毫无关系eventscreated_atdelivery_logdelivered_at,各自 PARTITION BY RANGE 切的都是时间轴,这个模块只负责往后按月建够分区。DDL 标识符没法参数化,所以它用三重校验兜底——表名必须在 PARTITIONED_TABLES(只有 eventsdelivery_log)这个白名单里,分区后缀只准数字和下划线(validate_partition_suffix),日期串严格 YYYY-MM-DD 且逐字节检查(validate_date_str)。单测直接拿 2026_03; DROP TABLE events-- 断言拒绝。它还容忍一种失败:CREATE TABLE ... PARTITION OF 撞上 42P17 且报错含 would overlap partition 时当作”已确保”处理,因为全新 schema 自带右边界兜底分区 events_p_future

要带走的判断是:隔离靠键,容量靠分区,这是两件事。 看到 partition.rs 就以为”每个租户一个分区”,会同时得出错误的容量模型和错误的安全模型——租户之间共享 id 空间、共享时间分区、共享连接池、共享 CPU,规范里是明写的。

数据库层还有两处容易漏掉的加固:lookup_community_by_host 的 WHERE 里带着 archived_at IS NULL,社区一归档,host 解析立刻查不到,走的还是那条泛化拒绝;Redis 侧的键在 crates/buzz-pubsub/src/topic.rs 里是从 TenantContext 拼出来的 buzz:{community}:channel:{channel_id}buzz:{community}:global,presence、连接控制、缓存失效通道(crates/buzz-pubsub/ 下各有一个模块)同样带社区段。共享一套 Redis 的部署里,这一段是必需的。但 topic.rs 的模块注释先把话说在前头:pub/sub 主题是路由与性能边界,不是授权边界——租户身份仍旧来自 TenantContext,本地扇出之前中继还会再核一遍访问权。键上带社区段是防串台的最低要求,不能拿它当权限判定。

五、边界与代价:这个设计放弃了什么

这一节贴着仓库自己的声明写(docs/multi-tenant-relay.md 的 Scope and Non-Goals 与 Isolation Boundary 两节),它把非目标列得比多数项目都清楚。

不证活性与性能。 查询能不能满足延迟预算、热分区会不会打满,规范说这是经验问题,交给性能台架,不由定理管。

不重证 Postgres 与密码学。 行级安全(RLS)的执行、MVCC 快照隔离、ON CONFLICT DO NOTHING 的语义、BIP-340 Schnorr 签名的不可伪造性、事件 id 哈希的第二原像抗性,全部作为公理写在案上,证的是这些公理之上的组合。

时序信道明确不做保证。 缓冲池命中率、autovacuum、执行计划统计、分区右边界吞吐、连接池尾延迟都是共享的,同租户可以拿它们当计时器测。规范把这一类命名为 C1 并声明出界。

撤销不追溯历史写入。 把成员从社区移除会拿掉他当前的成员关系和读能力,但不会给他在有权限期间写下的行重新打标或删除——那些行保留原社区标签,仍然在。规范把这条列为 C3 并明说:证的是”当前成员关系与当前读能力跟随当前准入”,不是”写入被追溯撤销”。留存清理是运维侧的另一条通路。

最该注意的一条:RLS 是公理,不是代码。 规范把 A-RLS-1..5 列为部署时逐条自证的义务:每张可查询的租户表启用 RLS 并挂一条比对 current_setting('app.community_id') 的限制性策略;请求角色非超级用户、NOBYPASSRLS 且不是表 owner;app.community_idSET LOCAL 在事务内设置;SECURITY DEFINER 与 leakproof 一类函数受约束;唯一键与外键都含 community_id。但在这份快照里,27 份迁移 SQL 搜不到任何 ROW LEVEL SECURITY 语句,也没有一条 CREATE POLICY——RLS 只以公理的形式停在文档里,schema 侧并不落地。兜底那层现在要由部署方自己装上并自证:前面三遍画的都是应用层的线,应用层漏一个谓词时替你止血的那层,得你自己确认在不在。

其余暴露面照实说。 自建中继意味着你的 Postgres 里躺着所有社区的事件正文与投递日志,共享 Redis 里躺着所有社区的在线状态与扇出流量,Host 头是唯一的租户选择器——谁能改写它,谁就能改写租户判定。Agent 在这套体系里就是一个持有私钥的普通参与者:它有权发消息,就有权往共享库里写行、就有权触发工作流。NIP-98 的新鲜度(crates/buzz-auth/src/nip98.rsTIMESTAMP_TOLERANCE_SECS = 60,配文档记的 120 秒 TTL 已见集合)在单副本下成立;多副本且没有共享 seen-set 时,规范自己说随包发的 HA 示例是不合规的。

六、上手与避坑清单

每条都写”为什么会踩”,只说”注意”没有意义。

  1. RELAY_URL 少带端口。 会踩是因为:直觉上 ws://localhost:3000 的 host 就是 localhost,而启动播种和 buzz-admin 用的是 relay_url_authority,得到 localhost:3000;报错不会说端口错了,只说这个 host 没映射到社区。怎么避:运维脚本要拿社区就复用 buzz_core::tenant::relay_url_authority,别自己 Url::host_str()
  2. 反向代理改写 Host。 会踩是因为:代理默认可能传内网名或不传,到 bind_community 手里是空串或陌生 host,直接失败关闭且不回显 host,你在日志里看不到”它以为的 host 是什么”。怎么避:代理层固定透传 Host,再按 communities.host 逐字节相同的形式核对(小写、无结尾点、默认端口省略、非默认端口保留、IPv6 带方括号)。
  3. 把非默认端口当成同一个租户。 会踩是因为:normalize_host 只吃掉 :80:443relay.example:8443另一个租户键——一台部署可以合法地在不同端口服务不同社区。怎么避:换端口等于换租户键,communities 里的行要跟着改。
  4. 只加 WHERE community_id 不改唯一键。 会踩是因为:谓词能挡住行返回,挡不住冲突结果;唯一键退回 UNIQUE (id),插入的”零行受影响”就成了跨租户存在性探针。怎么避:新表的每个唯一约束和外键都以 community_id 领头,并把它做成 CI 断言而不是评审习惯。
  5. 把分区当隔离。 会踩是因为:partition.rs 这名字太容易读成”按租户分区”,它其实按 created_at 分月且完全不认识租户。怎么避:容量与保留策略看分区,隔离只看键与索引最左列。
  6. 分区任务失败不致命。 会踩是因为:crates/buzz-relay/src/main.rsensure_future_partitions(3) 失败只 error! 记一行、不退出,启动看着是绿的,跨月之后写入才开始失败。怎么避:把这行日志接进告警,把”未来分区够不够”做成健康检查项。
  7. 自己 mint 一个 TenantContext 会踩是因为:TenantContext::resolvedpub 的,写新处理器时”手上已经有 community_id 了”很自然,一调就过编译。怎么避:新处理器一律接 &TenantContext,让它从 host 绑定那层传下来;有社区 id 但无 Host 头的内部路径走 bind_deployment_community,或用 lookup_community_host 反查 host 补全上下文(其注释明确:host 只用于标注,绝不用来反推社区)。
  8. 共享 Redis 与共享 seen-set。 会踩是因为:本地单社区跑起来一切正常,键不带租户段也不出事;上到多社区共享 Redis,跨节点扇出就可能把事件送进另一个社区的订阅。NIP-98 的 seen-set 同理,进程内缓存在多副本下等于没做重放防护。怎么避:键一律从 TenantContext 拼,多副本部署给 seen-set 换共享存储(要原子的”不存在才插入”语义和覆盖整个窗口的 TTL)。

收尾:四个问题和四个文件

把这三遍抽象出来,是可以搬到你自己系统上的四个问题:类型层,你的租户 id 有没有一条从客户端输入反序列化出来的路(有的话那条路就是你的边界)?绑定层,租户是在读到第一个业务字节之前定下来的,还是某个处理器中途查出来的,未映射与查询失败返回给客户端的字节是否完全一致?键层,翻一遍所有唯一约束、外键、索引最左列,有一个不带租户键就有一个存在性预言机?兜底层,应用层漏一个谓词时谁替你止血,如果答案是 RLS,那就去数据库里确认它真的开着。想接着往上收,可以配合最小权限设计看——那篇讲给 Agent 发多大的权,这篇讲权发出去之后靠什么把它关在一个租户里。

接下来该读哪个文件:看形状读 crates/buzz-core/src/tenant.rs(短,注释本身就是设计说明);看时机与失败关闭读 crates/buzz-relay/src/tenant.rs 连带它的 redteam_attack2 测试模块;看键读 migrations/0001_initial_schema.sql 的头部注释和前 300 行;部署前自证,拿 docs/multi-tenant-conformance.md 那张逐面核对表和文末的迁移门禁清单去对。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 拆解 Block 开源多 Agent 通信平台 buzz 的两套鉴权设计Block 开源 buzz 的审计链:多 Agent 平台里谁改了什么

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