generated from bcgov/quickstart-openshift
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #656 from bcgov/feat/srs-328
API to assign 'formsflow-client' group to BCSC and BCeID users
- Loading branch information
Showing
6 changed files
with
426 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { Controller, Post, Body, HttpException, HttpStatus } from '@nestjs/common'; | ||
import { Resource, RoleMatchingMode, Roles, Unprotected } from 'nest-keycloak-connect'; | ||
import { KeycloakService } from 'src/app/services/keycloak.service'; | ||
import { AddUserToGroupDto } from 'src/app/dto/addUserToGroup'; | ||
|
||
@Controller('users') | ||
@Resource('user-service') | ||
export class UserController { | ||
constructor(private readonly keyCloakService: KeycloakService) {} | ||
|
||
/** | ||
* Add user to a group in Keycloak. | ||
* @param addUserToGroupDto - Object containing userId. | ||
* @returns Object indicating success status and message. | ||
*/ | ||
@Post('/addGroup') | ||
@Roles({ roles: ['user-admin'], mode: RoleMatchingMode.ANY }) | ||
async addUserToGroup(@Body() addUserToGroupDto: AddUserToGroupDto): Promise<any> { | ||
try | ||
{ | ||
const { userId } = addUserToGroupDto; | ||
|
||
// Get access token from Keycloak | ||
const accessToken = await this.keyCloakService.getToken(); | ||
if (!accessToken) | ||
{ | ||
throw new HttpException('Failed to get access token', HttpStatus.INTERNAL_SERVER_ERROR); | ||
} | ||
|
||
// Find group ID by name | ||
const groupName = 'formsflow-client'; // Assuming 'formflow-client' is the group name | ||
const groupId = await this.keyCloakService.getGroupIdByName(groupName, accessToken); | ||
if (!groupId) | ||
{ | ||
throw new HttpException(`Group '${groupName}' not found`, HttpStatus.NOT_FOUND); | ||
} | ||
|
||
// Add user to group | ||
const result = await this.keyCloakService.addUserToGroup(userId, groupId, accessToken); | ||
if(result.success) | ||
{ | ||
return result; | ||
} | ||
} | ||
catch (error) | ||
{ | ||
// Handle errors | ||
if (error.response && error.response.data && error.response.data.error) | ||
{ | ||
// If Keycloak returns an error message, throw a Bad Request exception with the error message | ||
throw new HttpException(error.response.data.error, HttpStatus.BAD_REQUEST); | ||
} | ||
else { | ||
// If any other error occurs, throw an Internal Server Error exception | ||
throw new HttpException('Internal server error', HttpStatus.INTERNAL_SERVER_ERROR); | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import { IsNotEmpty, IsString } from 'class-validator'; | ||
|
||
export class AddUserToGroupDto { | ||
@IsNotEmpty() | ||
@IsString() | ||
userId: string; | ||
} |
189 changes: 189 additions & 0 deletions
189
backend/users/src/app/services/keycloak.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,189 @@ | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
import { ConfigModule, ConfigService } from '@nestjs/config'; | ||
import { KeycloakService } from './keycloak.service'; | ||
import axios from 'axios'; | ||
|
||
jest.mock('axios'); | ||
describe('KeycloakService', () => { | ||
let keycloakService: KeycloakService; | ||
|
||
// Mock axios response | ||
const axiosResponse = { | ||
data: [ | ||
{ | ||
"id": "1", | ||
"name": "formsflow", | ||
"path": "/formsflow", | ||
"subGroups": [ | ||
{ | ||
"id": "2", | ||
"name": "formsflow-reviewer", | ||
"path": "/formsflow/formsflow-reviewer", | ||
"subGroups": [] | ||
}, | ||
{ | ||
"id": "3", | ||
"name": "formsflow-client", | ||
"path": "/formsflow/formsflow-client", | ||
"subGroups": [] | ||
}, | ||
{ | ||
"id": "4", | ||
"name": "formsflow-designer", | ||
"path": "/formsflow/formsflow-designer", | ||
"subGroups": [] | ||
} | ||
] | ||
} | ||
], | ||
}; | ||
|
||
beforeEach(async () => { | ||
|
||
// Mock ConfigService | ||
const configServiceMock = { | ||
get: jest.fn().mockReturnValueOnce(axiosResponse), | ||
}; | ||
|
||
const module: TestingModule = await Test.createTestingModule({ | ||
imports: [ConfigModule.forRoot()], // Import ConfigModule | ||
providers: [ | ||
KeycloakService, | ||
{ provide: 'ConfigService', useValue: configServiceMock }, | ||
{ provide: axios, useValue: axios }, // Provide axios | ||
], // Provide KeycloakService | ||
}).compile(); | ||
|
||
keycloakService = module.get<KeycloakService>(KeycloakService); | ||
|
||
}); | ||
|
||
it('should be defined', () => { | ||
expect(keycloakService).toBeDefined(); | ||
}); | ||
|
||
describe('getGroupIdByName', () =>{ | ||
|
||
it('should return group ID when group name exists', async () => { | ||
// Arrange | ||
const groupName = 'formsflow-client'; | ||
const accessToken = 'hsneu889siejnd99003kkd0kdldl'; | ||
|
||
// Mock axios | ||
jest.spyOn(axios, 'get').mockResolvedValueOnce(axiosResponse); | ||
|
||
// Act | ||
const groupId = await keycloakService.getGroupIdByName(groupName, accessToken); | ||
|
||
// Assert | ||
expect(groupId).toEqual('3'); | ||
}); | ||
|
||
it('should return null when group name does not exist', async () => { | ||
// Arrange | ||
const groupName = 'non-existing-group'; | ||
const accessToken = 'hsneu889siejnd99003kkd0kdldl'; | ||
|
||
//Mock axios | ||
jest.spyOn(axios, 'get').mockResolvedValueOnce(axiosResponse); | ||
|
||
// Act | ||
const groupId = await keycloakService.getGroupIdByName(groupName, accessToken); | ||
|
||
// Assert | ||
expect(groupId).toBeNull(); | ||
}); | ||
|
||
it('should throw an error when retrieval fails', async () => { | ||
// Arrange | ||
const groupName = 'group-name'; | ||
const accessToken = 'hsneu889siejnd99003kkd0kdldl'; | ||
const errorMessage = 'Failed to retrieve group information'; | ||
|
||
// Mock axios to throw an error | ||
jest.spyOn(axios, 'get').mockRejectedValueOnce(new Error(errorMessage)); | ||
|
||
// Act & Assert | ||
await expect(keycloakService.getGroupIdByName(groupName, accessToken)).rejects.toThrowError(errorMessage); | ||
}); | ||
}); | ||
|
||
describe('addUserToGroup', () => { | ||
|
||
it('should add user to group in Keycloak', async () => { | ||
// Arrange | ||
const userId = '1'; | ||
const groupId = '1'; | ||
const accessToken = 'hsneu889siejnd99003kkd0kdldl'; | ||
|
||
// Mock axios.put to resolve | ||
jest.spyOn(axios, 'put').mockResolvedValueOnce({ status: 200, data: {} }); | ||
|
||
// Act | ||
await keycloakService.addUserToGroup(userId, groupId, accessToken); | ||
|
||
// Assert | ||
expect(axios.put).toHaveBeenCalledTimes(1); // Ensure axios.put is called | ||
expect(axios.put).toHaveBeenCalledWith( | ||
expect.stringContaining(`/users/${userId}/groups/${groupId}`), | ||
{}, // Empty object for the request body | ||
{ | ||
headers: { | ||
Authorization: `Bearer ${accessToken}`, | ||
'Content-Type': 'application/json', | ||
}, | ||
} | ||
); | ||
}); | ||
|
||
it('should throw an error when user addition fails', async () => { | ||
// Arrange | ||
const userId = '1'; | ||
const groupId = '1'; | ||
const accessToken = 'hsneu889siejnd99003kkd0kdldl'; | ||
const errorMessage = 'Failed to add user to group'; | ||
|
||
// Mock axios.put to reject with an error | ||
jest.spyOn(axios, 'put').mockRejectedValueOnce(new Error(errorMessage)); | ||
|
||
// Act & Assert | ||
await expect(keycloakService.addUserToGroup(userId, groupId, accessToken)).rejects.toThrowError(errorMessage); | ||
|
||
// Ensure axios.put is called | ||
expect(axios.put).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
|
||
describe('findGroupIdByName', () =>{ | ||
|
||
it('should return null if group is not found', () => { | ||
|
||
// Arrange | ||
const groupName = 'nonexistentGroup'; | ||
|
||
// Act | ||
const result = keycloakService.findGroupIdByName(axiosResponse.data, groupName); | ||
|
||
// Assert | ||
expect(result).toBeNull(); | ||
}); | ||
|
||
it('should return group object if group is found', () => { | ||
// Arrange | ||
const groupName = "formsflow-client"; | ||
|
||
// Act | ||
const result = keycloakService.findGroupIdByName(axiosResponse.data, groupName); | ||
|
||
// Assert | ||
expect(result).toEqual({ | ||
"id": "3", | ||
"name": "formsflow-client", | ||
"path": "/formsflow/formsflow-client", | ||
"subGroups": [] | ||
}); | ||
}); | ||
|
||
}); | ||
}); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { ConfigService } from '@nestjs/config'; | ||
import axios from 'axios'; | ||
|
||
@Injectable() | ||
export class KeycloakService { | ||
|
||
constructor(private readonly configService: ConfigService) {} | ||
|
||
/** | ||
* Retrieve access token from Keycloak. | ||
* @returns Access token. | ||
*/ | ||
async getToken(): Promise<any> { | ||
// Extract environment variables | ||
const keycloakAuthUrl = this.configService.get<string>('KEYCLOCK_AUTH_URL'); | ||
const realm = this.configService.get<string>('KEYCLOCK_MASTER_REALM'); | ||
const username = this.configService.get<string>('KEYCLOCK_USERNAME'); | ||
const password = this.configService.get<string>('KEYCLOCK_PASSWORD'); | ||
const clientId = this.configService.get<string>('KEYCLOCK_ADMIN_CLIENT_ID'); | ||
const grantType = this.configService.get<string>('KEYCLOCK_GRANT_TYPE'); | ||
|
||
// Construct URL for token request | ||
const url = `${keycloakAuthUrl}/realms/${realm}/protocol/openid-connect/token`; | ||
|
||
try { | ||
// Request access token from Keycloak | ||
const response = await axios.post( | ||
url, | ||
new URLSearchParams({ | ||
grant_type: grantType, | ||
client_id: clientId, | ||
username: username, | ||
password: password, | ||
}), | ||
{ | ||
headers: { | ||
'Content-Type': 'application/x-www-form-urlencoded', | ||
}, | ||
} | ||
); | ||
|
||
// Return access token | ||
return response.data.access_token; | ||
} | ||
catch (error) | ||
{ | ||
// Throw error if access token retrieval fails | ||
throw new Error(`Failed to get access token: ${error.message}`); | ||
} | ||
} | ||
|
||
/** | ||
* Get group ID by group name from Keycloak. | ||
* @param groupName - Name of the group. | ||
* @param accessToken - Access token. | ||
* @returns Group ID if found, otherwise null. | ||
*/ | ||
async getGroupIdByName(groupName: string, accessToken: string): Promise<any> { | ||
try { | ||
// Extract environment variables | ||
const keycloakAuthUrl = this.configService.get<string>('KEYCLOCK_AUTH_URL'); | ||
const realm = this.configService.get<string>('KEYCLOCK_REALM'); | ||
const url = `${keycloakAuthUrl}/admin/realms/${realm}/groups`; | ||
|
||
// Request group information from Keycloak | ||
const response = await axios.get(url, { | ||
headers: { | ||
Authorization: `Bearer ${accessToken}`, | ||
'Content-Type': 'application/json', | ||
}, | ||
params: { | ||
search: groupName, | ||
}, | ||
}); | ||
|
||
// Find group ID by name | ||
const group = this.findGroupIdByName(response.data, groupName); | ||
return group ? group.id : null; | ||
} | ||
catch (error) | ||
{ | ||
// Throw error if group ID retrieval fails | ||
throw new Error(`Failed to get group ID by name: ${error.message}`); | ||
} | ||
} | ||
|
||
/** | ||
* Add user to group in Keycloak. | ||
* @param userId - ID of the user. | ||
* @param groupId - ID of the group. | ||
* @param accessToken - Access token. | ||
*/ | ||
async addUserToGroup(userId: string, groupId: string, accessToken: string): Promise<any> { | ||
try { | ||
// Extract environment variables | ||
const keycloakAuthUrl = this.configService.get<string>('KEYCLOCK_AUTH_URL'); | ||
const realm = this.configService.get<string>('KEYCLOCK_REALM'); | ||
const url = `${keycloakAuthUrl}/admin/realms/${realm}/users/${userId}/groups/${groupId}`; | ||
|
||
// Add user to group in Keycloak | ||
await axios.put(url, {}, { | ||
headers: { | ||
Authorization: `Bearer ${accessToken}`, | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
|
||
// Return a success response | ||
return { | ||
success: true, | ||
message: 'User added to group successfully', | ||
}; | ||
} | ||
catch (error) | ||
{ | ||
// Throw error if user addition fails | ||
throw new Error(`Failed to add user to group: ${error.message}`); | ||
} | ||
} | ||
|
||
/** | ||
* Find group by name recursively. | ||
* @param groups - Array of groups to search. | ||
* @param name - Name of the group to find. | ||
* @returns Group object if found, otherwise null. | ||
*/ | ||
findGroupIdByName = (groups, name) => { | ||
const stack = [...groups]; | ||
while (stack.length) { | ||
const group = stack.pop(); | ||
if (group.name === name) { | ||
return group; | ||
} | ||
stack.push(...group.subGroups); | ||
} | ||
return null; | ||
}; | ||
} |
Oops, something went wrong.