OpenWork Den API 组织路由(Org Routes)架构解析:活动组织模型、成员/邀请/角色/SCIM 全链路实现
2026/9/13 23:37:05 网站建设 项目流程

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.tssso.tsapi-keys.tsllm-providers.tsmcp-connections.tsplugin-system/delete-organization.ts等更多分组(见 routes/org 目录),README 描述的是最初拆分时的最小核心集合,两者共同构成了当前的组织路由全景。

二、活动组织模型(Active Organization Model):一切路由的前提

这是整个组织路由层最核心的设计决策,README 用四条规则把它讲清楚了:

  1. 切换活动组织的唯一入口是 Better Auth 端点POST /api/auth/organization/set-active是唯一被允许显式切换用户活动组织的 Better Auth 端点。
  2. 新会话的初始值来自会话创建钩子:新会话应通过src/auth.ts中的 Better Auth 会话创建钩子获得初始activeOrganizationId
  3. GET /v1/org返回活动组织:从当前会话读取,包含嵌套的organization.owner对象,以及当前成员(current member)和团队上下文(member teams)。
  4. 活动组织作用域的资源走顶层路由:如/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(如gatewayDashboardmcpConnectionsopenworkWebcloud)、authMethodssso/scim)、planentitlements,供客户端做能力探测(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/...(且orgIdorg_前缀开头)时,把路径改写为/v1/...并在请求头注入LEGACY_ORG_PROXY_HEADER(来源为 middleware/user-organizations.ts),再交给 Hono 内部重新分发。这保证了旧客户端“路径携带 orgId”的调用方式在新架构下依然可用,同时新代码统一走活动组织模型。

四、核心组织路由:core.ts 逐条拆解

core.ts是组织生命周期最核心的端点集合,全部使用 Hono Zod 校验 + OpenAPI 描述(hono-openapidescribeRoute)。以下按端点逐一说明:

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的那位)、currentMembercurrentMemberTeamsdeploymentCapabilitiesplanentitlementscapabilitiesauthMethods(core.ts L636-L743)。

3.PATCH /v1/org— 更新组织设置

  • 权限门槛最高:orgRoleRoute(["super-admin"])+ensureOrganizationSuperAdmin,且要求近 15 分钟内登录过(见第五节“特权会话”说明);
  • 可更新字段:nameallowedEmailDomains(最多 100 个,非法域返回invalid_email_domain)、allowedDesktopVersions(最多 200 个)、requireSsobrandAppNamebrandLogoUrlbrandIconUrlbrandAccentColor
  • 启用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/acceptuserSessionRoute,要求已登录且邮箱已验证(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 }

守卫权限要求用途
ensureOrganizationAdminRoleowner / admin,无需近期登录常规管理工作
ensureOrganizationSuperAdminowner / super-admin + 特权会话敏感/破坏性操作
ensureOrganizationAdminadmin + 特权会话安全敏感操作
ensureInviteManagerowner / admin + 特权会话邀请管理
ensureMemberRemoverowner / admin + 特权会话移除成员
ensureTeamManagerowner / admin + 特权会话团队管理
ensureOwner仅 owner + 特权会话所有权转移
ensureApiKeyReader/ensureApiKeyManageradmin / super-adminAPI 密钥读写
ensureScimReader/ensureScimManageradmin / super-adminSCIM 读写
ensureSsoReader/ensureSsoManageradmin / super-adminSSO 读写

特权会话(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"))则直接放行。另外replaceRoleValuesplitRolesnormalizeRoleName等辅助函数封装了“成员角色以逗号分隔存储”的细节,供角色更新与重命名传播使用。

六、邀请流程:invitations.ts

邀请模块只有两个端点,但把“创建/刷新”和“取消”的边界情况处理得相当完整。

POST /v1/invitations— 创建或刷新邀请

  • 请求体:{ "email": string, "role": string },其中roletrim().min(1).max(64)
  • 中间件:orgRoleRoute(["admin"])+ensureInviteManager
  • 校验链(invitations.ts L116-L143):
    1. 邮箱域必须落在allowedEmailDomains白名单内,否则409 invite_email_domain_not_allowed
    2. 通过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,并携带reasonemail_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/rolesroleName要求 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读取连接器元数据:baseUrlssoReadyconnection(含groupMappingModemetadata_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字段(unresolvedFailureCountlastFailureAtnextRetryAtlastSuccessfulSyncAt等)把同步故障状态暴露给管理端,便于运维排查(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)publicRoutedelegatedRoute等,并通过hasExplicitAuthGuardHandler实现deny-by-default——test/route-access-policy.test.ts会在 CI 中拦截任何未挂显式访问策略标记的路由注册。角色判定由verifyOrgRole完成:rolesmember直接放行,否则用organizationRoleValueSatisfies按角色层级匹配。

校验规范(对应 validation.ts 与shared.tsidParamSchema):

  • 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;
  • 组织上下文的核心业务逻辑(createOrganizationForUseracceptInvitationForUsergetOrganizationContextForUserremoveOrganizationMembertransferOrganizationOwnership等)集中在 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询