Skip to content

Commit 10f541b

Browse files
authored
Merge pull request #1795 from rocket-admin/backend_custom_table_action_permission
added RocketAdmin::ActionEvent permissions
2 parents 47ffa41 + 2f89f22 commit 10f541b

54 files changed

Lines changed: 724 additions & 59 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export enum CedarAction {
99
TableEdit = 'table:edit',
1010
TableDelete = 'table:delete',
1111
TableAiRequest = 'table:ai-request',
12+
ActionEventTrigger = 'actionEvent:trigger',
1213
DashboardRead = 'dashboard:read',
1314
DashboardCreate = 'dashboard:create',
1415
DashboardEdit = 'dashboard:edit',
@@ -23,6 +24,7 @@ export enum CedarResourceType {
2324
Connection = 'RocketAdmin::Connection',
2425
Group = 'RocketAdmin::Group',
2526
Table = 'RocketAdmin::Table',
27+
ActionEvent = 'RocketAdmin::ActionEvent',
2628
Dashboard = 'RocketAdmin::Dashboard',
2729
Panel = 'RocketAdmin::Panel',
2830
}
@@ -31,12 +33,15 @@ export const CEDAR_ACTION_TYPE = 'RocketAdmin::Action';
3133
export const CEDAR_USER_TYPE = 'RocketAdmin::User';
3234
export const CEDAR_GROUP_TYPE = 'RocketAdmin::Group';
3335

36+
export const ACTION_EVENT_PROBE_ID = '__probe__';
37+
3438
export interface CedarValidationRequest {
3539
userId: string;
3640
action: CedarAction;
3741
connectionId?: string;
3842
groupId?: string;
3943
tableName?: string;
44+
actionEventId?: string;
4045
dashboardId?: string;
4146
panelId?: string;
4247
}

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
3434
}
3535

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

4040
const actionPrefix = action.split(':')[0];
@@ -56,6 +56,22 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
5656
resourceType = CedarResourceType.Table;
5757
resourceId = `${connectionId}/${tableName}`;
5858
break;
59+
case 'actionEvent': {
60+
if (!tableName || !actionEventId) return false;
61+
resourceType = CedarResourceType.ActionEvent;
62+
resourceId = `${connectionId}/${tableName}/${actionEventId}`;
63+
return this.evaluate(
64+
userId,
65+
connectionId,
66+
action,
67+
resourceType,
68+
resourceId,
69+
tableName,
70+
undefined,
71+
undefined,
72+
actionEventId,
73+
);
74+
}
5975
case 'dashboard': {
6076
resourceType = CedarResourceType.Dashboard;
6177
const needsSentinel = action === CedarAction.DashboardCreate || !dashboardId;
@@ -195,6 +211,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
195211
tableName?: string,
196212
dashboardId?: string,
197213
panelId?: string,
214+
actionEventId?: string,
198215
): Promise<boolean> {
199216
await this.assertUserNotSuspended(userId);
200217

@@ -204,7 +221,15 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
204221
const groupPolicies = this.loadPoliciesPerGroup(userGroups);
205222
if (groupPolicies.length === 0) return false;
206223

207-
const entities = buildCedarEntities(userId, userGroups, connectionId, tableName, dashboardId, panelId);
224+
const entities = buildCedarEntities(
225+
userId,
226+
userGroups,
227+
connectionId,
228+
tableName,
229+
dashboardId,
230+
panelId,
231+
actionEventId,
232+
);
208233

209234
for (const policy of groupPolicies) {
210235
const call = {

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function buildCedarEntities(
1313
tableName?: string,
1414
dashboardId?: string,
1515
panelId?: string,
16+
actionEventId?: string,
1617
): Array<CedarEntityRecord> {
1718
const entities: Array<CedarEntityRecord> = [];
1819

@@ -42,7 +43,7 @@ export function buildCedarEntities(
4243
parents: [],
4344
});
4445

45-
// Table entity (if table-level check)
46+
// Table entity (if table-level check, or as parent for an ActionEvent)
4647
if (tableName) {
4748
entities.push({
4849
uid: { type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` },
@@ -51,6 +52,16 @@ export function buildCedarEntities(
5152
});
5253
}
5354

55+
// ActionEvent entity, parented by its Table — required so `resource in Table::"..."`
56+
// policies authorize triggering specific events without naming each event.
57+
if (actionEventId && tableName) {
58+
entities.push({
59+
uid: { type: 'RocketAdmin::ActionEvent', id: `${connectionId}/${tableName}/${actionEventId}` },
60+
attrs: { connectionId: connectionId, tableName: tableName },
61+
parents: [{ type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` }],
62+
});
63+
}
64+
5465
if (dashboardId) {
5566
entities.push({
5667
uid: { type: 'RocketAdmin::Dashboard', id: `${connectionId}/${dashboardId}` },

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { Cacher } from '../../helpers/cache/cacher.js';
88
import { GroupEntity } from '../group/group.entity.js';
99
import { ITablePermissionData } from '../permission/permission.interface.js';
1010
import { IUserAccessRepository } from '../user-access/repository/user-access.repository.interface.js';
11-
import { CEDAR_ACTION_TYPE, CEDAR_USER_TYPE, CedarAction, CedarResourceType } from './cedar-action-map.js';
11+
import {
12+
ACTION_EVENT_PROBE_ID,
13+
CEDAR_ACTION_TYPE,
14+
CEDAR_USER_TYPE,
15+
CedarAction,
16+
CedarResourceType,
17+
} from './cedar-action-map.js';
1218
import { buildCedarEntities } from './cedar-entity-builder.js';
1319
import { CEDAR_SCHEMA } from './cedar-schema.js';
1420

@@ -222,6 +228,7 @@ export class CedarPermissionsService implements IUserAccessRepository {
222228
delete: false,
223229
edit: false,
224230
aiRequest: false,
231+
triggerCustomAction: false,
225232
},
226233
};
227234
}
@@ -327,6 +334,35 @@ export class CedarPermissionsService implements IUserAccessRepository {
327334
);
328335
}
329336

337+
async checkActionEventTrigger(
338+
cognitoUserName: string,
339+
connectionId: string,
340+
tableName: string,
341+
actionEventId: string,
342+
_masterPwd?: string,
343+
): Promise<boolean> {
344+
const ctx = await this.loadContext(connectionId, cognitoUserName);
345+
if (!ctx) return false;
346+
347+
const entities = buildCedarEntities(
348+
cognitoUserName,
349+
ctx.userGroups,
350+
connectionId,
351+
tableName,
352+
undefined,
353+
undefined,
354+
actionEventId,
355+
);
356+
return this.evaluatePolicies(
357+
cognitoUserName,
358+
CedarAction.ActionEventTrigger,
359+
CedarResourceType.ActionEvent,
360+
`${connectionId}/${tableName}/${actionEventId}`,
361+
ctx.policies,
362+
entities,
363+
);
364+
}
365+
330366
async getConnectionId(groupId: string): Promise<string> {
331367
const group = await this.globalDbContext.groupRepository.findGroupByIdWithConnectionAndUsers(groupId);
332368
if (!group?.connection?.id) {
@@ -404,6 +440,27 @@ export class CedarPermissionsService implements IUserAccessRepository {
404440
ctx.policies,
405441
entities,
406442
);
443+
// "Blanket trigger on this table" — only `permit(... resource in Table::"...")` policies
444+
// match this synthetic probe event. Per-event grants (resource == ActionEvent::"...x")
445+
// won't match the probe id, so the table-level flag stays false unless the user truly
446+
// has table-wide trigger.
447+
const probeEntities = buildCedarEntities(
448+
userId,
449+
ctx.userGroups,
450+
connectionId,
451+
tableName,
452+
undefined,
453+
undefined,
454+
ACTION_EVENT_PROBE_ID,
455+
);
456+
const canTriggerAnyCustomAction = this.evaluatePolicies(
457+
userId,
458+
CedarAction.ActionEventTrigger,
459+
CedarResourceType.ActionEvent,
460+
`${connectionId}/${tableName}/${ACTION_EVENT_PROBE_ID}`,
461+
ctx.policies,
462+
probeEntities,
463+
);
407464

408465
return {
409466
tableName,
@@ -414,6 +471,7 @@ export class CedarPermissionsService implements IUserAccessRepository {
414471
delete: canDelete,
415472
edit: canEdit,
416473
aiRequest: canAiRequest,
474+
triggerCustomAction: canTriggerAnyCustomAction,
417475
},
418476
};
419477
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export function generateCedarPolicyForGroup(
132132
const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`;
133133
const access = table.accessLevel;
134134

135+
// triggerCustomAction is intentionally excluded from hasAnyAccess: triggering custom
136+
// action events is a side-effect-only capability and does not imply table visibility.
135137
const hasAnyAccess = access.visibility || access.add || access.delete || access.edit;
136138
if (hasAnyAccess) {
137139
policies.push(
@@ -158,6 +160,22 @@ export function generateCedarPolicyForGroup(
158160
`permit(\n principal,\n action == RocketAdmin::Action::"table:ai-request",\n resource == ${tableRef}\n);`,
159161
);
160162
}
163+
if (access.triggerCustomAction) {
164+
// Blanket: any action event whose parent is this table is permitted.
165+
policies.push(
166+
`permit(\n principal,\n action == RocketAdmin::Action::"actionEvent:trigger",\n resource in ${tableRef}\n);`,
167+
);
168+
}
169+
}
170+
171+
if (permissions.actionEvents) {
172+
for (const event of permissions.actionEvents) {
173+
if (!event.accessLevel?.trigger) continue;
174+
const eventRef = `RocketAdmin::ActionEvent::"${connectionId}/${event.tableName}/${event.eventId}"`;
175+
policies.push(
176+
`permit(\n principal,\n action == RocketAdmin::Action::"actionEvent:trigger",\n resource == ${eventRef}\n);`,
177+
);
178+
}
161179
}
162180

163181
return policies.join('\n\n');

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AccessLevelEnum } from '../../enums/access-level.enum.js';
22
import {
3+
IActionEventPermissionData,
34
IComplexPermission,
45
IDashboardPermissionData,
56
IPanelPermissionData,
@@ -11,6 +12,7 @@ interface ParsedPermitStatement {
1112
resourceType: string | null;
1213
resourceId: string | null;
1314
isWildcard: boolean;
15+
isInRelation: boolean;
1416
}
1517

1618
export function parseCedarPolicyToClassicalPermissions(
@@ -24,11 +26,13 @@ export function parseCedarPolicyToClassicalPermissions(
2426
connection: { connectionId, accessLevel: AccessLevelEnum.none },
2527
group: { groupId, accessLevel: AccessLevelEnum.none },
2628
tables: [],
29+
actionEvents: [],
2730
dashboards: [],
2831
panels: [],
2932
};
3033

3134
const tableMap = new Map<string, ITablePermissionData>();
35+
const actionEventMap = new Map<string, IActionEventPermissionData>();
3236
const dashboardMap = new Map<string, IDashboardPermissionData>();
3337
const panelMap = new Map<string, IPanelPermissionData>();
3438

@@ -74,6 +78,28 @@ export function parseCedarPolicyToClassicalPermissions(
7478
applyTableAction(tableEntry, permit.action);
7579
break;
7680
}
81+
case 'actionEvent:trigger': {
82+
if (permit.resourceType === 'RocketAdmin::Table' && permit.isInRelation) {
83+
// Blanket: trigger any event on this table
84+
const tableName = extractTableName(permit.resourceId, connectionId);
85+
if (!tableName) break;
86+
const tableEntry = getOrCreateTableEntry(tableMap, tableName);
87+
tableEntry.accessLevel.triggerCustomAction = true;
88+
} else if (permit.resourceType === 'RocketAdmin::ActionEvent') {
89+
// Per-event grant
90+
const parts = extractActionEventResource(permit.resourceId, connectionId);
91+
if (!parts) break;
92+
const key = `${parts.tableName}/${parts.eventId}`;
93+
if (!actionEventMap.has(key)) {
94+
actionEventMap.set(key, {
95+
eventId: parts.eventId,
96+
tableName: parts.tableName,
97+
accessLevel: { trigger: true },
98+
});
99+
}
100+
}
101+
break;
102+
}
77103
case 'dashboard:read':
78104
case 'dashboard:create':
79105
case 'dashboard:edit':
@@ -102,6 +128,7 @@ export function parseCedarPolicyToClassicalPermissions(
102128
const a = table.accessLevel;
103129
a.readonly = a.visibility && !a.add && !a.edit && !a.delete;
104130
}
131+
result.actionEvents = Array.from(actionEventMap.values());
105132
result.dashboards = Array.from(dashboardMap.values());
106133
result.panels = Array.from(panelMap.values());
107134

@@ -173,6 +200,7 @@ function parsePermitBody(body: string): ParsedPermitStatement {
173200
resourceType: null,
174201
resourceId: null,
175202
isWildcard: false,
203+
isInRelation: false,
176204
};
177205

178206
const actionMatch = body.match(/action\s*==\s*RocketAdmin::Action::"([^"]+)"/);
@@ -185,10 +213,15 @@ function parsePermitBody(body: string): ParsedPermitStatement {
185213
}
186214
}
187215

188-
const resourceMatch = body.match(/resource\s*==\s*(RocketAdmin::\w+)::"([^"]+)"/);
189-
if (resourceMatch) {
190-
result.resourceType = resourceMatch[1];
191-
result.resourceId = resourceMatch[2];
216+
const resourceEqMatch = body.match(/resource\s*==\s*(RocketAdmin::\w+)::"([^"]+)"/);
217+
const resourceInMatch = body.match(/resource\s+in\s+(RocketAdmin::\w+)::"([^"]+)"/);
218+
if (resourceEqMatch) {
219+
result.resourceType = resourceEqMatch[1];
220+
result.resourceId = resourceEqMatch[2];
221+
} else if (resourceInMatch) {
222+
result.resourceType = resourceInMatch[1];
223+
result.resourceId = resourceInMatch[2];
224+
result.isInRelation = true;
192225
} else {
193226
const resourceClause = body.match(/,\s*(resource)\s*$/m);
194227
if (resourceClause && !result.action) {
@@ -229,6 +262,7 @@ function getOrCreateTableEntry(map: Map<string, ITablePermissionData>, tableName
229262
delete: false,
230263
edit: false,
231264
aiRequest: false,
265+
triggerCustomAction: false,
232266
},
233267
};
234268
map.set(tableName, entry);
@@ -256,6 +290,20 @@ function applyTableAction(entry: ITablePermissionData, action: string): void {
256290
}
257291
}
258292

293+
function extractActionEventResource(
294+
resourceId: string | null,
295+
connectionId: string,
296+
): { tableName: string; eventId: string } | null {
297+
if (!resourceId) return null;
298+
const prefix = `${connectionId}/`;
299+
const stripped = resourceId.startsWith(prefix) ? resourceId.slice(prefix.length) : resourceId;
300+
const slash = stripped.indexOf('/');
301+
if (slash <= 0 || slash === stripped.length - 1) return null;
302+
const tableName = stripped.slice(0, slash);
303+
const eventId = stripped.slice(slash + 1);
304+
return { tableName, eventId };
305+
}
306+
259307
function getOrCreateDashboardEntry(
260308
map: Map<string, IDashboardPermissionData>,
261309
dashboardId: string,

0 commit comments

Comments
 (0)