Skip to content

Commit

Permalink
Merge pull request #656 from bcgov/feat/srs-328
Browse files Browse the repository at this point in the history
API to assign 'formsflow-client' group to BCSC and BCeID users
  • Loading branch information
nikhila-aot authored Apr 9, 2024
2 parents 00695bb + 989ffb1 commit 8cac26b
Show file tree
Hide file tree
Showing 6 changed files with 426 additions and 4 deletions.
59 changes: 59 additions & 0 deletions backend/users/src/app/controllers/user.controller.ts
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);
}
}
}
}
7 changes: 7 additions & 0 deletions backend/users/src/app/dto/addUserToGroup.ts
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 backend/users/src/app/services/keycloak.service.spec.ts
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": []
});
});

});
});

139 changes: 139 additions & 0 deletions backend/users/src/app/services/keycloak.service.ts
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;
};
}
Loading

0 comments on commit 8cac26b

Please sign in to comment.