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
@@ -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);
}
+114
View File
@@ -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);
}