forked from bai/curriculum-project-hub
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { 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 { probeOpenRouterCredential, type ProviderReadinessProbe } from "./providerReadiness.js";
|
|
|
|
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
|
|
export interface ProviderCredentialInput {
|
|
readonly baseUrl: string;
|
|
readonly authToken: string;
|
|
readonly anthropicApiKey?: string;
|
|
}
|
|
|
|
export interface RotateByokProviderInput extends ProviderCredentialInput {
|
|
readonly organizationId: string;
|
|
readonly providerId: string;
|
|
readonly actorUserId: string;
|
|
}
|
|
|
|
export interface ProviderConnectionMetadata {
|
|
readonly id: string;
|
|
readonly providerId: string;
|
|
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
|
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
|
readonly activeVersion: number | null;
|
|
readonly keyId: string | null;
|
|
readonly createdAt: Date;
|
|
readonly updatedAt: Date;
|
|
}
|
|
|
|
export interface ProviderConnectionWriteResult extends ProviderConnectionMetadata {
|
|
readonly created: boolean;
|
|
}
|
|
|
|
export interface ProviderSecretPayloadV1 {
|
|
readonly schemaVersion: 1;
|
|
readonly baseUrl: string;
|
|
readonly authToken: string;
|
|
readonly anthropicApiKey: string;
|
|
}
|
|
|
|
export class ProviderConnectionService {
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly secrets: LocalSecretEnvelope,
|
|
private readonly readinessProbe: ProviderReadinessProbe = probeOpenRouterCredential,
|
|
) {}
|
|
|
|
async rotateByok(input: RotateByokProviderInput): Promise<ProviderConnectionWriteResult> {
|
|
const payload = validateProviderCredential(input);
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await requireByokActor(tx, input);
|
|
});
|
|
await this.readinessProbe({
|
|
providerId: input.providerId,
|
|
baseUrl: payload.baseUrl,
|
|
authToken: payload.authToken,
|
|
anthropicApiKey: payload.anthropicApiKey,
|
|
});
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await requireByokActor(tx, input);
|
|
const connection = await tx.organizationProviderConnection.upsert({
|
|
where: {
|
|
organizationId_providerId: {
|
|
organizationId: input.organizationId,
|
|
providerId: input.providerId,
|
|
},
|
|
},
|
|
update: {},
|
|
create: {
|
|
id: randomUUID(),
|
|
organizationId: input.organizationId,
|
|
providerId: input.providerId,
|
|
mode: "BYOK",
|
|
status: "DRAFT",
|
|
},
|
|
});
|
|
|
|
await lockConnection(tx, connection.id);
|
|
const locked = await tx.organizationProviderConnection.findUniqueOrThrow({
|
|
where: { id: connection.id },
|
|
include: {
|
|
activeSecretVersion: { select: { id: true } },
|
|
secretVersions: {
|
|
orderBy: { version: "desc" },
|
|
take: 1,
|
|
select: { version: true },
|
|
},
|
|
},
|
|
});
|
|
if (locked.organizationId !== input.organizationId || locked.providerId !== input.providerId) {
|
|
throw new Error("provider connection scope changed while rotating credential");
|
|
}
|
|
if (locked.mode !== "BYOK") {
|
|
throw new Error(
|
|
"provider connection is managed by platform administrators and cannot be changed through organization API",
|
|
);
|
|
}
|
|
|
|
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
|
const secretVersionId = randomUUID();
|
|
const envelope = this.secrets.encryptJson({
|
|
purpose: "provider-connection",
|
|
organizationId: input.organizationId,
|
|
connectionId: locked.id,
|
|
secretVersionId,
|
|
}, payload);
|
|
const now = new Date();
|
|
|
|
const secretVersion = await tx.providerCredentialVersion.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.providerCredentialVersion.update({
|
|
where: { id: locked.activeSecretVersion.id },
|
|
data: { retiredAt: now },
|
|
});
|
|
}
|
|
const activated = await tx.organizationProviderConnection.update({
|
|
where: { id: locked.id },
|
|
data: {
|
|
status: "ACTIVE",
|
|
activeSecretVersionId: secretVersion.id,
|
|
activatedAt: now,
|
|
disabledAt: null,
|
|
},
|
|
});
|
|
await tx.auditEntry.create({
|
|
data: {
|
|
organizationId: input.organizationId,
|
|
actorUserId: input.actorUserId,
|
|
action: version === 1 ? "provider_connection.created" : "provider_connection.rotated",
|
|
metadata: {
|
|
connectionId: locked.id,
|
|
providerId: input.providerId,
|
|
mode: "BYOK",
|
|
status: "ACTIVE",
|
|
secretVersion: version,
|
|
envelopeVersion: envelope.version,
|
|
keyId: envelope.keyId,
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
...metadata(activated, { version, keyId: secretVersion.keyId }),
|
|
created: version === 1,
|
|
};
|
|
});
|
|
}
|
|
|
|
async list(organizationId: string): Promise<readonly ProviderConnectionMetadata[]> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await lockActiveOrganization(tx, organizationId);
|
|
const connections = await tx.organizationProviderConnection.findMany({
|
|
where: { organizationId },
|
|
orderBy: [{ providerId: "asc" }, { createdAt: "asc" }],
|
|
include: {
|
|
activeSecretVersion: {
|
|
select: { version: true, keyId: true },
|
|
},
|
|
},
|
|
});
|
|
return connections.map((connection) => metadata(connection, connection.activeSecretVersion));
|
|
});
|
|
}
|
|
}
|
|
|
|
export function decryptStoredProviderCredential(
|
|
secrets: LocalSecretEnvelope,
|
|
input: {
|
|
readonly organizationId: string;
|
|
readonly connectionId: string;
|
|
readonly providerId: string;
|
|
readonly secretVersionId: string;
|
|
readonly envelopeVersion: number;
|
|
readonly keyId: string;
|
|
readonly envelope: Prisma.JsonValue;
|
|
},
|
|
): ProviderSecretPayloadV1 {
|
|
if (input.envelopeVersion !== 1) {
|
|
throw new Error(`unsupported provider secret envelope version: ${input.envelopeVersion}`);
|
|
}
|
|
const envelope = input.envelope as unknown as SecretEnvelopeV1;
|
|
if (envelope.keyId !== input.keyId || envelope.version !== input.envelopeVersion) {
|
|
throw new Error("provider secret envelope metadata mismatch");
|
|
}
|
|
const payload = secrets.decryptJson<unknown>({
|
|
purpose: "provider-connection",
|
|
organizationId: input.organizationId,
|
|
connectionId: input.connectionId,
|
|
secretVersionId: input.secretVersionId,
|
|
}, envelope);
|
|
return validateStoredProviderPayload(payload, input.providerId);
|
|
}
|
|
|
|
export async function verifyStoredProviderEnvelopes(
|
|
prisma: PrismaClient | Prisma.TransactionClient,
|
|
secrets: LocalSecretEnvelope,
|
|
): Promise<number> {
|
|
const versions = await prisma.providerCredentialVersion.findMany({
|
|
select: {
|
|
id: true,
|
|
envelopeVersion: true,
|
|
keyId: true,
|
|
envelope: true,
|
|
connection: {
|
|
select: { id: true, organizationId: true, providerId: true },
|
|
},
|
|
},
|
|
});
|
|
for (const version of versions) {
|
|
decryptStoredProviderCredential(secrets, {
|
|
organizationId: version.connection.organizationId,
|
|
connectionId: version.connection.id,
|
|
providerId: version.connection.providerId,
|
|
secretVersionId: version.id,
|
|
envelopeVersion: version.envelopeVersion,
|
|
keyId: version.keyId,
|
|
envelope: version.envelope,
|
|
});
|
|
}
|
|
return versions.length;
|
|
}
|
|
|
|
export async function rewrapStoredProviderEnvelopes(
|
|
prisma: Prisma.TransactionClient,
|
|
secrets: LocalSecretEnvelope,
|
|
): Promise<{ readonly verified: number; readonly rewrapped: number }> {
|
|
const versions = await prisma.providerCredentialVersion.findMany({
|
|
select: {
|
|
id: true,
|
|
envelopeVersion: true,
|
|
keyId: true,
|
|
envelope: true,
|
|
connection: {
|
|
select: { id: true, organizationId: true, providerId: true },
|
|
},
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
let rewrapped = 0;
|
|
for (const version of versions) {
|
|
const binding = {
|
|
purpose: "provider-connection",
|
|
organizationId: version.connection.organizationId,
|
|
connectionId: version.connection.id,
|
|
secretVersionId: version.id,
|
|
} as const;
|
|
decryptStoredProviderCredential(secrets, {
|
|
organizationId: version.connection.organizationId,
|
|
connectionId: version.connection.id,
|
|
providerId: version.connection.providerId,
|
|
secretVersionId: version.id,
|
|
envelopeVersion: version.envelopeVersion,
|
|
keyId: version.keyId,
|
|
envelope: version.envelope,
|
|
});
|
|
if (version.keyId === secrets.activeKeyId) continue;
|
|
|
|
const envelope = version.envelope as unknown as SecretEnvelopeV1;
|
|
const nextEnvelope = secrets.rewrap(binding, envelope);
|
|
const updated = await prisma.providerCredentialVersion.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(`provider secret envelope changed during KEK rotation: ${version.id}`);
|
|
}
|
|
await prisma.auditEntry.create({
|
|
data: {
|
|
organizationId: version.connection.organizationId,
|
|
action: "provider_secret.kek_rewrapped",
|
|
metadata: {
|
|
connectionId: version.connection.id,
|
|
providerId: version.connection.providerId,
|
|
secretVersionId: version.id,
|
|
previousKeyId: version.keyId,
|
|
keyId: nextEnvelope.keyId,
|
|
},
|
|
},
|
|
});
|
|
rewrapped += 1;
|
|
}
|
|
const verified = await verifyStoredProviderEnvelopes(prisma, secrets);
|
|
const remaining = await prisma.providerCredentialVersion.count({
|
|
where: { keyId: { not: secrets.activeKeyId } },
|
|
});
|
|
if (remaining !== 0) {
|
|
throw new Error(`KEK rotation left ${remaining} provider envelope(s) on a non-active key`);
|
|
}
|
|
return { verified, rewrapped };
|
|
}
|
|
|
|
function validateProviderCredential(input: RotateByokProviderInput): ProviderSecretPayloadV1 {
|
|
if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
|
|
throw new Error("invalid provider connection id");
|
|
}
|
|
let baseUrl: URL;
|
|
try {
|
|
baseUrl = new URL(input.baseUrl);
|
|
} catch (error) {
|
|
throw new Error("invalid provider base URL", { cause: error });
|
|
}
|
|
if (baseUrl.protocol !== "https:") {
|
|
throw new Error("invalid provider base URL: HTTPS is required");
|
|
}
|
|
const authToken = input.authToken.trim();
|
|
if (authToken === "") {
|
|
throw new Error("provider auth token is required");
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
baseUrl: baseUrl.toString().replace(/\/$/, ""),
|
|
authToken,
|
|
anthropicApiKey: input.anthropicApiKey?.trim() ?? "",
|
|
};
|
|
}
|
|
|
|
function metadata(
|
|
connection: {
|
|
readonly id: string;
|
|
readonly providerId: string;
|
|
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
|
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
|
readonly createdAt: Date;
|
|
readonly updatedAt: Date;
|
|
},
|
|
secret: { readonly version: number; readonly keyId: string } | null,
|
|
): ProviderConnectionMetadata {
|
|
return {
|
|
id: connection.id,
|
|
providerId: connection.providerId,
|
|
mode: connection.mode,
|
|
status: connection.status,
|
|
activeVersion: secret?.version ?? null,
|
|
keyId: secret?.keyId ?? null,
|
|
createdAt: connection.createdAt,
|
|
updatedAt: connection.updatedAt,
|
|
};
|
|
}
|
|
|
|
function validateStoredProviderPayload(value: unknown, providerId: string): ProviderSecretPayloadV1 {
|
|
if (!isRecord(value) || value["schemaVersion"] !== 1 || typeof value["baseUrl"] !== "string" ||
|
|
typeof value["authToken"] !== "string" || typeof value["anthropicApiKey"] !== "string" ||
|
|
value["authToken"].trim() === "") {
|
|
throw new Error(`invalid encrypted provider credential payload: ${providerId}`);
|
|
}
|
|
let baseUrl: URL;
|
|
try {
|
|
baseUrl = new URL(value["baseUrl"]);
|
|
} catch (error) {
|
|
throw new Error(`invalid encrypted provider credential payload: ${providerId}`, { cause: error });
|
|
}
|
|
if (baseUrl.protocol !== "https:") {
|
|
throw new Error(`invalid encrypted provider credential payload: ${providerId}`);
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
baseUrl: value["baseUrl"],
|
|
authToken: value["authToken"],
|
|
anthropicApiKey: value["anthropicApiKey"],
|
|
};
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
async function lockConnection(tx: Prisma.TransactionClient, connectionId: string): Promise<void> {
|
|
await tx.$queryRaw`SELECT "id" FROM "OrganizationProviderConnection" WHERE "id" = ${connectionId} FOR UPDATE`;
|
|
}
|
|
|
|
async function requireByokActor(
|
|
tx: Prisma.TransactionClient,
|
|
input: Pick<RotateByokProviderInput, "organizationId" | "providerId" | "actorUserId">,
|
|
): Promise<void> {
|
|
await lockActiveOrganization(tx, input.organizationId);
|
|
const [authorizedActor, existingConnection] = await Promise.all([
|
|
tx.organizationMembership.findFirst({
|
|
where: {
|
|
organizationId: input.organizationId,
|
|
userId: input.actorUserId,
|
|
role: { in: ["OWNER", "ADMIN"] },
|
|
revokedAt: null,
|
|
},
|
|
select: { id: true },
|
|
}),
|
|
tx.organizationProviderConnection.findUnique({
|
|
where: {
|
|
organizationId_providerId: {
|
|
organizationId: input.organizationId,
|
|
providerId: input.providerId,
|
|
},
|
|
},
|
|
select: { mode: true },
|
|
}),
|
|
]);
|
|
if (authorizedActor === null) {
|
|
throw new Error("BYOK provider rotation requires an active Organization OWNER or ADMIN");
|
|
}
|
|
if (existingConnection?.mode === "PLATFORM_MANAGED") {
|
|
throw new Error(
|
|
"provider connection is managed by platform administrators and cannot be changed through organization API",
|
|
);
|
|
}
|
|
}
|