From 215f65d4186a30e616b9e9a8764f1ba93f51b03f Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Thu, 8 May 2025 14:30:53 +0200 Subject: [PATCH 1/8] Add check for data access read rights --- .../backend/src/auth/ProposalAuthorization.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/backend/src/auth/ProposalAuthorization.ts b/apps/backend/src/auth/ProposalAuthorization.ts index cad19c18e0..df182638d1 100644 --- a/apps/backend/src/auth/ProposalAuthorization.ts +++ b/apps/backend/src/auth/ProposalAuthorization.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'tsyringe'; import { Tokens } from '../config/Tokens'; import { CallDataSource } from '../datasources/CallDataSource'; import { FapDataSource } from '../datasources/FapDataSource'; +import { InstrumentDataSource } from '../datasources/InstrumentDataSource'; import { ProposalDataSource } from '../datasources/ProposalDataSource'; import { ReviewDataSource } from '../datasources/ReviewDataSource'; import { StatusDataSource } from '../datasources/StatusDataSource'; @@ -35,6 +36,8 @@ export class ProposalAuthorization { private statusDataSource: StatusDataSource, @inject(Tokens.TechniqueDataSource) private techniqueDataSource: TechniqueDataSource, + @inject(Tokens.InstrumentDataSource) + private instrumentDataSource: InstrumentDataSource, @inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization ) {} @@ -239,6 +242,24 @@ export class ProposalAuthorization { let hasAccess = false; + // Check data access + + const proposalIsntruments = + await this.instrumentDataSource.getInstrumentsByProposalPk( + proposal.primaryKey + ); + proposalIsntruments.forEach((instrument) => { + agent.currentRole?.dataAccess.some((dataAccess) => { + if (dataAccess === instrument.shortCode) { + hasAccess = true; + + return true; + } + }); + }); + if (hasAccess) { + return true; + } switch (currentRole) { case Roles.USER: hasAccess = From cd14f463eebd1a1599148a5631a1fead868f8f4b Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Thu, 8 May 2025 15:08:58 +0200 Subject: [PATCH 2/8] Add new endpoints for role creation --- .../src/auth/ProposalAuthorization.spec.ts | 2 + .../backend/src/datasources/UserDataSource.ts | 4 + .../src/datasources/mockups/FapDataSource.ts | 2 + .../src/datasources/mockups/UserDataSource.ts | 85 +++++++++++++- .../datasources/postgres/UserDataSource.ts | 107 +++++++++++++++++- .../src/datasources/postgres/records.ts | 11 +- .../stfc/StfcUserDataSource.spec.ts | 28 +++-- .../datasources/stfc/StfcUserDataSource.ts | 10 ++ apps/backend/src/models/Role.ts | 4 +- apps/backend/src/mutations/UserMutations.ts | 41 +++++++ apps/backend/src/queries/ProposalQueries.ts | 1 + .../resolvers/mutations/CreateRoleMutation.ts | 57 ++++++++++ .../resolvers/mutations/DeleteRoleMutation.ts | 15 +++ .../resolvers/mutations/UpdateRoleMutation.ts | 60 ++++++++++ apps/backend/src/resolvers/types/Role.ts | 12 +- 15 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/resolvers/mutations/CreateRoleMutation.ts create mode 100644 apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts create mode 100644 apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts diff --git a/apps/backend/src/auth/ProposalAuthorization.spec.ts b/apps/backend/src/auth/ProposalAuthorization.spec.ts index 940b55287a..7aaded459c 100644 --- a/apps/backend/src/auth/ProposalAuthorization.spec.ts +++ b/apps/backend/src/auth/ProposalAuthorization.spec.ts @@ -114,6 +114,8 @@ test('A instrument sci cannot access proposals they are not on', async () => { title: 'Instrument Scientist', shortCode: 'instrument_scientist', description: '', + permissions: [], + dataAccess: [], }, }, 1 diff --git a/apps/backend/src/datasources/UserDataSource.ts b/apps/backend/src/datasources/UserDataSource.ts index 86cc723014..72c195aa07 100644 --- a/apps/backend/src/datasources/UserDataSource.ts +++ b/apps/backend/src/datasources/UserDataSource.ts @@ -3,6 +3,7 @@ import { Institution } from '../models/Institution'; import { Role, Roles } from '../models/Role'; import { BasicUserDetails, User, UserRole } from '../models/User'; import { AddUserRoleArgs } from '../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../resolvers/mutations/CreateUserByEmailInviteMutation'; import { UpdateUserArgs } from '../resolvers/mutations/UpdateUserMutation'; import { UsersArgs } from '../resolvers/queries/UsersQuery'; @@ -97,4 +98,7 @@ export interface UserDataSource { ): Promise; getRoleByShortCode(roleShortCode: Roles): Promise; mergeUsers(fromUserId: number, intoUserId: number): Promise; + createRole(args: CreateRoleArgs): Promise; + updateRole(args: CreateRoleArgs): Promise; + deleteRole(id: number): Promise; } diff --git a/apps/backend/src/datasources/mockups/FapDataSource.ts b/apps/backend/src/datasources/mockups/FapDataSource.ts index bf5f18a4cd..c368b78270 100644 --- a/apps/backend/src/datasources/mockups/FapDataSource.ts +++ b/apps/backend/src/datasources/mockups/FapDataSource.ts @@ -435,6 +435,8 @@ export class FapDataSourceMock implements FapDataSource { shortCode: 'fap_chair', title: 'Fap Chair', description: '', + permissions: [], + dataAccess: [], }; } diff --git a/apps/backend/src/datasources/mockups/UserDataSource.ts b/apps/backend/src/datasources/mockups/UserDataSource.ts index 141d7e9464..f6b582a3d3 100644 --- a/apps/backend/src/datasources/mockups/UserDataSource.ts +++ b/apps/backend/src/datasources/mockups/UserDataSource.ts @@ -8,6 +8,7 @@ import { UserWithRole, } from '../../models/User'; import { AddUserRoleArgs } from '../../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../../resolvers/mutations/CreateUserByEmailInviteMutation'; import { UpdateUserArgs } from '../../resolvers/mutations/UpdateUserMutation'; import { UsersArgs } from '../../resolvers/queries/UsersQuery'; @@ -74,6 +75,8 @@ export const dummyUserOfficerWithRole: UserWithRole = { title: 'User Officer', shortCode: 'user_officer', description: '', + permissions: [], + dataAccess: [], }, }; @@ -111,12 +114,21 @@ export const dummyPrincipalInvestigatorWithRole: UserWithRole = { title: 'Principal investigator', shortCode: 'user', description: '', + permissions: [], + dataAccess: [], }, }; export const dummyUserWithRole: UserWithRole = { ...dummyUser, - currentRole: { id: 1, title: 'User', shortCode: 'user', description: '' }, + currentRole: { + id: 1, + title: 'User', + shortCode: 'user', + description: '', + permissions: [], + dataAccess: [], + }, }; export const dummyFapChairWithRole: UserWithRole = { @@ -126,6 +138,8 @@ export const dummyFapChairWithRole: UserWithRole = { title: 'Fap Chair', shortCode: 'fap_chair', description: '', + permissions: [], + dataAccess: [], }, }; @@ -136,6 +150,8 @@ export const dummyFapSecretaryWithRole: UserWithRole = { title: 'Fap Secretary', shortCode: 'fap_secretary', description: '', + permissions: [], + dataAccess: [], }, }; @@ -146,6 +162,8 @@ export const dummyFapReviewerWithRole: UserWithRole = { title: 'Fap Reviewer', shortCode: 'fap_reviewer', description: '', + permissions: [], + dataAccess: [], }, }; @@ -156,6 +174,8 @@ export const dummySampleReviewer: UserWithRole = { title: 'Experiment Safety Reviewer', shortCode: 'experiment_safety_reviewer', description: '', + permissions: [], + dataAccess: [], }, }; @@ -166,6 +186,8 @@ export const dummyInternalReviewer: UserWithRole = { title: 'Internal Reviewer', shortCode: 'internal_reviewer', description: '', + permissions: [], + dataAccess: [], }, }; @@ -177,6 +199,8 @@ export const dummyInstrumentScientist: UserWithRole = { title: 'Instrument Scientist', shortCode: 'instrument_scientist', description: '', + permissions: [], + dataAccess: [], }, }; @@ -188,6 +212,8 @@ export const dummyVisitorWithRole: UserWithRole = { title: 'Visitor', shortCode: 'user', description: '', + permissions: [], + dataAccess: [], }, }; @@ -245,10 +271,26 @@ export const dummyUserNotOnProposal = new User( export const dummyUserNotOnProposalWithRole: UserWithRole = { ...dummyUserNotOnProposal, - currentRole: { id: 1, title: 'User', shortCode: 'user', description: '' }, + currentRole: { + id: 1, + title: 'User', + shortCode: 'user', + description: '', + permissions: [], + dataAccess: [], + }, }; export class UserDataSourceMock implements UserDataSource { + createRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + updateRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + deleteRole(id: number): Promise { + throw new Error('Method not implemented.'); + } async delete(id: number): Promise { return dummyUser; } @@ -331,6 +373,8 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'user_officer', title: 'User Officer', description: '', + permissions: [], + dataAccess: [], }, ]; } else if (id === dummyInstrumentScientist.id) { @@ -340,6 +384,8 @@ export class UserDataSourceMock implements UserDataSource { title: 'Instrument Scientist', shortCode: 'instrument_scientist', description: '', + permissions: [], + dataAccess: [], }, ]; } else if (id === 1001) { @@ -349,14 +395,32 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'fap_reviewer', title: 'Fap Reviewer', description: '', + permissions: [], + dataAccess: [], }, ]; } else if (id === dummyFapChairWithRole.id) { return [ - { id: 4, shortCode: 'fap_chair', title: 'Fap Chair', description: '' }, + { + id: 4, + shortCode: 'fap_chair', + title: 'Fap Chair', + description: '', + permissions: [], + dataAccess: [], + }, ]; } else { - return [{ id: 2, shortCode: 'user', title: 'User', description: '' }]; + return [ + { + id: 2, + shortCode: 'user', + title: 'User', + description: '', + permissions: [], + dataAccess: [], + }, + ]; } } @@ -367,8 +431,17 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'user_officer', title: 'User Officer', description: '', + permissions: [], + dataAccess: [], + }, + { + id: 2, + shortCode: 'user', + title: 'User', + description: '', + permissions: [], + dataAccess: [], }, - { id: 2, shortCode: 'user', title: 'User', description: '' }, ]; } @@ -482,6 +555,8 @@ export class UserDataSourceMock implements UserDataSource { shortCode: 'user_officer', title: 'User Officer', description: '', + permissions: [], + dataAccess: [], }; } diff --git a/apps/backend/src/datasources/postgres/UserDataSource.ts b/apps/backend/src/datasources/postgres/UserDataSource.ts index a1864dff2f..1310713f9b 100644 --- a/apps/backend/src/datasources/postgres/UserDataSource.ts +++ b/apps/backend/src/datasources/postgres/UserDataSource.ts @@ -13,7 +13,9 @@ import { UserRoleShortCodeMap, } from '../../models/User'; import { AddUserRoleArgs } from '../../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../../resolvers/mutations/CreateUserByEmailInviteMutation'; +import { UpdateRoleArgs } from '../../resolvers/mutations/UpdateRoleMutation'; import { UpdateUserArgs } from '../../resolvers/mutations/UpdateUserMutation'; import { UsersArgs } from '../../resolvers/queries/UsersQuery'; import { UserDataSource } from '../UserDataSource'; @@ -160,7 +162,9 @@ export default class PostgresUserDataSource implements UserDataSource { role.role_id, role.short_code, role.title, - role.description + role.description, + role.permissions, + role.data_access ) ) ); @@ -180,7 +184,9 @@ export default class PostgresUserDataSource implements UserDataSource { role.role_id, role.short_code, role.title, - role.description + role.description, + role.permissions, + role.data_access ) ) ); @@ -766,7 +772,14 @@ export default class PostgresUserDataSource implements UserDataSource { .first() .then( (role: RoleRecord) => - new Role(role.role_id, role.short_code, role.title, role.description) + new Role( + role.role_id, + role.short_code, + role.title, + role.description, + role.permissions, + role.data_access + ) ); } @@ -797,4 +810,92 @@ export default class PostgresUserDataSource implements UserDataSource { }); }); } + + private toPostgresArray(array: string[]): string { + return `{${array.map((item) => `"${item}"`).join(',')}}`; + } + + async createRole(args: CreateRoleArgs): Promise { + const { shortCode, title, description, permissions, dataAccess } = args; + + const postgresPermissions = Array.isArray(permissions) + ? this.toPostgresArray(permissions) + : permissions; + + const postgresDataAccess = Array.isArray(dataAccess) + ? this.toPostgresArray(dataAccess) + : dataAccess; + + const [roleRecord] = await database + .insert({ + short_code: shortCode, + title, + description, + permissions: postgresPermissions, + data_access: postgresDataAccess, + }) + .into('roles') + .returning('*'); + + return new Role( + roleRecord.role_id, + roleRecord.short_code, + roleRecord.title, + roleRecord.description, + roleRecord.permissions, // No need to parse as it's already in array format + roleRecord.data_access // No need to parse as it's already in array format + ); + } + + async updateRole(args: UpdateRoleArgs): Promise { + const { roleID, shortCode, title, description, permissions, dataAccess } = + args; + + const postgresPermissions = Array.isArray(permissions) + ? this.toPostgresArray(permissions) + : permissions; + + const postgresDataAccess = Array.isArray(dataAccess) + ? this.toPostgresArray(dataAccess) + : dataAccess; + + const [roleRecord] = await database + .update({ + title, + description, + permissions: postgresPermissions, + data_access: postgresDataAccess, + }) + .from('roles') + .where('role_id', roleID) + .returning('*'); + + return new Role( + roleRecord.role_id, + roleRecord.short_code, + roleRecord.title, + roleRecord.description, + roleRecord.permissions, // No need to parse as it's already in array format + roleRecord.data_access // No need to parse as it's already in array format + ); + } + + async deleteRole(id: number): Promise { + const [deletedRole] = await database('roles') + .where('role_id', id) + .returning('*'); + + if (!deletedRole) { + return null; + } + + return new Role( + deletedRole.role_id, + deletedRole.short_code, + deletedRole.title, + deletedRole.description, + deletedRole.permissions, + deletedRole.data_access + ); + } } diff --git a/apps/backend/src/datasources/postgres/records.ts b/apps/backend/src/datasources/postgres/records.ts index e84a335dbf..4fb4dfd335 100644 --- a/apps/backend/src/datasources/postgres/records.ts +++ b/apps/backend/src/datasources/postgres/records.ts @@ -262,6 +262,8 @@ export interface RoleRecord { readonly short_code: string; readonly title: string; readonly description: string; + readonly permissions: string[]; // Changed from string to string[] + readonly data_access: string[]; // Changed from string to string[] } export interface ReviewRecord { @@ -1175,7 +1177,14 @@ export const createFapReviewerObject = (fapMember: FapReviewerRecord) => { }; export const createRoleObject = (role: RoleRecord) => { - return new Role(role.role_id, role.short_code, role.title, role.description); + return new Role( + role.role_id, + role.short_code, + role.title, + role.description, + role.permissions, + role.data_access + ); }; export const createVisitObject = (visit: VisitRecord) => { diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts index 2c8c429a6e..8f84d8c094 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.spec.ts @@ -144,9 +144,16 @@ describe('Role tests', () => { const mockGetRoles = jest.spyOn(StfcUserDataSource.prototype, 'getRoles'); mockGetRoles.mockImplementation(() => Promise.resolve([ - new Role(1, Roles.USER, 'User', ''), - new Role(2, Roles.USER_OFFICER, 'User Officer', ''), - new Role(3, Roles.INSTRUMENT_SCIENTIST, 'Instrument Scientist', ''), + new Role(1, Roles.USER, 'User', '', [], []), + new Role(2, Roles.USER_OFFICER, 'User Officer', '', [], []), + new Role( + 3, + Roles.INSTRUMENT_SCIENTIST, + 'Instrument Scientist', + '', + [], + [] + ), ]) ); @@ -169,7 +176,7 @@ describe('Role tests', () => { return expect(roles[0]).toEqual( expect.objectContaining( - new Role(expect.any(Number), Roles.USER, 'User', '') + new Role(expect.any(Number), Roles.USER, 'User', '', [], []) ) ); }); @@ -181,9 +188,16 @@ describe('Role tests', () => { userdataSource.getUserRoles(dummyUserNumber) ).resolves.toEqual( expect.arrayContaining([ - new Role(1, Roles.USER, 'User', ''), - new Role(2, Roles.USER_OFFICER, 'User Officer', ''), - new Role(3, Roles.INSTRUMENT_SCIENTIST, 'Instrument Scientist', ''), + new Role(1, Roles.USER, 'User', '', [], []), + new Role(2, Roles.USER_OFFICER, 'User Officer', '', [], []), + new Role( + 3, + Roles.INSTRUMENT_SCIENTIST, + 'Instrument Scientist', + '', + [], + [] + ), ]) ); }); diff --git a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts index 5eab8c5558..8f67b1f4a8 100644 --- a/apps/backend/src/datasources/stfc/StfcUserDataSource.ts +++ b/apps/backend/src/datasources/stfc/StfcUserDataSource.ts @@ -7,6 +7,7 @@ import { Institution } from '../../models/Institution'; import { Role, Roles } from '../../models/Role'; import { BasicUserDetails, User } from '../../models/User'; import { AddUserRoleArgs } from '../../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../../resolvers/mutations/CreateUserByEmailInviteMutation'; import { UpdateUserArgs } from '../../resolvers/mutations/UpdateUserMutation'; import { UsersArgs } from '../../resolvers/queries/UsersQuery'; @@ -135,6 +136,15 @@ function toEssUser(stfcUser: StfcBasicPersonDetails): User { } export class StfcUserDataSource implements UserDataSource { + createRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + updateRole(args: CreateRoleArgs): Promise { + throw new Error('Method not implemented.'); + } + deleteRole(id: number): Promise { + throw new Error('Method not implemented.'); + } private static readonly userDetailsCacheMaxElements = 1000; private static readonly userDetailsCacheSecondsToLive = 600; // 10 minutes private static readonly rolesCacheMaxElements = 1000; diff --git a/apps/backend/src/models/Role.ts b/apps/backend/src/models/Role.ts index 409c33a888..66540a014c 100644 --- a/apps/backend/src/models/Role.ts +++ b/apps/backend/src/models/Role.ts @@ -3,7 +3,9 @@ export class Role { public id: number, public shortCode: string, public title: string, - public description: string + public description: string, + public permissions: string[], // New field for permissions + public dataAccess: string[] // New field for data access ) {} } diff --git a/apps/backend/src/mutations/UserMutations.ts b/apps/backend/src/mutations/UserMutations.ts index a2b65d902b..c6e4fb2e69 100644 --- a/apps/backend/src/mutations/UserMutations.ts +++ b/apps/backend/src/mutations/UserMutations.ts @@ -26,7 +26,9 @@ import { UserWithRole, } from '../models/User'; import { AddUserRoleArgs } from '../resolvers/mutations/AddUserRoleMutation'; +import { CreateRoleArgs } from '../resolvers/mutations/CreateRoleMutation'; import { CreateUserByEmailInviteArgs } from '../resolvers/mutations/CreateUserByEmailInviteMutation'; +import { UpdateRoleArgs } from '../resolvers/mutations/UpdateRoleMutation'; import { UpdateUserArgs, UpdateUserRolesArgs, @@ -456,4 +458,43 @@ export default class UserMutations { ): Promise { return this.dataSource.setUserNotPlaceholder(id); } + + @Authorized([Roles.USER_OFFICER]) + async createRole( + _: UserWithRole | null, + args: CreateRoleArgs + ): Promise { + try { + const role = await this.dataSource.createRole(args); + + return role; + } catch (err) { + return rejection('Could not create role', { args }, err); + } + } + + @Authorized([Roles.USER_OFFICER]) + async updateRole( + _: UserWithRole | null, + args: UpdateRoleArgs + ): Promise { + try { + const role = await this.dataSource.updateRole(args); + + return role; + } catch (err) { + return rejection('Could not update role', { args }, err); + } + } + + @Authorized([Roles.USER_OFFICER]) + async deleteRole(_: UserWithRole | null, id: number): Promise { + try { + const role = await this.dataSource.deleteRole(id); + + return role; + } catch (err) { + return null; + } + } } diff --git a/apps/backend/src/queries/ProposalQueries.ts b/apps/backend/src/queries/ProposalQueries.ts index 909d2797b4..37e330b1f0 100644 --- a/apps/backend/src/queries/ProposalQueries.ts +++ b/apps/backend/src/queries/ProposalQueries.ts @@ -102,6 +102,7 @@ export default class ProposalQueries { try { // leave await here because getProposalsFromView might thrown an exception // and we want to handle it here + return await this.dataSource.getProposalsFromView( filter, first, diff --git a/apps/backend/src/resolvers/mutations/CreateRoleMutation.ts b/apps/backend/src/resolvers/mutations/CreateRoleMutation.ts new file mode 100644 index 0000000000..539fd1a83e --- /dev/null +++ b/apps/backend/src/resolvers/mutations/CreateRoleMutation.ts @@ -0,0 +1,57 @@ +import { Field, InputType, ObjectType } from 'type-graphql'; +import { Resolver, Mutation, Arg, Ctx } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Role } from '../types/Role'; // Adjust the path as necessary + +@InputType() +export class CreateRoleArgs { + @Field() + shortCode: string; + + @Field() + title: string; + + @Field() + description: string; + + @Field(() => [String]) + permissions: string[]; + + @Field(() => [String]) + dataAccess: string[]; +} + +@ObjectType() +export class CreateRoleResponse { + @Field() + success: boolean; + + @Field(() => Role, { nullable: true }) + role?: Role; +} + +@Resolver() +export class CreateRoleMutation { + @Mutation(() => CreateRoleResponse) + async createRole( + @Arg('args') args: CreateRoleArgs, + @Ctx() context: ResolverContext + ): Promise { + const role = (await context.mutations.user.createRole( + context.user, + args + )) as Role | Error; + if (role instanceof Error) { + return { + success: false, + role: undefined, + }; + } + + return { + success: true, + role, + }; + } +} diff --git a/apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts b/apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts new file mode 100644 index 0000000000..e3361d2ce0 --- /dev/null +++ b/apps/backend/src/resolvers/mutations/DeleteRoleMutation.ts @@ -0,0 +1,15 @@ +import { Arg, Ctx, Int, Mutation, Resolver } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Role } from '../types/Role'; + +@Resolver() +export class DeleteRoleMutation { + @Mutation(() => Role) + deleteRole( + @Arg('roleId', () => Int) roleId: number, + @Ctx() context: ResolverContext + ) { + return context.mutations.user.deleteRole(context.user, roleId); + } +} diff --git a/apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts b/apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts new file mode 100644 index 0000000000..096057122e --- /dev/null +++ b/apps/backend/src/resolvers/mutations/UpdateRoleMutation.ts @@ -0,0 +1,60 @@ +import { Field, InputType, Int, ObjectType } from 'type-graphql'; +import { Resolver, Mutation, Arg, Ctx } from 'type-graphql'; + +import { ResolverContext } from '../../context'; +import { Role } from '../types/Role'; // Adjust the path as necessary + +@InputType() +export class UpdateRoleArgs { + @Field(() => Int) + public roleID: number; + + @Field() + shortCode: string; + + @Field() + title: string; + + @Field() + description: string; + + @Field(() => [String]) + permissions: string[]; + + @Field(() => [String]) + dataAccess: string[]; +} + +@ObjectType() +export class UpdateRoleResponse { + @Field() + success: boolean; + + @Field(() => Role, { nullable: true }) + role?: Role; +} + +@Resolver() +export class UpdateRoleMutation { + @Mutation(() => UpdateRoleResponse) + async updateRole( + @Arg('args') args: UpdateRoleArgs, + @Ctx() context: ResolverContext + ): Promise { + const role = (await context.mutations.user.updateRole( + context.user, + args + )) as Role | Error; + if (role instanceof Error) { + return { + success: false, + role: undefined, + }; + } + + return { + success: true, + role, + }; + } +} diff --git a/apps/backend/src/resolvers/types/Role.ts b/apps/backend/src/resolvers/types/Role.ts index 09e9366e54..7c32802a2f 100644 --- a/apps/backend/src/resolvers/types/Role.ts +++ b/apps/backend/src/resolvers/types/Role.ts @@ -1,8 +1,10 @@ import 'reflect-metadata'; import { Field, ObjectType, Int } from 'type-graphql'; +import { Role as RoleOrigin } from '../../models/Role'; + @ObjectType() -export class Role { +export class Role implements Partial { @Field(() => Int) public id: number; @@ -15,11 +17,19 @@ export class Role { @Field() public description: string; + @Field(() => [String]) + public dataAccess: string[]; + + @Field(() => [String]) + public permissions: string[]; + constructor(initObj: { id: number; shortCode: string; title: string; description: string; + dataAccess: string[]; + permissions: string[]; }) { Object.assign(this, initObj); } From a17a6b8f7ff7b7c477f5fcfab54ae98a81ad678f Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Thu, 8 May 2025 15:09:30 +0200 Subject: [PATCH 3/8] Add a check for permissions --- apps/backend/src/decorators/Authorized.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/decorators/Authorized.ts b/apps/backend/src/decorators/Authorized.ts index ae638ed088..0667819bbe 100644 --- a/apps/backend/src/decorators/Authorized.ts +++ b/apps/backend/src/decorators/Authorized.ts @@ -17,7 +17,6 @@ const Authorized = (roles: Roles[] = []) => { } ) => { const originalMethod = descriptor.value; - descriptor.value = async function (...args) { const [agent] = args; const isMutation = target.constructor.name.includes('Mutation'); @@ -57,6 +56,14 @@ const Authorized = (roles: Roles[] = []) => { (role) => role === agent.currentRole?.shortCode ); + //check if user has dynamic role with permissions for this method + if ( + agent.currentRole?.permissions.some( + (permission) => permission === `${target.constructor.name}.${name}` + ) + ) { + return await originalMethod?.apply(this, args); + } if (hasAccessRights) { return await originalMethod?.apply(this, args); } else { From dfa86fbc99e9a4a495b295bb4507dea83f24c3f6 Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Thu, 8 May 2025 15:10:36 +0200 Subject: [PATCH 4/8] Add GUI for dynamic role creation --- apps/frontend/src/components/AppRoutes.tsx | 9 + .../src/components/admin/RoleManagement.tsx | 153 ++++++++++ .../src/components/admin/RoleModal.tsx | 277 ++++++++++++++++++ .../src/components/menu/MenuItems.tsx | 45 ++- .../components/menu/SettingsMenuListItem.tsx | 10 + .../src/components/pages/OverviewPage.tsx | 1 + .../src/graphql/admin/createRole.graphql | 5 + .../src/graphql/admin/deleteRole.graphql | 5 + .../src/graphql/admin/updateRole.graphql | 5 + .../src/graphql/user/getRoles.graphql | 2 + .../src/graphql/user/getUserWithRoles.graphql | 2 + 11 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/src/components/admin/RoleManagement.tsx create mode 100644 apps/frontend/src/components/admin/RoleModal.tsx create mode 100644 apps/frontend/src/graphql/admin/createRole.graphql create mode 100644 apps/frontend/src/graphql/admin/deleteRole.graphql create mode 100644 apps/frontend/src/graphql/admin/updateRole.graphql diff --git a/apps/frontend/src/components/AppRoutes.tsx b/apps/frontend/src/components/AppRoutes.tsx index e524b8809f..960dc389b9 100644 --- a/apps/frontend/src/components/AppRoutes.tsx +++ b/apps/frontend/src/components/AppRoutes.tsx @@ -9,6 +9,7 @@ import { FeatureId, UserRole, WorkflowType } from 'generated/sdk'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; import { useTechniqueProposalAccess } from 'hooks/common/useTechniqueProposalAccess'; +import RoleManagement from './admin/RoleManagement'; import ChangeRole from './common/ChangeRole'; import OverviewPage from './pages/OverviewPage'; import ProposalPage from './proposal/ProposalPage'; @@ -207,6 +208,14 @@ const AppRoutes = () => { element={} />} /> )} + + } /> + } + /> + } />} diff --git a/apps/frontend/src/components/admin/RoleManagement.tsx b/apps/frontend/src/components/admin/RoleManagement.tsx new file mode 100644 index 0000000000..52c1be4808 --- /dev/null +++ b/apps/frontend/src/components/admin/RoleManagement.tsx @@ -0,0 +1,153 @@ +import { Typography } from '@mui/material'; +import React, { useState, useEffect } from 'react'; + +import SuperMaterialTable from 'components/common/SuperMaterialTable'; +import { Role } from 'generated/sdk'; +import { StyledContainer, StyledPaper } from 'styles/StyledComponents'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; +import { FunctionType } from 'utils/utilTypes'; + +import RoleModal from './RoleModal'; + +const RoleManagement: React.FC = () => { + const [roles, setRoles] = useState< + { + id: number; + shortCode: string; + title: string; + description: string; + dataAccess: string[]; + permissions: string[]; + }[] + >([]); + + const { api } = useDataApiWithFeedback(); + + useEffect(() => { + const fetchRoles = async () => { + try { + const response = await api().getRoles(); + setRoles( + (response.roles || []).map((role) => ({ + ...role, + dataAccess: role.dataAccess || undefined, + permissions: role.permissions || undefined, + })) + ); + } catch (error) { + console.error('Error fetching roles:', error); + } + }; + + fetchRoles(); + }, [api]); + + const handleRoleSubmit = async () => { + // Refresh roles after create/update + api() + .getRoles() + .then((response) => + setRoles( + (response.roles || []).map((role) => ({ + ...role, + dataAccess: role.dataAccess || undefined, + permissions: role.permissions || undefined, + })) + ) + ); + }; + + interface RoleRow { + id: number; + shortCode: string; + title: string; + description: string; + dataAccess: string[]; + permissions: string[]; + } + + const columns: Array<{ + title: string; + field: keyof RoleRow; + render?: (rowData: RoleRow) => React.ReactNode; + }> = [ + { title: 'Short Code', field: 'shortCode' }, + { title: 'Title', field: 'title' }, + { title: 'Description', field: 'description' }, + { + title: 'Data Access', + field: 'dataAccess', + render: (rowData) => rowData.dataAccess?.join(', ') || '-', + }, + { + title: 'Permissions', + field: 'permissions', + render: (rowData) => rowData.permissions?.join(', ') || '-', + }, + ]; + const deleteRole = async (id: number | string) => { + try { + await api({ + toastSuccessMessage: 'Call deleted successfully', + }).deleteRole({ + id: id as number, + }); + const newObjectsArray = roles.filter( + (objectItem) => objectItem.id !== id + ); + setRoles(newObjectsArray); + + return true; + } catch (error) { + return false; + } + }; + + const createModal = ( + onUpdate: FunctionType, + onCreate: FunctionType, + editRole: Role | null + ) => ( + (!!editRole ? onUpdate(null) : onCreate(null))} + role={editRole} // Pass `null` for create mode, or the role object for update mode + onSubmit={() => { + handleRoleSubmit(); + !!editRole ? onUpdate(null) : onCreate(null); + }} + /> + ); + + return ( + + + Role Management + + Existing Roles + + } + columns={columns} + data={roles} + isLoading={false} + options={{ + search: true, + paging: true, + }} + /> + + + ); +}; + +export default RoleManagement; diff --git a/apps/frontend/src/components/admin/RoleModal.tsx b/apps/frontend/src/components/admin/RoleModal.tsx new file mode 100644 index 0000000000..e1b4c62440 --- /dev/null +++ b/apps/frontend/src/components/admin/RoleModal.tsx @@ -0,0 +1,277 @@ +import { + Dialog, + DialogContent, + Typography, + TextField, + Button, + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + Grid, +} from '@mui/material'; +import React, { useState, useEffect } from 'react'; + +import { useQueriesMutationsAndServicesData } from 'hooks/admin/useQueriesMutationsAndServicesData'; +import { useInstrumentsData } from 'hooks/instrument/useInstrumentsData'; +import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; + +interface RoleModalProps { + open: boolean; + onClose: () => void; + role: { + id: number; + shortCode: string; + title: string; + description: string; + dataAccess?: string[]; + permissions?: string[]; + } | null; + onSubmit: () => void; +} + +const RoleModal: React.FC = ({ + open, + onClose, + role, + onSubmit, +}) => { + const isEditMode = !!role; + const [shortCode, setShortCode] = useState(''); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [dataAccess, setDataAccess] = useState([]); + const [permissions, setPermissions] = useState([]); + const { api } = useDataApiWithFeedback(); + const { queriesMutationsAndServices, loadingQueriesMutationsAndServices } = + useQueriesMutationsAndServicesData(); + const { instruments, loadingInstruments } = useInstrumentsData(); + + useEffect(() => { + if (isEditMode && role) { + setShortCode(role.shortCode || ''); + setTitle(role.title || ''); + setDescription(role.description || ''); + setDataAccess(role.dataAccess || []); + setPermissions(role.permissions || []); + } else { + setShortCode(''); + setTitle(''); + setDescription(''); + setDataAccess([]); + setPermissions([]); + } + }, [isEditMode, role]); + + const handleSubmit = async () => { + const apiCall = isEditMode + ? api({ + toastSuccessMessage: 'Role updated successfully', + }).updateRole({ + args: { + roleID: role.id, + shortCode, + title, + description, + dataAccess, + permissions, + }, + }) + : api({ + toastSuccessMessage: 'Role created successfully', + }).createRole({ + args: { + shortCode, + title, + description, + dataAccess, + permissions, + }, + }); + + try { + await apiCall; + onSubmit(); + onClose(); + } catch (error) { + console.error(error); + alert( + `An error occurred while ${ + isEditMode ? 'updating' : 'creating' + } the role.` + ); + } + }; + + const renderPermissions = () => { + if (loadingQueriesMutationsAndServices) { + return Loading permissions...; + } + + return ( + + Permissions + + + Queries + + + {queriesMutationsAndServices.queries.map((group, groupIndex) => + group.items.map((query, index) => ( + + { + if (e.target.checked) { + setPermissions([...permissions, query]); + } else { + setPermissions( + permissions.filter((perm) => perm !== query) + ); + } + }} + /> + } + label={query} + /> + + )) + )} + + + Mutations + + + {queriesMutationsAndServices.mutations.map((group, groupIndex) => + group.items.map((mutation, index) => ( + + { + if (e.target.checked) { + setPermissions([...permissions, mutation]); + } else { + setPermissions( + permissions.filter((perm) => perm !== mutation) + ); + } + }} + /> + } + label={mutation} + /> + + )) + )} + + + + ); + }; + + const renderDataAccess = () => { + if (loadingInstruments) { + return Loading data access options...; + } + + return ( + + Data Access + + + {instruments.map((instrument, index) => ( + + { + if (e.target.checked) { + setDataAccess([...dataAccess, instrument.name]); + } else { + setDataAccess( + dataAccess.filter( + (access) => access !== instrument.name + ) + ); + } + }} + /> + } + label={instrument.name} + /> + + ))} + + + + ); + }; + + return ( + + + + {isEditMode ? 'Update Role' : 'Create New Role'} + + setShortCode(e.target.value)} + fullWidth + margin="normal" + disabled={isEditMode} + /> + setTitle(e.target.value)} + fullWidth + margin="normal" + /> + setDescription(e.target.value)} + fullWidth + margin="normal" + /> + {/* Permissions Section */} +
+ Permissions + {renderPermissions()} +
+ {/* Data Access Section */} +
+ Data Access + {renderDataAccess()} +
+ +
+
+ ); +}; + +export default RoleModal; diff --git a/apps/frontend/src/components/menu/MenuItems.tsx b/apps/frontend/src/components/menu/MenuItems.tsx index 4b3d6bcb78..972656a04f 100644 --- a/apps/frontend/src/components/menu/MenuItems.tsx +++ b/apps/frontend/src/components/menu/MenuItems.tsx @@ -25,6 +25,7 @@ import { } from 'components/experiment/DateFilter'; import { TimeSpan } from 'components/experiment/PresetDateSelector'; import { FeatureContext } from 'context/FeatureContextProvider'; +import { UserContext } from 'context/UserContextProvider'; import { FeatureId, SettingsId, UserRole } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { CallsDataQuantity, useCallsData } from 'hooks/call/useCallsData'; @@ -68,6 +69,8 @@ const ProposalsMenuListItem = () => { const MenuItems = ({ currentRole }: MenuItemsProps) => { const context = useContext(FeatureContext); + const userInfo = useContext(UserContext); + const { t } = useTranslation(); const { format } = useFormattedDateTime({ settingsFormatToUse: SettingsId.DATE_FORMAT, @@ -99,7 +102,47 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { CallsDataQuantity.MINIMAL ).calls; + const currentRoleInfo = userInfo.roles.find((role) => { + if ( + role.shortCode.toLocaleLowerCase() === currentRole?.toLocaleLowerCase() + ) { + return true; + } + }); + + const permissions = currentRoleInfo?.permissions; + let dynamicMenuItems: React.ReactNode = null; const openCall = calls?.find((call) => call.isActive); + if (permissions) { + dynamicMenuItems = ( +
+ {permissions.some( + (permission) => permission === 'ProposalQueries.getAll' + ) && ( + + + + + + + + + )} + {permissions.some( + (permission) => permission === 'InstrumentQueries.getAll' + ) && ( + + + + + + + + + )} +
+ ); + } const techniqueProposalUrl = openCall && openCall.id @@ -353,7 +396,7 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { case UserRole.INTERNAL_REVIEWER: return internalReviewer; default: - return null; + return dynamicMenuItems; } }; diff --git a/apps/frontend/src/components/menu/SettingsMenuListItem.tsx b/apps/frontend/src/components/menu/SettingsMenuListItem.tsx index b3454e7baf..9cbbd7605d 100644 --- a/apps/frontend/src/components/menu/SettingsMenuListItem.tsx +++ b/apps/frontend/src/components/menu/SettingsMenuListItem.tsx @@ -3,6 +3,7 @@ import DisplaySettingsIcon from '@mui/icons-material/DisplaySettings'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import FunctionsIcon from '@mui/icons-material/Functions'; +import People from '@mui/icons-material/People'; import Settings from '@mui/icons-material/Settings'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import VpnKey from '@mui/icons-material/VpnKey'; @@ -28,6 +29,7 @@ const menuMap = { ApiAccessTokens: '/ApiAccessTokens', Features: '/Features', Settings: '/Settings', + RoleManagement: '/admin/roles', SampleEsiTemplates: '/SampleEsiTemplates', }; @@ -135,6 +137,14 @@ const SettingsMenuListItem = () => { + + + + + + + + diff --git a/apps/frontend/src/components/pages/OverviewPage.tsx b/apps/frontend/src/components/pages/OverviewPage.tsx index b1d42c5946..66991ed9ce 100644 --- a/apps/frontend/src/components/pages/OverviewPage.tsx +++ b/apps/frontend/src/components/pages/OverviewPage.tsx @@ -24,6 +24,7 @@ const Paper = ({ children }: { children: React.ReactNode }) => ( ); export default function OverviewPage(props: { userRole: UserRole }) { + console.log('OverviewPage', props.userRole); const [loadingContent, pageContent] = useGetPageContent( props.userRole === UserRole.USER ? PageName.HOMEPAGE : PageName.REVIEWPAGE ); diff --git a/apps/frontend/src/graphql/admin/createRole.graphql b/apps/frontend/src/graphql/admin/createRole.graphql new file mode 100644 index 0000000000..d613f6796e --- /dev/null +++ b/apps/frontend/src/graphql/admin/createRole.graphql @@ -0,0 +1,5 @@ +mutation createRole($args: CreateRoleArgs!) { + createRole(args: $args) { + success + } +} diff --git a/apps/frontend/src/graphql/admin/deleteRole.graphql b/apps/frontend/src/graphql/admin/deleteRole.graphql new file mode 100644 index 0000000000..4dc8090f7f --- /dev/null +++ b/apps/frontend/src/graphql/admin/deleteRole.graphql @@ -0,0 +1,5 @@ +mutation deleteRole($id: Int!) { + deleteRole(roleId: $id) { + id + } +} diff --git a/apps/frontend/src/graphql/admin/updateRole.graphql b/apps/frontend/src/graphql/admin/updateRole.graphql new file mode 100644 index 0000000000..2c6f357f49 --- /dev/null +++ b/apps/frontend/src/graphql/admin/updateRole.graphql @@ -0,0 +1,5 @@ +mutation updateRole($args: UpdateRoleArgs!) { + updateRole(args: $args) { + success + } +} diff --git a/apps/frontend/src/graphql/user/getRoles.graphql b/apps/frontend/src/graphql/user/getRoles.graphql index a91839aeb7..b84ef4fc65 100644 --- a/apps/frontend/src/graphql/user/getRoles.graphql +++ b/apps/frontend/src/graphql/user/getRoles.graphql @@ -4,5 +4,7 @@ query getRoles { shortCode title description + dataAccess + permissions } } diff --git a/apps/frontend/src/graphql/user/getUserWithRoles.graphql b/apps/frontend/src/graphql/user/getUserWithRoles.graphql index d5fa26e0d3..f8def29f8b 100644 --- a/apps/frontend/src/graphql/user/getUserWithRoles.graphql +++ b/apps/frontend/src/graphql/user/getUserWithRoles.graphql @@ -7,6 +7,8 @@ query getUserWithRoles($userId: Int!) { shortCode title description + dataAccess + permissions } } } From a527f9dbe70e4d582404f9426e5637df579ecd3b Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Thu, 8 May 2025 15:11:19 +0200 Subject: [PATCH 5/8] Add db patch for data_access and permissions --- apps/backend/db_patches/0180_CreateUserRoles | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/backend/db_patches/0180_CreateUserRoles diff --git a/apps/backend/db_patches/0180_CreateUserRoles b/apps/backend/db_patches/0180_CreateUserRoles new file mode 100644 index 0000000000..cc9a551917 --- /dev/null +++ b/apps/backend/db_patches/0180_CreateUserRoles @@ -0,0 +1,17 @@ +DO +$$ +BEGIN + IF register_patch('0180_RenameXpressFeature.sql', 'Fredrik Bolmsten', 'Create dynamic user roles', '2025-05-06') THEN + BEGIN + + ALTER TABLE roles + ADD COLUMN permissions TEXT[] NOT NULL DEFAULT '{}'; + + ALTER TABLE roles + ADD COLUMN data_access TEXT[] NOT NULL DEFAULT '{}'; + + END; + END IF; +END; +$$ +LANGUAGE plpgsql; From 578a1d4bd758ab88ab0299cbabf431a162b3118e Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Fri, 9 May 2025 08:56:34 +0200 Subject: [PATCH 6/8] fix delete function --- apps/backend/src/datasources/postgres/UserDataSource.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/datasources/postgres/UserDataSource.ts b/apps/backend/src/datasources/postgres/UserDataSource.ts index 1310713f9b..c68af06874 100644 --- a/apps/backend/src/datasources/postgres/UserDataSource.ts +++ b/apps/backend/src/datasources/postgres/UserDataSource.ts @@ -883,6 +883,7 @@ export default class PostgresUserDataSource implements UserDataSource { async deleteRole(id: number): Promise { const [deletedRole] = await database('roles') .where('role_id', id) + .del() .returning('*'); if (!deletedRole) { From 2db1029bf065f041efead698a544d1587dc351f5 Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Fri, 9 May 2025 09:30:32 +0200 Subject: [PATCH 7/8] Add new hook for getting user data and load new user data when creating menuItems --- .../components/AppToolbar/RoleSelection.tsx | 42 ++++--------------- .../src/components/menu/MenuItems.tsx | 22 +++++----- .../src/graphql/user/getMyRoles.graphql | 2 + apps/frontend/src/hooks/user/useMeData.ts | 32 ++++++++++++++ 4 files changed, 54 insertions(+), 44 deletions(-) create mode 100644 apps/frontend/src/hooks/user/useMeData.ts diff --git a/apps/frontend/src/components/AppToolbar/RoleSelection.tsx b/apps/frontend/src/components/AppToolbar/RoleSelection.tsx index cdb597aa45..a9dc059fc6 100644 --- a/apps/frontend/src/components/AppToolbar/RoleSelection.tsx +++ b/apps/frontend/src/components/AppToolbar/RoleSelection.tsx @@ -1,11 +1,11 @@ import MaterialTable, { Column } from '@material-table/core'; import Button from '@mui/material/Button'; -import React, { useContext, useState, useEffect } from 'react'; -import { Navigate, useNavigate } from 'react-router-dom'; +import React, { useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { UserContext } from 'context/UserContextProvider'; import { Role } from 'generated/sdk'; -import { getUniqueArrayBy } from 'utils/helperFunctions'; +import { useMeData } from 'hooks/user/useMeData'; import { tableIcons } from 'utils/materialIcons'; import useDataApiWithFeedback from 'utils/useDataApiWithFeedback'; import { FunctionType } from 'utils/utilTypes'; @@ -30,36 +30,10 @@ const RoleSelection = ({ onClose }: { onClose: FunctionType }) => { const [loading, setLoading] = useState(false); const { api } = useDataApiWithFeedback(); const navigate = useNavigate(); - const [roles, setRoles] = useState([]); + const { meData, loading: meDataLoading } = useMeData(); - useEffect(() => { - let unmounted = false; - - const getUserInformation = async () => { - setLoading(true); - const data = await api().getMyRoles(); - if (unmounted) { - return; - } - - if (data?.me) { - /** NOTE: We have roles that are duplicated in role_id and user_id but different only in fap_id - * which is used to determine if the user with that role is member of a Fap. - */ - setRoles(getUniqueArrayBy(data.me?.roles, 'id')); - setLoading(false); - } - }; - getUserInformation(); - - return () => { - unmounted = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!roles) { - return ; + if (!meData) { + return

Loading...

; } const selectUserRole = async (role: Role) => { @@ -98,7 +72,7 @@ const RoleSelection = ({ onClose }: { onClose: FunctionType }) => { ); - const rolesWithRoleAction = roles.map((role) => ({ + const rolesWithRoleAction = meData.roles.map((role) => ({ ...role, roleAction: RoleAction(role), })); @@ -110,7 +84,7 @@ const RoleSelection = ({ onClose }: { onClose: FunctionType }) => { title="User roles" columns={columns} data={rolesWithRoleAction} - isLoading={loading} + isLoading={loading || meDataLoading} options={{ search: false, paging: false, diff --git a/apps/frontend/src/components/menu/MenuItems.tsx b/apps/frontend/src/components/menu/MenuItems.tsx index 972656a04f..fae12cd2fb 100644 --- a/apps/frontend/src/components/menu/MenuItems.tsx +++ b/apps/frontend/src/components/menu/MenuItems.tsx @@ -25,11 +25,11 @@ import { } from 'components/experiment/DateFilter'; import { TimeSpan } from 'components/experiment/PresetDateSelector'; import { FeatureContext } from 'context/FeatureContextProvider'; -import { UserContext } from 'context/UserContextProvider'; import { FeatureId, SettingsId, UserRole } from 'generated/sdk'; import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { CallsDataQuantity, useCallsData } from 'hooks/call/useCallsData'; import { useTechniqueProposalAccess } from 'hooks/common/useTechniqueProposalAccess'; +import { useMeData } from 'hooks/user/useMeData'; import SettingsMenuListItem from './SettingsMenuListItem'; import { TemplateMenuListItem } from './TemplateMenuListItem'; @@ -69,8 +69,7 @@ const ProposalsMenuListItem = () => { const MenuItems = ({ currentRole }: MenuItemsProps) => { const context = useContext(FeatureContext); - const userInfo = useContext(UserContext); - + const { meData } = useMeData(); const { t } = useTranslation(); const { format } = useFormattedDateTime({ settingsFormatToUse: SettingsId.DATE_FORMAT, @@ -102,14 +101,17 @@ const MenuItems = ({ currentRole }: MenuItemsProps) => { CallsDataQuantity.MINIMAL ).calls; - const currentRoleInfo = userInfo.roles.find((role) => { - if ( - role.shortCode.toLocaleLowerCase() === currentRole?.toLocaleLowerCase() - ) { - return true; - } - }); + let currentRoleInfo = null; + if (meData) { + currentRoleInfo = meData.roles.find((role) => { + if ( + role.shortCode.toLocaleLowerCase() === currentRole?.toLocaleLowerCase() + ) { + return true; + } + }); + } const permissions = currentRoleInfo?.permissions; let dynamicMenuItems: React.ReactNode = null; const openCall = calls?.find((call) => call.isActive); diff --git a/apps/frontend/src/graphql/user/getMyRoles.graphql b/apps/frontend/src/graphql/user/getMyRoles.graphql index 10ec82d36d..66e085a71f 100644 --- a/apps/frontend/src/graphql/user/getMyRoles.graphql +++ b/apps/frontend/src/graphql/user/getMyRoles.graphql @@ -7,6 +7,8 @@ query getMyRoles { shortCode title description + dataAccess + permissions } } } diff --git a/apps/frontend/src/hooks/user/useMeData.ts b/apps/frontend/src/hooks/user/useMeData.ts new file mode 100644 index 0000000000..749298025a --- /dev/null +++ b/apps/frontend/src/hooks/user/useMeData.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +import { GetMyRolesQuery } from 'generated/sdk'; +import { useDataApi } from 'hooks/common/useDataApi'; + +export function useMeData() { + const api = useDataApi(); + const [meData, setMeData] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let unmounted = false; + + setLoading(true); + api() + .getMyRoles() + .then((data) => { + if (unmounted) { + return; + } + + setMeData(data.me); + setLoading(false); + }); + + return () => { + unmounted = true; + }; + }, [api]); + + return { loading, meData, setMeData } as const; +} From 38e3fb3e2c40654415591e197afeb6b8fbcd9b93 Mon Sep 17 00:00:00 2001 From: Fredrik Bolmsten Date: Fri, 9 May 2025 11:22:17 +0200 Subject: [PATCH 8/8] Fetch user data access and permission on server side --- .../backend/src/auth/ProposalAuthorization.ts | 31 ++++++++++++++----- apps/backend/src/decorators/Authorized.ts | 31 ++++++++++++++++--- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/auth/ProposalAuthorization.ts b/apps/backend/src/auth/ProposalAuthorization.ts index df182638d1..e6599adda0 100644 --- a/apps/backend/src/auth/ProposalAuthorization.ts +++ b/apps/backend/src/auth/ProposalAuthorization.ts @@ -9,7 +9,7 @@ import { ReviewDataSource } from '../datasources/ReviewDataSource'; import { StatusDataSource } from '../datasources/StatusDataSource'; import { TechniqueDataSource } from '../datasources/TechniqueDataSource'; import { VisitDataSource } from '../datasources/VisitDataSource'; -import { Roles } from '../models/Role'; +import { Role, Roles } from '../models/Role'; import { ProposalStatusDefaultShortCodes } from '../models/Status'; import { UserWithRole } from '../models/User'; import { Proposal } from '../resolvers/types/Proposal'; @@ -248,15 +248,30 @@ export class ProposalAuthorization { await this.instrumentDataSource.getInstrumentsByProposalPk( proposal.primaryKey ); - proposalIsntruments.forEach((instrument) => { - agent.currentRole?.dataAccess.some((dataAccess) => { - if (dataAccess === instrument.shortCode) { - hasAccess = true; - return true; - } - }); + const rolesArray: Role[] = await this.userDataSource.getUserRoles(agent.id); + const userRoles: Record = + rolesArray.reduce( + (acc, role) => { + acc[role.shortCode] = { dataAccess: role.dataAccess }; + + return acc; + }, + {} as Record + ); + proposalIsntruments.forEach((instrument) => { + agent.currentRole?.shortCode && + userRoles[agent.currentRole.shortCode]?.dataAccess.some( + (dataAccess) => { + if (dataAccess === instrument.shortCode) { + hasAccess = true; + + return true; + } + } + ); }); + if (hasAccess) { return true; } diff --git a/apps/backend/src/decorators/Authorized.ts b/apps/backend/src/decorators/Authorized.ts index 0667819bbe..ec3a768be6 100644 --- a/apps/backend/src/decorators/Authorized.ts +++ b/apps/backend/src/decorators/Authorized.ts @@ -1,8 +1,11 @@ import { logger } from '@user-office-software/duo-logger'; import { GraphQLError } from 'graphql'; +import { container } from 'tsyringe'; +import { Tokens } from '../config/Tokens'; +import { UserDataSource } from '../datasources/UserDataSource'; import { Rejection, rejection } from '../models/Rejection'; -import { Roles } from '../models/Role'; +import { Role, Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; const Authorized = (roles: Roles[] = []) => { @@ -52,18 +55,36 @@ const Authorized = (roles: Roles[] = []) => { return await originalMethod?.apply(this, args); } - const hasAccessRights = roles.some( - (role) => role === agent.currentRole?.shortCode + const userDataSource = container.resolve( + Tokens.UserDataSource ); + const rolesArray: Role[] = await userDataSource.getUserRoles(agent.id); + const userRoles: Record = + rolesArray.reduce( + (acc, role) => { + acc[role.shortCode] = { permissions: role.permissions }; + + return acc; + }, + {} as Record + ); + //check if user has dynamic role with permissions for this method if ( - agent.currentRole?.permissions.some( - (permission) => permission === `${target.constructor.name}.${name}` + agent.currentRole?.shortCode && + userRoles[agent.currentRole.shortCode]?.permissions.some( + (permission: string) => + permission === `${target.constructor.name}.${name}` ) ) { return await originalMethod?.apply(this, args); } + + const hasAccessRights = roles.some( + (role) => role === agent.currentRole?.shortCode + ); + if (hasAccessRights) { return await originalMethod?.apply(this, args); } else {