Boogy 授权(服务内鉴权) 是做什么的?
Boogy 的授权分为三层。切勿手动实现任何一层——SDK 会生成已验证的辅助函数,确保安全不变性不被破坏。
三层模型
- 入口准入——平台的入口模式决定请求是否能到达你的服务。当你的代码运行时,调用者已经是已知的 主体 或匿名者。
- 守卫门控路由——每个路由在处理器执行前进行检查:要求登录,或要求对目标资源拥有所有权。
- 辅助函数限定行范围——处理器仅列出/加载调用者自己的数据行。
使用 auth::current_principal() -> Option<String> 解析调用者。主体是不透明的——切勿解析、去除前缀或假定为 UUID;只能将其用作所有者列的值和 auth::* 函数的输入。
路由级入口:受限服务中的公开路由
第 1 层(入口)通常是一个服务级的 mode。但有时某个路由必须能被其他路由拒绝的调用者访问——典型情况是,在需要 authenticated(或 internal/mixed)的服务内部需要一个公开的 /webhook 接收者。使用路由级覆盖将其分离出来;服务的其余部分保持受限:
[ingress]
mode = "authenticated" # 服务级默认值——其他所有路由
[[ingress.routes]]
path = "/webhook" # 服务相对路径;所有 HTTP 方法
mode = "public" # 任何人都可以访问 /webhook
安全契约(请务必阅读——它是失效关闭的):
| 规则 | 为何重要 |
|---|---|
服务相关的字面路径。 精确匹配(/webhook)或前缀匹配(/stripe/*,按段边界)。不支持 {param} 捕获。 | path 是你的路由器看到的路径,与处理器的帧相同。 |
| 最具体匹配胜出;始终只有一个模式生效。 精确匹配优先于前缀匹配;较长前缀优先于较短前缀。 | 公开子路由永远无法扩大同级路由的范围:/webhook 公开不会放宽 /data。 |
默认值 = 服务级模式。 不匹配的路径(或 .. 遍历尝试)将回退到受限的默认值,绝不回退到权限最宽的路由。 | 忘记列出某路由意味着它保持受保护状态,而非暴露。 |
每个覆盖路由携带自己的模式 + 允许列表。 覆盖路由不会继承 allowed_agents/allowed_origins。 | 在路由块中列出它们;同样应用非空验证,因此拼写错误不会静默地拒绝所有请求。 |
按路径而非按方法(目前)。 path = "/webhook" 适用于 GET、POST 等所有方法。 | 公开的 /webhook 可通过任何动词访问——因此仍需在处理器内验证请求(误发的 GET 应无操作)。 |
| 在边缘由主机强制执行。 入口在你的 wasm 实例化之前运行。 | 你不必在代码中自行门控公开路由;但公开路由意味着任何人都能访问——通过其他方式(例如 HMAC 签名;参见 boogy:boogy-webhooks)对其进行身份验证。 |
| 委托门控 + 速率限制保持服务级。 | 公开路由覆盖无法绕过 [ingress.delegation] 门控,并且共享速率限制桶。 |
没有 [[ingress.routes]] 的清单完全保持之前的行为——这纯粹是新增功能。
反向场景:开放服务中的受限于树(仅服务所有者可访问 /admin)
另一种情况——服务的默认模式是开放的(authenticated),但其 /admin/* 子树仅允许服务所有者(服务提供者)访问。陷阱在于:可被提供的模块由任何人部署,因此你绝不能在清单中硬编码身份(@alice,boogy://alice/services/*)——这样的字面所有者对于其他提供者都是错误的,并且入口允许列表字符串在部署时不会被替换。入口没有“与我有相同所有者”的匹配器。
使用无门控的 caller_is_service_owner() 能力——主机证明调用者是否是此服务的所有者(它们的代理令牌,由主机端根据代理注册表解析,或者是它们自己的工作负载)。无需路由级入口,无需硬编码身份:
[ingress]
mode = "authenticated" # 所有路由;处理器自行门控 /admin
// require_operator():由主机证明,无硬编码。
fn require_operator() -> Result<(), ApiError> {
if caller_is_service_owner() { return Ok(()); } // 所有者:其代理或自身工作负载
// OBO:所有者的后端代表用户——被证明的角色是工作负载。
let id = current_identity();
let actor_owner = id.and_then(|i| i.actor).and_then(workload_owner);
if actor_owner == Some(self_identity().owner) { return Ok(()); }
Err(ApiError::forbidden("仅限操作员"))
}
为何这是合适的原语:人类所有者可以直接使用其令牌 curl /admin(wasm 无法解析代理的句柄,但主机可以——这正是该能力的作用),并且所有者的后端可以作为工作负载工作。失效关闭:匿名、不同的所有者或无法解析的调用者 → false。(参见 resend-base 目录模块。)之前的“internal + 相同所有者工作负载”模式也可行,但排除了人类直接 curl——对于仅所有者界面,推荐使用 caller_is_service_owner。
守卫与辅助函数快速参考(已验证)
| 项目 | 用途 |
|---|---|
auth::required() -> Guard | 如果匿名则返回 401。用于集合路由(list,create)。 |
auth::owns_resource(table, owner_col, id_param) -> Guard | 用于条目路由(GET/DELETE /things/{id})。加载该行,如果缺失或不属于你则返回 404,并将其存储在 req.ctx 中。使用 .slot("name") 处理多次加载。 |
auth::find_owned(table, owner_col) -> Result<Vec<Row>, _> | 用于索引端点,按主体限定范围列出。匿名时返回 401。 |
auth::load_owned(table, owner_col, id) -> Result<Option<Row>, _> | 用于 MCP/JSON-RPC(id 在请求体中,而非路径中)的单次加载 + 所有权检查。None 表示缺失或不属于你。 |
auth::require_scope(scope) -> Guard | 粗粒度能力门控:匿名时返回 401,已登录但缺少该范围时返回 403。 |
所有者列始终是 DEFAULT_OWNER_COL("owner_principal")——切勿发明 owner_id/created_by。将条目路由编写为 .group([auth::owns_resource("things", DEFAULT_OWNER_COL, "id")], |g| …)。该守卫后的处理器读取上下文中存储的行——不要重新获取。
来自令牌的所有者规则: 创建时,从 auth::current_principal() 填充所有者列。切勿从请求体中读取所有者——客户端可能声称是另一个用户的 id。
铁律:按存在掩码拒绝
缺失和存在但不属于你统一返回 404 —— 绝不返回 403。 这是平台安全不变性,而非风格选择。403-vs-404 的区别是一个枚举 oracle:经过身份验证的用户探测 id 并得知哪些存在(403)与哪些不存在(404)。SDK 守卫强制执行这一规则。
“但 UI 想告诉用户原因。” 在客户端处理:将 404 统一视为“对你不可用”并渲染相应消息;网络状态码保持 404。不要分叉或修补 SDK 守卫来拆分这个掩码。(require_scope 的 403 是不同的维度——粗粒度能力,而非资源所有权——这样是正确的。)
程序化调用者的 API 密钥(已验证方案)
在 wit_glue! 旁边调用 api_keys_glue!(bindings),然后:
- 在
init_tables中调用api_key_routes::install_table()。 - 通过
ApiKeyRoutes扩展特质挂载管理路由:Router::new().with_api_key_routes()(/_keys)或.with_api_key_routes_at("/admin/keys")。 - 门控你的路由:
.group([api_key_routes::guard], |g| ...)。
| 事实 | 详情 |
|---|---|
| 双重凭证 | guard 接受会话 bearer 令牌或 sk_* 密钥;两者统一为 current_principal(),因此 owns_resource/find_owned 无需更改即可工作。 |
| 管理密钥 | 需要会话身份;sk_* 密钥不能创建密钥。 |
| 无权限提升 | 密钥仅携带创建者已拥有的范围(否则返回 403)。 |
| 存储 | 密钥以哈希形式存储在服务自身的存储中;切勿自行创建表。 |
| 格式 | sk_<env>_<…>_<crc>。 |
常见误区
| 想法 | 现实 |
|---|---|
| “返回 403 ‘不属于你’ 可以告诉用户原因。” | 这是一个 id 枚举 oracle。对两种情况都返回 404;在客户端解释原因。 |
| “我将在加载行后在处理器中检查所有权。” | owns_resource 在单个守卫中完成了加载 + 检查 + 上下文存储。读取已存储的行;不要重新实现检查。 |
| “我会添加一个自定义的 api_keys 表。” | api_keys_glue! 提供了一个已哈希、隔离、具有范围意识的表。使用它。 |
| “从请求体中读取所有者 id。” | 从 current_principal() 填充。请求体是攻击者可控的。 |
“一个公开的 [[ingress.routes]] 路由仍需在 wasm 中进行身份验证检查。” | 公开意味着任何人都能访问它——通过其他方式(例如 Webhook 的 HMAC 签名)进行身份验证。覆盖不会自行门控。 |
集成
← boogy:designing-boogy-services 选择入口模式,供给第 1 层。boogy:boogy-account-auth 是主体的来源(登录→你读取的令牌)。关于跨服务代表用户操作,请参见 boogy:boogy-obo-delegation。用于已签名回调的公开路由覆盖是 boogy:boogy-webhooks 的前半部分。