fix(api): resolve Docker startup failures (secrets, Redis, Prisma)
- Pass BETTER_AUTH_SECRET through all 6 docker-compose files to API container - Fix BullModule to parse VALKEY_URL instead of VALKEY_HOST/VALKEY_PORT, matching all other Redis consumers in the codebase - Migrate Prisma encryption from removed $use() middleware to $extends() client extensions (Prisma 6.x compatibility), keeping extends PrismaClient pattern with only account and llmProviderInstance getters overridden Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,10 +60,13 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
}),
|
||||
// BullMQ job queue configuration
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
host: process.env.VALKEY_HOST ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
|
||||
},
|
||||
connection: (() => {
|
||||
const url = new URL(process.env.VALKEY_URL ?? "redis://localhost:6379");
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port || "6379", 10),
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
TelemetryModule,
|
||||
PrismaModule,
|
||||
|
||||
144
apps/api/src/prisma/account-encryption.extension.ts
Normal file
144
apps/api/src/prisma/account-encryption.extension.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Account Encryption Extension
|
||||
*
|
||||
* Prisma Client Extension that transparently encrypts/decrypts OAuth tokens
|
||||
* in the Account table using VaultService (OpenBao Transit or AES-256-GCM fallback).
|
||||
*
|
||||
* Migrated from $use() middleware (removed in Prisma 5+) to $extends() API.
|
||||
*/
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { VaultService } from "../vault/vault.service";
|
||||
import { TransitKey } from "../vault/vault.constants";
|
||||
|
||||
const TOKEN_FIELDS = ["accessToken", "refreshToken", "idToken"] as const;
|
||||
|
||||
interface AccountData extends Record<string, unknown> {
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
idToken?: string | null;
|
||||
encryptionVersion?: string | null;
|
||||
}
|
||||
|
||||
export function createAccountEncryptionExtension(vaultService: VaultService) {
|
||||
const logger = new Logger("AccountEncryptionExtension");
|
||||
|
||||
return Prisma.defineExtension({
|
||||
name: "account-encryption",
|
||||
query: {
|
||||
account: {
|
||||
async create({ args, query }) {
|
||||
await encryptTokens(args.data as AccountData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async update({ args, query }) {
|
||||
await encryptTokens(args.data as AccountData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async updateMany({ args, query }) {
|
||||
await encryptTokens(args.data as AccountData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async upsert({ args, query }) {
|
||||
await encryptTokens(args.create as AccountData, vaultService);
|
||||
await encryptTokens(args.update as AccountData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async findUnique({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result && typeof result === "object") {
|
||||
await decryptTokens(result as AccountData, vaultService, logger);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async findFirst({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result && typeof result === "object") {
|
||||
await decryptTokens(result as AccountData, vaultService, logger);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async findMany({ args, query }) {
|
||||
const results = await query(args);
|
||||
for (const account of results) {
|
||||
await decryptTokens(account as AccountData, vaultService, logger);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function encryptTokens(data: AccountData, vaultService: VaultService): Promise<void> {
|
||||
let encrypted = false;
|
||||
let encryptionVersion: "aes" | "vault" | null = null;
|
||||
|
||||
for (const field of TOKEN_FIELDS) {
|
||||
const value = data[field];
|
||||
if (value == null) continue;
|
||||
if (typeof value === "string" && isEncrypted(value)) continue;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const ciphertext = await vaultService.encrypt(value, TransitKey.ACCOUNT_TOKENS);
|
||||
data[field] = ciphertext;
|
||||
encrypted = true;
|
||||
encryptionVersion = ciphertext.startsWith("vault:v1:") ? "vault" : "aes";
|
||||
}
|
||||
}
|
||||
|
||||
if (encrypted && encryptionVersion) {
|
||||
data.encryptionVersion = encryptionVersion;
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptTokens(
|
||||
account: AccountData,
|
||||
vaultService: VaultService,
|
||||
_logger: Logger
|
||||
): Promise<void> {
|
||||
const shouldDecrypt =
|
||||
account.encryptionVersion === "aes" || account.encryptionVersion === "vault";
|
||||
|
||||
for (const field of TOKEN_FIELDS) {
|
||||
const value = account[field];
|
||||
if (value == null) continue;
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (shouldDecrypt) {
|
||||
try {
|
||||
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(
|
||||
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
|
||||
);
|
||||
}
|
||||
} else if (!account.encryptionVersion && isEncrypted(value)) {
|
||||
try {
|
||||
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(
|
||||
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
if (isAESEncrypted(value)) return true;
|
||||
if (value.startsWith("vault:v1:")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAESEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const parts = value.split(":");
|
||||
if (parts.length !== 3) return false;
|
||||
return parts.every((part) => /^[0-9a-f]+$/i.test(part));
|
||||
}
|
||||
119
apps/api/src/prisma/llm-encryption.extension.ts
Normal file
119
apps/api/src/prisma/llm-encryption.extension.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* LLM Encryption Extension
|
||||
*
|
||||
* Prisma Client Extension that transparently encrypts/decrypts LLM provider API keys
|
||||
* in the LlmProviderInstance.config JSON field using VaultService.
|
||||
*
|
||||
* Migrated from $use() middleware (removed in Prisma 5+) to $extends() API.
|
||||
*/
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { VaultService } from "../vault/vault.service";
|
||||
import { TransitKey } from "../vault/vault.constants";
|
||||
|
||||
interface LlmProviderInstanceData extends Record<string, unknown> {
|
||||
config?: LlmProviderConfig;
|
||||
}
|
||||
|
||||
interface LlmProviderConfig {
|
||||
apiKey?: string | null;
|
||||
endpoint?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function createLlmEncryptionExtension(vaultService: VaultService) {
|
||||
const logger = new Logger("LlmEncryptionExtension");
|
||||
|
||||
return Prisma.defineExtension({
|
||||
name: "llm-encryption",
|
||||
query: {
|
||||
llmProviderInstance: {
|
||||
async create({ args, query }) {
|
||||
await encryptConfig(args.data as LlmProviderInstanceData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async update({ args, query }) {
|
||||
await encryptConfig(args.data as LlmProviderInstanceData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async updateMany({ args, query }) {
|
||||
await encryptConfig(args.data as LlmProviderInstanceData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async upsert({ args, query }) {
|
||||
await encryptConfig(args.create as LlmProviderInstanceData, vaultService);
|
||||
await encryptConfig(args.update as LlmProviderInstanceData, vaultService);
|
||||
return query(args);
|
||||
},
|
||||
async findUnique({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result && typeof result === "object") {
|
||||
await decryptConfig(result as LlmProviderInstanceData, vaultService, logger);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async findFirst({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result && typeof result === "object") {
|
||||
await decryptConfig(result as LlmProviderInstanceData, vaultService, logger);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async findMany({ args, query }) {
|
||||
const results = await query(args);
|
||||
for (const instance of results) {
|
||||
await decryptConfig(instance as LlmProviderInstanceData, vaultService, logger);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function encryptConfig(
|
||||
data: LlmProviderInstanceData,
|
||||
vaultService: VaultService
|
||||
): Promise<void> {
|
||||
if (!data.config || typeof data.config !== "object") return;
|
||||
const config = data.config;
|
||||
if (!config.apiKey || typeof config.apiKey !== "string") return;
|
||||
if (isEncrypted(config.apiKey)) return;
|
||||
|
||||
config.apiKey = await vaultService.encrypt(config.apiKey, TransitKey.LLM_CONFIG);
|
||||
}
|
||||
|
||||
async function decryptConfig(
|
||||
instance: LlmProviderInstanceData,
|
||||
vaultService: VaultService,
|
||||
_logger: Logger
|
||||
): Promise<void> {
|
||||
if (!instance.config || typeof instance.config !== "object") return;
|
||||
const config = instance.config;
|
||||
if (!config.apiKey || typeof config.apiKey !== "string") return;
|
||||
if (!isEncrypted(config.apiKey)) return;
|
||||
|
||||
try {
|
||||
config.apiKey = await vaultService.decrypt(config.apiKey, TransitKey.LLM_CONFIG);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new Error(
|
||||
`Failed to decrypt LLM provider configuration. Please re-enter the API key. Details: ${errorMsg}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
if (value.startsWith("vault:v1:")) return true;
|
||||
if (isAESEncrypted(value)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAESEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const parts = value.split(":");
|
||||
if (parts.length !== 3) return false;
|
||||
return parts.every((part) => /^[0-9a-f]+$/i.test(part));
|
||||
}
|
||||
@@ -48,10 +48,14 @@ describe("PrismaService", () => {
|
||||
});
|
||||
|
||||
describe("onModuleInit", () => {
|
||||
it("should connect to the database", async () => {
|
||||
it("should connect to the database and register encryption extensions", async () => {
|
||||
const connectSpy = vi.spyOn(service, "$connect").mockResolvedValue(undefined);
|
||||
// Mock $use to prevent middleware registration errors in tests
|
||||
(service as any).$use = vi.fn();
|
||||
// Mock $extends to return a mock client (extensions chain)
|
||||
const mockExtendedClient = { account: {}, llmProviderInstance: {} };
|
||||
vi.spyOn(service, "$extends").mockReturnValue({
|
||||
...mockExtendedClient,
|
||||
$extends: vi.fn().mockReturnValue(mockExtendedClient),
|
||||
} as never);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { VaultService } from "../vault/vault.service";
|
||||
import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware";
|
||||
import { registerLlmEncryptionMiddleware } from "./llm-encryption.middleware";
|
||||
import { createAccountEncryptionExtension } from "./account-encryption.extension";
|
||||
import { createLlmEncryptionExtension } from "./llm-encryption.extension";
|
||||
|
||||
/**
|
||||
* Prisma service that manages database connection lifecycle
|
||||
@@ -11,11 +11,20 @@ import { registerLlmEncryptionMiddleware } from "./llm-encryption.middleware";
|
||||
* IMPORTANT: VaultService is required (not optional) for encryption/decryption
|
||||
* of sensitive Account tokens. It automatically falls back to AES-256-GCM when
|
||||
* OpenBao is unavailable.
|
||||
*
|
||||
* Encryption is handled via Prisma Client Extensions ($extends) on the `account`
|
||||
* and `llmProviderInstance` models. The extended client is stored in `_xClient`
|
||||
* and only those two model getters are overridden — all other models use the
|
||||
* base PrismaClient inheritance, preserving full type safety.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
// Extended client with encryption hooks for account + llmProviderInstance
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _xClient: any = null;
|
||||
|
||||
constructor(private readonly vaultService: VaultService) {
|
||||
super({
|
||||
log: process.env.NODE_ENV === "development" ? ["query", "info", "warn", "error"] : ["error"],
|
||||
@@ -30,20 +39,30 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
await this.$connect();
|
||||
this.logger.log("Database connection established");
|
||||
|
||||
// Register Account token encryption middleware
|
||||
// VaultService provides OpenBao Transit encryption with AES-256-GCM fallback
|
||||
registerAccountEncryptionMiddleware(this, this.vaultService);
|
||||
this.logger.log("Account encryption middleware registered");
|
||||
|
||||
// Register LLM provider API key encryption middleware
|
||||
registerLlmEncryptionMiddleware(this, this.vaultService);
|
||||
this.logger.log("LLM encryption middleware registered");
|
||||
// Register encryption extensions (replaces removed $use() middleware)
|
||||
this._xClient = this.$extends(createAccountEncryptionExtension(this.vaultService)).$extends(
|
||||
createLlmEncryptionExtension(this.vaultService)
|
||||
);
|
||||
this.logger.log("Encryption extensions registered (Account, LlmProviderInstance)");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to connect to database", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Override only the 2 models that need encryption hooks.
|
||||
// All other models (user, task, workspace, etc.) use base PrismaClient via inheritance.
|
||||
// Cast _xClient to PrismaClient to preserve the accessor return types for consumers.
|
||||
|
||||
override get account() {
|
||||
return this._xClient ? (this._xClient as PrismaClient).account : super.account;
|
||||
}
|
||||
|
||||
override get llmProviderInstance() {
|
||||
if (this._xClient) return (this._xClient as PrismaClient).llmProviderInstance;
|
||||
return super.llmProviderInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database when NestJS module is destroyed
|
||||
*/
|
||||
|
||||
@@ -85,6 +85,7 @@ services:
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
ports:
|
||||
- "${API_PORT:-3001}:${API_PORT:-3001}"
|
||||
|
||||
@@ -290,6 +290,7 @@ services:
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
OPENBAO_ADDR: ${OPENBAO_ADDR:-http://openbao:8200}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
|
||||
@@ -321,6 +321,7 @@ services:
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
OPENBAO_ADDR: ${OPENBAO_ADDR:-http://openbao:8200}
|
||||
ORCHESTRATOR_URL: ${ORCHESTRATOR_URL:-http://orchestrator:3001}
|
||||
|
||||
@@ -370,6 +370,8 @@ services:
|
||||
# JWT
|
||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
# Ollama (optional)
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
# OpenBao (optional)
|
||||
|
||||
@@ -380,6 +380,8 @@ services:
|
||||
# JWT
|
||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
# Ollama (optional)
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
# OpenBao (optional)
|
||||
|
||||
@@ -85,6 +85,7 @@ services:
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
ports:
|
||||
- "${API_PORT:-3001}:${API_PORT:-3001}"
|
||||
|
||||
Reference in New Issue
Block a user