forge-sql-orm
Version:
Drizzle ORM integration for Atlassian @forge/sql. Provides a custom driver, schema migration, two levels of caching (local and global via @forge/kvs), optimistic locking, and query analysis.
930 lines (856 loc) • 32.7 kB
text/typescript
import {
ForgeSqlOperation,
ForgeSqlOrmOptions,
RlsSettings,
RovoIntegration,
RovoIntegrationSetting,
RovoIntegrationSettingCreator,
} from "./ForgeSQLQueryBuilder";
import { Result, sql } from "@forge/sql";
import { Parser, Select } from "node-sql-parser";
import { AnyMySqlTable, MySqlColumn } from "drizzle-orm/mysql-core";
import { getTableName } from "drizzle-orm/table";
/**
* Implementation of RovoIntegrationSetting interface.
* Stores configuration for Rovo query execution including user context, table name, and RLS settings.
*
* @class RovoIntegrationSettingImpl
* @implements {RovoIntegrationSetting}
*/
class RovoIntegrationSettingImpl implements RovoIntegrationSetting {
private readonly accountId: string;
private readonly tableName: string;
private readonly contextParam: Record<string, string>;
private readonly rls: boolean;
private readonly rlsFields: string[];
private readonly rlsWherePart: (alias: string) => string;
/**
* Creates a new RovoIntegrationSettingImpl instance.
*
* @param {string} accountId - The account ID of the active user
* @param {string} tableName - The name of the table to query
* @param {Record<string, string>} contextParam - Context parameters for query substitution (parameter name -> value mapping)
* @param {boolean} rls - Whether Row-Level Security is enabled
* @param {string[]} rlsFields - Array of field names required for RLS validation
* @param {(alias: string) => string} rlsWherePart - Function that generates WHERE clause for RLS filtering
*/
constructor(
accountId: string,
tableName: string,
contextParam: Record<string, string>,
rls: boolean,
rlsFields: string[],
rlsWherePart: (alias: string) => string,
) {
this.accountId = accountId;
this.tableName = tableName;
this.contextParam = contextParam;
this.rls = rls;
this.rlsFields = rlsFields;
this.rlsWherePart = rlsWherePart;
}
/**
* Gets the account ID of the active user.
*
* @returns {string} The account ID of the active user
*/
getActiveUser(): string {
return this.accountId;
}
/**
* Gets the context parameters for query substitution.
*
* @returns {Record<string, string>} Map of parameter names to their values
*/
getParameters(): Record<string, string> {
return this.contextParam;
}
/**
* Gets the name of the table to query.
*
* @returns {string} The table name
*/
getTableName(): string {
return this.tableName;
}
/**
* Checks if Row-Level Security is enabled.
*
* @returns {boolean} True if RLS is enabled, false otherwise
*/
isUseRLS(): boolean {
return this.rls;
}
/**
* Gets the list of field names required for RLS validation.
*
* @returns {string[]} Array of field names that must be present in SELECT clause for RLS
*/
userScopeFields(): string[] {
return this.rlsFields;
}
/**
* Generates the WHERE clause for Row-Level Security filtering.
*
* @param {string} alias - The table alias to use in the WHERE clause
* @returns {string} SQL WHERE clause condition for RLS filtering
*/
userScopeWhere(alias: string): string {
return this.rlsWherePart(alias);
}
}
/**
* Builder class for creating RovoIntegrationSetting instances.
* Provides a fluent API for configuring Rovo query settings including context parameters and RLS.
*
* @class RovoIntegrationSettingCreatorImpl
* @implements {RovoIntegrationSettingCreator}
*/
class RovoIntegrationSettingCreatorImpl implements RovoIntegrationSettingCreator {
private readonly tableName: string;
private readonly accountId: string;
private readonly contextParam: Record<string, string> = {};
private readonly rlsFields: string[] = [];
private isUseRls: boolean = false;
private isUseRlsConditional: () => Promise<boolean> = async () => true;
private wherePart: (alias: string) => string = () => "";
/**
* Creates a new RovoIntegrationSettingCreatorImpl instance.
*
* @param {string} tableName - The name of the table to query
* @param {string} accountId - The account ID of the active user
*/
constructor(tableName: string, accountId: string) {
this.tableName = tableName;
this.accountId = accountId;
}
/**
* Adds a string context parameter for query substitution.
* The value will be wrapped in single quotes in the SQL query.
*
* @param {string} parameterName - The parameter name to replace in the query (e.g., '{{projectKey}}')
* @param {string} value - The string value to substitute for the parameter
* @returns {RovoIntegrationSettingCreator} This builder instance for method chaining
*
* @example
* ```typescript
* builder.addStringContextParameter('{{projectKey}}', 'PROJ-123');
* // In SQL: {{projectKey}} will be replaced with 'PROJ-123'
* ```
*/
addStringContextParameter(parameterName: string, value: string): RovoIntegrationSettingCreator {
this.addContextParameter(parameterName, value, true);
return this;
}
/**
* Adds a number context parameter for query substitution.
* The value will be inserted as-is without quotes in the SQL query.
*
* @param {string} parameterName - The parameter name to replace in the query (e.g., '{{limit}}')
* @param {number} value - The numeric value to substitute for the parameter
* @returns {RovoIntegrationSettingCreator} This builder instance for method chaining
*
* @example
* ```typescript
* builder.addNumberContextParameter('{{limit}}', 100);
* // In SQL: {{limit}} will be replaced with 100
* ```
*/
addNumberContextParameter(parameterName: string, value: number): RovoIntegrationSettingCreator {
this.addContextParameter(parameterName, String(value), false);
return this;
}
/**
* Adds a boolean context parameter for query substitution.
* The value will be converted to 1 (true) or 0 (false) and inserted as a number.
*
* @param {string} parameterName - The parameter name to replace in the query (e.g., '{{isActive}}')
* @param {boolean} value - The boolean value to substitute for the parameter
* @returns {RovoIntegrationSettingCreator} This builder instance for method chaining
*
* @example
* ```typescript
* builder.addBooleanContextParameter('{{isActive}}', true);
* // In SQL: {{isActive}} will be replaced with 1
* ```
*/
addBooleanContextParameter(parameterName: string, value: boolean): RovoIntegrationSettingCreator {
this.addNumberContextParameter(parameterName, value ? 1 : 0);
return this;
}
/**
* Adds a context parameter for query substitution.
* Context parameters are replaced in the SQL query before execution.
*
* @param {string} parameterName - The parameter name to replace in the query (e.g., '{{projectKey}}')
* @param {string} value - The value to substitute for the parameter
* @param {boolean} wrap - Whether to wrap the value in single quotes (true for strings, false for numbers)
* @returns {RovoIntegrationSettingCreator} This builder instance for method chaining
*
* @example
* ```typescript
* builder.addContextParameter('{{projectKey}}', 'PROJ-123', true);
* // In SQL: {{projectKey}} will be replaced with 'PROJ-123'
* ```
*/
addContextParameter(
parameterName: string,
value: string,
wrap: boolean,
): RovoIntegrationSettingCreator {
this.contextParam[parameterName] = wrap ? `'${value}'` : value;
return this;
}
/**
* Enables Row-Level Security (RLS) for the query.
* Returns a RlsSettings builder for configuring RLS options.
*
* @returns {RlsSettings} RLS settings builder for configuring security options
*
* @example
* ```typescript
* builder.useRLS()
* .addRlsColumn(usersTable.id)
* .addRlsWherePart((alias) => `${alias}.id = '${accountId}'`)
* .finish();
* ```
*/
useRLS(): RlsSettings {
/**
* Internal implementation of RlsSettings interface.
* Provides fluent API for configuring Row-Level Security settings.
*
* @class RlsSettingsImpl
* @implements {RlsSettings}
*/
return new (class RlsSettingsImpl implements RlsSettings {
private isUseRlsConditionalSettings: () => Promise<boolean> = async () => true;
private rlsFieldsSettings: string[] = [];
private wherePartSettings: (alias: string) => string = () => "";
/**
* Creates a new RlsSettingsImpl instance.
*
* @param {RovoIntegrationSettingCreatorImpl} parent - The parent settings builder instance
*/
constructor(private readonly parent: RovoIntegrationSettingCreatorImpl) {}
/**
* Sets a conditional function to determine if RLS should be applied.
*
* @param {() => Promise<boolean>} condition - Async function that returns true if RLS should be enabled
* @returns {RlsSettings} This builder instance for method chaining
*
* @example
* ```typescript
* .addRlsCondition(async () => {
* const user = await getUser();
* return !user.isAdmin;
* })
* ```
*/
addRlsCondition(condition: () => Promise<boolean>): RlsSettings {
this.isUseRlsConditionalSettings = condition;
return this;
}
/**
* Adds a column name that must be present in the SELECT clause for RLS validation.
*
* @param {string} columnName - The name of the column to require
* @returns {RlsSettings} This builder instance for method chaining
*
* @example
* ```typescript
* .addRlsColumnName('userId')
* ```
*/
addRlsColumnName(columnName: string): RlsSettings {
this.rlsFieldsSettings.push(columnName);
return this;
}
/**
* Adds a Drizzle column that must be present in the SELECT clause for RLS validation.
*
* @param {MySqlColumn} column - The Drizzle column object
* @returns {RlsSettings} This builder instance for method chaining
*
* @example
* ```typescript
* .addRlsColumn(usersTable.userId)
* ```
*/
addRlsColumn(column: MySqlColumn): RlsSettings {
this.rlsFieldsSettings.push(column.name);
return this;
}
/**
* Sets the WHERE clause function for RLS filtering.
* The function receives a table alias and should return a SQL WHERE condition.
*
* @param {(alias: string) => string} wherePart - Function that generates WHERE clause
* @returns {RlsSettings} This builder instance for method chaining
*
* @example
* ```typescript
* .addRlsWherePart((alias) => `${alias}.userId = '${accountId}'`)
* ```
*/
addRlsWherePart(wherePart: (alias: string) => string): RlsSettings {
this.wherePartSettings = wherePart;
return this;
}
/**
* Finishes RLS configuration and returns to the settings builder.
*
* @returns {RovoIntegrationSettingCreator} The parent settings builder
*/
finish(): RovoIntegrationSettingCreator {
this.parent.isUseRls = true;
this.rlsFieldsSettings.forEach((columnName) => this.parent.rlsFields.push(columnName));
this.parent.wherePart = this.wherePartSettings;
this.parent.isUseRlsConditional = this.isUseRlsConditionalSettings;
return this.parent;
}
})(this);
}
/**
* Builds and returns the RovoIntegrationSetting instance.
* Evaluates the RLS condition if RLS is enabled.
*
* @returns {Promise<RovoIntegrationSetting>} The configured RovoIntegrationSetting instance
*
* @example
* ```typescript
* const settings = await builder
* .addContextParameter('{{projectKey}}', 'PROJ-123')
* .useRLS()
* .addRlsColumn(usersTable.id)
* .addRlsWherePart((alias) => `${alias}.id = '${accountId}'`)
* .finish()
* .build();
* ```
*/
async build(): Promise<RovoIntegrationSetting> {
const useRls = this.isUseRls ? await this.isUseRlsConditional() : false;
return new RovoIntegrationSettingImpl(
this.accountId,
this.tableName,
this.contextParam,
useRls,
this.rlsFields,
this.wherePart,
);
}
}
/**
* Main class for Rovo integration - a secure pattern for natural-language analytics in Forge apps.
*
* Rovo provides a secure way to execute dynamic SQL queries with comprehensive security validations:
* - Only SELECT queries are allowed
* - Queries are restricted to a single table
* - JOINs, subqueries, and window functions are blocked
* - Row-Level Security (RLS) support for data isolation
* - Post-execution validation of query results
*
* @class Rovo
* @implements {RovoIntegration}
*
* @example
* ```typescript
* const rovo = forgeSQL.rovo();
* const settings = await rovo.rovoSettingBuilder(usersTable, accountId)
* .useRLS()
* .addRlsColumn(usersTable.id)
* .addRlsWherePart((alias) => `${alias}.id = '${accountId}'`)
* .finish()
* .build();
*
* const result = await rovo.dynamicIsolatedQuery(
* "SELECT id, name FROM users WHERE status = 'active'",
* settings
* );
* ```
*/
export class Rovo implements RovoIntegration {
private readonly forgeOperations: ForgeSqlOperation;
private readonly options: ForgeSqlOrmOptions;
/**
* Creates a new Rovo instance.
*
* @param {ForgeSqlOperation} forgeSqlOperations - The ForgeSQL operations instance for query analysis and execution
* @param {ForgeSqlOrmOptions} options - Configuration options for the ORM (e.g., logging settings)
*/
constructor(forgeSqlOperations: ForgeSqlOperation, options: ForgeSqlOrmOptions) {
this.forgeOperations = forgeSqlOperations;
this.options = options;
}
/**
* Parses SQL query into AST and validates it's a single SELECT statement.
*
* @param {string} sqlQuery - Normalized SQL query string
* @returns {Select} Parsed AST of the SELECT statement
* @throws {Error} If parsing fails or query is not a single SELECT statement
*/
private parseSqlQuery(sqlQuery: string): Select {
const parser = new Parser();
let ast;
try {
ast = parser.astify(sqlQuery);
} catch (parseError: any) {
throw new Error(
`SQL parsing error: ${parseError.message || "Invalid SQL syntax"}. Please check your query syntax.`,
);
}
// Validate that query is a SELECT statement
// Parser can return either an object (single statement) or an array (multiple statements)
if (Array.isArray(ast)) {
if (ast.length !== 1 || ast[0].type !== "select") {
throw new Error(
"Only a single SELECT query is allowed. Multiple statements or non-SELECT statements are not permitted.",
);
}
return ast[0];
} else if (ast?.type === "select") {
return ast;
} else {
throw new Error("Only SELECT queries are allowed.");
}
}
/**
* Recursively processes array or single node and extracts table names.
*
* @param {any} items - Array of AST nodes or single AST node
* @param {string[]} tables - Accumulator array for collecting table names (modified in place)
*/
private extractTablesFromItems(items: any, tables: string[]): void {
if (Array.isArray(items)) {
items.forEach((item: any) => {
tables.push(...this.extractTables(item));
});
} else {
tables.push(...this.extractTables(items));
}
}
/**
* Extracts table name from table AST node.
*
* @param {any} node - AST node with table information
* @returns {string | null} Table name in uppercase, or null if not applicable (e.g., 'dual' table)
*/
private extractTableName(node: any): string | null {
if (!node.table) {
return null;
}
const tableName = node.table === "dual" ? "dual" : node.table.name || node.table;
return tableName && tableName !== "dual" ? tableName.toUpperCase() : null;
}
/**
* Recursively extracts all table names from SQL AST node.
* Traverses FROM and JOIN clauses to find all referenced tables.
*
* @param {any} node - AST node to extract tables from
* @returns {string[]} Array of table names in uppercase
*/
private extractTables(node: any): string[] {
const tables: string[] = [];
// Extract table name if node is a table type
if (node.type === "table" || node.type === "dual") {
const tableName = this.extractTableName(node);
if (tableName) {
tables.push(tableName);
}
}
// Extract tables from FROM clause
if (node.from) {
this.extractTablesFromItems(node.from, tables);
}
// Extract tables from JOIN clause
if (node.join) {
this.extractTablesFromItems(node.join, tables);
}
return tables;
}
/**
* Recursively checks if AST node contains scalar subqueries.
* Used for security validation to prevent subquery-based attacks.
*
* @param {any} node - AST node to check for subqueries
* @returns {boolean} True if node contains scalar subquery, false otherwise
*/
private hasScalarSubquery(node: any): boolean {
if (!node) return false;
if (node.type === "subquery" || node.ast?.type === "select") {
return true;
}
if (Array.isArray(node)) {
return node.some((item) => this.hasScalarSubquery(item));
}
if (typeof node === "object") {
return Object.values(node).some((value) => this.hasScalarSubquery(value));
}
return false;
}
/**
* Creates a settings builder for Rovo queries using a raw table name.
*
* @param {string} tableName - The name of the table to query (case-insensitive)
* @param {string} accountId - The account ID of the active user for RLS filtering
* @returns {RovoIntegrationSettingCreator} Builder for configuring Rovo query settings
*
* @example
* ```typescript
* const builder = rovo.rovoRawSettingBuilder('users', accountId);
* const settings = await builder
* .addStringContextParameter('{{status}}', 'active')
* .build();
* ```
*/
rovoRawSettingBuilder(tableName: string, accountId: string): RovoIntegrationSettingCreator {
return new RovoIntegrationSettingCreatorImpl(tableName, accountId);
}
/**
* Creates a settings builder for Rovo queries using a Drizzle table object.
* This is a convenience method that extracts the table name from the Drizzle table object.
*
* @param {AnyMySqlTable} table - The Drizzle table object
* @param {string} accountId - The account ID of the active user for RLS filtering
* @returns {RovoIntegrationSettingCreator} Builder for configuring Rovo query settings
*
* @example
* ```typescript
* const builder = rovo.rovoSettingBuilder(usersTable, accountId);
* const settings = await builder
* .useRLS()
* .addRlsColumn(usersTable.id)
* .addRlsWherePart((alias) => `${alias}.userId = '${accountId}'`)
* .finish()
* .build();
* ```
*/
rovoSettingBuilder(table: AnyMySqlTable, accountId: string): RovoIntegrationSettingCreator {
return this.rovoRawSettingBuilder(getTableName(table), accountId);
}
/**
* Validates basic input parameters for the SQL query.
*
* @param {string} query - The SQL query string to validate
* @param {string} tableName - The expected table name
* @returns {string} The trimmed query string
* @throws {Error} If query is empty, table name is missing, or query is not a SELECT statement
*/
private validateInputs(query: string, tableName: string): string {
if (!query?.trim()) {
throw new Error("SQL query is required. Please provide a valid SELECT query.");
}
if (!tableName) {
throw new Error("Table Name is required. Please provide a valid Table Name.");
}
const trimmedQuery = query.trim();
const quickUpper = trimmedQuery.toUpperCase();
if (!quickUpper.startsWith("SELECT")) {
throw new Error(
"Only SELECT queries are allowed. Data modification operations (INSERT, UPDATE, DELETE, etc.) are not permitted.",
);
}
return trimmedQuery;
}
/**
* Normalizes SQL query using AST parsing and stringification.
* This ensures consistent formatting and validates the query structure.
*
* @param {string} sql - The SQL query string to normalize
* @returns {string} The normalized SQL query string
* @throws {Error} If parsing fails, query is not a SELECT statement, or multiple statements are detected
*/
private normalizeSqlString(sql: string): string {
try {
const parser = new Parser();
const ast = parser.astify(sql.trim());
if (Array.isArray(ast)) {
if (ast.length !== 1 || ast[0].type !== "select") {
throw new Error(
"Only a single SELECT query is allowed. Multiple statements or non-SELECT statements are not permitted.",
);
}
} else if (ast && ast.type !== "select") {
throw new Error("Only SELECT queries are allowed.");
}
const normalized = parser.sqlify(Array.isArray(ast) ? ast[0] : ast);
return normalized.trim();
} catch (error: any) {
if (
error.message &&
(error.message.includes("Only") || error.message.includes("single SELECT"))
) {
throw error;
}
if (error.message?.includes("SQL parsing error")) {
throw error;
}
throw new Error(
`SQL parsing error: ${error.message || "Invalid SQL syntax"}. Please check your query syntax.`,
);
}
}
/**
* Validates that query targets the correct table.
* Checks that the FROM clause references only the expected table.
*
* @param {string} normalized - The normalized SQL query string
* @param {string} tableName - The expected table name
* @throws {Error} If query does not target the expected table
*/
private validateTableName(normalized: string, tableName: string): void {
const upperTableName = tableName.toUpperCase();
const tableNamePattern = new RegExp(`FROM\\s+[\`]?${upperTableName}[\`]?`, "i");
if (!tableNamePattern.test(normalized)) {
throw new Error(
"Queries must target the '" +
upperTableName +
"' table only. Other tables are not accessible.",
);
}
}
/**
* Validates query structure for security compliance.
* Checks that only the specified table is referenced and no scalar subqueries are present.
*
* @param {Select} selectAst - The parsed SELECT AST node
* @param {string} tableName - The expected table name
* @throws {Error} If query references other tables or contains scalar subqueries
*/
private validateQueryStructure(selectAst: Select, tableName: string): void {
const upperTableName = tableName.toUpperCase();
const tablesInQuery = this.extractTables(selectAst);
const uniqueTables = [...new Set(tablesInQuery)];
const invalidTables = uniqueTables.filter((table) => table !== upperTableName);
if (invalidTables.length > 0) {
throw new Error(
`Security violation: Query references table(s) other than '${tableName}': ${invalidTables.join(", ")}. ` +
`Only queries against the ${tableName} table are allowed. ` +
"JOINs, subqueries, or references to other tables are not permitted for security reasons.",
);
}
if (selectAst.columns && Array.isArray(selectAst.columns)) {
const hasSubqueryInColumns = selectAst.columns.some((col: any) => {
if (col.expr) {
return this.hasScalarSubquery(col.expr);
}
return this.hasScalarSubquery(col);
});
if (hasSubqueryInColumns) {
throw new Error(
"Security violation: Scalar subqueries in SELECT columns are not allowed. " +
"Subqueries can be used to access data from other tables or bypass security restrictions. " +
"Please rewrite your query without using subqueries in the SELECT clause.",
);
}
}
}
/**
* Validates query execution plan for security violations.
* Uses EXPLAIN to detect JOINs, window functions, and references to other tables.
*
* @param {string} normalized - The normalized SQL query string
* @param {string} tableName - The expected table name
* @returns {Promise<void>}
* @throws {Error} If execution plan reveals JOINs, window functions, or references to other tables
*/
private async validateExecutionPlan(normalized: string, tableName: string): Promise<void> {
const explainRows = await this.forgeOperations.analyze().explainRaw(normalized, []);
const hasJoin = explainRows.some((row) => {
const info = (row.operatorInfo ?? "").toUpperCase();
return (
info.includes("JOIN") ||
info.includes("CARTESIAN") ||
info.includes("NESTED LOOP") ||
info.includes("HASH JOIN")
);
});
if (hasJoin) {
throw new Error(
"Security violation: JOIN operations are not allowed. " +
`For security reasons, Rovo analytics only supports queries over the ${tableName} table without joins, subqueries, or references to other tables. ` +
`Please rewrite your query to use only the ${tableName} table.`,
);
}
const hasWindow = explainRows.some((row) => {
const id = row.id.toUpperCase();
const info = (row.operatorInfo ?? "").toUpperCase();
return id.includes("WINDOW") || info.includes(" OVER(") || info.includes(" OVER()");
});
if (hasWindow) {
throw new Error(
"Window functions (for example COUNT(*) OVER(...)) are not allowed in Rovo SQL for this app. " +
"Please rephrase your question so that it uses regular aggregates instead of window functions.",
);
}
const tablesInPlan = explainRows.filter(
(row) =>
row.accessObject?.startsWith("table:") &&
row.accessObject?.toLowerCase() !== "table:" + tableName.toLowerCase(),
);
if (tablesInPlan.length > 0) {
throw new Error(
`Security violation: Query execution plan detected references to tables other than '${tableName.toLowerCase()}'. ` +
`Only queries against the ${tableName.toLowerCase()} table are allowed. ` +
"JOINs, subqueries, or references to other tables are not permitted for security reasons.",
);
}
}
/**
* Applies row-level security filtering to query.
* Wraps the original query in a subquery and adds a WHERE clause with RLS conditions.
*
* @param {string} normalized - The normalized SQL query string
* @param {RovoIntegrationSetting} settings - Rovo settings containing RLS configuration
* @returns {string} The SQL query with RLS filtering applied
*/
private applyRLSFiltering(normalized: string, settings: RovoIntegrationSetting): string {
if (normalized.endsWith(";")) {
normalized = normalized.slice(0, -1);
}
return `
SELECT *
FROM (
${normalized}
) AS t
WHERE (${settings.userScopeWhere("t")})
`;
}
/**
* Validates query results for RLS compliance.
* Ensures that required RLS fields are present and all fields originate from the correct table.
*
* @param {Result<unknown>} result - The query execution result
* @param {RovoIntegrationSetting} settings - Rovo settings containing RLS field requirements
* @param {string} upperTableName - The expected table name in uppercase
* @throws {Error} If required RLS fields are missing or fields originate from other tables
*/
private validateQueryResults(
result: Result<unknown>,
settings: RovoIntegrationSetting,
upperTableName: string,
): void {
if (!result?.metadata?.fields) {
return;
}
const fields = result.metadata.fields as Array<{
name: string;
schema?: string;
table?: string;
orgTable?: string;
}>;
settings.userScopeFields().forEach((field) => {
const actualFields = fields.filter((f) => f.name.toLowerCase() === field?.toLowerCase());
if (actualFields.length === 0) {
throw new Error(
`Security validation failed: The query must include ${field} as a raw column in the SELECT statement. This field is required for row-level security enforcement.`,
);
}
const actualField = actualFields.find(
(f) => !f.orgTable || f.orgTable.toUpperCase() !== upperTableName,
);
if (actualField) {
throw new Error(
`Security validation failed: '${field}' must come directly from the ${upperTableName} table. Joins, subqueries, or table aliases that change the origin of this column are not allowed.`,
);
}
});
const fieldsFromOtherTables = fields.filter(
(f) => f.orgTable && f.orgTable.toUpperCase() !== upperTableName,
);
if (fieldsFromOtherTables.length > 0) {
throw new Error(
`Security validation failed: All fields must come from the ${upperTableName} table. ` +
"Fields from other tables detected, which indicates the use of JOINs, subqueries, or references to other tables. " +
"This is not allowed for security reasons.",
);
}
}
/**
* Executes a dynamic SQL query with comprehensive security validations.
*
* This method performs multiple security checks:
* 1. Validates that the query is a SELECT statement
* 2. Ensures the query targets only the specified table
* 3. Blocks JOINs, subqueries, and window functions
* 4. Applies Row-Level Security filtering if enabled
* 5. Validates query results to ensure security fields are present
*
* @param {string} dynamicSql - The SQL query to execute (must be a SELECT statement)
* @param {RovoIntegrationSetting} settings - Configuration settings for the query
* @returns {Promise<Result<unknown>>} Query execution result with metadata
* @throws {Error} If the query violates security restrictions, parsing fails, or validation errors occur
*
* @example
* ```typescript
* const result = await rovo.dynamicIsolatedQuery(
* "SELECT id, name, email FROM users WHERE status = 'active' ORDER BY name",
* settings
* );
*
* console.log(result.rows); // Query results
* console.log(result.metadata); // Query metadata
* ```
*/
async dynamicIsolatedQuery(
dynamicSql: string,
settings: RovoIntegrationSetting,
): Promise<Result<unknown>> {
const tableName = settings.getTableName();
const accountId = settings.getActiveUser();
const parameters = settings.getParameters();
// Validate inputs
const trimmedQuery = this.validateInputs(dynamicSql, tableName);
// Normalize SQL
let normalized: string;
try {
normalized = this.normalizeSqlString(trimmedQuery);
} catch (error: any) {
if (
error.message &&
(error.message.includes("Only") || error.message.includes("single SELECT"))
) {
throw error;
}
if (error.message?.includes("SQL parsing error")) {
throw error;
}
throw new Error(
`SQL parsing error: ${error.message || "Invalid SQL syntax"}. Please check your query syntax.`,
);
}
// Validate table name and account
this.validateTableName(normalized, tableName);
if (!accountId) {
throw new Error(
"Authentication error: User account ID is missing. Please ensure you are logged in.",
);
}
// Replace parameters in query
normalized = normalized.replaceAll("ari:cloud:identity::user/", "");
Object.entries(parameters).forEach(([key, value]) => {
normalized = normalized.replaceAll(key, value);
});
// Validate query structure
const selectAst = this.parseSqlQuery(normalized);
this.validateQueryStructure(selectAst, tableName);
// Validate execution plan
await this.validateExecutionPlan(normalized, tableName);
// Apply RLS filtering if needed
const isUseRLSFiltering = settings.isUseRLS();
if (isUseRLSFiltering) {
normalized = this.applyRLSFiltering(normalized, settings);
}
if (this.options.logRawSqlQuery) {
// eslint-disable-next-line no-console
console.debug("Rovo query: " + normalized);
}
const result = await sql.executeRaw(normalized);
// Validate query results for RLS compliance
if (isUseRLSFiltering) {
const upperTableName = tableName.toUpperCase();
this.validateQueryResults(result, settings, upperTableName);
}
return result;
}
}