diff --git a/apps/api/src/modules/data-source/source-access.ts b/apps/api/src/modules/data-source/source-access.ts index 19ebc50..5556e5b 100644 --- a/apps/api/src/modules/data-source/source-access.ts +++ b/apps/api/src/modules/data-source/source-access.ts @@ -97,7 +97,11 @@ export function scopedDataSourceForRead( policy: DataSourceAccessPolicy ): DataSourceRecord | null { if (!canReadDataSource(source, policy)) return null; - return source; + if (!policy.allowedTableIds) return source; + return { + ...source, + tables: source.tables.filter(table => canReadDataSourceTable(source, table, policy)) + }; } export function scopedDataSourcesForRead( diff --git a/apps/api/src/modules/data-source/source-table-rows.ts b/apps/api/src/modules/data-source/source-table-rows.ts index c06f84a..aec5e6b 100644 --- a/apps/api/src/modules/data-source/source-table-rows.ts +++ b/apps/api/src/modules/data-source/source-table-rows.ts @@ -18,7 +18,7 @@ import { } from './live-sql-query-engine.js'; import { quoteSqlIdentifierForType } from './sql-dialect.js'; import type { SqlQueryCell, SqlQueryEngineResult, SqlQueryResult } from './sql-query-types.js'; -import type { DataSourceAccessPolicy } from './source-access.js'; +import { canReadDataSourceTable, type DataSourceAccessPolicy } from './source-access.js'; import { type ApiRuntimeStateOptions, isApiDataSource, @@ -53,12 +53,14 @@ export async function readDataSourceTableRows( const source = findDataSource(dataSourceId); const table = findTableInDataSource(dataSourceId, tableIdOrName)?.table; if (!source || !table) return { ok: false, statusCode: 404, error: 'Table not found in data source' }; + if (options.access && !canReadDataSourceTable(source, table, options.access)) { + return { ok: false, statusCode: 403, error: 'Data source table access is denied' }; + } const pagination = normalizeTablePagination(options, 1000); const readMaxLimit = Math.max(options.maxLimit ?? pagination.readLimit, pagination.readLimit); const selectedFields = selectedTableFields(table, options.selectFields); const query = `select ${selectClauseForFields(selectedFields, source.type)} from ${quoteSqlIdentifierForType(table.name, source.type)}`; - void options.access; if (isSqlModelTable(table) && isLiveSqlDataSource(source)) { const result = await executeSqlModelTableQuery({ source, diff --git a/tests/smoke/table-scope-access.test.ts b/tests/smoke/table-scope-access.test.ts new file mode 100644 index 0000000..0a8bde9 --- /dev/null +++ b/tests/smoke/table-scope-access.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildDataSource, + dataSources, + type DataSourceRecord +} from '../../apps/api/src/modules/data-source/foundation-store.js'; +import { readDataSourceFieldOptions } from '../../apps/api/src/modules/data-source/data-source-field-options.js'; +import { readDataSourceTableRows } from '../../apps/api/src/modules/data-source/source-table-rows.js'; +import { getSqlEditorSchema } from '../../apps/api/src/modules/sql-chart/sql-editor-service.js'; +import type { DataSourceAccessPolicy } from '../../apps/api/src/modules/data-source/source-access.js'; + +describe('table-scoped data source access', () => { + afterEach(() => { + const index = dataSources.findIndex(source => source.id === 'table-scope-source'); + if (index >= 0) dataSources.splice(index, 1); + }); + + it('filters SQL editor schema tables to allowedTableIds', () => { + dataSources.push(tableScopedSource()); + + const result = getSqlEditorSchema('table-scope-source', tableScopePolicy()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.tables.map(table => table.name)).toEqual(['allowed_table']); + }); + + it('blocks field option reads for tables outside allowedTableIds', async () => { + dataSources.push(tableScopedSource()); + + const result = await readDataSourceFieldOptions( + 'table-scope-source', + { tableName: 'hidden_table', fieldName: 'secret', limit: 10 }, + null, + tableScopePolicy() + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.statusCode).toBe(404); + }); + + it('blocks row reads for tables outside allowedTableIds', async () => { + dataSources.push(tableScopedSource()); + + const result = await readDataSourceTableRows('table-scope-source', 'hidden_table', { + access: tableScopePolicy() + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.statusCode).toBe(403); + }); +}); + +function tableScopePolicy(): DataSourceAccessPolicy { + return { + allowUnscopedAccess: true, + allowedTableIds: new Set(['allowed_table']), + showSampleDataSources: true + }; +} + +function tableScopedSource(): DataSourceRecord { + return buildDataSource({ + id: 'table-scope-source', + name: 'Table Scope Source', + type: 'sample', + sourceType: 'source', + status: 'connected', + isSample: true, + isGloballyVisible: true, + config: {}, + settings: {}, + dictionary: {}, + tables: [ + { + id: 'allowed_table', + name: 'allowed_table', + fields: [{ name: 'id', type: 'number' }], + sampleRows: [{ id: 1 }] + }, + { + id: 'hidden_table', + name: 'hidden_table', + fields: [{ name: 'secret', type: 'string' }], + sampleRows: [{ secret: 'leaked' }] + } + ] + }); +}