forked from bai/curriculum-project-hub
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83ec835d4c | |||
| 6ed56ddfc8 |
@@ -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 不提供开放注册、自助密钥管理或跨组织资源共享。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.13",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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,
|
||||
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 } : {}),
|
||||
});
|
||||
if (identity.organizationId !== statePayload.organizationId) {
|
||||
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
|
||||
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;
|
||||
});
|
||||
userId = identity.userId;
|
||||
setSessionCookie(reply, config, {
|
||||
userId,
|
||||
@@ -235,6 +273,34 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get("/auth/feishu/complete", async (request, reply) => {
|
||||
const query = request.query as { org?: string };
|
||||
const organizationName = typeof query.org === "string" && query.org.trim() !== ""
|
||||
? query.org.trim()
|
||||
: "当前组织";
|
||||
return reply.type("text/html").send(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Educraft 登录成功</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
|
||||
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:25rem;text-align:center}
|
||||
.ok{font-size:3rem;margin:0 0 .5rem}.hint{color:#646a73;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="ok">✅</p>
|
||||
<h1>登录并加入组织成功</h1>
|
||||
<p>你已加入 ${escapeHtml(organizationName)}。</p>
|
||||
<p class="hint">现在可以关闭本页面,返回飞书群再次 @机器人继续使用。</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
app.get("/api/me", async (request, reply) => {
|
||||
try {
|
||||
const auth = await requireSession(request, reply, guardDeps);
|
||||
@@ -363,10 +429,13 @@ async function resolvePostLoginRedirect(
|
||||
revokedAt: null,
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: { organization: { select: { slug: true } } },
|
||||
select: { organization: { select: { slug: true, name: true } } },
|
||||
});
|
||||
if (intended === null) return "/admin?error=not_an_active_org_member";
|
||||
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
||||
if (returnTo === "/admin") {
|
||||
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`;
|
||||
}
|
||||
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
||||
}
|
||||
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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,32 @@ 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);
|
||||
|
||||
const defaultStart = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
|
||||
const defaultAuthorize = new URL(String(defaultStart.headers.location));
|
||||
const defaultState = defaultAuthorize.searchParams.get("state");
|
||||
expect(defaultState).not.toBeNull();
|
||||
const defaultCallback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(defaultState!)}`,
|
||||
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
|
||||
});
|
||||
expect(defaultCallback.statusCode).toBe(302);
|
||||
expect(defaultCallback.headers.location).toBe(
|
||||
"/auth/feishu/complete?org=Test%20Default%20Organization",
|
||||
);
|
||||
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
|
||||
expect(complete.statusCode).toBe(200);
|
||||
expect(complete.headers["content-type"]).toContain("text/html");
|
||||
expect(complete.body).toContain("登录并加入组织成功");
|
||||
expect(complete.body).toContain("Test Default Organization");
|
||||
|
||||
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 +363,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user