From 6419ea3828b6c180a7610ea104cce1e92cd5b7e4 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 13 Mar 2026 14:06:37 +0000 Subject: [PATCH 01/15] Add Cedar policy editor to group creation and edit dialogs Expose cedarPolicy through backend API (DTOs, responses, use cases) and add Monaco-based code editor to group-add-dialog and group-name-edit-dialog in the Angular frontend, allowing users to write Cedar authorization policies when creating or editing user groups. Co-Authored-By: Claude Opus 4.6 --- .../create-group-in-connection.ds.ts | 1 + .../dto/create-group-in-connection.dto.ts | 7 +++- .../found-user-groups-in-connection.dto.ts | 3 ++ .../connection/connection.controller.ts | 10 ++--- .../create-group-in-connection.use.case.ts | 12 +++--- ...found-user-group-in-connection-dto.util.ts | 1 + .../data-sctructures/found-user-groups.ds.ts | 3 ++ .../group/dto/found-group-response.dto.ts | 3 ++ .../group/dto/update-group-title.dto.ts | 7 +++- .../use-cases/update-group-title.use.case.ts | 10 ++++- .../utils/biuld-found-group-response.dto.ts | 1 + .../group-add-dialog.component.css | 18 +++++++++ .../group-add-dialog.component.html | 18 +++++++-- .../group-add-dialog.component.spec.ts | 12 +++++- .../group-add-dialog.component.ts | 30 +++++++++++++-- .../group-name-edit-dialog.component.css | 18 +++++++++ .../group-name-edit-dialog.component.html | 22 ++++++++--- .../group-name-edit-dialog.component.spec.ts | 12 +++++- .../group-name-edit-dialog.component.ts | 37 ++++++++++++++++--- .../permissions-add-dialog.component.ts | 2 +- .../components/users/users.component.spec.ts | 2 +- .../app/components/users/users.component.ts | 4 +- frontend/src/app/models/user.ts | 1 + .../src/app/services/users.service.spec.ts | 2 +- frontend/src/app/services/users.service.ts | 8 ++-- 25 files changed, 196 insertions(+), 48 deletions(-) diff --git a/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts b/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts index 7dea8f36b..9066077e9 100644 --- a/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts +++ b/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts @@ -2,6 +2,7 @@ export class CreateGroupInConnectionDs { group_parameters: { title: string; connectionId: string; + cedarPolicy?: string | null; }; creation_info: { cognitoUserName: string; diff --git a/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts b/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts index e78c052e1..49aca94a3 100644 --- a/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts +++ b/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts @@ -1,9 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateGroupInConnectionDTO { @IsNotEmpty() @IsString() @ApiProperty() title: string; + + @IsOptional() + @IsString() + @ApiProperty({ required: false, nullable: true }) + cedarPolicy?: string | null; } diff --git a/backend/src/entities/connection/application/dto/found-user-groups-in-connection.dto.ts b/backend/src/entities/connection/application/dto/found-user-groups-in-connection.dto.ts index c49277cbf..ca94c92db 100644 --- a/backend/src/entities/connection/application/dto/found-user-groups-in-connection.dto.ts +++ b/backend/src/entities/connection/application/dto/found-user-groups-in-connection.dto.ts @@ -12,6 +12,9 @@ export class FoundGroupInConnectionDTO { @ApiProperty() isMain: boolean; + @ApiProperty({ required: false, nullable: true }) + cedarPolicy?: string | null; + @ApiProperty({ required: false, isArray: true, type: SimpleFoundUserInfoDs }) users?: Array; } diff --git a/backend/src/entities/connection/connection.controller.ts b/backend/src/entities/connection/connection.controller.ts index a3e57631a..e2d930066 100644 --- a/backend/src/entities/connection/connection.controller.ts +++ b/backend/src/entities/connection/connection.controller.ts @@ -22,11 +22,7 @@ import { AmplitudeEventTypeEnum, InTransactionEnum } from '../../enums/index.js' import { Messages } from '../../exceptions/text/messages.js'; import { processExceptionMessage } from '../../exceptions/utils/process-exception-message.js'; import { ConnectionEditGuard, ConnectionReadGuard } from '../../guards/index.js'; -import { - isConnectionTypeAgent, - slackPostMessage, - toPrettyErrorsMsg, -} from '../../helpers/index.js'; +import { isConnectionTypeAgent, slackPostMessage, toPrettyErrorsMsg } from '../../helpers/index.js'; import { SentryInterceptor } from '../../interceptors/index.js'; import { SuccessResponse } from '../../microservices/saas-microservice/data-structures/common-responce.ds.js'; import { AmplitudeService } from '../amplitude/amplitude.service.js'; @@ -413,7 +409,7 @@ export class ConnectionController { @SlugUuid('connectionId') connectionId: string, @UserId() userId: string, ): Promise { - const { title } = groupData; + const { title, cedarPolicy } = groupData; if (!title) { throw new BadRequestException(Messages.GROUP_TITLE_MISSING); } @@ -421,6 +417,7 @@ export class ConnectionController { group_parameters: { title: title, connectionId: connectionId, + cedarPolicy: cedarPolicy, }, creation_info: { cognitoUserName: userId, @@ -689,5 +686,4 @@ export class ConnectionController { } return await this.unfreezeConnectionUseCase.execute({ connectionId, userId }, InTransactionEnum.ON); } - } diff --git a/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts b/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts index 22bb5ac71..32a2ea9f4 100644 --- a/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts +++ b/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts @@ -26,7 +26,7 @@ export class CreateGroupInConnectionUseCase protected async implementation(inputData: CreateGroupInConnectionDs): Promise { const { - group_parameters: { connectionId, title }, + group_parameters: { connectionId, title, cedarPolicy }, creation_info: { cognitoUserName }, } = inputData; const connectionToUpdate = await this._dbContext.connectionRepository.findConnectionWithGroups(connectionId); @@ -36,15 +36,13 @@ export class CreateGroupInConnectionUseCase const foundUser = await this._dbContext.userRepository.findOneUserById(cognitoUserName); const newGroupEntity = buildNewGroupEntityForConnectionWithUser(connectionToUpdate, foundUser, title); const savedGroup = await this._dbContext.groupRepository.saveNewOrUpdatedGroup(newGroupEntity); - savedGroup.cedarPolicy = generateCedarPolicyForGroup( - connectionId, - false, - { + savedGroup.cedarPolicy = + cedarPolicy ?? + generateCedarPolicyForGroup(connectionId, false, { connection: { connectionId, accessLevel: AccessLevelEnum.none }, group: { groupId: savedGroup.id, accessLevel: AccessLevelEnum.none }, tables: [], - }, - ); + }); await this._dbContext.groupRepository.saveNewOrUpdatedGroup(savedGroup); Cacher.invalidateCedarPolicyCache(connectionId); return buildFoundGroupResponseDto(savedGroup); diff --git a/backend/src/entities/connection/utils/build-found-user-group-in-connection-dto.util.ts b/backend/src/entities/connection/utils/build-found-user-group-in-connection-dto.util.ts index ef2a8649b..b9fb49a22 100644 --- a/backend/src/entities/connection/utils/build-found-user-group-in-connection-dto.util.ts +++ b/backend/src/entities/connection/utils/build-found-user-group-in-connection-dto.util.ts @@ -12,6 +12,7 @@ export function buildFoundUserGroupInConnectionDto( id: group.id, title: group.title, isMain: group.isMain, + cedarPolicy: group.cedarPolicy, users: group.users?.length ? group.users.map((user) => buildSimpleUserInfoDs(user)) : undefined, }, accessLevel, diff --git a/backend/src/entities/group/application/data-sctructures/found-user-groups.ds.ts b/backend/src/entities/group/application/data-sctructures/found-user-groups.ds.ts index 7a17616bc..ef572b7e9 100644 --- a/backend/src/entities/group/application/data-sctructures/found-user-groups.ds.ts +++ b/backend/src/entities/group/application/data-sctructures/found-user-groups.ds.ts @@ -11,6 +11,9 @@ export class FoundGroupDataInfoDs { @ApiProperty() isMain: boolean; + + @ApiProperty({ required: false, nullable: true }) + cedarPolicy?: string | null; } export class FoundGroupDataWithUsersDs extends FoundGroupDataInfoDs { diff --git a/backend/src/entities/group/dto/found-group-response.dto.ts b/backend/src/entities/group/dto/found-group-response.dto.ts index 2ce125525..5d13a5521 100644 --- a/backend/src/entities/group/dto/found-group-response.dto.ts +++ b/backend/src/entities/group/dto/found-group-response.dto.ts @@ -11,6 +11,9 @@ export class FoundGroupResponseDto { @ApiProperty() isMain: boolean; + @ApiProperty({ required: false, nullable: true }) + cedarPolicy?: string | null; + @ApiProperty({ required: false, isArray: true, type: SimpleFoundUserInfoDs }) users?: Array; } diff --git a/backend/src/entities/group/dto/update-group-title.dto.ts b/backend/src/entities/group/dto/update-group-title.dto.ts index d23237f41..8b740b1e8 100644 --- a/backend/src/entities/group/dto/update-group-title.dto.ts +++ b/backend/src/entities/group/dto/update-group-title.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; export class UpdateGroupTitleDto { @ApiProperty() @@ -12,4 +12,9 @@ export class UpdateGroupTitleDto { @IsNotEmpty() @IsUUID() groupId: string; + + @IsOptional() + @IsString() + @ApiProperty({ required: false, nullable: true }) + cedarPolicy?: string | null; } diff --git a/backend/src/entities/group/use-cases/update-group-title.use.case.ts b/backend/src/entities/group/use-cases/update-group-title.use.case.ts index d36918baf..dfe131b4f 100644 --- a/backend/src/entities/group/use-cases/update-group-title.use.case.ts +++ b/backend/src/entities/group/use-cases/update-group-title.use.case.ts @@ -3,6 +3,7 @@ import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; import { Messages } from '../../../exceptions/text/messages.js'; +import { Cacher } from '../../../helpers/cache/cacher.js'; import { FoundGroupDataInfoDs } from '../application/data-sctructures/found-user-groups.ds.js'; import { UpdateGroupTitleDto } from '../dto/update-group-title.dto.js'; import { IUpdateGroupTitle } from './use-cases.interfaces.js'; @@ -20,7 +21,7 @@ export class UpdateGroupTitleUseCase } protected async implementation(groupData: UpdateGroupTitleDto): Promise { - const { groupId, title } = groupData; + const { groupId, title, cedarPolicy } = groupData; const groupToUpdate = await this._dbContext.groupRepository.findGroupByIdWithConnectionAndUsers(groupId); if (!groupToUpdate) { throw new HttpException( @@ -34,16 +35,21 @@ export class UpdateGroupTitleUseCase groupToUpdate.connection.id, ); - if (connectionWithGroups.groups.find((group) => group.title === title)) { + if (connectionWithGroups.groups.find((group) => group.title === title && group.id !== groupId)) { throw new BadRequestException(Messages.GROUP_NAME_UNIQUE); } groupToUpdate.title = title; + if (cedarPolicy !== undefined) { + groupToUpdate.cedarPolicy = cedarPolicy; + Cacher.invalidateCedarPolicyCache(groupToUpdate.connection.id); + } const updatedGroup = await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate); return { id: updatedGroup.id, title: updatedGroup.title, isMain: updatedGroup.isMain, + cedarPolicy: updatedGroup.cedarPolicy, }; } } diff --git a/backend/src/entities/group/utils/biuld-found-group-response.dto.ts b/backend/src/entities/group/utils/biuld-found-group-response.dto.ts index 63804fef3..4fd560bc6 100644 --- a/backend/src/entities/group/utils/biuld-found-group-response.dto.ts +++ b/backend/src/entities/group/utils/biuld-found-group-response.dto.ts @@ -7,6 +7,7 @@ export function buildFoundGroupResponseDto(group: GroupEntity): FoundGroupRespon id: group.id, title: group.title, isMain: group.isMain, + cedarPolicy: group.cedarPolicy, users: group.users?.map((user) => buildSimpleUserInfoDs(user)), }; } diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css index fcaa8cf92..6f5509a77 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css @@ -1,3 +1,21 @@ .mat-mdc-form-field { width: 100%; } + +.cedar-policy-section { + margin-top: 8px; +} + +.cedar-policy-label { + display: block; + font-size: 14px; + color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); + margin-bottom: 8px; +} + +.code-editor-box { + height: 200px; + border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); + border-radius: 4px; + overflow: hidden; +} diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html index e7dcdf429..e73b1a27d 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html @@ -6,12 +6,24 @@

Create group of users

Title should not be empty. + +
+ +
+ + +
+
- - + - \ No newline at end of file + diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts index 00b6d07b8..059c19ffe 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts @@ -1,4 +1,5 @@ import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; @@ -6,9 +7,11 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2Module } from 'angulartics2'; import { of } from 'rxjs'; import { UsersService } from 'src/app/services/users.service'; +import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; import { GroupAddDialogComponent } from './group-add-dialog.component'; describe('GroupAddDialogComponent', () => { @@ -36,7 +39,12 @@ describe('GroupAddDialogComponent', () => { { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: MatDialogRef, useValue: mockDialogRef }, ], - }).compileComponents(); + }) + .overrideComponent(GroupAddDialogComponent, { + remove: { imports: [CodeEditorModule] }, + add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); }); beforeEach(() => { @@ -58,7 +66,7 @@ describe('GroupAddDialogComponent', () => { component.addGroup(); - expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers'); + expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers', null); // expect(component.dialogRef.close).toHaveBeenCalled(); expect(component.submitting).toBe(false); }); diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts index 9d0f2f249..7d2d53fcd 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts @@ -5,37 +5,61 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; import { ConnectionsService } from 'src/app/services/connections.service'; +import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { UsersService } from 'src/app/services/users.service'; @Component({ selector: 'app-group-add-dialog', - imports: [NgIf, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule], + imports: [NgIf, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, CodeEditorModule], templateUrl: './group-add-dialog.component.html', styleUrls: ['./group-add-dialog.component.css'], }) export class GroupAddDialogComponent implements OnInit { public connectionID: string; public groupTitle: string = ''; + public cedarPolicy: string = ''; public submitting: boolean = false; + public cedarPolicyModel: object; + public codeEditorOptions = { + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + wordWrap: 'on', + }; + public codeEditorTheme: string; + constructor( private _connections: ConnectionsService, public _usersService: UsersService, public dialogRef: MatDialogRef, private angulartics2: Angulartics2, - ) {} + private _uiSettings: UiSettingsService, + ) { + this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; + } ngOnInit(): void { this.connectionID = this._connections.currentConnectionID; this._usersService.cast.subscribe(); + this.cedarPolicyModel = { + language: 'plaintext', + uri: 'cedar-policy-create.cedar', + value: this.cedarPolicy, + }; + } + + onCedarPolicyChange(value: string) { + this.cedarPolicy = value; } addGroup() { this.submitting = true; - this._usersService.createUsersGroup(this.connectionID, this.groupTitle).subscribe( + this._usersService.createUsersGroup(this.connectionID, this.groupTitle, this.cedarPolicy || null).subscribe( () => { this.submitting = false; this.dialogRef.close(); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css index fcaa8cf92..6f5509a77 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css @@ -1,3 +1,21 @@ .mat-mdc-form-field { width: 100%; } + +.cedar-policy-section { + margin-top: 8px; +} + +.cedar-policy-label { + display: block; + font-size: 14px; + color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); + margin-bottom: 8px; +} + +.code-editor-box { + height: 200px; + border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); + border-radius: 4px; + overflow: hidden; +} diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html index a5e2e2619..6e6769ae3 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html @@ -1,17 +1,29 @@ -

Change group name

+

Edit group

- Change group name + Group name Title should not be empty. + +
+ +
+ + +
+
- - +
diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts index 834d4b22e..9a640bf4a 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts @@ -1,10 +1,13 @@ import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CodeEditorModule } from '@ngstack/code-editor'; +import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; import { GroupNameEditDialogComponent } from './group-name-edit-dialog.component'; describe('GroupNameEditDialogComponent', () => { @@ -20,10 +23,15 @@ describe('GroupNameEditDialogComponent', () => { imports: [MatDialogModule, MatSnackBarModule, FormsModule, BrowserAnimationsModule, GroupNameEditDialogComponent], providers: [ provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: { id: 'test-id', title: 'Test Group', cedarPolicy: '' } }, { provide: MatDialogRef, useValue: mockDialogRef }, ], - }).compileComponents(); + }) + .overrideComponent(GroupNameEditDialogComponent, { + remove: { imports: [CodeEditorModule] }, + add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); fixture = TestBed.createComponent(GroupNameEditDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts index 9da466ab9..5192ae99d 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts @@ -5,34 +5,59 @@ import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { CodeEditorModule } from '@ngstack/code-editor'; +import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { UsersService } from 'src/app/services/users.service'; -import { GroupAddDialogComponent } from '../group-add-dialog/group-add-dialog.component'; +import { GroupNameEditDialogComponent as Self } from './group-name-edit-dialog.component'; @Component({ selector: 'app-group-name-edit-dialog', templateUrl: './group-name-edit-dialog.component.html', styleUrls: ['./group-name-edit-dialog.component.css'], - imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule], + imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule, CodeEditorModule], }) export class GroupNameEditDialogComponent { public connectionID: string; public groupTitle: string = ''; + public cedarPolicy: string = ''; public submitting: boolean = false; + public cedarPolicyModel: object; + public codeEditorOptions = { + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + wordWrap: 'on', + }; + public codeEditorTheme: string; + constructor( - @Inject(MAT_DIALOG_DATA) public group: any, + @Inject(MAT_DIALOG_DATA) public group: { id: string; title: string; cedarPolicy?: string | null }, public _usersService: UsersService, - public dialogRef: MatDialogRef, - ) {} + public dialogRef: MatDialogRef, + private _uiSettings: UiSettingsService, + ) { + this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; + } ngOnInit(): void { this.groupTitle = this.group.title; + this.cedarPolicy = this.group.cedarPolicy || ''; this._usersService.cast.subscribe(); + this.cedarPolicyModel = { + language: 'plaintext', + uri: `cedar-policy-edit-${this.group.id}.cedar`, + value: this.cedarPolicy, + }; + } + + onCedarPolicyChange(value: string) { + this.cedarPolicy = value; } addGroup() { this.submitting = true; - this._usersService.editUsersGroupName(this.group.id, this.groupTitle).subscribe( + this._usersService.editUsersGroupName(this.group.id, this.groupTitle, this.cedarPolicy || null).subscribe( () => { this.submitting = false; this.dialogRef.close(); diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts index 5c33e1841..ad188ea68 100644 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts +++ b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts @@ -100,7 +100,7 @@ export class PermissionsAddDialogComponent implements OnInit { handleOpenNewGroupPopup() { this.dialogRef.close('add_group'); this.dialog.open(GroupAddDialogComponent, { - width: '25em', + width: '40em', }); } diff --git a/frontend/src/app/components/users/users.component.spec.ts b/frontend/src/app/components/users/users.component.spec.ts index 1f42e1009..0feab6f8a 100644 --- a/frontend/src/app/components/users/users.component.spec.ts +++ b/frontend/src/app/components/users/users.component.spec.ts @@ -97,7 +97,7 @@ describe('UsersComponent', () => { component.openCreateUsersGroupDialog(event); expect(fakeCreateUsersGroupOpen).toHaveBeenCalledWith(GroupAddDialogComponent, { - width: '25em', + width: '40em', }); }); diff --git a/frontend/src/app/components/users/users.component.ts b/frontend/src/app/components/users/users.component.ts index cec08bfff..e96dfc837 100644 --- a/frontend/src/app/components/users/users.component.ts +++ b/frontend/src/app/components/users/users.component.ts @@ -177,7 +177,7 @@ export class UsersComponent implements OnInit, OnDestroy { event.preventDefault(); event.stopImmediatePropagation(); this.dialog.open(GroupAddDialogComponent, { - width: '25em', + width: '40em', }); } @@ -206,7 +206,7 @@ export class UsersComponent implements OnInit, OnDestroy { openEditGroupNameDialog(e: Event, group: UserGroup) { e.stopPropagation(); this.dialog.open(GroupNameEditDialogComponent, { - width: '25em', + width: '40em', data: group, }); } diff --git a/frontend/src/app/models/user.ts b/frontend/src/app/models/user.ts index d64b74b77..64013c95e 100644 --- a/frontend/src/app/models/user.ts +++ b/frontend/src/app/models/user.ts @@ -17,6 +17,7 @@ export interface UserGroup { id: string; title: string; isMain: boolean; + cedarPolicy?: string | null; users?: { id: string; isActive: boolean; diff --git a/frontend/src/app/services/users.service.spec.ts b/frontend/src/app/services/users.service.spec.ts index b865070b5..792380b14 100644 --- a/frontend/src/app/services/users.service.spec.ts +++ b/frontend/src/app/services/users.service.spec.ts @@ -245,7 +245,7 @@ describe('UsersService', () => { const req = httpMock.expectOne(`/connection/group/12345678`); expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ title: 'Managers' }); + expect(req.request.body).toEqual({ title: 'Managers', cedarPolicy: null }); req.flush(groupNetwork); expect(isSubscribeCalled).toBe(true); diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 66ba3766f..31987ff5b 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -50,8 +50,8 @@ export class UsersService { ); } - createUsersGroup(connectionID: string, title: string) { - return this._http.post(`/connection/group/${connectionID}`, { title: title }).pipe( + createUsersGroup(connectionID: string, title: string, cedarPolicy?: string | null) { + return this._http.post(`/connection/group/${connectionID}`, { title, cedarPolicy: cedarPolicy || null }).pipe( map((res) => { this.groups.next({ action: 'add group', group: res }); this._notifications.showSuccessSnackbar('Group of users has been created.'); @@ -118,8 +118,8 @@ export class UsersService { ); } - editUsersGroupName(groupId: string, title: string) { - return this._http.put(`/group/title`, { title, groupId }).pipe( + editUsersGroupName(groupId: string, title: string, cedarPolicy?: string | null) { + return this._http.put(`/group/title`, { title, groupId, cedarPolicy }).pipe( map(() => { this.groups.next({ action: 'edit group name', groupId: groupId }); this._notifications.showSuccessSnackbar('Group name has been updated.'); From 5849a54b3581ce39da3170deddfe1b3bf696cc84 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 13 Mar 2026 17:13:10 +0000 Subject: [PATCH 02/15] Replace permissions form with list-based Cedar policy editor Replace the old grid-based permissions form (PermissionsFormComponent) and standalone permissions dialog (PermissionsAddDialogComponent) with a new list-based CedarPolicyListComponent. Each policy is shown as a row with add/edit/delete controls. Remove the "Configure permissions" button from the users page since permissions are now managed inline in group dialogs. Co-Authored-By: Claude Opus 4.6 --- .../cedar-policy-list.component.css | 81 +++++ .../cedar-policy-list.component.html | 95 ++++++ .../cedar-policy-list.component.spec.ts | 136 ++++++++ .../cedar-policy-list.component.ts | 111 +++++++ .../group-add-dialog.component.css | 9 +- .../group-add-dialog.component.html | 20 +- .../group-add-dialog.component.spec.ts | 60 +++- .../group-add-dialog.component.ts | 85 ++++- .../group-name-edit-dialog.component.css | 9 +- .../group-name-edit-dialog.component.html | 20 +- .../group-name-edit-dialog.component.spec.ts | 68 +++- .../group-name-edit-dialog.component.ts | 98 +++++- .../permissions-add-dialog.component.css | 152 --------- .../permissions-add-dialog.component.html | 130 -------- .../permissions-add-dialog.component.spec.ts | 300 ------------------ .../permissions-add-dialog.component.ts | 178 ----------- .../app/components/users/users.component.html | 17 +- .../components/users/users.component.spec.ts | 18 +- .../app/components/users/users.component.ts | 16 +- frontend/src/app/lib/cedar-monaco-language.ts | 49 +++ .../src/app/lib/cedar-policy-generator.ts | 65 ++++ frontend/src/app/lib/cedar-policy-items.ts | 122 +++++++ frontend/src/app/lib/cedar-policy-parser.ts | 235 ++++++++++++++ 23 files changed, 1255 insertions(+), 819 deletions(-) create mode 100644 frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css create mode 100644 frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html create mode 100644 frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts create mode 100644 frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts delete mode 100644 frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css delete mode 100644 frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html delete mode 100644 frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts delete mode 100644 frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts create mode 100644 frontend/src/app/lib/cedar-monaco-language.ts create mode 100644 frontend/src/app/lib/cedar-policy-generator.ts create mode 100644 frontend/src/app/lib/cedar-policy-items.ts create mode 100644 frontend/src/app/lib/cedar-policy-parser.ts diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css new file mode 100644 index 000000000..42a95e63b --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css @@ -0,0 +1,81 @@ +.policy-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.empty-state { + color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); + font-size: 14px; + padding: 12px 0; +} + +.policy-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.12)); +} + +.policy-item--add { + border-style: dashed; +} + +.policy-item__content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.policy-item__icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--color-accentedPalette-500, #1976d2); + flex-shrink: 0; +} + +.policy-item__label { + font-size: 14px; + font-weight: 500; +} + +.policy-item__table { + font-size: 14px; + color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); +} + +.policy-item__actions { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.policy-item__edit-form { + display: flex; + align-items: flex-start; + gap: 8px; + flex: 1; + flex-wrap: wrap; +} + +.policy-field { + flex: 1; + min-width: 180px; +} + +.policy-item__edit-actions { + display: flex; + align-items: center; + gap: 4px; + padding-top: 8px; +} + +.add-policy-button { + align-self: flex-start; + margin-top: 4px; +} diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html new file mode 100644 index 000000000..1d7fa426d --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html @@ -0,0 +1,95 @@ +
+ + +
+ No policies defined. Add a policy to grant permissions. +
+ +
+ + +
+ security + {{ getActionLabel(policy.action) }} + + — {{ getTableDisplayName(policy.tableName) }} + +
+
+ + +
+
+ + + +
+ + Action + + + {{ action.label }} + + + + + + Table + + + {{ table.displayName }} + + + + +
+ + +
+
+
+
+ + +
+
+ + Action + + + {{ action.label }} + + + + + + Table + + + {{ table.displayName }} + + + + +
+ + +
+
+
+ + +
diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts new file mode 100644 index 000000000..755dde5aa --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts @@ -0,0 +1,136 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CedarPolicyListComponent } from './cedar-policy-list.component'; + +describe('CedarPolicyListComponent', () => { + let component: CedarPolicyListComponent; + let fixture: ComponentFixture; + + const fakeTables = [ + { tableName: 'customers', displayName: 'Customers' }, + { tableName: 'orders', displayName: 'Orders' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(CedarPolicyListComponent); + component = fixture.componentInstance; + component.availableTables = fakeTables; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should add a policy', () => { + const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + component.showAddForm = true; + component.newAction = 'connection:read'; + component.addPolicy(); + + expect(component.policies.length).toBe(1); + expect(component.policies[0].action).toBe('connection:read'); + expect(emitSpy).toHaveBeenCalled(); + expect(component.showAddForm).toBe(false); + }); + + it('should add a table policy with tableName', () => { + component.showAddForm = true; + component.newAction = 'table:read'; + component.newTableName = 'customers'; + component.addPolicy(); + + expect(component.policies.length).toBe(1); + expect(component.policies[0].action).toBe('table:read'); + expect(component.policies[0].tableName).toBe('customers'); + }); + + it('should not add policy without action', () => { + component.showAddForm = true; + component.newAction = ''; + component.addPolicy(); + + expect(component.policies.length).toBe(0); + }); + + it('should not add table policy without table name', () => { + component.showAddForm = true; + component.newAction = 'table:read'; + component.newTableName = ''; + component.addPolicy(); + + expect(component.policies.length).toBe(0); + }); + + it('should remove a policy', () => { + component.policies = [{ action: 'connection:read' }, { action: 'group:read' }]; + const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + + component.removePolicy(0); + + expect(component.policies.length).toBe(1); + expect(component.policies[0].action).toBe('group:read'); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should start and save edit', () => { + component.policies = [{ action: 'connection:read' }]; + const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + + component.startEdit(0); + expect(component.editingIndex).toBe(0); + expect(component.editAction).toBe('connection:read'); + + component.editAction = 'connection:edit'; + component.saveEdit(0); + + expect(component.policies[0].action).toBe('connection:edit'); + expect(component.editingIndex).toBeNull(); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should cancel edit', () => { + component.policies = [{ action: 'connection:read' }]; + component.startEdit(0); + component.editAction = 'connection:edit'; + component.cancelEdit(); + + expect(component.editingIndex).toBeNull(); + expect(component.policies[0].action).toBe('connection:read'); + }); + + it('should return correct action labels', () => { + expect(component.getActionLabel('*')).toBe('Full access (all permissions)'); + expect(component.getActionLabel('connection:read')).toBe('Connection: Read'); + expect(component.getActionLabel('table:edit')).toBe('Table: Edit'); + }); + + it('should return correct table display names', () => { + expect(component.getTableDisplayName('customers')).toBe('Customers'); + expect(component.getTableDisplayName('unknown')).toBe('unknown'); + }); + + it('should detect needsTable correctly', () => { + component.newAction = 'connection:read'; + expect(component.needsTable).toBe(false); + + component.newAction = 'table:read'; + expect(component.needsTable).toBe(true); + }); + + it('should reset add form', () => { + component.showAddForm = true; + component.newAction = 'connection:read'; + component.newTableName = 'test'; + component.resetAddForm(); + + expect(component.showAddForm).toBe(false); + expect(component.newAction).toBe(''); + expect(component.newTableName).toBe(''); + }); +}); diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts new file mode 100644 index 000000000..42d7a6d9d --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts @@ -0,0 +1,111 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { CedarPolicyItem, POLICY_ACTIONS } from 'src/app/lib/cedar-policy-items'; +import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; + +export interface AvailableTable { + tableName: string; + displayName: string; +} + +@Component({ + selector: 'app-cedar-policy-list', + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + MatTooltipModule, + ContentLoaderComponent, + ], + templateUrl: './cedar-policy-list.component.html', + styleUrls: ['./cedar-policy-list.component.css'], +}) +export class CedarPolicyListComponent { + @Input() policies: CedarPolicyItem[] = []; + @Input() availableTables: AvailableTable[] = []; + @Input() loading: boolean = false; + @Output() policiesChange = new EventEmitter(); + + showAddForm = false; + newAction = ''; + newTableName = ''; + + editingIndex: number | null = null; + editAction = ''; + editTableName = ''; + + availableActions = POLICY_ACTIONS; + + get needsTable(): boolean { + return this.newAction.startsWith('table:'); + } + + get editNeedsTable(): boolean { + return this.editAction.startsWith('table:'); + } + + getActionLabel(action: string): string { + return this.availableActions.find((a) => a.value === action)?.label || action; + } + + getTableDisplayName(tableName: string): string { + return this.availableTables.find((t) => t.tableName === tableName)?.displayName || tableName; + } + + addPolicy() { + if (!this.newAction) return; + if (this.needsTable && !this.newTableName) return; + + const item: CedarPolicyItem = { action: this.newAction }; + if (this.needsTable) { + item.tableName = this.newTableName; + } + this.policies = [...this.policies, item]; + this.policiesChange.emit(this.policies); + this.resetAddForm(); + } + + removePolicy(index: number) { + this.policies = this.policies.filter((_, i) => i !== index); + this.policiesChange.emit(this.policies); + } + + startEdit(index: number) { + this.editingIndex = index; + this.editAction = this.policies[index].action; + this.editTableName = this.policies[index].tableName || ''; + } + + saveEdit(index: number) { + if (!this.editAction) return; + if (this.editNeedsTable && !this.editTableName) return; + + const updated = [...this.policies]; + updated[index] = { + action: this.editAction, + tableName: this.editNeedsTable ? this.editTableName : undefined, + }; + this.policies = updated; + this.policiesChange.emit(this.policies); + this.editingIndex = null; + } + + cancelEdit() { + this.editingIndex = null; + } + + resetAddForm() { + this.showAddForm = false; + this.newAction = ''; + this.newTableName = ''; + } +} diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css index 6f5509a77..8133ce193 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css @@ -10,7 +10,14 @@ display: block; font-size: 14px; color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); - margin-bottom: 8px; + margin-bottom: 0; +} + +.editor-mode-toggle { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; } .code-editor-box { diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html index e73b1a27d..bdf88abdb 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html @@ -8,8 +8,24 @@

Create group of users

- -
+
+ + + Form + Code + +
+ +
+ + +
+ +
{ let component: GroupAddDialogComponent; let fixture: ComponentFixture; let usersService: UsersService; + let tablesService: TablesService; const mockDialogRef = { close: () => {}, }; - beforeEach(async () => { + const fakeTables = [ + { + table: 'customers', + display_name: 'Customers', + permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, + }, + { + table: 'orders', + display_name: 'Orders', + permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, + }, + ]; + + beforeEach(() => { TestBed.configureTestingModule({ imports: [ MatSnackBarModule, @@ -51,6 +66,8 @@ describe('GroupAddDialogComponent', () => { fixture = TestBed.createComponent(GroupAddDialogComponent); component = fixture.componentInstance; usersService = TestBed.inject(UsersService); + tablesService = TestBed.inject(TablesService); + vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); fixture.detectChanges(); }); @@ -67,7 +84,46 @@ describe('GroupAddDialogComponent', () => { component.addGroup(); expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers', null); - // expect(component.dialogRef.close).toHaveBeenCalled(); expect(component.submitting).toBe(false); }); + + it('should load tables on init', () => { + expect(tablesService.fetchTables).toHaveBeenCalled(); + expect(component.allTables.length).toBe(2); + expect(component.availableTables.length).toBe(2); + expect(component.tablesLoading).toBe(false); + }); + + it('should start in form mode', () => { + expect(component.editorMode).toBe('form'); + }); + + it('should switch to code mode and generate cedar policy from policy items', () => { + component.policyItems = [{ action: 'connection:read' }, { action: 'group:edit' }]; + + component.onEditorModeChange('code'); + + expect(component.editorMode).toBe('code'); + expect(component.cedarPolicy).toContain('connection:read'); + expect(component.cedarPolicy).toContain('group:edit'); + }); + + it('should switch back to form mode and parse cedar policy into items', () => { + component.connectionID = 'test-conn'; + component.cedarPolicy = `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"test-conn"\n);`; + + component.onEditorModeChange('code'); + component.onEditorModeChange('form'); + + expect(component.editorMode).toBe('form'); + }); + + it('should add and remove policy items', () => { + component.onPolicyItemsChange([{ action: '*' }]); + expect(component.policyItems.length).toBe(1); + expect(component.policyItems[0].action).toBe('*'); + + component.onPolicyItemsChange([]); + expect(component.policyItems.length).toBe(0); + }); }); diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts index 7d2d53fcd..03f0897f5 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts @@ -2,19 +2,38 @@ import { NgIf } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { CodeEditorModule } from '@ngstack/code-editor'; +import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; +import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; +import { generateCedarPolicy } from 'src/app/lib/cedar-policy-generator'; +import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToPermissions } from 'src/app/lib/cedar-policy-items'; +import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; +import { normalizeTableName } from 'src/app/lib/normalize'; +import { TablePermission } from 'src/app/models/user'; import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { UsersService } from 'src/app/services/users.service'; +import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component'; @Component({ selector: 'app-group-add-dialog', - imports: [NgIf, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, CodeEditorModule], + imports: [ + NgIf, + FormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatButtonToggleModule, + CodeEditorModule, + CedarPolicyListComponent, + ], templateUrl: './group-add-dialog.component.html', styleUrls: ['./group-add-dialog.component.css'], }) @@ -24,6 +43,12 @@ export class GroupAddDialogComponent implements OnInit { public cedarPolicy: string = ''; public submitting: boolean = false; + public editorMode: 'form' | 'code' = 'form'; + public policyItems: CedarPolicyItem[] = []; + public availableTables: AvailableTable[] = []; + public allTables: TablePermission[] = []; + public tablesLoading: boolean = true; + public cedarPolicyModel: object; public codeEditorOptions = { minimap: { enabled: false }, @@ -39,26 +64,80 @@ export class GroupAddDialogComponent implements OnInit { public dialogRef: MatDialogRef, private angulartics2: Angulartics2, private _uiSettings: UiSettingsService, + private _tablesService: TablesService, + private _editorService: CodeEditorService, ) { this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; + this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco)); } ngOnInit(): void { this.connectionID = this._connections.currentConnectionID; this._usersService.cast.subscribe(); this.cedarPolicyModel = { - language: 'plaintext', + language: 'cedar', uri: 'cedar-policy-create.cedar', value: this.cedarPolicy, }; + + this._tablesService.fetchTables(this.connectionID).subscribe((tables) => { + this.allTables = tables.map((t) => ({ + tableName: t.table, + display_name: t.display_name || normalizeTableName(t.table), + accessLevel: { + visibility: false, + readonly: false, + add: false, + delete: false, + edit: false, + }, + })); + this.availableTables = tables.map((t) => ({ + tableName: t.table, + displayName: t.display_name || normalizeTableName(t.table), + })); + this.tablesLoading = false; + }); } onCedarPolicyChange(value: string) { this.cedarPolicy = value; } + onPolicyItemsChange(items: CedarPolicyItem[]) { + this.policyItems = items; + } + + onEditorModeChange(mode: 'form' | 'code') { + if (mode === this.editorMode) return; + + if (mode === 'code') { + // Form → Code: convert policy items to cedar text + const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, '__new__', this.allTables); + this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions); + this.cedarPolicyModel = { + language: 'cedar', + uri: `cedar-policy-create-${Date.now()}.cedar`, + value: this.cedarPolicy, + }; + } else { + // Code → Form: parse cedar text into policy items + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, '__new__', this.allTables); + this.policyItems = permissionsToPolicyItems(parsed); + } + + this.editorMode = mode; + } + addGroup() { this.submitting = true; + + // If in form mode, generate cedar policy from policy items + if (this.editorMode === 'form') { + const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, '__new__', this.allTables); + this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions); + } + this._usersService.createUsersGroup(this.connectionID, this.groupTitle, this.cedarPolicy || null).subscribe( () => { this.submitting = false; diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css index 6f5509a77..8133ce193 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css @@ -10,7 +10,14 @@ display: block; font-size: 14px; color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); - margin-bottom: 8px; + margin-bottom: 0; +} + +.editor-mode-toggle { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; } .code-editor-box { diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html index 6e6769ae3..edb29d26b 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html @@ -8,8 +8,24 @@

Edit group

- -
+
+ + + Form + Code + +
+ +
+ + +
+ +
{ let component: GroupNameEditDialogComponent; let fixture: ComponentFixture; + let tablesService: TablesService; const mockDialogRef = { close: () => {}, }; + const fakeTables = [ + { + table: 'customers', + display_name: 'Customers', + permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, + }, + { + table: 'orders', + display_name: 'Orders', + permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, + }, + ]; + + const cedarPolicyWithConnection = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:read",', + ' resource == RocketAdmin::Connection::"conn-123"', + ');', + ].join('\n'); + beforeEach(() => { TestBed.configureTestingModule({ - imports: [MatDialogModule, MatSnackBarModule, FormsModule, BrowserAnimationsModule, GroupNameEditDialogComponent], + imports: [ + MatDialogModule, + MatSnackBarModule, + FormsModule, + BrowserAnimationsModule, + Angulartics2Module.forRoot({}), + GroupNameEditDialogComponent, + ], providers: [ provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: { id: 'test-id', title: 'Test Group', cedarPolicy: '' } }, + provideRouter([]), + { + provide: MAT_DIALOG_DATA, + useValue: { id: 'test-id', title: 'Test Group', cedarPolicy: cedarPolicyWithConnection }, + }, { provide: MatDialogRef, useValue: mockDialogRef }, ], }) @@ -32,6 +69,10 @@ describe('GroupNameEditDialogComponent', () => { add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, }) .compileComponents(); + + tablesService = TestBed.inject(TablesService); + vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); + fixture = TestBed.createComponent(GroupNameEditDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -40,4 +81,27 @@ describe('GroupNameEditDialogComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should load tables on init', () => { + expect(tablesService.fetchTables).toHaveBeenCalled(); + expect(component.allTables.length).toBe(2); + expect(component.availableTables.length).toBe(2); + expect(component.tablesLoading).toBe(false); + }); + + it('should pre-populate policy items from existing cedar policy', () => { + // The cedar policy has connection:read, so policyItems should contain it + expect(component.policyItems.length).toBeGreaterThan(0); + expect(component.policyItems.some((item) => item.action === 'connection:read')).toBe(true); + }); + + it('should start in form mode', () => { + expect(component.editorMode).toBe('form'); + }); + + it('should switch to code mode', () => { + component.onEditorModeChange('code'); + expect(component.editorMode).toBe('code'); + expect(component.cedarPolicy).toBeTruthy(); + }); }); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts index 5192ae99d..8b3674042 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts @@ -1,27 +1,53 @@ import { NgIf } from '@angular/common'; -import { Component, Inject } from '@angular/core'; +import { Component, Inject, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { CodeEditorModule } from '@ngstack/code-editor'; +import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; +import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; +import { generateCedarPolicy } from 'src/app/lib/cedar-policy-generator'; +import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToPermissions } from 'src/app/lib/cedar-policy-items'; +import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; +import { normalizeTableName } from 'src/app/lib/normalize'; +import { TablePermission } from 'src/app/models/user'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { UsersService } from 'src/app/services/users.service'; +import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component'; import { GroupNameEditDialogComponent as Self } from './group-name-edit-dialog.component'; @Component({ selector: 'app-group-name-edit-dialog', templateUrl: './group-name-edit-dialog.component.html', styleUrls: ['./group-name-edit-dialog.component.css'], - imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule, CodeEditorModule], + imports: [ + NgIf, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatButtonToggleModule, + FormsModule, + CodeEditorModule, + CedarPolicyListComponent, + ], }) -export class GroupNameEditDialogComponent { +export class GroupNameEditDialogComponent implements OnInit { public connectionID: string; public groupTitle: string = ''; public cedarPolicy: string = ''; public submitting: boolean = false; + public editorMode: 'form' | 'code' = 'form'; + public policyItems: CedarPolicyItem[] = []; + public availableTables: AvailableTable[] = []; + public allTables: TablePermission[] = []; + public tablesLoading: boolean = true; + public cedarPolicyModel: object; public codeEditorOptions = { minimap: { enabled: false }, @@ -36,27 +62,89 @@ export class GroupNameEditDialogComponent { public _usersService: UsersService, public dialogRef: MatDialogRef, private _uiSettings: UiSettingsService, + private _connections: ConnectionsService, + private _tablesService: TablesService, + private _editorService: CodeEditorService, ) { this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; + this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco)); } ngOnInit(): void { + this.connectionID = this._connections.currentConnectionID; this.groupTitle = this.group.title; this.cedarPolicy = this.group.cedarPolicy || ''; this._usersService.cast.subscribe(); this.cedarPolicyModel = { - language: 'plaintext', + language: 'cedar', uri: `cedar-policy-edit-${this.group.id}.cedar`, value: this.cedarPolicy, }; + + this._tablesService.fetchTables(this.connectionID).subscribe((tables) => { + this.allTables = tables.map((t) => ({ + tableName: t.table, + display_name: t.display_name || normalizeTableName(t.table), + accessLevel: { + visibility: false, + readonly: false, + add: false, + delete: false, + edit: false, + }, + })); + this.availableTables = tables.map((t) => ({ + tableName: t.table, + displayName: t.display_name || normalizeTableName(t.table), + })); + this.tablesLoading = false; + + // Pre-populate form from existing cedar policy + if (this.cedarPolicy) { + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.group.id, this.allTables); + this.policyItems = permissionsToPolicyItems(parsed); + } + }); } onCedarPolicyChange(value: string) { this.cedarPolicy = value; } + onPolicyItemsChange(items: CedarPolicyItem[]) { + this.policyItems = items; + } + + onEditorModeChange(mode: 'form' | 'code') { + if (mode === this.editorMode) return; + + if (mode === 'code') { + // Form → Code: convert policy items to cedar text + const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, this.group.id, this.allTables); + this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions); + this.cedarPolicyModel = { + language: 'cedar', + uri: `cedar-policy-edit-${this.group.id}-${Date.now()}.cedar`, + value: this.cedarPolicy, + }; + } else { + // Code → Form: parse cedar text into policy items + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.group.id, this.allTables); + this.policyItems = permissionsToPolicyItems(parsed); + } + + this.editorMode = mode; + } + addGroup() { this.submitting = true; + + // If in form mode, generate cedar policy from policy items + if (this.editorMode === 'form') { + const permissions = policyItemsToPermissions(this.policyItems, this.connectionID, this.group.id, this.allTables); + this.cedarPolicy = generateCedarPolicy(this.connectionID, permissions); + } + this._usersService.editUsersGroupName(this.group.id, this.groupTitle, this.cedarPolicy || null).subscribe( () => { this.submitting = false; diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css deleted file mode 100644 index 420ed8631..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css +++ /dev/null @@ -1,152 +0,0 @@ -.permissions-header { - display: flex; - align-items: center; - justify-content: space-between; - margin: 16px 0; -} - -.permissions-header::before { - display: none; -} - -.permissions-alert { - --alert-margin: 0 0 20px; - - top: 0; -} - -.permissions-form ::ng-deep .mat-mdc-dialog-content { - color: var(--mat-sidenav-content-text-color); -} - -.permissions { - display: grid; - grid-template-columns: auto 1fr; - grid-column-gap: 60px; - grid-row-gap: 16px; - align-items: center; - margin-bottom: 8px; -} - -.permissions__title { - margin-bottom: 0 !important; -} - -.permissions-toggle-group { - justify-self: flex-start; -} - -.tables-options { - display: flex; - align-items: center; - justify-content: flex-end; -} - -.tables-list { - position: relative; - grid-column: 1 / span 2; - display: grid; - grid-template-columns: 72px 1fr 72px repeat(3, 60px); - grid-row-gap: 8px; - align-items: center; - justify-items: center; - margin: 0 -8px; -} - -.tables-list__header { - grid-column: 1 / -1; - display: grid; - grid-template-columns: subgrid; - justify-items: center; - background-color: var(--color-accentedPalette-100); - padding: 8px 0; -} - -@media (prefers-color-scheme: dark) { - .tables-list__header { - background-color: var(--color-accentedPalette-800); - } -} - -.tables-list__item { - display: contents; - border-bottom: 1px solid rgba(0, 0, 0, 0.12); -} - -.tables-list__divider { - grid-column: 1 / -1; - width: 100%; -} - -.table-name-title { - justify-self: flex-start; - padding-left: 16px; -} - -.table-name { - --normalized-table-name-color: var(--mat-sidenav-content-text-color); - --orginal-table-name-color: rgba(0, 0, 0, 0.6); - - justify-self: flex-start; - display: flex; - flex-direction: column; - gap: 4px; - padding-left: 16px; -} - -@media (prefers-color-scheme: dark) { - .table-name { - --orginal-table-name-color: rgba(255, 255, 255, 0.75); - } -} - -.table-name_disabled { - --normalized-table-name-color: rgba(0, 0, 0, 0.38); - --orginal-table-name-color: rgba(0, 0, 0, 0.38); -} - -@media (prefers-color-scheme: dark) { - .table-name_disabled { - --normalized-table-name-color: rgba(255, 255, 255, 0.38); - --orginal-table-name-color: rgba(255, 255, 255, 0.38); - } -} - -.table-name__title { - color: var(--normalized-table-name-color); - font-size: 16px; - margin-bottom: -6px; -} - -.table-name__line { - color: var(--orginal-table-name-color); - font-size: 12px; -} - -.tables-overlay { - position: absolute; - background: rgba(255, 255, 255, 0.75); - box-sizing: border-box; - padding-top: 12px; - width: 100%; - height: 100%; - z-index: 2; - text-align: center; -} - -.tables-overlay__message { - background: rgba(255, 255, 255, 0.9); - box-shadow: 0 0 8px 8px rgba(255, 255, 255, 0.9); - padding: 8px; - display: block; - max-width: 32%; - margin: 0 auto; -} - -.visibilityIcon { - cursor: pointer; -} - -.visibilityIcon_visible { - color: var(--color-accentedPalette-500); -} diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html deleted file mode 100644 index 715c1e953..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html +++ /dev/null @@ -1,130 +0,0 @@ -
-

- Permissions for {{ group.title }} group - - open_in_new - Docs - -

- - - - -
-

Connection credentials

- - None - ReadOnly - Full access - - -

User management

- - None - ReadOnly - Manage the list - - -

Tables

- Full access -
- - - -
- - -
-
-
-
Visibility
-
Table name
-
ReadOnly
-
Add
-
Delete
-
Edit
-
- -
-
-
- - -
- -
- {{table.display_name}} - {{table.tableName}} -
- - - -
- - -
-
- - -
-
- - -
-
- -
-
-
-
- - - - - - - - - - -
\ No newline at end of file diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts deleted file mode 100644 index 54c1bf3d7..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { forwardRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { AccessLevel } from 'src/app/models/user'; -import { UsersService } from 'src/app/services/users.service'; -import { PermissionsAddDialogComponent } from './permissions-add-dialog.component'; - -describe('PermissionsAddDialogComponent', () => { - let component: PermissionsAddDialogComponent; - let fixture: ComponentFixture; - let usersService: UsersService; - - const mockDialogRef = { - close: () => {}, - }; - - const fakeCustomersPermissionsResponse = { - tableName: 'customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: false, - edit: true, - }, - }; - - const fakeCustomersPermissionsApp = { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: false, - edit: true, - }, - }; - - const fakeOrdersPermissionsResponse = { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }; - - const fakeOrdersPermissionsApp = { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }; - - const fakeTablePermissionsResponse = [fakeCustomersPermissionsResponse, fakeOrdersPermissionsResponse]; - - const fakeTablePermissionsApp = [fakeCustomersPermissionsApp, fakeOrdersPermissionsApp]; - - const fakePermissionsResponse = { - connection: { - connectionId: '5e1092f8-4e50-4e6c-bad9-bd0b04d1af2a', - accessLevel: 'readonly', - }, - group: { - groupId: '77154868-eaf0-4a53-9693-0470182d0971', - accessLevel: 'edit', - }, - tables: fakeTablePermissionsResponse, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - MatRadioModule, - MatSlideToggleModule, - MatCheckboxModule, - MatDialogModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - PermissionsAddDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PermissionsAddDialogComponent), - multi: true, - }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PermissionsAddDialogComponent); - component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); - vi.spyOn(usersService, 'fetchPermission').mockReturnValue(of(fakePermissionsResponse)); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set initial state of permissions', async () => { - vi.spyOn(usersService, 'fetchPermission').mockReturnValue(of(fakePermissionsResponse)); - - component.ngOnInit(); - fixture.detectChanges(); - await fixture.whenStable(); - - // crutch, i don't like it - component.tablesAccess = [...fakeTablePermissionsApp]; - - expect(component.connectionAccess).toEqual('readonly'); - expect(component.groupAccess).toEqual('edit'); - expect(component.tablesAccess).toEqual(fakeTablePermissionsApp); - }); - - it('should uncheck actions if table is readonly', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.uncheckActions(component.tablesAccess[0]); - - expect(component.tablesAccess[0].accessLevel.readonly).toBe(false); - expect(component.tablesAccess[0].accessLevel.add).toBe(false); - expect(component.tablesAccess[0].accessLevel.delete).toBe(false); - expect(component.tablesAccess[0].accessLevel.edit).toBe(false); - }); - - it('should uncheck actions if table is invisible', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.uncheckActions(component.tablesAccess[1]); - - expect(component.tablesAccess[1].accessLevel.readonly).toBe(false); - expect(component.tablesAccess[1].accessLevel.add).toBe(false); - expect(component.tablesAccess[1].accessLevel.delete).toBe(false); - expect(component.tablesAccess[1].accessLevel.edit).toBe(false); - }); - - it('should select all tables', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.grantFullTableAccess(); - - expect(component.tablesAccess).toEqual([ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ]); - }); - - it('should deselect all tables', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.deselectAllTables(); - - expect(component.tablesAccess).toEqual([ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }, - ]); - }); - - it('should call add permissions service', () => { - component.connectionID = '12345678'; - component.connectionAccess = AccessLevel.Readonly; - component.group.id = '12345678-123'; - component.groupAccess = AccessLevel.Edit; - component.tablesAccess = [ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ]; - - const fakseUpdatePermission = vi.spyOn(usersService, 'updatePermission').mockReturnValue(of()); - vi.spyOn(mockDialogRef, 'close'); - - component.addPermissions(); - - expect(fakseUpdatePermission).toHaveBeenCalledWith('12345678', { - connection: { - connectionId: '12345678', - accessLevel: AccessLevel.Readonly, - }, - group: { - groupId: '12345678-123', - accessLevel: AccessLevel.Edit, - }, - tables: [ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ], - }); - // expect(component.dialogRef.close).toHaveBeenCalled(); - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts deleted file mode 100644 index ad188ea68..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { AlertActionType, AlertType } from 'src/app/models/alert'; -import { AccessLevel, TablePermission, UserGroup } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { UsersService } from 'src/app/services/users.service'; -import { AlertComponent } from '../../ui-components/alert/alert.component'; -import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; -import { GroupAddDialogComponent } from '../group-add-dialog/group-add-dialog.component'; -import { GroupDeleteDialogComponent } from '../group-delete-dialog/group-delete-dialog.component'; - -@Component({ - selector: 'app-permissions-add-dialog', - imports: [ - CommonModule, - FormsModule, - MatDialogModule, - MatButtonModule, - MatButtonToggleModule, - MatCheckboxModule, - MatIconModule, - MatSlideToggleModule, - MatTooltipModule, - MatDividerModule, - ContentLoaderComponent, - AlertComponent, - ], - templateUrl: './permissions-add-dialog.component.html', - styleUrls: ['./permissions-add-dialog.component.css'], -}) -export class PermissionsAddDialogComponent implements OnInit { - public submitting: boolean = false; - public loading: boolean = true; - public connectionAccess: AccessLevel; - public groupAccess: AccessLevel; - public tablesAccessOptions = 'select'; - public tablesAccess: TablePermission[] = []; - public connectionID: string; - public adminGroupAlert = { - id: 10000, - type: AlertType.Info, - message: "Admin group permissions can't be changed, create a new group to configure.", - actions: [ - { - type: AlertActionType.Button, - caption: 'New group', - action: () => this.handleOpenNewGroupPopup(), - }, - ], - }; - public connectionFullAccessAlert = { - id: 10000, - type: AlertType.Info, - message: - "Connection full access automatically means full access to group management, view, add, edit and delete all tables' rows.", - }; - - constructor( - @Inject(MAT_DIALOG_DATA) public group: UserGroup, - private _usersService: UsersService, - public dialogRef: MatDialogRef, - public dialog: MatDialog, - private _connections: ConnectionsService, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this._usersService.fetchPermission(this.connectionID, this.group.id).subscribe((res) => { - this.connectionAccess = res.connection.accessLevel; - this.groupAccess = res.group.accessLevel; - this.tablesAccess = res.tables.map((table) => { - return { ...table, display_name: table.display_name || normalizeTableName(table.tableName) }; - }); - this.loading = false; - - if (this.group.title === 'Admin') this.grantFullTableAccess(); - }); - } - - uncheckActions(table: TablePermission) { - if (!table.accessLevel.visibility) table.accessLevel.readonly = false; - table.accessLevel.add = false; - table.accessLevel.delete = false; - table.accessLevel.edit = false; - } - - handleOpenNewGroupPopup() { - this.dialogRef.close('add_group'); - this.dialog.open(GroupAddDialogComponent, { - width: '40em', - }); - } - - grantFullTableAccess() { - this.tablesAccess.forEach((table) => { - table.accessLevel.add = true; - table.accessLevel.delete = true; - table.accessLevel.edit = true; - table.accessLevel.readonly = false; - table.accessLevel.visibility = true; - }); - } - - deselectAllTables() { - this.tablesAccess.forEach((table) => { - table.accessLevel.add = false; - table.accessLevel.delete = false; - table.accessLevel.edit = false; - table.accessLevel.readonly = false; - table.accessLevel.visibility = false; - }); - } - - handleConnectionAccessChange() { - if (this.connectionAccess === 'edit') { - this.groupAccess = AccessLevel.Edit; - this.grantFullTableAccess(); - } else { - this.deselectAllTables(); - } - } - - onVisibilityChange(event: Event, index: number) { - if (!this.tablesAccess[index].accessLevel.visibility) { - event.preventDefault(); - this.tablesAccess[index].accessLevel.readonly = !this.tablesAccess[index].accessLevel.readonly; - } - this.tablesAccess[index].accessLevel.visibility = true; - } - - onRecordActionPermissionChange(action: string, index: number) { - this.tablesAccess[index].accessLevel[action] = !this.tablesAccess[index].accessLevel[action]; - this.tablesAccess[index].accessLevel.readonly = false; - this.tablesAccess[index].accessLevel.visibility = true; - } - - addPermissions() { - this.submitting = true; - let permissions = { - connection: { - connectionId: this.connectionID, - accessLevel: this.connectionAccess, - }, - group: { - groupId: this.group.id, - accessLevel: this.groupAccess, - }, - tables: this.tablesAccess, - }; - this._usersService.updatePermission(this.connectionID, permissions).subscribe( - () => { - this.dialogRef.close(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'User groups: user group permissions were updated successfully', - }); - posthog.capture('User groups: user group permissions were updated successfully'); - }, - () => {}, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html index 541379c00..f1f5c5673 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -1,7 +1,7 @@

User groups

- - - - + + diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts new file mode 100644 index 000000000..16e4a1d38 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts @@ -0,0 +1,103 @@ +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; +import { CodeEditorModule } from '@ngstack/code-editor'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { TablesService } from 'src/app/services/tables.service'; +import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; +import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog.component'; + +describe('CedarPolicyEditorDialogComponent', () => { + let component: CedarPolicyEditorDialogComponent; + let fixture: ComponentFixture; + let tablesService: TablesService; + + const mockDialogRef = { + close: () => {}, + }; + + const fakeTables = [ + { + table: 'customers', + display_name: 'Customers', + permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, + }, + { + table: 'orders', + display_name: 'Orders', + permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, + }, + ]; + + const cedarPolicyWithConnection = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:read",', + ' resource == RocketAdmin::Connection::"conn-123"', + ');', + ].join('\n'); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + MatDialogModule, + MatSnackBarModule, + BrowserAnimationsModule, + Angulartics2Module.forRoot({}), + CedarPolicyEditorDialogComponent, + ], + providers: [ + provideHttpClient(), + provideRouter([]), + { + provide: MAT_DIALOG_DATA, + useValue: { groupId: 'group-123', groupTitle: 'Test Group', cedarPolicy: cedarPolicyWithConnection }, + }, + { provide: MatDialogRef, useValue: mockDialogRef }, + ], + }) + .overrideComponent(CedarPolicyEditorDialogComponent, { + remove: { imports: [CodeEditorModule] }, + add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + + tablesService = TestBed.inject(TablesService); + vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); + + fixture = TestBed.createComponent(CedarPolicyEditorDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load tables on init', () => { + expect(tablesService.fetchTables).toHaveBeenCalled(); + expect(component.allTables.length).toBe(2); + expect(component.availableTables.length).toBe(2); + expect(component.tablesLoading).toBe(false); + }); + + it('should pre-populate policy items from existing cedar policy', () => { + expect(component.policyItems.length).toBeGreaterThan(0); + expect(component.policyItems.some((item) => item.action === 'connection:read')).toBe(true); + }); + + it('should start in form mode', () => { + expect(component.editorMode).toBe('form'); + }); + + it('should switch to code mode', () => { + component.onEditorModeChange('code'); + expect(component.editorMode).toBe('code'); + expect(component.cedarPolicy).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts new file mode 100644 index 000000000..470a9da05 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts @@ -0,0 +1,148 @@ +import { NgIf } from '@angular/common'; +import { Component, Inject, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; +import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; +import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items'; +import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; +import { normalizeTableName } from 'src/app/lib/normalize'; +import { TablePermission } from 'src/app/models/user'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; +import { UiSettingsService } from 'src/app/services/ui-settings.service'; +import { UsersService } from 'src/app/services/users.service'; +import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component'; +import { CedarPolicyEditorDialogComponent as Self } from './cedar-policy-editor-dialog.component'; + +export interface CedarPolicyEditorDialogData { + groupId: string; + groupTitle: string; + cedarPolicy?: string | null; +} + +@Component({ + selector: 'app-cedar-policy-editor-dialog', + templateUrl: './cedar-policy-editor-dialog.component.html', + styleUrls: ['./cedar-policy-editor-dialog.component.css'], + imports: [NgIf, MatDialogModule, MatButtonModule, MatButtonToggleModule, CodeEditorModule, CedarPolicyListComponent], +}) +export class CedarPolicyEditorDialogComponent implements OnInit { + public connectionID: string; + public cedarPolicy: string = ''; + public submitting: boolean = false; + + public editorMode: 'form' | 'code' = 'form'; + public policyItems: CedarPolicyItem[] = []; + public availableTables: AvailableTable[] = []; + public allTables: TablePermission[] = []; + public tablesLoading: boolean = true; + + public cedarPolicyModel: object; + public codeEditorOptions = { + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + wordWrap: 'on', + }; + public codeEditorTheme: string; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: CedarPolicyEditorDialogData, + public dialogRef: MatDialogRef, + private _connections: ConnectionsService, + private _usersService: UsersService, + private _uiSettings: UiSettingsService, + private _tablesService: TablesService, + private _editorService: CodeEditorService, + ) { + this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; + this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco)); + } + + ngOnInit(): void { + this.connectionID = this._connections.currentConnectionID; + this.cedarPolicy = this.data.cedarPolicy || ''; + this.cedarPolicyModel = { + language: 'cedar', + uri: `cedar-policy-${this.data.groupId}.cedar`, + value: this.cedarPolicy, + }; + + this._tablesService.fetchTables(this.connectionID).subscribe((tables) => { + this.allTables = tables.map((t) => ({ + tableName: t.table, + display_name: t.display_name || normalizeTableName(t.table), + accessLevel: { + visibility: false, + readonly: false, + add: false, + delete: false, + edit: false, + }, + })); + this.availableTables = tables.map((t) => ({ + tableName: t.table, + displayName: t.display_name || normalizeTableName(t.table), + })); + this.tablesLoading = false; + + if (this.cedarPolicy) { + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); + this.policyItems = permissionsToPolicyItems(parsed); + } + }); + } + + onCedarPolicyChange(value: string) { + this.cedarPolicy = value; + } + + onPolicyItemsChange(items: CedarPolicyItem[]) { + this.policyItems = items; + } + + onEditorModeChange(mode: 'form' | 'code') { + if (mode === this.editorMode) return; + + if (mode === 'code') { + this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId); + this.cedarPolicyModel = { + language: 'cedar', + uri: `cedar-policy-${this.data.groupId}-${Date.now()}.cedar`, + value: this.cedarPolicy, + }; + } else { + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); + this.policyItems = permissionsToPolicyItems(parsed); + } + + this.editorMode = mode; + } + + savePolicy() { + this.submitting = true; + + if (this.editorMode === 'form') { + this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId); + } + + if (!this.cedarPolicy) { + this.submitting = false; + this.dialogRef.close(); + return; + } + + this._usersService.saveCedarPolicy(this.connectionID, this.data.groupId, this.cedarPolicy).subscribe( + () => { + this.submitting = false; + this.dialogRef.close(); + }, + () => {}, + () => { + this.submitting = false; + }, + ); + } +} diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css index 8133ce193..fcaa8cf92 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css @@ -1,28 +1,3 @@ .mat-mdc-form-field { width: 100%; } - -.cedar-policy-section { - margin-top: 8px; -} - -.cedar-policy-label { - display: block; - font-size: 14px; - color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); - margin-bottom: 0; -} - -.editor-mode-toggle { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.code-editor-box { - height: 200px; - border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); - border-radius: 4px; - overflow: hidden; -} diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html index d70fd22b0..dd7483afe 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html @@ -6,33 +6,6 @@

Create group of users

Title should not be empty. - -
-
- - Form - Code - -
- -
- - -
- -
- - -
-
diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts index 70610bac4..12b36c54c 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts @@ -1,5 +1,4 @@ import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; @@ -7,37 +6,20 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; -import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2Module } from 'angulartics2'; import { of } from 'rxjs'; -import { TablesService } from 'src/app/services/tables.service'; import { UsersService } from 'src/app/services/users.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; import { GroupAddDialogComponent } from './group-add-dialog.component'; describe('GroupAddDialogComponent', () => { let component: GroupAddDialogComponent; let fixture: ComponentFixture; let usersService: UsersService; - let tablesService: TablesService; const mockDialogRef = { close: () => {}, }; - const fakeTables = [ - { - table: 'customers', - display_name: 'Customers', - permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, - }, - { - table: 'orders', - display_name: 'Orders', - permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, - }, - ]; - beforeEach(() => { TestBed.configureTestingModule({ imports: [ @@ -54,20 +36,13 @@ describe('GroupAddDialogComponent', () => { { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: MatDialogRef, useValue: mockDialogRef }, ], - }) - .overrideComponent(GroupAddDialogComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); + }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(GroupAddDialogComponent); component = fixture.componentInstance; usersService = TestBed.inject(UsersService); - tablesService = TestBed.inject(TablesService); - vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); fixture.detectChanges(); }); @@ -83,47 +58,7 @@ describe('GroupAddDialogComponent', () => { component.addGroup(); - expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers', null); + expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers'); expect(component.submitting).toBe(false); }); - - it('should load tables on init', () => { - expect(tablesService.fetchTables).toHaveBeenCalled(); - expect(component.allTables.length).toBe(2); - expect(component.availableTables.length).toBe(2); - expect(component.tablesLoading).toBe(false); - }); - - it('should start in form mode', () => { - expect(component.editorMode).toBe('form'); - }); - - it('should switch to code mode and generate cedar policy from policy items', () => { - component.policyItems = [{ action: 'connection:read' }, { action: 'group:edit' }]; - - component.onEditorModeChange('code'); - - expect(component.editorMode).toBe('code'); - expect(component.cedarPolicy).toContain('connection:read'); - expect(component.cedarPolicy).toContain('group:edit'); - }); - - it('should switch back to form mode and parse cedar policy into items', () => { - component.connectionID = 'test-conn'; - component.cedarPolicy = `permit(\n principal,\n action == RocketAdmin::Action::"connection:read",\n resource == RocketAdmin::Connection::"test-conn"\n);`; - - component.onEditorModeChange('code'); - component.onEditorModeChange('form'); - - expect(component.editorMode).toBe('form'); - }); - - it('should add and remove policy items', () => { - component.onPolicyItemsChange([{ action: '*' }]); - expect(component.policyItems.length).toBe(1); - expect(component.policyItems[0].action).toBe('*'); - - component.onPolicyItemsChange([]); - expect(component.policyItems.length).toBe(0); - }); }); diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts index 7cf912e02..9d0f2f249 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts @@ -2,140 +2,40 @@ import { NgIf } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; -import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; -import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items'; -import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TablePermission } from 'src/app/models/user'; import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { UsersService } from 'src/app/services/users.service'; -import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component'; @Component({ selector: 'app-group-add-dialog', - imports: [ - NgIf, - FormsModule, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatButtonToggleModule, - CodeEditorModule, - CedarPolicyListComponent, - ], + imports: [NgIf, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule], templateUrl: './group-add-dialog.component.html', styleUrls: ['./group-add-dialog.component.css'], }) export class GroupAddDialogComponent implements OnInit { public connectionID: string; public groupTitle: string = ''; - public cedarPolicy: string = ''; public submitting: boolean = false; - public editorMode: 'form' | 'code' = 'form'; - public policyItems: CedarPolicyItem[] = []; - public availableTables: AvailableTable[] = []; - public allTables: TablePermission[] = []; - public tablesLoading: boolean = true; - - public cedarPolicyModel: object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - public codeEditorTheme: string; - constructor( private _connections: ConnectionsService, public _usersService: UsersService, public dialogRef: MatDialogRef, private angulartics2: Angulartics2, - private _uiSettings: UiSettingsService, - private _tablesService: TablesService, - private _editorService: CodeEditorService, - ) { - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco)); - } + ) {} ngOnInit(): void { this.connectionID = this._connections.currentConnectionID; this._usersService.cast.subscribe(); - this.cedarPolicyModel = { - language: 'cedar', - uri: 'cedar-policy-create.cedar', - value: this.cedarPolicy, - }; - - this._tablesService.fetchTables(this.connectionID).subscribe((tables) => { - this.allTables = tables.map((t) => ({ - tableName: t.table, - display_name: t.display_name || normalizeTableName(t.table), - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - })); - this.availableTables = tables.map((t) => ({ - tableName: t.table, - displayName: t.display_name || normalizeTableName(t.table), - })); - this.tablesLoading = false; - }); - } - - onCedarPolicyChange(value: string) { - this.cedarPolicy = value; - } - - onPolicyItemsChange(items: CedarPolicyItem[]) { - this.policyItems = items; - } - - onEditorModeChange(mode: 'form' | 'code') { - if (mode === this.editorMode) return; - - if (mode === 'code') { - // Form → Code: convert policy items directly to cedar text - this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, '__new__'); - this.cedarPolicyModel = { - language: 'cedar', - uri: `cedar-policy-create-${Date.now()}.cedar`, - value: this.cedarPolicy, - }; - } else { - // Code → Form: parse cedar text into policy items - const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, '__new__', this.allTables); - this.policyItems = permissionsToPolicyItems(parsed); - } - - this.editorMode = mode; } addGroup() { this.submitting = true; - - // If in form mode, generate cedar policy from policy items - if (this.editorMode === 'form') { - this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, '__new__'); - } - - this._usersService.createUsersGroup(this.connectionID, this.groupTitle, this.cedarPolicy || null).subscribe( + this._usersService.createUsersGroup(this.connectionID, this.groupTitle).subscribe( () => { this.submitting = false; this.dialogRef.close(); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css index 8133ce193..fcaa8cf92 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css @@ -1,28 +1,3 @@ .mat-mdc-form-field { width: 100%; } - -.cedar-policy-section { - margin-top: 8px; -} - -.cedar-policy-label { - display: block; - font-size: 14px; - color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6)); - margin-bottom: 0; -} - -.editor-mode-toggle { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.code-editor-box { - height: 200px; - border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); - border-radius: 4px; - overflow: hidden; -} diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html index ef37069dd..e42551ba0 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html @@ -1,44 +1,17 @@ -

Edit group

+

Change group name

- Group name + Change group name Title should not be empty. - -
-
- - Form - Code - -
- -
- - -
- -
- - -
-
diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts index e143af545..75aeaf474 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts @@ -1,5 +1,4 @@ import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; @@ -7,43 +6,17 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; -import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { TablesService } from 'src/app/services/tables.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; import { GroupNameEditDialogComponent } from './group-name-edit-dialog.component'; describe('GroupNameEditDialogComponent', () => { let component: GroupNameEditDialogComponent; let fixture: ComponentFixture; - let tablesService: TablesService; const mockDialogRef = { close: () => {}, }; - const fakeTables = [ - { - table: 'customers', - display_name: 'Customers', - permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, - }, - { - table: 'orders', - display_name: 'Orders', - permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, - }, - ]; - - const cedarPolicyWithConnection = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"connection:read",', - ' resource == RocketAdmin::Connection::"conn-123"', - ');', - ].join('\n'); - beforeEach(() => { TestBed.configureTestingModule({ imports: [ @@ -59,19 +32,11 @@ describe('GroupNameEditDialogComponent', () => { provideRouter([]), { provide: MAT_DIALOG_DATA, - useValue: { id: 'test-id', title: 'Test Group', cedarPolicy: cedarPolicyWithConnection }, + useValue: { id: 'test-id', title: 'Test Group' }, }, { provide: MatDialogRef, useValue: mockDialogRef }, ], - }) - .overrideComponent(GroupNameEditDialogComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - tablesService = TestBed.inject(TablesService); - vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); + }).compileComponents(); fixture = TestBed.createComponent(GroupNameEditDialogComponent); component = fixture.componentInstance; @@ -81,27 +46,4 @@ describe('GroupNameEditDialogComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); - - it('should load tables on init', () => { - expect(tablesService.fetchTables).toHaveBeenCalled(); - expect(component.allTables.length).toBe(2); - expect(component.availableTables.length).toBe(2); - expect(component.tablesLoading).toBe(false); - }); - - it('should pre-populate policy items from existing cedar policy', () => { - // The cedar policy has connection:read, so policyItems should contain it - expect(component.policyItems.length).toBeGreaterThan(0); - expect(component.policyItems.some((item) => item.action === 'connection:read')).toBe(true); - }); - - it('should start in form mode', () => { - expect(component.editorMode).toBe('form'); - }); - - it('should switch to code mode', () => { - component.onEditorModeChange('code'); - expect(component.editorMode).toBe('code'); - expect(component.cedarPolicy).toBeTruthy(); - }); }); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts index 35670688f..4a07cd8b1 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts @@ -1,148 +1,37 @@ import { NgIf } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; -import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; -import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items'; -import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TablePermission } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { UsersService } from 'src/app/services/users.service'; -import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component'; import { GroupNameEditDialogComponent as Self } from './group-name-edit-dialog.component'; @Component({ selector: 'app-group-name-edit-dialog', templateUrl: './group-name-edit-dialog.component.html', styleUrls: ['./group-name-edit-dialog.component.css'], - imports: [ - NgIf, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatButtonToggleModule, - FormsModule, - CodeEditorModule, - CedarPolicyListComponent, - ], + imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule], }) -export class GroupNameEditDialogComponent implements OnInit { - public connectionID: string; +export class GroupNameEditDialogComponent { public groupTitle: string = ''; - public cedarPolicy: string = ''; public submitting: boolean = false; - public editorMode: 'form' | 'code' = 'form'; - public policyItems: CedarPolicyItem[] = []; - public availableTables: AvailableTable[] = []; - public allTables: TablePermission[] = []; - public tablesLoading: boolean = true; - - public cedarPolicyModel: object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - public codeEditorTheme: string; - constructor( - @Inject(MAT_DIALOG_DATA) public group: { id: string; title: string; cedarPolicy?: string | null }, + @Inject(MAT_DIALOG_DATA) public group: { id: string; title: string }, public _usersService: UsersService, public dialogRef: MatDialogRef, - private _uiSettings: UiSettingsService, - private _connections: ConnectionsService, - private _tablesService: TablesService, - private _editorService: CodeEditorService, - ) { - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco)); - } + ) {} ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; this.groupTitle = this.group.title; - this.cedarPolicy = this.group.cedarPolicy || ''; this._usersService.cast.subscribe(); - this.cedarPolicyModel = { - language: 'cedar', - uri: `cedar-policy-edit-${this.group.id}.cedar`, - value: this.cedarPolicy, - }; - - this._tablesService.fetchTables(this.connectionID).subscribe((tables) => { - this.allTables = tables.map((t) => ({ - tableName: t.table, - display_name: t.display_name || normalizeTableName(t.table), - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - })); - this.availableTables = tables.map((t) => ({ - tableName: t.table, - displayName: t.display_name || normalizeTableName(t.table), - })); - this.tablesLoading = false; - - // Pre-populate form from existing cedar policy - if (this.cedarPolicy) { - const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.group.id, this.allTables); - this.policyItems = permissionsToPolicyItems(parsed); - } - }); - } - - onCedarPolicyChange(value: string) { - this.cedarPolicy = value; - } - - onPolicyItemsChange(items: CedarPolicyItem[]) { - this.policyItems = items; - } - - onEditorModeChange(mode: 'form' | 'code') { - if (mode === this.editorMode) return; - - if (mode === 'code') { - // Form → Code: convert policy items directly to cedar text - this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.group.id); - this.cedarPolicyModel = { - language: 'cedar', - uri: `cedar-policy-edit-${this.group.id}-${Date.now()}.cedar`, - value: this.cedarPolicy, - }; - } else { - // Code → Form: parse cedar text into policy items - const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.group.id, this.allTables); - this.policyItems = permissionsToPolicyItems(parsed); - } - - this.editorMode = mode; } addGroup() { this.submitting = true; - - // If in form mode, generate cedar policy from policy items - if (this.editorMode === 'form') { - this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.group.id); - } - - this._usersService.editUsersGroupName(this.group.id, this.groupTitle, this.cedarPolicy || null).subscribe( + this._usersService.editUsersGroupName(this.group.id, this.groupTitle).subscribe( () => { this.submitting = false; this.dialogRef.close(); diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html index f1f5c5673..6efc9bb28 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -28,6 +28,13 @@

User groups

+ @@ -84,9 +97,19 @@ + + Dashboard + + New dashboards + + {{ dashboard.name }} + + + +
diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts index c0c1025b8..c8f56c03f 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts @@ -12,6 +12,11 @@ describe('CedarPolicyListComponent', () => { { tableName: 'orders', displayName: 'Orders' }, ]; + const fakeDashboards = [ + { id: 'dash-1', name: 'Sales Dashboard' }, + { id: 'dash-2', name: 'Analytics' }, + ]; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule], @@ -20,6 +25,7 @@ describe('CedarPolicyListComponent', () => { fixture = TestBed.createComponent(CedarPolicyListComponent); component = fixture.componentInstance; component.availableTables = fakeTables; + component.availableDashboards = fakeDashboards; fixture.detectChanges(); }); @@ -149,4 +155,38 @@ describe('CedarPolicyListComponent', () => { expect(component.newAction).toBe(''); expect(component.newTableName).toBe(''); }); + + it('should add a dashboard policy with dashboardId', () => { + component.showAddForm = true; + component.newAction = 'dashboard:read'; + component.newDashboardId = 'dash-1'; + component.addPolicy(); + + expect(component.policies.length).toBe(1); + expect(component.policies[0].action).toBe('dashboard:read'); + expect(component.policies[0].dashboardId).toBe('dash-1'); + }); + + it('should not add dashboard policy without dashboard id', () => { + component.showAddForm = true; + component.newAction = 'dashboard:edit'; + component.newDashboardId = ''; + component.addPolicy(); + + expect(component.policies.length).toBe(0); + }); + + it('should detect needsDashboard correctly', () => { + component.newAction = 'connection:read'; + expect(component.needsDashboard).toBe(false); + + component.newAction = 'dashboard:read'; + expect(component.needsDashboard).toBe(true); + }); + + it('should return correct dashboard display names', () => { + expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard'); + expect(component.getDashboardDisplayName('unknown')).toBe('unknown'); + expect(component.getDashboardDisplayName('__new__')).toBe('New dashboards'); + }); }); diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts index b5a039092..f628dff4f 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts @@ -14,6 +14,11 @@ export interface AvailableTable { displayName: string; } +export interface AvailableDashboard { + id: string; + name: string; +} + @Component({ selector: 'app-cedar-policy-list', imports: [ @@ -32,16 +37,19 @@ export interface AvailableTable { export class CedarPolicyListComponent { @Input() policies: CedarPolicyItem[] = []; @Input() availableTables: AvailableTable[] = []; + @Input() availableDashboards: AvailableDashboard[] = []; @Input() loading: boolean = false; @Output() policiesChange = new EventEmitter(); showAddForm = false; newAction = ''; newTableName = ''; + newDashboardId = ''; editingIndex: number | null = null; editAction = ''; editTableName = ''; + editDashboardId = ''; availableActions = POLICY_ACTIONS; actionGroups = POLICY_ACTION_GROUPS; @@ -50,10 +58,18 @@ export class CedarPolicyListComponent { return this.availableActions.find((a) => a.value === this.newAction)?.needsTable ?? false; } + get needsDashboard(): boolean { + return this.availableActions.find((a) => a.value === this.newAction)?.needsDashboard ?? false; + } + get editNeedsTable(): boolean { return this.availableActions.find((a) => a.value === this.editAction)?.needsTable ?? false; } + get editNeedsDashboard(): boolean { + return this.availableActions.find((a) => a.value === this.editAction)?.needsDashboard ?? false; + } + getActionLabel(action: string): string { return this.availableActions.find((a) => a.value === action)?.label || action; } @@ -63,14 +79,23 @@ export class CedarPolicyListComponent { return this.availableTables.find((t) => t.tableName === tableName)?.displayName || tableName; } + getDashboardDisplayName(dashboardId: string): string { + if (dashboardId === '__new__') return 'New dashboards'; + return this.availableDashboards.find((d) => d.id === dashboardId)?.name || dashboardId; + } + addPolicy() { if (!this.newAction) return; if (this.needsTable && !this.newTableName) return; + if (this.needsDashboard && !this.newDashboardId) return; const item: CedarPolicyItem = { action: this.newAction }; if (this.needsTable) { item.tableName = this.newTableName; } + if (this.needsDashboard) { + item.dashboardId = this.newDashboardId; + } this.policies = [...this.policies, item]; this.policiesChange.emit(this.policies); this.resetAddForm(); @@ -85,16 +110,19 @@ export class CedarPolicyListComponent { this.editingIndex = index; this.editAction = this.policies[index].action; this.editTableName = this.policies[index].tableName || ''; + this.editDashboardId = this.policies[index].dashboardId || ''; } saveEdit(index: number) { if (!this.editAction) return; if (this.editNeedsTable && !this.editTableName) return; + if (this.editNeedsDashboard && !this.editDashboardId) return; const updated = [...this.policies]; updated[index] = { action: this.editAction, tableName: this.editNeedsTable ? this.editTableName : undefined, + dashboardId: this.editNeedsDashboard ? this.editDashboardId : undefined, }; this.policies = updated; this.policiesChange.emit(this.policies); @@ -109,5 +137,6 @@ export class CedarPolicyListComponent { this.showAddForm = false; this.newAction = ''; this.newTableName = ''; + this.newDashboardId = ''; } } diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index 36d6cca2d..443d4238b 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -3,12 +3,14 @@ import { AccessLevel, Permissions, TablePermission } from '../models/user'; export interface CedarPolicyItem { action: string; tableName?: string; + dashboardId?: string; } export interface PolicyAction { value: string; label: string; needsTable: boolean; + needsDashboard: boolean; } export interface PolicyActionGroup { @@ -19,30 +21,39 @@ export interface PolicyActionGroup { export const POLICY_ACTION_GROUPS: PolicyActionGroup[] = [ { group: 'General', - actions: [{ value: '*', label: 'Full access (all permissions)', needsTable: false }], + actions: [{ value: '*', label: 'Full access (all permissions)', needsTable: false, needsDashboard: false }], }, { group: 'Connection', actions: [ - { value: 'connection:read', label: 'Connection read', needsTable: false }, - { value: 'connection:edit', label: 'Connection full access', needsTable: false }, + { value: 'connection:read', label: 'Connection read', needsTable: false, needsDashboard: false }, + { value: 'connection:edit', label: 'Connection full access', needsTable: false, needsDashboard: false }, ], }, { group: 'Group', actions: [ - { value: 'group:read', label: 'Group read', needsTable: false }, - { value: 'group:edit', label: 'Group manage', needsTable: false }, + { value: 'group:read', label: 'Group read', needsTable: false, needsDashboard: false }, + { value: 'group:edit', label: 'Group manage', needsTable: false, needsDashboard: false }, ], }, { group: 'Table', actions: [ - { value: 'table:*', label: 'Full table access', needsTable: true }, - { value: 'table:read', label: 'Table read', needsTable: true }, - { value: 'table:add', label: 'Table add', needsTable: true }, - { value: 'table:edit', label: 'Table edit', needsTable: true }, - { value: 'table:delete', label: 'Table delete', needsTable: true }, + { value: 'table:*', label: 'Full table access', needsTable: true, needsDashboard: false }, + { value: 'table:read', label: 'Table read', needsTable: true, needsDashboard: false }, + { value: 'table:add', label: 'Table add', needsTable: true, needsDashboard: false }, + { value: 'table:edit', label: 'Table edit', needsTable: true, needsDashboard: false }, + { value: 'table:delete', label: 'Table delete', needsTable: true, needsDashboard: false }, + ], + }, + { + group: 'Dashboard', + actions: [ + { value: 'dashboard:read', label: 'Dashboard read', needsTable: false, needsDashboard: true }, + { value: 'dashboard:create', label: 'Dashboard create', needsTable: false, needsDashboard: true }, + { value: 'dashboard:edit', label: 'Dashboard edit', needsTable: false, needsDashboard: true }, + { value: 'dashboard:delete', label: 'Dashboard delete', needsTable: false, needsDashboard: true }, ], }, ]; @@ -132,6 +143,9 @@ export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: } else { resource = `resource == RocketAdmin::Table::"${connectionId}/${item.tableName}"`; } + } else if (item.action.startsWith('dashboard:')) { + const dashId = item.dashboardId || '__new__'; + resource = `resource == RocketAdmin::Dashboard::"${connectionId}/${dashId}"`; } else if (item.action.startsWith('group:')) { resource = `resource == RocketAdmin::Group::"${groupId}"`; } else { diff --git a/frontend/src/app/lib/cedar-policy-parser.ts b/frontend/src/app/lib/cedar-policy-parser.ts index 45c83a93b..2a268aa9c 100644 --- a/frontend/src/app/lib/cedar-policy-parser.ts +++ b/frontend/src/app/lib/cedar-policy-parser.ts @@ -1,4 +1,5 @@ import { AccessLevel, Permissions, TablePermission } from '../models/user'; +import { CedarPolicyItem } from './cedar-policy-items'; interface ParsedPermitStatement { action: string | null; @@ -62,6 +63,11 @@ export function parseCedarPolicy( applyTableAction(tableEntry, permit.action); break; } + case 'dashboard:read': + case 'dashboard:create': + case 'dashboard:edit': + case 'dashboard:delete': + break; } } @@ -113,6 +119,30 @@ export function parseCedarPolicy( return result; } +export function parseCedarDashboardItems(policyText: string, connectionId: string): CedarPolicyItem[] { + const permits = extractPermitStatements(policyText); + const items: CedarPolicyItem[] = []; + + for (const permit of permits) { + if (!permit.action || !permit.action.startsWith('dashboard:')) continue; + const dashboardId = extractDashboardId(permit.resourceId, connectionId); + if (dashboardId) { + items.push({ action: permit.action, dashboardId }); + } + } + + return items; +} + +function extractDashboardId(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 extractPermitStatements(policyText: string): ParsedPermitStatement[] { const results: ParsedPermitStatement[] = []; const permitKeyword = 'permit'; From bffa5cfea09f4a1cc1b1e92f1ed62e5588aad64a Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 17 Mar 2026 14:18:07 +0000 Subject: [PATCH 10/15] revert: remove all backend changes from cedar-policy-editor branch Co-Authored-By: Claude Opus 4.6 (1M context) --- .../create-group-in-connection.ds.ts | 1 - .../dto/create-group-in-connection.dto.ts | 7 +------ .../entities/connection/connection.controller.ts | 3 +-- .../create-group-in-connection.use.case.ts | 14 ++++++-------- .../entities/group/dto/found-group-response.dto.ts | 3 --- .../entities/group/dto/update-group-title.dto.ts | 7 +------ .../group/use-cases/update-group-title.use.case.ts | 9 ++------- .../group/utils/biuld-found-group-response.dto.ts | 1 - 8 files changed, 11 insertions(+), 34 deletions(-) diff --git a/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts b/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts index 9066077e9..7dea8f36b 100644 --- a/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts +++ b/backend/src/entities/connection/application/data-structures/create-group-in-connection.ds.ts @@ -2,7 +2,6 @@ export class CreateGroupInConnectionDs { group_parameters: { title: string; connectionId: string; - cedarPolicy?: string | null; }; creation_info: { cognitoUserName: string; diff --git a/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts b/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts index 49aca94a3..e78c052e1 100644 --- a/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts +++ b/backend/src/entities/connection/application/dto/create-group-in-connection.dto.ts @@ -1,14 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; export class CreateGroupInConnectionDTO { @IsNotEmpty() @IsString() @ApiProperty() title: string; - - @IsOptional() - @IsString() - @ApiProperty({ required: false, nullable: true }) - cedarPolicy?: string | null; } diff --git a/backend/src/entities/connection/connection.controller.ts b/backend/src/entities/connection/connection.controller.ts index e2d930066..c34502312 100644 --- a/backend/src/entities/connection/connection.controller.ts +++ b/backend/src/entities/connection/connection.controller.ts @@ -409,7 +409,7 @@ export class ConnectionController { @SlugUuid('connectionId') connectionId: string, @UserId() userId: string, ): Promise { - const { title, cedarPolicy } = groupData; + const { title } = groupData; if (!title) { throw new BadRequestException(Messages.GROUP_TITLE_MISSING); } @@ -417,7 +417,6 @@ export class ConnectionController { group_parameters: { title: title, connectionId: connectionId, - cedarPolicy: cedarPolicy, }, creation_info: { cognitoUserName: userId, diff --git a/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts b/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts index 32a2ea9f4..647066985 100644 --- a/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts +++ b/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts @@ -26,7 +26,7 @@ export class CreateGroupInConnectionUseCase protected async implementation(inputData: CreateGroupInConnectionDs): Promise { const { - group_parameters: { connectionId, title, cedarPolicy }, + group_parameters: { connectionId, title }, creation_info: { cognitoUserName }, } = inputData; const connectionToUpdate = await this._dbContext.connectionRepository.findConnectionWithGroups(connectionId); @@ -36,13 +36,11 @@ export class CreateGroupInConnectionUseCase const foundUser = await this._dbContext.userRepository.findOneUserById(cognitoUserName); const newGroupEntity = buildNewGroupEntityForConnectionWithUser(connectionToUpdate, foundUser, title); const savedGroup = await this._dbContext.groupRepository.saveNewOrUpdatedGroup(newGroupEntity); - savedGroup.cedarPolicy = - cedarPolicy ?? - generateCedarPolicyForGroup(connectionId, false, { - connection: { connectionId, accessLevel: AccessLevelEnum.none }, - group: { groupId: savedGroup.id, accessLevel: AccessLevelEnum.none }, - tables: [], - }); + savedGroup.cedarPolicy = generateCedarPolicyForGroup(connectionId, false, { + connection: { connectionId, accessLevel: AccessLevelEnum.none }, + group: { groupId: savedGroup.id, accessLevel: AccessLevelEnum.none }, + tables: [], + }); await this._dbContext.groupRepository.saveNewOrUpdatedGroup(savedGroup); Cacher.invalidateCedarPolicyCache(connectionId); return buildFoundGroupResponseDto(savedGroup); diff --git a/backend/src/entities/group/dto/found-group-response.dto.ts b/backend/src/entities/group/dto/found-group-response.dto.ts index 5d13a5521..2ce125525 100644 --- a/backend/src/entities/group/dto/found-group-response.dto.ts +++ b/backend/src/entities/group/dto/found-group-response.dto.ts @@ -11,9 +11,6 @@ export class FoundGroupResponseDto { @ApiProperty() isMain: boolean; - @ApiProperty({ required: false, nullable: true }) - cedarPolicy?: string | null; - @ApiProperty({ required: false, isArray: true, type: SimpleFoundUserInfoDs }) users?: Array; } diff --git a/backend/src/entities/group/dto/update-group-title.dto.ts b/backend/src/entities/group/dto/update-group-title.dto.ts index 8b740b1e8..d23237f41 100644 --- a/backend/src/entities/group/dto/update-group-title.dto.ts +++ b/backend/src/entities/group/dto/update-group-title.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; export class UpdateGroupTitleDto { @ApiProperty() @@ -12,9 +12,4 @@ export class UpdateGroupTitleDto { @IsNotEmpty() @IsUUID() groupId: string; - - @IsOptional() - @IsString() - @ApiProperty({ required: false, nullable: true }) - cedarPolicy?: string | null; } diff --git a/backend/src/entities/group/use-cases/update-group-title.use.case.ts b/backend/src/entities/group/use-cases/update-group-title.use.case.ts index dfe131b4f..004d4f439 100644 --- a/backend/src/entities/group/use-cases/update-group-title.use.case.ts +++ b/backend/src/entities/group/use-cases/update-group-title.use.case.ts @@ -3,7 +3,6 @@ import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; import { Messages } from '../../../exceptions/text/messages.js'; -import { Cacher } from '../../../helpers/cache/cacher.js'; import { FoundGroupDataInfoDs } from '../application/data-sctructures/found-user-groups.ds.js'; import { UpdateGroupTitleDto } from '../dto/update-group-title.dto.js'; import { IUpdateGroupTitle } from './use-cases.interfaces.js'; @@ -21,7 +20,7 @@ export class UpdateGroupTitleUseCase } protected async implementation(groupData: UpdateGroupTitleDto): Promise { - const { groupId, title, cedarPolicy } = groupData; + const { groupId, title } = groupData; const groupToUpdate = await this._dbContext.groupRepository.findGroupByIdWithConnectionAndUsers(groupId); if (!groupToUpdate) { throw new HttpException( @@ -35,15 +34,11 @@ export class UpdateGroupTitleUseCase groupToUpdate.connection.id, ); - if (connectionWithGroups.groups.find((group) => group.title === title && group.id !== groupId)) { + if (connectionWithGroups.groups.find((group) => group.title === title)) { throw new BadRequestException(Messages.GROUP_NAME_UNIQUE); } groupToUpdate.title = title; - if (cedarPolicy !== undefined) { - groupToUpdate.cedarPolicy = cedarPolicy; - Cacher.invalidateCedarPolicyCache(groupToUpdate.connection.id); - } const updatedGroup = await this._dbContext.groupRepository.saveNewOrUpdatedGroup(groupToUpdate); return { id: updatedGroup.id, diff --git a/backend/src/entities/group/utils/biuld-found-group-response.dto.ts b/backend/src/entities/group/utils/biuld-found-group-response.dto.ts index 4fd560bc6..63804fef3 100644 --- a/backend/src/entities/group/utils/biuld-found-group-response.dto.ts +++ b/backend/src/entities/group/utils/biuld-found-group-response.dto.ts @@ -7,7 +7,6 @@ export function buildFoundGroupResponseDto(group: GroupEntity): FoundGroupRespon id: group.id, title: group.title, isMain: group.isMain, - cedarPolicy: group.cedarPolicy, users: group.users?.map((user) => buildSimpleUserInfoDs(user)), }; } From e8cbf682f1e3a5988aab242b2db8e72926a83f2a Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 17 Mar 2026 14:26:52 +0000 Subject: [PATCH 11/15] feat: add dashboard:* full access action and use "All dashboards" wildcard Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cedar-policy-list.component.html | 2 +- .../cedar-policy-list.component.spec.ts | 2 +- .../cedar-policy-list/cedar-policy-list.component.ts | 2 +- frontend/src/app/lib/cedar-policy-items.ts | 12 ++++++++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html index 5642292c7..d5b042e33 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html @@ -55,7 +55,7 @@ Dashboard - New dashboards + All dashboards {{ dashboard.name }} diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts index c8f56c03f..bfd4367ea 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts @@ -187,6 +187,6 @@ describe('CedarPolicyListComponent', () => { it('should return correct dashboard display names', () => { expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard'); expect(component.getDashboardDisplayName('unknown')).toBe('unknown'); - expect(component.getDashboardDisplayName('__new__')).toBe('New dashboards'); + expect(component.getDashboardDisplayName('*')).toBe('All dashboards'); }); }); diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts index f628dff4f..1e9fb8dec 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts @@ -80,7 +80,7 @@ export class CedarPolicyListComponent { } getDashboardDisplayName(dashboardId: string): string { - if (dashboardId === '__new__') return 'New dashboards'; + if (dashboardId === '*') return 'All dashboards'; return this.availableDashboards.find((d) => d.id === dashboardId)?.name || dashboardId; } diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index 443d4238b..4ba97690e 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -50,6 +50,7 @@ export const POLICY_ACTION_GROUPS: PolicyActionGroup[] = [ { group: 'Dashboard', actions: [ + { value: 'dashboard:*', label: 'Full dashboard access', needsTable: false, needsDashboard: true }, { value: 'dashboard:read', label: 'Dashboard read', needsTable: false, needsDashboard: true }, { value: 'dashboard:create', label: 'Dashboard create', needsTable: false, needsDashboard: true }, { value: 'dashboard:edit', label: 'Dashboard edit', needsTable: false, needsDashboard: true }, @@ -132,8 +133,8 @@ export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: } const actionRef = - item.action === 'table:*' - ? `action like RocketAdmin::Action::"table:*"` + item.action === 'table:*' || item.action === 'dashboard:*' + ? `action like RocketAdmin::Action::"${item.action}"` : `action == RocketAdmin::Action::"${item.action}"`; let resource: string; @@ -144,8 +145,11 @@ export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: resource = `resource == RocketAdmin::Table::"${connectionId}/${item.tableName}"`; } } else if (item.action.startsWith('dashboard:')) { - const dashId = item.dashboardId || '__new__'; - resource = `resource == RocketAdmin::Dashboard::"${connectionId}/${dashId}"`; + if (item.dashboardId === '*') { + resource = `resource like RocketAdmin::Dashboard::"${connectionId}/*"`; + } else { + resource = `resource == RocketAdmin::Dashboard::"${connectionId}/${item.dashboardId}"`; + } } else if (item.action.startsWith('group:')) { resource = `resource == RocketAdmin::Group::"${groupId}"`; } else { From 9e18c17f93042bd2cff361e3db17246484beca41 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 17 Mar 2026 14:29:06 +0000 Subject: [PATCH 12/15] fix: replace remaining "New dashboards" with "All dashboards" in add form Co-Authored-By: Claude Opus 4.6 (1M context) --- .../users/cedar-policy-list/cedar-policy-list.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html index d5b042e33..97d3d4726 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html @@ -100,7 +100,7 @@ Dashboard - New dashboards + All dashboards {{ dashboard.name }} From b6c4670f7be781a8900e98b57a21007f9bdc07ac Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 17 Mar 2026 15:15:40 +0000 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20simplify=20cedar=20policy=20c?= =?UTF-8?q?ode=20=E2=80=94=20remove=20dead=20code,=20fix=20leaks,=20dedupl?= =?UTF-8?q?icate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete unused cedar-policy-generator.ts - Remove unused policyItemsToPermissions function - Merge duplicate extractTableName/extractDashboardId into extractResourceSuffix - Extract buildResourceRef helper for table/dashboard resource refs - Extract _parseCedarToPolicyItems private method (was duplicated) - Remove pointless forkJoin wrapping single observable - Fix subscription leak: _editorService.loaded now uses take(1) - Add takeUntilDestroyed to fetchTables and saveCedarPolicy subscriptions - Combine duplicate table array iteration into single loop - Remove no-op map((res) => res) from saveCedarPolicy - Fix deprecated positional .subscribe() to object form Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cedar-policy-editor-dialog.component.ts | 99 ++++++++++--------- .../src/app/lib/cedar-policy-generator.ts | 65 ------------ frontend/src/app/lib/cedar-policy-items.ts | 96 ++---------------- frontend/src/app/lib/cedar-policy-parser.ts | 15 +-- frontend/src/app/services/users.service.ts | 1 - 5 files changed, 62 insertions(+), 214 deletions(-) delete mode 100644 frontend/src/app/lib/cedar-policy-generator.ts diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts index 370f5abd5..dfed24700 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts @@ -1,15 +1,15 @@ import { NgIf } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, DestroyRef, Inject, inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; -import { forkJoin } from 'rxjs'; +import { take } from 'rxjs'; import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items'; import { parseCedarDashboardItems, parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; import { normalizeTableName } from 'src/app/lib/normalize'; -import { Dashboard } from 'src/app/models/dashboard'; import { TablePermission } from 'src/app/models/user'; import { ConnectionsService } from 'src/app/services/connections.service'; import { DashboardsService } from 'src/app/services/dashboards.service'; @@ -56,6 +56,8 @@ export class CedarPolicyEditorDialogComponent implements OnInit { }; public codeEditorTheme: string; + private _destroyRef = inject(DestroyRef); + constructor( @Inject(MAT_DIALOG_DATA) public data: CedarPolicyEditorDialogData, public dialogRef: MatDialogRef, @@ -67,7 +69,7 @@ export class CedarPolicyEditorDialogComponent implements OnInit { private _editorService: CodeEditorService, ) { this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco)); + this._editorService.loaded.pipe(take(1)).subscribe(({ monaco }) => registerCedarLanguage(monaco)); } ngOnInit(): void { @@ -81,36 +83,33 @@ export class CedarPolicyEditorDialogComponent implements OnInit { this._dashboardsService.setActiveConnection(this.connectionID); - forkJoin([this._tablesService.fetchTables(this.connectionID)]).subscribe(([tables]) => { - this.allTables = tables.map((t) => ({ - tableName: t.table, - display_name: t.display_name || normalizeTableName(t.table), - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - })); - this.availableTables = tables.map((t) => ({ - tableName: t.table, - displayName: t.display_name || normalizeTableName(t.table), - })); - - this.availableDashboards = this._dashboardsService.dashboards().map((d: Dashboard) => ({ - id: d.id, - name: d.name, - })); - - this.loading = false; - - if (this.cedarPolicy) { - const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); - const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID); - this.policyItems = [...permissionsToPolicyItems(parsed), ...dashboardItems]; - } - }); + this._tablesService + .fetchTables(this.connectionID) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((tables) => { + this.allTables = []; + this.availableTables = []; + for (const t of tables) { + const displayName = t.display_name || normalizeTableName(t.table); + this.allTables.push({ + tableName: t.table, + display_name: displayName, + accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, + }); + this.availableTables.push({ tableName: t.table, displayName }); + } + + this.availableDashboards = this._dashboardsService.dashboards().map((d) => ({ + id: d.id, + name: d.name, + })); + + this.loading = false; + + if (this.cedarPolicy) { + this.policyItems = this._parseCedarToPolicyItems(); + } + }); } onCedarPolicyChange(value: string) { @@ -132,9 +131,7 @@ export class CedarPolicyEditorDialogComponent implements OnInit { value: this.cedarPolicy, }; } else { - const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); - const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID); - this.policyItems = [...permissionsToPolicyItems(parsed), ...dashboardItems]; + this.policyItems = this._parseCedarToPolicyItems(); } this.editorMode = mode; @@ -153,15 +150,23 @@ export class CedarPolicyEditorDialogComponent implements OnInit { return; } - this._usersService.saveCedarPolicy(this.connectionID, this.data.groupId, this.cedarPolicy).subscribe( - () => { - this.submitting = false; - this.dialogRef.close(); - }, - () => {}, - () => { - this.submitting = false; - }, - ); + this._usersService + .saveCedarPolicy(this.connectionID, this.data.groupId, this.cedarPolicy) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe({ + next: () => { + this.submitting = false; + this.dialogRef.close(); + }, + complete: () => { + this.submitting = false; + }, + }); + } + + private _parseCedarToPolicyItems(): CedarPolicyItem[] { + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); + const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID); + return [...permissionsToPolicyItems(parsed), ...dashboardItems]; } } diff --git a/frontend/src/app/lib/cedar-policy-generator.ts b/frontend/src/app/lib/cedar-policy-generator.ts deleted file mode 100644 index b54422338..000000000 --- a/frontend/src/app/lib/cedar-policy-generator.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { AccessLevel, Permissions } from '../models/user'; - -export function generateCedarPolicy(connectionId: string, permissions: Permissions): string { - const policies: string[] = []; - const connectionRef = `RocketAdmin::Connection::"${connectionId}"`; - - // Connection full access → wildcard policy (grants everything) - const connAccess = permissions.connection.accessLevel; - if (connAccess === AccessLevel.Edit) { - policies.push(`permit(\n principal,\n action,\n resource\n);`); - return policies.join('\n\n'); - } - - if (connAccess === AccessLevel.Readonly) { - policies.push( - `permit(\n principal,\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 === AccessLevel.Edit) { - policies.push( - `permit(\n principal,\n action == RocketAdmin::Action::"group:read",\n resource == ${groupResourceRef}\n);`, - ); - policies.push( - `permit(\n principal,\n action == RocketAdmin::Action::"group:edit",\n resource == ${groupResourceRef}\n);`, - ); - } else if (groupAccess === AccessLevel.Readonly) { - policies.push( - `permit(\n principal,\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,\n action == RocketAdmin::Action::"table:read",\n resource == ${tableRef}\n);`, - ); - } - if (access.add) { - policies.push( - `permit(\n principal,\n action == RocketAdmin::Action::"table:add",\n resource == ${tableRef}\n);`, - ); - } - if (access.edit) { - policies.push( - `permit(\n principal,\n action == RocketAdmin::Action::"table:edit",\n resource == ${tableRef}\n);`, - ); - } - if (access.delete) { - policies.push( - `permit(\n principal,\n action == RocketAdmin::Action::"table:delete",\n resource == ${tableRef}\n);`, - ); - } - } - - return policies.join('\n\n'); -} diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index 4ba97690e..2b29237d9 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -1,4 +1,4 @@ -import { AccessLevel, Permissions, TablePermission } from '../models/user'; +import { AccessLevel, Permissions } from '../models/user'; export interface CedarPolicyItem { action: string; @@ -139,17 +139,9 @@ export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: let resource: string; if (item.action.startsWith('table:')) { - if (item.tableName === '*') { - resource = `resource like RocketAdmin::Table::"${connectionId}/*"`; - } else { - resource = `resource == RocketAdmin::Table::"${connectionId}/${item.tableName}"`; - } + resource = buildResourceRef('Table', connectionId, item.tableName); } else if (item.action.startsWith('dashboard:')) { - if (item.dashboardId === '*') { - resource = `resource like RocketAdmin::Dashboard::"${connectionId}/*"`; - } else { - resource = `resource == RocketAdmin::Dashboard::"${connectionId}/${item.dashboardId}"`; - } + resource = buildResourceRef('Dashboard', connectionId, item.dashboardId); } else if (item.action.startsWith('group:')) { resource = `resource == RocketAdmin::Group::"${groupId}"`; } else { @@ -162,83 +154,9 @@ export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: return policies.join('\n\n'); } -export function policyItemsToPermissions( - items: CedarPolicyItem[], - connectionId: string, - groupId: string, - availableTables: TablePermission[], -): Permissions { - const result: Permissions = { - connection: { connectionId, accessLevel: AccessLevel.None }, - group: { groupId, accessLevel: AccessLevel.None }, - tables: availableTables.map((t) => ({ - ...t, - accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, - })), - }; - - for (const item of items) { - if (item.action === '*') { - result.connection.accessLevel = AccessLevel.Edit; - result.group.accessLevel = AccessLevel.Edit; - result.tables.forEach((t) => { - t.accessLevel = { visibility: true, readonly: false, add: true, delete: true, edit: true }; - }); - return result; - } - - switch (item.action) { - case 'connection:read': - if (result.connection.accessLevel === AccessLevel.None) { - result.connection.accessLevel = AccessLevel.Readonly; - } - break; - case 'connection:edit': - result.connection.accessLevel = AccessLevel.Edit; - break; - case 'group:read': - if (result.group.accessLevel === AccessLevel.None) { - result.group.accessLevel = AccessLevel.Readonly; - } - break; - case 'group:edit': - result.group.accessLevel = AccessLevel.Edit; - break; - case 'table:*': - case 'table:read': - case 'table:add': - case 'table:edit': - case 'table:delete': { - if (!item.tableName) break; - const targets = - item.tableName === '*' ? result.tables : result.tables.filter((t) => t.tableName === item.tableName); - for (const table of targets) { - table.accessLevel.visibility = true; - switch (item.action) { - case 'table:*': - table.accessLevel.readonly = true; - table.accessLevel.add = true; - table.accessLevel.edit = true; - table.accessLevel.delete = true; - break; - case 'table:read': - table.accessLevel.readonly = true; - break; - case 'table:add': - table.accessLevel.add = true; - break; - case 'table:edit': - table.accessLevel.edit = true; - break; - case 'table:delete': - table.accessLevel.delete = true; - break; - } - } - break; - } - } +function buildResourceRef(type: string, connectionId: string, id: string | undefined): string { + if (id === '*') { + return `resource like RocketAdmin::${type}::"${connectionId}/*"`; } - - return result; + return `resource == RocketAdmin::${type}::"${connectionId}/${id}"`; } diff --git a/frontend/src/app/lib/cedar-policy-parser.ts b/frontend/src/app/lib/cedar-policy-parser.ts index 2a268aa9c..9809bd8ad 100644 --- a/frontend/src/app/lib/cedar-policy-parser.ts +++ b/frontend/src/app/lib/cedar-policy-parser.ts @@ -57,7 +57,7 @@ export function parseCedarPolicy( case 'table:add': case 'table:edit': case 'table:delete': { - const tableName = extractTableName(permit.resourceId, connectionId); + const tableName = extractResourceSuffix(permit.resourceId, connectionId); if (!tableName) break; const tableEntry = getOrCreateTableEntry(tableMap, tableName); applyTableAction(tableEntry, permit.action); @@ -125,7 +125,7 @@ export function parseCedarDashboardItems(policyText: string, connectionId: strin for (const permit of permits) { if (!permit.action || !permit.action.startsWith('dashboard:')) continue; - const dashboardId = extractDashboardId(permit.resourceId, connectionId); + const dashboardId = extractResourceSuffix(permit.resourceId, connectionId); if (dashboardId) { items.push({ action: permit.action, dashboardId }); } @@ -134,15 +134,6 @@ export function parseCedarDashboardItems(policyText: string, connectionId: strin return items; } -function extractDashboardId(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 extractPermitStatements(policyText: string): ParsedPermitStatement[] { const results: ParsedPermitStatement[] = []; const permitKeyword = 'permit'; @@ -231,7 +222,7 @@ function parsePermitBody(body: string): ParsedPermitStatement { return result; } -function extractTableName(resourceId: string | null, connectionId: string): string | null { +function extractResourceSuffix(resourceId: string | null, connectionId: string): string | null { if (!resourceId) return null; const prefix = `${connectionId}/`; if (resourceId.startsWith(prefix)) { diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 0ed251afa..86307192c 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -67,7 +67,6 @@ export class UsersService { saveCedarPolicy(connectionID: string, groupId: string, cedarPolicy: string) { return this._http.post(`/connection/cedar-policy/${connectionID}`, { cedarPolicy, groupId }).pipe( - map((res) => res), catchError((err) => { console.log(err); this._notifications.showErrorSnackbar(err.error?.message || err.message); From f17ecbaa977fee03a15e66ea45de5eb6aac8dcf3 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 18 Mar 2026 10:06:11 +0000 Subject: [PATCH 14/15] fix: open cedar policy form after group creation and refresh data after policy save Co-Authored-By: Claude Opus 4.6 (1M context) --- .../group-add-dialog.component.ts | 4 ++-- .../src/app/components/users/users.component.ts | 17 +++++++++++++++-- frontend/src/app/services/users.service.ts | 5 +++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts index 9d0f2f249..5e0bbf9f4 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts @@ -36,9 +36,9 @@ export class GroupAddDialogComponent implements OnInit { addGroup() { this.submitting = true; this._usersService.createUsersGroup(this.connectionID, this.groupTitle).subscribe( - () => { + (res) => { this.submitting = false; - this.dialogRef.close(); + this.dialogRef.close(res); this.angulartics2.eventTrack.next({ action: 'User groups: user groups was created successfully', }); diff --git a/frontend/src/app/components/users/users.component.ts b/frontend/src/app/components/users/users.component.ts index 6feb4d545..60b3b1816 100644 --- a/frontend/src/app/components/users/users.component.ts +++ b/frontend/src/app/components/users/users.component.ts @@ -89,7 +89,12 @@ export class UsersComponent implements OnInit, OnDestroy { }); this.usersSubscription = this._usersService.cast.subscribe((arg) => { - if (arg.action === 'add group' || arg.action === 'delete group' || arg.action === 'edit group name') { + if ( + arg.action === 'add group' || + arg.action === 'delete group' || + arg.action === 'edit group name' || + arg.action === 'save policy' + ) { this.getUsersGroups(); } else if (arg.action === 'add user' || arg.action === 'delete user') { this.fetchAndPopulateGroupUsers(arg.groupId).subscribe({ @@ -176,9 +181,17 @@ export class UsersComponent implements OnInit, OnDestroy { openCreateUsersGroupDialog(event) { event.preventDefault(); event.stopImmediatePropagation(); - this.dialog.open(GroupAddDialogComponent, { + const dialogRef = this.dialog.open(GroupAddDialogComponent, { width: '25em', }); + dialogRef.afterClosed().subscribe((createdGroup) => { + if (createdGroup) { + this.dialog.open(CedarPolicyEditorDialogComponent, { + width: '40em', + data: { groupId: createdGroup.id, groupTitle: createdGroup.title, cedarPolicy: null }, + }); + } + }); } openAddUserDialog(group: UserGroup) { diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 86307192c..4818ce135 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -67,6 +67,11 @@ export class UsersService { saveCedarPolicy(connectionID: string, groupId: string, cedarPolicy: string) { return this._http.post(`/connection/cedar-policy/${connectionID}`, { cedarPolicy, groupId }).pipe( + map((res) => { + this.groups.next({ action: 'save policy', groupId }); + this._notifications.showSuccessSnackbar('Cedar policy has been saved.'); + return res; + }), catchError((err) => { console.log(err); this._notifications.showErrorSnackbar(err.error?.message || err.message); From 67e0df3cdd8735b78464ecb09770f60d65ca623a Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 18 Mar 2026 11:08:36 +0000 Subject: [PATCH 15/15] fix: show warning instead of form when cedar policy uses unsupported syntax Detects forbid statements, when/unless clauses, and unknown actions. Falls back to code editor with a warning message. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cedar-policy-editor-dialog.component.css | 16 ++++++++ .../cedar-policy-editor-dialog.component.html | 7 +++- .../cedar-policy-editor-dialog.component.ts | 24 ++++++++++-- frontend/src/app/lib/cedar-policy-parser.ts | 38 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css index 70da71ee6..6427b3615 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css @@ -16,6 +16,22 @@ text-decoration: underline; } +.form-parse-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + margin-bottom: 12px; + border-radius: 4px; + background: var(--mdc-theme-warning-container, #fff3e0); + color: var(--mdc-theme-on-warning-container, #e65100); + font-size: 13px; +} + +.form-parse-warning mat-icon { + flex-shrink: 0; +} + .code-editor-box { height: 300px; border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html index e15f6a415..1a9704896 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html @@ -7,7 +7,12 @@

Policy — {{ data.groupTitle }}

-
+
+ warning + This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. +
+ +