forked from EduCraft/curriculum-project-hub
feat: secure organization Feishu credentials
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
|
||||
import { probeFeishuApplication, type FeishuReadinessProbe } from "./feishuReadiness.js";
|
||||
|
||||
export interface FeishuApplicationCredentialInput {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly botOpenId: string;
|
||||
readonly verificationToken?: string;
|
||||
readonly encryptKey?: string;
|
||||
}
|
||||
|
||||
export interface RotateFeishuApplicationInput extends FeishuApplicationCredentialInput {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
}
|
||||
|
||||
export interface FeishuApplicationConnectionMetadata {
|
||||
readonly id: string;
|
||||
readonly appFingerprint: string;
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly activeVersion: number | null;
|
||||
readonly keyId: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ResolvedFeishuApplication extends Required<FeishuApplicationCredentialInput> {
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
export class FeishuApplicationConnectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly readinessProbe: FeishuReadinessProbe = probeFeishuApplication,
|
||||
) {}
|
||||
|
||||
async rotateCustomerApplication(
|
||||
input: RotateFeishuApplicationInput,
|
||||
): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> {
|
||||
const payload = validateCredential(input);
|
||||
const appIdentityFingerprint = fingerprintAppId(payload.appId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||
});
|
||||
await this.readinessProbe(payload);
|
||||
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||
const connection = await tx.organizationFeishuApplicationConnection.upsert({
|
||||
where: { organizationId: input.organizationId },
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
appIdentityFingerprint,
|
||||
status: "DRAFT",
|
||||
},
|
||||
});
|
||||
await tx.$queryRaw`
|
||||
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||
WHERE "id" = ${connection.id} FOR UPDATE
|
||||
`;
|
||||
const locked = await tx.organizationFeishuApplicationConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: {
|
||||
activeSecretVersion: true,
|
||||
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
|
||||
},
|
||||
});
|
||||
if (locked.organizationId !== input.organizationId) {
|
||||
throw new Error("Feishu Application Connection scope changed during rotation");
|
||||
}
|
||||
if (locked.appIdentityFingerprint !== appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application Connection cannot change app identity");
|
||||
}
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
if (locked.activeSecretVersion.connectionId !== locked.id) {
|
||||
throw new Error("Feishu Application Connection active version scope mismatch");
|
||||
}
|
||||
decryptFeishuCredential(this.secrets, {
|
||||
organizationId: locked.organizationId,
|
||||
connectionId: locked.id,
|
||||
appIdentityFingerprint: locked.appIdentityFingerprint,
|
||||
secretVersionId: locked.activeSecretVersion.id,
|
||||
envelopeVersion: locked.activeSecretVersion.envelopeVersion,
|
||||
keyId: locked.activeSecretVersion.keyId,
|
||||
envelope: locked.activeSecretVersion.envelope,
|
||||
});
|
||||
}
|
||||
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
||||
const secretVersionId = randomUUID();
|
||||
const envelope = this.secrets.encryptJson({
|
||||
purpose: "feishu-application",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: locked.id,
|
||||
secretVersionId,
|
||||
}, payload);
|
||||
const now = new Date();
|
||||
const secretVersion = await tx.feishuApplicationCredentialVersion.create({
|
||||
data: {
|
||||
id: secretVersionId,
|
||||
connectionId: locked.id,
|
||||
version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
});
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
await tx.feishuApplicationCredentialVersion.update({
|
||||
where: { id: locked.activeSecretVersion.id },
|
||||
data: { retiredAt: now },
|
||||
});
|
||||
}
|
||||
const activated = await tx.organizationFeishuApplicationConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: secretVersion.id,
|
||||
activatedAt: now,
|
||||
disabledAt: null,
|
||||
},
|
||||
});
|
||||
const action = version === 1
|
||||
? "feishu_application.created"
|
||||
: locked.status === "DISABLED"
|
||||
? "feishu_application.reactivated"
|
||||
: "feishu_application.rotated";
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action,
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
appFingerprint: redactedFingerprint(appIdentityFingerprint),
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...metadata(activated, { version, keyId: secretVersion.keyId }),
|
||||
created: version === 1,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAppFingerprintUniqueViolation(error)) {
|
||||
throw new Error("Feishu Application cannot be shared across Organizations", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readMetadata(organizationId: string): Promise<FeishuApplicationConnectionMetadata | null> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, organizationId);
|
||||
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
return connection === null ? null : metadata(connection, connection.activeSecretVersion);
|
||||
});
|
||||
}
|
||||
|
||||
async disable(input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
}): Promise<FeishuApplicationConnectionMetadata> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId: input.organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (connection === null) throw new Error("Feishu Application Connection not found");
|
||||
await tx.$queryRaw`
|
||||
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||
WHERE "id" = ${connection.id} FOR UPDATE
|
||||
`;
|
||||
const locked = await tx.organizationFeishuApplicationConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
if (locked.status === "DISABLED") return metadata(locked, locked.activeSecretVersion);
|
||||
const disabled = await tx.organizationFeishuApplicationConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: { status: "DISABLED", disabledAt: new Date() },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "feishu_application.disabled",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
appFingerprint: redactedFingerprint(locked.appIdentityFingerprint),
|
||||
previousStatus: locked.status,
|
||||
status: "DISABLED",
|
||||
},
|
||||
},
|
||||
});
|
||||
return metadata(disabled, locked.activeSecretVersion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveActiveFeishuApplication(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
scope: { readonly organizationId: string } | { readonly connectionId: string },
|
||||
): Promise<ResolvedFeishuApplication> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const connection = "organizationId" in scope
|
||||
? await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId: scope.organizationId },
|
||||
include: { activeSecretVersion: true },
|
||||
})
|
||||
: await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { id: scope.connectionId },
|
||||
include: { activeSecretVersion: true },
|
||||
});
|
||||
if (connection === null) throw new Error("active Feishu Application Connection not found");
|
||||
await lockActiveOrganization(tx, connection.organizationId);
|
||||
const version = connection.activeSecretVersion;
|
||||
if (connection.status !== "ACTIVE" || version === null || version.retiredAt !== null ||
|
||||
version.connectionId !== connection.id) {
|
||||
throw new Error("active Feishu Application Connection not found");
|
||||
}
|
||||
const payload = decryptFeishuCredential(secrets, {
|
||||
organizationId: connection.organizationId,
|
||||
connectionId: connection.id,
|
||||
appIdentityFingerprint: connection.appIdentityFingerprint,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
organizationId: connection.organizationId,
|
||||
appId: payload.appId,
|
||||
appSecret: payload.appSecret,
|
||||
botOpenId: payload.botOpenId,
|
||||
verificationToken: payload.verificationToken,
|
||||
encryptKey: payload.encryptKey,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function decryptFeishuCredential(
|
||||
secrets: LocalSecretEnvelope,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly connectionId: string;
|
||||
readonly appIdentityFingerprint: string;
|
||||
readonly secretVersionId: string;
|
||||
readonly envelopeVersion: number;
|
||||
readonly keyId: string;
|
||||
readonly envelope: Prisma.JsonValue;
|
||||
},
|
||||
): FeishuSecretPayloadV1 {
|
||||
if (input.envelopeVersion !== 1) throw new Error("unsupported Feishu secret envelope version");
|
||||
const envelope = input.envelope as unknown as SecretEnvelopeV1;
|
||||
if (envelope.version !== input.envelopeVersion || envelope.keyId !== input.keyId) {
|
||||
throw new Error("Feishu secret envelope metadata mismatch");
|
||||
}
|
||||
const payload = secrets.decryptJson<unknown>({
|
||||
purpose: "feishu-application",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: input.connectionId,
|
||||
secretVersionId: input.secretVersionId,
|
||||
}, envelope);
|
||||
const validated = validateStoredPayload(payload);
|
||||
if (fingerprintAppId(validated.appId) !== input.appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application app identity fingerprint mismatch");
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
export async function verifyStoredFeishuApplicationEnvelopes(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
): Promise<number> {
|
||||
const versions = await prisma.feishuApplicationCredentialVersion.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
envelopeVersion: true,
|
||||
keyId: true,
|
||||
envelope: true,
|
||||
connection: { select: { id: true, organizationId: true, appIdentityFingerprint: true } },
|
||||
},
|
||||
});
|
||||
for (const version of versions) {
|
||||
decryptFeishuCredential(secrets, {
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
appIdentityFingerprint: version.connection.appIdentityFingerprint,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
}
|
||||
return versions.length;
|
||||
}
|
||||
|
||||
export async function rewrapStoredFeishuApplicationEnvelopes(
|
||||
prisma: Prisma.TransactionClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
): Promise<{ readonly verified: number; readonly rewrapped: number }> {
|
||||
const versions = await prisma.feishuApplicationCredentialVersion.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
envelopeVersion: true,
|
||||
keyId: true,
|
||||
envelope: true,
|
||||
connection: { select: { id: true, organizationId: true, appIdentityFingerprint: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
let rewrapped = 0;
|
||||
for (const version of versions) {
|
||||
const binding = {
|
||||
purpose: "feishu-application",
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
secretVersionId: version.id,
|
||||
} as const;
|
||||
decryptFeishuCredential(secrets, {
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
appIdentityFingerprint: version.connection.appIdentityFingerprint,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
if (version.keyId === secrets.activeKeyId) continue;
|
||||
const nextEnvelope = secrets.rewrap(binding, version.envelope as unknown as SecretEnvelopeV1);
|
||||
const updated = await prisma.feishuApplicationCredentialVersion.updateMany({
|
||||
where: { id: version.id, keyId: version.keyId },
|
||||
data: {
|
||||
keyId: nextEnvelope.keyId,
|
||||
envelope: nextEnvelope as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Feishu secret envelope changed during KEK rotation: ${version.id}`);
|
||||
}
|
||||
await prisma.auditEntry.create({
|
||||
data: {
|
||||
organizationId: version.connection.organizationId,
|
||||
action: "feishu_application_secret.kek_rewrapped",
|
||||
metadata: {
|
||||
connectionId: version.connection.id,
|
||||
secretVersionId: version.id,
|
||||
previousKeyId: version.keyId,
|
||||
keyId: nextEnvelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
rewrapped += 1;
|
||||
}
|
||||
const verified = await verifyStoredFeishuApplicationEnvelopes(prisma, secrets);
|
||||
const remaining = await prisma.feishuApplicationCredentialVersion.count({
|
||||
where: { keyId: { not: secrets.activeKeyId } },
|
||||
});
|
||||
if (remaining !== 0) {
|
||||
throw new Error(`KEK rotation left ${remaining} Feishu envelope(s) on a non-active key`);
|
||||
}
|
||||
return { verified, rewrapped };
|
||||
}
|
||||
|
||||
function validateCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
|
||||
const appId = nonEmpty(input.appId, "Feishu appId");
|
||||
const appSecret = nonEmpty(input.appSecret, "Feishu appSecret");
|
||||
const botOpenId = nonEmpty(input.botOpenId, "Feishu botOpenId");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
appId,
|
||||
appSecret,
|
||||
botOpenId,
|
||||
verificationToken: input.verificationToken?.trim() ?? "",
|
||||
encryptKey: input.encryptKey?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function validateStoredPayload(value: unknown): FeishuSecretPayloadV1 {
|
||||
if (!isRecord(value) || value["schemaVersion"] !== 1 ||
|
||||
typeof value["appId"] !== "string" || value["appId"] === "" ||
|
||||
typeof value["appSecret"] !== "string" || value["appSecret"] === "" ||
|
||||
typeof value["botOpenId"] !== "string" || value["botOpenId"] === "" ||
|
||||
typeof value["verificationToken"] !== "string" || typeof value["encryptKey"] !== "string") {
|
||||
throw new Error("invalid encrypted Feishu Application credential payload");
|
||||
}
|
||||
return value as unknown as FeishuSecretPayloadV1;
|
||||
}
|
||||
|
||||
async function requireFeishuAdmin(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: Pick<RotateFeishuApplicationInput, "organizationId" | "actorUserId">,
|
||||
): Promise<void> {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const actor = await tx.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.actorUserId,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (actor === null) {
|
||||
throw new Error("Feishu Application Connection write requires an active Organization OWNER or ADMIN");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertAppIdentityAvailable(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
appIdentityFingerprint: string,
|
||||
): Promise<void> {
|
||||
const organizationConnection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId },
|
||||
select: { appIdentityFingerprint: true },
|
||||
});
|
||||
if (organizationConnection !== null &&
|
||||
organizationConnection.appIdentityFingerprint !== appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application Connection cannot change app identity");
|
||||
}
|
||||
const appConnection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { appIdentityFingerprint },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (appConnection !== null && appConnection.organizationId !== organizationId) {
|
||||
throw new Error("Feishu Application cannot be shared across Organizations");
|
||||
}
|
||||
}
|
||||
|
||||
function metadata(
|
||||
connection: {
|
||||
readonly id: string;
|
||||
readonly appIdentityFingerprint: string;
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
secret: { readonly version: number; readonly keyId: string } | null,
|
||||
): FeishuApplicationConnectionMetadata {
|
||||
return {
|
||||
id: connection.id,
|
||||
appFingerprint: redactedFingerprint(connection.appIdentityFingerprint),
|
||||
status: connection.status,
|
||||
activeVersion: secret?.version ?? null,
|
||||
keyId: secret?.keyId ?? null,
|
||||
createdAt: connection.createdAt,
|
||||
updatedAt: connection.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprintAppId(appId: string): string {
|
||||
return createHash("sha256").update("cph-feishu-app\0").update(appId, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function redactedFingerprint(fingerprint: string): string {
|
||||
return `sha256:${fingerprint.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function isAppFingerprintUniqueViolation(error: unknown): boolean {
|
||||
if (!isRecord(error) || error["code"] !== "P2002" || !isRecord(error["meta"])) return false;
|
||||
const target = error["meta"]["target"];
|
||||
return Array.isArray(target) && target.includes("appIdentityFingerprint");
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||
|
||||
export interface FeishuReadinessInput {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly botOpenId: string;
|
||||
}
|
||||
|
||||
export type FeishuReadinessProbe = (input: FeishuReadinessInput) => Promise<void>;
|
||||
|
||||
export class FeishuReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: "feishu_readiness_unreachable" | "feishu_readiness_rejected",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "http" | "provider_rejection",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FeishuReadinessError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Obtain a tenant token, prove the configured bot identity, then discard it. */
|
||||
export async function probeFeishuApplication(input: FeishuReadinessInput): Promise<void> {
|
||||
const response = await feishuFetch(
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ app_id: input.appId, app_secret: input.appSecret }),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
`Feishu application readiness check failed: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu application readiness check returned an invalid response",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
if (!isRecord(payload) || payload["code"] !== 0 ||
|
||||
typeof payload["tenant_access_token"] !== "string" || payload["tenant_access_token"] === "") {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu application credentials were rejected",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
const botResponse = await feishuFetch("https://open.feishu.cn/open-apis/bot/v3/info", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${payload["tenant_access_token"]}`,
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!botResponse.ok) {
|
||||
await botResponse.body?.cancel();
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
`Feishu bot readiness check failed: status ${botResponse.status}`,
|
||||
"http",
|
||||
botResponse.status,
|
||||
);
|
||||
}
|
||||
let botPayload: unknown;
|
||||
try {
|
||||
botPayload = await botResponse.json();
|
||||
} catch {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu bot readiness check returned an invalid response",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
if (!isRecord(botPayload) || botPayload["code"] !== 0 ||
|
||||
!isRecord(botPayload["bot"]) || botPayload["bot"]["open_id"] !== input.botOpenId) {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu bot identity does not match the configured botOpenId",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function feishuFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_unreachable",
|
||||
"Feishu application readiness check could not reach provider",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PrismaClient } from "@prisma/client";
|
||||
import { parse } from "dotenv";
|
||||
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
|
||||
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import { verifyStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
||||
import {
|
||||
DeploymentPreflightError,
|
||||
validateDeploymentPreflight,
|
||||
@@ -218,32 +219,41 @@ async function validatePostgresQuery(
|
||||
log: [],
|
||||
});
|
||||
let queryFailure: unknown;
|
||||
let envelopeFailure: unknown;
|
||||
const envelopeFailures: Array<{ readonly kind: "provider" | "Feishu"; readonly error: unknown }> = [];
|
||||
try {
|
||||
await client.$queryRawUnsafe("SELECT 1");
|
||||
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
|
||||
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
|
||||
`;
|
||||
if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) {
|
||||
console.log("[preflight] empty database has no migration table or stored provider envelopes");
|
||||
console.log("[preflight] empty database has no migration table or stored connection envelopes");
|
||||
} else {
|
||||
const migration = await client.$queryRaw<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
|
||||
SELECT "finished_at", "rolled_back_at"
|
||||
FROM "_prisma_migrations"
|
||||
WHERE "migration_name" = '20260710220000_org_provider_secret_envelopes'
|
||||
ORDER BY "started_at" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const applied = migration[0]?.finished_at !== null && migration[0]?.finished_at !== undefined &&
|
||||
migration[0]?.rolled_back_at === null;
|
||||
if (!applied) {
|
||||
const providerApplied = await migrationApplied(
|
||||
client,
|
||||
"20260710220000_org_provider_secret_envelopes",
|
||||
);
|
||||
if (!providerApplied) {
|
||||
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
|
||||
} else {
|
||||
try {
|
||||
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
|
||||
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
|
||||
} catch (error) {
|
||||
envelopeFailure = error;
|
||||
envelopeFailures.push({ kind: "provider", error });
|
||||
}
|
||||
}
|
||||
const feishuApplied = await migrationApplied(
|
||||
client,
|
||||
"20260710230000_org_feishu_application_secrets",
|
||||
);
|
||||
if (!feishuApplied) {
|
||||
console.log("[preflight] Feishu envelope migration is pending; no stored envelopes to authenticate");
|
||||
} else {
|
||||
try {
|
||||
const verified = await verifyStoredFeishuApplicationEnvelopes(client, secretEnvelope);
|
||||
console.log(`[preflight] authenticated ${verified} stored Feishu envelope(s)`);
|
||||
} catch (error) {
|
||||
envelopeFailures.push({ kind: "Feishu", error });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,15 +282,34 @@ async function validatePostgresQuery(
|
||||
{ cause: disconnectFailure },
|
||||
);
|
||||
}
|
||||
if (envelopeFailure !== undefined) {
|
||||
if (envelopeFailures.length > 0) {
|
||||
const failures = envelopeFailures.map(({ error }) => error);
|
||||
const cause = failures.length === 1
|
||||
? failures[0]
|
||||
: new AggregateError(failures, "multiple connection envelope verifications failed");
|
||||
const details = envelopeFailures
|
||||
.map(({ kind, error }) => `${kind}: ${formatFailure(error)}`)
|
||||
.join("; ");
|
||||
throw new Error(
|
||||
`stored provider envelope verification failed (${formatFailure(envelopeFailure)})`,
|
||||
{ cause: envelopeFailure },
|
||||
`stored connection envelope verification failed (${details})`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
console.log("[preflight] PostgreSQL authenticated query succeeded");
|
||||
}
|
||||
|
||||
async function migrationApplied(client: PrismaClient, migrationName: string): Promise<boolean> {
|
||||
const rows = await client.$queryRaw<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
|
||||
SELECT "finished_at", "rolled_back_at"
|
||||
FROM "_prisma_migrations"
|
||||
WHERE "migration_name" = ${migrationName}
|
||||
ORDER BY "started_at" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0]?.finished_at !== null && rows[0]?.finished_at !== undefined &&
|
||||
rows[0]?.rolled_back_at === null;
|
||||
}
|
||||
|
||||
async function validateProvisionedDirectories(
|
||||
input: DeploymentPreflightInput,
|
||||
serviceUid: number,
|
||||
|
||||
+6
-1
@@ -9,6 +9,7 @@ import { LocalSecretEnvelope, loadLocalSecretKeyring } from "./security/secretEn
|
||||
import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
|
||||
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
|
||||
import { openProviderProxyLease } from "./connections/providerProxy.js";
|
||||
import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuApplicationConnections.js";
|
||||
import { readServerBinding } from "./settings/server.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
@@ -36,7 +37,11 @@ export async function startHub(): Promise<void> {
|
||||
|
||||
const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
||||
const verifiedProviderEnvelopes = await verifyStoredProviderEnvelopes(prisma, secretEnvelope);
|
||||
app.log.info({ verified: verifiedProviderEnvelopes }, "startup: authenticated stored provider envelopes");
|
||||
const verifiedFeishuEnvelopes = await verifyStoredFeishuApplicationEnvelopes(prisma, secretEnvelope);
|
||||
app.log.info(
|
||||
{ provider: verifiedProviderEnvelopes, feishu: verifiedFeishuEnvelopes },
|
||||
"startup: authenticated stored connection envelopes",
|
||||
);
|
||||
const runtimeSettings = createDatabaseRuntimeSettings(
|
||||
prisma,
|
||||
secretEnvelope,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lstat, open, rename, unlink, type FileHandle } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import { rewrapStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
||||
import {
|
||||
LocalSecretEnvelope,
|
||||
loadLocalSecretKeyringFile,
|
||||
@@ -76,14 +77,19 @@ async function rotateUnderLock(
|
||||
const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys };
|
||||
try {
|
||||
await replaceKeyringAtomically(input.keyringFile, keyring, metadata.uid, metadata.gid);
|
||||
const result = await rewrapStoredProviderEnvelopes(
|
||||
const providerResult = await rewrapStoredProviderEnvelopes(
|
||||
tx,
|
||||
new LocalSecretEnvelope(keyring),
|
||||
);
|
||||
const feishuResult = await rewrapStoredFeishuApplicationEnvelopes(
|
||||
tx,
|
||||
new LocalSecretEnvelope(keyring),
|
||||
);
|
||||
return {
|
||||
previousActiveKeyId: previous.activeKeyId,
|
||||
activeKeyId: keyId,
|
||||
...result,
|
||||
verified: providerResult.verified + feishuResult.verified,
|
||||
rewrapped: providerResult.rewrapped + feishuResult.rewrapped,
|
||||
};
|
||||
} finally {
|
||||
generatedKey.fill(0);
|
||||
|
||||
Reference in New Issue
Block a user