Webhook 订阅不是一个 URL 配置项,而是“哪个租户授权哪个 API Client 接收哪些事件”的安全边界。当前实现用 Endpoint Resource/Action 控制管理者,再用 Tenant、Client 状态和 Client Scope 控制订阅是否可以创建以及每次投递是否继续有效。
1. 订阅聚合的数据契约
WebhookSubscription 直接映射 SysWebhookSubscription:
| 字段 | 约束与含义 |
|---|---|
| SubscriptionId | 聚合主键;Create 初始为 0,ORM Add 时分配持久化 ID |
| TenantId | 可信当前租户,最长 64,不接受首尾空格 |
| ClientId | Identity API Client,最长 64;创建后不可修改 |
| Name | trim 后非空,最长 120 |
| TargetUrl | HTTPS 绝对 URI,AbsoluteUri 最长 512 |
| EventTypesJson | 至少一个、区分大小写、去重后的严格 JSON 字符串数组 |
| ScopesJson | 至少一个、区分大小写、去重后的严格 JSON 字符串数组 |
| IpAllowlistJson | 可为空;聚合只拒绝空白字符串,CIDR 合法性延迟到投递 |
| SecretHash | 当前明文 secret 的 SHA-256;不进入摘要 |
| SecretMaterial | 预期为 DataProtection 密文;不进入摘要 |
| PreviousSecretMaterial/ExpiresAt | 轮换重叠字段;当前投递/验签流程并不使用上一份材料 |
| Status | Active、Suspended、Deleted |
| RotatedAt | 最近轮换时间 |
ORM materialization 使用 WebhookStorageJson 严格还原集合。空 required array、重复值、首尾空格、非法 JSON 和旧换行分隔格式都会抛错,而不是静默变成空集合。这能暴露迁移脏数据,但也要求上线前先验证旧行。
var canonical = WebhookStorageJson.SerializeRequired( [WebhookEventTypes.FileFinalized, WebhookEventTypes.FileDeleted]);// => ["files.finalized","files.deleted"]
var restored = WebhookStorageJson.DeserializeRequired(canonical);
// 旧的逐行格式、空 required 集合和重复项都会 fail closed。Should.Throw<InvalidOperationException>(() => WebhookStorageJson.DeserializeRequired("files.finalized\nfiles.deleted"));Should.Throw<InvalidOperationException>(() => WebhookStorageJson.DeserializeRequired("[]"));2. 两层授权不是一回事
第一层是管理 API 授权:
- subscription View/Create/Update/Delete;
- delivery View/Update;
- ResourceDescriptor 固定为
webhooks/subscription或webhooks/delivery; - Suspend、Resume、RotateSecret、Update 共用 Update;RetryDelivery 也用 Update。
第二层是外部应用准入:
- 当前租户必须 Active;
- EventType 必须在 registry;
- ClientId 必须可查询;
- Client 必须属于当前租户且
IsServiceable(now); - Client 必须拥有每个事件对应的 required scope;
- Client 还必须拥有请求中声明的每个 scope。
Update 不允许更换 ClientId:Handler 先加载旧订阅,再用旧 ClientId 校验新 EventTypes/Scopes。若需要把订阅迁到另一个 Client,必须新建订阅并显式切流。
3. 当前注册事件与 required scope
InMemoryWebhookEventTypeRegistry 启动时注册:
| EventType | Required Client Scope |
|---|---|
files.finalized | files.webhooks.deliver |
files.deleted | files.webhooks.deliver |
billing.invoice-issued | billing.webhooks.deliver |
tickets.opened | tickets.webhooks.deliver |
workflow.started | workflow.webhooks.deliver |
workflow.completed | workflow.webhooks.deliver |
api.version.deprecated 有常量和 CAP consumer,却未注册,也没有对应 Webhook Scope 常量。因此该示例事件到达后会返回 Webhook.EventTypeNotRegistered,订阅 API 也无法注册它。
订阅的 Scopes 并不要求等于事件 required scopes,也不要求包含它们。Guard 分别检查 required scopes 和声明 scopes,所以 Client 可以拥有 Files scope,而订阅声明一个无关但同样已授权的 scope。若 Scopes 是外部可见的授权快照,应由 EventTypes 派生或验证包含关系,避免误导审计者。
4. 创建、更新与输入顺序
创建 Handler 先调用 Guard,后调用聚合 Create。这个顺序有两个实际影响:
- JSON 把
eventTypes或scopes绑定为 null 时,Guard 会直接 foreach/Any,可能抛 NullReference,而不是返回Webhook.CollectionsRequired; - 带首尾空格的事件先因 registry 精确匹配失败,聚合没有机会规范化。
模块没有 Create/Update IRequestRule。输入校验主要由 Guard 和聚合承担,但二者对错误顺序、空集合和格式的职责不完全一致。公开 HTTP 契约应补 request rules,先固定 null、数量、长度、URI 与 CIDR 语法,再进入跨端口准入。
public static class WebhookErrors{ public static readonly Error EventTypesRequired = Error.Validation("Webhook.EventTypesRequired", "至少选择一个事件。");
public static readonly Error ScopesRequired = Error.Validation("Webhook.ScopesRequired", "至少声明一个 Scope。");}
// HTTP 输入形状先失败,避免用 null/空集合访问 Tenant 与 Client 端口。public sealed class CreateWebhookSubscriptionRule : IRequestRule<CreateSubscription.Command>{ public ValueTask<Result> ValidateAsync( CreateSubscription.Command request, CancellationToken cancellationToken) { if (request.EventTypes is not { Count: > 0 }) return ValueTask.FromResult(Result.Failure(WebhookErrors.EventTypesRequired));
if (request.Scopes is not { Count: > 0 }) return ValueTask.FromResult(Result.Failure(WebhookErrors.ScopesRequired));
// 这里只验证输入形状;租户、Client 与 Scope 归属仍由 Guard 判断。 return ValueTask.FromResult(Result.Success()); }}5. URL 与 allowlist 的职责边界
聚合只证明 TargetUrl 是 HTTPS 绝对 URI且长度不超限。它没有拒绝:
- URI userinfo;
- 非标准端口;
- localhost、loopback、link-local、私网或云元数据地址;
- Unicode/IDN 风险主机;
- URL fragment;
- 重定向目的地;
- DNS 解析变化。
IpAllowlist 在创建时只拒绝空白条目,not-a-cidr 仍会成功保存,直到某次投递被生产 CIDR policy 判死信。管理 UI 应在保存前复用同一 CIDR parser,并清楚说明这里是“目标服务器地址白名单”,不是“允许哪些来源调用 BitzOrcas”。
6. 三状态生命周期
Suspend 和 Resume 对非 Deleted 状态都是赋值式成功,没有严格 from-state 冲突。Delete 始终把状态与软删除字段设为 Deleted;公开 Find 排除 Deleted,所以删除后的再次调用返回 NotFound。Deleted 不能 Rotate、Suspend 或 Resume。
Update 对 Suspended 订阅仍允许,方便先修改配置再恢复。Update 本身不改变状态。列表排除 Deleted,但同时返回 Active 与 Suspended。
7. 查询与数据暴露
订阅列表和投递列表都以 currentUser.User.TenantId 作为仓储过滤条件。跨租户 ID 被映射为 NotFound,不暴露存在性。摘要返回 TargetUrl、EventTypes、Scopes、IpAllowlist 和状态;这意味着拥有 subscription.view 的人可以看到外部网络拓扑与 ClientId,应把权限只授予集成管理员。
两个列表当前都无 PageIndex/PageSize:调用 ListAsync 取回全租户行后在内存按 CreateTime/OccurredAt 倒序。没有 Id 次序补键,相同时间的排列不稳定。生产 API 应使用 provider-neutral Query Shape 下推稳定分页,并分别设置最大页大小。
8. 状态操作用例
// 1. 暂停后,FindActiveByEventAsync 不会再选中该订阅。var suspended = await mediator.Send( new SuspendSubscription.Command(subscriptionId), cancellationToken);if (suspended.IsFailure) return suspended.Error;
// 2. 更新仍使用原 ClientId 做事件与 Scope 复核。var updated = await mediator.Send( new UpdateSubscription.Command( subscriptionId, Name: "ERP v2", TargetUrl: new Uri("https://hooks.erp.example/v2/events"), EventTypes: [WebhookEventTypes.FileFinalized], Scopes: [WebhookScopes.FilesDeliver], IpAllowlist: ["203.0.113.64/28"]), cancellationToken);if (updated.IsFailure) return updated.Error;
// 3. 恢复只改变状态;不会做目标端握手或测试投递。return await mediator.Send( new ResumeSubscription.Command(subscriptionId), cancellationToken);当前没有“测试投递”端点。不要用正式业务事件探测新 URL,因为它会生成真实幂等键、投递日志和可能的下游副作用。应新增明确的 challenge/handshake 契约。
9. 必测授权矩阵
至少覆盖:
- 六个权限动作分别允许/拒绝;
- 跨租户 subscription/delivery ID 均返回 NotFound;
- inactive/expired Client、错误租户 Client、缺 required scope;
- EventTypes 重复、大小写变化、null、空数组和空白项;
- Scopes 未包含 required scope、包含无关授权 scope、包含未授权 scope;
- HTTPS userinfo、非标准端口、IDN、fragment、loopback/private/link-local;
- 非法 CIDR 在创建阶段应该返回 400,而不是延迟死信;
- Suspended 更新、Resume 幂等、Deleted 全部行为;
- 大租户稳定分页与相同时间排序;
- 摘要与序列化永不暴露三个 secret 字段。
10. 审查命令
# 路由、Resource 和 Action 必须与权限目录一致。rg -n "GenerateEndpoint|ResourceDescriptor|AuthorizationAction" \ src/Platform/Webhooks/BitzOrcas.Platform.Webhooks.Application
# 注册事件必须与 scope、CAP consumer 和生产者契约形成一一对应。rg -n "Register\(|CapSubscribe|WebhookEventTypes|WebhookScopes" \ src/Platform/Webhooks -g '*.cs'
# 预期补齐后 Create/Update 存在 IRequestRule,null 不再进入 Guard。rg -n "IRequestRule<CreateSubscription|IRequestRule<UpdateSubscription" \ src/Platform/Webhooks -g '*.cs'返回 Webhooks 总览 · 签名与密钥 · 网络安全