Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/keycloak user management #380

Draft
wants to merge 11 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions app/api/keycloak.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ const logger = require('../log');

const $keycloak = {};

const getHeaders = (token) => {
return {
Accept: 'application/json',
'Content-Encoding': 'deflate',
'Content-Type': 'application/json',
Authorization: `Bearer ${token.access_token}`,
};
};

const getRealmUri = (baseuri, realm) => {
return `${baseuri}/auth/admin/realms/${realm}`;
};

$keycloak.getToken = async (username, password) => {
const {clientId, uri} = nconf.get('keycloak');
const options = {
Expand All @@ -25,4 +38,176 @@ $keycloak.getToken = async (username, password) => {
return request(options);
};

$keycloak.getAdminCliToken = async (username, password) => {
const {uri} = nconf.get('keycloak');
const options = {
method: 'POST',
url: uri,
json: true,
body: form({
client_id: 'admin-cli',
grant_type: 'password',
username,
password,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
};
logger.debug(`Requesting admin-cli token from ${uri}`);
return request(options);
};

$keycloak.createKeycloakUser = async (token, newUsername, newEmail) => {
const {enableV16UserManagement, baseuri, realm} = nconf.get('keycloak');
if (!enableV16UserManagement) {
return {};
}
const headers = getHeaders(token);
const realmUri = getRealmUri(baseuri, realm);

// create the user
await request({
method: 'POST',
url: `${realmUri}/users`,
headers,
body: JSON.stringify({
username: newUsername,
email: newEmail,
emailVerified: true,
enabled: true,
}),
});

// get the id of the newly created user
const userslist = await request({method: 'GET',
json: true,
url: `${realmUri}/users`,
headers});
const newUser = (userslist).filter((item) => {return item.username === newUsername;})[0];

// get the ids of the ipr and graphkb roles
const roleslist = await request({method: 'GET',
json: true,
url: `${realmUri}/roles`,
headers});
const iprRoleId = (roleslist).filter((item) => {return item.name === 'IPR';})[0].id;
const graphkbRoleId = (roleslist).filter((item) => {return item.name === 'GraphKB';})[0].id;

// add the roles
const roleMappingResult = await request({
method: 'POST',
url: `${realmUri}/users/${newUser.id}/role-mappings/realm`,
headers,
body: JSON.stringify([
{id: graphkbRoleId, name: 'GraphKB'},
{id: iprRoleId, name: 'IPR'},
]),
});
return roleMappingResult.status_code;
};

$keycloak.deleteKeycloakUser = async (token, username) => {
const {enableV16UserManagement, baseuri, realm} = nconf.get('keycloak');
if (!enableV16UserManagement) {
return {};
}
const headers = getHeaders(token);
const realmUri = getRealmUri(baseuri, realm);
const userslist = await request({method: 'GET',
json: true,
url: `${realmUri}/users`,
headers});
const currUser = (userslist).filter((item) => {return item.username === username;})[0];
const deleteUserSuccess = await request({
method: 'DELETE',
json: true,
headers,
url: `${realmUri}/users/${currUser.id}`,
});
return deleteUserSuccess.status_code;
};

$keycloak.grantRealmAdmin = async (token, editUsername, editUseremail) => {
const {enableV16UserManagement, baseuri, realm} = nconf.get('keycloak');
if (!enableV16UserManagement) {
return {};
}
const headers = getHeaders(token);
const realmUri = getRealmUri(baseuri, realm);

// get the id of the user to be updated
const userslist = await request({method: 'GET',
url: `${realmUri}/users`,
json: true,
headers});
const currUser = (userslist).filter((item) => {return item.username === editUsername && item.email === editUseremail;})[0];

// get the id of the realm-management client
const clients = await request({method: 'GET',
url: `${realmUri}/clients`,
json: true,
headers});
const rmClient = (clients).filter((item) => {return item.clientId === 'realm-management';})[0];

// get the id of the realm-admin role in the realm-management client
const clientRoles = await request({method: 'GET',
url: `${realmUri}/clients/${rmClient.id}/roles`,
json: true,
headers});
const realmAdmin = (clientRoles).filter((item) => {return item.name === 'realm-admin';})[0];

// add the realm-admin role in the realm-management client to the user
const postclientroles = await request({method: 'POST',
url: `${realmUri}/users/${currUser.id}/role-mappings/clients/${rmClient.id}`,
headers,
body: JSON.stringify([{
id: realmAdmin.id,
name: 'realm-admin',
}])});
return postclientroles;
};

$keycloak.ungrantRealmAdmin = async (token, editUsername, editUseremail) => {
const {enableV16UserManagement, baseuri, realm} = nconf.get('keycloak');
if (!enableV16UserManagement) {
return {};
}
const headers = getHeaders(token);
const realmUri = getRealmUri(baseuri, realm);

// get the record for the current user
const userslist = await request({method: 'GET',
url: `${realmUri}/users`,
headers});
const currUser = (userslist).filter((item) => {return item.username === editUsername && item.email === editUseremail;})[0];

// get the record for the realm-management client
const clients = await request({method: 'GET',
url: `${realmUri}/clients`,
headers});
const rmClient = (clients).filter((item) => {return item.clientId === 'realm-management';})[0];

// get the record connecting the user to the role
const clientRoleMappings = await request({
method: 'GET',
url: `${realmUri}/users/${currUser.id}/role-mappings/clients/${rmClient.id}`,
headers,
});
// TODO - double check this, should be realm-admin not realm-management
const clientRM = (clientRoleMappings).filter((item) => {return item.name === 'realm-management';})[0];

// delete the role
const deleteOutcome = await request({
method: 'DELETE',
url: `${realmUri}/users/${currUser.id}/role-mappings/clients/${rmClient.id}`,
headers,
body: JSON.stringify([{
id: clientRM.id,
name: 'realm-admin',
}]),
});
return deleteOutcome;
};

module.exports = $keycloak;
8 changes: 6 additions & 2 deletions app/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@
uri: ENV === 'production'
? 'https://sso.bcgsc.ca/auth/realms/GSC/protocol/openid-connect/token'
: 'https://keycloakdev01.bcgsc.ca/auth/realms/GSC/protocol/openid-connect/token',
baseuri: ENV === 'production'
? 'https://sso.bcgsc.ca'
: 'https://keycloakdev01.bcgsc.ca',
enableV16UserManagerment: false, // keycloak 16

Check failure on line 56 in app/config.js

View workflow job for this annotation

GitHub Actions / eslint

Multiple spaces found before '// keycloak 16'
clientId: 'IPR',
realm: 'PORI',
role: 'IPR',
keyfile: ENV === 'production'
? 'keys/prodkey.pem'
Expand Down Expand Up @@ -97,7 +102,7 @@
? 6380
: 6379,
},
redis_queue: REDIS_QUEUE_CONFIG,
redisqueue: REDIS_QUEUE_CONFIG,
paths: {
data: {
POGdata: '/projects/tumour_char/pog/reports/genomic',
Expand All @@ -119,7 +124,6 @@

for (const [key, value] of Object.entries(env)) {
let newKey = key;

if (/^ipr_\w+$/i.exec(key)) {
if (lowerCase) {
newKey = newKey.toLowerCase();
Expand Down
5 changes: 5 additions & 0 deletions app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@
// ensure the db connection is ready
await sequelize.authenticate();

const enableV16UserManagement = conf.get('keycloak:enablev16usermanagement');
if (enableV16UserManagement) {
logger.info(`user management with Keycloak 16 is enabled`);

Check failure on line 120 in app/index.js

View workflow job for this annotation

GitHub Actions / eslint

Strings must use singlequote
}

// set up the routing
const routing = new Routing();
try {
Expand Down
46 changes: 46 additions & 0 deletions app/libs/getAdminCliToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const HTTP_STATUS = require('http-status-codes');
const keycloak = require('../api/keycloak');
const nconf = require('../config');

const logger = require('../log');

const getAdminCliToken = async (req, res) => {
const {enableV16UserManagement} = nconf.get('keycloak');
if (!enableV16UserManagement) {
return null;
}
const token = req.header('Authorization') || '';
let adminToken;
// Check for basic authorization header
if (token.includes('Basic')) {
let credentials;
try {
credentials = Buffer.from(token.split(' ')[1], 'base64').toString('utf-8').split(':');
} catch (err) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({message: 'The authentication header you provided was not properly formatted.'});
}
try {
const adminCliToken = await keycloak.getAdminCliToken(credentials[0], credentials[1]);
adminToken = adminCliToken.access_token;
} catch (error) {
let errorDescription;
try {
errorDescription = JSON.parse(error.error).error_description;
} catch (parseError) {
// if the error is propagated from upstread of the keycloak server it will not have the error.error_description format (ex. certificate failure)
errorDescription = error;
}
logger.error(`Authentication failed ${error.name} ${error.statusCode} - ${errorDescription}`);
return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Authentication failed ${error.name} ${error.statusCode} - ${errorDescription}`}});
}
}
if (!token) {
return res.status(HTTP_STATUS.FORBIDDEN).json({message: 'Missing required Authorization token'});
}

return adminToken;
};

module.exports = {
getAdminCliToken,
};
18 changes: 18 additions & 0 deletions app/middleware/keycloakUserManagement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const nconf = require('../config');

const {
getAdminCliToken,
} = require('../libs/getAdminCliToken');

// Require Active Session Middleware
module.exports = async (req, res, next) => {
const {enableV16UserManagement} = nconf.get('keycloak');
if (!enableV16UserManagement) {
return next();
}

// Get Authorization Header
const adminCliToken = await getAdminCliToken(req, res);
req.adminCliToken = adminCliToken;
return next();
};
2 changes: 1 addition & 1 deletion app/queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const nodemailer = require('nodemailer');
const conf = require('./config');
const createReport = require('./libs/createReport');

const {host, port, enableQueue} = conf.get('redis_queue');
const {host, port, enableQueue} = conf.get('redisqueue');
const logger = require('./log'); // Load logging library

const CONFIG = require('./config');
Expand Down
7 changes: 7 additions & 0 deletions app/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const germlineMiddleware = require('../middleware/germlineSmallMutation/reports');
const authMiddleware = require('../middleware/auth');
const aclMiddleware = require('../middleware/acl');
const keycloakUserManagement = require('../middleware/keycloakUserManagement');

// Get route files
const APIVersion = require('./version');
Expand Down Expand Up @@ -59,6 +60,12 @@
// To every route except for specification routes (/spec and /spec.json)
this.router.use(/^((?!^\/spec).)*$/i, authMiddleware);

// Add keycloak 16 admin-cli token getter
// To every route that uses that token (user management routes)
// If keycloak user management is enabled
this.router.use('/user', keycloakUserManagement)

Check failure on line 66 in app/routes/index.js

View workflow job for this annotation

GitHub Actions / eslint

Missing semicolon
this.router.use('/user/:user/member', keycloakUserManagement)

Check failure on line 67 in app/routes/index.js

View workflow job for this annotation

GitHub Actions / eslint

Missing semicolon

// Acl middleware
// To every route except for specification and report routes
this.router.use(/^(?!^\/spec|\/germline-small-mutation-reports(?:\/([^\\?]+?))|\/reports(?:\/([^\\?]+?))[\\?]?.*)/i, aclMiddleware);
Expand Down
30 changes: 28 additions & 2 deletions app/routes/user/member.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ const express = require('express');
const db = require('../../models');
const logger = require('../../log');

const nconf = require('../../config');

const router = express.Router({mergeParams: true});
const {isAdmin} = require('../../libs/helperFunctions');
const schemaGenerator = require('../../schemas/schemaGenerator');
const validateAgainstSchema = require('../../libs/validateAgainstSchema');
const {BASE_EXCLUDE} = require('../../schemas/exclude');
const {grantRealmAdmin, ungrantRealmAdmin} = require('../../api/keycloak');

// Generate schema
const memberSchema = schemaGenerator(db.models.userGroupMember, {
Expand Down Expand Up @@ -83,6 +86,18 @@ router.route('/')
// Add user to group
await db.models.userGroupMember.create({group_id: req.group.id, user_id: user.id});
await user.reload();
const {enableV16UserManagement} = nconf.get('keycloak');
if ((groupIsAdmin || req.group.name === 'manager') && enableV16UserManagement) {
try {
const token = req.adminCliToken;
await grantRealmAdmin(token, user.username, user.email);
} catch (error) {
logger.error('Error while trying to add user to keycloak realm-management');
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
error: {message: 'User creation succeeded but error granting user realm-management role in keycloak'},
});
}
}
return res.status(HTTP_STATUS.CREATED).json(user.view('public'));
} catch (error) {
logger.error(`Error while trying to add user to group ${error}`);
Expand Down Expand Up @@ -153,9 +168,20 @@ router.route('/')
error: {message: 'User doesn\'t belong to group'},
});
}

// Remove membership
await membership.destroy();
const {enableV16UserManagement} = nconf.get('keycloak');
if ((groupIsAdmin || req.group.name === 'manager') && enableV16UserManagement) {
try {
const token = req.adminCliToken;
await ungrantRealmAdmin(token, req.body.username, req.body.email);
} catch (error) {
logger.error('Error while trying to ungrant user keycloak realm-management');
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
error: {message: 'User group removal succeeded but error removing user realm-management role in keycloak'},
});
}
}
// Remove membership
return res.status(HTTP_STATUS.NO_CONTENT).send();
} catch (error) {
logger.error(`Error while trying to remove user from group ${error}`);
Expand Down
Loading
Loading