From 44557da499fbfcd0eb2f2e96bfda63c134568395 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 23:13:11 +0800 Subject: [PATCH] feat: secure organization Feishu credentials --- hub/deploy/README.md | 4 +- .../migration.sql | 59 +++ hub/prisma/schema.prisma | 46 ++ hub/src/admin/errors.ts | 26 + hub/src/admin/plugin.ts | 5 + .../feishuApplicationConnectionRoutes.ts | 136 +++++ hub/src/admin/routes/orgRoutes.ts | 11 + .../feishuApplicationConnections.ts | 499 ++++++++++++++++++ hub/src/connections/feishuReadiness.ts | 114 ++++ hub/src/deployment/preflight-cli.ts | 61 ++- hub/src/hub.ts | 7 +- hub/src/security/localKekRotation.ts | 10 +- ...min-feishu-application-connections.test.ts | 160 ++++++ .../deployment-preflight-cli.test.ts | 13 +- .../feishu-application-connections.test.ts | 231 ++++++++ hub/test/integration/helpers.ts | 2 + .../integration/local-kek-rotation.test.ts | 37 +- hub/test/unit/feishu-readiness.test.ts | 46 ++ 18 files changed, 1443 insertions(+), 24 deletions(-) create mode 100644 hub/prisma/migrations/20260710230000_org_feishu_application_secrets/migration.sql create mode 100644 hub/src/admin/routes/feishuApplicationConnectionRoutes.ts create mode 100644 hub/src/connections/feishuApplicationConnections.ts create mode 100644 hub/src/connections/feishuReadiness.ts create mode 100644 hub/test/integration/admin-feishu-application-connections.test.ts create mode 100644 hub/test/integration/feishu-application-connections.test.ts create mode 100644 hub/test/unit/feishu-readiness.test.ts diff --git a/hub/deploy/README.md b/hub/deploy/README.md index 941f25c..912d823 100644 --- a/hub/deploy/README.md +++ b/hub/deploy/README.md @@ -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 ' diff --git a/hub/prisma/migrations/20260710230000_org_feishu_application_secrets/migration.sql b/hub/prisma/migrations/20260710230000_org_feishu_application_secrets/migration.sql new file mode 100644 index 0000000..66c917c --- /dev/null +++ b/hub/prisma/migrations/20260710230000_org_feishu_application_secrets/migration.sql @@ -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; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 0dd1bd2..70db83a 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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 diff --git a/hub/src/admin/errors.ts b/hub/src/admin/errors.ts index 7e4854a..01ecf26 100644 --- a/hub/src/admin/errors.ts +++ b/hub/src/admin/errors.ts @@ -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 { @@ -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 }; } diff --git a/hub/src/admin/plugin.ts b/hub/src/admin/plugin.ts index e4b9c42..96e5d01 100644 --- a/hub/src/admin/plugin.ts +++ b/hub/src/admin/plugin.ts @@ -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 } + : {}), }); } diff --git a/hub/src/admin/routes/feishuApplicationConnectionRoutes.ts b/hub/src/admin/routes/feishuApplicationConnectionRoutes.ts new file mode 100644 index 0000000..e61ab2b --- /dev/null +++ b/hub/src/admin/routes/feishuApplicationConnectionRoutes.ts @@ -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 { + 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; + 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 } : {}), + }; +} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 1b841c1..8bec133 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -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 { @@ -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 } + : {}), + }); } diff --git a/hub/src/connections/feishuApplicationConnections.ts b/hub/src/connections/feishuApplicationConnections.ts new file mode 100644 index 0000000..4fd57ac --- /dev/null +++ b/hub/src/connections/feishuApplicationConnections.ts @@ -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 { + readonly connectionId: string; + readonly organizationId: string; +} + +interface FeishuSecretPayloadV1 extends Required { + 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 { + 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 { + 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 { + 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 { + 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({ + 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 { + 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, +): Promise { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/hub/src/connections/feishuReadiness.ts b/hub/src/connections/feishuReadiness.ts new file mode 100644 index 0000000..cd81020 --- /dev/null +++ b/hub/src/connections/feishuReadiness.ts @@ -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; + +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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/hub/src/deployment/preflight-cli.ts b/hub/src/deployment/preflight-cli.ts index 5886682..984fff4 100644 --- a/hub/src/deployment/preflight-cli.ts +++ b/hub/src/deployment/preflight-cli.ts @@ -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>` 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>` - 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 { + const rows = await client.$queryRaw>` + 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, diff --git a/hub/src/hub.ts b/hub/src/hub.ts index c9b03cb..814045b 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -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 { 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, diff --git a/hub/src/security/localKekRotation.ts b/hub/src/security/localKekRotation.ts index 5748de8..7b58abc 100644 --- a/hub/src/security/localKekRotation.ts +++ b/hub/src/security/localKekRotation.ts @@ -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); diff --git a/hub/test/integration/admin-feishu-application-connections.test.ts b/hub/test/integration/admin-feishu-application-connections.test.ts new file mode 100644 index 0000000..99b5e16 --- /dev/null +++ b/hub/test/integration/admin-feishu-application-connections.test.ts @@ -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) { + 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 { + 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(); + } + }); +}); diff --git a/hub/test/integration/deployment-preflight-cli.test.ts b/hub/test/integration/deployment-preflight-cli.test.ts index 9816949..1880c1d 100644 --- a/hub/test/integration/deployment-preflight-cli.test.ts +++ b/hub/test/integration/deployment-preflight-cli.test.ts @@ -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", + }); } }); diff --git a/hub/test/integration/feishu-application-connections.test.ts b/hub/test/integration/feishu-application-connections.test.ts new file mode 100644 index 0000000..a294024 --- /dev/null +++ b/hub/test/integration/feishu-application-connections.test.ts @@ -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>` + 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 { + await prisma.user.create({ + data: { id: userId, feishuOpenId: `legacy_${userId}`, displayName: userId }, + }); + await prisma.organizationMembership.create({ + data: { organizationId, userId, role }, + }); +} diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 63a48aa..291a594 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -36,6 +36,8 @@ export const prisma = new PrismaClient({ export async function resetDb(): Promise { const tables = [ "FeishuEventReceipt", + "FeishuApplicationCredentialVersion", + "OrganizationFeishuApplicationConnection", "ProviderCredentialVersion", "OrganizationProviderConnection", "AgentFileChange", diff --git a/hub/test/integration/local-kek-rotation.test.ts b/hub/test/integration/local-kek-rotation.test.ts index 95e3eed..17f5a83 100644 --- a/hub/test/integration/local-kek-rotation.test.ts +++ b/hub/test/integration/local-kek-rotation.test.ts @@ -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); }); }); diff --git a/hub/test/unit/feishu-readiness.test.ts b/hub/test/unit/feishu-readiness.test.ts new file mode 100644 index 0000000..dbdabe4 --- /dev/null +++ b/hub/test/unit/feishu-readiness.test.ts @@ -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", + }); + }); +});