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;
|
||||
}
|
||||
@@ -56,7 +56,8 @@ export interface RunRequest {
|
||||
readonly model: string | undefined;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt: string | undefined;
|
||||
readonly providerEnv?: Record<string, string | undefined> | undefined;
|
||||
/** Run-scoped loopback proxy capability; never an Organization provider credential. */
|
||||
readonly providerProxyEnv?: Record<string, string | undefined> | undefined;
|
||||
readonly resumeSessionId?: string | undefined;
|
||||
/**
|
||||
* RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
|
||||
@@ -125,7 +126,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const security = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
providerEnv: req.providerEnv,
|
||||
providerProxyEnv: req.providerProxyEnv,
|
||||
});
|
||||
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
|
||||
@@ -29,7 +29,8 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||
export interface AgentSecurityInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
|
||||
readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
env.DO_NOT_TRACK = "1";
|
||||
env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";
|
||||
|
||||
for (const [name, value] of Object.entries(input.providerEnv ?? {})) {
|
||||
for (const [name, value] of Object.entries(input.providerProxyEnv ?? {})) {
|
||||
if (!PROVIDER_ENV_KEYS.has(name)) {
|
||||
throw new Error(`unsupported provider environment variable: ${name}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type NetworkFailureCategory =
|
||||
| "timeout"
|
||||
| "dns"
|
||||
| "tls"
|
||||
| "connection"
|
||||
| "aborted"
|
||||
| "network";
|
||||
|
||||
/** Classify transport failures without retaining messages that may contain a URL. */
|
||||
export function classifyNetworkFailure(error: unknown): NetworkFailureCategory {
|
||||
const chain = errorChain(error);
|
||||
const names = chain.map((candidate) => candidate.name);
|
||||
const codes = chain.flatMap((candidate) =>
|
||||
typeof candidate.code === "string" ? [candidate.code.toUpperCase()] : []
|
||||
);
|
||||
if (names.includes("TimeoutError") || codes.some((code) => code === "ETIMEDOUT")) return "timeout";
|
||||
if (names.includes("AbortError") || codes.some((code) => code === "ABORT_ERR")) return "aborted";
|
||||
if (codes.some((code) => code === "ENOTFOUND" || code === "EAI_AGAIN")) return "dns";
|
||||
if (codes.some((code) => code.includes("TLS") || code.includes("CERT") || code.includes("SSL"))) return "tls";
|
||||
if (codes.some((code) => ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"].includes(code))) {
|
||||
return "connection";
|
||||
}
|
||||
return "network";
|
||||
}
|
||||
|
||||
function errorChain(error: unknown): Array<{ readonly name: string; readonly code?: unknown }> {
|
||||
const chain: Array<{ readonly name: string; readonly code?: unknown }> = [];
|
||||
let candidate = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
if (typeof candidate !== "object" || candidate === null) break;
|
||||
const record = candidate as { readonly name?: unknown; readonly code?: unknown; readonly cause?: unknown };
|
||||
chain.push({
|
||||
name: typeof record.name === "string" ? record.name : "Error",
|
||||
...(record.code !== undefined ? { code: record.code } : {}),
|
||||
});
|
||||
candidate = record.cause;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||
|
||||
const MAX_PROVIDER_REQUEST_BYTES = 32 * 1024 * 1024;
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
const REPLACED_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP_HEADERS,
|
||||
"authorization",
|
||||
"content-length",
|
||||
"host",
|
||||
"x-api-key",
|
||||
]);
|
||||
|
||||
export interface ProviderUpstreamCredential {
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
}
|
||||
|
||||
export interface AgentProviderLease {
|
||||
readonly sdkEnv: Readonly<Record<string, string | undefined>>;
|
||||
readonly sensitiveValues: readonly string[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProviderProxyDiagnostic {
|
||||
readonly code:
|
||||
| "provider_proxy_unauthorized"
|
||||
| "provider_proxy_request_too_large"
|
||||
| "provider_proxy_redirect_refused"
|
||||
| "provider_proxy_upstream_failed";
|
||||
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
|
||||
}
|
||||
|
||||
export interface ProviderProxyOptions {
|
||||
readonly onDiagnostic?: (diagnostic: ProviderProxyDiagnostic) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one loopback-only proxy for one Agent run. The Agent receives only a
|
||||
* random run capability; customer provider credentials stay in this Hub
|
||||
* closure and are attached immediately before the upstream request.
|
||||
*/
|
||||
export async function openProviderProxyLease(
|
||||
upstream: ProviderUpstreamCredential,
|
||||
options: ProviderProxyOptions = {},
|
||||
): Promise<AgentProviderLease> {
|
||||
const capability = randomBytes(32).toString("base64url");
|
||||
const handler = (request: IncomingMessage, response: ServerResponse): void => {
|
||||
void forwardProviderRequest(request, response, capability, upstream, options);
|
||||
};
|
||||
const server = createServer(handler);
|
||||
server.requestTimeout = 5 * 60_000;
|
||||
server.headersTimeout = 30_000;
|
||||
server.maxRequestsPerSocket = 1_000;
|
||||
server.listen(0, "127.0.0.1");
|
||||
await Promise.race([
|
||||
once(server, "listening"),
|
||||
once(server, "error").then(([error]) => Promise.reject(error)),
|
||||
]);
|
||||
server.unref();
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("provider proxy did not bind a TCP address");
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: capability,
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
sensitiveValues: [capability],
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
server.closeAllConnections();
|
||||
if (!server.listening) return;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function forwardProviderRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
capability: string,
|
||||
upstream: ProviderUpstreamCredential,
|
||||
options: ProviderProxyOptions,
|
||||
): Promise<void> {
|
||||
if (!authorized(request.headers.authorization, capability)) {
|
||||
options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" });
|
||||
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestUrl = new URL(request.url ?? "/", "http://provider-proxy.invalid");
|
||||
const upstreamBase = upstream.baseUrl.endsWith("/") ? upstream.baseUrl : `${upstream.baseUrl}/`;
|
||||
const upstreamUrl = new URL(`${requestUrl.pathname.replace(/^\//, "")}${requestUrl.search}`, upstreamBase);
|
||||
const body = await readBoundedBody(request);
|
||||
const headers = forwardedRequestHeaders(request.headers);
|
||||
headers.set("authorization", `Bearer ${upstream.authToken}`);
|
||||
if (upstream.anthropicApiKey !== "") {
|
||||
headers.set("x-api-key", upstream.anthropicApiKey);
|
||||
}
|
||||
headers.set("accept-encoding", "identity");
|
||||
|
||||
const abort = new AbortController();
|
||||
request.once("aborted", () => abort.abort());
|
||||
const upstreamResponse = await fetch(upstreamUrl, {
|
||||
method: request.method ?? "GET",
|
||||
headers,
|
||||
...(body.byteLength === 0 ? {} : { body }),
|
||||
redirect: "manual",
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
||||
options.onDiagnostic?.({ code: "provider_proxy_redirect_refused", category: "redirect" });
|
||||
await upstreamResponse.body?.cancel();
|
||||
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("provider redirect refused");
|
||||
return;
|
||||
}
|
||||
response.writeHead(upstreamResponse.status, forwardedResponseHeaders(upstreamResponse.headers));
|
||||
if (upstreamResponse.body === null) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
for await (const chunk of upstreamResponse.body) {
|
||||
if (!response.write(Buffer.from(chunk))) {
|
||||
await once(response, "drain");
|
||||
}
|
||||
}
|
||||
response.end();
|
||||
} catch (error) {
|
||||
const diagnostic = error instanceof ProviderProxyRequestError
|
||||
? { code: error.code, category: error.category } as const
|
||||
: {
|
||||
code: "provider_proxy_upstream_failed",
|
||||
category: classifyNetworkFailure(error),
|
||||
} as const;
|
||||
options.onDiagnostic?.(diagnostic);
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("provider request failed");
|
||||
} else {
|
||||
response.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function authorized(value: string | undefined, capability: string): boolean {
|
||||
if (value === undefined || !value.startsWith("Bearer ")) return false;
|
||||
const supplied = Buffer.from(value.slice("Bearer ".length));
|
||||
const expected = Buffer.from(capability);
|
||||
return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected);
|
||||
}
|
||||
|
||||
function forwardedRequestHeaders(source: IncomingHttpHeaders): Headers {
|
||||
const result = new Headers();
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (value === undefined || REPLACED_REQUEST_HEADERS.has(name.toLowerCase())) continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) result.append(name, item);
|
||||
} else {
|
||||
result.set(name, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function forwardedResponseHeaders(source: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [name, value] of source.entries()) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (HOP_BY_HOP_HEADERS.has(normalized) || normalized === "content-length" ||
|
||||
normalized === "content-encoding") continue;
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readBoundedBody(request: IncomingMessage): Promise<Buffer> {
|
||||
const declared = Number(request.headers["content-length"] ?? 0);
|
||||
if (Number.isFinite(declared) && declared > MAX_PROVIDER_REQUEST_BYTES) {
|
||||
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
bytes += buffer.byteLength;
|
||||
if (bytes > MAX_PROVIDER_REQUEST_BYTES) {
|
||||
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
return Buffer.concat(chunks, bytes);
|
||||
}
|
||||
|
||||
class ProviderProxyRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: "provider_proxy_request_too_large",
|
||||
readonly category: "request_limit",
|
||||
) {
|
||||
super(code);
|
||||
this.name = "ProviderProxyRequestError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||
|
||||
export interface ProviderReadinessInput {
|
||||
readonly providerId: string;
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
}
|
||||
|
||||
export type ProviderReadinessProbe = (input: ProviderReadinessInput) => Promise<void>;
|
||||
|
||||
export class ProviderReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: "provider_readiness_unsupported" | "provider_readiness_unreachable" | "provider_readiness_rejected",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "configuration" | "http",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderReadinessError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify the pilot's OpenRouter credential without starting or billing a model run. */
|
||||
export async function probeOpenRouterCredential(input: ProviderReadinessInput): Promise<void> {
|
||||
if (input.providerId !== "openrouter") {
|
||||
throw new ProviderReadinessError(
|
||||
"provider_readiness_unsupported",
|
||||
`provider readiness probe is unavailable: ${input.providerId}`,
|
||||
"configuration",
|
||||
);
|
||||
}
|
||||
const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`;
|
||||
const url = new URL("v1/key", base);
|
||||
const headers = new Headers({
|
||||
authorization: `Bearer ${input.authToken}`,
|
||||
accept: "application/json",
|
||||
});
|
||||
if (input.anthropicApiKey !== "") headers.set("x-api-key", input.anthropicApiKey);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ProviderReadinessError(
|
||||
"provider_readiness_unreachable",
|
||||
"provider credential readiness check could not reach provider",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (!response.ok) {
|
||||
throw new ProviderReadinessError(
|
||||
"provider_readiness_rejected",
|
||||
`provider credential readiness check failed: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { parse } from "dotenv";
|
||||
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
|
||||
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import {
|
||||
DeploymentPreflightError,
|
||||
validateDeploymentPreflight,
|
||||
@@ -18,6 +20,7 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
interface Options {
|
||||
readonly envFile: string;
|
||||
readonly keyringFile: string;
|
||||
readonly nodeBin: string;
|
||||
readonly runuserBin: string;
|
||||
readonly setprivBin: string;
|
||||
@@ -47,6 +50,7 @@ async function main(): Promise<void> {
|
||||
const canonicalInput = await canonicalizeInput(rawInput);
|
||||
const result = validateDeploymentPreflight(canonicalInput);
|
||||
await validateSecretFile(options.envFile);
|
||||
const secretEnvelope = await validateKeyringFile(options.keyringFile);
|
||||
await validateNodeVersion(options.nodeBin);
|
||||
await validateExecutable(options.runuserBin, "runuser");
|
||||
await validateExecutable(options.setprivBin, "setpriv");
|
||||
@@ -56,7 +60,7 @@ async function main(): Promise<void> {
|
||||
await validateExecutable(options.pgIsReadyBin, "pg_isready");
|
||||
await validateCph(result.cphBin);
|
||||
await validatePostgres(options.pgIsReadyBin, result);
|
||||
await validatePostgresQuery(result.databaseUrl);
|
||||
await validatePostgresQuery(result.databaseUrl, secretEnvelope);
|
||||
|
||||
if (options.serviceUid !== undefined && options.serviceGid !== undefined) {
|
||||
await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid);
|
||||
@@ -125,6 +129,22 @@ async function validateSecretFile(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateKeyringFile(path: string): Promise<LocalSecretEnvelope> {
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
||||
throw new Error(`local secret keyring is not a regular non-symlink file: ${path}`);
|
||||
}
|
||||
if ((metadata.mode & 0o077) !== 0) {
|
||||
throw new Error(`local secret keyring must not be accessible by group or others: ${path}`);
|
||||
}
|
||||
const keyring = await loadLocalSecretKeyring({
|
||||
NODE_ENV: "preflight",
|
||||
HUB_SECRET_KEYRING_FILE: path,
|
||||
});
|
||||
console.log("[preflight] local secret keyring is valid");
|
||||
return new LocalSecretEnvelope(keyring);
|
||||
}
|
||||
|
||||
async function validateNodeVersion(nodeBin: string): Promise<void> {
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -189,14 +209,44 @@ async function validatePostgres(pgIsReadyBin: string, result: DeploymentPrefligh
|
||||
console.log("[preflight] PostgreSQL is accepting connections");
|
||||
}
|
||||
|
||||
async function validatePostgresQuery(databaseUrl: string): Promise<void> {
|
||||
async function validatePostgresQuery(
|
||||
databaseUrl: string,
|
||||
secretEnvelope: LocalSecretEnvelope,
|
||||
): Promise<void> {
|
||||
const client = new PrismaClient({
|
||||
datasources: { db: { url: databaseUrl } },
|
||||
log: [],
|
||||
});
|
||||
let queryFailure: unknown;
|
||||
let envelopeFailure: unknown;
|
||||
try {
|
||||
await client.$queryRawUnsafe("SELECT 1");
|
||||
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
|
||||
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
|
||||
`;
|
||||
if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) {
|
||||
console.log("[preflight] empty database has no migration table or stored provider envelopes");
|
||||
} else {
|
||||
const migration = 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" = '20260710220000_org_provider_secret_envelopes'
|
||||
ORDER BY "started_at" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const applied = migration[0]?.finished_at !== null && migration[0]?.finished_at !== undefined &&
|
||||
migration[0]?.rolled_back_at === null;
|
||||
if (!applied) {
|
||||
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
|
||||
} else {
|
||||
try {
|
||||
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
|
||||
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
|
||||
} catch (error) {
|
||||
envelopeFailure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
queryFailure = error;
|
||||
}
|
||||
@@ -222,6 +272,12 @@ async function validatePostgresQuery(databaseUrl: string): Promise<void> {
|
||||
{ cause: disconnectFailure },
|
||||
);
|
||||
}
|
||||
if (envelopeFailure !== undefined) {
|
||||
throw new Error(
|
||||
`stored provider envelope verification failed (${formatFailure(envelopeFailure)})`,
|
||||
{ cause: envelopeFailure },
|
||||
);
|
||||
}
|
||||
console.log("[preflight] PostgreSQL authenticated query succeeded");
|
||||
}
|
||||
|
||||
@@ -383,6 +439,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
|
||||
const allowed = new Set([
|
||||
"--env-file",
|
||||
"--keyring-file",
|
||||
"--node-bin",
|
||||
"--runuser-bin",
|
||||
"--setpriv-bin",
|
||||
@@ -411,6 +468,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
|
||||
return {
|
||||
envFile: required(values, "--env-file"),
|
||||
keyringFile: required(values, "--keyring-file"),
|
||||
nodeBin: required(values, "--node-bin"),
|
||||
runuserBin: required(values, "--runuser-bin"),
|
||||
setprivBin: required(values, "--setpriv-bin"),
|
||||
|
||||
@@ -83,12 +83,11 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL");
|
||||
}
|
||||
|
||||
const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems);
|
||||
if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) {
|
||||
problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL");
|
||||
for (const name of ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const) {
|
||||
if (Object.hasOwn(input.env, name)) {
|
||||
problems.push(`${name} must not be set; configure Organization Provider Connections`);
|
||||
}
|
||||
}
|
||||
readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems);
|
||||
requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems);
|
||||
|
||||
readRequired(input.env, "FEISHU_APP_ID", problems);
|
||||
readRequired(input.env, "FEISHU_APP_SECRET", problems);
|
||||
@@ -139,12 +138,6 @@ function readRequired(env: DeploymentEnv, name: string, problems: string[]): str
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") {
|
||||
problems.push(`${name} must be present and empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const raw = env[name]?.trim();
|
||||
if (raw === undefined || raw === "") return;
|
||||
@@ -184,15 +177,6 @@ function isPostgresUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpsUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { rotateLocalKek } from "../security/localKekRotation.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SERVICE_UNIT = "cph-hub.service";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.getuid?.() !== 0) {
|
||||
throw new Error("local KEK rotation must run as root");
|
||||
}
|
||||
await requireStoppedService();
|
||||
const keyringFile = parseKeyringFile(process.argv.slice(2));
|
||||
const databaseUrl = process.env["DATABASE_URL"]?.trim();
|
||||
if (databaseUrl === undefined || databaseUrl === "") {
|
||||
throw new Error("DATABASE_URL is required for local KEK rotation");
|
||||
}
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
|
||||
try {
|
||||
const result = await rotateLocalKek({ keyringFile, prisma });
|
||||
console.log(
|
||||
`[secret-kek] active=${result.activeKeyId} rewrapped=${result.rewrapped} verified=${result.verified}`,
|
||||
);
|
||||
console.log("[secret-kek] back up the updated keyring separately before retiring any previous key");
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function requireStoppedService(): Promise<void> {
|
||||
let activeState: string;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"systemctl",
|
||||
["show", "--property=ActiveState", "--value", SERVICE_UNIT],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
activeState = stdout.trim();
|
||||
} catch {
|
||||
throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`);
|
||||
}
|
||||
if (activeState !== "inactive" && activeState !== "failed") {
|
||||
throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyringFile(argv: readonly string[]): string {
|
||||
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
|
||||
throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json");
|
||||
}
|
||||
return argv[1];
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(`[secret-kek] failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+66
-58
@@ -410,63 +410,72 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// stop the SDK query. Cleared in the finally below.
|
||||
const abortController = new AbortController();
|
||||
activeRuns.set(run.id, abortController);
|
||||
const sdkStderr = createAgentSdkStderrSink({
|
||||
logger: deps.logger,
|
||||
runId: run.id,
|
||||
projectId,
|
||||
sensitiveValues: Object.values(provider.sdkEnv),
|
||||
});
|
||||
runAgent({
|
||||
prompt: promptForAgent,
|
||||
model,
|
||||
project: projectCtx,
|
||||
systemPrompt,
|
||||
providerEnv: provider.sdkEnv,
|
||||
resumeSessionId: sessionMetadata.claudeSessionId,
|
||||
tools: roleTools,
|
||||
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
||||
maxTurns: runPolicy.maxTurns,
|
||||
runId: run.id,
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
abortController,
|
||||
onSdkStderr: sdkStderr.write,
|
||||
onStream: (event) => {
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
card.appendText(event.text);
|
||||
break;
|
||||
case "thinking-delta":
|
||||
card.appendReasoning(event.text);
|
||||
break;
|
||||
case "tool-start":
|
||||
card.onToolStart(event.toolName, event.toolUseId);
|
||||
break;
|
||||
case "tool-end":
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: event.input,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
break;
|
||||
case "tool-result":
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: undefined,
|
||||
result: event.result,
|
||||
error: event.isError ? event.result : undefined,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
break;
|
||||
case "finish":
|
||||
break;
|
||||
}
|
||||
},
|
||||
})
|
||||
const agentExecution = (async () => {
|
||||
const providerLease = await provider.openAgentLease({ runId: run.id });
|
||||
const sdkStderr = createAgentSdkStderrSink({
|
||||
logger: deps.logger,
|
||||
runId: run.id,
|
||||
projectId,
|
||||
sensitiveValues: providerLease.sensitiveValues,
|
||||
});
|
||||
try {
|
||||
return await runAgent({
|
||||
prompt: promptForAgent,
|
||||
model,
|
||||
project: projectCtx,
|
||||
systemPrompt,
|
||||
providerProxyEnv: { ...providerLease.sdkEnv },
|
||||
resumeSessionId: sessionMetadata.claudeSessionId,
|
||||
tools: roleTools,
|
||||
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
||||
maxTurns: runPolicy.maxTurns,
|
||||
runId: run.id,
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
abortController,
|
||||
onSdkStderr: sdkStderr.write,
|
||||
onStream: (event) => {
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
card.appendText(event.text);
|
||||
break;
|
||||
case "thinking-delta":
|
||||
card.appendReasoning(event.text);
|
||||
break;
|
||||
case "tool-start":
|
||||
card.onToolStart(event.toolName, event.toolUseId);
|
||||
break;
|
||||
case "tool-end":
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: event.input,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
break;
|
||||
case "tool-result":
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: undefined,
|
||||
result: event.result,
|
||||
error: event.isError ? event.result : undefined,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
break;
|
||||
case "finish":
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
sdkStderr.flush();
|
||||
await providerLease.close();
|
||||
}
|
||||
})();
|
||||
agentExecution
|
||||
.then(async (result) => {
|
||||
const interrupted = result.status === "interrupted";
|
||||
const finalText =
|
||||
@@ -519,7 +528,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
|
||||
})
|
||||
.finally(async () => {
|
||||
sdkStderr.flush();
|
||||
try {
|
||||
await releaseLock(deps.prisma, run.id);
|
||||
} catch (e) {
|
||||
|
||||
+24
-3
@@ -5,7 +5,10 @@ import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
||||
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "./security/secretEnvelope.js";
|
||||
import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
|
||||
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
|
||||
import { openProviderProxyLease } from "./connections/providerProxy.js";
|
||||
import { readServerBinding } from "./settings/server.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
@@ -31,8 +34,25 @@ export async function startHub(): Promise<void> {
|
||||
const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot);
|
||||
app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages");
|
||||
|
||||
const runtimeSettings = createEnvRuntimeSettings();
|
||||
await runtimeSettings.provider("openrouter");
|
||||
const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
||||
const verifiedProviderEnvelopes = await verifyStoredProviderEnvelopes(prisma, secretEnvelope);
|
||||
app.log.info({ verified: verifiedProviderEnvelopes }, "startup: authenticated stored provider envelopes");
|
||||
const runtimeSettings = createDatabaseRuntimeSettings(
|
||||
prisma,
|
||||
secretEnvelope,
|
||||
process.env,
|
||||
(credential, context) => openProviderProxyLease(credential, {
|
||||
onDiagnostic: (diagnostic) => {
|
||||
app.log.error({
|
||||
runId: context.runId,
|
||||
projectId: context.projectId,
|
||||
providerId: context.providerId,
|
||||
errorCode: diagnostic.code,
|
||||
failureCategory: diagnostic.category,
|
||||
}, "provider proxy diagnostic");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
@@ -58,6 +78,7 @@ export async function startHub(): Promise<void> {
|
||||
feishuAppId,
|
||||
feishuAppSecret,
|
||||
projectWorkspaceRoot,
|
||||
secretEnvelope,
|
||||
});
|
||||
|
||||
const address = await app.listen(bind);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { lstat, open, rename, unlink, type FileHandle } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import {
|
||||
LocalSecretEnvelope,
|
||||
loadLocalSecretKeyringFile,
|
||||
type LocalSecretKeyring,
|
||||
} from "./secretEnvelope.js";
|
||||
|
||||
export interface RotateLocalKekInput {
|
||||
readonly keyringFile: string;
|
||||
readonly prisma: PrismaClient;
|
||||
readonly now?: Date;
|
||||
readonly generatedKey?: Buffer;
|
||||
readonly expectedOwner?: { readonly uid: number; readonly gid: number };
|
||||
}
|
||||
|
||||
export interface RotateLocalKekResult {
|
||||
readonly previousActiveKeyId: string;
|
||||
readonly activeKeyId: string;
|
||||
readonly verified: number;
|
||||
readonly rewrapped: number;
|
||||
}
|
||||
|
||||
export const LOCAL_KEK_ROTATION_ADVISORY_LOCK = 7_310_579_862_737_525_041n;
|
||||
|
||||
/**
|
||||
* Atomically installs a new active KEK while retaining all previous keys, then
|
||||
* rewraps database DEKs. A crash after the file rename is recoverable because
|
||||
* both old and new KEKs remain in the keyring and the operation is rerunnable.
|
||||
*/
|
||||
export async function rotateLocalKek(input: RotateLocalKekInput): Promise<RotateLocalKekResult> {
|
||||
if (!isAbsolute(input.keyringFile)) {
|
||||
throw new Error("local secret keyring path must be absolute");
|
||||
}
|
||||
const expectedOwner = input.expectedOwner ?? { uid: 0, gid: 0 };
|
||||
const initialMetadata = await lstat(input.keyringFile);
|
||||
assertKeyringMetadata(initialMetadata, expectedOwner);
|
||||
return input.prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<Array<{ readonly locked: boolean }>>`
|
||||
SELECT pg_try_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK}) AS "locked"
|
||||
`;
|
||||
if (rows[0]?.locked !== true) {
|
||||
throw new Error("local KEK rotation lock is already held");
|
||||
}
|
||||
return rotateUnderLock(input, expectedOwner, tx);
|
||||
}, { maxWait: 5_000, timeout: 300_000 });
|
||||
}
|
||||
|
||||
async function rotateUnderLock(
|
||||
input: RotateLocalKekInput,
|
||||
expectedOwner: { readonly uid: number; readonly gid: number },
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<RotateLocalKekResult> {
|
||||
const metadata = await lstat(input.keyringFile);
|
||||
assertKeyringMetadata(metadata, expectedOwner);
|
||||
|
||||
const previous = await loadLocalSecretKeyringFile(input.keyringFile);
|
||||
const generatedKey = input.generatedKey === undefined
|
||||
? randomBytes(32)
|
||||
: Buffer.from(input.generatedKey);
|
||||
if (generatedKey.byteLength !== 32) {
|
||||
generatedKey.fill(0);
|
||||
throw new Error("generated local KEK must contain exactly 32 bytes");
|
||||
}
|
||||
const keyId = nextKeyId(input.now ?? new Date());
|
||||
if (previous.keys.has(keyId)) {
|
||||
generatedKey.fill(0);
|
||||
throw new Error(`generated local KEK identifier already exists: ${keyId}`);
|
||||
}
|
||||
|
||||
const keys = new Map(previous.keys);
|
||||
keys.set(keyId, generatedKey);
|
||||
const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys };
|
||||
try {
|
||||
await replaceKeyringAtomically(input.keyringFile, keyring, metadata.uid, metadata.gid);
|
||||
const result = await rewrapStoredProviderEnvelopes(
|
||||
tx,
|
||||
new LocalSecretEnvelope(keyring),
|
||||
);
|
||||
return {
|
||||
previousActiveKeyId: previous.activeKeyId,
|
||||
activeKeyId: keyId,
|
||||
...result,
|
||||
};
|
||||
} finally {
|
||||
generatedKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKeyringMetadata(
|
||||
metadata: {
|
||||
readonly mode: number;
|
||||
readonly uid: number;
|
||||
readonly gid: number;
|
||||
isFile(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
},
|
||||
expectedOwner: { readonly uid: number; readonly gid: number },
|
||||
): void {
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
||||
throw new Error("local secret keyring must be a regular non-symlink file");
|
||||
}
|
||||
if (metadata.uid !== expectedOwner.uid || metadata.gid !== expectedOwner.gid) {
|
||||
throw new Error(
|
||||
`local secret keyring must be owned by ${expectedOwner.uid}:${expectedOwner.gid}`,
|
||||
);
|
||||
}
|
||||
if ((metadata.mode & 0o777) !== 0o600) {
|
||||
throw new Error("local secret keyring must have mode 0600");
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceKeyringAtomically(
|
||||
file: string,
|
||||
keyring: LocalSecretKeyring,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Promise<void> {
|
||||
const directory = dirname(file);
|
||||
const temporary = join(directory, `.${basename(file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
||||
const encodedKeys = Object.fromEntries(
|
||||
[...keyring.keys].map(([keyId, key]) => [keyId, key.toString("base64")]),
|
||||
);
|
||||
const contents = `${JSON.stringify({ version: 1, activeKeyId: keyring.activeKeyId, keys: encodedKeys })}\n`;
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await open(temporary, "wx", 0o600);
|
||||
await handle.chown(uid, gid);
|
||||
await handle.writeFile(contents, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await rename(temporary, file);
|
||||
await syncDirectory(directory);
|
||||
} catch (error) {
|
||||
const cleanupFailures: unknown[] = [];
|
||||
if (handle !== undefined) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await removeIfExists(temporary);
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError);
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupFailures],
|
||||
"local keyring replacement and cleanup both failed",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(directory, "r");
|
||||
let syncFailure: unknown;
|
||||
try {
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
syncFailure = error;
|
||||
}
|
||||
let closeFailure: unknown;
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
closeFailure = error;
|
||||
}
|
||||
if (syncFailure !== undefined && closeFailure !== undefined) {
|
||||
throw new AggregateError(
|
||||
[syncFailure, closeFailure],
|
||||
"keyring directory sync and handle close both failed",
|
||||
{ cause: syncFailure },
|
||||
);
|
||||
}
|
||||
if (syncFailure !== undefined) throw syncFailure;
|
||||
if (closeFailure !== undefined) throw closeFailure;
|
||||
}
|
||||
|
||||
async function removeIfExists(file: string): Promise<void> {
|
||||
try {
|
||||
await unlink(file);
|
||||
} catch (error) {
|
||||
if (isErrno(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function nextKeyId(now: Date): string {
|
||||
const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
return `local-${timestamp}-${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const KEY_BYTES = 32;
|
||||
const NONCE_BYTES = 12;
|
||||
const AUTH_TAG_BYTES = 16;
|
||||
const PRODUCTION_CREDENTIAL_NAME = "cph-secret-keyring";
|
||||
|
||||
export interface SecretBinding {
|
||||
readonly purpose: string;
|
||||
readonly organizationId: string;
|
||||
readonly connectionId: string;
|
||||
readonly secretVersionId: string;
|
||||
}
|
||||
|
||||
export interface EncryptedPart {
|
||||
readonly nonce: string;
|
||||
readonly ciphertext: string;
|
||||
readonly authTag: string;
|
||||
}
|
||||
|
||||
export interface SecretEnvelopeV1 {
|
||||
readonly version: 1;
|
||||
readonly algorithm: "AES-256-GCM";
|
||||
readonly keyId: string;
|
||||
readonly wrappedDek: EncryptedPart;
|
||||
readonly payload: EncryptedPart;
|
||||
}
|
||||
|
||||
export interface LocalSecretKeyring {
|
||||
readonly activeKeyId: string;
|
||||
readonly keys: ReadonlyMap<string, Buffer>;
|
||||
}
|
||||
|
||||
export type SecretKeyringEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
/**
|
||||
* Loads the one local KEK source accepted by the current runtime. Production
|
||||
* deliberately has no file-path fallback: systemd must materialize the fixed
|
||||
* credential below CREDENTIALS_DIRECTORY.
|
||||
*/
|
||||
export async function loadLocalSecretKeyring(
|
||||
env: SecretKeyringEnv = process.env,
|
||||
): Promise<LocalSecretKeyring> {
|
||||
const nodeEnv = env["NODE_ENV"]?.trim();
|
||||
const configuredFile = env["HUB_SECRET_KEYRING_FILE"]?.trim();
|
||||
let file: string;
|
||||
|
||||
if (nodeEnv === "production") {
|
||||
if (configuredFile !== undefined && configuredFile !== "") {
|
||||
throw new Error("production secret keyring must use the systemd credential");
|
||||
}
|
||||
const credentialsDirectory = env["CREDENTIALS_DIRECTORY"]?.trim();
|
||||
if (credentialsDirectory === undefined || credentialsDirectory === "") {
|
||||
throw new Error("missing required secret keyring setting: CREDENTIALS_DIRECTORY");
|
||||
}
|
||||
file = path.join(credentialsDirectory, PRODUCTION_CREDENTIAL_NAME);
|
||||
} else {
|
||||
if (configuredFile === undefined || configuredFile === "") {
|
||||
throw new Error("missing required secret keyring setting: HUB_SECRET_KEYRING_FILE");
|
||||
}
|
||||
file = configuredFile;
|
||||
}
|
||||
|
||||
return loadLocalSecretKeyringFile(file);
|
||||
}
|
||||
|
||||
export async function loadLocalSecretKeyringFile(file: string): Promise<LocalSecretKeyring> {
|
||||
let source: string;
|
||||
try {
|
||||
source = await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
throw new Error("local secret keyring is unavailable", { cause: error });
|
||||
}
|
||||
|
||||
return parseKeyring(source);
|
||||
}
|
||||
|
||||
export class LocalSecretEnvelope {
|
||||
constructor(private readonly keyring: LocalSecretKeyring) {}
|
||||
|
||||
get activeKeyId(): string {
|
||||
return this.keyring.activeKeyId;
|
||||
}
|
||||
|
||||
encryptJson(binding: SecretBinding, value: unknown): SecretEnvelopeV1 {
|
||||
validateBinding(binding);
|
||||
const kek = requireKey(this.keyring, this.keyring.activeKeyId);
|
||||
const dek = randomBytes(KEY_BYTES);
|
||||
const payloadBytes = Buffer.from(JSON.stringify(value), "utf8");
|
||||
|
||||
try {
|
||||
return {
|
||||
version: 1,
|
||||
algorithm: "AES-256-GCM",
|
||||
keyId: this.keyring.activeKeyId,
|
||||
wrappedDek: encryptPart(kek, dek, additionalData(binding, "dek")),
|
||||
payload: encryptPart(dek, payloadBytes, additionalData(binding, "payload")),
|
||||
};
|
||||
} finally {
|
||||
dek.fill(0);
|
||||
payloadBytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
decryptJson<T = unknown>(binding: SecretBinding, envelope: SecretEnvelopeV1): T {
|
||||
validateBinding(binding);
|
||||
validateEnvelope(envelope);
|
||||
const kek = requireKey(this.keyring, envelope.keyId);
|
||||
const dek = decryptPart(kek, envelope.wrappedDek, additionalData(binding, "dek"));
|
||||
|
||||
try {
|
||||
const plaintext = decryptPart(dek, envelope.payload, additionalData(binding, "payload"));
|
||||
try {
|
||||
return JSON.parse(plaintext.toString("utf8")) as T;
|
||||
} catch (error) {
|
||||
throw new Error("secret envelope payload is invalid", { cause: error });
|
||||
} finally {
|
||||
plaintext.fill(0);
|
||||
}
|
||||
} finally {
|
||||
dek.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
rewrap(binding: SecretBinding, envelope: SecretEnvelopeV1): SecretEnvelopeV1 {
|
||||
validateBinding(binding);
|
||||
validateEnvelope(envelope);
|
||||
const previousKek = requireKey(this.keyring, envelope.keyId);
|
||||
const activeKek = requireKey(this.keyring, this.keyring.activeKeyId);
|
||||
const dek = decryptPart(previousKek, envelope.wrappedDek, additionalData(binding, "dek"));
|
||||
|
||||
try {
|
||||
return {
|
||||
...envelope,
|
||||
keyId: this.keyring.activeKeyId,
|
||||
wrappedDek: encryptPart(activeKek, dek, additionalData(binding, "dek")),
|
||||
};
|
||||
} finally {
|
||||
dek.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseKeyring(source: string): LocalSecretKeyring {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error("invalid local secret keyring", { cause: error });
|
||||
}
|
||||
|
||||
if (!isRecord(value) || value["version"] !== 1 || !isNonEmptyString(value["activeKeyId"]) ||
|
||||
!isRecord(value["keys"])) {
|
||||
throw new Error("invalid local secret keyring");
|
||||
}
|
||||
|
||||
const keys = new Map<string, Buffer>();
|
||||
for (const [keyId, encodedKey] of Object.entries(value["keys"])) {
|
||||
if (!isNonEmptyString(keyId) || !isNonEmptyString(encodedKey)) {
|
||||
throw new Error("invalid local secret keyring");
|
||||
}
|
||||
const key = decodeBase64(encodedKey, "invalid local secret keyring");
|
||||
if (key.byteLength !== KEY_BYTES) {
|
||||
throw new Error("invalid local secret keyring");
|
||||
}
|
||||
keys.set(keyId, key);
|
||||
}
|
||||
|
||||
const activeKeyId = value["activeKeyId"];
|
||||
if (!keys.has(activeKeyId)) {
|
||||
throw new Error("local secret keyring active key is unavailable");
|
||||
}
|
||||
return { activeKeyId, keys };
|
||||
}
|
||||
|
||||
function encryptPart(key: Buffer, plaintext: Buffer, aad: Buffer): EncryptedPart {
|
||||
const nonce = randomBytes(NONCE_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES });
|
||||
cipher.setAAD(aad);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return {
|
||||
nonce: nonce.toString("base64"),
|
||||
ciphertext: ciphertext.toString("base64"),
|
||||
authTag: authTag.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptPart(key: Buffer, encrypted: EncryptedPart, aad: Buffer): Buffer {
|
||||
try {
|
||||
const nonce = decodeBase64(encrypted.nonce, "secret envelope is malformed");
|
||||
const ciphertext = decodeBase64(encrypted.ciphertext, "secret envelope is malformed");
|
||||
const authTag = decodeBase64(encrypted.authTag, "secret envelope is malformed");
|
||||
if (nonce.byteLength !== NONCE_BYTES || authTag.byteLength !== AUTH_TAG_BYTES) {
|
||||
throw new Error("secret envelope is malformed");
|
||||
}
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES });
|
||||
decipher.setAAD(aad);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "secret envelope is malformed") {
|
||||
throw error;
|
||||
}
|
||||
throw new Error("secret envelope authentication failed", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function additionalData(binding: SecretBinding, layer: "dek" | "payload"): Buffer {
|
||||
return Buffer.from(JSON.stringify([
|
||||
"cph-secret-envelope",
|
||||
1,
|
||||
layer,
|
||||
binding.purpose,
|
||||
binding.organizationId,
|
||||
binding.connectionId,
|
||||
binding.secretVersionId,
|
||||
]), "utf8");
|
||||
}
|
||||
|
||||
function requireKey(keyring: LocalSecretKeyring, keyId: string): Buffer {
|
||||
const key = keyring.keys.get(keyId);
|
||||
if (key === undefined) {
|
||||
throw new Error("secret envelope key is unavailable");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function validateBinding(binding: SecretBinding): void {
|
||||
if (!isNonEmptyString(binding.purpose) || !isNonEmptyString(binding.organizationId) ||
|
||||
!isNonEmptyString(binding.connectionId) || !isNonEmptyString(binding.secretVersionId)) {
|
||||
throw new Error("secret envelope binding is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function validateEnvelope(envelope: SecretEnvelopeV1): void {
|
||||
if (!isRecord(envelope) || envelope["version"] !== 1 || envelope["algorithm"] !== "AES-256-GCM" ||
|
||||
!isNonEmptyString(envelope["keyId"]) || !isEncryptedPart(envelope["wrappedDek"]) ||
|
||||
!isEncryptedPart(envelope["payload"])) {
|
||||
throw new Error("secret envelope is malformed");
|
||||
}
|
||||
}
|
||||
|
||||
function isEncryptedPart(value: unknown): value is EncryptedPart {
|
||||
return isRecord(value) && isNonEmptyString(value["nonce"]) &&
|
||||
isNonEmptyString(value["ciphertext"]) && isNonEmptyString(value["authTag"]);
|
||||
}
|
||||
|
||||
function decodeBase64(value: string, errorMessage: string): Buffer {
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const decoded = Buffer.from(value, "base64");
|
||||
if (decoded.toString("base64") !== value) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim() !== "";
|
||||
}
|
||||
+4
-2
@@ -10,8 +10,10 @@
|
||||
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
|
||||
*
|
||||
* Env (see .env.example):
|
||||
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
|
||||
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
|
||||
* DATABASE_URL, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
|
||||
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL.
|
||||
* Provider credentials are org-scoped encrypted records; production receives
|
||||
* the local master-key keyring only through systemd CREDENTIALS_DIRECTORY.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { startHub } from "./hub.js";
|
||||
|
||||
+138
-32
@@ -1,22 +1,37 @@
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import { decryptStoredProviderCredential } from "../connections/providerConnections.js";
|
||||
import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
|
||||
export type Env = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
type EnvSource = Env | (() => Env);
|
||||
|
||||
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||
const DEFAULT_AGENT_MAX_TURNS = 25;
|
||||
|
||||
export interface ProviderRuntimeSettings {
|
||||
readonly id: string;
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
readonly sdkEnv: Record<string, string | undefined>;
|
||||
openAgentLease(input: { readonly runId: string }): Promise<AgentProviderLease>;
|
||||
}
|
||||
|
||||
export interface ProviderLeaseContext {
|
||||
readonly providerId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export type ProviderLeaseFactory = (
|
||||
credential: ProviderUpstreamCredential,
|
||||
context: ProviderLeaseContext,
|
||||
) => Promise<AgentProviderLease>;
|
||||
|
||||
const DEFAULT_PROVIDER_LEASE_FACTORY: ProviderLeaseFactory = (credential) =>
|
||||
openProviderProxyLease(credential);
|
||||
|
||||
export interface RuntimeScope {
|
||||
readonly projectId?: string;
|
||||
}
|
||||
@@ -44,26 +59,11 @@ export class EnvRuntimeSettings implements RuntimeSettings {
|
||||
}
|
||||
|
||||
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
|
||||
void providerId;
|
||||
void scope;
|
||||
if (providerId !== "openrouter") {
|
||||
throw new Error(`unknown provider runtime settings: ${providerId}`);
|
||||
}
|
||||
const env = this.envSource();
|
||||
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
|
||||
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
|
||||
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl,
|
||||
authToken,
|
||||
anthropicApiKey,
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
ANTHROPIC_API_KEY: anthropicApiKey,
|
||||
},
|
||||
};
|
||||
throw new Error(
|
||||
"process-global provider credentials are disabled; use the project-scoped provider resolver",
|
||||
);
|
||||
}
|
||||
|
||||
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
||||
@@ -79,10 +79,124 @@ export class EnvRuntimeSettings implements RuntimeSettings {
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseRuntimeSettings implements RuntimeSettings {
|
||||
private readonly envSettings: EnvRuntimeSettings;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
env: EnvSource = process.env,
|
||||
private readonly leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY,
|
||||
) {
|
||||
this.envSettings = new EnvRuntimeSettings(env);
|
||||
}
|
||||
|
||||
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
|
||||
const projectId = scope?.projectId?.trim();
|
||||
if (projectId === undefined || projectId === "") {
|
||||
throw new Error("projectId is required to resolve provider credentials");
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await loadActiveProviderSecret(tx, projectId, providerId);
|
||||
});
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
openAgentLease: async ({ runId }) => this.prisma.$transaction(async (tx) => {
|
||||
const resolved = await loadActiveProviderSecret(tx, projectId, providerId);
|
||||
const secretVersion = resolved.connection.activeSecretVersion;
|
||||
const credential = decryptStoredProviderCredential(this.secrets, {
|
||||
organizationId: resolved.organizationId,
|
||||
connectionId: resolved.connection.id,
|
||||
providerId,
|
||||
secretVersionId: secretVersion.id,
|
||||
envelopeVersion: secretVersion.envelopeVersion,
|
||||
keyId: secretVersion.keyId,
|
||||
envelope: secretVersion.envelope,
|
||||
});
|
||||
return this.leaseFactory(
|
||||
{
|
||||
baseUrl: credential.baseUrl,
|
||||
authToken: credential.authToken,
|
||||
anthropicApiKey: credential.anthropicApiKey,
|
||||
},
|
||||
{ providerId, projectId, runId },
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
||||
return this.envSettings.modelRegistry(scope);
|
||||
}
|
||||
|
||||
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||
return this.envSettings.runPolicy(input);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveProviderSecret(
|
||||
tx: Prisma.TransactionClient,
|
||||
projectId: string,
|
||||
providerId: string,
|
||||
): Promise<{
|
||||
readonly organizationId: string;
|
||||
readonly connection: {
|
||||
readonly id: string;
|
||||
readonly activeSecretVersion: {
|
||||
readonly id: string;
|
||||
readonly connectionId: string;
|
||||
readonly envelopeVersion: number;
|
||||
readonly keyId: string;
|
||||
readonly envelope: Prisma.JsonValue;
|
||||
readonly retiredAt: Date | null;
|
||||
};
|
||||
};
|
||||
}> {
|
||||
const project = await tx.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { organizationId: true, archivedAt: true },
|
||||
});
|
||||
if (project === null || project.archivedAt !== null) {
|
||||
throw new Error(`active project not found: ${projectId}`);
|
||||
}
|
||||
await lockActiveOrganization(tx, project.organizationId);
|
||||
|
||||
const connection = await tx.organizationProviderConnection.findUnique({
|
||||
where: {
|
||||
organizationId_providerId: {
|
||||
organizationId: project.organizationId,
|
||||
providerId,
|
||||
},
|
||||
},
|
||||
include: { activeSecretVersion: true },
|
||||
});
|
||||
const activeSecretVersion = connection?.activeSecretVersion;
|
||||
if (connection === null || connection.status !== "ACTIVE" ||
|
||||
activeSecretVersion === null || activeSecretVersion === undefined || activeSecretVersion.retiredAt !== null ||
|
||||
activeSecretVersion.connectionId !== connection.id) {
|
||||
throw new Error(`active provider connection not found: ${providerId} for project ${projectId}`);
|
||||
}
|
||||
return {
|
||||
organizationId: project.organizationId,
|
||||
connection: { id: connection.id, activeSecretVersion },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
|
||||
return new EnvRuntimeSettings(env);
|
||||
}
|
||||
|
||||
export function createDatabaseRuntimeSettings(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
env: EnvSource = process.env,
|
||||
leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY,
|
||||
): RuntimeSettings {
|
||||
return new DatabaseRuntimeSettings(prisma, secrets, env, leaseFactory);
|
||||
}
|
||||
|
||||
export function defaultSonnetModel(env: Env = process.env): string {
|
||||
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
|
||||
}
|
||||
@@ -109,14 +223,6 @@ function readEnv(env: Env, name: string): string | undefined {
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
function requireSetting(env: Env, name: string): string {
|
||||
const value = readEnv(env, name);
|
||||
if (value === undefined) {
|
||||
throw new Error(`missing required runtime setting: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
|
||||
const raw = readEnv(env, name);
|
||||
if (raw === undefined) return fallback;
|
||||
|
||||
Reference in New Issue
Block a user