From bb24e5a28533b23815bbdb72fd897eafe312e80f Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Wed, 27 May 2026 11:32:30 +0000 Subject: [PATCH] feat: implement table read permission checks for saved queries and add utility functions for query table resolution --- .../panel/use-cases/execute-panel.use.case.ts | 21 +- .../panel/use-cases/test-db-query.use.case.ts | 21 +- .../assert-query-tables-readable.util.ts | 49 ++++ .../panel/utils/collect-query-tables.util.ts | 101 +++++++ backend/src/exceptions/text/messages.ts | 1 + .../connection-diagram-preview.test.ts | 2 +- .../non-saas-collect-query-tables.test.ts | 65 +++++ ...-saved-query-table-permissions-e2e.test.ts | 259 ++++++++++++++++++ 8 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 backend/src/entities/visualizations/panel/utils/assert-query-tables-readable.util.ts create mode 100644 backend/src/entities/visualizations/panel/utils/collect-query-tables.util.ts create mode 100644 backend/test/ava-tests/non-saas-tests/non-saas-collect-query-tables.test.ts create mode 100644 backend/test/ava-tests/saas-tests/saas-saved-query-table-permissions-e2e.test.ts diff --git a/backend/src/entities/visualizations/panel/use-cases/execute-panel.use.case.ts b/backend/src/entities/visualizations/panel/use-cases/execute-panel.use.case.ts index 446481896..c5f77e454 100644 --- a/backend/src/entities/visualizations/panel/use-cases/execute-panel.use.case.ts +++ b/backend/src/entities/visualizations/panel/use-cases/execute-panel.use.case.ts @@ -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'; @@ -19,6 +22,7 @@ export class ExecuteSavedDbQueryUseCase constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, + private readonly cedarAuthService: CedarAuthorizationService, ) { super(); } @@ -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); diff --git a/backend/src/entities/visualizations/panel/use-cases/test-db-query.use.case.ts b/backend/src/entities/visualizations/panel/use-cases/test-db-query.use.case.ts index 26e5674fb..ff1f5dd9c 100644 --- a/backend/src/entities/visualizations/panel/use-cases/test-db-query.use.case.ts +++ b/backend/src/entities/visualizations/panel/use-cases/test-db-query.use.case.ts @@ -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'; @@ -16,6 +19,7 @@ export class TestDbQueryUseCase extends AbstractUseCase + 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); diff --git a/backend/src/entities/visualizations/panel/utils/assert-query-tables-readable.util.ts b/backend/src/entities/visualizations/panel/utils/assert-query-tables-readable.util.ts new file mode 100644 index 000000000..0488e196c --- /dev/null +++ b/backend/src/entities/visualizations/panel/utils/assert-query-tables-readable.util.ts @@ -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; + /** Lists every table in the connection; used for the all-tables fallback check. */ + listAllTableNames: () => Promise>; +} + +/** + * 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 { + const { query, connectionType, connectionId, validateTableRead, listAllTableNames } = params; + + const collected = collectQueryTables(query, connectionType); + + let tablesToCheck: Array; + 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)); + } + } +} diff --git a/backend/src/entities/visualizations/panel/utils/collect-query-tables.util.ts b/backend/src/entities/visualizations/panel/utils/collect-query-tables.util.ts new file mode 100644 index 000000000..98784a54c --- /dev/null +++ b/backend/src/entities/visualizations/panel/utils/collect-query-tables.util.ts @@ -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 = new Set([ + ConnectionTypesEnum.mongodb, + ConnectionTypesEnum.agent_mongodb, + ConnectionTypesEnum.elasticsearch, + ConnectionTypesEnum.redis, + ConnectionTypesEnum.agent_redis, + ConnectionTypesEnum.dynamodb, +]); + +export type CollectQueryTablesResult = + | { kind: 'tables'; tables: Array } + | { 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); + const parser = new Parser(); + + let rawTableList: Array; + let cteNames: Set; + 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(); + 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, query: string, dialect: string): Set { + const names = new Set(); + 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; +} diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index e37a4b86e..193c26721 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -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, diff --git a/backend/test/ava-tests/connection-diagram-preview.test.ts b/backend/test/ava-tests/connection-diagram-preview.test.ts index b1c2f5e80..a2fa3f9a1 100644 --- a/backend/test/ava-tests/connection-diagram-preview.test.ts +++ b/backend/test/ava-tests/connection-diagram-preview.test.ts @@ -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) => { diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-collect-query-tables.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-collect-query-tables.test.ts new file mode 100644 index 000000000..3a7b8cf5c --- /dev/null +++ b/backend/test/ava-tests/non-saas-tests/non-saas-collect-query-tables.test.ts @@ -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 { + 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']); +}); diff --git a/backend/test/ava-tests/saas-tests/saas-saved-query-table-permissions-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-saved-query-table-permissions-e2e.test.ts new file mode 100644 index 000000000..cf8846bf0 --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-saved-query-table-permissions-e2e.test.ts @@ -0,0 +1,259 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Messages } from '../../../src/exceptions/text/messages.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { TestUtils } from '../../utils/test.utils.js'; +import { createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions } from '../../utils/user-with-different-permissions-utils.js'; + +let app: INestApplication; +let _testUtils: TestUtils; +let currentTest: string; + +test.before(async () => { + process.env.CEDAR_AUTHORIZATION_ENABLED = 'true'; + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + app = moduleFixture.createNestApplication(); + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + try { + await Cacher.clearAllCache(); + delete process.env.CEDAR_AUTHORIZATION_ENABLED; + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +const connectionReadPolicy = (connectionId: string): string => + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + +const tableReadPolicy = (connectionId: string, tableName: string): string => + `permit(\n principal,\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`; + +const panelReadPolicy = (connectionId: string, panelId: string): string => + `permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == RocketAdmin::Panel::"${connectionId}/${panelId}"\n);`; + +async function saveCedarPolicy( + connectionId: string, + groupId: string, + adminToken: string, + policyLines: Array, +): Promise { + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy: policyLines.join('\n\n'), groupId }) + .set('Cookie', adminToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + if (savePolicyResponse.status !== 201) { + throw new Error(`Failed to save cedar policy: ${savePolicyResponse.status} ${savePolicyResponse.text}`); + } +} + +async function createSavedQueryAsAdmin(connectionId: string, adminToken: string, queryText: string): Promise { + const createPanel = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query`) + .send({ name: 'Permission Test Query', query_text: queryText, widget_type: 'table' }) + .set('Cookie', adminToken) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + if (createPanel.status !== 201) { + throw new Error(`Failed to create saved query: ${createPanel.status} ${createPanel.text}`); + } + return createPanel.body.id; +} + +currentTest = 'POST /connection/:connectionId/query/test (table read permissions)'; + +test.serial( + `${currentTest} should allow testing a query when user has read permission on the referenced table`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + await saveCedarPolicy(connectionId, groupId, testData.users.adminUserToken, [ + connectionReadPolicy(connectionId), + tableReadPolicy(connectionId, tableName), + ]); + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/query/test`) + .send({ query_text: `SELECT * FROM "${tableName}"` }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 201); + t.true(Array.isArray(testQueryResponse.body.data)); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial(`${currentTest} should return 403 when user lacks read permission on the referenced table`, async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // Connection read only — no table read permission granted. + await saveCedarPolicy(connectionId, groupId, testData.users.adminUserToken, [connectionReadPolicy(connectionId)]); + + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/query/test`) + .send({ query_text: `SELECT * FROM "${tableName}"` }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 403); + t.is(testQueryResponse.body.message, Messages.NO_READ_PERMISSION_FOR_TABLE(tableName)); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial( + `${currentTest} should return 403 for an indeterminate query when user has no table read permissions (all-tables fallback)`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + + // Connection read only — no table read permission granted. + await saveCedarPolicy(connectionId, groupId, testData.users.adminUserToken, [connectionReadPolicy(connectionId)]); + + // "SELECT 1" resolves to no concrete table, so the check falls back to requiring read on + // every table in the connection — which this user does not have. + const testQueryResponse = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/query/test`) + .send({ query_text: 'SELECT 1' }) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(testQueryResponse.status, 403); + t.true(testQueryResponse.body.message.includes('read permission')); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +currentTest = 'POST /connection/:connectionId/saved-query/:queryId/execute (table read permissions)'; + +test.serial( + `${currentTest} should return 403 when executing a saved query touching a table the user cannot read`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const panelId = await createSavedQueryAsAdmin( + connectionId, + testData.users.adminUserToken, + `SELECT * FROM "${tableName}"`, + ); + + // Grant panel read (so PanelReadGuard passes) but withhold table read. + await saveCedarPolicy(connectionId, groupId, testData.users.adminUserToken, [ + connectionReadPolicy(connectionId), + panelReadPolicy(connectionId, panelId), + ]); + + const executeResponse = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query/${panelId}/execute`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(executeResponse.status, 403); + t.is(executeResponse.body.message, Messages.NO_READ_PERMISSION_FOR_TABLE(tableName)); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should execute a saved query when user has both panel read and table read permissions`, + async (t) => { + try { + const testData = + await createConnectionsAndInviteNewUserInNewGroupWithTableDifferentConnectionGroupReadOnlyPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + const panelId = await createSavedQueryAsAdmin( + connectionId, + testData.users.adminUserToken, + `SELECT * FROM "${tableName}"`, + ); + + await saveCedarPolicy(connectionId, groupId, testData.users.adminUserToken, [ + connectionReadPolicy(connectionId), + panelReadPolicy(connectionId, panelId), + tableReadPolicy(connectionId, tableName), + ]); + + const executeResponse = await request(app.getHttpServer()) + .post(`/connection/${connectionId}/saved-query/${panelId}/execute`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(executeResponse.status, 201); + t.is(executeResponse.body.query_id, panelId); + t.true(Array.isArray(executeResponse.body.data)); + } catch (error) { + console.error(error); + throw error; + } + }, +);