forked from bai/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
@@ -23,6 +23,12 @@ export async function handleRouteError(reply: FastifyReply, err: unknown): Promi
|
||||
|
||||
function mapDomainError(message: string): { statusCode: number; code: string; message: string } | null {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("provider credential readiness check could not reach provider")) {
|
||||
return { statusCode: 502, code: "provider_unavailable", message };
|
||||
}
|
||||
if (lower.includes("provider credential readiness check failed")) {
|
||||
return { statusCode: 400, code: "provider_credential_rejected", message };
|
||||
}
|
||||
if (lower.includes("not found")) {
|
||||
return { statusCode: 404, code: "not_found", message };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { registerAuthRoutes } from "./routes/authRoutes.js";
|
||||
import { registerOrgRoutes } from "./routes/orgRoutes.js";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import type { ProviderReadinessProbe } from "../connections/providerReadiness.js";
|
||||
|
||||
export interface AdminPluginConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
@@ -14,6 +16,8 @@ export interface AdminPluginConfig {
|
||||
readonly feishuAppId: string;
|
||||
readonly feishuAppSecret: string;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
||||
readonly cookieSecure?: boolean;
|
||||
readonly oauthScope?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
@@ -43,5 +47,9 @@ export async function registerAdminPlugin(
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
projectWorkspaceRoot: config.projectWorkspaceRoot,
|
||||
secretEnvelope: config.secretEnvelope,
|
||||
...(config.providerReadinessProbe !== undefined
|
||||
? { providerReadinessProbe: config.providerReadinessProbe }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user