Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { IGlobalDatabaseContext } from '../../../../common/application/global-da
import { BaseType } from '../../../../common/data-injection.tokens.js';
import { Messages } from '../../../../exceptions/text/messages.js';
import { isConnectionTypeAgent } from '../../../../helpers/is-connection-entity-agent.js';
import { CedarAction } from '../../../cedar-authorization/cedar-action-map.js';
import { CedarAuthorizationService } from '../../../cedar-authorization/cedar-authorization.service.js';
import { ExecuteSavedDbQueryDs } from '../data-structures/execute-saved-db-query.ds.js';
import { ExecuteSavedDbQueryResultDto } from '../dto/execute-saved-db-query-result.dto.js';
import { assertUserCanReadQueryTables } from '../utils/assert-query-tables-readable.util.js';
import { validateQuerySafety } from '../utils/check-query-is-safe.util.js';
import { IExecuteSavedDbQuery } from './panel-use-cases.interface.js';

Expand All @@ -19,6 +22,7 @@ export class ExecuteSavedDbQueryUseCase
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly cedarAuthService: CedarAuthorizationService,
) {
super();
}
Expand All @@ -43,12 +47,27 @@ export class ExecuteSavedDbQueryUseCase

validateQuerySafety(foundQuery.query_text, foundConnection.type as ConnectionTypesEnum);

const dao = getDataAccessObject(foundConnection);

await assertUserCanReadQueryTables({
query: foundQuery.query_text,
connectionType: foundConnection.type as ConnectionTypesEnum,
connectionId,
validateTableRead: (referencedTableName) =>
this.cedarAuthService.validate({
userId,
action: CedarAction.TableRead,
connectionId,
tableName: referencedTableName,
}),
listAllTableNames: async () => (await dao.getTablesFromDB()).map((table) => table.tableName),
});

let userEmail: string | null = null;
if (isConnectionTypeAgent(foundConnection.type)) {
userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId);
}

const dao = getDataAccessObject(foundConnection);
const startTime = Date.now();

const executionResult = await dao.executeRawQuery(foundQuery.query_text, tableName, userEmail);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { IGlobalDatabaseContext } from '../../../../common/application/global-da
import { BaseType } from '../../../../common/data-injection.tokens.js';
import { Messages } from '../../../../exceptions/text/messages.js';
import { isConnectionTypeAgent } from '../../../../helpers/is-connection-entity-agent.js';
import { CedarAction } from '../../../cedar-authorization/cedar-action-map.js';
import { CedarAuthorizationService } from '../../../cedar-authorization/cedar-authorization.service.js';
import { TestDbQueryDs } from '../data-structures/test-db-query.ds.js';
import { TestDbQueryResultDto } from '../dto/test-db-query-result.dto.js';
import { assertUserCanReadQueryTables } from '../utils/assert-query-tables-readable.util.js';
import { validateQuerySafety } from '../utils/check-query-is-safe.util.js';
import { ITestDbQuery } from './panel-use-cases.interface.js';

Expand All @@ -16,6 +19,7 @@ export class TestDbQueryUseCase extends AbstractUseCase<TestDbQueryDs, TestDbQue
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly cedarAuthService: CedarAuthorizationService,
) {
super();
}
Expand All @@ -34,12 +38,27 @@ export class TestDbQueryUseCase extends AbstractUseCase<TestDbQueryDs, TestDbQue

validateQuerySafety(query_text, foundConnection.type as ConnectionTypesEnum);

const dao = getDataAccessObject(foundConnection);

await assertUserCanReadQueryTables({
query: query_text,
connectionType: foundConnection.type as ConnectionTypesEnum,
connectionId,
validateTableRead: (referencedTableName) =>
this.cedarAuthService.validate({
userId,
action: CedarAction.TableRead,
connectionId,
tableName: referencedTableName,
}),
listAllTableNames: async () => (await dao.getTablesFromDB()).map((table) => table.tableName),
});

let userEmail: string | null = null;
if (isConnectionTypeAgent(foundConnection.type)) {
userEmail = await this._dbContext.userRepository.getUserEmailOrReturnNull(userId);
}

const dao = getDataAccessObject(foundConnection);
const startTime = Date.now();

const executionResult = await dao.executeRawQuery(query_text, tableName, userEmail);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ForbiddenException } from '@nestjs/common';
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
import { Messages } from '../../../../exceptions/text/messages.js';
import { slackPostMessage } from '../../../../helpers/slack/slack-post-message.js';
import { collectQueryTables } from './collect-query-tables.util.js';

interface AssertQueryTablesReadableParams {
query: string;
connectionType: ConnectionTypesEnum;
connectionId: string;
/** Resolves to `true` when the user is allowed to read the given table (table:read). */
validateTableRead: (tableName: string) => Promise<boolean>;
/** Lists every table in the connection; used for the all-tables fallback check. */
listAllTableNames: () => Promise<Array<string>>;
}

/**
* Guards a raw read-only query against table-level read permissions: the user must have read access
* to every table the query touches.
*
* When the exact set of tables cannot be resolved (non-SQL connection, parse failure, or a statement
* that resolves to no concrete table), we cannot trust the query to be harmless, so we fall back to
* requiring read permission on EVERY table in the connection. This guarantees a user can never read
* data from a table they lack permission on through this endpoint, while still letting users with
* full read access run such queries.
*/
export async function assertUserCanReadQueryTables(params: AssertQueryTablesReadableParams): Promise<void> {
const { query, connectionType, connectionId, validateTableRead, listAllTableNames } = params;

const collected = collectQueryTables(query, connectionType);

let tablesToCheck: Array<string>;
if (collected.kind === 'tables') {
tablesToCheck = collected.tables;
} else {
slackPostMessage(
`Saved-query permission check could not resolve referenced tables for connection ${connectionId} ` +
`(reason: ${collected.reason}); falling back to all-tables read check. Query: ${query}`,
);
tablesToCheck = await listAllTableNames();
}

for (const tableName of tablesToCheck) {
const allowed = await validateTableRead(tableName);
if (!allowed) {
throw new ForbiddenException(Messages.NO_READ_PERMISSION_FOR_TABLE(tableName));
}
Comment on lines +43 to +47
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
import sqlParser from 'node-sql-parser';
import { connectionTypeToParserDialect } from '../../../table-schema/utils/assert-dialect-supported.js';

const { Parser } = sqlParser;

// Connection types that node-sql-parser cannot analyze. Their queries are not SQL, so we never
// attempt to parse them and instead let the caller fall back to the all-tables permission check.
const NON_SQL_CONNECTION_TYPES: ReadonlySet<ConnectionTypesEnum> = new Set([
ConnectionTypesEnum.mongodb,
ConnectionTypesEnum.agent_mongodb,
ConnectionTypesEnum.elasticsearch,
ConnectionTypesEnum.redis,
ConnectionTypesEnum.agent_redis,
ConnectionTypesEnum.dynamodb,
]);

export type CollectQueryTablesResult =
| { kind: 'tables'; tables: Array<string> }
| { kind: 'indeterminate'; reason: string };

/**
* Extracts the real tables a read-only query references, so the caller can verify the user has
* read permission on each of them.
*
* Returns `{ kind: 'tables' }` only when a confident, non-empty list of concrete tables could be
* resolved. In every other case — non-SQL connections, parse failures, or statements that resolve
* to no concrete table (e.g. `SELECT 1`, `SHOW`, `DESCRIBE`) — it returns `{ kind: 'indeterminate' }`
* and the caller must fall back to a stricter check rather than assume the query is harmless.
*/
export function collectQueryTables(query: string, connectionType: ConnectionTypesEnum): CollectQueryTablesResult {
if (NON_SQL_CONNECTION_TYPES.has(connectionType)) {
return { kind: 'indeterminate', reason: `non-SQL connection type "${connectionType}"` };
}

const dialect = connectionTypeToParserDialect(connectionType);
Comment on lines +32 to +36
const parser = new Parser();

let rawTableList: Array<string>;
let cteNames: Set<string>;
try {
rawTableList = parser.tableList(query, { database: dialect });
cteNames = collectCteNames(parser, query, dialect);
} catch (error) {
return { kind: 'indeterminate', reason: `parse error: ${(error as Error).message}` };
}

const tables = new Set<string>();
for (const entry of rawTableList) {
// node-sql-parser returns entries formatted as "{type}::{db}::{table}". We ignore the schema
// ("db") segment since permissions are keyed on bare table names.
const tableName = entry.split('::').pop();
if (!tableName || tableName === 'null') {
continue;
}
// Common Table Expressions are aliases for inline subqueries, not real tables; the subqueries'
// underlying tables are already present in the list, so the CTE names must be dropped.
if (cteNames.has(tableName.toLowerCase())) {
continue;
}
tables.add(tableName);
}

if (tables.size === 0) {
return { kind: 'indeterminate', reason: 'query resolved to no concrete table' };
}

return { kind: 'tables', tables: Array.from(tables) };
}

function collectCteNames(parser: InstanceType<typeof Parser>, query: string, dialect: string): Set<string> {
const names = new Set<string>();
const ast = parser.astify(query, { database: dialect });
const statements = Array.isArray(ast) ? ast : [ast];
for (const statement of statements) {
const withClause = (statement as { with?: unknown })?.with;
if (!Array.isArray(withClause)) {
continue;
}
for (const cte of withClause) {
const name = extractCteName((cte as { name?: unknown })?.name);
if (name) {
names.add(name.toLowerCase());
}
}
}
return names;
}

function extractCteName(nameNode: unknown): string | null {
if (typeof nameNode === 'string') {
return nameNode;
}
if (nameNode && typeof nameNode === 'object') {
const value = (nameNode as { value?: unknown }).value;
if (typeof value === 'string') {
return value;
}
}
return null;
}
1 change: 1 addition & 0 deletions backend/src/exceptions/text/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export const Messages = {
`There are no such fields: ${fields.join(', ')} - in the table "${tableName}"`,
NO_SUCH_FIELD_IN_TABLE: (fieldName: string, tableName: string) =>
`There is no such field: "${fieldName}" in the table "${tableName}"`,
NO_READ_PERMISSION_FOR_TABLE: (tableName: string) => `You do not have read permission for table "${tableName}".`,
NOT_ALLOWED_IN_THIS_MODE: 'This operation is not allowed in this mode',
ORDERING_FIELD_INCORRECT: `Value of sorting order is incorrect. You can choose from values ${enumToString(
QueryOrderingEnum,
Expand Down
2 changes: 1 addition & 1 deletion backend/test/ava-tests/connection-diagram-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ test('buildMermaidErDiagram: highlight tags new columns with NEW marker', (t) =>
addedColumns: new Map([['users', new Set(['nickname'])]]),
addedForeignKeys: new Map(),
});
t.regex(diagram, /varchar nickname[^"\n]*NEW/);
t.regex(diagram, /varchar nickname "[^"\n]*\[NEW\]"/);
});

test('buildMermaidErDiagram: highlight tags new FK relationships with [NEW]', (t) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
import test from 'ava';
import { collectQueryTables } from '../../../src/entities/visualizations/panel/utils/collect-query-tables.util.js';

function tablesOf(query: string, type = ConnectionTypesEnum.postgres): Array<string> {
const result = collectQueryTables(query, type);
if (result.kind !== 'tables') {
throw new Error(`expected resolved tables, got indeterminate: ${result.reason}`);
}
return [...result.tables].sort();
}

test('resolves a single table from a simple SELECT', (t) => {
t.deepEqual(tablesOf('SELECT id, name FROM users'), ['users']);
});

test('resolves all tables referenced in a JOIN', (t) => {
t.deepEqual(tablesOf('SELECT * FROM users u JOIN orders o ON u.id = o.user_id'), ['orders', 'users']);
});

test('resolves tables hidden inside a subquery', (t) => {
t.deepEqual(tablesOf('SELECT * FROM users WHERE id IN (SELECT user_id FROM secret_payouts)'), [
'secret_payouts',
'users',
]);
});

test('resolves tables across a UNION', (t) => {
t.deepEqual(tablesOf('SELECT id FROM a UNION SELECT id FROM b'), ['a', 'b']);
});

test('excludes CTE aliases but keeps their underlying tables', (t) => {
t.deepEqual(tablesOf('WITH cte AS (SELECT * FROM orders) SELECT * FROM cte JOIN users ON true'), ['orders', 'users']);
});

test('strips the schema qualifier and keeps the bare table name', (t) => {
t.deepEqual(tablesOf('SELECT * FROM analytics.events'), ['events']);
});

test('treats a query with no concrete table as indeterminate', (t) => {
const result = collectQueryTables('SELECT 1', ConnectionTypesEnum.postgres);
t.is(result.kind, 'indeterminate');
});

test('treats DESCRIBE as indeterminate (target table not resolvable)', (t) => {
const result = collectQueryTables('DESCRIBE users', ConnectionTypesEnum.mysql);
t.is(result.kind, 'indeterminate');
});

test('treats unparseable SQL as indeterminate', (t) => {
const result = collectQueryTables('EXPLAIN SELECT * FROM users', ConnectionTypesEnum.postgres);
t.is(result.kind, 'indeterminate');
});

test('treats non-SQL connection types as indeterminate without parsing', (t) => {
const result = collectQueryTables('db.users.find({})', ConnectionTypesEnum.mongodb);
t.is(result.kind, 'indeterminate');
if (result.kind === 'indeterminate') {
t.true(result.reason.includes('non-SQL'));
}
});

test('resolves tables from a multi-statement query', (t) => {
t.deepEqual(tablesOf('SELECT * FROM a; SELECT * FROM b'), ['a', 'b']);
});
Loading
Loading