OpenWork Den API 组织路由(Org Routes)架构解析:活动组织模型、成员/邀请/角色/SCIM 全链路实现
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
导读
本文基于 ee/apps/den-api/src/routes/org/README.md 展开,深入剖析 OpenWork 企业版 Den API 中面向组织(Organization / Workspace)的全部服务端路由:从“活动组织(active organization)”会话模型、Hono 中间件组合、Zod 校验规范,到邀请、成员、动态角色、SCIM 连接器的逐条实现。读完本文,你将掌握这套路由层的文件组织原则、路由权限矩阵(owner / super-admin / admin / member)的真实落地方式,以及/v1/org与/v1/orgs/**两类路径的设计差异,并可直接对照源码验证每一个端点。
一、路由层概览:这个目录归谁管
ee/apps/den-api/src/routes/org/是 Den API 中“面向组织(organization-facing)”的专属路由目录。与普通用户级路由不同,这里的每个端点都依赖当前会话解析出的活动组织上下文,即“当前登录用户当前正在操作的工作区”。
README 中声明的职责分工(目录清单)如下:
| 文件 | 职责 |
|---|---|
index.ts | 注册所有组织路由分组,并承载旧版/v1/orgs/:orgId/*兼容代理 |
core.ts | 组织创建、邀请预览/接受、组织上下文(GET/POST/PATCH/v1/org) |
invitations.ts | 邀请的创建与取消 |
members.ts | 成员角色更新、成员移除、所有权转移 |
roles.ts | 动态(自定义)角色的 CRUD |
scim.ts | 当前组织 SCIM 连接器元数据与令牌轮换 |
shared.ts | 路由级共享辅助函数、参数 Schema 与权限守卫 |
需要说明的是,随着仓库持续演进,实际目录中还出现了teams.ts、sso.ts、api-keys.ts、llm-providers.ts、mcp-connections.ts、plugin-system/、delete-organization.ts等更多分组(见 routes/org 目录),README 描述的是最初拆分时的最小核心集合,两者共同构成了当前的组织路由全景。
二、活动组织模型(Active Organization Model):一切路由的前提
这是整个组织路由层最核心的设计决策,README 用四条规则把它讲清楚了:
- 切换活动组织的唯一入口是 Better Auth 端点:
POST /api/auth/organization/set-active是唯一被允许显式切换用户活动组织的 Better Auth 端点。 - 新会话的初始值来自会话创建钩子:新会话应通过
src/auth.ts中的 Better Auth 会话创建钩子获得初始activeOrganizationId。 GET /v1/org返回活动组织:从当前会话读取,包含嵌套的organization.owner对象,以及当前成员(current member)和团队上下文(member teams)。- 活动组织作用域的资源走顶层路由:如
/v1/teams、/v1/roles、/v1/api-keys、/v1/llm-providers、/v1/scim以及插件系统的/v1/...路由,路径中不应出现:orgId或:orgSlug。
这条“路径中不带 orgId”的设计与POST /v1/org创建组织后“把会话切到新组织”的行为是配套的:客户端只要先调用 set-active 完成工作区切换,随后所有/v1/...资源路由自然操作的就是新工作区。README 特别强调了两类路径的分工:
/v1/orgs/**保留给跨组织(cross-org)流程,即尚未绑定到活动工作区的操作,例如邀请预览(/v1/orgs/invitations/preview)与邀请接受(/v1/orgs/invitations/accept);- 切换工作区的正确姿势:先调用 Better Auth set-active,再调用活动组织作用域的
/v1/...资源路由。
源码印证:创建组织后自动切换活动组织
在 core.ts 的POST /v1/org处理器 中可以看到这套模型的完整实现:
const organizationId = await createOrganizationForUser({ userId: normalizeDenTypeId("user", user.id), name: input.name, }) await setRequestActiveOrganization(c, organizationId)setRequestActiveOrganization优先调用auth.api.setActiveOrganization(...)(即 README 指定的唯一切换端点),失败时才回退到setSessionActiveOrganization直接写库(core.ts)。同时,GET /v1/org的响应里会附加capabilities(如gatewayDashboard、mcpConnections、openworkWeb、cloud)、authMethods(sso/scim)、plan与entitlements,供客户端做能力探测(core.ts L636-L743)。源码注释还特别说明:orgManagedDashboards: true是显式的协议信号,用于较新版本 Den 在分批上线期间的 fail-closed 判断。
三、路由注册中枢与旧版兼容代理
index.ts是整个组织路由面的装配点。registerOrgRoutes依次调用各分组的registerXxxRoutes(app),把近三十个路由组挂到同一个 Hono 应用上(index.ts L61-L89)。
值得单独说明的是 README 未展开、但源码中真实存在的旧版路径兼容代理(index.ts L91-L107):
app.all("/v1/orgs/:orgId/*", delegatedRoute, async (c) => { const target = extractLegacyOrgProxyTarget(url.pathname) ... headers.set(LEGACY_ORG_PROXY_HEADER, target.organizationId) return app.fetch(proxiedRequest) })其逻辑是:当请求路径形如/v1/orgs/org_xxx/...(且orgId以org_前缀开头)时,把路径改写为/v1/...并在请求头注入LEGACY_ORG_PROXY_HEADER(来源为 middleware/user-organizations.ts),再交给 Hono 内部重新分发。这保证了旧客户端“路径携带 orgId”的调用方式在新架构下依然可用,同时新代码统一走活动组织模型。
四、核心组织路由:core.ts 逐条拆解
core.ts是组织生命周期最核心的端点集合,全部使用 Hono Zod 校验 + OpenAPI 描述(hono-openapi的describeRoute)。以下按端点逐一说明:
1.POST /v1/org— 创建组织
- 请求体:
{ "name": string },要求trim().min(2).max(120)且.strict()拒绝多余字段(createOrganizationSchema); - 中间件链:
userSessionRoute()+jsonValidator(...); - 单组织部署(
env.orgMode === "single_org")直接返回409 single_org_mode; - 成功返回
201,响应体为{ organization }(core.ts L288-L332)。
2.GET /v1/org— 获取活动组织上下文
- 中间件链:
orgMemberRoute()(即resolveOrganizationContextMiddleware)+queryValidator+resolveMemberTeamsMiddleware; - 支持查询参数
refreshRoles=true:由 owner/admin 触发默认角色播种(seedDefaultOrganizationRoles)后重新加载上下文; - 响应包含
organization.owner(从成员列表中找到isOwner的那位)、currentMember、currentMemberTeams、deploymentCapabilities、plan、entitlements、capabilities、authMethods(core.ts L636-L743)。
3.PATCH /v1/org— 更新组织设置
- 权限门槛最高:
orgRoleRoute(["super-admin"])+ensureOrganizationSuperAdmin,且要求近 15 分钟内登录过(见第五节“特权会话”说明); - 可更新字段:
name、allowedEmailDomains(最多 100 个,非法域返回invalid_email_domain)、allowedDesktopVersions(最多 200 个)、requireSso、brandAppName、brandLogoUrl、brandIconUrl、brandAccentColor; - 启用
requireSso/ 桌面版本锁定时会校验 Enterprise 计划权益(checkEntitlement(payload.organization.metadata, "orgControls")),启用品牌字段时校验desktopPolicies权益,品牌图标 URL 还会经过validateBrandIconUrl验证(core.ts L450-L534)。
4. 跨组织流程:邀请预览与接受
这两个端点挂在/v1/orgs/**下,正是 README 所说的“跨组织、未绑定活动工作区”的典型:
GET /v1/orgs/invitations/preview?id=...:publicRoute,无需登录即可查看邀请详情(组织名、slug、允许邮箱域、品牌信息、邀请状态与过期时间),便于用户在决定加入前先确认(core.ts L334-L359);POST /v1/orgs/invitations/accept:userSessionRoute,要求已登录且邮箱已验证(requireEmailVerification开启时),处理邮箱域限制(account_email_domain_not_allowed→ 409)、SCIM 已撤销(scim_deprovisioned→ 409)、曾加入后被移除(membership_removed→ 410)等边界,接受成功后同样调用setRequestActiveOrganization把会话切到新工作区(core.ts L361-L448)。
5. 登录路由辅助端点
GET /v1/orgs/sso/singleton:单组织部署下返回“唯一组织”的 SSO 配置状态与signInUrl;GET /v1/orgs/sso/resolve?email=...:按邮箱解析登录方式(SSO 或 google/password/signup)。这是一个安全敏感端点,源码中专门写了安全说明:按已验证域名而非成员关系解析 SSO,要求 Vercel BotID 校验,并对 IP / 邮箱 / 域名三层做限流——SSO_RESOLVE_IDENTITY_RATE_LIMIT_MAX = 20/分钟,域级窗口放宽到 10 分钟 120 次、未命中(miss)桶 10 分钟 30 次(core.ts L92-L102、L560-L634)。
五、共享守卫与校验基建:shared.ts
README 提到的“shared route-local helpers, param schemas, and guard helpers”在 shared.ts 中有非常完整的实现,是理解整个组织路由权限体系的关键。
权限守卫一览(每个守卫都返回{ ok }或{ response })
| 守卫 | 权限要求 | 用途 |
|---|---|---|
ensureOrganizationAdminRole | owner / admin,无需近期登录 | 常规管理工作 |
ensureOrganizationSuperAdmin | owner / super-admin + 特权会话 | 敏感/破坏性操作 |
ensureOrganizationAdmin | admin + 特权会话 | 安全敏感操作 |
ensureInviteManager | owner / admin + 特权会话 | 邀请管理 |
ensureMemberRemover | owner / admin + 特权会话 | 移除成员 |
ensureTeamManager | owner / admin + 特权会话 | 团队管理 |
ensureOwner | 仅 owner + 特权会话 | 所有权转移 |
ensureApiKeyReader/ensureApiKeyManager | admin / super-admin | API 密钥读写 |
ensureScimReader/ensureScimManager | admin / super-admin | SCIM 读写 |
ensureSsoReader/ensureSsoManager | admin / super-admin | SSO 读写 |
特权会话(privileged session)窗口
shared.ts定义了三档会话新鲜度窗口(shared.ts L26-L30):
PRIVILEGED_SESSION_MAX_AGE_MS = 15 * 60 * 1000(15 分钟):访问控制、凭据管理、发布与破坏性变更;CONTENT_EDIT_SESSION_MAX_AGE_MS = 60 * 60 * 1000(1 小时):内容编辑类操作复用确认;CONNECTIONS_READ_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000(24 小时):连接只读操作。
守卫通过hasFreshPrivilegedSession比较session.createdAt与当前时间的差值;过期则返回{ error: "reauth", reason: "fresh_auth_required", message: "For security, confirm it's you before changing workspace settings." }。API 密钥调用方(c.get("apiKey"))则直接放行。另外replaceRoleValue、splitRoles、normalizeRoleName等辅助函数封装了“成员角色以逗号分隔存储”的细节,供角色更新与重命名传播使用。
六、邀请流程:invitations.ts
邀请模块只有两个端点,但把“创建/刷新”和“取消”的边界情况处理得相当完整。
POST /v1/invitations— 创建或刷新邀请
- 请求体:
{ "email": string, "role": string },其中role为trim().min(1).max(64); - 中间件:
orgRoleRoute(["admin"])+ensureInviteManager; - 校验链(invitations.ts L116-L143):
- 邮箱域必须落在
allowedEmailDomains白名单内,否则409 invite_email_domain_not_allowed; - 通过
listAssignableRoles拿到可分配角色集,validateInvitationRoleAssignment校验目标角色是否存在、当前用户是否有权授予(普通 admin 只能邀请 member);
- 邮箱域必须落在
- 事务内逻辑(invitations.ts L146-L275):
- 组织行与既有邀请行均加
FOR UPDATE锁,防止并发重复邀请; - 邮箱已是活跃成员 →
409 member_exists; - 存在 pending 邀请 → 刷新角色、邀请人、令牌与过期时间(默认 7 天,
now + 7 * 24h); - 否则创建新邀请 + 预建一条
userId = null的成员占位行,并检查 seat 计费额度(getOrganizationSeatAddEligibility,不足时402 seat_subscription_required); - 写入组织审计事件(
recordOrganizationAuditEvent);
- 组织行与既有邀请行均加
- 邮件发送失败(
DenEmailSendError)时返回502 invitation_email_failed,并携带reason(email_not_configured/resend_rejected/resend_network/nodemailer_rejected)与invitationId——邀请行已持久化,客户端可据此提示重试; - 邀请链接由
buildInvitationLink(inviteToken)生成,指向{origin}/join-org?invite={token}(shared.ts L132-L140)。
POST /v1/invitations/:invitationId/cancel— 取消邀请
- 参数
invitationId必须是合法的invitation类型 Den ID(denTypeIdSchema),非法 ID 直接404; - 仅 pending 状态可取消,否则
409 invitation_not_pending; - 取消的同时会清理占位成员行(
removeOrganizationMember),并记录审计事件(invitations.ts L378-L495)。
七、成员管理:members.ts
POST /v1/members/:memberId/role— 更新成员角色
- 权限:
orgRoleRoute(["super-admin"])+ensureOrganizationSuperAdmin,普通 admin 无权改角色; - 角色必须是组织内已存在的角色(
listAssignableRoles),否则400 invalid_role; - 角色变更时记录
memberRoleUpdated审计事件(含previousRole/nextRole)(members.ts L21-L89)。
POST /v1/members/:memberId/transfer-ownership— 转移所有权
- 权限:
orgRoleRoute(["owner"])+ensureOwner,仅 owner 本人可发起,且要求 15 分钟内的特权会话; - 目标必须是活跃的 super-admin 成员,转移后原 owner 降为
member; - 审计事件记录了新旧 owner 的完整前后角色(members.ts L91-L153)。
DELETE /v1/members/:memberId— 移除成员
- 权限:
orgRoleRoute(["admin"])+ensureMemberRemover; - owner 角色受保护(
removeOrganizationMember内部拒绝删除 owner),成功返回204(members.ts L155-L212)。
八、动态角色 CRUD:roles.ts
组织支持自定义角色,权限以“命名权限映射”存储:permission: Record<string, string[]>。
- 创建
POST /v1/roles:roleName要求 2–64 字符;角色名不能是内置受保护角色(isProtectedOrganizationRoleName);同名角色返回409 role_exists;权限必须通过validateAssignableOrganizationPermissionRecord(不能授予超过创建者自身权限的权限集)(roles.ts L34-L103); - 更新
PATCH /v1/roles/:roleId:允许改名与改权限。改名会级联传播——遍历所有未移除成员与所有 pending 邀请,用replaceRoleValue把旧角色名替换为新角色名(roles.ts L185-L220);权限一旦变化,会调用revokeCredentialsForOrganizationRoleMembers撤销该角色成员的既有凭据(roles.ts L222-L227); - 删除
DELETE /v1/roles/:roleId:先确认没有活跃成员或 pending 邀请仍在引用该角色(role_in_use),内置角色不可删,成功返回204(roles.ts L246-L328)。
九、SCIM 连接器:scim.ts
SCIM 端点全部挂在活动组织顶层路径/v1/scim下,是“active-org 资源走顶层路由”的典型例子:
| 端点 | 说明 |
|---|---|
GET /v1/scim | 读取连接器元数据:baseUrl、ssoReady、connection(含groupMappingMode:metadata_only/create_teams)与health状态 |
POST /v1/scim/token | 创建/轮换 SCIM bearer token;必须先启用 SSO 连接,否则409 sso_required;成功返回201并附新令牌 |
PATCH /v1/scim | 更新组映射模式(metadata_only→ 仅元数据,create_teams→ 由 SCIM Group 创建并管理团队) |
POST /v1/scim/reconcile | 执行漂移修复:检查 SCIM 托管身份的成员关系/Provider 账户状态不一致,返回{ checked, repaired, failures } |
DELETE /v1/scim | 删除连接并使当前 token 失效,返回204 |
读操作要求 admin(ensureScimReader),写操作要求 super-admin + 特权会话(ensureScimManager)。health字段(unresolvedFailureCount、lastFailureAt、nextRetryAt、lastSuccessfulSyncAt等)把同步故障状态暴露给管理端,便于运维排查(scim.ts)。
十、中间件期望与校验规范
README 对路由作者提出两条硬性规范,源码也一一对应:
中间件从src/middleware/index.ts统一导入(对应文件 ee/apps/den-api/src/middleware/index.ts):
requireUserMiddleware(current-user.ts):要求已登录用户;resolveOrganizationContextMiddleware(organization-context.ts):解析当前组织与成员上下文,填充c.get("organizationContext");resolveMemberTeamsMiddleware(member-teams.ts):加载当前组织成员所属团队,填充c.get("memberTeams")。
除此之外,route-access.ts 还提供组合型入口:userSessionRoute()、orgMemberRoute()、orgRoleRoute(roles)、publicRoute、delegatedRoute等,并通过hasExplicitAuthGuardHandler实现deny-by-default——test/route-access-policy.test.ts会在 CI 中拦截任何未挂显式访问策略标记的路由注册。角色判定由verifyOrgRole完成:roles含member直接放行,否则用organizationRoleValueSatisfies按角色层级匹配。
校验规范(对应 validation.ts 与shared.ts的idParamSchema):
- query / JSON body / params 一律使用 Hono Zod 校验器(
jsonValidator/queryValidator/paramValidator); - 处理器内通过
c.req.valid("query" | "json" | "param")读取已校验数据; - 禁止在处理器内直接
c.req.param()、c.req.query()或手动safeParse()。idParamSchema会把路径参数(如memberId)绑定为特定 Den Type ID 类型(denTypeIdSchema("member")等),从源头拦截非法 ID 注入。
十一、为什么这样拆分
README 结尾给出了拆分的直接理由:org 路由面是当前迁移规模最大的区域。如果所有端点挤在一个巨型 router 文件里,每次改动都需要通读全局;按关注点(concern)拆分为core/invitations/members/roles/scim等小组后:
- 单次编辑范围被压缩到一个文件内,代码评审更聚焦;
- Agent(AI 协作开发)可以只读
invitations.ts就完成邀请相关的修改,无需扫描无关代码; - 每个分组拥有独立的
registerXxxRoutes函数,天然适配index.ts的集中装配。
十二、延伸阅读
- 角色层级与访问矩阵的官方定义见 docs/cloud-organization-role-access.md,这是理解
super-admin/admin/member以及orgRoleRoute参数含义的权威文档; - 中间件的完整上下文说明(
c.get("user")、c.get("organizationContext")、c.get("memberTeams")等)见 ee/apps/den-api/src/middleware/README.md; - 组织上下文的核心业务逻辑(
createOrganizationForUser、acceptInvitationForUser、getOrganizationContextForUser、removeOrganizationMember、transferOrganizationOwnership等)集中在 ee/apps/den-api/src/orgs.ts; - 路由访问策略的 deny-by-default 保障来自 ee/apps/den-api/src/middleware/route-access.ts 及配套的
test/route-access-policy.test.ts测试。
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考