diff --git a/backend/src/entities/cedar-authorization/cedar-action-map.ts b/backend/src/entities/cedar-authorization/cedar-action-map.ts index b161ba42b..559784ffa 100644 --- a/backend/src/entities/cedar-authorization/cedar-action-map.ts +++ b/backend/src/entities/cedar-authorization/cedar-action-map.ts @@ -5,6 +5,8 @@ export enum CedarAction { GroupRead = 'group:read', GroupEdit = 'group:edit', TableRead = 'table:read', + TableQuery = 'table:query', + ColumnRead = 'column:read', TableAdd = 'table:add', TableEdit = 'table:edit', TableDelete = 'table:delete', @@ -24,6 +26,7 @@ export enum CedarResourceType { Connection = 'RocketAdmin::Connection', Group = 'RocketAdmin::Group', Table = 'RocketAdmin::Table', + Column = 'RocketAdmin::Column', ActionEvent = 'RocketAdmin::ActionEvent', Dashboard = 'RocketAdmin::Dashboard', Panel = 'RocketAdmin::Panel', @@ -35,12 +38,15 @@ export const CEDAR_GROUP_TYPE = 'RocketAdmin::Group'; export const ACTION_EVENT_PROBE_ID = '__probe__'; +export const COLUMN_PROBE_ID = '__probe__'; + export interface CedarValidationRequest { userId: string; action: CedarAction; connectionId?: string; groupId?: string; tableName?: string; + columnName?: string; actionEventId?: string; dashboardId?: string; panelId?: string; diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts index 260a84b67..9d2c39fa5 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts @@ -35,7 +35,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } async validate(request: CedarValidationRequest): Promise { - const { userId, action, groupId, tableName, dashboardId, panelId, actionEventId } = request; + const { userId, action, groupId, tableName, columnName, dashboardId, panelId, actionEventId } = request; let { connectionId } = request; const actionPrefix = action.split(':')[0]; @@ -59,7 +59,46 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On if (!connectionId) return false; resourceType = CedarResourceType.Table; resourceId = `${connectionId}/${tableName}`; + if (action === CedarAction.TableQuery) { + // table:read is an alias for table:query + column:read(*). Honor legacy or + // hand-written policies that grant table:read directly as a QueryTable grant. + if (await this.evaluate(userId, connectionId, CedarAction.TableQuery, resourceType, resourceId, tableName)) { + return true; + } + return this.evaluate(userId, connectionId, CedarAction.TableRead, resourceType, resourceId, tableName); + } break; + case 'column': { + if (!connectionId) return false; + if (!tableName || !columnName) return false; + resourceType = CedarResourceType.Column; + resourceId = `${connectionId}/${tableName}/${columnName}`; + if ( + await this.evaluate( + userId, + connectionId, + action, + resourceType, + resourceId, + tableName, + undefined, + undefined, + undefined, + columnName, + ) + ) { + return true; + } + // Legacy alias: a direct table:read grant covers every column of the table. + return this.evaluate( + userId, + connectionId, + CedarAction.TableRead, + CedarResourceType.Table, + `${connectionId}/${tableName}`, + tableName, + ); + } case 'actionEvent': { if (!connectionId) return false; if (!tableName || !actionEventId) return false; @@ -220,6 +259,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On dashboardId?: string, panelId?: string, actionEventId?: string, + columnName?: string, ): Promise { await this.assertUserNotSuspended(userId); @@ -237,6 +277,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On dashboardId, panelId, actionEventId, + columnName, ); for (const policy of groupPolicies) { @@ -350,6 +391,19 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On } } + const columnResourceIds = [...cedarPolicy.matchAll(/resource\s*(?:==|in)\s*RocketAdmin::Column::"([^"]+)"/g)].map( + (m) => m[1], + ); + + for (const columnRef of columnResourceIds) { + if (!columnRef.startsWith(`${connectionId}/`)) { + throw new HttpException( + { message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION }, + HttpStatus.BAD_REQUEST, + ); + } + } + const dashboardResourceIds = [...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Dashboard::"([^"]+)"/g)].map( (m) => m[1], ); diff --git a/backend/src/entities/cedar-authorization/cedar-entity-builder.ts b/backend/src/entities/cedar-authorization/cedar-entity-builder.ts index ce511a264..7b14e6c57 100644 --- a/backend/src/entities/cedar-authorization/cedar-entity-builder.ts +++ b/backend/src/entities/cedar-authorization/cedar-entity-builder.ts @@ -14,6 +14,7 @@ export function buildCedarEntities( dashboardId?: string, panelId?: string, actionEventId?: string, + columnName?: string, ): Array { const entities: Array = []; @@ -52,6 +53,16 @@ export function buildCedarEntities( }); } + // Column entity, parented by its Table — required so `resource in Table::"..."` + // policies authorize reading any column without naming each one (the table:read alias). + if (columnName && tableName) { + entities.push({ + uid: { type: 'RocketAdmin::Column', id: `${connectionId}/${tableName}/${columnName}` }, + attrs: { connectionId: connectionId, tableName: tableName }, + parents: [{ type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` }], + }); + } + // ActionEvent entity, parented by its Table — required so `resource in Table::"..."` // policies authorize triggering specific events without naming each event. if (actionEventId && tableName) { diff --git a/backend/src/entities/cedar-authorization/cedar-permissions.service.ts b/backend/src/entities/cedar-authorization/cedar-permissions.service.ts index ab4bd2388..8a5389b4f 100644 --- a/backend/src/entities/cedar-authorization/cedar-permissions.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-permissions.service.ts @@ -14,6 +14,7 @@ import { CEDAR_USER_TYPE, CedarAction, CedarResourceType, + COLUMN_PROBE_ID, } from './cedar-action-map.js'; import { buildCedarEntities } from './cedar-entity-builder.js'; import { CEDAR_SCHEMA } from './cedar-schema.js'; @@ -256,6 +257,8 @@ export class CedarPermissionsService implements IUserAccessRepository { return result; } + // "Table read" now means "may query this table" (the QueryTable half of the table:read + // alias). Column-level visibility is enforced separately via checkColumnRead/getReadableColumns. async checkTableRead( cognitoUserName: string, connectionId: string, @@ -265,15 +268,44 @@ export class CedarPermissionsService implements IUserAccessRepository { const ctx = await this.loadContext(connectionId, cognitoUserName); if (!ctx) return false; - const entities = buildCedarEntities(cognitoUserName, ctx.userGroups, connectionId, tableName); - return this.evaluatePolicies( - cognitoUserName, - CedarAction.TableRead, - CedarResourceType.Table, - `${connectionId}/${tableName}`, - ctx.policies, - entities, - ); + return this.evaluateTableQuery(cognitoUserName, connectionId, tableName, ctx); + } + + async checkColumnRead( + cognitoUserName: string, + connectionId: string, + tableName: string, + columnName: string, + ): Promise { + const ctx = await this.loadContext(connectionId, cognitoUserName); + if (!ctx) return false; + + return this.evaluateColumnRead(cognitoUserName, connectionId, tableName, columnName, ctx); + } + + // Returns the subset of `allColumnNames` the user may read. A single probe detects a + // table-wide grant (the table:read alias → ColumnRead(table, *)); only column-restricted + // tables pay a per-column evaluation. + async getReadableColumns( + cognitoUserName: string, + connectionId: string, + tableName: string, + allColumnNames: Array, + ): Promise> { + const ctx = await this.loadContext(connectionId, cognitoUserName); + if (!ctx) return new Set(); + + if (this.evaluateColumnRead(cognitoUserName, connectionId, tableName, COLUMN_PROBE_ID, ctx)) { + return new Set(allColumnNames); + } + + const readable = new Set(); + for (const columnName of allColumnNames) { + if (this.evaluateColumnRead(cognitoUserName, connectionId, tableName, columnName, ctx)) { + readable.add(columnName); + } + } + return readable; } async checkTableAdd( @@ -402,14 +434,7 @@ export class CedarPermissionsService implements IUserAccessRepository { const entities = buildCedarEntities(userId, ctx.userGroups, connectionId, tableName); const resourceId = `${connectionId}/${tableName}`; - const canRead = this.evaluatePolicies( - userId, - CedarAction.TableRead, - CedarResourceType.Table, - resourceId, - ctx.policies, - entities, - ); + const canRead = this.evaluateTableQuery(userId, connectionId, tableName, ctx); const canAdd = this.evaluatePolicies( userId, CedarAction.TableAdd, @@ -478,6 +503,65 @@ export class CedarPermissionsService implements IUserAccessRepository { }; } + // QueryTable check honoring the table:read alias: a direct table:read grant (legacy or + // hand-written policy) also permits querying the table. + private evaluateTableQuery(userId: string, connectionId: string, tableName: string, ctx: EvalContext): boolean { + const entities = buildCedarEntities(userId, ctx.userGroups, connectionId, tableName); + const resourceId = `${connectionId}/${tableName}`; + return ( + this.evaluatePolicies( + userId, + CedarAction.TableQuery, + CedarResourceType.Table, + resourceId, + ctx.policies, + entities, + ) || + this.evaluatePolicies(userId, CedarAction.TableRead, CedarResourceType.Table, resourceId, ctx.policies, entities) + ); + } + + private evaluateColumnRead( + userId: string, + connectionId: string, + tableName: string, + columnName: string, + ctx: EvalContext, + ): boolean { + const columnEntities = buildCedarEntities( + userId, + ctx.userGroups, + connectionId, + tableName, + undefined, + undefined, + undefined, + columnName, + ); + if ( + this.evaluatePolicies( + userId, + CedarAction.ColumnRead, + CedarResourceType.Column, + `${connectionId}/${tableName}/${columnName}`, + ctx.policies, + columnEntities, + ) + ) { + return true; + } + // Legacy alias: a direct table:read grant covers every column of the table. + const tableEntities = buildCedarEntities(userId, ctx.userGroups, connectionId, tableName); + return this.evaluatePolicies( + userId, + CedarAction.TableRead, + CedarResourceType.Table, + `${connectionId}/${tableName}`, + ctx.policies, + tableEntities, + ); + } + private evaluatePolicies( userId: string, action: CedarAction, diff --git a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts index b35a3d1ac..4fd10a247 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts @@ -136,9 +136,26 @@ export function generateCedarPolicyForGroup( // action events is a side-effect-only capability and does not imply table visibility. const hasAnyAccess = access.visibility || access.add || access.delete || access.edit; if (hasAnyAccess) { + // QueryTable: may run a read query against the table at all (checked before the query). policies.push( - `permit(\n principal,\n action == RocketAdmin::Action::"table:read",\n resource == ${tableRef}\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == ${tableRef}\n);`, ); + // ColumnRead (checked after the query). `table:read` is an alias for + // QueryTable + ColumnRead(table, *): when no explicit column whitelist is given we + // grant every column via `resource in Table`; otherwise one grant per allowed column. + const readableColumns = table.readableColumns; + if (readableColumns && readableColumns.length > 0) { + for (const columnName of readableColumns) { + const columnRef = `RocketAdmin::Column::"${connectionId}/${table.tableName}/${columnName}"`; + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == ${columnRef}\n);`, + ); + } + } else { + policies.push( + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource in ${tableRef}\n);`, + ); + } } if (access.add) { policies.push( diff --git a/backend/src/entities/cedar-authorization/cedar-policy-parser.ts b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts index 710ce3bb9..02e76f516 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-parser.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-parser.ts @@ -68,6 +68,7 @@ export function parseCedarPolicyToClassicalPermissions( result.group.accessLevel = AccessLevelEnum.edit; break; case 'table:read': + case 'table:query': case 'table:add': case 'table:edit': case 'table:delete': @@ -78,6 +79,25 @@ export function parseCedarPolicyToClassicalPermissions( applyTableAction(tableEntry, permit.action); break; } + case 'column:read': { + if (permit.resourceType === 'RocketAdmin::Table' && permit.isInRelation) { + // Wildcard: read every column on this table (the table:read alias). No explicit + // column whitelist — leave readableColumns undefined ⇒ "all columns". + const tableName = extractTableName(permit.resourceId, connectionId); + if (!tableName) break; + getOrCreateTableEntry(tableMap, tableName); + } else if (permit.resourceType === 'RocketAdmin::Column') { + // Per-column grant: add this column to the table's readable whitelist. + const parts = extractColumnResource(permit.resourceId, connectionId); + if (!parts) break; + const tableEntry = getOrCreateTableEntry(tableMap, parts.tableName); + if (!tableEntry.readableColumns) tableEntry.readableColumns = []; + if (!tableEntry.readableColumns.includes(parts.columnName)) { + tableEntry.readableColumns.push(parts.columnName); + } + } + break; + } case 'actionEvent:trigger': { if (permit.resourceType === 'RocketAdmin::Table' && permit.isInRelation) { // Blanket: trigger any event on this table @@ -273,6 +293,7 @@ function getOrCreateTableEntry(map: Map, tableName function applyTableAction(entry: ITablePermissionData, action: string): void { switch (action) { case 'table:read': + case 'table:query': entry.accessLevel.visibility = true; break; case 'table:add': @@ -290,6 +311,20 @@ function applyTableAction(entry: ITablePermissionData, action: string): void { } } +function extractColumnResource( + resourceId: string | null, + connectionId: string, +): { tableName: string; columnName: string } | null { + if (!resourceId) return null; + const prefix = `${connectionId}/`; + const stripped = resourceId.startsWith(prefix) ? resourceId.slice(prefix.length) : resourceId; + const slash = stripped.indexOf('/'); + if (slash <= 0 || slash === stripped.length - 1) return null; + const tableName = stripped.slice(0, slash); + const columnName = stripped.slice(slash + 1); + return { tableName, columnName }; +} + function extractActionEventResource( resourceId: string | null, connectionId: string, diff --git a/backend/src/entities/cedar-authorization/cedar-schema.json b/backend/src/entities/cedar-authorization/cedar-schema.json index 0460d3ce8..ace820dd5 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.json +++ b/backend/src/entities/cedar-authorization/cedar-schema.json @@ -36,6 +36,16 @@ } } }, + "Column": { + "memberOfTypes": ["Table"], + "shape": { + "type": "Record", + "attributes": { + "connectionId": { "type": "String" }, + "tableName": { "type": "String" } + } + } + }, "ActionEvent": { "memberOfTypes": ["Table"], "shape": { @@ -93,6 +103,18 @@ "resourceTypes": ["Table"] } }, + "table:query": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Table"] + } + }, + "column:read": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Column"] + } + }, "table:add": { "appliesTo": { "principalTypes": ["User"], diff --git a/backend/src/entities/cedar-authorization/cedar-schema.ts b/backend/src/entities/cedar-authorization/cedar-schema.ts index 996aa0828..a50151dea 100644 --- a/backend/src/entities/cedar-authorization/cedar-schema.ts +++ b/backend/src/entities/cedar-authorization/cedar-schema.ts @@ -36,6 +36,16 @@ export const CEDAR_SCHEMA = { }, }, }, + Column: { + memberOfTypes: ['Table'], + shape: { + type: 'Record', + attributes: { + connectionId: { type: 'String' }, + tableName: { type: 'String' }, + }, + }, + }, ActionEvent: { memberOfTypes: ['Table'], shape: { @@ -102,6 +112,18 @@ export const CEDAR_SCHEMA = { resourceTypes: ['Table'], }, }, + 'table:query': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Table'], + }, + }, + 'column:read': { + appliesTo: { + principalTypes: ['User'], + resourceTypes: ['Column'], + }, + }, 'table:add': { appliesTo: { principalTypes: ['User'], diff --git a/backend/src/entities/permission/application/data-structures/create-permissions.ds.ts b/backend/src/entities/permission/application/data-structures/create-permissions.ds.ts index 666eb1c1c..1664d4a72 100644 --- a/backend/src/entities/permission/application/data-structures/create-permissions.ds.ts +++ b/backend/src/entities/permission/application/data-structures/create-permissions.ds.ts @@ -62,6 +62,12 @@ export class TablePermissionDs { @ApiProperty() @IsString() tableName: string; + + @ApiProperty({ required: false, isArray: true, type: String }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + readableColumns?: Array; } export class GroupPermissionDs { diff --git a/backend/src/entities/permission/permission.interface.ts b/backend/src/entities/permission/permission.interface.ts index 4255ba772..d07b1c74b 100644 --- a/backend/src/entities/permission/permission.interface.ts +++ b/backend/src/entities/permission/permission.interface.ts @@ -32,6 +32,9 @@ export interface ITableAccessLevel { export interface ITablePermissionData { tableName: string; accessLevel: ITableAccessLevel; + // Whitelist of columns the user may read. Undefined/empty ⇒ all columns readable + // (the table:read alias = QueryTable + ColumnRead(table, *)). + readableColumns?: Array; } export interface ITableAndViewPermissionData extends ITablePermissionData { diff --git a/backend/src/entities/table-filters/table-filters.controller.ts b/backend/src/entities/table-filters/table-filters.controller.ts index 159349fee..a1082e082 100644 --- a/backend/src/entities/table-filters/table-filters.controller.ts +++ b/backend/src/entities/table-filters/table-filters.controller.ts @@ -21,7 +21,7 @@ import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { Messages } from '../../exceptions/text/messages.js'; import { ConnectionEditGuard } from '../../guards/connection-edit.guard.js'; import { ConnectionReadGuard } from '../../guards/connection-read.guard.js'; -import { TableReadGuard } from '../../guards/table-read.guard.js'; +import { QueryTableGuard } from '../../guards/query-table.guard.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; import { SuccessResponse } from '../../microservices/saas-microservice/data-structures/common-responce.ds.js'; import { CreateTableFilterDs } from './application/data-structures/create-table-filters.ds.js'; @@ -103,7 +103,7 @@ export class TableFiltersController { }) @ApiQuery({ name: 'tableName', required: true }) @ApiParam({ name: 'connectionId', required: true }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Get('/:connectionId/all') async findTableFilters( @QueryTableName() tableName: string, diff --git a/backend/src/entities/table-settings/personal-table-settings/personal-table-settings.controller.ts b/backend/src/entities/table-settings/personal-table-settings/personal-table-settings.controller.ts index c2022904a..01288ab5e 100644 --- a/backend/src/entities/table-settings/personal-table-settings/personal-table-settings.controller.ts +++ b/backend/src/entities/table-settings/personal-table-settings/personal-table-settings.controller.ts @@ -20,7 +20,7 @@ import { Timeout } from '../../../decorators/timeout.decorator.js'; import { UserId } from '../../../decorators/user-id.decorator.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; import { Messages } from '../../../exceptions/text/messages.js'; -import { TableReadGuard } from '../../../guards/table-read.guard.js'; +import { QueryTableGuard } from '../../../guards/query-table.guard.js'; import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js'; import { CreatePersonalTableSettingsDs } from './data-structures/create-personal-table-settings.ds.js'; import { FindPersonalTableSettingsDs } from './data-structures/find-personal-table-settings.ds.js'; @@ -53,7 +53,7 @@ export class PersonalTableSettingsController { }) @ApiParam({ name: 'connectionId', required: true }) @ApiQuery({ name: 'tableName', required: true }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Get('/settings/personal/:connectionId') async findAll( @SlugUuid('connectionId') connectionId: string, @@ -87,7 +87,7 @@ export class PersonalTableSettingsController { @ApiBody({ type: CreatePersonalTableSettingsDto }) @ApiParam({ name: 'connectionId', required: true }) @ApiQuery({ name: 'tableName', required: true }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Put('/settings/personal/:connectionId') async createOrUpdate( @SlugUuid('connectionId') connectionId: string, diff --git a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts index 0555e4769..fcc2253ea 100644 --- a/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts +++ b/backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.controller.ts @@ -27,10 +27,10 @@ import { UserId } from '../../../decorators/user-id.decorator.js'; import { InTransactionEnum } from '../../../enums/in-transaction.enum.js'; import { ConnectionNotFoundException } from '../../../exceptions/custom-exceptions/connection-not-found-exception.js'; import { Messages } from '../../../exceptions/text/messages.js'; +import { QueryTableGuard } from '../../../guards/query-table.guard.js'; import { TableAddGuard } from '../../../guards/table-add.guard.js'; import { TableDeleteGuard } from '../../../guards/table-delete.guard.js'; import { TableEditGuard } from '../../../guards/table-edit.guard.js'; -import { TableReadGuard } from '../../../guards/table-read.guard.js'; import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; import { isObjectEmpty } from '../../../helpers/is-object-empty.js'; import { isObjectPropertyExists } from '../../../helpers/validators/is-object-property-exists-validator.js'; @@ -113,7 +113,7 @@ export class TablePureCrudOperationsController { @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'perPage', required: false }) @ApiQuery({ name: 'search', required: false }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Timeout(TimeoutDefaults.EXTENDED) @Throttle({ default: { limit: 300, ttl: 60000 } }) @HttpCode(HttpStatus.OK) @@ -161,7 +161,7 @@ export class TablePureCrudOperationsController { }) @ApiResponse({ status: 200, description: 'Row found.', type: PureCrudRowResponseDs }) @ApiQuery({ name: 'tableName', required: true }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Get('/table/crud/:connectionId') async readRow( @Query() query: Record, diff --git a/backend/src/entities/table/table.controller.ts b/backend/src/entities/table/table.controller.ts index 2e3bcb280..0b9c4ab33 100644 --- a/backend/src/entities/table/table.controller.ts +++ b/backend/src/entities/table/table.controller.ts @@ -32,10 +32,10 @@ import { AmplitudeEventTypeEnum } from '../../enums/amplitude-event-type.enum.js import { InTransactionEnum } from '../../enums/in-transaction.enum.js'; import { ConnectionNotFoundException } from '../../exceptions/custom-exceptions/connection-not-found-exception.js'; import { Messages } from '../../exceptions/text/messages.js'; +import { QueryTableGuard } from '../../guards/query-table.guard.js'; import { TableAddGuard } from '../../guards/table-add.guard.js'; import { TableDeleteGuard } from '../../guards/table-delete.guard.js'; import { TableEditGuard } from '../../guards/table-edit.guard.js'; -import { TableReadGuard } from '../../guards/table-read.guard.js'; import { TablesReceiveGuard } from '../../guards/tables-receive.guard.js'; import { Constants } from '../../helpers/constants/constants.js'; import { isConnectionTypeAgent } from '../../helpers/is-connection-entity-agent.js'; @@ -194,7 +194,7 @@ export class TableController { description: 'Returns all table rows.', type: FoundTableRowsDs, }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @ApiQuery({ name: 'tableName', required: true }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'perPage', required: false }) @@ -268,7 +268,7 @@ export class TableController { description: 'Invalidate table metadata cache before reading rows. Underscore prefix avoids column-name collisions.', }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Timeout(TimeoutDefaults.EXTENDED) @Throttle({ default: { limit: 300, ttl: 60000 } }) @HttpCode(HttpStatus.OK) @@ -332,7 +332,7 @@ export class TableController { type: TableStructureDs, }) @ApiQuery({ name: 'tableName', required: true }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Get('/table/structure/:connectionId') async getTableStructure( @QueryTableName() tableName: string, @@ -368,7 +368,7 @@ export class TableController { type: TableStructureDs, }) @ApiQuery({ name: 'tableName', required: true }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Get('/table/structure/no-cache/:connectionId') async getTableStructureWithoutCache( @QueryTableName() tableName: string, @@ -656,7 +656,7 @@ export class TableController { type: Boolean, description: 'Invalidate table metadata cache before reading. Underscore prefix avoids column-name collisions.', }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Get('/table/row/:connectionId') async getRowByPrimaryKey( @Query() query: Record, @@ -722,7 +722,7 @@ export class TableController { @ApiProperty({ name: 'page', required: false }) @ApiProperty({ name: 'perPage', required: false }) @ApiProperty({ name: 'search', required: false }) - @UseGuards(TableReadGuard) + @UseGuards(QueryTableGuard) @Timeout(TimeoutDefaults.EXTENDED) @Post('/table/csv/export/:connectionId') async exportCSVFromTable( diff --git a/backend/src/entities/table/use-cases/export-csv-from-table.use.case.ts b/backend/src/entities/table/use-cases/export-csv-from-table.use.case.ts index fc63534a3..8f75d360a 100644 --- a/backend/src/entities/table/use-cases/export-csv-from-table.use.case.ts +++ b/backend/src/entities/table/use-cases/export-csv-from-table.use.case.ts @@ -1,3 +1,4 @@ +import { Transform } from 'node:stream'; import { HttpException, HttpStatus, Inject, Injectable, StreamableFile } from '@nestjs/common'; import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js'; import { buildDAOsTableSettingsDs } from '@rocketadmin/shared-code/dist/src/helpers/data-structures-builders/table-settings.ds.builder.js'; @@ -13,10 +14,12 @@ import { getErrorMessage } from '../../../helpers/get-error-message.js'; import { isConnectionTypeAgent } from '../../../helpers/is-connection-entity-agent.js'; import { isObjectEmpty } from '../../../helpers/is-object-empty.js'; import { slackPostMessage } from '../../../helpers/slack/slack-post-message.js'; +import { CedarPermissionsService } from '../../cedar-authorization/cedar-permissions.service.js'; import { TableLogsService } from '../../table-logs/table-logs.service.js'; import { GetTableRowsDs } from '../application/data-structures/get-table-rows.ds.js'; import { FilteringFieldsDs } from '../table-datastructures.js'; import { buildCommonTableSettingsInput } from '../utils/build-common-table-settings-input.util.js'; +import { filterRowByReadableColumns, isAllColumnsReadable } from '../utils/filter-columns-by-read-permission.util.js'; import { findFilteringFieldsUtil, parseFilteringFieldsFromBodyData } from '../utils/find-filtering-fields.util.js'; import { findOrderingFieldUtil } from '../utils/find-ordering-field.util.js'; import { isHexString } from '../utils/is-hex-string.js'; @@ -32,6 +35,7 @@ export class ExportCSVFromTableUseCase @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, private tableLogsService: TableLogsService, + private readonly cedarPermissions: CedarPermissionsService, ) { super(); } @@ -102,6 +106,17 @@ export class ExportCSVFromTableUseCase operationResult = OperationResultStatusEnum.successfully; + // Column-level read permission (the ColumnRead half of table:read): drop columns the + // user may not read from the exported rows. + const allColumnNames = tableStructure.map((column) => column.column_name); + const readableColumns = await this.cedarPermissions.getReadableColumns( + userId, + connectionId, + tableName, + allColumnNames, + ); + const restrictColumns = !isAllColumnsReadable(readableColumns, allColumnNames); + //todo: rework as streams when node oracle driver will support it correctly //todo: agent return data as array of table rows, not as stream, because we cant //todo: transfer data as a stream from clint to server @@ -114,7 +129,21 @@ export class ExportCSVFromTableUseCase connection.type === 'redis' || isConnectionTypeAgent(connection.type) ) { - return new StreamableFile(csv.stringify(rowsStream as any, { header: true })); + const rowsArray = restrictColumns + ? (rowsStream as unknown as Array>).map((row) => + filterRowByReadableColumns(row, readableColumns), + ) + : rowsStream; + return new StreamableFile(csv.stringify(rowsArray as any, { header: true })); + } + if (restrictColumns) { + const columnFilterTransform = new Transform({ + objectMode: true, + transform(row, _encoding, callback) { + callback(null, filterRowByReadableColumns(row as Record, readableColumns)); + }, + }); + return new StreamableFile(rowsStream.pipe(columnFilterTransform).pipe(csv.stringify({ header: true }))); } return new StreamableFile(rowsStream.pipe(csv.stringify({ header: true }))); } catch (error) { diff --git a/backend/src/entities/table/use-cases/get-row-by-primary-key.use.case.ts b/backend/src/entities/table/use-cases/get-row-by-primary-key.use.case.ts index 7a2ea2faf..032f1d908 100644 --- a/backend/src/entities/table/use-cases/get-row-by-primary-key.use.case.ts +++ b/backend/src/entities/table/use-cases/get-row-by-primary-key.use.case.ts @@ -22,6 +22,12 @@ import { buildCommonTableSettingsInput } from '../utils/build-common-table-setti import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; import { convertHexDataInPrimaryKeyUtil } from '../utils/convert-hex-data-in-primary-key.util.js'; import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; +import { + filterColumnNamesByReadable, + filterRowByReadableColumns, + filterStructureByReadableColumns, + isAllColumnsReadable, +} from '../utils/filter-columns-by-read-permission.util.js'; import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; import { findAvailableFields } from '../utils/find-available-fields.utils.js'; import { formFullTableStructure } from '../utils/form-full-table-structure.js'; @@ -147,7 +153,23 @@ export class GetRowByPrimaryKeyUseCase ); } rowData = removePasswordsFromRowsUtil(rowData, tableWidgets); - const formedTableStructure = formFullTableStructure(tableStructure, tableSettings); + let formedTableStructure = formFullTableStructure(tableStructure, tableSettings); + + // Column-level read permission (the ColumnRead half of table:read): strip columns the + // user may not read from the row and metadata. + const allColumnNames = tableStructure.map((column) => column.column_name); + const readableColumns = await this.cedarPermissions.getReadableColumns( + userId, + connectionId, + tableName, + allColumnNames, + ); + let listFields = findAvailableFields(builtDAOsTableSettings, tableStructure); + if (!isAllColumnsReadable(readableColumns, allColumnNames)) { + rowData = filterRowByReadableColumns(rowData, readableColumns); + formedTableStructure = filterStructureByReadableColumns(formedTableStructure, readableColumns); + listFields = filterColumnNamesByReadable(listFields, readableColumns) ?? []; + } await filterReferencedTablesByPermission( referencedTableNamesAndColumns, @@ -169,7 +191,7 @@ export class GetRowByPrimaryKeyUseCase structure: formedTableStructure, table_widgets: tableWidgets, readonly_fields: tableSettings?.readonly_fields ? tableSettings.readonly_fields : [], - list_fields: findAvailableFields(builtDAOsTableSettings, tableStructure), + list_fields: listFields, action_events: customActionEvents.map((event) => buildActionEventDto(event)), table_actions: customActionEvents.map((el) => buildActionEventDto(el)), identity_column: tableSettings?.identity_column ? tableSettings.identity_column : null, diff --git a/backend/src/entities/table/use-cases/get-table-rows.use.case.ts b/backend/src/entities/table/use-cases/get-table-rows.use.case.ts index 8d07e2b16..021c85b40 100644 --- a/backend/src/entities/table/use-cases/get-table-rows.use.case.ts +++ b/backend/src/entities/table/use-cases/get-table-rows.use.case.ts @@ -37,6 +37,12 @@ import { attachForeignColumnNames } from '../utils/attach-foreign-column-names.u import { buildCommonTableSettingsInput } from '../utils/build-common-table-settings-input.util.js'; import { buildTableSettingsForResponse } from '../utils/build-table-settings-for-response.util.js'; import { extractForeignKeysFromWidgets } from '../utils/extract-foreign-keys-from-widgets.util.js'; +import { + filterColumnNamesByReadable, + filterRowsByReadableColumns, + filterStructureByReadableColumns, + isAllColumnsReadable, +} from '../utils/filter-columns-by-read-permission.util.js'; import { filterForeignKeysByReadPermission } from '../utils/filter-foreign-keys-by-permission.util.js'; import { findAutocompleteFieldsUtil } from '../utils/find-autocomplete-fields.util.js'; import { findAvailableFields } from '../utils/find-available-fields.utils.js'; @@ -210,6 +216,18 @@ export class GetTableRowsUseCase extends AbstractUseCase buildActionEventDto(el)); const savedFiltersRO = savedTableFilters.map((el) => buildCreatedTableFilterRO(el)); + // Column-level read permission (the ColumnRead half of table:read). Computed once; + // when the user lacks read access to some columns we strip them from the rows and + // metadata below, after foreign-key identity enrichment has run. + const allColumnNames = tableStructure.map((column) => column.column_name); + const readableColumns = await this.cedarPermissions.getReadableColumns( + userId, + connectionId, + tableName, + allColumnNames, + ); + const restrictColumns = !isAllColumnsReadable(readableColumns, allColumnNames); + const rowsRO = { rows: rows.data, primaryColumns: tablePrimaryColumns, @@ -342,6 +360,15 @@ export class GetTableRowsUseCase extends AbstractUseCase, allColumnNames: Array): boolean { + return allColumnNames.every((columnName) => readable.has(columnName)); +} + +export function filterRowByReadableColumns( + row: Record, + readable: Set, +): Record { + const result: Record = {}; + for (const key of Object.keys(row)) { + if (readable.has(key)) { + result[key] = row[key]; + } + } + return result; +} + +export function filterRowsByReadableColumns( + rows: Array>, + readable: Set, +): Array> { + return rows.map((row) => filterRowByReadableColumns(row, readable)); +} + +export function filterStructureByReadableColumns( + structure: Array, + readable: Set, +): Array { + return structure.filter((column) => readable.has(column.column_name)); +} + +export function filterColumnNamesByReadable( + columnNames: Array | undefined, + readable: Set, +): Array | undefined { + if (!columnNames) return columnNames; + return columnNames.filter((columnName) => readable.has(columnName)); +} 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 a34a56c83..f51be9bcd 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 @@ -57,7 +57,7 @@ export class ExecuteSavedDbQueryUseCase validateTableRead: (referencedTableName) => this.cedarAuthService.validate({ userId, - action: CedarAction.TableRead, + action: CedarAction.TableQuery, connectionId, tableName: referencedTableName, }), 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 698000eb9..c33250aca 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 @@ -47,7 +47,7 @@ export class TestDbQueryUseCase extends AbstractUseCase this.cedarAuthService.validate({ userId, - action: CedarAction.TableRead, + action: CedarAction.TableQuery, connectionId, tableName: referencedTableName, }), diff --git a/backend/src/guards/table-read.guard.ts b/backend/src/guards/query-table.guard.ts similarity index 95% rename from backend/src/guards/table-read.guard.ts rename to backend/src/guards/query-table.guard.ts index 9337d9467..5e03e0a77 100644 --- a/backend/src/guards/table-read.guard.ts +++ b/backend/src/guards/query-table.guard.ts @@ -8,7 +8,7 @@ import { ValidationHelper } from '../helpers/validators/validation-helper.js'; import { validateUuidByRegex } from './utils/validate-uuid-by-regex.js'; @Injectable() -export class TableReadGuard implements CanActivate { +export class QueryTableGuard implements CanActivate { constructor(private readonly cedarAuthService: CedarAuthorizationService) {} canActivate(context: ExecutionContext): boolean | Promise | Observable { @@ -33,7 +33,7 @@ export class TableReadGuard implements CanActivate { try { const allowed = await this.cedarAuthService.validate({ userId: cognitoUserName, - action: CedarAction.TableRead, + action: CedarAction.TableQuery, connectionId, tableName, }); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts index 493a568a9..366f851f1 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-entity-builder.test.ts @@ -82,6 +82,33 @@ test('empty groups array means user has no parents', (t) => { t.deepEqual(userEntity.parents, []); }); +test('column entity created when columnName provided, parented by its table', (t) => { + const tableName = 'users'; + const columnName = 'email'; + const entities = buildCedarEntities( + userId, + [makeGroup('g1', false)], + connectionId, + tableName, + undefined, + undefined, + undefined, + columnName, + ); + const columnEntity = entities.find((e) => e.uid.type === 'RocketAdmin::Column'); + t.truthy(columnEntity); + t.is(columnEntity.uid.id, `${connectionId}/${tableName}/${columnName}`); + t.is(columnEntity.attrs.connectionId, connectionId); + t.is(columnEntity.attrs.tableName, tableName); + t.deepEqual(columnEntity.parents, [{ type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` }]); +}); + +test('no column entity when columnName omitted', (t) => { + const entities = buildCedarEntities(userId, [makeGroup('g1', false)], connectionId, 'users'); + const columnEntity = entities.find((e) => e.uid.type === 'RocketAdmin::Column'); + t.falsy(columnEntity); +}); + test('dashboard entity created when dashboardId provided with correct id and parent', (t) => { const dashboardId = 'dash-1'; const entities = buildCedarEntities(userId, [makeGroup('g1', false)], connectionId, undefined, dashboardId); diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts index 433fece21..7a5ebd155 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts @@ -95,7 +95,7 @@ test('group:readonly generates only group:read', (t) => { t.is(permits.length, 1); }); -test('table with visibility=true only generates only table:read', (t) => { +test('table with visibility=true generates table:query + wildcard column:read (table:read alias)', (t) => { const result = generateCedarPolicyForGroup( connectionId, false, @@ -108,15 +108,43 @@ test('table with visibility=true only generates only table:read', (t) => { ], }), ); - t.true(result.includes('action == RocketAdmin::Action::"table:read"')); + t.true(result.includes('action == RocketAdmin::Action::"table:query"')); + t.true(result.includes('action == RocketAdmin::Action::"column:read"')); + // Wildcard column read: every column of the table via `resource in Table`. + t.true(result.includes(`resource in RocketAdmin::Table::"${connectionId}/users"`)); + // table:read is no longer emitted directly; it is the alias = table:query + column:read(*). + t.false(result.includes('action == RocketAdmin::Action::"table:read"')); t.false(result.includes('table:add')); t.false(result.includes('table:edit')); t.false(result.includes('table:delete')); const permits = result.match(/permit\(/g); - t.is(permits.length, 1); + t.is(permits.length, 2); +}); + +test('table with explicit readableColumns generates per-column column:read (no wildcard)', (t) => { + const result = generateCedarPolicyForGroup( + connectionId, + false, + makePermissions({ + tables: [ + { + tableName: 'users', + accessLevel: { visibility: true, readonly: false, add: false, delete: false, edit: false }, + readableColumns: ['id', 'email'], + }, + ], + }), + ); + t.true(result.includes('action == RocketAdmin::Action::"table:query"')); + t.true(result.includes(`resource == RocketAdmin::Column::"${connectionId}/users/id"`)); + t.true(result.includes(`resource == RocketAdmin::Column::"${connectionId}/users/email"`)); + // No table-wide wildcard when an explicit whitelist is given. + t.false(result.includes(`resource in RocketAdmin::Table::"${connectionId}/users"`)); + const permits = result.match(/permit\(/g); + t.is(permits.length, 3); // table:query + 2 columns }); -test('table with all flags true generates table:read + table:add + table:edit + table:delete', (t) => { +test('table with all flags true generates table:query + column:read + table:add + table:edit + table:delete', (t) => { const result = generateCedarPolicyForGroup( connectionId, false, @@ -129,15 +157,16 @@ test('table with all flags true generates table:read + table:add + table:edit + ], }), ); - t.true(result.includes('table:read')); + t.true(result.includes('table:query')); + t.true(result.includes('column:read')); t.true(result.includes('table:add')); t.true(result.includes('table:edit')); t.true(result.includes('table:delete')); const permits = result.match(/permit\(/g); - t.is(permits.length, 4); + t.is(permits.length, 5); }); -test('table with add=true only generates table:read + table:add (hasAnyAccess triggers table:read)', (t) => { +test('table with add=true only generates table:query + column:read + table:add (hasAnyAccess triggers read)', (t) => { const result = generateCedarPolicyForGroup( connectionId, false, @@ -150,12 +179,13 @@ test('table with add=true only generates table:read + table:add (hasAnyAccess tr ], }), ); - t.true(result.includes('table:read')); + t.true(result.includes('table:query')); + t.true(result.includes('column:read')); t.true(result.includes('table:add')); t.false(result.includes('table:edit')); t.false(result.includes('table:delete')); const permits = result.match(/permit\(/g); - t.is(permits.length, 2); + t.is(permits.length, 3); }); test('table with all flags false generates no policies for that table', (t) => { @@ -199,9 +229,9 @@ test('multiple tables generate separate policies per table with correct resource ); t.true(result.includes(`RocketAdmin::Table::"${connectionId}/users"`)); t.true(result.includes(`RocketAdmin::Table::"${connectionId}/orders"`)); - // users: table:read only; orders: table:read + table:add + // users: table:query + column:read (2); orders: table:query + column:read + table:add (3) const permits = result.match(/permit\(/g); - t.is(permits.length, 3); + t.is(permits.length, 5); }); test('dashboard with read=true generates only dashboard:read', (t) => { diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts index 3aa0ffff9..cc0681787 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-parser.test.ts @@ -1,4 +1,5 @@ import test from 'ava'; +import { generateCedarPolicyForGroup } from '../../../src/entities/cedar-authorization/cedar-policy-generator.js'; import { parseCedarPolicyToClassicalPermissions } from '../../../src/entities/cedar-authorization/cedar-policy-parser.js'; import { AccessLevelEnum } from '../../../src/enums/access-level.enum.js'; @@ -89,6 +90,71 @@ test('parses multiple tables separately', (t) => { t.is(ordersTable.accessLevel.add, true); }); +test('parses table:query + wildcard column:read into visibility with all columns readable', (t) => { + const policy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource in RocketAdmin::Table::"${connectionId}/users"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 1); + t.is(result.tables[0].tableName, 'users'); + t.is(result.tables[0].accessLevel.visibility, true); + t.is(result.tables[0].accessLevel.readonly, true); + // Wildcard column read ⇒ no explicit whitelist (all columns readable). + t.is(result.tables[0].readableColumns, undefined); +}); + +test('parses per-column column:read into readableColumns whitelist', (t) => { + const policy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == RocketAdmin::Table::"${connectionId}/users"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/users/id"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/users/email"\n);`, + ].join('\n\n'); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 1); + t.is(result.tables[0].accessLevel.visibility, true); + t.deepEqual(result.tables[0].readableColumns, ['id', 'email']); +}); + +test('generator → parser round-trip preserves readableColumns whitelist', (t) => { + const policy = generateCedarPolicyForGroup(connectionId, false, { + connection: { connectionId, accessLevel: AccessLevelEnum.none }, + group: { groupId, accessLevel: AccessLevelEnum.none }, + tables: [ + { + tableName: 'users', + accessLevel: { visibility: true, readonly: true, add: false, delete: false, edit: false }, + readableColumns: ['id', 'email'], + }, + ], + }); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 1); + t.is(result.tables[0].accessLevel.visibility, true); + t.deepEqual(result.tables[0].readableColumns, ['id', 'email']); +}); + +test('generator → parser round-trip of full read leaves readableColumns undefined', (t) => { + const policy = generateCedarPolicyForGroup(connectionId, false, { + connection: { connectionId, accessLevel: AccessLevelEnum.none }, + group: { groupId, accessLevel: AccessLevelEnum.none }, + tables: [ + { + tableName: 'users', + accessLevel: { visibility: true, readonly: true, add: false, delete: false, edit: false }, + }, + ], + }); + + const result = parseCedarPolicyToClassicalPermissions(policy, connectionId, groupId); + t.is(result.tables.length, 1); + t.is(result.tables[0].accessLevel.visibility, true); + t.is(result.tables[0].readableColumns, undefined); +}); + test('parses dashboard permissions', (t) => { const policy = [ `permit(\n principal,\n action == RocketAdmin::Action::"dashboard:read",\n resource == RocketAdmin::Dashboard::"${connectionId}/dash-1"\n);`, diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts index 9e8b9bfa1..cfc4c701a 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-save-policy-e2e.test.ts @@ -192,6 +192,283 @@ test.serial( }, ); +test.serial( + `${currentTest} should enforce column-level read - user only sees whitelisted columns in rows and structure`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const allowedColumn = testData.firstTableInfo.testTableColumnName; + const hiddenColumn = testData.firstTableInfo.testTableSecondColumnName; + + // QueryTable + per-column ColumnRead for `id` and one column only (not the second column). + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/${tableName}/id"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/${tableName}/${allowedColumn}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + const getTableRows = await request(app.getHttpServer()) + .get(`/table/rows/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableRows.status, 200); + const body = getTableRows.body; + + // Rows expose only the whitelisted columns. + t.true(body.rows.length > 0); + const rowKeys = Object.keys(body.rows[0]); + t.true(rowKeys.includes('id')); + t.true(rowKeys.includes(allowedColumn)); + t.false(rowKeys.includes(hiddenColumn)); + + // Structure metadata hides the non-readable column too. + const structureColumns = body.structure.map((column: { column_name: string }) => column.column_name); + t.true(structureColumns.includes('id')); + t.true(structureColumns.includes(allowedColumn)); + t.false(structureColumns.includes(hiddenColumn)); + + // The reported readable-columns whitelist excludes the hidden column. + t.true(body.table_permissions.readableColumns.includes(allowedColumn)); + t.false(body.table_permissions.readableColumns.includes(hiddenColumn)); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial( + `${currentTest} should enforce QueryTable - user without table:query is denied before the query`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + + // Connection read only — no table:query / table:read grant at all. + const cedarPolicy = `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`; + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + const getTableRows = await request(app.getHttpServer()) + .get(`/table/rows/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableRows.status, 403); + } catch (error) { + console.error(error); + throw error; + } + }, +); + +test.serial(`${currentTest} legacy table:read still grants full column read (alias)`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const firstColumn = testData.firstTableInfo.testTableColumnName; + const secondColumn = testData.firstTableInfo.testTableSecondColumnName; + + // Only a direct table:read grant (no table:query / column:read) — must behave as the + // alias QueryTable + ColumnRead(table, *). + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"table:read",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(savePolicyResponse.status, 201); + + const getTableRows = await request(app.getHttpServer()) + .get(`/table/rows/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableRows.status, 200); + const rowKeys = Object.keys(getTableRows.body.rows[0]); + t.true(rowKeys.includes(firstColumn)); + t.true(rowKeys.includes(secondColumn)); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial(`${currentTest} should enforce column-level read on single-row GET /table/row/:id`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const allowedColumn = testData.firstTableInfo.testTableColumnName; + const hiddenColumn = testData.firstTableInfo.testTableSecondColumnName; + + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/${tableName}/id"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/${tableName}/${allowedColumn}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(savePolicyResponse.status, 201); + + const getRow = await request(app.getHttpServer()) + .get(`/table/row/${connectionId}?tableName=${tableName}&id=1`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getRow.status, 200); + const rowKeys = Object.keys(getRow.body.row); + t.true(rowKeys.includes('id')); + t.true(rowKeys.includes(allowedColumn)); + t.false(rowKeys.includes(hiddenColumn)); + + const structureColumns = getRow.body.structure.map((column: { column_name: string }) => column.column_name); + t.true(structureColumns.includes(allowedColumn)); + t.false(structureColumns.includes(hiddenColumn)); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial(`${currentTest} should enforce column-level read on CSV export`, async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const allowedColumn = testData.firstTableInfo.testTableColumnName; + const hiddenColumn = testData.firstTableInfo.testTableSecondColumnName; + + const cedarPolicy = [ + `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"${connectionId}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == RocketAdmin::Table::"${connectionId}/${tableName}"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/${tableName}/id"\n);`, + `permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == RocketAdmin::Column::"${connectionId}/${tableName}/${allowedColumn}"\n);`, + ].join('\n\n'); + + const savePolicyResponse = await request(app.getHttpServer()) + .post(`/connection/cedar-policy/${connectionId}`) + .send({ cedarPolicy, groupId }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(savePolicyResponse.status, 201); + + const csvResponse = await request(app.getHttpServer()) + .post(`/table/csv/export/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'text/csv') + .set('Accept', 'text/csv'); + + t.is(csvResponse.status, 201); + // The exported file is returned as a binary body (Buffer), not parsed text. + const csvText = (csvResponse.body as Buffer)?.toString() ?? csvResponse.text; + // The CSV header is derived from row keys; the hidden column must not appear at all. + t.true(csvText.includes(allowedColumn)); + t.false(csvText.includes(hiddenColumn)); + } catch (error) { + console.error(error); + throw error; + } +}); + +test.serial( + `${currentTest} PUT /permissions with readableColumns persists, round-trips, and enforces column filtering`, + async (t) => { + try { + const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app); + const connectionId = testData.connections.firstId; + const groupId = testData.groups.createdGroupId; + const tableName = testData.firstTableInfo.testTableName; + const allowedColumn = testData.firstTableInfo.testTableColumnName; + const hiddenColumn = testData.firstTableInfo.testTableSecondColumnName; + + const permissions = { + connection: { connectionId, accessLevel: AccessLevelEnum.readonly }, + group: { groupId, accessLevel: AccessLevelEnum.none }, + tables: [ + { + tableName, + accessLevel: { visibility: true, readonly: true, add: false, delete: false, edit: false }, + readableColumns: ['id', allowedColumn], + }, + ], + }; + + const putResponse = await request(app.getHttpServer()) + .put(`/permissions/${groupId}?connectionId=${connectionId}`) + .send({ permissions }) + .set('Cookie', testData.users.adminUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(putResponse.status, 200); + const savedTable = putResponse.body.tables.find((tb: { tableName: string }) => tb.tableName === tableName); + t.truthy(savedTable); + t.true(savedTable.readableColumns.includes(allowedColumn)); + t.false(savedTable.readableColumns.includes(hiddenColumn)); + + // Enforcement: the invited group member only sees the whitelisted columns. + const getTableRows = await request(app.getHttpServer()) + .get(`/table/rows/${connectionId}?tableName=${tableName}`) + .set('Cookie', testData.users.simpleUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(getTableRows.status, 200); + const rowKeys = Object.keys(getTableRows.body.rows[0]); + t.true(rowKeys.includes(allowedColumn)); + t.false(rowKeys.includes(hiddenColumn)); + } catch (error) { + console.error(error); + throw error; + } + }, +); + test.serial(`${currentTest} should enforce saved cedar policy - user without table:add cannot add rows`, async (t) => { try { const testData = await createConnectionsAndInviteNewUserInNewGroupWithGroupPermissions(app);