diff --git a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts index 75e5d478f..1d9d1c99b 100644 --- a/backend/src/entities/cedar-authorization/cedar-authorization.service.ts +++ b/backend/src/entities/cedar-authorization/cedar-authorization.service.ts @@ -63,10 +63,13 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On resourceType = CedarResourceType.Table; resourceId = `${connectionId}/${tableName}`; break; - case 'dashboard': + case 'dashboard': { resourceType = CedarResourceType.Dashboard; - resourceId = `${connectionId}/${dashboardId}`; - break; + const needsSentinel = action === CedarAction.DashboardCreate || !dashboardId; + const effectiveDashboardId = needsSentinel ? '__new__' : dashboardId; + resourceId = `${connectionId}/${effectiveDashboardId}`; + return this.evaluate(userId, connectionId, action, resourceType, resourceId, tableName, effectiveDashboardId); + } default: return false; } @@ -155,8 +158,16 @@ export class CedarAuthorizationService implements ICedarAuthorizationService, On entities: [], schema: schema, }; - cedarWasm.isAuthorized(testCall as Parameters[0]); + const result = cedarWasm.isAuthorized(testCall as Parameters[0]); + if (result.type !== 'success') { + const errors = (result as unknown as { type: string; errors: string[] }).errors ?? []; + throw new HttpException( + { message: `Invalid cedar schema: ${errors.join('; ') || 'unknown validation error'}` }, + HttpStatus.BAD_REQUEST, + ); + } } catch (e) { + if (e instanceof HttpException) throw e; throw new HttpException({ message: `Invalid cedar schema: ${e.message}` }, HttpStatus.BAD_REQUEST); } } diff --git a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts index 51356d9aa..eae23f419 100644 --- a/backend/src/entities/cedar-authorization/cedar-policy-generator.ts +++ b/backend/src/entities/cedar-authorization/cedar-policy-generator.ts @@ -50,19 +50,20 @@ export function generateCedarPolicyForGroup( } if (permissions.dashboards) { + let hasCreatePermission = false; + let hasReadPermission = false; for (const dashboard of permissions.dashboards) { const dashboardRef = `RocketAdmin::Dashboard::"${connectionId}/${dashboard.dashboardId}"`; const access = dashboard.accessLevel; if (access.read) { + hasReadPermission = true; policies.push( `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:read",\n resource == ${dashboardRef}\n);`, ); } if (access.create) { - policies.push( - `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:create",\n resource == ${dashboardRef}\n);`, - ); + hasCreatePermission = true; } if (access.edit) { policies.push( @@ -75,6 +76,17 @@ export function generateCedarPolicyForGroup( ); } } + const newDashboardRef = `RocketAdmin::Dashboard::"${connectionId}/__new__"`; + if (hasReadPermission) { + policies.push( + `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:read",\n resource == ${newDashboardRef}\n);`, + ); + } + if (hasCreatePermission) { + policies.push( + `permit(\n principal in ${groupRef},\n action == RocketAdmin::Action::"dashboard:create",\n resource == ${newDashboardRef}\n);`, + ); + } } for (const table of permissions.tables) { diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts index 5b0b59595..bc0db8bd7 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-cedar-policy-generator.test.ts @@ -231,7 +231,7 @@ test('dashboard with read=true generates only dashboard:read', (t) => { t.false(result.includes('dashboard:edit')); t.false(result.includes('dashboard:delete')); const permits = result.match(/permit\(/g); - t.is(permits.length, 1); + t.is(permits.length, 2); // dash-1 read + __new__ read }); test('dashboard with all flags true generates dashboard:read + dashboard:create + dashboard:edit + dashboard:delete', (t) => { @@ -253,7 +253,7 @@ test('dashboard with all flags true generates dashboard:read + dashboard:create t.true(result.includes('dashboard:edit')); t.true(result.includes('dashboard:delete')); const permits = result.match(/permit\(/g); - t.is(permits.length, 4); + t.is(permits.length, 5); // dash-1: read + edit + delete, __new__: read + create }); test('dashboard with all flags false generates no policies for that dashboard', (t) => { diff --git a/backend/test/ava-tests/saas-tests/connection-e2e.test.ts b/backend/test/ava-tests/saas-tests/connection-e2e.test.ts index 257975627..753b96179 100644 --- a/backend/test/ava-tests/saas-tests/connection-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/connection-e2e.test.ts @@ -218,10 +218,9 @@ test.serial(`${currentTest} should return all connection users from different gr const foundUsersRO = JSON.parse(findAllUsersResponse.text); t.is(foundUsersRO.length, 2); - // t.is(foundUsersRO[0].isActive, false); - t.is(foundUsersRO[1].isActive, true); - t.is(Object.hasOwn(foundUsersRO[1], 'email'), true); + t.true(foundUsersRO.some((u: { isActive: boolean }) => u.isActive === true)); t.is(Object.hasOwn(foundUsersRO[0], 'email'), true); + t.is(Object.hasOwn(foundUsersRO[1], 'email'), true); t.is(Object.hasOwn(foundUsersRO[0], 'createdAt'), true); t.is(Object.hasOwn(foundUsersRO[1], 'createdAt'), true);