UNPKG

@accounter/server

Version:
370 lines (333 loc) • 12.7 kB
import { AsyncLocalStorage } from 'node:async_hooks'; import { Mutex } from 'async-mutex'; import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import type { PoolClient, QueryResult, QueryResultRow } from 'pg'; import { resolveWriteTargetBusinessId } from '../../shared/helpers/auth-scope.js'; import type { AuthContext } from '../../shared/types/auth.js'; import { AuthContextProvider } from '../auth/providers/auth-context.provider.js'; import { DBProvider } from './db.provider.js'; /** * TenantAwareDBClient enforces Row-Level Security (RLS) by setting PostgreSQL * session variables for every database transaction. * * RLS Enforcement: * - app.current_business_id: Set to the authenticated user's active business * - app.current_user_id: Set to the authenticated user's ID (or NULL for API keys) * - app.auth_type: Set to 'jwt' or 'apiKey' * * **Usage:** * Inject into Operation-scoped providers via constructor DI: * * @example * @Injectable({ scope: Scope.Operation }) * class BusinessesProvider { * constructor(private db: TenantAwareDBClient) {} * * async getBusinesses() { * return this.db.query('SELECT * FROM businesses') * } * } * * Transaction Management: * - Supports nested transactions via SAVEPOINTs * - Automatically rolls back on error * - Automatically releases connection on dispose * * **DO NOT** access from Yoga context - use DI injection instead. * * @throws {GraphQLError} UNAUTHENTICATED if auth context is null */ @Injectable({ scope: Scope.Operation, global: true, }) export class TenantAwareDBClient { private mutex = new Mutex(); private storage = new AsyncLocalStorage<boolean>(); private activeClient: PoolClient | null = null; private transactionDepth = 0; private isDisposed = false; private initializationPromise: Promise<void> | null = null; private authContext: AuthContext | null = null; private authContextInitialized = false; constructor( private dbProvider: DBProvider, private authContextProvider: AuthContextProvider, ) {} /** * Execute a query with RLS enforcement. * If a transaction is already active, uses it. * If not, starts a new transaction/session, executes the query, and commits. */ public async query<T extends QueryResultRow = QueryResultRow>( text: string, params?: unknown[], ): Promise<QueryResult<T> & { rowCount: number }> { this.ensureNotDisposed(); await this.ensureAuthContext(); if (!this.authContext) { throw new GraphQLError( 'Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } }, ); } if (this.storage.getStore() && this.activeClient) { const result = await this.activeClient.query<T>(text, params); return { ...result, rowCount: result.rowCount ?? 0 }; } return this.transaction(async client => { const result = await client.query<T>(text, params); return { ...result, rowCount: result.rowCount ?? 0 }; }); } /** * Execute a function within a transaction block. * Handles nested transactions using SAVEPOINTs. */ public async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> { this.ensureNotDisposed(); await this.ensureAuthContext(); if (!this.authContext) { throw new GraphQLError( 'Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } }, ); } if (this.storage.getStore()) { return this.executeTransactionInternal(fn); } return this.mutex.runExclusive(() => { this.ensureNotDisposed(); return this.storage.run(true, () => { return this.executeTransactionInternal(fn); }); }); } private async executeTransactionInternal<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> { // 1. Wait for initialization if in progress if (this.initializationPromise) { try { await this.initializationPromise; } catch { // Initialization failed. // We proceed to check (!this.activeClient) which will re-attempt or fail. } } // 2. Initialize if needed if (!this.activeClient) { this.initializationPromise = (async () => { const client = await this.dbProvider.pool.connect(); try { await client.query('BEGIN'); await this.setRLSVariables(client); this.activeClient = client; } catch (error) { client.release(); throw error; } })(); try { await this.initializationPromise; } finally { this.initializationPromise = null; } } // Guard: activeClient must be set by now if (!this.activeClient) { throw new Error('Failed to initialize database client'); } const client = this.activeClient; this.transactionDepth++; let success = false; try { let result: T; // Use a savepoint for all nested scopes (depth > 1) to isolate failures // and allow partial success/failure within the shared transaction. if (this.transactionDepth > 1) { const savepointName = `sp_${this.transactionDepth}`; try { await client.query(`SAVEPOINT ${savepointName}`); result = await fn(client); await client.query(`RELEASE SAVEPOINT ${savepointName}`); } catch (error) { await client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`); throw error; } } else { // Root scope (depth === 1) runs directly in the main transaction result = await fn(client); } success = true; return result; } catch (error) { // If we are the last active scope and an error occurred, we deliberately ROLLBACK. // Note: If depth > 1, the inner try/catch already handled the savepoint rollback, // so this block only handles the root transaction failure or unhandled critical errors. if (this.transactionDepth === 1) { // Ensure we don't try to rollback if already disposed or closed try { await client.query('ROLLBACK'); } catch { // Ignore rollback errors (e.g. if connection closed) } } throw error; } finally { this.transactionDepth--; // If we are the last scope to exit, we are responsible for cleanup. if (this.transactionDepth === 0) { if (success && !this.isDisposed) { try { await client.query('COMMIT'); } catch (commitError) { console.error('Failed to commit transaction:', commitError); } } client.release(); this.activeClient = null; } } } /** * Set PostgreSQL session variables for Row-Level Security. */ private async setRLSVariables(client: PoolClient): Promise<void> { if (!this.authContext) { throw new GraphQLError('Unauthenticated', { extensions: { code: 'UNAUTHENTICATED', }, }); } const { tenant, user, authType, activeReadScope } = this.authContext ?? {}; // Write-target: the single business this request owns / writes to, derived // from the primary tenant business and the active scope. The auth context // already re-points `tenant.businessId` to this value; resolving it here // again keeps the RLS session correct as defense-in-depth. const businessIdValue = resolveWriteTargetBusinessId(tenant?.businessId, activeReadScope); if (!businessIdValue) { throw new Error('Missing businessId in AuthContext'); } // API keys use a non-UUID identifier (e.g. "api-key:<id>") for app-level tracing. // The DB helper get_current_user_id() casts app.current_user_id to UUID and handles // empty string via NULLIF(..., ''), so we pass '' for API key sessions to avoid a // runtime cast error while explicitly clearing the setting. const userIdValue = authType === 'apiKey' ? '' : (user?.userId ?? null); // Read scope: the businesses this request may read from, serialized as a // Postgres array literal ('{uuid1,uuid2}') for get_current_business_scope(). // When empty/absent we pass '' so the DB helper falls back to the single // write-target business. Writes remain pinned to app.current_business_id. const readScopeValue = activeReadScope && activeReadScope.businessIds.length > 0 ? `{${activeReadScope.businessIds.map(id => `"${id.replace(/"/g, '\\"')}"`).join(',')}}` : ''; await client.query( ` SELECT set_config('app.current_business_id', $1, true), set_config('app.current_user_id', $2, true), set_config('app.auth_type', $3, true), set_config('app.current_business_scope', $4, true); `, [businessIdValue, userIdValue, authType, readScopeValue], ); } /** * Manually dispose the client. */ public async dispose(): Promise<void> { if (this.isDisposed) return; // In normal operation, activeClient should already be null because // executeTransactionInternal's finally block releases it. // If it's null, we can skip the expensive mutex acquisition. if (!this.activeClient) { this.isDisposed = true; return; } // If we get here, activeClient is not null, which means the transaction // didn't clean up properly. This is an abnormal situation (e.g., connection leak, // error in finally block, or dispose called prematurely). console.warn( 'TenantAwareDBClient.dispose() called with activeClient still set. Forcing cleanup.', ); // Use a timeout or race to prevent hanging indefinitely during disposal // If a query is stuck, we don't want to block the entire server request handler. const TIMEOUT_MS = 5000; let release: (() => void) | undefined; try { release = await Promise.race([ this.mutex.acquire(), new Promise<() => void>((_, reject) => setTimeout(() => reject(new Error('Timeout acquiring mutex')), TIMEOUT_MS), ), ]); } catch (e) { console.warn( 'Timeout acquiring mutex during TenantAwareDBClient disposal. Connection may be in use.', e, ); // Don't force cleanup - let executeTransactionInternal's finally block handle it. // Forcing cleanup here creates a race condition where we destroy a connection // that's actively being used, causing query failures. // However, mark as disposed to prevent further usage of this instance. this.isDisposed = true; return; } try { if (this.isDisposed) return; if (this.activeClient) { let hadRollbackError = false; try { // Attempt rollback, but catch errors if connection is busy/closed await Promise.race([ this.activeClient.query('ROLLBACK'), new Promise((_, reject) => setTimeout(() => reject(new Error('Rollback timeout')), 1000), ), ]); } catch (error) { hadRollbackError = true; console.error('Error disposing TenantAwareDBClient (rollback failed):', error); } finally { try { // Only destroy the connection (release(true)) if there was an error. // For normal disposal, return it to the pool (release()) to avoid pool exhaustion. // pg's release(true) removes the connection from the pool permanently. this.activeClient.release(hadRollbackError); } catch (e) { console.error('Error releasing client during disposal:', e); } this.activeClient = null; } } this.isDisposed = true; } finally { if (release) { release(); } } } private ensureNotDisposed() { if (this.isDisposed) { throw new Error('TenantAwareDBClient is already disposed'); } } /** * Lazy initialization of auth context on first use. * This ensures the async provider is called only when needed. */ private async ensureAuthContext(): Promise<void> { if (this.authContextInitialized) { return; } if (!this.authContextProvider) { throw new GraphQLError( 'Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } }, ); } this.authContext = await this.authContextProvider.getAuthContext(); this.authContextInitialized = true; } }