forked from EduCraft/curriculum-project-hub
feat: secure organization Feishu credentials
This commit is contained in:
@@ -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
|
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
|
atomically adds a new active key while retaining every previous key, then
|
||||||
authenticates and rewraps every stored Provider DEK, writes redacted org audit
|
authenticates and rewraps every stored Provider and Feishu Application DEK,
|
||||||
events, and verifies the complete set again:
|
writes redacted org audit events, and verifies the complete set again:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo bash -c '
|
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;
|
||||||
@@ -42,6 +42,7 @@ model Organization {
|
|||||||
teams Team[]
|
teams Team[]
|
||||||
externalDirectoryConnections ExternalDirectoryConnection[]
|
externalDirectoryConnections ExternalDirectoryConnection[]
|
||||||
providerConnections OrganizationProviderConnection[]
|
providerConnections OrganizationProviderConnection[]
|
||||||
|
feishuApplicationConnection OrganizationFeishuApplicationConnection?
|
||||||
auditEntries AuditEntry[] @relation("organizationAudit")
|
auditEntries AuditEntry[] @relation("organizationAudit")
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -109,6 +110,51 @@ model User {
|
|||||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||||
auditEntries AuditEntry[] @relation("auditActor")
|
auditEntries AuditEntry[] @relation("auditActor")
|
||||||
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
|
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
|
/// ADR-0024: model-provider connection identity is stable and belongs to one
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Map domain / HTTP errors to consistent JSON responses.
|
* Map domain / HTTP errors to consistent JSON responses.
|
||||||
*/
|
*/
|
||||||
import type { FastifyReply } from "fastify";
|
import type { FastifyReply } from "fastify";
|
||||||
|
import { FeishuReadinessError } from "../connections/feishuReadiness.js";
|
||||||
|
import { ProviderReadinessError } from "../connections/providerReadiness.js";
|
||||||
import { HttpError, sendError } from "./auth/guards.js";
|
import { HttpError, sendError } from "./auth/guards.js";
|
||||||
|
|
||||||
export async function handleRouteError(reply: FastifyReply, err: unknown): Promise<void> {
|
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);
|
await sendError(reply, err.statusCode, err.code, err.message);
|
||||||
return;
|
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) {
|
if (err instanceof Error) {
|
||||||
const mapped = mapDomainError(err.message);
|
const mapped = mapDomainError(err.message);
|
||||||
if (mapped !== null) {
|
if (mapped !== null) {
|
||||||
@@ -29,6 +49,12 @@ function mapDomainError(message: string): { statusCode: number; code: string; me
|
|||||||
if (lower.includes("provider credential readiness check failed")) {
|
if (lower.includes("provider credential readiness check failed")) {
|
||||||
return { statusCode: 400, code: "provider_credential_rejected", message };
|
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")) {
|
if (lower.includes("not found")) {
|
||||||
return { statusCode: 404, code: "not_found", message };
|
return { statusCode: 404, code: "not_found", message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { registerAuthRoutes } from "./routes/authRoutes.js";
|
|||||||
import { registerOrgRoutes } from "./routes/orgRoutes.js";
|
import { registerOrgRoutes } from "./routes/orgRoutes.js";
|
||||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||||
import type { ProviderReadinessProbe } from "../connections/providerReadiness.js";
|
import type { ProviderReadinessProbe } from "../connections/providerReadiness.js";
|
||||||
|
import type { FeishuReadinessProbe } from "../connections/feishuReadiness.js";
|
||||||
|
|
||||||
export interface AdminPluginConfig {
|
export interface AdminPluginConfig {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -18,6 +19,7 @@ export interface AdminPluginConfig {
|
|||||||
readonly projectWorkspaceRoot: string;
|
readonly projectWorkspaceRoot: string;
|
||||||
readonly secretEnvelope: LocalSecretEnvelope;
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
||||||
|
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
|
||||||
readonly cookieSecure?: boolean;
|
readonly cookieSecure?: boolean;
|
||||||
readonly oauthScope?: string;
|
readonly oauthScope?: string;
|
||||||
readonly fetchImpl?: typeof fetch;
|
readonly fetchImpl?: typeof fetch;
|
||||||
@@ -51,5 +53,8 @@ export async function registerAdminPlugin(
|
|||||||
...(config.providerReadinessProbe !== undefined
|
...(config.providerReadinessProbe !== undefined
|
||||||
? { providerReadinessProbe: config.providerReadinessProbe }
|
? { providerReadinessProbe: config.providerReadinessProbe }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(config.feishuConnectionReadinessProbe !== undefined
|
||||||
|
? { feishuConnectionReadinessProbe: config.feishuConnectionReadinessProbe }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { FeishuApplicationConnectionService } from "../../connections/feishuApplicationConnections.js";
|
||||||
|
import { FeishuReadinessError, type FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||||
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||||
|
import { handleRouteError } from "../errors.js";
|
||||||
|
|
||||||
|
export interface FeishuApplicationConnectionRouteConfig {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
readonly sessionSecret: string;
|
||||||
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
|
readonly readinessProbe?: FeishuReadinessProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerFeishuApplicationConnectionRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: FeishuApplicationConnectionRouteConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||||
|
const connections = new FeishuApplicationConnectionService(
|
||||||
|
config.prisma,
|
||||||
|
config.secretEnvelope,
|
||||||
|
config.readinessProbe,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/api/org/:orgSlug/feishu-application-connection", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
return { connection: await connections.readMetadata(auth.organization.id) };
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({
|
||||||
|
requestId: request.id,
|
||||||
|
operation: "feishu_application_connection.read",
|
||||||
|
errorCode: "feishu_application_connection_read_failed",
|
||||||
|
}, "Feishu Application Connection read failed");
|
||||||
|
return handleRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/org/:orgSlug/feishu-application-connection", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = parseBody(request.body);
|
||||||
|
const result = await connections.rotateCustomerApplication({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
actorUserId: auth.user.id,
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
request.log.info({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
connectionId: result.id,
|
||||||
|
status: result.status,
|
||||||
|
secretVersion: result.activeVersion,
|
||||||
|
keyId: result.keyId,
|
||||||
|
}, result.created ? "Feishu Application Connection created" : "Feishu Application Connection rotated");
|
||||||
|
const { created, ...metadata } = result;
|
||||||
|
return reply.status(created ? 201 : 200).send(metadata);
|
||||||
|
} catch (error) {
|
||||||
|
const facts = error instanceof FeishuReadinessError
|
||||||
|
? {
|
||||||
|
errorCode: error.code,
|
||||||
|
failureCategory: error.category,
|
||||||
|
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
|
||||||
|
}
|
||||||
|
: { errorCode: "feishu_application_connection_write_failed" };
|
||||||
|
request.log.error({
|
||||||
|
requestId: request.id,
|
||||||
|
operation: "feishu_application_connection.rotate",
|
||||||
|
...facts,
|
||||||
|
}, "Feishu Application Connection mutation failed");
|
||||||
|
return handleRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/org/:orgSlug/feishu-application-connection", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const result = await connections.disable({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
actorUserId: auth.user.id,
|
||||||
|
});
|
||||||
|
request.log.info({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
connectionId: result.id,
|
||||||
|
status: result.status,
|
||||||
|
secretVersion: result.activeVersion,
|
||||||
|
keyId: result.keyId,
|
||||||
|
}, "Feishu Application Connection disabled");
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({
|
||||||
|
requestId: request.id,
|
||||||
|
operation: "feishu_application_connection.disable",
|
||||||
|
errorCode: "feishu_application_connection_disable_failed",
|
||||||
|
}, "Feishu Application Connection disable failed");
|
||||||
|
return handleRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(value: unknown): {
|
||||||
|
readonly appId: string;
|
||||||
|
readonly appSecret: string;
|
||||||
|
readonly botOpenId: string;
|
||||||
|
readonly verificationToken?: string;
|
||||||
|
readonly encryptKey?: string;
|
||||||
|
} {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error("invalid Feishu Application credential body");
|
||||||
|
}
|
||||||
|
const body = value as Record<string, unknown>;
|
||||||
|
for (const name of ["appId", "appSecret", "botOpenId"] as const) {
|
||||||
|
if (typeof body[name] !== "string") throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
for (const name of ["verificationToken", "encryptKey"] as const) {
|
||||||
|
if (body[name] !== undefined && typeof body[name] !== "string") {
|
||||||
|
throw new Error(`${name} must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appId: body["appId"] as string,
|
||||||
|
appSecret: body["appSecret"] as string,
|
||||||
|
botOpenId: body["botOpenId"] as string,
|
||||||
|
...(body["verificationToken"] !== undefined
|
||||||
|
? { verificationToken: body["verificationToken"] as string }
|
||||||
|
: {}),
|
||||||
|
...(body["encryptKey"] !== undefined ? { encryptKey: body["encryptKey"] as string } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { registerTeamsRoutes } from "./teamsRoutes.js";
|
|||||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||||
|
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||||
|
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
|
||||||
|
|
||||||
export interface OrgRouteConfig {
|
export interface OrgRouteConfig {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -24,6 +26,7 @@ export interface OrgRouteConfig {
|
|||||||
readonly projectWorkspaceRoot: string;
|
readonly projectWorkspaceRoot: string;
|
||||||
readonly secretEnvelope: LocalSecretEnvelope;
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
||||||
|
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
|
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 }
|
? { providerReadinessProbe: config.providerReadinessProbe }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
await registerFeishuApplicationConnectionRoutes(app, {
|
||||||
|
prisma: config.prisma,
|
||||||
|
sessionSecret: config.sessionSecret,
|
||||||
|
secretEnvelope: config.secretEnvelope,
|
||||||
|
...(config.feishuConnectionReadinessProbe !== undefined
|
||||||
|
? { readinessProbe: config.feishuConnectionReadinessProbe }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
import { lockActiveOrganization } from "../org/status.js";
|
||||||
|
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
|
||||||
|
import { probeFeishuApplication, type FeishuReadinessProbe } from "./feishuReadiness.js";
|
||||||
|
|
||||||
|
export interface FeishuApplicationCredentialInput {
|
||||||
|
readonly appId: string;
|
||||||
|
readonly appSecret: string;
|
||||||
|
readonly botOpenId: string;
|
||||||
|
readonly verificationToken?: string;
|
||||||
|
readonly encryptKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotateFeishuApplicationInput extends FeishuApplicationCredentialInput {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly actorUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeishuApplicationConnectionMetadata {
|
||||||
|
readonly id: string;
|
||||||
|
readonly appFingerprint: string;
|
||||||
|
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||||
|
readonly activeVersion: number | null;
|
||||||
|
readonly keyId: string | null;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedFeishuApplication extends Required<FeishuApplicationCredentialInput> {
|
||||||
|
readonly connectionId: string;
|
||||||
|
readonly organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
|
||||||
|
readonly schemaVersion: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeishuApplicationConnectionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly secrets: LocalSecretEnvelope,
|
||||||
|
private readonly readinessProbe: FeishuReadinessProbe = probeFeishuApplication,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async rotateCustomerApplication(
|
||||||
|
input: RotateFeishuApplicationInput,
|
||||||
|
): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> {
|
||||||
|
const payload = validateCredential(input);
|
||||||
|
const appIdentityFingerprint = fingerprintAppId(payload.appId);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await requireFeishuAdmin(tx, input);
|
||||||
|
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||||
|
});
|
||||||
|
await this.readinessProbe(payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
await requireFeishuAdmin(tx, input);
|
||||||
|
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||||
|
const connection = await tx.organizationFeishuApplicationConnection.upsert({
|
||||||
|
where: { organizationId: input.organizationId },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: randomUUID(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
appIdentityFingerprint,
|
||||||
|
status: "DRAFT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.$queryRaw`
|
||||||
|
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||||
|
WHERE "id" = ${connection.id} FOR UPDATE
|
||||||
|
`;
|
||||||
|
const locked = await tx.organizationFeishuApplicationConnection.findUniqueOrThrow({
|
||||||
|
where: { id: connection.id },
|
||||||
|
include: {
|
||||||
|
activeSecretVersion: true,
|
||||||
|
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (locked.organizationId !== input.organizationId) {
|
||||||
|
throw new Error("Feishu Application Connection scope changed during rotation");
|
||||||
|
}
|
||||||
|
if (locked.appIdentityFingerprint !== appIdentityFingerprint) {
|
||||||
|
throw new Error("Feishu Application Connection cannot change app identity");
|
||||||
|
}
|
||||||
|
if (locked.activeSecretVersion !== null) {
|
||||||
|
if (locked.activeSecretVersion.connectionId !== locked.id) {
|
||||||
|
throw new Error("Feishu Application Connection active version scope mismatch");
|
||||||
|
}
|
||||||
|
decryptFeishuCredential(this.secrets, {
|
||||||
|
organizationId: locked.organizationId,
|
||||||
|
connectionId: locked.id,
|
||||||
|
appIdentityFingerprint: locked.appIdentityFingerprint,
|
||||||
|
secretVersionId: locked.activeSecretVersion.id,
|
||||||
|
envelopeVersion: locked.activeSecretVersion.envelopeVersion,
|
||||||
|
keyId: locked.activeSecretVersion.keyId,
|
||||||
|
envelope: locked.activeSecretVersion.envelope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
||||||
|
const secretVersionId = randomUUID();
|
||||||
|
const envelope = this.secrets.encryptJson({
|
||||||
|
purpose: "feishu-application",
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
connectionId: locked.id,
|
||||||
|
secretVersionId,
|
||||||
|
}, payload);
|
||||||
|
const now = new Date();
|
||||||
|
const secretVersion = await tx.feishuApplicationCredentialVersion.create({
|
||||||
|
data: {
|
||||||
|
id: secretVersionId,
|
||||||
|
connectionId: locked.id,
|
||||||
|
version,
|
||||||
|
envelopeVersion: envelope.version,
|
||||||
|
keyId: envelope.keyId,
|
||||||
|
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||||
|
createdByUserId: input.actorUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (locked.activeSecretVersion !== null) {
|
||||||
|
await tx.feishuApplicationCredentialVersion.update({
|
||||||
|
where: { id: locked.activeSecretVersion.id },
|
||||||
|
data: { retiredAt: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const activated = await tx.organizationFeishuApplicationConnection.update({
|
||||||
|
where: { id: locked.id },
|
||||||
|
data: {
|
||||||
|
status: "ACTIVE",
|
||||||
|
activeSecretVersionId: secretVersion.id,
|
||||||
|
activatedAt: now,
|
||||||
|
disabledAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const action = version === 1
|
||||||
|
? "feishu_application.created"
|
||||||
|
: locked.status === "DISABLED"
|
||||||
|
? "feishu_application.reactivated"
|
||||||
|
: "feishu_application.rotated";
|
||||||
|
await tx.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
action,
|
||||||
|
metadata: {
|
||||||
|
connectionId: locked.id,
|
||||||
|
appFingerprint: redactedFingerprint(appIdentityFingerprint),
|
||||||
|
status: "ACTIVE",
|
||||||
|
secretVersion: version,
|
||||||
|
envelopeVersion: envelope.version,
|
||||||
|
keyId: envelope.keyId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...metadata(activated, { version, keyId: secretVersion.keyId }),
|
||||||
|
created: version === 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAppFingerprintUniqueViolation(error)) {
|
||||||
|
throw new Error("Feishu Application cannot be shared across Organizations", { cause: error });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readMetadata(organizationId: string): Promise<FeishuApplicationConnectionMetadata | null> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await lockActiveOrganization(tx, organizationId);
|
||||||
|
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||||
|
where: { organizationId },
|
||||||
|
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||||
|
});
|
||||||
|
return connection === null ? null : metadata(connection, connection.activeSecretVersion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async disable(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly actorUserId: string;
|
||||||
|
}): Promise<FeishuApplicationConnectionMetadata> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await requireFeishuAdmin(tx, input);
|
||||||
|
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||||
|
where: { organizationId: input.organizationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (connection === null) throw new Error("Feishu Application Connection not found");
|
||||||
|
await tx.$queryRaw`
|
||||||
|
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||||
|
WHERE "id" = ${connection.id} FOR UPDATE
|
||||||
|
`;
|
||||||
|
const locked = await tx.organizationFeishuApplicationConnection.findUniqueOrThrow({
|
||||||
|
where: { id: connection.id },
|
||||||
|
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||||
|
});
|
||||||
|
if (locked.status === "DISABLED") return metadata(locked, locked.activeSecretVersion);
|
||||||
|
const disabled = await tx.organizationFeishuApplicationConnection.update({
|
||||||
|
where: { id: locked.id },
|
||||||
|
data: { status: "DISABLED", disabledAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
actorUserId: input.actorUserId,
|
||||||
|
action: "feishu_application.disabled",
|
||||||
|
metadata: {
|
||||||
|
connectionId: locked.id,
|
||||||
|
appFingerprint: redactedFingerprint(locked.appIdentityFingerprint),
|
||||||
|
previousStatus: locked.status,
|
||||||
|
status: "DISABLED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return metadata(disabled, locked.activeSecretVersion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveActiveFeishuApplication(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
secrets: LocalSecretEnvelope,
|
||||||
|
scope: { readonly organizationId: string } | { readonly connectionId: string },
|
||||||
|
): Promise<ResolvedFeishuApplication> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const connection = "organizationId" in scope
|
||||||
|
? await tx.organizationFeishuApplicationConnection.findUnique({
|
||||||
|
where: { organizationId: scope.organizationId },
|
||||||
|
include: { activeSecretVersion: true },
|
||||||
|
})
|
||||||
|
: await tx.organizationFeishuApplicationConnection.findUnique({
|
||||||
|
where: { id: scope.connectionId },
|
||||||
|
include: { activeSecretVersion: true },
|
||||||
|
});
|
||||||
|
if (connection === null) throw new Error("active Feishu Application Connection not found");
|
||||||
|
await lockActiveOrganization(tx, connection.organizationId);
|
||||||
|
const version = connection.activeSecretVersion;
|
||||||
|
if (connection.status !== "ACTIVE" || version === null || version.retiredAt !== null ||
|
||||||
|
version.connectionId !== connection.id) {
|
||||||
|
throw new Error("active Feishu Application Connection not found");
|
||||||
|
}
|
||||||
|
const payload = decryptFeishuCredential(secrets, {
|
||||||
|
organizationId: connection.organizationId,
|
||||||
|
connectionId: connection.id,
|
||||||
|
appIdentityFingerprint: connection.appIdentityFingerprint,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
envelopeVersion: version.envelopeVersion,
|
||||||
|
keyId: version.keyId,
|
||||||
|
envelope: version.envelope,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
connectionId: connection.id,
|
||||||
|
organizationId: connection.organizationId,
|
||||||
|
appId: payload.appId,
|
||||||
|
appSecret: payload.appSecret,
|
||||||
|
botOpenId: payload.botOpenId,
|
||||||
|
verificationToken: payload.verificationToken,
|
||||||
|
encryptKey: payload.encryptKey,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptFeishuCredential(
|
||||||
|
secrets: LocalSecretEnvelope,
|
||||||
|
input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly connectionId: string;
|
||||||
|
readonly appIdentityFingerprint: string;
|
||||||
|
readonly secretVersionId: string;
|
||||||
|
readonly envelopeVersion: number;
|
||||||
|
readonly keyId: string;
|
||||||
|
readonly envelope: Prisma.JsonValue;
|
||||||
|
},
|
||||||
|
): FeishuSecretPayloadV1 {
|
||||||
|
if (input.envelopeVersion !== 1) throw new Error("unsupported Feishu secret envelope version");
|
||||||
|
const envelope = input.envelope as unknown as SecretEnvelopeV1;
|
||||||
|
if (envelope.version !== input.envelopeVersion || envelope.keyId !== input.keyId) {
|
||||||
|
throw new Error("Feishu secret envelope metadata mismatch");
|
||||||
|
}
|
||||||
|
const payload = secrets.decryptJson<unknown>({
|
||||||
|
purpose: "feishu-application",
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
connectionId: input.connectionId,
|
||||||
|
secretVersionId: input.secretVersionId,
|
||||||
|
}, envelope);
|
||||||
|
const validated = validateStoredPayload(payload);
|
||||||
|
if (fingerprintAppId(validated.appId) !== input.appIdentityFingerprint) {
|
||||||
|
throw new Error("Feishu Application app identity fingerprint mismatch");
|
||||||
|
}
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyStoredFeishuApplicationEnvelopes(
|
||||||
|
prisma: PrismaClient | Prisma.TransactionClient,
|
||||||
|
secrets: LocalSecretEnvelope,
|
||||||
|
): Promise<number> {
|
||||||
|
const versions = await prisma.feishuApplicationCredentialVersion.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
envelopeVersion: true,
|
||||||
|
keyId: true,
|
||||||
|
envelope: true,
|
||||||
|
connection: { select: { id: true, organizationId: true, appIdentityFingerprint: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const version of versions) {
|
||||||
|
decryptFeishuCredential(secrets, {
|
||||||
|
organizationId: version.connection.organizationId,
|
||||||
|
connectionId: version.connection.id,
|
||||||
|
appIdentityFingerprint: version.connection.appIdentityFingerprint,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
envelopeVersion: version.envelopeVersion,
|
||||||
|
keyId: version.keyId,
|
||||||
|
envelope: version.envelope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return versions.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rewrapStoredFeishuApplicationEnvelopes(
|
||||||
|
prisma: Prisma.TransactionClient,
|
||||||
|
secrets: LocalSecretEnvelope,
|
||||||
|
): Promise<{ readonly verified: number; readonly rewrapped: number }> {
|
||||||
|
const versions = await prisma.feishuApplicationCredentialVersion.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
envelopeVersion: true,
|
||||||
|
keyId: true,
|
||||||
|
envelope: true,
|
||||||
|
connection: { select: { id: true, organizationId: true, appIdentityFingerprint: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
let rewrapped = 0;
|
||||||
|
for (const version of versions) {
|
||||||
|
const binding = {
|
||||||
|
purpose: "feishu-application",
|
||||||
|
organizationId: version.connection.organizationId,
|
||||||
|
connectionId: version.connection.id,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
} as const;
|
||||||
|
decryptFeishuCredential(secrets, {
|
||||||
|
organizationId: version.connection.organizationId,
|
||||||
|
connectionId: version.connection.id,
|
||||||
|
appIdentityFingerprint: version.connection.appIdentityFingerprint,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
envelopeVersion: version.envelopeVersion,
|
||||||
|
keyId: version.keyId,
|
||||||
|
envelope: version.envelope,
|
||||||
|
});
|
||||||
|
if (version.keyId === secrets.activeKeyId) continue;
|
||||||
|
const nextEnvelope = secrets.rewrap(binding, version.envelope as unknown as SecretEnvelopeV1);
|
||||||
|
const updated = await prisma.feishuApplicationCredentialVersion.updateMany({
|
||||||
|
where: { id: version.id, keyId: version.keyId },
|
||||||
|
data: {
|
||||||
|
keyId: nextEnvelope.keyId,
|
||||||
|
envelope: nextEnvelope as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (updated.count !== 1) {
|
||||||
|
throw new Error(`Feishu secret envelope changed during KEK rotation: ${version.id}`);
|
||||||
|
}
|
||||||
|
await prisma.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: version.connection.organizationId,
|
||||||
|
action: "feishu_application_secret.kek_rewrapped",
|
||||||
|
metadata: {
|
||||||
|
connectionId: version.connection.id,
|
||||||
|
secretVersionId: version.id,
|
||||||
|
previousKeyId: version.keyId,
|
||||||
|
keyId: nextEnvelope.keyId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
rewrapped += 1;
|
||||||
|
}
|
||||||
|
const verified = await verifyStoredFeishuApplicationEnvelopes(prisma, secrets);
|
||||||
|
const remaining = await prisma.feishuApplicationCredentialVersion.count({
|
||||||
|
where: { keyId: { not: secrets.activeKeyId } },
|
||||||
|
});
|
||||||
|
if (remaining !== 0) {
|
||||||
|
throw new Error(`KEK rotation left ${remaining} Feishu envelope(s) on a non-active key`);
|
||||||
|
}
|
||||||
|
return { verified, rewrapped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
|
||||||
|
const appId = nonEmpty(input.appId, "Feishu appId");
|
||||||
|
const appSecret = nonEmpty(input.appSecret, "Feishu appSecret");
|
||||||
|
const botOpenId = nonEmpty(input.botOpenId, "Feishu botOpenId");
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
botOpenId,
|
||||||
|
verificationToken: input.verificationToken?.trim() ?? "",
|
||||||
|
encryptKey: input.encryptKey?.trim() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStoredPayload(value: unknown): FeishuSecretPayloadV1 {
|
||||||
|
if (!isRecord(value) || value["schemaVersion"] !== 1 ||
|
||||||
|
typeof value["appId"] !== "string" || value["appId"] === "" ||
|
||||||
|
typeof value["appSecret"] !== "string" || value["appSecret"] === "" ||
|
||||||
|
typeof value["botOpenId"] !== "string" || value["botOpenId"] === "" ||
|
||||||
|
typeof value["verificationToken"] !== "string" || typeof value["encryptKey"] !== "string") {
|
||||||
|
throw new Error("invalid encrypted Feishu Application credential payload");
|
||||||
|
}
|
||||||
|
return value as unknown as FeishuSecretPayloadV1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireFeishuAdmin(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: Pick<RotateFeishuApplicationInput, "organizationId" | "actorUserId">,
|
||||||
|
): Promise<void> {
|
||||||
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
|
const actor = await tx.organizationMembership.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
userId: input.actorUserId,
|
||||||
|
role: { in: ["OWNER", "ADMIN"] },
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (actor === null) {
|
||||||
|
throw new Error("Feishu Application Connection write requires an active Organization OWNER or ADMIN");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAppIdentityAvailable(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
organizationId: string,
|
||||||
|
appIdentityFingerprint: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const organizationConnection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||||
|
where: { organizationId },
|
||||||
|
select: { appIdentityFingerprint: true },
|
||||||
|
});
|
||||||
|
if (organizationConnection !== null &&
|
||||||
|
organizationConnection.appIdentityFingerprint !== appIdentityFingerprint) {
|
||||||
|
throw new Error("Feishu Application Connection cannot change app identity");
|
||||||
|
}
|
||||||
|
const appConnection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||||
|
where: { appIdentityFingerprint },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
if (appConnection !== null && appConnection.organizationId !== organizationId) {
|
||||||
|
throw new Error("Feishu Application cannot be shared across Organizations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadata(
|
||||||
|
connection: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly appIdentityFingerprint: string;
|
||||||
|
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly updatedAt: Date;
|
||||||
|
},
|
||||||
|
secret: { readonly version: number; readonly keyId: string } | null,
|
||||||
|
): FeishuApplicationConnectionMetadata {
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
appFingerprint: redactedFingerprint(connection.appIdentityFingerprint),
|
||||||
|
status: connection.status,
|
||||||
|
activeVersion: secret?.version ?? null,
|
||||||
|
keyId: secret?.keyId ?? null,
|
||||||
|
createdAt: connection.createdAt,
|
||||||
|
updatedAt: connection.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprintAppId(appId: string): string {
|
||||||
|
return createHash("sha256").update("cph-feishu-app\0").update(appId, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactedFingerprint(fingerprint: string): string {
|
||||||
|
return `sha256:${fingerprint.slice(0, 16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAppFingerprintUniqueViolation(error: unknown): boolean {
|
||||||
|
if (!isRecord(error) || error["code"] !== "P2002" || !isRecord(error["meta"])) return false;
|
||||||
|
const target = error["meta"]["target"];
|
||||||
|
return Array.isArray(target) && target.includes("appIdentityFingerprint");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmpty(value: string, label: string): string {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized === "") throw new Error(`${label} is required`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||||
|
|
||||||
|
export interface FeishuReadinessInput {
|
||||||
|
readonly appId: string;
|
||||||
|
readonly appSecret: string;
|
||||||
|
readonly botOpenId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeishuReadinessProbe = (input: FeishuReadinessInput) => Promise<void>;
|
||||||
|
|
||||||
|
export class FeishuReadinessError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: "feishu_readiness_unreachable" | "feishu_readiness_rejected",
|
||||||
|
message: string,
|
||||||
|
readonly category: NetworkFailureCategory | "http" | "provider_rejection",
|
||||||
|
readonly upstreamStatus?: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FeishuReadinessError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Obtain a tenant token, prove the configured bot identity, then discard it. */
|
||||||
|
export async function probeFeishuApplication(input: FeishuReadinessInput): Promise<void> {
|
||||||
|
const response = await feishuFetch(
|
||||||
|
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify({ app_id: input.appId, app_secret: input.appSecret }),
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_rejected",
|
||||||
|
`Feishu application readiness check failed: status ${response.status}`,
|
||||||
|
"http",
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_rejected",
|
||||||
|
"Feishu application readiness check returned an invalid response",
|
||||||
|
"provider_rejection",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecord(payload) || payload["code"] !== 0 ||
|
||||||
|
typeof payload["tenant_access_token"] !== "string" || payload["tenant_access_token"] === "") {
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_rejected",
|
||||||
|
"Feishu application credentials were rejected",
|
||||||
|
"provider_rejection",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const botResponse = await feishuFetch("https://open.feishu.cn/open-apis/bot/v3/info", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
authorization: `Bearer ${payload["tenant_access_token"]}`,
|
||||||
|
},
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!botResponse.ok) {
|
||||||
|
await botResponse.body?.cancel();
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_rejected",
|
||||||
|
`Feishu bot readiness check failed: status ${botResponse.status}`,
|
||||||
|
"http",
|
||||||
|
botResponse.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let botPayload: unknown;
|
||||||
|
try {
|
||||||
|
botPayload = await botResponse.json();
|
||||||
|
} catch {
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_rejected",
|
||||||
|
"Feishu bot readiness check returned an invalid response",
|
||||||
|
"provider_rejection",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecord(botPayload) || botPayload["code"] !== 0 ||
|
||||||
|
!isRecord(botPayload["bot"]) || botPayload["bot"]["open_id"] !== input.botOpenId) {
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_rejected",
|
||||||
|
"Feishu bot identity does not match the configured botOpenId",
|
||||||
|
"provider_rejection",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function feishuFetch(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetch(url, init);
|
||||||
|
} catch (error) {
|
||||||
|
throw new FeishuReadinessError(
|
||||||
|
"feishu_readiness_unreachable",
|
||||||
|
"Feishu application readiness check could not reach provider",
|
||||||
|
classifyNetworkFailure(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { PrismaClient } from "@prisma/client";
|
|||||||
import { parse } from "dotenv";
|
import { parse } from "dotenv";
|
||||||
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
|
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
|
||||||
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||||
|
import { verifyStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
||||||
import {
|
import {
|
||||||
DeploymentPreflightError,
|
DeploymentPreflightError,
|
||||||
validateDeploymentPreflight,
|
validateDeploymentPreflight,
|
||||||
@@ -218,32 +219,41 @@ async function validatePostgresQuery(
|
|||||||
log: [],
|
log: [],
|
||||||
});
|
});
|
||||||
let queryFailure: unknown;
|
let queryFailure: unknown;
|
||||||
let envelopeFailure: unknown;
|
const envelopeFailures: Array<{ readonly kind: "provider" | "Feishu"; readonly error: unknown }> = [];
|
||||||
try {
|
try {
|
||||||
await client.$queryRawUnsafe("SELECT 1");
|
await client.$queryRawUnsafe("SELECT 1");
|
||||||
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
|
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
|
||||||
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
|
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
|
||||||
`;
|
`;
|
||||||
if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) {
|
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 {
|
} else {
|
||||||
const migration = await client.$queryRaw<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
|
const providerApplied = await migrationApplied(
|
||||||
SELECT "finished_at", "rolled_back_at"
|
client,
|
||||||
FROM "_prisma_migrations"
|
"20260710220000_org_provider_secret_envelopes",
|
||||||
WHERE "migration_name" = '20260710220000_org_provider_secret_envelopes'
|
);
|
||||||
ORDER BY "started_at" DESC
|
if (!providerApplied) {
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const applied = migration[0]?.finished_at !== null && migration[0]?.finished_at !== undefined &&
|
|
||||||
migration[0]?.rolled_back_at === null;
|
|
||||||
if (!applied) {
|
|
||||||
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
|
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
|
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
|
||||||
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
|
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
|
||||||
} catch (error) {
|
} 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 },
|
{ 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(
|
throw new Error(
|
||||||
`stored provider envelope verification failed (${formatFailure(envelopeFailure)})`,
|
`stored connection envelope verification failed (${details})`,
|
||||||
{ cause: envelopeFailure },
|
{ cause },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log("[preflight] PostgreSQL authenticated query succeeded");
|
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(
|
async function validateProvisionedDirectories(
|
||||||
input: DeploymentPreflightInput,
|
input: DeploymentPreflightInput,
|
||||||
serviceUid: number,
|
serviceUid: number,
|
||||||
|
|||||||
+6
-1
@@ -9,6 +9,7 @@ import { LocalSecretEnvelope, loadLocalSecretKeyring } from "./security/secretEn
|
|||||||
import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
|
import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
|
||||||
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
|
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
|
||||||
import { openProviderProxyLease } from "./connections/providerProxy.js";
|
import { openProviderProxyLease } from "./connections/providerProxy.js";
|
||||||
|
import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuApplicationConnections.js";
|
||||||
import { readServerBinding } from "./settings/server.js";
|
import { readServerBinding } from "./settings/server.js";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
@@ -36,7 +37,11 @@ export async function startHub(): Promise<void> {
|
|||||||
|
|
||||||
const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
||||||
const verifiedProviderEnvelopes = await verifyStoredProviderEnvelopes(prisma, secretEnvelope);
|
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(
|
const runtimeSettings = createDatabaseRuntimeSettings(
|
||||||
prisma,
|
prisma,
|
||||||
secretEnvelope,
|
secretEnvelope,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { lstat, open, rename, unlink, type FileHandle } from "node:fs/promises";
|
|||||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||||
|
import { rewrapStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
||||||
import {
|
import {
|
||||||
LocalSecretEnvelope,
|
LocalSecretEnvelope,
|
||||||
loadLocalSecretKeyringFile,
|
loadLocalSecretKeyringFile,
|
||||||
@@ -76,14 +77,19 @@ async function rotateUnderLock(
|
|||||||
const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys };
|
const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys };
|
||||||
try {
|
try {
|
||||||
await replaceKeyringAtomically(input.keyringFile, keyring, metadata.uid, metadata.gid);
|
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,
|
tx,
|
||||||
new LocalSecretEnvelope(keyring),
|
new LocalSecretEnvelope(keyring),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
previousActiveKeyId: previous.activeKeyId,
|
previousActiveKeyId: previous.activeKeyId,
|
||||||
activeKeyId: keyId,
|
activeKeyId: keyId,
|
||||||
...result,
|
verified: providerResult.verified + feishuResult.verified,
|
||||||
|
rewrapped: providerResult.rewrapped + feishuResult.rewrapped,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
generatedKey.fill(0);
|
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,
|
TEST_SECRET_KEY_ID,
|
||||||
} from "./helpers.js";
|
} from "./helpers.js";
|
||||||
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
||||||
|
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
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);
|
const result = await runCli(fixture.args);
|
||||||
|
|
||||||
expect(result.exitCode).toBe(1);
|
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).toContain("secret envelope authentication failed");
|
||||||
expect(result.stderr).not.toContain("stored-envelope-secret");
|
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 () => {
|
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",
|
baseUrl: "https://provider.example/api",
|
||||||
authToken: "stored-envelope-secret",
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ export const prisma = new PrismaClient({
|
|||||||
export async function resetDb(): Promise<void> {
|
export async function resetDb(): Promise<void> {
|
||||||
const tables = [
|
const tables = [
|
||||||
"FeishuEventReceipt",
|
"FeishuEventReceipt",
|
||||||
|
"FeishuApplicationCredentialVersion",
|
||||||
|
"OrganizationFeishuApplicationConnection",
|
||||||
"ProviderCredentialVersion",
|
"ProviderCredentialVersion",
|
||||||
"OrganizationProviderConnection",
|
"OrganizationProviderConnection",
|
||||||
"AgentFileChange",
|
"AgentFileChange",
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js";
|
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 { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js";
|
||||||
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
|
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
|
||||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||||
@@ -36,10 +40,21 @@ describe("local KEK rotation", () => {
|
|||||||
baseUrl: "https://provider.example/api",
|
baseUrl: "https://provider.example/api",
|
||||||
authToken: "credential-stays-encrypted",
|
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({
|
const before = await prisma.providerCredentialVersion.findFirstOrThrow({
|
||||||
include: { connection: true },
|
include: { connection: true },
|
||||||
});
|
});
|
||||||
const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1;
|
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-"));
|
const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-"));
|
||||||
temporaryDirectories.push(directory);
|
temporaryDirectories.push(directory);
|
||||||
const keyringFile = join(directory, "keyring.json");
|
const keyringFile = join(directory, "keyring.json");
|
||||||
@@ -99,29 +114,47 @@ describe("local KEK rotation", () => {
|
|||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
previousActiveKeyId: "local-old",
|
previousActiveKeyId: "local-old",
|
||||||
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
|
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
|
||||||
rewrapped: 1,
|
rewrapped: 2,
|
||||||
verified: 1,
|
verified: 2,
|
||||||
});
|
});
|
||||||
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
|
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
|
||||||
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
|
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
|
||||||
expect(after.keyId).toBe(result.activeKeyId);
|
expect(after.keyId).toBe(result.activeKeyId);
|
||||||
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
|
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
|
||||||
expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek);
|
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");
|
const keyringSource = await readFile(keyringFile, "utf8");
|
||||||
expect(keyringSource).toContain("local-old");
|
expect(keyringSource).toContain("local-old");
|
||||||
expect(keyringSource).toContain(result.activeKeyId);
|
expect(keyringSource).toContain(result.activeKeyId);
|
||||||
expect(keyringSource).not.toContain("credential-stays-encrypted");
|
expect(keyringSource).not.toContain("credential-stays-encrypted");
|
||||||
|
expect(keyringSource).not.toContain("feishu-credential-stays-encrypted");
|
||||||
|
|
||||||
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||||
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
||||||
|
await expect(verifyStoredFeishuApplicationEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
||||||
expect(() => oldSecrets.decryptJson({
|
expect(() => oldSecrets.decryptJson({
|
||||||
purpose: "provider-connection",
|
purpose: "provider-connection",
|
||||||
organizationId: before.connection.organizationId,
|
organizationId: before.connection.organizationId,
|
||||||
connectionId: before.connection.id,
|
connectionId: before.connection.id,
|
||||||
secretVersionId: before.id,
|
secretVersionId: before.id,
|
||||||
}, afterEnvelope)).toThrow("secret envelope key is unavailable");
|
}, 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({
|
await expect(prisma.auditEntry.count({
|
||||||
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
||||||
})).resolves.toBe(1);
|
})).resolves.toBe(1);
|
||||||
|
await expect(prisma.auditEntry.count({
|
||||||
|
where: { action: "feishu_application_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
||||||
|
})).resolves.toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user