diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a217a33..43733e3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/prisma/account-encryption.extension.ts b/apps/api/src/prisma/account-encryption.extension.ts new file mode 100644 index 0000000..9c835f3 --- /dev/null +++ b/apps/api/src/prisma/account-encryption.extension.ts @@ -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 { + 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 { + 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 { + 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)); +} diff --git a/apps/api/src/prisma/llm-encryption.extension.ts b/apps/api/src/prisma/llm-encryption.extension.ts new file mode 100644 index 0000000..ac8026d --- /dev/null +++ b/apps/api/src/prisma/llm-encryption.extension.ts @@ -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 { + 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 { + 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 { + 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)); +} diff --git a/apps/api/src/prisma/prisma.service.spec.ts b/apps/api/src/prisma/prisma.service.spec.ts index 212a541..bfe3925 100644 --- a/apps/api/src/prisma/prisma.service.spec.ts +++ b/apps/api/src/prisma/prisma.service.spec.ts @@ -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(); diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index a66f4f0..8ffad80 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -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 */ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fe4f799..7e2a2b2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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}" diff --git a/docker-compose.swarm.portainer.yml b/docker-compose.swarm.portainer.yml index 538fc21..df9c713 100644 --- a/docker-compose.swarm.portainer.yml +++ b/docker-compose.swarm.portainer.yml @@ -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} diff --git a/docker-compose.swarm.yml b/docker-compose.swarm.yml index 0763c48..d3e6a70 100644 --- a/docker-compose.swarm.yml +++ b/docker-compose.swarm.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index beca7d0..aac7c61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index f180fe9..d05e7a5 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -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) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index dd346a9..1f58afb 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -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}"