Implements transparent encryption/decryption of OAuth tokens via Prisma middleware with progressive migration strategy. Core Implementation: - Prisma middleware transparently encrypts tokens on write, decrypts on read - Auto-detects ciphertext format: aes:iv:authTag:encrypted, vault:v1:..., or plaintext - Uses existing CryptoService (AES-256-GCM) for encryption - Progressive encryption: tokens encrypted as they're accessed/refreshed - Zero-downtime migration (schema change only, no bulk data migration) Security Features: - Startup key validation prevents silent data loss if ENCRYPTION_KEY changes - Secure error logging (no stack traces that could leak sensitive data) - Graceful handling of corrupted encrypted data - Idempotent encryption prevents double-encryption - Future-proofed for OpenBao Transit encryption (Phase 2) Token Fields Encrypted: - accessToken (OAuth access tokens) - refreshToken (OAuth refresh tokens) - idToken (OpenID Connect ID tokens) Backward Compatibility: - Existing plaintext tokens readable (encryptionVersion = NULL) - Progressive encryption on next write - BetterAuth integration transparent (middleware layer) Test Coverage: - 20 comprehensive unit tests (89.06% coverage) - Encryption/decryption scenarios - Null/undefined handling - Corrupted data handling - Legacy plaintext compatibility - Future vault format support - All CRUD operations (create, update, updateMany, upsert) Files Created: - apps/api/src/prisma/account-encryption.middleware.ts - apps/api/src/prisma/account-encryption.middleware.spec.ts - apps/api/prisma/migrations/20260207_encrypt_account_tokens/migration.sql Files Modified: - apps/api/src/prisma/prisma.service.ts (register middleware) - apps/api/src/prisma/prisma.module.ts (add CryptoService) - apps/api/src/federation/crypto.service.ts (add key validation) - apps/api/prisma/schema.prisma (add encryptionVersion) - .env.example (document ENCRYPTION_KEY) Fixes #352 Co-Authored-By: Claude Opus 4.6 <[email protected]>
161 lines
5.1 KiB
TypeScript
161 lines
5.1 KiB
TypeScript
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { CryptoService } from "../federation/crypto.service";
|
|
import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware";
|
|
|
|
/**
|
|
* Prisma service that manages database connection lifecycle
|
|
* Extends PrismaClient to provide connection management and health checks
|
|
*
|
|
* IMPORTANT: CryptoService is required (not optional) because it will throw
|
|
* if ENCRYPTION_KEY is not configured, providing fail-fast behavior.
|
|
*/
|
|
@Injectable()
|
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
|
private readonly logger = new Logger(PrismaService.name);
|
|
|
|
constructor(private readonly cryptoService: CryptoService) {
|
|
super({
|
|
log: process.env.NODE_ENV === "development" ? ["query", "info", "warn", "error"] : ["error"],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Connect to database when NestJS module initializes
|
|
*/
|
|
async onModuleInit() {
|
|
try {
|
|
await this.$connect();
|
|
this.logger.log("Database connection established");
|
|
|
|
// Register Account token encryption middleware
|
|
// CryptoService constructor will have already validated ENCRYPTION_KEY exists
|
|
registerAccountEncryptionMiddleware(this, this.cryptoService);
|
|
this.logger.log("Account encryption middleware registered");
|
|
} catch (error) {
|
|
this.logger.error("Failed to connect to database", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect from database when NestJS module is destroyed
|
|
*/
|
|
async onModuleDestroy() {
|
|
await this.$disconnect();
|
|
this.logger.log("Database connection closed");
|
|
}
|
|
|
|
/**
|
|
* Health check for database connectivity
|
|
* @returns true if database is accessible, false otherwise
|
|
*/
|
|
async isHealthy(): Promise<boolean> {
|
|
try {
|
|
await this.$queryRaw`SELECT 1`;
|
|
return true;
|
|
} catch (error) {
|
|
this.logger.error("Database health check failed", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get database connection info for debugging
|
|
* @returns Connection status and basic info
|
|
*/
|
|
async getConnectionInfo(): Promise<{
|
|
connected: boolean;
|
|
database?: string;
|
|
version?: string;
|
|
}> {
|
|
try {
|
|
const result = await this.$queryRaw<{ current_database: string; version: string }[]>`
|
|
SELECT current_database(), version()
|
|
`;
|
|
|
|
if (result.length > 0 && result[0]) {
|
|
const dbVersion = result[0].version.split(" ")[0];
|
|
return {
|
|
connected: true,
|
|
database: result[0].current_database,
|
|
...(dbVersion && { version: dbVersion }),
|
|
};
|
|
}
|
|
|
|
return { connected: false };
|
|
} catch (error) {
|
|
this.logger.error("Failed to get connection info", error);
|
|
return { connected: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets workspace context for Row-Level Security (RLS)
|
|
* Sets both user_id and workspace_id session variables for PostgreSQL RLS policies
|
|
*
|
|
* IMPORTANT: Must be called within a transaction or use the default client
|
|
* Session variables are transaction-scoped (SET LOCAL) for connection pool safety
|
|
*
|
|
* @param userId - The ID of the authenticated user
|
|
* @param workspaceId - The ID of the workspace context
|
|
* @param client - Optional Prisma client (uses 'this' if not provided)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* await prisma.$transaction(async (tx) => {
|
|
* await prisma.setWorkspaceContext(userId, workspaceId, tx);
|
|
* const tasks = await tx.task.findMany(); // Filtered by RLS
|
|
* });
|
|
* ```
|
|
*/
|
|
async setWorkspaceContext(
|
|
userId: string,
|
|
workspaceId: string,
|
|
client: PrismaClient = this
|
|
): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
|
}
|
|
|
|
/**
|
|
* Clears workspace context session variables
|
|
* Typically not needed as SET LOCAL is automatically cleared at transaction end
|
|
*
|
|
* @param client - Optional Prisma client (uses 'this' if not provided)
|
|
*/
|
|
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
|
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
|
}
|
|
|
|
/**
|
|
* Executes a function with workspace context set within a transaction
|
|
* Automatically sets the context and ensures proper scoping
|
|
*
|
|
* @param userId - The ID of the authenticated user
|
|
* @param workspaceId - The ID of the workspace context
|
|
* @param fn - Function to execute with context (receives transaction client)
|
|
* @returns The result of the function
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const tasks = await prisma.withWorkspaceContext(userId, workspaceId, async (tx) => {
|
|
* return tx.task.findMany({
|
|
* where: { status: 'IN_PROGRESS' }
|
|
* });
|
|
* });
|
|
* ```
|
|
*/
|
|
async withWorkspaceContext<T>(
|
|
userId: string,
|
|
workspaceId: string,
|
|
fn: (tx: PrismaClient) => Promise<T>
|
|
): Promise<T> {
|
|
return this.$transaction(async (tx) => {
|
|
await this.setWorkspaceContext(userId, workspaceId, tx as PrismaClient);
|
|
return fn(tx as PrismaClient);
|
|
});
|
|
}
|
|
}
|