Skip to content

Commit 4433ae3

Browse files
authored
Merge pull request #1843 from rocket-admin/backend_column_level_permission
feat: implement column-level read permissions for table queries
2 parents 6b5d9ed + 73b8d24 commit 4433ae3

25 files changed

Lines changed: 832 additions & 52 deletions

backend/src/entities/cedar-authorization/cedar-action-map.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export enum CedarAction {
55
GroupRead = 'group:read',
66
GroupEdit = 'group:edit',
77
TableRead = 'table:read',
8+
TableQuery = 'table:query',
9+
ColumnRead = 'column:read',
810
TableAdd = 'table:add',
911
TableEdit = 'table:edit',
1012
TableDelete = 'table:delete',
@@ -24,6 +26,7 @@ export enum CedarResourceType {
2426
Connection = 'RocketAdmin::Connection',
2527
Group = 'RocketAdmin::Group',
2628
Table = 'RocketAdmin::Table',
29+
Column = 'RocketAdmin::Column',
2730
ActionEvent = 'RocketAdmin::ActionEvent',
2831
Dashboard = 'RocketAdmin::Dashboard',
2932
Panel = 'RocketAdmin::Panel',
@@ -35,12 +38,15 @@ export const CEDAR_GROUP_TYPE = 'RocketAdmin::Group';
3538

3639
export const ACTION_EVENT_PROBE_ID = '__probe__';
3740

41+
export const COLUMN_PROBE_ID = '__probe__';
42+
3843
export interface CedarValidationRequest {
3944
userId: string;
4045
action: CedarAction;
4146
connectionId?: string;
4247
groupId?: string;
4348
tableName?: string;
49+
columnName?: string;
4450
actionEventId?: string;
4551
dashboardId?: string;
4652
panelId?: string;

backend/src/entities/cedar-authorization/cedar-authorization.service.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
3535
}
3636

3737
async validate(request: CedarValidationRequest): Promise<boolean> {
38-
const { userId, action, groupId, tableName, dashboardId, panelId, actionEventId } = request;
38+
const { userId, action, groupId, tableName, columnName, dashboardId, panelId, actionEventId } = request;
3939
let { connectionId } = request;
4040

4141
const actionPrefix = action.split(':')[0];
@@ -59,7 +59,46 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
5959
if (!connectionId) return false;
6060
resourceType = CedarResourceType.Table;
6161
resourceId = `${connectionId}/${tableName}`;
62+
if (action === CedarAction.TableQuery) {
63+
// table:read is an alias for table:query + column:read(*). Honor legacy or
64+
// hand-written policies that grant table:read directly as a QueryTable grant.
65+
if (await this.evaluate(userId, connectionId, CedarAction.TableQuery, resourceType, resourceId, tableName)) {
66+
return true;
67+
}
68+
return this.evaluate(userId, connectionId, CedarAction.TableRead, resourceType, resourceId, tableName);
69+
}
6270
break;
71+
case 'column': {
72+
if (!connectionId) return false;
73+
if (!tableName || !columnName) return false;
74+
resourceType = CedarResourceType.Column;
75+
resourceId = `${connectionId}/${tableName}/${columnName}`;
76+
if (
77+
await this.evaluate(
78+
userId,
79+
connectionId,
80+
action,
81+
resourceType,
82+
resourceId,
83+
tableName,
84+
undefined,
85+
undefined,
86+
undefined,
87+
columnName,
88+
)
89+
) {
90+
return true;
91+
}
92+
// Legacy alias: a direct table:read grant covers every column of the table.
93+
return this.evaluate(
94+
userId,
95+
connectionId,
96+
CedarAction.TableRead,
97+
CedarResourceType.Table,
98+
`${connectionId}/${tableName}`,
99+
tableName,
100+
);
101+
}
63102
case 'actionEvent': {
64103
if (!connectionId) return false;
65104
if (!tableName || !actionEventId) return false;
@@ -220,6 +259,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
220259
dashboardId?: string,
221260
panelId?: string,
222261
actionEventId?: string,
262+
columnName?: string,
223263
): Promise<boolean> {
224264
await this.assertUserNotSuspended(userId);
225265

@@ -237,6 +277,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
237277
dashboardId,
238278
panelId,
239279
actionEventId,
280+
columnName,
240281
);
241282

242283
for (const policy of groupPolicies) {
@@ -350,6 +391,19 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
350391
}
351392
}
352393

394+
const columnResourceIds = [...cedarPolicy.matchAll(/resource\s*(?:==|in)\s*RocketAdmin::Column::"([^"]+)"/g)].map(
395+
(m) => m[1],
396+
);
397+
398+
for (const columnRef of columnResourceIds) {
399+
if (!columnRef.startsWith(`${connectionId}/`)) {
400+
throw new HttpException(
401+
{ message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION },
402+
HttpStatus.BAD_REQUEST,
403+
);
404+
}
405+
}
406+
353407
const dashboardResourceIds = [...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Dashboard::"([^"]+)"/g)].map(
354408
(m) => m[1],
355409
);

backend/src/entities/cedar-authorization/cedar-entity-builder.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function buildCedarEntities(
1414
dashboardId?: string,
1515
panelId?: string,
1616
actionEventId?: string,
17+
columnName?: string,
1718
): Array<CedarEntityRecord> {
1819
const entities: Array<CedarEntityRecord> = [];
1920

@@ -52,6 +53,16 @@ export function buildCedarEntities(
5253
});
5354
}
5455

56+
// Column entity, parented by its Table — required so `resource in Table::"..."`
57+
// policies authorize reading any column without naming each one (the table:read alias).
58+
if (columnName && tableName) {
59+
entities.push({
60+
uid: { type: 'RocketAdmin::Column', id: `${connectionId}/${tableName}/${columnName}` },
61+
attrs: { connectionId: connectionId, tableName: tableName },
62+
parents: [{ type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` }],
63+
});
64+
}
65+
5566
// ActionEvent entity, parented by its Table — required so `resource in Table::"..."`
5667
// policies authorize triggering specific events without naming each event.
5768
if (actionEventId && tableName) {

backend/src/entities/cedar-authorization/cedar-permissions.service.ts

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
CEDAR_USER_TYPE,
1515
CedarAction,
1616
CedarResourceType,
17+
COLUMN_PROBE_ID,
1718
} from './cedar-action-map.js';
1819
import { buildCedarEntities } from './cedar-entity-builder.js';
1920
import { CEDAR_SCHEMA } from './cedar-schema.js';
@@ -256,6 +257,8 @@ export class CedarPermissionsService implements IUserAccessRepository {
256257
return result;
257258
}
258259

260+
// "Table read" now means "may query this table" (the QueryTable half of the table:read
261+
// alias). Column-level visibility is enforced separately via checkColumnRead/getReadableColumns.
259262
async checkTableRead(
260263
cognitoUserName: string,
261264
connectionId: string,
@@ -265,15 +268,44 @@ export class CedarPermissionsService implements IUserAccessRepository {
265268
const ctx = await this.loadContext(connectionId, cognitoUserName);
266269
if (!ctx) return false;
267270

268-
const entities = buildCedarEntities(cognitoUserName, ctx.userGroups, connectionId, tableName);
269-
return this.evaluatePolicies(
270-
cognitoUserName,
271-
CedarAction.TableRead,
272-
CedarResourceType.Table,
273-
`${connectionId}/${tableName}`,
274-
ctx.policies,
275-
entities,
276-
);
271+
return this.evaluateTableQuery(cognitoUserName, connectionId, tableName, ctx);
272+
}
273+
274+
async checkColumnRead(
275+
cognitoUserName: string,
276+
connectionId: string,
277+
tableName: string,
278+
columnName: string,
279+
): Promise<boolean> {
280+
const ctx = await this.loadContext(connectionId, cognitoUserName);
281+
if (!ctx) return false;
282+
283+
return this.evaluateColumnRead(cognitoUserName, connectionId, tableName, columnName, ctx);
284+
}
285+
286+
// Returns the subset of `allColumnNames` the user may read. A single probe detects a
287+
// table-wide grant (the table:read alias → ColumnRead(table, *)); only column-restricted
288+
// tables pay a per-column evaluation.
289+
async getReadableColumns(
290+
cognitoUserName: string,
291+
connectionId: string,
292+
tableName: string,
293+
allColumnNames: Array<string>,
294+
): Promise<Set<string>> {
295+
const ctx = await this.loadContext(connectionId, cognitoUserName);
296+
if (!ctx) return new Set();
297+
298+
if (this.evaluateColumnRead(cognitoUserName, connectionId, tableName, COLUMN_PROBE_ID, ctx)) {
299+
return new Set(allColumnNames);
300+
}
301+
302+
const readable = new Set<string>();
303+
for (const columnName of allColumnNames) {
304+
if (this.evaluateColumnRead(cognitoUserName, connectionId, tableName, columnName, ctx)) {
305+
readable.add(columnName);
306+
}
307+
}
308+
return readable;
277309
}
278310

279311
async checkTableAdd(
@@ -402,14 +434,7 @@ export class CedarPermissionsService implements IUserAccessRepository {
402434
const entities = buildCedarEntities(userId, ctx.userGroups, connectionId, tableName);
403435
const resourceId = `${connectionId}/${tableName}`;
404436

405-
const canRead = this.evaluatePolicies(
406-
userId,
407-
CedarAction.TableRead,
408-
CedarResourceType.Table,
409-
resourceId,
410-
ctx.policies,
411-
entities,
412-
);
437+
const canRead = this.evaluateTableQuery(userId, connectionId, tableName, ctx);
413438
const canAdd = this.evaluatePolicies(
414439
userId,
415440
CedarAction.TableAdd,
@@ -478,6 +503,65 @@ export class CedarPermissionsService implements IUserAccessRepository {
478503
};
479504
}
480505

506+
// QueryTable check honoring the table:read alias: a direct table:read grant (legacy or
507+
// hand-written policy) also permits querying the table.
508+
private evaluateTableQuery(userId: string, connectionId: string, tableName: string, ctx: EvalContext): boolean {
509+
const entities = buildCedarEntities(userId, ctx.userGroups, connectionId, tableName);
510+
const resourceId = `${connectionId}/${tableName}`;
511+
return (
512+
this.evaluatePolicies(
513+
userId,
514+
CedarAction.TableQuery,
515+
CedarResourceType.Table,
516+
resourceId,
517+
ctx.policies,
518+
entities,
519+
) ||
520+
this.evaluatePolicies(userId, CedarAction.TableRead, CedarResourceType.Table, resourceId, ctx.policies, entities)
521+
);
522+
}
523+
524+
private evaluateColumnRead(
525+
userId: string,
526+
connectionId: string,
527+
tableName: string,
528+
columnName: string,
529+
ctx: EvalContext,
530+
): boolean {
531+
const columnEntities = buildCedarEntities(
532+
userId,
533+
ctx.userGroups,
534+
connectionId,
535+
tableName,
536+
undefined,
537+
undefined,
538+
undefined,
539+
columnName,
540+
);
541+
if (
542+
this.evaluatePolicies(
543+
userId,
544+
CedarAction.ColumnRead,
545+
CedarResourceType.Column,
546+
`${connectionId}/${tableName}/${columnName}`,
547+
ctx.policies,
548+
columnEntities,
549+
)
550+
) {
551+
return true;
552+
}
553+
// Legacy alias: a direct table:read grant covers every column of the table.
554+
const tableEntities = buildCedarEntities(userId, ctx.userGroups, connectionId, tableName);
555+
return this.evaluatePolicies(
556+
userId,
557+
CedarAction.TableRead,
558+
CedarResourceType.Table,
559+
`${connectionId}/${tableName}`,
560+
ctx.policies,
561+
tableEntities,
562+
);
563+
}
564+
481565
private evaluatePolicies(
482566
userId: string,
483567
action: CedarAction,

backend/src/entities/cedar-authorization/cedar-policy-generator.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,26 @@ export function generateCedarPolicyForGroup(
136136
// action events is a side-effect-only capability and does not imply table visibility.
137137
const hasAnyAccess = access.visibility || access.add || access.delete || access.edit;
138138
if (hasAnyAccess) {
139+
// QueryTable: may run a read query against the table at all (checked before the query).
139140
policies.push(
140-
`permit(\n principal,\n action == RocketAdmin::Action::"table:read",\n resource == ${tableRef}\n);`,
141+
`permit(\n principal,\n action == RocketAdmin::Action::"table:query",\n resource == ${tableRef}\n);`,
141142
);
143+
// ColumnRead (checked after the query). `table:read` is an alias for
144+
// QueryTable + ColumnRead(table, *): when no explicit column whitelist is given we
145+
// grant every column via `resource in Table`; otherwise one grant per allowed column.
146+
const readableColumns = table.readableColumns;
147+
if (readableColumns && readableColumns.length > 0) {
148+
for (const columnName of readableColumns) {
149+
const columnRef = `RocketAdmin::Column::"${connectionId}/${table.tableName}/${columnName}"`;
150+
policies.push(
151+
`permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource == ${columnRef}\n);`,
152+
);
153+
}
154+
} else {
155+
policies.push(
156+
`permit(\n principal,\n action == RocketAdmin::Action::"column:read",\n resource in ${tableRef}\n);`,
157+
);
158+
}
142159
}
143160
if (access.add) {
144161
policies.push(

backend/src/entities/cedar-authorization/cedar-policy-parser.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function parseCedarPolicyToClassicalPermissions(
6868
result.group.accessLevel = AccessLevelEnum.edit;
6969
break;
7070
case 'table:read':
71+
case 'table:query':
7172
case 'table:add':
7273
case 'table:edit':
7374
case 'table:delete':
@@ -78,6 +79,25 @@ export function parseCedarPolicyToClassicalPermissions(
7879
applyTableAction(tableEntry, permit.action);
7980
break;
8081
}
82+
case 'column:read': {
83+
if (permit.resourceType === 'RocketAdmin::Table' && permit.isInRelation) {
84+
// Wildcard: read every column on this table (the table:read alias). No explicit
85+
// column whitelist — leave readableColumns undefined ⇒ "all columns".
86+
const tableName = extractTableName(permit.resourceId, connectionId);
87+
if (!tableName) break;
88+
getOrCreateTableEntry(tableMap, tableName);
89+
} else if (permit.resourceType === 'RocketAdmin::Column') {
90+
// Per-column grant: add this column to the table's readable whitelist.
91+
const parts = extractColumnResource(permit.resourceId, connectionId);
92+
if (!parts) break;
93+
const tableEntry = getOrCreateTableEntry(tableMap, parts.tableName);
94+
if (!tableEntry.readableColumns) tableEntry.readableColumns = [];
95+
if (!tableEntry.readableColumns.includes(parts.columnName)) {
96+
tableEntry.readableColumns.push(parts.columnName);
97+
}
98+
}
99+
break;
100+
}
81101
case 'actionEvent:trigger': {
82102
if (permit.resourceType === 'RocketAdmin::Table' && permit.isInRelation) {
83103
// Blanket: trigger any event on this table
@@ -273,6 +293,7 @@ function getOrCreateTableEntry(map: Map<string, ITablePermissionData>, tableName
273293
function applyTableAction(entry: ITablePermissionData, action: string): void {
274294
switch (action) {
275295
case 'table:read':
296+
case 'table:query':
276297
entry.accessLevel.visibility = true;
277298
break;
278299
case 'table:add':
@@ -290,6 +311,20 @@ function applyTableAction(entry: ITablePermissionData, action: string): void {
290311
}
291312
}
292313

314+
function extractColumnResource(
315+
resourceId: string | null,
316+
connectionId: string,
317+
): { tableName: string; columnName: string } | null {
318+
if (!resourceId) return null;
319+
const prefix = `${connectionId}/`;
320+
const stripped = resourceId.startsWith(prefix) ? resourceId.slice(prefix.length) : resourceId;
321+
const slash = stripped.indexOf('/');
322+
if (slash <= 0 || slash === stripped.length - 1) return null;
323+
const tableName = stripped.slice(0, slash);
324+
const columnName = stripped.slice(slash + 1);
325+
return { tableName, columnName };
326+
}
327+
293328
function extractActionEventResource(
294329
resourceId: string | null,
295330
connectionId: string,

0 commit comments

Comments
 (0)