feat: secure organization provider credentials

This commit is contained in:
2026-07-10 22:04:43 +08:00
parent e049cb6880
commit 848f913597
51 changed files with 3280 additions and 230 deletions
+13
View File
@@ -14,11 +14,16 @@ import { registerExplorerRoutes } from "./explorerRoutes.js";
import { registerMembersRoutes } from "./membersRoutes.js";
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly projectWorkspaceRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -101,4 +106,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerProviderConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.providerReadinessProbe !== undefined
? { providerReadinessProbe: config.providerReadinessProbe }
: {}),
});
}
@@ -0,0 +1,122 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { ProviderConnectionService } from "../../connections/providerConnections.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
import { ProviderReadinessError, type ProviderReadinessProbe } from "../../connections/providerReadiness.js";
export interface ProviderConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
}
export async function registerProviderConnectionRoutes(
app: FastifyInstance,
config: ProviderConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new ProviderConnectionService(
config.prisma,
config.secretEnvelope,
config.providerReadinessProbe,
);
app.get("/api/org/:orgSlug/provider-connections", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return {
connections: await connections.list(auth.organization.id),
};
} catch (error) {
request.log.error({
requestId: request.id,
operation: "provider_connection.list",
...providerFailureFacts(error),
}, "provider connection read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/provider-connections/:providerId", async (request, reply) => {
try {
const { orgSlug, providerId } = request.params as { orgSlug: string; providerId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseCredentialBody(request.body);
const result = await connections.rotateByok({
organizationId: auth.organization.id,
providerId,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
connectionId: result.id,
providerId: result.providerId,
mode: result.mode,
status: result.status,
secretVersion: result.activeVersion,
keyId: result.keyId,
}, result.created ? "provider connection created" : "provider connection rotated");
return reply.status(result.created ? 201 : 200).send(withoutWriteResult(result));
} catch (error) {
request.log.error({
requestId: request.id,
operation: "provider_connection.rotate_byok",
...providerFailureFacts(error),
}, "provider connection mutation failed");
return handleRouteError(reply, error);
}
});
}
function providerFailureFacts(error: unknown): {
readonly errorCode: string;
readonly failureCategory: string;
readonly upstreamStatus?: number;
} {
if (error instanceof ProviderReadinessError) {
return {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
};
}
return {
errorCode: "provider_connection_operation_failed",
failureCategory: error instanceof Error ? error.name : typeof error,
};
}
function parseCredentialBody(value: unknown): {
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey?: string;
} {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid provider credential body");
}
const body = value as Record<string, unknown>;
if (typeof body["baseUrl"] !== "string" || typeof body["authToken"] !== "string") {
throw new Error("provider baseUrl and authToken are required");
}
const anthropicApiKey = body["anthropicApiKey"];
if (anthropicApiKey !== undefined && typeof anthropicApiKey !== "string") {
throw new Error("invalid anthropicApiKey");
}
return {
baseUrl: body["baseUrl"],
authToken: body["authToken"],
...(anthropicApiKey !== undefined ? { anthropicApiKey } : {}),
};
}
function withoutWriteResult<T extends { readonly created: boolean }>(result: T): Omit<T, "created"> {
const { created: _created, ...metadata } = result;
return metadata;
}