feat: secure organization Feishu credentials

This commit is contained in:
2026-07-10 23:13:11 +08:00
parent 848f913597
commit 44557da499
18 changed files with 1443 additions and 24 deletions
+26
View File
@@ -2,6 +2,8 @@
* Map domain / HTTP errors to consistent JSON responses.
*/
import type { FastifyReply } from "fastify";
import { FeishuReadinessError } from "../connections/feishuReadiness.js";
import { ProviderReadinessError } from "../connections/providerReadiness.js";
import { HttpError, sendError } from "./auth/guards.js";
export async function handleRouteError(reply: FastifyReply, err: unknown): Promise<void> {
@@ -9,6 +11,24 @@ export async function handleRouteError(reply: FastifyReply, err: unknown): Promi
await sendError(reply, err.statusCode, err.code, err.message);
return;
}
if (err instanceof ProviderReadinessError) {
await sendError(
reply,
err.code === "provider_readiness_unreachable" ? 502 : 400,
err.code === "provider_readiness_unreachable" ? "provider_unavailable" : "provider_credential_rejected",
err.message,
);
return;
}
if (err instanceof FeishuReadinessError) {
await sendError(
reply,
err.code === "feishu_readiness_unreachable" ? 502 : 400,
err.code === "feishu_readiness_unreachable" ? "feishu_unavailable" : "feishu_credential_rejected",
err.message,
);
return;
}
if (err instanceof Error) {
const mapped = mapDomainError(err.message);
if (mapped !== null) {
@@ -29,6 +49,12 @@ function mapDomainError(message: string): { statusCode: number; code: string; me
if (lower.includes("provider credential readiness check failed")) {
return { statusCode: 400, code: "provider_credential_rejected", message };
}
if (lower.includes("feishu application readiness check could not reach provider")) {
return { statusCode: 502, code: "feishu_unavailable", message };
}
if (lower.includes("feishu application readiness check") || lower.includes("feishu application credentials were rejected")) {
return { statusCode: 400, code: "feishu_credential_rejected", message };
}
if (lower.includes("not found")) {
return { statusCode: 404, code: "not_found", message };
}
+5
View File
@@ -8,6 +8,7 @@ import { registerAuthRoutes } from "./routes/authRoutes.js";
import { registerOrgRoutes } from "./routes/orgRoutes.js";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../connections/feishuReadiness.js";
export interface AdminPluginConfig {
readonly prisma: PrismaClient;
@@ -18,6 +19,7 @@ export interface AdminPluginConfig {
readonly projectWorkspaceRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
readonly cookieSecure?: boolean;
readonly oauthScope?: string;
readonly fetchImpl?: typeof fetch;
@@ -51,5 +53,8 @@ export async function registerAdminPlugin(
...(config.providerReadinessProbe !== undefined
? { providerReadinessProbe: config.providerReadinessProbe }
: {}),
...(config.feishuConnectionReadinessProbe !== undefined
? { feishuConnectionReadinessProbe: config.feishuConnectionReadinessProbe }
: {}),
});
}
@@ -0,0 +1,136 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { FeishuApplicationConnectionService } from "../../connections/feishuApplicationConnections.js";
import { FeishuReadinessError, type FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface FeishuApplicationConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly readinessProbe?: FeishuReadinessProbe;
}
export async function registerFeishuApplicationConnectionRoutes(
app: FastifyInstance,
config: FeishuApplicationConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new FeishuApplicationConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
app.get("/api/org/:orgSlug/feishu-application-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connection: await connections.readMetadata(auth.organization.id) };
} catch (error) {
request.log.error({
requestId: request.id,
operation: "feishu_application_connection.read",
errorCode: "feishu_application_connection_read_failed",
}, "Feishu Application Connection read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/feishu-application-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const result = await connections.rotateCustomerApplication({
organizationId: auth.organization.id,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
connectionId: result.id,
status: result.status,
secretVersion: result.activeVersion,
keyId: result.keyId,
}, result.created ? "Feishu Application Connection created" : "Feishu Application Connection rotated");
const { created, ...metadata } = result;
return reply.status(created ? 201 : 200).send(metadata);
} catch (error) {
const facts = error instanceof FeishuReadinessError
? {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
}
: { errorCode: "feishu_application_connection_write_failed" };
request.log.error({
requestId: request.id,
operation: "feishu_application_connection.rotate",
...facts,
}, "Feishu Application Connection mutation failed");
return handleRouteError(reply, error);
}
});
app.delete("/api/org/:orgSlug/feishu-application-connection", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const result = await connections.disable({
organizationId: auth.organization.id,
actorUserId: auth.user.id,
});
request.log.info({
organizationId: auth.organization.id,
connectionId: result.id,
status: result.status,
secretVersion: result.activeVersion,
keyId: result.keyId,
}, "Feishu Application Connection disabled");
return reply.send(result);
} catch (error) {
request.log.error({
requestId: request.id,
operation: "feishu_application_connection.disable",
errorCode: "feishu_application_connection_disable_failed",
}, "Feishu Application Connection disable failed");
return handleRouteError(reply, error);
}
});
}
function parseBody(value: unknown): {
readonly appId: string;
readonly appSecret: string;
readonly botOpenId: string;
readonly verificationToken?: string;
readonly encryptKey?: string;
} {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid Feishu Application credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["appId", "appSecret", "botOpenId"] as const) {
if (typeof body[name] !== "string") throw new Error(`${name} is required`);
}
for (const name of ["verificationToken", "encryptKey"] as const) {
if (body[name] !== undefined && typeof body[name] !== "string") {
throw new Error(`${name} must be a string`);
}
}
return {
appId: body["appId"] as string,
appSecret: body["appSecret"] as string,
botOpenId: body["botOpenId"] as string,
...(body["verificationToken"] !== undefined
? { verificationToken: body["verificationToken"] as string }
: {}),
...(body["encryptKey"] !== undefined ? { encryptKey: body["encryptKey"] as string } : {}),
};
}
+11
View File
@@ -17,6 +17,8 @@ import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
@@ -24,6 +26,7 @@ export interface OrgRouteConfig {
readonly projectWorkspaceRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -114,4 +117,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
? { providerReadinessProbe: config.providerReadinessProbe }
: {}),
});
await registerFeishuApplicationConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.feishuConnectionReadinessProbe !== undefined
? { readinessProbe: config.feishuConnectionReadinessProbe }
: {}),
});
}