Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-action-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum CedarAction {
TableEdit = 'table:edit',
TableDelete = 'table:delete',
TableAiRequest = 'table:ai-request',
ActionEventTrigger = 'actionEvent:trigger',
DashboardRead = 'dashboard:read',
DashboardCreate = 'dashboard:create',
DashboardEdit = 'dashboard:edit',
Expand All @@ -23,6 +24,7 @@ export enum CedarResourceType {
Connection = 'RocketAdmin::Connection',
Group = 'RocketAdmin::Group',
Table = 'RocketAdmin::Table',
ActionEvent = 'RocketAdmin::ActionEvent',
Dashboard = 'RocketAdmin::Dashboard',
Panel = 'RocketAdmin::Panel',
}
Expand All @@ -31,12 +33,15 @@ export const CEDAR_ACTION_TYPE = 'RocketAdmin::Action';
export const CEDAR_USER_TYPE = 'RocketAdmin::User';
export const CEDAR_GROUP_TYPE = 'RocketAdmin::Group';

export const ACTION_EVENT_PROBE_ID = '__probe__';

export interface CedarValidationRequest {
userId: string;
action: CedarAction;
connectionId?: string;
groupId?: string;
tableName?: string;
actionEventId?: string;
dashboardId?: string;
panelId?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
}

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

const actionPrefix = action.split(':')[0];
Expand All @@ -56,6 +56,22 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
resourceType = CedarResourceType.Table;
resourceId = `${connectionId}/${tableName}`;
break;
case 'actionEvent': {
if (!tableName || !actionEventId) return false;
resourceType = CedarResourceType.ActionEvent;
resourceId = `${connectionId}/${tableName}/${actionEventId}`;
return this.evaluate(
userId,
connectionId,
action,
resourceType,
resourceId,
tableName,
undefined,
undefined,
actionEventId,
);
}
case 'dashboard': {
resourceType = CedarResourceType.Dashboard;
const needsSentinel = action === CedarAction.DashboardCreate || !dashboardId;
Expand Down Expand Up @@ -195,6 +211,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
tableName?: string,
dashboardId?: string,
panelId?: string,
actionEventId?: string,
): Promise<boolean> {
await this.assertUserNotSuspended(userId);

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

const entities = buildCedarEntities(userId, userGroups, connectionId, tableName, dashboardId, panelId);
const entities = buildCedarEntities(
userId,
userGroups,
connectionId,
tableName,
dashboardId,
panelId,
actionEventId,
);

for (const policy of groupPolicies) {
const call = {
Expand Down
13 changes: 12 additions & 1 deletion backend/src/entities/cedar-authorization/cedar-entity-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function buildCedarEntities(
tableName?: string,
dashboardId?: string,
panelId?: string,
actionEventId?: string,
): Array<CedarEntityRecord> {
const entities: Array<CedarEntityRecord> = [];

Expand Down Expand Up @@ -42,7 +43,7 @@ export function buildCedarEntities(
parents: [],
});

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

// ActionEvent entity, parented by its Table — required so `resource in Table::"..."`
// policies authorize triggering specific events without naming each event.
if (actionEventId && tableName) {
entities.push({
uid: { type: 'RocketAdmin::ActionEvent', id: `${connectionId}/${tableName}/${actionEventId}` },
attrs: { connectionId: connectionId, tableName: tableName },
parents: [{ type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` }],
});
}

if (dashboardId) {
entities.push({
uid: { type: 'RocketAdmin::Dashboard', id: `${connectionId}/${dashboardId}` },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { Cacher } from '../../helpers/cache/cacher.js';
import { GroupEntity } from '../group/group.entity.js';
import { ITablePermissionData } from '../permission/permission.interface.js';
import { IUserAccessRepository } from '../user-access/repository/user-access.repository.interface.js';
import { CEDAR_ACTION_TYPE, CEDAR_USER_TYPE, CedarAction, CedarResourceType } from './cedar-action-map.js';
import {
ACTION_EVENT_PROBE_ID,
CEDAR_ACTION_TYPE,
CEDAR_USER_TYPE,
CedarAction,
CedarResourceType,
} from './cedar-action-map.js';
import { buildCedarEntities } from './cedar-entity-builder.js';
import { CEDAR_SCHEMA } from './cedar-schema.js';

Expand Down Expand Up @@ -222,6 +228,7 @@ export class CedarPermissionsService implements IUserAccessRepository {
delete: false,
edit: false,
aiRequest: false,
triggerCustomAction: false,
},
};
}
Expand Down Expand Up @@ -327,6 +334,35 @@ export class CedarPermissionsService implements IUserAccessRepository {
);
}

async checkActionEventTrigger(
cognitoUserName: string,
connectionId: string,
tableName: string,
actionEventId: string,
_masterPwd?: string,
): Promise<boolean> {
const ctx = await this.loadContext(connectionId, cognitoUserName);
if (!ctx) return false;

const entities = buildCedarEntities(
cognitoUserName,
ctx.userGroups,
connectionId,
tableName,
undefined,
undefined,
actionEventId,
);
return this.evaluatePolicies(
cognitoUserName,
CedarAction.ActionEventTrigger,
CedarResourceType.ActionEvent,
`${connectionId}/${tableName}/${actionEventId}`,
ctx.policies,
entities,
);
}

async getConnectionId(groupId: string): Promise<string> {
const group = await this.globalDbContext.groupRepository.findGroupByIdWithConnectionAndUsers(groupId);
if (!group?.connection?.id) {
Expand Down Expand Up @@ -404,6 +440,27 @@ export class CedarPermissionsService implements IUserAccessRepository {
ctx.policies,
entities,
);
// "Blanket trigger on this table" — only `permit(... resource in Table::"...")` policies
// match this synthetic probe event. Per-event grants (resource == ActionEvent::"...x")
// won't match the probe id, so the table-level flag stays false unless the user truly
// has table-wide trigger.
const probeEntities = buildCedarEntities(
userId,
ctx.userGroups,
connectionId,
tableName,
undefined,
undefined,
ACTION_EVENT_PROBE_ID,
);
const canTriggerAnyCustomAction = this.evaluatePolicies(
userId,
CedarAction.ActionEventTrigger,
CedarResourceType.ActionEvent,
`${connectionId}/${tableName}/${ACTION_EVENT_PROBE_ID}`,
ctx.policies,
probeEntities,
);

return {
tableName,
Expand All @@ -414,6 +471,7 @@ export class CedarPermissionsService implements IUserAccessRepository {
delete: canDelete,
edit: canEdit,
aiRequest: canAiRequest,
triggerCustomAction: canTriggerAnyCustomAction,
},
};
}
Expand Down
18 changes: 18 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-policy-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export function generateCedarPolicyForGroup(
const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`;
const access = table.accessLevel;

// triggerCustomAction is intentionally excluded from hasAnyAccess: triggering custom
// 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) {
policies.push(
Expand All @@ -158,6 +160,22 @@ export function generateCedarPolicyForGroup(
`permit(\n principal,\n action == RocketAdmin::Action::"table:ai-request",\n resource == ${tableRef}\n);`,
);
}
if (access.triggerCustomAction) {
// Blanket: any action event whose parent is this table is permitted.
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"actionEvent:trigger",\n resource in ${tableRef}\n);`,
);
}
}

if (permissions.actionEvents) {
for (const event of permissions.actionEvents) {
if (!event.accessLevel?.trigger) continue;
const eventRef = `RocketAdmin::ActionEvent::"${connectionId}/${event.tableName}/${event.eventId}"`;
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"actionEvent:trigger",\n resource == ${eventRef}\n);`,
);
}
}

return policies.join('\n\n');
Expand Down
56 changes: 52 additions & 4 deletions backend/src/entities/cedar-authorization/cedar-policy-parser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AccessLevelEnum } from '../../enums/access-level.enum.js';
import {
IActionEventPermissionData,
IComplexPermission,
IDashboardPermissionData,
IPanelPermissionData,
Expand All @@ -11,6 +12,7 @@ interface ParsedPermitStatement {
resourceType: string | null;
resourceId: string | null;
isWildcard: boolean;
isInRelation: boolean;
}

export function parseCedarPolicyToClassicalPermissions(
Expand All @@ -24,11 +26,13 @@ export function parseCedarPolicyToClassicalPermissions(
connection: { connectionId, accessLevel: AccessLevelEnum.none },
group: { groupId, accessLevel: AccessLevelEnum.none },
tables: [],
actionEvents: [],
dashboards: [],
panels: [],
};

const tableMap = new Map<string, ITablePermissionData>();
const actionEventMap = new Map<string, IActionEventPermissionData>();
const dashboardMap = new Map<string, IDashboardPermissionData>();
const panelMap = new Map<string, IPanelPermissionData>();

Expand Down Expand Up @@ -74,6 +78,28 @@ export function parseCedarPolicyToClassicalPermissions(
applyTableAction(tableEntry, permit.action);
break;
}
case 'actionEvent:trigger': {
if (permit.resourceType === 'RocketAdmin::Table' && permit.isInRelation) {
// Blanket: trigger any event on this table
const tableName = extractTableName(permit.resourceId, connectionId);
if (!tableName) break;
const tableEntry = getOrCreateTableEntry(tableMap, tableName);
tableEntry.accessLevel.triggerCustomAction = true;
} else if (permit.resourceType === 'RocketAdmin::ActionEvent') {
// Per-event grant
const parts = extractActionEventResource(permit.resourceId, connectionId);
if (!parts) break;
const key = `${parts.tableName}/${parts.eventId}`;
if (!actionEventMap.has(key)) {
actionEventMap.set(key, {
eventId: parts.eventId,
tableName: parts.tableName,
accessLevel: { trigger: true },
});
}
}
break;
}
case 'dashboard:read':
case 'dashboard:create':
case 'dashboard:edit':
Expand Down Expand Up @@ -102,6 +128,7 @@ export function parseCedarPolicyToClassicalPermissions(
const a = table.accessLevel;
a.readonly = a.visibility && !a.add && !a.edit && !a.delete;
}
result.actionEvents = Array.from(actionEventMap.values());
result.dashboards = Array.from(dashboardMap.values());
result.panels = Array.from(panelMap.values());

Expand Down Expand Up @@ -173,6 +200,7 @@ function parsePermitBody(body: string): ParsedPermitStatement {
resourceType: null,
resourceId: null,
isWildcard: false,
isInRelation: false,
};

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

const resourceMatch = body.match(/resource\s*==\s*(RocketAdmin::\w+)::"([^"]+)"/);
if (resourceMatch) {
result.resourceType = resourceMatch[1];
result.resourceId = resourceMatch[2];
const resourceEqMatch = body.match(/resource\s*==\s*(RocketAdmin::\w+)::"([^"]+)"/);
const resourceInMatch = body.match(/resource\s+in\s+(RocketAdmin::\w+)::"([^"]+)"/);
if (resourceEqMatch) {
result.resourceType = resourceEqMatch[1];
result.resourceId = resourceEqMatch[2];
} else if (resourceInMatch) {
result.resourceType = resourceInMatch[1];
result.resourceId = resourceInMatch[2];
result.isInRelation = true;
} else {
const resourceClause = body.match(/,\s*(resource)\s*$/m);
if (resourceClause && !result.action) {
Expand Down Expand Up @@ -229,6 +262,7 @@ function getOrCreateTableEntry(map: Map<string, ITablePermissionData>, tableName
delete: false,
edit: false,
aiRequest: false,
triggerCustomAction: false,
},
};
map.set(tableName, entry);
Expand Down Expand Up @@ -256,6 +290,20 @@ function applyTableAction(entry: ITablePermissionData, action: string): void {
}
}

function extractActionEventResource(
resourceId: string | null,
connectionId: string,
): { tableName: string; eventId: 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 eventId = stripped.slice(slash + 1);
return { tableName, eventId };
}

function getOrCreateDashboardEntry(
map: Map<string, IDashboardPermissionData>,
dashboardId: string,
Expand Down
Loading
Loading