/** * SELECT Query Transformer * Applies filter policies to SELECT queries by adding WHERE conditions */ import type { SelectQueryBuilder } from 'kysely' import type { PolicyRegistry } from '../policy/registry.js' import type { PolicyEvaluationContext } from '../policy/types.js' import type { RLSContext } from '../context/types.js' import { rlsContext } from '../context/manager.js' import { RLSError, RLSErrorCodes } from '../errors.js' import { createQualifiedColumn, applyWhereCondition, createRawCondition } from '../utils/type-utils.js' /** * SELECT query transformer * Applies filter policies to SELECT queries by adding WHERE conditions */ export class SelectTransformer { constructor(private registry: PolicyRegistry) {} /** * Transform a SELECT query by applying filter policies * * @param qb - The query builder to transform * @param table - Table name being queried * @returns Transformed query builder with RLS filters applied * * @example * ```typescript * const transformer = new SelectTransformer(registry); * let query = db.selectFrom('posts').selectAll(); * query = transformer.transform(query, 'posts'); * // Query now includes WHERE conditions from RLS policies * ``` */ transform( qb: SelectQueryBuilder, table: string ): SelectQueryBuilder { // Check for context const ctx = rlsContext.getContextOrNull() if (!ctx) { // SECURITY FIX (H-11): This should never happen as plugin.interceptQuery // now handles missing context. If we reach here, apply defensive WHERE FALSE // to prevent unfiltered queries from leaking through. // This is a defense-in-depth measure. return applyWhereCondition( qb, createRawCondition('FALSE') as unknown as string, '=', true ) as SelectQueryBuilder } // Check if system user (bypass RLS) if (ctx.auth.isSystem) { return qb } // Check if user role should skip RLS const skipFor = this.registry.getSkipFor(table) if (skipFor.some(role => ctx.auth.roles.includes(role))) { return qb } // Get filter policies for this table const filters = this.registry.getFilters(table) if (filters.length === 0) { return qb } // Apply each filter as WHERE condition let result = qb for (const filter of filters) { const conditions = this.evaluateFilter(filter, ctx, table) result = this.applyConditions(result, conditions, table) } return result } /** * Evaluate a filter policy to get WHERE conditions * * @param filter - The filter policy to evaluate * @param ctx - RLS context * @param table - Table name * @returns WHERE clause conditions as key-value pairs */ private evaluateFilter( filter: { name: string getConditions: ( ctx: PolicyEvaluationContext ) => Record | Promise> }, ctx: RLSContext, table: string ): Record { const evalCtx: PolicyEvaluationContext = { auth: ctx.auth, ...(ctx.meta !== undefined && { meta: ctx.meta as Record }) } const result = filter.getConditions(evalCtx) // Note: If async filters are needed, this method signature would need to change // For now, we assume synchronous filter evaluation if (result instanceof Promise) { throw new RLSError( `Async filter policies are not supported in SELECT transformers. ` + `Filter '${filter.name}' on table '${table}' returned a Promise. ` + `Use synchronous conditions for filter policies.`, RLSErrorCodes.RLS_POLICY_INVALID ) } return result } /** * Apply filter conditions to query builder * * Uses type-safe wrappers from utils/type-utils.ts to encapsulate the boundary * between runtime policy conditions and Kysely's compile-time type system. * * Type safety is maintained through: * 1. Policy conditions are validated during schema registration * 2. Column names come from policy definitions (developer-controlled) * 3. Values are type-checked by the policy condition functions * * @param qb - Query builder to modify * @param conditions - WHERE clause conditions * @param table - Table name (for qualified column names) * @returns Modified query builder */ private applyConditions( qb: SelectQueryBuilder, conditions: Record, table: string ): SelectQueryBuilder { let result = qb for (const [column, value] of Object.entries(conditions)) { // Use table-qualified column name to avoid ambiguity in joins const qualifiedColumn = createQualifiedColumn(table, column) if (value === null) { // NULL check result = applyWhereCondition(result, qualifiedColumn, 'is', null) } else if (value === undefined) { // Skip undefined values continue } else if (Array.isArray(value)) { if (value.length === 0) { // Empty array means no matches - add impossible condition using SQL FALSE // This ensures the query returns no rows without using magic strings // that could potentially match actual data result = applyWhereCondition( result, createRawCondition('FALSE') as unknown as string, '=', true ) } else { // IN clause for array values result = applyWhereCondition(result, qualifiedColumn, 'in', value) } } else { // Equality check result = applyWhereCondition(result, qualifiedColumn, '=', value) } } return result } }