Compare commits

...

3 Commits

7 changed files with 183 additions and 32 deletions
+3 -3
View File
@@ -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 首次登录后自动成为 MEMBEROWNER/ADMIN 提权和被移除成员的恢复仍需人工操作
- Alpha 不提供开放注册、自助密钥管理或跨组织资源共享。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.11",
"version": "0.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.11",
"version": "0.0.14",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.11",
"version": "0.0.14",
"private": true,
"type": "module",
"engines": {
+80 -11
View File
@@ -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,
@@ -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")) {
+3 -4
View File
@@ -35,7 +35,6 @@ import type { RuntimeSettings } from "../settings/runtime.js";
import { currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
@@ -550,7 +549,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: result.status === "failed" && result.error !== undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text;
await card.finish(finalText, { interrupted, footerText: formatRunCostLine(result.costUsd) });
await card.finish(finalText, { interrupted });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) {
await deps.prisma.agentSession.update({
@@ -1160,14 +1159,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]!;
+86 -4
View File
@@ -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();
+8 -7
View File
@@ -168,7 +168,8 @@ describe("trigger full lifecycle (integration)", () => {
msgType: "interactive",
replyInThread: undefined,
});
expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true);
expect(rt.sentTexts.some((text) => text.includes("mock response"))).toBe(true);
expect(rt.sentTexts.some((text) => text.includes("本次成本"))).toBe(false);
expect(runAgentCalls).toHaveLength(1);
expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
@@ -662,8 +663,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 +685,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 +1012,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 +1034,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);