OpenWork Den API 中间件体系解析:Hono 认证、组织上下文与校验器的分层设计
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
导读
本文以 ee/apps/den-api/src/middleware/README.md 为核心骨架,系统讲解 OpenWork 企业版 Den API 中基于 Hono 构建的可复用中间件层:认证(管理员白名单 / 登录用户 / 会话)、组织上下文(用户所属组织、:orgSlug组织与成员上下文、成员团队)以及 Zod 校验器。读完本文,你将掌握这套中间件的导出面、c.get(...)上下文契约、按需组合的接入方式,以及 Den API 默认拒绝(deny-by-default)的路由访问策略如何在底层用中间件标记强制实施。
一、中间件目录的定位与设计哲学
Den API(位于 ee/apps/den-api)是一个以 Hono 为框架的 REST 服务。为了让多个路由区域(route areas)共享通用的请求处理逻辑,仓库在 ee/apps/den-api/src/middleware/README.md 中明确了该目录的定位:
This folder contains reusable Hono middleware that route areas can compose as needed.
这句话点出了两个关键约束:
- 可复用(reusable):中间件必须跨路由区域有价值,而不是某个路由的一次性辅助函数;
- 按需组合(compose as needed):路由只装配自己需要的中间件,不强制全量挂载。
实际目录中除 README 提到的 7 个文件外,还包含一个由源码注释反复强调的route-access.ts,它承载 Den API 的"默认拒绝"路由访问策略,我们将在第四节单独展开。目录完整文件清单如下:
ee/apps/den-api/src/middleware/ ├── README.md # 使用说明与设计准则 ├── index.ts # 公共导出面 ├── admin.ts # 需要认证的管理员白名单 ├── current-user.ts # 需要认证的登录用户 ├── route-access.ts # 路由访问策略标记(deny-by-default) ├── user-organizations.ts # 加载当前用户所属组织 ├── organization-context.ts # 为 :orgSlug 路由加载组织 + 成员上下文 ├── member-teams.ts # 加载当前组织成员所属团队 └── validation.ts # JSON / query / params 的 Zod 校验包装二、公共导出面 index.ts:一条 import 链接入全部中间件
ee/apps/den-api/src/middleware/index.ts 是整个中间件目录的"门面",它不实现任何逻辑,只是将各文件以命名导出(named exports)的方式统一转发:
export * from "./admin.js" export * from "./current-user.js" export * from "./route-access.js" export * from "./user-organizations.js" export * from "./organization-context.js" export * from "./member-teams.js" export * from "./validation.js"注意两点:
- 导出使用
./xxx.js后缀而非.ts,这是 Den API(以及该仓库多数 TS 服务)遵循的 ESM 风格——源码编译后运行时的导入路径与源码一致; - README 中给出的推荐用法就是从
src/middleware/index.js批量导入,例如:
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware, } from "../../middleware/index.js"然后"只组合路由真正需要的那一部分"。这条规则与第四节的访问策略标记体系相互配合:认证与上下文中间件不是靠路由手动散装拼凑,而是通过route-access.ts中的工厂函数统一返回并登记。
三、认证中间件:管理员白名单与登录用户会话
认证层由 ee/apps/den-api/src/middleware/admin.ts 与 ee/apps/den-api/src/middleware/current-user.ts 提供,它们都声明为MiddlewareHandler<{ Variables: AuthContextVariables }>,AuthContextVariables定义于 ee/apps/den-api/src/session.ts:
export type AuthContextVariables = { user: AuthSessionValue["user"] | null session: AuthSessionValue["session"] | null apiKey: DenApiKeySession | null }也就是说,经过会话层解析后,c.get("user")/c.get("session")/c.get("apiKey")已经可用,认证中间件只需要在此基础上做二次判定。
3.1 requireUserMiddleware:最基础的登录门槛
ee/apps/den-api/src/middleware/current-user.ts 的逻辑非常精简——只要c.get("user")?.id不存在,立即返回401 { error: "unauthorized" };否则放行next()。
export const requireUserMiddleware: MiddlewareHandler<{ Variables: AuthContextVariables }> = async (c, next) => { if (!c.get("user")?.id) { return c.json({ error: "unauthorized" }, 401) as never } await next() }3.2 requireUserSessionMiddleware:必须是"真人会话"
与requireUserMiddleware只要求"有用户"不同,requireUserSessionMiddleware 进一步要求请求必须来自签发的用户会话:如果走的是 API Key(c.get("apiKey")非空),或会话缺少id/token,或session.id无法通过 TypeID 归一化,都会返回403,提示"请使用已登录的用户会话执行该操作"。这一设计把"API Key 自动化调用"与"需要用户身份的操作"(如修改会话自身状态)区分开,避免凭证越权。
3.3 requireAdminMiddleware:平台管理员白名单
ee/apps/den-api/src/middleware/admin.ts 的判定链是:
- 无
user.id→401 unauthorized; - 邮箱为空(
normalizeEmail对trim().toLowerCase()归一化后为空)→403 admin_email_required; - 邮箱不在管理员白名单 →
403 forbidden; - 全部通过才
next()。
白名单判定核心是 isAdminEmailAllowed:它会先调用ensureAdminAllowlistSeeded()确保白名单表完成种子初始化,再查询AdminAllowlistTable中是否存在该邮箱;isPlatformAdminUserId 则是从用户 ID 反查邮箱后再走同一判定,并同时供"admin 路由中间件"与"den-admin MCP 端点"两处复用(源码注释明确说明这一共享关系)。
四、路由访问策略:Den API 的默认拒绝机制
README 未单列、但源码注释极其强调的 ee/apps/den-api/src/middleware/route-access.ts 定义了 Den API 的安全基线。文件头部注释写明:
Den API routes are deny-by-default: every
app.get/post/patch/delete/all/onregistration must include one explicit access policy marker from this file.
即:每条路由注册都必须显式挂一个访问策略标记,否则test/route-access-policy.test.ts会在 CI 中失败。这从工程上杜绝了"忘记加认证"这类低级漏洞。
各标记及其语义如下:
| 标记工厂/常量 | 语义 | 底层中间件 |
|---|---|---|
publicRoute | 公开路由 | 空操作放行 |
signedWebhookRoute | 签名 Webhook | 空操作放行,鉴权在 handler 内做专门校验 |
tokenRoute | token 路由 | 空操作放行,handler 内专门校验 |
delegatedRoute | 委托代理路由 | 空操作放行,handler 内专门校验 |
authenticatedRoute() | 登录用户 | requireUserMiddleware |
userSessionRoute() | 用户会话 | requireUserSessionMiddleware |
adminRoute() | 白名单管理员 | requireAdminMiddleware |
orgMemberRoute() | 组织成员 | 默认resolveOrganizationContextMiddleware;传{ useUserOrganizations: true }时用resolveUserOrganizationsMiddleware |
orgRoleRoute(roles) | 组织角色校验 | 先解析组织上下文,再按角色层级校验 |
cloudTransportRoute() | MCP 云传输 | 校验 MCP 请求签名、DEN_MCP_WRITE_SCOPE作用域与组织成员关系 |
关键实现细节:
- 登记机制:hasExplicitAuthGuardHandler 依赖
explicitAuthGuardHandlers(一个WeakSet<object>),内置中间件及orgRoleRoute动态生成的 handler 都会被登记进去,供路由访问策略测试检测"是否显式挂载了合法标记"; - 角色判定:verifyOrgRole 中,如果所需角色列表包含
"member"则直接放行;否则调用organizationRoleValueSatisfies(定义于 ee/apps/den-api/src/organization-role-hierarchy.ts)按组织角色层级比较当前成员角色是否满足要求,isOwner作为最高权限参与比较; - 云传输路径:
cloudTransportRouteHandler通过verifyMcpRequest校验 MCP 请求主体验证、要求携带DEN_MCP_WRITE_SCOPE,再调用getOrganizationContextForUser加载组织上下文,成员关系被撤销时返回403 mcp_membership_revoked,并把上下文写入c.set("organizationContext", ...)。
五、组织上下文中间件:user → organizations → orgContext → teams
这一组中间件解决"当前请求发生在哪个组织、该用户在该组织中是什么角色"的问题,是 Den API 多租户路由的基础。README 列出的上下文值中,userOrganizations/activeOrganizationId/activeOrganizationSlug/organizationContext/memberTeams全部由这一组产出。
5.1 resolveUserOrganizationsMiddleware:用户视角的组织列表
ee/apps/den-api/src/middleware/user-organizations.ts 的执行顺序是:
- 无
user.id→401; - 确定"作用域组织 ID"(
scopedOrganizationId):优先取 API Key 绑定的组织(getApiKeyScopedOrganizationId),其次取请求头指定的组织; - 请求头支持两个组织 ID 头:
x-openwork-org-id(ORG_SCOPE_HEADER)与兼容旧版代理的x-openwork-legacy-org-id(LEGACY_ORG_PROXY_HEADER),后者作为前者缺失时的回退; - 调用
resolveUserOrganizations解析用户组织;若存在作用域组织,则把组织列表过滤到仅该组织; - 设置
userOrganizations、activeOrganizationId、activeOrganizationSlug三个上下文变量。
此外它还承担会话活性组织水合(session hydration):当请求没有显式作用域、会话也没有记录活跃组织、但解析出了默认活跃组织时,调用hydrateSessionActiveOrganization把该组织写回 Better Auth 会话,并同步更新c.set("session", ...)——这样用户下一次访问时活跃组织状态已经持久化,无需重复解析。
5.2 resolveOrganizationContextMiddleware:组织 + 成员全量上下文
ee/apps/den-api/src/middleware/organization-context.ts 面向:orgSlug类路由,产出 README 中描述的organizationContext:org 记录、当前成员、成员列表、邀请、角色。它的解析优先级是:
- API Key 作用域组织 → 请求头组织 ID → 已有
activeOrganizationId→ 会话活跃组织 → 用户默认活跃组织; - 调用
getOrganizationContextForUser({ userId, organizationId })加载上下文,加载失败且无显式作用域时回退到用户默认组织重试; - 无组织可解析 →
404 organization_not_found; - API Key 作用域校验:若使用 API Key,则必须与目标组织一致(
isScopedApiKeyForOrganization),否则403("This API key is scoped to a different organization.");若 API Key 元数据中绑定了orgMembershipId,还必须与当前成员的id一致,否则403("no longer valid for the current organization member")——这能实时撤销离职成员已签发的组织级 API Key; - 最终写入
organizationContext、activeOrganizationId、activeOrganizationSlug。
5.3 resolveMemberTeamsMiddleware:当前成员所在团队
ee/apps/den-api/src/middleware/member-teams.ts 是组织上下文的"下游依赖":它要求c.get("organizationContext")必须先被解析,否则返回500 organization_context_required(这是一个编码错误而非客户端错误)。随后基于context.organization.id与context.currentMember.id调用listTeamsForMember,把结果写入memberTeams。它不重复做认证,也不重复查组织,充分体现"按需组合、各司其职"的分层思想。
六、校验中间件:Zod 驱动的 400 响应标准化
ee/apps/den-api/src/middleware/validation.ts 基于hono-openapi的validator as zValidator封装了三个校验器,覆盖 Hono 的三类输入位置:
export function jsonValidator<T extends ZodSchema>(schema: T) // 请求体 JSON export function queryValidator<T extends ZodSchema>(schema: T) // URL query export function paramValidator<T extends ZodSchema>(schema: T) // 路径参数三个函数结构一致:把 Zod schema 交给zValidator,当校验失败时统一返回400 { error: "invalid_request", details: result.error }。details携带 Zod 原始的校验错误对象(字段名、错误路径、错误信息),前端与调试工具可直接据此定位非法字段。使用示例:
import { z } from "zod" import { jsonValidator, paramValidator } from "../../middleware/index.js" app.post( "/api/orgs/:orgSlug/members", paramValidator(z.object({ orgSlug: z.string().min(1) })), jsonValidator(z.object({ email: z.string().email(), role: z.string() })), handler, )由于校验失败在中间件阶段即被拦截,业务 handler 内可以放心地把参数当作已通过 schema 校验的类型使用,无需再写重复的防御式判断。
七、上下文契约速查:路由内可以安全读取什么
结合 README 的 "Available context" 与源码实现,中间件层向后续 handler 提供的c.get(...)契约可汇总如下:
| 上下文键 | 类型/内容 | 由谁写入 | 典型使用场景 |
|---|---|---|---|
user | 当前认证用户 | session.ts 的sessionMiddleware | 判定身份、展示个人信息 |
session | 当前 Better Auth 会话 | sessionMiddleware | 会话级操作、活跃组织水合 |
apiKey | 当前 API Key 会话 | sessionMiddleware | 区分凭证来源、作用域校验 |
userOrganizations | 当前用户所属组织摘要数组 | resolveUserOrganizationsMiddleware | 组织列表 UI、切换组织 |
activeOrganizationId | 当前活跃组织 ID(TypeID) | 用户组织 / 组织上下文中间件 | 多租户数据过滤 |
activeOrganizationSlug | 当前活跃组织 slug | 用户组织 / 组织上下文中间件 | 路由重定向、URL 构造 |
organizationContext | org 记录、当前成员、成员、邀请、角色 | resolveOrganizationContextMiddleware(含cloudTransportRouteHandler) | :orgSlug路由、成员管理、角色判定 |
memberTeams | 当前组织成员所属团队摘要 | resolveMemberTeamsMiddleware | 团队级授权与数据过滤 |
需要说明的是,user/session/apiKey的真正来源是 session.ts 中的sessionMiddleware:它按"内部 MCP principal 头 → 签名 Cookie 会话 → Bearer token"的优先级解析身份(API Key 优先于 Cookie 解析,且登出请求/api/auth/sign-out会跳过会话解析)。内部 MCP principal 头使用进程内随机密钥做 HMAC-SHA256 签名并设 60 秒 TTL,将信任边界绑定到进程内调用方。中间件目录中的各认证中间件正是消费这套已解析的身份再做授权判定。
八、组合示例:一个典型的组织管理路由
将以上中间件按"最小必要"原则组合,可以得到 Den API 中组织管理路由的标准写法:
import { Hono } from "hono" import { jsonValidator, paramValidator, orgMemberRoute, orgRoleRoute, } from "../../middleware/index.js" const orgRoutes = new Hono<{ Variables: AuthContextVariables }>() // 所有 /:orgSlug 路由先解析组织上下文 orgRoutes.use("/:orgSlug/*", orgMemberRoute()) // 只有组织 owner 能读取成员列表 orgRoutes.get( "/:orgSlug/members", orgRoleRoute(["owner"]), async (c) => { const { organizationContext } = c.get("organizationContext") return c.json(organizationContext.members) }, ) // 新增成员:路径参数 + 请求体双重校验 orgRoutes.post( "/:orgSlug/members", orgRoleRoute(["owner", "admin"]), paramValidator(z.object({ orgSlug: z.string() })), jsonValidator(z.object({ email: z.string().email() })), async (c) => { /* ... */ }, )这里可以清楚看到各中间件的分工:orgMemberRoute()负责"谁有资格访问这个组织"(成员判定 + 上下文注入),orgRoleRoute([...])负责"该成员是否有权限做这件事"(角色层级判定),paramValidator/jsonValidator负责"请求数据是否合法"。三者叠加后,handler 内几乎只剩纯业务逻辑。
九、设计准则:何时放入中间件目录
README 以 "Rule of thumb" 给出两条边界清晰的准则,这也是判断代码归属的决策树:
- If a value is broadly useful across multiple route areas, put it here—— 如果某个能力被多个路由区域复用(认证、组织上下文、通用校验),放入本目录并在 index.ts 统一导出;
- If a helper only exists for one route area, keep it in that route folder instead—— 如果只是单个路由区域的一次性辅助函数,应留在该路由目录内部,避免中间件目录变成"杂物抽屉"。
配合route-access.ts的默认拒绝策略与test/route-access-policy.test.ts的 CI 强制校验,Den API 在"中间件复用"与"路由安全"之间形成了互相支撑的闭环:公共逻辑集中、可测试、可审计;路由显式声明访问策略,漏挂即失败。对于希望在自己的 Hono 服务中建立类似多租户中间件层的团队,这套"认证 → 组织上下文 → 角色授权 → 输入校验"的分层与组合模式是可直接借鉴的范本。
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考