Channel Binding 与可信入站路由¶
本页是 Issue #26 的实现契约。它先固定控制面模型、候选路由和可信边界,随后由
trpcservice/channels的领域模型、InMemory Repository 和 fake verifier 实现。文中没有 把真实企业微信/Telegram 适配器或 HTTP Gateway 误写成当前交付物。
目标与边界¶
Channel Binding 把一个外部 IM 账号绑定到同一租户的 Agent App。它解决的是“公开回调信息 如何发现候选,以及验签成功后如何建立可信租户上下文”,不是完整的 IM 协议栈。
本 Issue 交付:
- 稳定的
tenant_id + binding_id身份、租户内唯一binding_key、状态、版本和配置摘要; wecom与telegram两种显式 Channel 类型,未知类型拒绝;- 只保存不可逆的公开 route key 摘要、Secret Manager 引用和经过 schema 校验的非敏感协议配置;
- 显式租户边界的 InMemory Repository、乐观锁生命周期和防御性复制;
- 只以
channel + public_route_key_digest查找候选的受限索引; - 不含 Tenant/App/Secret 的一次性
CandidateBindingContext,以及用途绑定、过期和防重放的 candidate-scoped Resolver/Verifier 契约; - 验签成功后固定
tenant + binding + app + version的VerifiedBinding、可信 Routing Target 和无拼接碰撞的单聊/群聊/线程 Runner identity; - 使用 fake resolver/verifier 的离线集成测试。
明确不在范围内:真实供应商 SDK、企业微信 AES 解密、Telegram Bot API、HTTP Gateway、KMS/ Vault、PostgreSQL migration、消息去重/回复 Outbox、队列和生产审计持久化。
控制面模型¶
领域字段¶
| 字段 | 约束与用途 |
|---|---|
tenant_id、binding_id |
不可变稳定身份;所有 Admin 操作显式携带二者 |
binding_key |
租户内唯一、规范化的小写机器键;不可变,不参与跨租户路由 |
channel |
只接受 wecom、telegram;协议类型不能由入站 payload 覆盖 |
provider_account_id |
外部 corp/bot/account 的稳定规范身份;不使用昵称 |
public_route_key_digest |
route key 的 SHA-256 摘要;只用于候选发现,不保存明文 |
app_id |
同租户 Agent App 引用;可信路由固定它,payload/header 不能覆盖 |
secret_ref |
Secret Manager 的不透明引用;不保存 token、AES key 或 bot token |
protocol_config |
按 Channel 的显式 schema 校验的非敏感配置;未知字段拒绝 |
status、version |
draft → active ↔ suspended → disabled;更新和迁移使用 expected version |
config_digest |
规范化非密钥配置的 SHA-256;用于缓存失效和审计,不是授权凭据 |
created_at、updated_at |
UTC 生命周期时间,更新时单调不回退 |
secret_ref 可以参与 config_digest,但 Secret 值永远不能进入 Binding、快照、事件、日志、
trace 或缓存键。Secret Resolver 在可信 Tenant 已确定后,必须以 (tenant_id, secret_ref)
作为查找边界。
PostgreSQL 形状¶
下面是目标持久化形状,不是本 Issue 执行的 migration。protocol_config 只有在应用层按
Channel schema 解码、拒绝未知字段和敏感字段后才能写入。
CREATE TABLE channel_binding (
tenant_id TEXT NOT NULL REFERENCES tenant(tenant_id),
binding_id TEXT NOT NULL,
binding_key TEXT NOT NULL,
channel TEXT NOT NULL
CHECK (channel IN ('wecom', 'telegram')),
provider_account_id TEXT NOT NULL
CHECK (length(btrim(provider_account_id)) BETWEEN 1 AND 256),
public_route_key_digest TEXT NOT NULL
CHECK (public_route_key_digest ~ '^[0-9a-f]{64}$'),
app_id TEXT NOT NULL,
secret_ref TEXT NOT NULL
CHECK (length(btrim(secret_ref)) BETWEEN 1 AND 256),
protocol_config JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'active', 'suspended', 'disabled')),
version BIGINT NOT NULL DEFAULT 1 CHECK (version >= 1),
config_digest TEXT NOT NULL CHECK (config_digest ~ '^[0-9a-f]{64}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, binding_id),
UNIQUE (tenant_id, binding_key),
FOREIGN KEY (tenant_id, app_id)
REFERENCES agent_app(tenant_id, app_id),
CHECK (binding_key ~ '^[a-z][a-z0-9-]{1,63}$')
);
-- Route lookup never scans by tenant and never returns a full Binding.
CREATE INDEX channel_binding_candidate_idx
ON channel_binding (channel, public_route_key_digest)
WHERE status = 'active';
-- A provider account has one active owner. Shared accounts require a new,
-- explicit sharing model; they must not weaken this invariant.
CREATE UNIQUE INDEX channel_binding_active_account_idx
ON channel_binding (channel, provider_account_id)
WHERE status = 'active';
Admin Repository 的 Create/Get/Update/Activate/Suspend/Resume/Disable 都接收显式
tenant_id + binding_id。Binding key 只在 (tenant_id, binding_key) 内唯一,因此不同租户
可以使用相同的 key。当前 InMemory 生命周期边界校验 Binding 自身的状态、版本和 active 账号
唯一性;它不偷偷加载跨聚合的 Tenant/App。可信快照边界 NewRoutingTarget 必须再校验 Tenant
active、Binding active、App 属于同一 Tenant 且 App 可执行;不能因为两个对象的字符串 ID 看起来
相似就跳过复合归属检查。
生命周期与版本¶
Activate Resume
draft ----------------> active <------------ suspended
| | ^ |
| | | |
v v | v
disabled suspended disabled
Suspend Disable (terminal)
draft:可编辑,不进入候选索引;新建对象的默认状态。active:可以由候选索引发现并接受新消息;配置更新仍需 expected version。suspended:拒绝新路由,但保留配置,可以在重新校验后恢复。disabled:终态,不得恢复或重新进入候选索引。
每次写入都在锁内校验 expected_version,成功后递增一次版本并返回变更事件。事件只包含
租户、Binding、actor、reason、correlation ID、状态/摘要和前后版本,不包含 route key 明文、
Secret ref 的值或协议 payload。
候选发现与可信边界¶
公开 route key 是发现线索,不是授权凭据。适配器先规范化 Channel 并计算
SHA-256(长度前缀(channel) + 长度前缀(route_key)),Repository 只接收摘要。候选查询只接收
channel + digest,并返回一个或多个不透明候选;不存在、非 active 或不可用候选使用同一类
不可枚举错误,不返回 Tenant、App、Secret 或完整 Binding。
type CandidateBindingContext struct {
Channel Channel
PublicRouteKeyDigest string
BindingVersion int64
ConfigDigest string
Purpose VerificationPurpose
CandidateToken string // opaque, short-lived, single-use
IssuedAt time.Time
ExpiresAt time.Time
}
Purpose 固定候选只能用于 webhook-verification 这一候选用途;Resolver 不得把同一个候选
转换成别的用途。CandidateToken 是候选索引签发的 opaque bearer capability;它不是 binding_id、tenant_id、
app_id、secret_ref 或 Secret 值,也不能由 URL、header 或 payload 自行拼接。候选 Context
及 token 都只在短 TTL 内有效,调用方修改返回副本不能改变索引内部状态。Resolver 必须把
token 绑定到唯一候选和明确用途,例如 webhook-verification,消费成功后返回一次性
ScopedVerifierHandle。Verifier 只接受供应商
的 signature/token、timestamp、nonce、密文摘要和 receive ID 等协议输入;它不能接受调用方
提供的 tenant_id,也不能让 payload/header 选择 App。
sequenceDiagram
participant IM as IM callback
participant A as Channel Adapter
participant I as Candidate Index
participant R as Scoped Resolver
participant V as Candidate Verifier
participant G as Gateway / trusted target
IM->>A: public route + untrusted payload/header
A->>I: channel + route_key_digest
I-->>A: opaque CandidateBindingContext(s)
A->>R: context + purpose=webhook-verification
R-->>A: one-time ScopedVerifierHandle
A->>V: signature/token + timestamp + nonce + ciphertext digest
alt verification fails, expires, replays, or purpose mismatches
V-->>A: generic verification failure
A-->>IM: provider-safe failure/ack; no tenant identity
else verification succeeds
V-->>A: VerifiedBinding(tenant, binding, version, app, channel)
A->>G: normalized message + VerifiedBinding
G->>G: validate active tenant/binding/app and fixed versions
G->>G: ignore payload/header tenant/app/binding fields
G-->>A: trusted RoutingTarget + idempotency input
end
VerifiedBinding 是验签器固定的结果,至少包含 tenant_id + binding_id + binding_version +
app_id + channel + provider_account_id。建立 Routing Target 时再次读取/校验 Binding、Tenant
和 App 的可信快照,并要求版本与验签结果一致。只有这个边界之后,系统才能创建 Tenant Context、
Execution Plan 或 tenant + binding + channel + message_id 的幂等键。
Binding-aware identity¶
Runner 的 identity 不能用易碰撞的字符串拼接,也不能只依赖 Tenant 前缀。平台先用长度前缀 编码构造 Binding 作用域:
Encode(parts...) = len(bytes(part_1)) + ":" + part_1 + ...
user = Encode(channel, binding_id, external_user_id)
direct = Encode(channel, binding_id, "direct", external_peer_id, thread_id)
group = Encode(channel, binding_id, "group", external_chat_id, thread_id)
再把 user 与 direct/group session 值传给现有
tenant.NewRunnerIdentity(tenant_id, scoped_user, scoped_session)。因此同一个外部用户在
不同 Binding、同一个用户在不同群聊、以及不同线程都不会复用 identity;持久化层仍然必须显式
携带 tenant_id 和 binding_id,identity 只是第二层碰撞保护。
与现有运行时的映射¶
可信 Routing Target 只固定路由身份和非敏感摘要,不直接创建 HTTP 响应或真实 IM client:
- 用 Target 的 Tenant ID 读取 active Tenant Configuration Snapshot;
- 用 Target 的 App ID 读取同租户 active App 和当前 published Revision;
- 由现有
runtime.NewExecutionPlan固定 Agent、Model、Backend 的版本和摘要; - 由 Binding-scoped identity 创建 Runner
userID/sessionID; - 后续 Gateway/Worker 才按 Tenant-scoped Secret Resolver 获取出站或模型凭据。
Tenant、App、Binding 任一状态不是 active 时,Target 构造失败并拒绝新消息。已创建的执行 继续使用自己的固定 plan;暂停或停用不篡改进行中的快照。
离线验证矩阵¶
fake resolver/verifier 不连接外部 IM,也不把 fake secret 写进 Binding。测试必须证明:
- route digest 能发现候选,但 Candidate Context 没有 Tenant/App/Secret 字段;
- 正确 proof 生成固定版本的 VerifiedBinding 和 Routing Target;
- 错误 proof、过期、重放和用途错配统一失败关闭;
- payload/header 伪造 Tenant/App/Binding 不影响 VerifiedBinding;
- 同 Channel/账号的 active Binding 不可跨租户重复;相同 binding key 在不同租户可以存在;
- suspended/disabled Binding、非 active Tenant 或不可执行 App 不能生成 Target;
- 更新和候选消费响应 Context 取消,读写返回防御性副本,竞争更新只有一个 expected version 获胜;
- direct/group/thread identity 在不同 Binding、会话和线程之间无碰撞且同一输入稳定。
这些测试只证明平台安全边界和领域闭环;真实协议验签、解密、消息幂等、出站重试和持久化 一致性由后续 Gateway/Adapter issue 负责。