/** * Mutation Guard * Validates CREATE, UPDATE, DELETE operations against RLS policies */ import type { PolicyRegistry } from '../policy/registry.js' import type { PolicyEvaluationContext, Operation } from '../policy/types.js' import type { RLSContext } from '../context/types.js' import { rlsContext } from '../context/manager.js' import { RLSPolicyViolation, RLSPolicyEvaluationError } from '../errors.js' /** * Default chunk size for parallel row filtering */ const DEFAULT_CHUNK_SIZE = 100 /** * Mutation guard * Validates mutations (CREATE, UPDATE, DELETE) against allow/deny/validate policies */ export class MutationGuard { constructor(private registry: PolicyRegistry) {} /** * Check if CREATE operation is allowed * * @param table - Table name * @param data - Data being inserted * @throws RLSPolicyViolation if access is denied * * @example * ```typescript * const guard = new MutationGuard(registry); * await guard.checkCreate('posts', { title: 'Hello', tenant_id: 1 }); * ``` */ async checkCreate(table: string, data: Record): Promise { await this.checkMutation(table, 'create', undefined, data) } /** * Check if an UPDATE operation is allowed by RLS policies. * * IMPORTANT: To prevent TOCTOU race conditions, always call this method * within the same transaction as the actual update operation. The existingRow * should be fetched with SELECT FOR UPDATE within the transaction. * * Without proper transaction isolation, the existingRow could be modified by * a concurrent operation between the time it was fetched and the time this * check runs, leading to policy decisions based on stale data. * * @param table - Target table name * @param existingRow - Current row data (should be fetched within same transaction) * @param data - New data being applied * @throws RLSPolicyViolation if the operation is not allowed * * @example * ```typescript * // RECOMMENDED: Use within a transaction with row locking * await db.transaction().execute(async (trx) => { * const existingPost = await trx * .selectFrom('posts') * .where('id', '=', 1) * .forUpdate() * .selectAll() * .executeTakeFirst(); * * await guard.checkUpdate('posts', existingPost, { title: 'Updated' }); * await trx.updateTable('posts').set({ title: 'Updated' }).where('id', '=', 1).execute(); * }); * ``` */ async checkUpdate( table: string, existingRow: Record, data: Record ): Promise { await this.checkMutation(table, 'update', existingRow, data) } /** * Check if a DELETE operation is allowed by RLS policies. * * IMPORTANT: To prevent TOCTOU race conditions, always call this method * within the same transaction as the actual delete operation. The existingRow * should be fetched with SELECT FOR UPDATE within the transaction. * * Without proper transaction isolation, the existingRow could be modified by * a concurrent operation between the time it was fetched and the time this * check runs, leading to policy decisions based on stale data (e.g., a row's * ownership could change, allowing an unauthorized delete). * * @param table - Target table name * @param existingRow - Current row data (should be fetched within same transaction) * @throws RLSPolicyViolation if the operation is not allowed * * @example * ```typescript * // RECOMMENDED: Use within a transaction with row locking * await db.transaction().execute(async (trx) => { * const existingPost = await trx * .selectFrom('posts') * .where('id', '=', 1) * .forUpdate() * .selectAll() * .executeTakeFirst(); * * await guard.checkDelete('posts', existingPost); * await trx.deleteFrom('posts').where('id', '=', 1).execute(); * }); * ``` */ async checkDelete(table: string, existingRow: Record): Promise { await this.checkMutation(table, 'delete', existingRow) } /** * Check if READ operation is allowed on a specific row * * @param table - Table name * @param row - Row to check access for * @returns true if access is allowed * * @example * ```typescript * const guard = new MutationGuard(registry); * const post = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst(); * const canRead = await guard.checkRead('posts', post); * ``` */ async checkRead(table: string, row: Record): Promise { try { await this.checkMutation(table, 'read', row) return true } catch (error) { // Both policy violations and evaluation errors result in denial if (error instanceof RLSPolicyViolation || error instanceof RLSPolicyEvaluationError) { return false } throw error } } /** * Generic check for any operation (helper for testing) * * @param operation - Operation type * @param table - Table name * @param row - Existing row (for UPDATE/DELETE/READ) * @param data - New data (for CREATE/UPDATE) * @returns true if access is allowed, false otherwise */ async canMutate( operation: Operation, table: string, row?: Record, data?: Record ): Promise { try { await this.checkMutation(table, operation, row, data) return true } catch (error) { // Both policy violations and evaluation errors result in denial if (error instanceof RLSPolicyViolation || error instanceof RLSPolicyEvaluationError) { return false } throw error } } /** * Validate mutation data (for validate policies) * * @param operation - Operation type * @param table - Table name * @param data - New data (for CREATE/UPDATE) * @param row - Existing row (for UPDATE) * @returns true if valid, false otherwise */ async validateMutation( operation: Operation, table: string, data: Record, row?: Record ): Promise { const ctx = rlsContext.getContextOrNull() if (!ctx) { return false } // System users bypass validation if (ctx.auth.isSystem) { return true } // Check skipFor roles const skipFor = this.registry.getSkipFor(table) if (skipFor.some(role => ctx.auth.roles.includes(role))) { return true } // Get validate policies for this operation const validates = this.registry.getValidates(table, operation) if (validates.length === 0) { return true } // Build evaluation context const evalCtx: PolicyEvaluationContext = { auth: ctx.auth, row: row, data: data, table: table, operation: operation, ...(ctx.meta !== undefined && { meta: ctx.meta as Record }) } // All validate policies must pass for (const validate of validates) { const result = await this.evaluatePolicy(validate.evaluate, evalCtx, validate.name) if (!result) { return false } } return true } /** * Check mutation against RLS policies * * @param table - Table name * @param operation - Operation type * @param row - Existing row (for UPDATE/DELETE/READ) * @param data - New data (for CREATE/UPDATE) * @throws RLSPolicyViolation if access is denied */ private async checkMutation( table: string, operation: Operation, row?: Record, data?: Record ): Promise { const ctx = rlsContext.getContextOrNull() if (!ctx) { throw new RLSPolicyViolation(operation, table, 'No RLS context available') } // System users bypass all checks if (ctx.auth.isSystem) { return } // Check if user role should skip RLS const skipFor = this.registry.getSkipFor(table) if (skipFor.some(role => ctx.auth.roles.includes(role))) { return } // Create evaluation context once and reuse for all policies const evalCtx = this.createEvalContext(ctx, table, operation, row, data) // Evaluate deny policies first (they override allows) const denies = this.registry.getDenies(table, operation) for (const deny of denies) { const result = await this.evaluatePolicy(deny.evaluate, evalCtx, deny.name) if (result) { throw new RLSPolicyViolation(operation, table, `Denied by policy: ${deny.name}`) } } // Evaluate validate policies (for CREATE/UPDATE) if ((operation === 'create' || operation === 'update') && data) { const validates = this.registry.getValidates(table, operation) for (const validate of validates) { const result = await this.evaluatePolicy(validate.evaluate, evalCtx, validate.name) if (!result) { throw new RLSPolicyViolation(operation, table, `Validation failed: ${validate.name}`) } } } // Evaluate allow policies const allows = this.registry.getAllows(table, operation) const defaultDeny = this.registry.hasDefaultDeny(table) if (defaultDeny && allows.length === 0) { throw new RLSPolicyViolation(operation, table, 'No allow policies defined (default deny)') } if (allows.length > 0) { let allowed = false for (const allow of allows) { const result = await this.evaluatePolicy(allow.evaluate, evalCtx, allow.name) if (result) { allowed = true break } } if (!allowed) { throw new RLSPolicyViolation(operation, table, 'No allow policies matched') } } } /** * Create policy evaluation context */ private createEvalContext( ctx: RLSContext, table: string, operation: Operation, row?: Record, data?: Record ): PolicyEvaluationContext { return { auth: ctx.auth, row, data, table, operation, ...(ctx.meta !== undefined && { meta: ctx.meta as Record }) } } /** * Evaluate a policy condition * * @param condition - Policy condition function * @param evalCtx - Policy evaluation context * @param policyName - Name of the policy being evaluated (for error reporting) * @returns Boolean result of policy evaluation * @throws RLSPolicyEvaluationError if the policy throws an error */ private async evaluatePolicy( condition: (ctx: PolicyEvaluationContext) => boolean | Promise, evalCtx: PolicyEvaluationContext, policyName?: string ): Promise { try { const result = condition(evalCtx) return result instanceof Promise ? await result : result } catch (error) { // Distinguish between policy evaluation errors and policy violations // RLSPolicyEvaluationError indicates a bug in the policy, not a legitimate denial const originalError = error instanceof Error ? error : undefined throw new RLSPolicyEvaluationError( evalCtx.operation ?? 'unknown', evalCtx.table ?? 'unknown', error instanceof Error ? error.message : 'Unknown error', policyName, originalError ) } } /** * Filter rows based on read policies. * Uses parallel processing with chunking for performance. * * @param table - Table name * @param rows - Rows to filter * @param chunkSize - Number of rows to process in parallel (default: 100) * @returns Filtered array containing only accessible rows * * @example * ```typescript * const guard = new MutationGuard(registry); * const allPosts = await db.selectFrom('posts').selectAll().execute(); * const accessiblePosts = await guard.filterRows('posts', allPosts); * * // With custom chunk size for large datasets * const accessiblePosts = await guard.filterRows('posts', allPosts, 50); * ``` */ async filterRows>( table: string, rows: T[], chunkSize: number = DEFAULT_CHUNK_SIZE ): Promise { if (rows.length === 0) return [] const results: T[] = [] // Process in chunks to avoid overwhelming the event loop for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize) // Process chunk in parallel const chunkResults = await Promise.all( chunk.map(async (row) => { const allowed = await this.checkRead(table, row) return allowed ? row : null }) ) // Filter out nulls and add to results for (const row of chunkResults) { if (row !== null) { results.push(row) } } } return results } }