@accounter/server
Version:
Accounter GraphQL server
370 lines (333 loc) • 12.7 kB
text/typescript
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
*/
({
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;
}
}