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
6 changes: 6 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-action-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ export enum CedarAction {
DashboardCreate = 'dashboard:create',
DashboardEdit = 'dashboard:edit',
DashboardDelete = 'dashboard:delete',
PanelRead = 'panel:read',
PanelCreate = 'panel:create',
PanelEdit = 'panel:edit',
PanelDelete = 'panel:delete',
}

export enum CedarResourceType {
Connection = 'RocketAdmin::Connection',
Group = 'RocketAdmin::Group',
Table = 'RocketAdmin::Table',
Dashboard = 'RocketAdmin::Dashboard',
Panel = 'RocketAdmin::Panel',
}

export const CEDAR_ACTION_TYPE = 'RocketAdmin::Action';
Expand All @@ -31,4 +36,5 @@ export interface CedarValidationRequest {
groupId?: string;
tableName?: 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 } = request;
const { userId, action, groupId, tableName, dashboardId, panelId } = request;
let { connectionId } = request;

const actionPrefix = action.split(':')[0];
Expand All @@ -61,13 +61,20 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
const needsSentinel = action === CedarAction.DashboardCreate || !dashboardId;
const effectiveDashboardId = needsSentinel ? '__new__' : dashboardId;
resourceId = `${connectionId}/${effectiveDashboardId}`;
return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, effectiveDashboardId);
return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, effectiveDashboardId, undefined);
}
case 'panel': {
resourceType = CedarResourceType.Panel;
const needsSentinel = action === CedarAction.PanelCreate || !panelId;
const effectivePanelId = needsSentinel ? '__new__' : panelId;
resourceId = `${connectionId}/${effectivePanelId}`;
return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, undefined, effectivePanelId);
}
default:
return false;
}

return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, dashboardId);
return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, dashboardId, undefined);
}

invalidatePolicyCacheForConnection(connectionId: string): void {
Expand Down Expand Up @@ -169,6 +176,7 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
resourceId: string,
tableName?: string,
dashboardId?: string,
panelId?: string,
): Promise<boolean> {
await this.assertUserNotSuspended(userId);

Expand All @@ -178,7 +186,7 @@ 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);
const entities = buildCedarEntities(userId, userGroups, connectionId, tableName, dashboardId, panelId);

for (const policy of groupPolicies) {
const call = {
Expand Down Expand Up @@ -303,6 +311,19 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On
);
}
}

const panelResourceIds = [...cedarPolicy.matchAll(/resource\s*==\s*RocketAdmin::Panel::"([^"]+)"/g)].map(
(m) => m[1],
);

for (const panelRef of panelResourceIds) {
if (!panelRef.startsWith(`${connectionId}/`)) {
throw new HttpException(
{ message: Messages.CEDAR_POLICY_REFERENCES_FOREIGN_CONNECTION },
HttpStatus.BAD_REQUEST,
);
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function buildCedarEntities(
connectionId: string,
tableName?: string,
dashboardId?: string,
panelId?: string,
): Array<CedarEntityRecord> {
const entities: Array<CedarEntityRecord> = [];

Expand Down Expand Up @@ -58,5 +59,13 @@ export function buildCedarEntities(
});
}

if (panelId) {
entities.push({
uid: { type: 'RocketAdmin::Panel', id: `${connectionId}/${panelId}` },
attrs: { connectionId: connectionId },
parents: [{ type: 'RocketAdmin::Connection', id: connectionId }],
});
}

return entities;
}
40 changes: 40 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-policy-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,46 @@ export function generateCedarPolicyForGroup(
}
}

if (permissions.panels) {
let hasPanelCreatePermission = false;
let hasPanelReadPermission = false;
for (const panel of permissions.panels) {
const panelRef = `RocketAdmin::Panel::"${connectionId}/${panel.panelId}"`;
const access = panel.accessLevel;

if (access.read) {
hasPanelReadPermission = true;
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == ${panelRef}\n);`,
);
}
if (access.create) {
hasPanelCreatePermission = true;
}
if (access.edit) {
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"panel:edit",\n resource == ${panelRef}\n);`,
);
}
if (access.delete) {
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"panel:delete",\n resource == ${panelRef}\n);`,
);
}
}
const newPanelRef = `RocketAdmin::Panel::"${connectionId}/__new__"`;
if (hasPanelReadPermission) {
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"panel:read",\n resource == ${newPanelRef}\n);`,
);
Comment on lines +117 to +121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not grant panel:read on the __new__ sentinel.

cedar-authorization.service.ts falls back to __new__ when a panel read is evaluated without a panelId. Emitting a read permit for that sentinel means a missing panelId can succeed against __new__ instead of the real panel. __new__ only needs panel:create; keep panel:read scoped to concrete panel IDs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/entities/cedar-authorization/cedar-policy-generator.ts` around
lines 117 - 121, Remove the code that emits a permit for
RocketAdmin::Action::"panel:read" against the __new__ sentinel (newPanelRef); do
not grant panel:read for `RocketAdmin::Panel::"${connectionId}/__new__"`.
Instead, if you need to allow operations on the sentinel, emit only a permit for
RocketAdmin::Action::"panel:create" against newPanelRef (or rely on the existing
create-permission variable/logic), and keep panel:read permits scoped only to
concrete panel IDs (leave the hasPanelReadPermission handling for real panel
resources untouched).

}
if (hasPanelCreatePermission) {
policies.push(
`permit(\n principal,\n action == RocketAdmin::Action::"panel:create",\n resource == ${newPanelRef}\n);`,
);
}
}

for (const table of permissions.tables) {
const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`;
const access = table.accessLevel;
Expand Down
60 changes: 60 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-policy-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AccessLevelEnum } from '../../enums/index.js';
import {
IComplexPermission,
IDashboardPermissionData,
IPanelPermissionData,
ITablePermissionData,
} from '../permission/permission.interface.js';

Expand All @@ -24,10 +25,12 @@ export function parseCedarPolicyToClassicalPermissions(
group: { groupId, accessLevel: AccessLevelEnum.none },
tables: [],
dashboards: [],
panels: [],
};

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

for (const permit of permits) {
if (permit.isWildcard) {
Expand Down Expand Up @@ -75,6 +78,16 @@ export function parseCedarPolicyToClassicalPermissions(
applyDashboardAction(dashboardEntry, permit.action);
break;
}
case 'panel:read':
case 'panel:create':
case 'panel:edit':
case 'panel:delete': {
const panelId = extractPanelId(permit.resourceId, connectionId);
if (!panelId) break;
const panelEntry = getOrCreatePanelEntry(panelMap, panelId);
applyPanelAction(panelEntry, permit.action);
break;
}
}
}

Expand All @@ -84,6 +97,7 @@ export function parseCedarPolicyToClassicalPermissions(
a.readonly = a.visibility && !a.add && !a.edit && !a.delete;
}
result.dashboards = Array.from(dashboardMap.values());
result.panels = Array.from(panelMap.values());

return result;
}
Expand Down Expand Up @@ -268,3 +282,49 @@ function applyDashboardAction(entry: IDashboardPermissionData, action: string):
break;
}
}

function extractPanelId(resourceId: string | null, connectionId: string): string | null {
if (!resourceId) return null;
const prefix = `${connectionId}/`;
if (resourceId.startsWith(prefix)) {
return resourceId.slice(prefix.length);
}
return resourceId;
}

function getOrCreatePanelEntry(
map: Map<string, IPanelPermissionData>,
panelId: string,
): IPanelPermissionData {
let entry = map.get(panelId);
if (!entry) {
entry = {
panelId,
accessLevel: {
read: false,
create: false,
edit: false,
delete: false,
},
};
map.set(panelId, entry);
}
return entry;
}

function applyPanelAction(entry: IPanelPermissionData, action: string): void {
switch (action) {
case 'panel:read':
entry.accessLevel.read = true;
break;
case 'panel:create':
entry.accessLevel.create = true;
break;
case 'panel:edit':
entry.accessLevel.edit = true;
break;
case 'panel:delete':
entry.accessLevel.delete = true;
break;
}
}
33 changes: 33 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export const CEDAR_SCHEMA = {
},
},
},
Panel: {
memberOfTypes: ['Connection'],
shape: {
type: 'Record',
attributes: {
connectionId: { type: 'String' },
},
},
},
},
actions: {
'connection:read': {
Expand Down Expand Up @@ -119,6 +128,30 @@ export const CEDAR_SCHEMA = {
resourceTypes: ['Dashboard'],
},
},
'panel:read': {
appliesTo: {
principalTypes: ['User'],
resourceTypes: ['Panel'],
},
},
'panel:create': {
appliesTo: {
principalTypes: ['User'],
resourceTypes: ['Panel'],
},
},
'panel:edit': {
appliesTo: {
principalTypes: ['User'],
resourceTypes: ['Panel'],
},
},
'panel:delete': {
appliesTo: {
principalTypes: ['User'],
resourceTypes: ['Panel'],
},
},
},
},
};
13 changes: 13 additions & 0 deletions backend/src/entities/permission/permission.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface IComplexPermission {
group: IGroupPermissionData;
tables: Array<ITablePermissionData>;
dashboards?: Array<IDashboardPermissionData>;
panels?: Array<IPanelPermissionData>;
}

export interface IConnectionPermissionData {
Expand Down Expand Up @@ -45,3 +46,15 @@ export interface IDashboardPermissionData {
dashboardId: string;
accessLevel: IDashboardAccessLevel;
}

export interface IPanelAccessLevel {
read: boolean;
create: boolean;
edit: boolean;
delete: boolean;
}

export interface IPanelPermissionData {
panelId: string;
accessLevel: IPanelAccessLevel;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Timeout, TimeoutDefaults } from '../../../decorators/timeout.decorator.
import { UserId } from '../../../decorators/user-id.decorator.js';
import { InTransactionEnum } from '../../../enums/in-transaction.enum.js';
import { ConnectionEditGuard } from '../../../guards/connection-edit.guard.js';
import { DashboardEditGuard } from '../../../guards/dashboard-edit.guard.js';
import { SentryInterceptor } from '../../../interceptors/sentry.interceptor.js';
import { CreatePanelPositionDs } from './data-structures/create-panel-position.ds.js';
import { DeletePanelPositionDs } from './data-structures/delete-panel-position.ds.js';
Expand Down Expand Up @@ -67,7 +68,7 @@ export class DashboardWidgetController {
@ApiBody({ type: CreatePanelPositionDto })
@ApiParam({ name: 'dashboardId', required: true })
@ApiParam({ name: 'connectionId', required: true })
@UseGuards(ConnectionEditGuard)
@UseGuards(DashboardEditGuard)
@Post('/dashboard/:dashboardId/widget/:connectionId')
async createWidget(
@SlugUuid('connectionId') connectionId: string,
Expand Down Expand Up @@ -100,7 +101,7 @@ export class DashboardWidgetController {
@ApiParam({ name: 'dashboardId', required: true })
@ApiParam({ name: 'widgetId', required: true })
@ApiParam({ name: 'connectionId', required: true })
@UseGuards(ConnectionEditGuard)
@UseGuards(DashboardEditGuard)
@Put('/dashboard/:dashboardId/widget/:widgetId/:connectionId')
async updateWidget(
@SlugUuid('connectionId') connectionId: string,
Expand Down Expand Up @@ -134,7 +135,7 @@ export class DashboardWidgetController {
@ApiParam({ name: 'dashboardId', required: true })
@ApiParam({ name: 'widgetId', required: true })
@ApiParam({ name: 'connectionId', required: true })
@UseGuards(ConnectionEditGuard)
@UseGuards(DashboardEditGuard)
@Delete('/dashboard/:dashboardId/widget/:widgetId/:connectionId')
async deleteWidget(
@SlugUuid('connectionId') connectionId: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export class FindAllPanelsDs {
connectionId: string;
masterPassword: string;
userId: string;
}
Loading
Loading