From 6ed56ddfc8f0fa56cd3d8fda3f258bcde0b83841 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 11 Jul 2026 14:00:43 +0800 Subject: [PATCH] feat: auto-join scoped Feishu OAuth users --- docs/para-26071100-feishu-setup.md | 6 +-- hub/package-lock.json | 4 +- hub/package.json | 2 +- hub/src/admin/routes/authRoutes.ts | 58 ++++++++++++++++---- hub/src/feishu/trigger.ts | 4 +- hub/test/integration/admin-auth.test.ts | 71 +++++++++++++++++++++++-- hub/test/integration/trigger.test.ts | 12 ++--- 7 files changed, 129 insertions(+), 28 deletions(-) diff --git a/docs/para-26071100-feishu-setup.md b/docs/para-26071100-feishu-setup.md index 87ce463..a6ab17f 100644 --- a/docs/para-26071100-feishu-setup.md +++ b/docs/para-26071100-feishu-setup.md @@ -99,7 +99,7 @@ https://example-school.educraft.paradigm-edu.net/auth/feishu/callback 必须使用 Educraft 部署人员最终确认的 slug;不要直接照抄示例。该 URL 用于 OAuth 返回并创建应用作用域下的飞书用户身份,不代表当前已经开放组织管理台。 -身份登录和组织成员资格是两件事:OAuth 登录只建立用户身份;当前 Alpha 阶段,Educraft 部署人员还需要通过受控管理命令把该用户加入组织。首位 OWNER 在 bootstrap 时由部署人员完成,其他试点成员按需人工加入。 +组织专属 OAuth 同时完成身份建立和入组:首次成功登录的用户会自动成为当前 Organization 的 `MEMBER`,回到群聊即可使用。`OWNER` 和 `ADMIN` 仍只能由部署人员或管理员显式授予;曾被移除的成员重新登录不会自动恢复资格。 ## 6. 发布并安装应用 @@ -203,7 +203,7 @@ Educraft 部署人员收到信息后,会回传最终 organization slug、访 1. OWNER 在试点群中 @机器人发送一条纯文本消息。 2. 如果群尚未绑定项目,确认机器人返回项目创建/绑定卡片。 3. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。 -4. 选择一位非 OWNER 试点成员完成 OAuth 登录,并由部署人员加入组织。 +4. 选择一位非 OWNER 试点成员完成 OAuth 登录,确认其自动以 MEMBER 身份加入组织。 5. 该成员在同一群中 @机器人,确认能够进入已绑定项目。 6. 测试一个小文件附件、一次运行中断,以及一个需要生成文档的任务。 @@ -215,5 +215,5 @@ Educraft 部署人员收到信息后,会回传最终 organization slug、访 - 组织的 role、system prompt、tools 和 skills 是运行时配置,不需要跟随版本发布。 - 同一项目同一时间只执行一个任务,避免并发修改同一个 workspace;组织级并发上限由部署配置决定。 - 当前由 Educraft 人工创建组织、OWNER、Provider Connection 和初始 Team,并通过服务器上的受控管理命令运维;组织管理台尚未开放。 -- 非 OWNER 试点成员需要先完成飞书 OAuth 身份建立,再由 Educraft 部署人员人工加入组织;当前没有自助入组织入口。 +- 非 OWNER 试点成员通过组织专属 OAuth 首次登录后自动成为 MEMBER;OWNER/ADMIN 提权和被移除成员的恢复仍需人工操作。 - Alpha 不提供开放注册、自助密钥管理或跨组织资源共享。 diff --git a/hub/package-lock.json b/hub/package-lock.json index 28cbf0d..8df708e 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.11", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.11", + "version": "0.0.12", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index b360339..759fe88 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.11", + "version": "0.0.12", "private": true, "type": "module", "engines": { diff --git a/hub/src/admin/routes/authRoutes.ts b/hub/src/admin/routes/authRoutes.ts index 6aff11e..ca6266b 100644 --- a/hub/src/admin/routes/authRoutes.ts +++ b/hub/src/admin/routes/authRoutes.ts @@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto"; import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js"; -import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js"; +import { upsertScopedFeishuIdentityInTransaction } from "../../feishu/identityNamespace.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import { buildAuthorizeUrl, @@ -164,16 +164,54 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute const feishuUser = await exchangeCodeForUser(oauthConfig, code); let userId: string; if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) { - const identity = await upsertScopedFeishuIdentity(config.prisma, { - connectionId: statePayload.connectionId, - openId: feishuUser.openId, - ...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}), - displayName: feishuUser.displayName, - ...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}), + const connectionId = statePayload.connectionId; + const organizationId = statePayload.organizationId; + const identity = await config.prisma.$transaction(async (tx) => { + const resolved = await upsertScopedFeishuIdentityInTransaction(tx, { + connectionId, + expectedOrganizationId: organizationId, + openId: feishuUser.openId, + ...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}), + displayName: feishuUser.displayName, + ...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}), + }); + const activeMembership = await tx.organizationMembership.findFirst({ + where: { + organizationId, + userId: resolved.userId, + revokedAt: null, + }, + select: { id: true }, + }); + if (activeMembership === null) { + const revokedMembership = await tx.organizationMembership.findFirst({ + where: { + organizationId, + userId: resolved.userId, + revokedAt: { not: null }, + }, + select: { id: true }, + }); + if (revokedMembership === null) { + await tx.organizationMembership.create({ + data: { + organizationId, + userId: resolved.userId, + role: "MEMBER", + }, + }); + await tx.auditEntry.create({ + data: { + organizationId, + actorUserId: resolved.userId, + action: "organization_member.oauth_auto_joined", + metadata: { connectionId: resolved.connectionId, role: "MEMBER" }, + }, + }); + } + } + return resolved; }); - if (identity.organizationId !== statePayload.organizationId) { - throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch"); - } userId = identity.userId; setSessionCookie(reply, config, { userId, diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index dd49786..9421eb2 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -1160,14 +1160,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return { status: "error", reason: "identity_missing", - message: `请先通过飞书登录建立身份:${loginUrl}\n登录后仍需由组织管理员将你加入组织。`, + message: `请先通过飞书登录并加入组织:${loginUrl}\n完成后返回群聊重试。`, }; } if (user.organizationMemberships.length === 0) { return { status: "error", reason: "membership_missing", - message: "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", + message: "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。", }; } const membership = user.organizationMemberships[0]!; diff --git a/hub/test/integration/admin-auth.test.ts b/hub/test/integration/admin-auth.test.ts index e0dabd3..c32e61d 100644 --- a/hub/test/integration/admin-auth.test.ts +++ b/hub/test/integration/admin-auth.test.ts @@ -267,9 +267,6 @@ describe("admin auth + org API guards", () => { openId: "ou_scoped_user", displayName: "Invited User", }); - await prisma.organizationMembership.create({ - data: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, role: "ADMIN" }, - }); await seedTestOrganization("org_scoped_other", "scoped-other"); await prisma.organizationMembership.create({ data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" }, @@ -317,7 +314,7 @@ describe("admin auth + org API guards", () => { expect(me.statusCode).toBe(200); expect(me.json()).toMatchObject({ user: { id: identity.userId, displayName: "Scoped User" }, - organizations: [expect.objectContaining({ slug: "test-default", role: "ADMIN" })], + organizations: [expect.objectContaining({ slug: "test-default", role: "MEMBER" })], }); expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1); const crossOrganization = await app.inject({ @@ -331,6 +328,13 @@ describe("admin auth + org API guards", () => { where: { id: identity.identityId }, select: { unionId: true }, })).resolves.toEqual({ unionId: "on_scoped_union" }); + await expect(prisma.auditEntry.count({ + where: { + organizationId: DEFAULT_ORG_ID, + actorUserId: identity.userId, + action: "organization_member.oauth_auto_joined", + }, + })).resolves.toBe(1); await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" }); const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } }); @@ -340,6 +344,65 @@ describe("admin auth + org API guards", () => { } }); + it("does not restore a revoked Organization membership during scoped OAuth", async () => { + await seedUser("revoked-owner", "legacy_revoked_owner", "OWNER"); + const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}); + const connection = await connections.rotateCustomerApplication({ + organizationId: DEFAULT_ORG_ID, + actorUserId: "revoked-owner", + appId: "cli_revoked_oauth", + appSecret: "revoked-oauth-secret", + botOpenId: "ou_revoked_bot", + }); + const identity = await upsertScopedFeishuIdentity(prisma, { + connectionId: connection.id, + openId: "ou_revoked_user", + displayName: "Revoked User", + }); + await prisma.organizationMembership.create({ + data: { + organizationId: DEFAULT_ORG_ID, + userId: identity.userId, + role: "MEMBER", + revokedAt: new Date(), + }, + }); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/oauth/token")) { + return Response.json({ code: 0, access_token: "revoked-user-token" }); + } + if (url.includes("/user_info")) { + return Response.json({ + code: 0, + data: { open_id: "ou_revoked_user", name: "Revoked User" }, + }); + } + throw new Error(`unexpected ${url}`); + }); + const app = await buildApp(fetchImpl as unknown as typeof fetch); + try { + const start = await app.inject({ method: "GET", url: "/auth/feishu/test-default" }); + const authorize = new URL(String(start.headers.location)); + const state = authorize.searchParams.get("state"); + expect(state).not.toBeNull(); + const callback = await app.inject({ + method: "GET", + url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`, + headers: { cookie: cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) }, + }); + expect(callback.statusCode).toBe(302); + await expect(prisma.organizationMembership.count({ + where: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, revokedAt: null }, + })).resolves.toBe(0); + await expect(prisma.auditEntry.count({ + where: { action: "organization_member.oauth_auto_joined", actorUserId: identity.userId }, + })).resolves.toBe(0); + } finally { + await app.close(); + } + }); + it("unknown org slug returns 404 for admin", async () => { await seedUser("u-admin", "ou_admin", "ADMIN"); const app = await buildApp(); diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index cf0cc54..1c168e5 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -662,8 +662,8 @@ describe("trigger full lifecycle (integration)", () => { await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt); expect(rt.sentTexts).toContain( - "请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" + - "登录后仍需由组织管理员将你加入组织。", + "请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" + + "完成后返回群聊重试。", ); expect(rt.sentTexts).not.toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); @@ -684,7 +684,7 @@ describe("trigger full lifecycle (integration)", () => { await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt); expect(rt.sentTexts).toContain( - "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", + "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。", ); expect(rt.sentTexts).not.toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); @@ -1011,8 +1011,8 @@ describe("trigger full lifecycle (integration)", () => { await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt); expect(rt.sentTexts).toContain( - "请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" + - "登录后仍需由组织管理员将你加入组织。", + "请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" + + "完成后返回群聊重试。", ); expect(rt.sentCards).toHaveLength(0); const runs = await prisma.agentRun.findMany(); @@ -1033,7 +1033,7 @@ describe("trigger full lifecycle (integration)", () => { await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt); expect(rt.sentTexts).toContain( - "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", + "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。", ); expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/"); await expect(prisma.agentRun.count()).resolves.toBe(0);