Skip to content
Open
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
4 changes: 3 additions & 1 deletion backend/.development.env
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,6 @@ MYSQL_CONNECTION_SSH_PORT=22
MYSQL_CONNECTION_SSH_USERNAME=TEST_MYSQL_CONNECTION_SSH_USERNAME
MYSQL_CONNECTION_SSH_KEY=TEST_MYSQL_CONNECTION_SSH_KEY

TEST_CONNECTIONS=
TEST_CONNECTIONS=

CEDAR_AUTHORIZATION_ENABLED='true'
9 changes: 5 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@
},
"dependencies": {
"@amplitude/node": "1.10.2",
"@aws-sdk/client-bedrock-runtime": "^3.999.0",
"@aws-sdk/client-s3": "^3.999.0",
"@aws-sdk/lib-dynamodb": "^3.999.0",
"@aws-sdk/s3-request-presigner": "^3.999.0",
"@aws-sdk/client-bedrock-runtime": "^3.990.0",
"@aws-sdk/client-s3": "^3.990.0",
"@aws-sdk/lib-dynamodb": "^3.990.0",
"@aws-sdk/s3-request-presigner": "^3.990.0",
"@cedar-policy/cedar-wasm": "^4.9.0",
"@electric-sql/pglite": "^0.3.15",
"@faker-js/faker": "^10.3.0",
"@langchain/aws": "^1.3.0",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { DashboardModule } from './entities/visualizations/dashboard/dashboards.
import { PanelPositionModule } from './entities/visualizations/panel-position/panel-position.module.js';
import { PanelModule } from './entities/visualizations/panel/panel.module.js';
import { TableWidgetModule } from './entities/widget/table-widget.module.js';
import { CedarAuthorizationModule } from './entities/cedar-authorization/cedar-authorization.module.js';
import { SaaSGatewayModule } from './microservices/gateways/saas-gateway.ts/saas-gateway.module.js';
import { SaasModule } from './microservices/saas-microservice/saas.module.js';
import { AppLoggerMiddleware } from './middlewares/logging-middleware/app-logger-middlewate.js';
Expand All @@ -59,6 +60,7 @@ import { GetHelloUseCase } from './use-cases-app/get-hello.use.case.js';
},
],
}),
CedarAuthorizationModule,
AICoreModule,
ConnectionModule,
ConnectionPropertiesModule,
Expand Down
28 changes: 28 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-action-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export enum CedarAction {
ConnectionRead = 'connection:read',
ConnectionEdit = 'connection:edit',
GroupRead = 'group:read',
GroupEdit = 'group:edit',
TableRead = 'table:read',
TableAdd = 'table:add',
TableEdit = 'table:edit',
TableDelete = 'table:delete',
}

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

export const CEDAR_ACTION_TYPE = 'RocketAdmin::Action';
export const CEDAR_USER_TYPE = 'RocketAdmin::User';
export const CEDAR_GROUP_TYPE = 'RocketAdmin::Group';

export interface CedarValidationRequest {
userId: string;
action: CedarAction;
connectionId?: string;
groupId?: string;
tableName?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { CedarAuthorizationService } from './cedar-authorization.service.js';

@Global()
@Module({
providers: [CedarAuthorizationService],
exports: [CedarAuthorizationService],
})
export class CedarAuthorizationModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { CedarValidationRequest } from './cedar-action-map.js';

export interface ICedarAuthorizationService {
isFeatureEnabled(): boolean;
validate(request: CedarValidationRequest): Promise<boolean>;
invalidatePolicyCacheForConnection(connectionId: string): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { HttpException, HttpStatus, Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BaseType } from '../../common/data-injection.tokens.js';
import { Messages } from '../../exceptions/text/messages.js';
import { Cacher } from '../../helpers/cache/cacher.js';
import { GroupEntity } from '../group/group.entity.js';
import { groupCustomRepositoryExtension } from '../group/repository/group-custom-repository-extension.js';
import { IGroupRepository } from '../group/repository/group.repository.interface.js';
import { UserEntity } from '../user/user.entity.js';
import {
CedarAction,
CedarResourceType,
CedarValidationRequest,
CEDAR_ACTION_TYPE,
CEDAR_USER_TYPE,
} from './cedar-action-map.js';
import { ICedarAuthorizationService } from './cedar-authorization.service.interface.js';
import { buildCedarEntities } from './cedar-entity-builder.js';
import { CEDAR_SCHEMA } from './cedar-schema.js';

@Injectable()
export class CedarAuthorizationService implements ICedarAuthorizationService, OnModuleInit {
private cedarModule: typeof import('@cedar-policy/cedar-wasm/nodejs');
private schema: Record<string, unknown>;
private groupRepository: IGroupRepository;
private readonly logger = new Logger(CedarAuthorizationService.name);

constructor(
@Inject(BaseType.DATA_SOURCE)
private readonly dataSource: DataSource,
) {}

async onModuleInit(): Promise<void> {
if (!this.isFeatureEnabled()) return;
this.cedarModule = await import('@cedar-policy/cedar-wasm/nodejs');
this.schema = CEDAR_SCHEMA as Record<string, unknown>;
this.groupRepository = this.dataSource.getRepository(GroupEntity).extend(groupCustomRepositoryExtension);
this.logger.log('Cedar authorization service initialized');
}

isFeatureEnabled(): boolean {
return process.env.CEDAR_AUTHORIZATION_ENABLED === 'true';
}

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

const actionPrefix = action.split(':')[0];
let resourceType: CedarResourceType;
let resourceId: string;

switch (actionPrefix) {
case 'connection':
resourceType = CedarResourceType.Connection;
resourceId = connectionId;
break;
case 'group':
resourceType = CedarResourceType.Group;
connectionId = await this.getConnectionIdForGroup(groupId);
if (!connectionId) return false;
resourceId = groupId;
break;
case 'table':
resourceType = CedarResourceType.Table;
resourceId = `${connectionId}/${tableName}`;
break;
default:
return false;
}

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

invalidatePolicyCacheForConnection(connectionId: string): void {
Cacher.invalidateCedarPolicyCache(connectionId);
}

private async evaluate(
userId: string,
connectionId: string,
action: CedarAction,
resourceType: CedarResourceType,
resourceId: string,
tableName?: string,
): Promise<boolean> {
await this.assertUserNotSuspended(userId);

const userGroups = await this.groupRepository.findAllUserGroupsInConnection(connectionId, userId);
if (userGroups.length === 0) return false;

const policies = await this.loadPoliciesForConnection(connectionId);
if (!policies) return false;

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

const call = {
principal: { type: CEDAR_USER_TYPE, id: userId },
action: { type: CEDAR_ACTION_TYPE, id: action },
resource: { type: resourceType as string, id: resourceId },
context: {},
policies: { staticPolicies: policies },
entities: entities,
schema: this.schema,
};

const result = this.cedarModule.isAuthorized(call as Parameters<typeof this.cedarModule.isAuthorized>[0]);
if (result.type === 'success') {
return result.response.decision === 'allow';
}

this.logger.warn(`Cedar authorization error: ${JSON.stringify(result.errors)}`);
return false;
}

private async loadPoliciesForConnection(connectionId: string): Promise<string | null> {
const cached = Cacher.getCedarPolicyCache(connectionId);
if (cached !== null) return cached;

const groups = await this.groupRepository.findAllGroupsInConnection(connectionId);
const policyTexts = groups.map((g) => g.cedarPolicy).filter(Boolean);

if (policyTexts.length === 0) return null;

const combined = policyTexts.join('\n\n');
Cacher.setCedarPolicyCache(connectionId, combined);
return combined;
}
Comment on lines +92 to +128
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

evaluate() returns false when loadPoliciesForConnection() returns null/empty or when Cedar returns a non-success result. In the guards, a false return becomes a hard 403 and does not trigger the intended legacy fallback (fallback only happens on thrown exceptions). This can lock out users if Cedar is enabled but policies haven’t been backfilled yet, or if Cedar returns structured errors instead of throwing. Consider throwing on Cedar evaluation errors / missing policy set (or returning a tri-state like {allowed, shouldFallback}) so the guards can correctly fall back to legacy auth when Cedar can’t make a decision.

Copilot uses AI. Check for mistakes.

private async assertUserNotSuspended(userId: string): Promise<void> {
const user = await this.dataSource.getRepository(UserEntity).findOne({
where: { id: userId },
select: ['id', 'suspended'],
});
if (user?.suspended) {
throw new HttpException(
{
message: Messages.ACCOUNT_SUSPENDED,
},
HttpStatus.FORBIDDEN,
);
}
}

private async getConnectionIdForGroup(groupId: string): Promise<string | null> {
const group = await this.dataSource
.getRepository(GroupEntity)
.createQueryBuilder('group')
.leftJoinAndSelect('group.connection', 'connection')
.where('group.id = :groupId', { groupId })
.getOne();
return group?.connection?.id ?? null;
}
}
53 changes: 53 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-entity-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { GroupEntity } from '../group/group.entity.js';

export interface CedarEntityRecord {
uid: { type: string; id: string };
attrs: Record<string, unknown>;
parents: Array<{ type: string; id: string }>;
}

export function buildCedarEntities(
userId: string,
userGroups: Array<GroupEntity>,
connectionId: string,
tableName?: string,
): Array<CedarEntityRecord> {
const entities: Array<CedarEntityRecord> = [];

// User entity with group memberships
entities.push({
uid: { type: 'RocketAdmin::User', id: userId },
attrs: { suspended: false },
parents: userGroups.map((g) => ({ type: 'RocketAdmin::Group', id: g.id })),
});

// Group entities
for (const group of userGroups) {
entities.push({
uid: { type: 'RocketAdmin::Group', id: group.id },
attrs: {
isMain: group.isMain,
connectionId: connectionId,
},
parents: [],
});
}

// Connection entity
entities.push({
uid: { type: 'RocketAdmin::Connection', id: connectionId },
attrs: {},
parents: [],
});

// Table entity (if table-level check)
if (tableName) {
entities.push({
uid: { type: 'RocketAdmin::Table', id: `${connectionId}/${tableName}` },
attrs: { connectionId: connectionId },
parents: [{ type: 'RocketAdmin::Connection', id: connectionId }],
});
}

return entities;
}
81 changes: 81 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-policy-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { AccessLevelEnum } from '../../enums/index.js';
import { IComplexPermission } from '../permission/permission.interface.js';

export function generateCedarPolicyForGroup(
groupId: string,
connectionId: string,
isMain: boolean,
permissions: IComplexPermission,
): string {
const policies: Array<string> = [];
const groupRef = `RocketAdmin::Group::"${groupId}"`;
const connectionRef = `RocketAdmin::Connection::"${connectionId}"`;

if (isMain) {
policies.push(
`permit(\n principal in ${groupRef},\n action,\n resource\n);`,
);
return policies.join('\n\n');
}

// Connection permissions
const connAccess = permissions.connection.accessLevel;
if (connAccess === AccessLevelEnum.edit) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"connection:read",\n resource == ${connectionRef}\n);`,
);
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"connection:edit",\n resource == ${connectionRef}\n);`,
);
} else if (connAccess === AccessLevelEnum.readonly) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"connection:read",\n resource == ${connectionRef}\n);`,
);
}

// Group permissions
const groupAccess = permissions.group.accessLevel;
const groupResourceRef = `RocketAdmin::Group::"${permissions.group.groupId}"`;
if (groupAccess === AccessLevelEnum.edit) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"group:read",\n resource == ${groupResourceRef}\n);`,
);
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"group:edit",\n resource == ${groupResourceRef}\n);`,
);
} else if (groupAccess === AccessLevelEnum.readonly) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"group:read",\n resource == ${groupResourceRef}\n);`,
);
}

// Table permissions
for (const table of permissions.tables) {
const tableRef = `RocketAdmin::Table::"${connectionId}/${table.tableName}"`;
const access = table.accessLevel;

const hasAnyAccess = access.visibility || access.add || access.delete || access.edit;
if (hasAnyAccess) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"table:read",\n resource == ${tableRef}\n);`,
);
}
if (access.add) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"table:add",\n resource == ${tableRef}\n);`,
);
}
if (access.edit) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"table:edit",\n resource == ${tableRef}\n);`,
);
}
if (access.delete) {
policies.push(
`permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"table:delete",\n resource == ${tableRef}\n);`,
);
}
}

return policies.join('\n\n');
}
Loading
Loading