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
+2 -2
View File
@@ -33,8 +33,8 @@ process-global `ANTHROPIC_*` variables; preflight rejects those legacy settings.
Rotate the local KEK only from the controlled host console. The command first
atomically adds a new active key while retaining every previous key, then
authenticates and rewraps every stored Provider DEK, writes redacted org audit
events, and verifies the complete set again:
authenticates and rewraps every stored Provider and Feishu Application DEK,
writes redacted org audit events, and verifies the complete set again:
```sh
sudo bash -c '
@@ -0,0 +1,59 @@
-- ADR-0024: one Organization-scoped customer Feishu application connection
-- with immutable encrypted credential versions. No Feishu plaintext column is
-- present in either table.
CREATE TABLE "OrganizationFeishuApplicationConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"appIdentityFingerprint" TEXT NOT NULL,
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
"activeSecretVersionId" TEXT,
"activatedAt" TIMESTAMP(3),
"disabledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationFeishuApplicationConnection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FeishuApplicationCredentialVersion" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
"keyId" TEXT NOT NULL,
"envelope" JSONB NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retiredAt" TIMESTAMP(3),
CONSTRAINT "FeishuApplicationCredentialVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationFeishuApplicationConnection_organizationId_key"
ON "OrganizationFeishuApplicationConnection"("organizationId");
CREATE UNIQUE INDEX "OrganizationFeishuApplicationConnection_appIdentityFingerprint_key"
ON "OrganizationFeishuApplicationConnection"("appIdentityFingerprint");
CREATE UNIQUE INDEX "OrganizationFeishuApplicationConnection_activeSecretVersionId_key"
ON "OrganizationFeishuApplicationConnection"("activeSecretVersionId");
CREATE INDEX "OrganizationFeishuApplicationConnection_status_idx"
ON "OrganizationFeishuApplicationConnection"("status");
CREATE UNIQUE INDEX "FeishuApplicationCredentialVersion_connectionId_version_key"
ON "FeishuApplicationCredentialVersion"("connectionId", "version");
CREATE INDEX "FeishuApplicationCredentialVersion_connectionId_retiredAt_idx"
ON "FeishuApplicationCredentialVersion"("connectionId", "retiredAt");
CREATE INDEX "FeishuApplicationCredentialVersion_keyId_idx"
ON "FeishuApplicationCredentialVersion"("keyId");
CREATE INDEX "FeishuApplicationCredentialVersion_createdByUserId_idx"
ON "FeishuApplicationCredentialVersion"("createdByUserId");
ALTER TABLE "OrganizationFeishuApplicationConnection"
ADD CONSTRAINT "OrganizationFeishuApplicationConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FeishuApplicationCredentialVersion"
ADD CONSTRAINT "FeishuApplicationCredentialVersion_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationFeishuApplicationConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FeishuApplicationCredentialVersion"
ADD CONSTRAINT "FeishuApplicationCredentialVersion_createdByUserId_fkey"
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationFeishuApplicationConnection"
ADD CONSTRAINT "OrganizationFeishuApplicationConnection_activeSecretVersionId_fkey"
FOREIGN KEY ("activeSecretVersionId") REFERENCES "FeishuApplicationCredentialVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+46
View File
@@ -42,6 +42,7 @@ model Organization {
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection?
auditEntries AuditEntry[] @relation("organizationAudit")
@@index([status])
@@ -109,6 +110,51 @@ model User {
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
}
/// ADR-0024: exactly one customer-owned Feishu application connection may be
/// configured for an Organization. External Feishu identifiers are scoped by
/// this stable connection id, never treated as process-global identities.
model OrganizationFeishuApplicationConnection {
id String @id @default(cuid())
organizationId String @unique
appIdentityFingerprint String @unique
status OrganizationConnectionStatus @default(DRAFT)
activeSecretVersionId String? @unique
activatedAt DateTime?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions")
activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
@@index([status])
}
/// ADR-0024: all Feishu app material, including provider-local app/bot ids, is
/// inside one immutable authenticated envelope version.
model FeishuApplicationCredentialVersion {
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion")
createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@index([keyId])
@@index([createdByUserId])
}
/// ADR-0024: model-provider connection identity is stable and belongs to one
+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 }
: {}),
});
}
@@ -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);
}
+45 -16
View File
@@ -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
View File
@@ -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,
+8 -2
View File
@@ -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);
@@ -0,0 +1,160 @@
import Fastify from "fastify";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mintSessionToken, sessionCookieHeader } from "../../src/admin/routes/authRoutes.js";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import { FeishuReadinessError } from "../../src/connections/feishuReadiness.js";
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
async function buildApp(readinessProbe: (input: {
readonly appId: string;
readonly appSecret: string;
readonly botOpenId: string;
}) => Promise<void>) {
const app = Fastify({ logger: false });
await registerAdminPlugin(app, {
prisma,
sessionSecret: SESSION_SECRET,
publicBaseUrl: "http://127.0.0.1:8788",
feishuAppId: "cli_legacy_auth_only",
feishuAppSecret: "legacy-auth-secret",
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
secretEnvelope: testSecretEnvelope,
feishuConnectionReadinessProbe: readinessProbe,
cookieSecure: false,
});
await app.ready();
return app;
}
async function seedUser(role: "OWNER" | "ADMIN" | "MEMBER"): Promise<string> {
const id = `feishu-${role.toLowerCase()}`;
const openId = `ou_feishu_${role.toLowerCase()}`;
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: id, role },
});
return mintSessionToken({ userId: id, feishuOpenId: openId }, SESSION_SECRET);
}
describe("Organization Feishu Application Connection API", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("accepts write-only credentials and returns only redacted metadata", async () => {
const token = await seedUser("OWNER");
const readiness = vi.fn(async () => {});
const app = await buildApp(readiness);
const credential = {
appId: "cli_customer",
appSecret: "api-secret-never-return",
botOpenId: "ou_customer_bot",
verificationToken: "verification-never-return",
encryptKey: "encrypt-key-never-return",
};
try {
const created = await app.inject({
method: "PUT",
url: "/api/org/test-default/feishu-application-connection",
headers: {
cookie: sessionCookieHeader(token),
"content-type": "application/json",
},
payload: credential,
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({
status: "ACTIVE",
activeVersion: 1,
keyId: "test-active",
});
for (const plaintext of Object.values(credential)) {
expect(created.body).not.toContain(plaintext);
}
expect(readiness).toHaveBeenCalledWith({
appId: credential.appId,
appSecret: credential.appSecret,
botOpenId: credential.botOpenId,
verificationToken: credential.verificationToken,
encryptKey: credential.encryptKey,
schemaVersion: 1,
});
const read = await app.inject({
method: "GET",
url: "/api/org/test-default/feishu-application-connection",
headers: { cookie: sessionCookieHeader(token) },
});
expect(read.statusCode).toBe(200);
expect(read.json()).toEqual({ connection: expect.objectContaining({ activeVersion: 1 }) });
for (const plaintext of Object.values(credential)) {
expect(read.body).not.toContain(plaintext);
}
const disabled = await app.inject({
method: "DELETE",
url: "/api/org/test-default/feishu-application-connection",
headers: { cookie: sessionCookieHeader(token) },
});
expect(disabled.statusCode).toBe(200);
expect(disabled.json()).toMatchObject({ status: "DISABLED", activeVersion: 1 });
for (const plaintext of Object.values(credential)) {
expect(disabled.body).not.toContain(plaintext);
}
} finally {
await app.close();
}
});
it("rejects members and does not persist credentials rejected by Feishu", async () => {
const memberToken = await seedUser("MEMBER");
const app = await buildApp(async () => {
throw new FeishuReadinessError(
"feishu_readiness_rejected",
"Feishu bot identity does not match the configured botOpenId",
"provider_rejection",
);
});
const payload = {
appId: "cli_rejected",
appSecret: "rejected-secret",
botOpenId: "ou_rejected",
};
try {
const denied = await app.inject({
method: "PUT",
url: "/api/org/test-default/feishu-application-connection",
headers: {
cookie: sessionCookieHeader(memberToken),
"content-type": "application/json",
},
payload,
});
expect(denied.statusCode).toBe(403);
const adminToken = await seedUser("ADMIN");
const rejected = await app.inject({
method: "PUT",
url: "/api/org/test-default/feishu-application-connection",
headers: {
cookie: sessionCookieHeader(adminToken),
"content-type": "application/json",
},
payload,
});
expect(rejected.statusCode).toBe(400);
expect(rejected.json()).toMatchObject({ error: { code: "feishu_credential_rejected" } });
await expect(prisma.organizationFeishuApplicationConnection.count()).resolves.toBe(0);
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(0);
} finally {
await app.close();
}
});
});
@@ -13,6 +13,7 @@ import {
TEST_SECRET_KEY_ID,
} from "./helpers.js";
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
@@ -115,9 +116,12 @@ describe("deployment preflight CLI", () => {
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("stored provider envelope verification failed");
expect(result.stderr).toContain("stored connection envelope verification failed");
expect(result.stderr).toContain("provider: secret envelope authentication failed");
expect(result.stderr).toContain("Feishu: secret envelope authentication failed");
expect(result.stderr).toContain("secret envelope authentication failed");
expect(result.stderr).not.toContain("stored-envelope-secret");
expect(result.stderr).not.toContain("stored-feishu-envelope-secret");
});
it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => {
@@ -282,6 +286,13 @@ describe("deployment preflight CLI", () => {
baseUrl: "https://provider.example/api",
authToken: "stored-envelope-secret",
});
await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}).rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "preflight-admin",
appId: "cli_preflight",
appSecret: "stored-feishu-envelope-secret",
botOpenId: "ou_preflight_bot",
});
}
});
@@ -0,0 +1,231 @@
import { createHash } from "node:crypto";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import {
FeishuApplicationConnectionService,
rewrapStoredFeishuApplicationEnvelopes,
resolveActiveFeishuApplication,
verifyStoredFeishuApplicationEnvelopes,
} from "../../src/connections/feishuApplicationConnections.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
describe("Organization Feishu Application Connections", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("stores write-only app credentials and resolves them only from the explicit Organization", async () => {
await seedAdmin("feishu-admin", DEFAULT_ORG_ID, "ADMIN");
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const credential = {
appId: "cli_shared_provider_local_id",
appSecret: "feishu-secret-never-return",
botOpenId: "ou_shared_bot",
verificationToken: "verification-never-return",
encryptKey: "encrypt-key-never-return",
};
const created = await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-admin",
...credential,
});
expect(created).toMatchObject({
created: true,
status: "ACTIVE",
activeVersion: 1,
keyId: "test-active",
});
const persisted = await prisma.$queryRaw<Array<{ envelope: unknown }>>`
SELECT "envelope" FROM "FeishuApplicationCredentialVersion"
`;
const serialized = JSON.stringify(persisted);
for (const plaintext of Object.values(credential)) expect(serialized).not.toContain(plaintext);
await expect(resolveActiveFeishuApplication(prisma, testSecretEnvelope, {
organizationId: DEFAULT_ORG_ID,
})).resolves.toMatchObject({
connectionId: created.id,
organizationId: DEFAULT_ORG_ID,
...credential,
});
});
it("allows colliding provider-local identifiers in two connection namespaces", async () => {
await seedTestOrganization("org_feishu_other", "feishu-other");
await seedAdmin("feishu-admin-a", DEFAULT_ORG_ID, "OWNER");
await seedAdmin("feishu-admin-b", "org_feishu_other", "ADMIN");
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const shared = {
appSecret: "same-secret-is-still-separately-encrypted",
botOpenId: "ou_same_bot",
};
const first = await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-admin-a",
appId: "cli_app_a",
...shared,
});
const second = await service.rotateCustomerApplication({
organizationId: "org_feishu_other",
actorUserId: "feishu-admin-b",
appId: "cli_app_b",
...shared,
});
expect(first.id).not.toBe(second.id);
const resolved = await Promise.all([
resolveActiveFeishuApplication(prisma, testSecretEnvelope, { connectionId: first.id }),
resolveActiveFeishuApplication(prisma, testSecretEnvelope, { connectionId: second.id }),
]);
expect(resolved.map((item) => item.organizationId)).toEqual([DEFAULT_ORG_ID, "org_feishu_other"]);
});
it("refuses to relabel a stable connection or share one app across Organizations", async () => {
await seedTestOrganization("org_feishu_other", "feishu-other");
await seedAdmin("feishu-owner-a", DEFAULT_ORG_ID, "OWNER");
await seedAdmin("feishu-owner-b", "org_feishu_other", "OWNER");
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner-a",
appId: "cli_stable",
appSecret: "secret-one",
botOpenId: "ou_bot",
});
await expect(service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner-a",
appId: "cli_replacement",
appSecret: "replacement-secret",
botOpenId: "ou_replacement_bot",
})).rejects.toThrow("cannot change app identity");
await expect(service.rotateCustomerApplication({
organizationId: "org_feishu_other",
actorUserId: "feishu-owner-b",
appId: "cli_stable",
appSecret: "secret-one",
botOpenId: "ou_bot",
})).rejects.toThrow("cannot be shared across Organizations");
await expect(prisma.organizationFeishuApplicationConnection.count()).resolves.toBe(1);
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(1);
});
it("rotates immutable versions and rejects a non-admin service caller", async () => {
await seedAdmin("feishu-owner", DEFAULT_ORG_ID, "OWNER");
await seedAdmin("feishu-member", DEFAULT_ORG_ID, "MEMBER");
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner",
appId: "cli_one",
appSecret: "secret-one",
botOpenId: "ou_bot",
});
const rotated = await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner",
appId: "cli_one",
appSecret: "secret-two",
botOpenId: "ou_bot",
});
expect(rotated).toMatchObject({ created: false, activeVersion: 2 });
await expect(prisma.feishuApplicationCredentialVersion.findMany({
orderBy: { version: "asc" },
select: { version: true, retiredAt: true },
})).resolves.toEqual([
{ version: 1, retiredAt: expect.any(Date) },
{ version: 2, retiredAt: null },
]);
await expect(service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-member",
appId: "cli_forbidden",
appSecret: "forbidden-secret",
botOpenId: "ou_bot",
})).rejects.toThrow("requires an active Organization OWNER or ADMIN");
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(2);
});
it("disables immediately through an authorized audited lifecycle write", async () => {
await seedAdmin("feishu-owner", DEFAULT_ORG_ID, "OWNER");
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const created = await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner",
appId: "cli_disable",
appSecret: "disable-secret",
botOpenId: "ou_bot",
});
await expect(service.disable({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner",
})).resolves.toMatchObject({ id: created.id, status: "DISABLED", activeVersion: 1 });
await expect(resolveActiveFeishuApplication(prisma, testSecretEnvelope, {
connectionId: created.id,
})).rejects.toThrow("active Feishu Application Connection not found");
await expect(prisma.auditEntry.count({
where: { organizationId: DEFAULT_ORG_ID, action: "feishu_application.disabled" },
})).resolves.toBe(1);
});
it("fails closed when the stored app fingerprint is detached from the encrypted app id", async () => {
await seedAdmin("feishu-owner", DEFAULT_ORG_ID, "OWNER");
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const created = await service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner",
appId: "cli_original",
appSecret: "original-secret",
botOpenId: "ou_bot",
});
const replacementFingerprint = createHash("sha256")
.update("cph-feishu-app\0")
.update("cli_replacement", "utf8")
.digest("hex");
await prisma.organizationFeishuApplicationConnection.update({
where: { id: created.id },
data: { appIdentityFingerprint: replacementFingerprint },
});
await expect(resolveActiveFeishuApplication(prisma, testSecretEnvelope, {
connectionId: created.id,
})).rejects.toThrow("app identity fingerprint mismatch");
await expect(verifyStoredFeishuApplicationEnvelopes(
prisma,
testSecretEnvelope,
)).rejects.toThrow("app identity fingerprint mismatch");
await expect(prisma.$transaction((tx) => rewrapStoredFeishuApplicationEnvelopes(
tx,
testSecretEnvelope,
))).rejects.toThrow("app identity fingerprint mismatch");
await expect(service.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "feishu-owner",
appId: "cli_replacement",
appSecret: "replacement-secret",
botOpenId: "ou_replacement_bot",
})).rejects.toThrow("app identity fingerprint mismatch");
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(1);
});
});
async function seedAdmin(
userId: string,
organizationId: string,
role: "OWNER" | "ADMIN" | "MEMBER",
): Promise<void> {
await prisma.user.create({
data: { id: userId, feishuOpenId: `legacy_${userId}`, displayName: userId },
});
await prisma.organizationMembership.create({
data: { organizationId, userId, role },
});
}
+2
View File
@@ -36,6 +36,8 @@ export const prisma = new PrismaClient({
export async function resetDb(): Promise<void> {
const tables = [
"FeishuEventReceipt",
"FeishuApplicationCredentialVersion",
"OrganizationFeishuApplicationConnection",
"ProviderCredentialVersion",
"OrganizationProviderConnection",
"AgentFileChange",
@@ -3,6 +3,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js";
import {
FeishuApplicationConnectionService,
verifyStoredFeishuApplicationEnvelopes,
} from "../../src/connections/feishuApplicationConnections.js";
import { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js";
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
@@ -36,10 +40,21 @@ describe("local KEK rotation", () => {
baseUrl: "https://provider.example/api",
authToken: "credential-stays-encrypted",
});
await new FeishuApplicationConnectionService(prisma, oldSecrets, async () => {}).rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "rotation-admin",
appId: "cli_rotation",
appSecret: "feishu-credential-stays-encrypted",
botOpenId: "ou_rotation_bot",
});
const before = await prisma.providerCredentialVersion.findFirstOrThrow({
include: { connection: true },
});
const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1;
const beforeFeishu = await prisma.feishuApplicationCredentialVersion.findFirstOrThrow({
include: { connection: true },
});
const beforeFeishuEnvelope = beforeFeishu.envelope as unknown as SecretEnvelopeV1;
const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-"));
temporaryDirectories.push(directory);
const keyringFile = join(directory, "keyring.json");
@@ -99,29 +114,47 @@ describe("local KEK rotation", () => {
expect(result).toMatchObject({
previousActiveKeyId: "local-old",
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
rewrapped: 1,
verified: 1,
rewrapped: 2,
verified: 2,
});
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
expect(after.keyId).toBe(result.activeKeyId);
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek);
const afterFeishu = await prisma.feishuApplicationCredentialVersion.findUniqueOrThrow({
where: { id: beforeFeishu.id },
});
const afterFeishuEnvelope = afterFeishu.envelope as unknown as SecretEnvelopeV1;
expect(afterFeishu.keyId).toBe(result.activeKeyId);
expect(afterFeishuEnvelope.payload).toEqual(beforeFeishuEnvelope.payload);
expect(afterFeishuEnvelope.wrappedDek).not.toEqual(beforeFeishuEnvelope.wrappedDek);
const keyringSource = await readFile(keyringFile, "utf8");
expect(keyringSource).toContain("local-old");
expect(keyringSource).toContain(result.activeKeyId);
expect(keyringSource).not.toContain("credential-stays-encrypted");
expect(keyringSource).not.toContain("feishu-credential-stays-encrypted");
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
await expect(verifyStoredFeishuApplicationEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
expect(() => oldSecrets.decryptJson({
purpose: "provider-connection",
organizationId: before.connection.organizationId,
connectionId: before.connection.id,
secretVersionId: before.id,
}, afterEnvelope)).toThrow("secret envelope key is unavailable");
expect(() => oldSecrets.decryptJson({
purpose: "feishu-application",
organizationId: beforeFeishu.connection.organizationId,
connectionId: beforeFeishu.connection.id,
secretVersionId: beforeFeishu.id,
}, afterFeishuEnvelope)).toThrow("secret envelope key is unavailable");
await expect(prisma.auditEntry.count({
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
})).resolves.toBe(1);
await expect(prisma.auditEntry.count({
where: { action: "feishu_application_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
})).resolves.toBe(1);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeishuReadinessError, probeFeishuApplication } from "../../src/connections/feishuReadiness.js";
describe("Feishu application readiness", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("authenticates the app and verifies the exact bot identity", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(Response.json({ code: 0, tenant_access_token: "tenant-secret" }))
.mockResolvedValueOnce(Response.json({ code: 0, bot: { open_id: "ou_expected" } }));
vi.stubGlobal("fetch", fetchMock);
await expect(probeFeishuApplication({
appId: "cli_expected",
appSecret: "app-secret",
botOpenId: "ou_expected",
})).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://open.feishu.cn/open-apis/bot/v3/info");
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
method: "GET",
redirect: "manual",
headers: { authorization: "Bearer tenant-secret" },
});
});
it("rejects a valid app credential paired with the wrong bot id", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(Response.json({ code: 0, tenant_access_token: "tenant-secret" }))
.mockResolvedValueOnce(Response.json({ code: 0, bot: { open_id: "ou_actual" } })));
const failure = probeFeishuApplication({
appId: "cli_expected",
appSecret: "app-secret",
botOpenId: "ou_wrong",
});
await expect(failure).rejects.toBeInstanceOf(FeishuReadinessError);
await expect(failure).rejects.toMatchObject({
code: "feishu_readiness_rejected",
category: "provider_rejection",
});
});
});