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

[BIOMAGE-1878] - Add v2 cellsets endpoints #346

Merged
merged 22 commits into from
May 9, 2022
Merged
Show file tree
Hide file tree
Changes from 21 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
8 changes: 6 additions & 2 deletions src/api.v2/controllers/__mocks__/accessController.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const mockGetExperimentUsers = jest.fn();
const mockGetUserAccess = jest.fn();
const mockInviteUser = jest.fn();
const mockRevokeAccess = jest.fn();

module.exports = {
getExperimentUsers: mockGetExperimentUsers,
getUserAccess: mockGetUserAccess,
inviteUser: mockInviteUser,
revokeAccess: mockRevokeAccess,
};
7 changes: 7 additions & 0 deletions src/api.v2/controllers/__mocks__/cellSetsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const mockGetCellSets = jest.fn();
const mockPatchCellSets = jest.fn();

module.exports = {
getCellSets: mockGetCellSets,
patchCellSets: mockPatchCellSets,
};
40 changes: 36 additions & 4 deletions src/api.v2/controllers/accessController.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,51 @@
const getUserRoles = require('../helpers/access/getUserRoles');
const getExperimentUsers = require('../helpers/access/getExperimentUsers');
const createUserInvite = require('../helpers/access/createUserInvite');
const removeAccess = require('../helpers/access/removeAccess');

const OK = require('../../utils/responses/OK');
const getLogger = require('../../utils/getLogger');

const logger = getLogger('[AccessController] - ');

const getExperimentUsers = async (req, res) => {
const getUserAccess = async (req, res) => {
const { experimentId } = req.params;

logger.log(`Fetching users for experiment ${experimentId}`);
const users = await getUserRoles(experimentId);
const users = await getExperimentUsers(experimentId);

logger.log(`Users fetched for experiment ${experimentId}`);

res.json(users);
};

const inviteUser = async (req, res) => {
const { experimentId } = req.params;
const {
userEmail, role,
} = req.body;

logger.log(`Inviting users to experiment ${experimentId}`);
await createUserInvite(experimentId, userEmail, role, req.user);

logger.log(`Users invited to experiment ${experimentId}`);

res.json(OK());
};

const revokeAccess = async (req, res) => {
const { experimentId } = req.params;
const { userEmail } = req.body;

logger.log(`Deleting user access from experiment ${experimentId}`);
await removeAccess(experimentId, userEmail);

logger.log(`User access deleted from experiment ${experimentId}`);

res.json(OK());
};

module.exports = {
getExperimentUsers,
getUserAccess,
inviteUser,
revokeAccess,
};
45 changes: 45 additions & 0 deletions src/api.v2/controllers/cellSetsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const getLogger = require('../../utils/getLogger');

const getS3Object = require('../helpers/s3/getObject');
const bucketNames = require('../helpers/s3/bucketNames');
const formatExperimentId = require('../../utils/v1Compatibility/formatExperimentId');

const { OK } = require('../../utils/responses');

const patchCellSetsObject = require('../helpers/s3/patchCellSetsObject');

const logger = getLogger('[CellSetsController] - ');

const getCellSets = async (req, res) => {
let { experimentId } = req.params;

experimentId = experimentId.replace(/-/g, '');

logger.log(`Getting cell sets for experiment ${experimentId}`);

const cellSets = await getS3Object({
Bucket: bucketNames.CELL_SETS,
Key: formatExperimentId(experimentId),
});

logger.log(`Finished getting cell sets for experiment ${experimentId}`);

res.send(cellSets);
};

const patchCellSets = async (req, res) => {
const { experimentId } = req.params;
const patch = req.body;

logger.log(`Patching cell sets for ${experimentId}`);
await patchCellSetsObject(formatExperimentId(experimentId), patch);

logger.log(`Finished patching cell sets for experiment ${experimentId}`);

res.json(OK());
};

module.exports = {
getCellSets,
patchCellSets,
};
43 changes: 43 additions & 0 deletions src/api.v2/helpers/access/createUserInvite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const UserAccess = require('../../model/UserAccess');

const { getAwsUserAttributesByEmail } = require('../../../utils/aws/user');
const sendEmail = require('../../../utils/send-email');
const buildUserInvitedEmailBody = require('../../../utils/emailTemplates/buildUserInvitedEmailBody');
const buildUserInvitedNotRegisteredEmailBody = require('../../../utils/emailTemplates/buildUserInvitedNotRegisteredEmailBody');

const OK = require('../../../utils/responses/OK');

const getLogger = require('../../../utils/getLogger');

const logger = getLogger('[AccessModel] - ');

const createUserInvite = async (experimentId, userEmail, role, inviterUser) => {
let userAttributes;
try {
userAttributes = await getAwsUserAttributesByEmail(userEmail);
} catch (e) {
if (e.code !== 'UserNotFoundException') {
throw e;
}

logger.log('User has not yet signed up, inviting new user');
}


let emailBody;
if (!userAttributes) {
new UserAccess().addToInviteAccess(userEmail, experimentId, role);
emailBody = buildUserInvitedNotRegisteredEmailBody(userEmail, inviterUser);
// User is added to experiment after they registered using post-register-lambda defined in IAC.
} else {
const userSub = userAttributes.find((attr) => attr.Name === 'sub').Value;
new UserAccess().grantAccess(userSub, experimentId, role);
emailBody = buildUserInvitedEmailBody(userEmail, experimentId, inviterUser);
}

await sendEmail(emailBody);

return OK();
};

module.exports = createUserInvite;
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
const config = require('../../../config');
const { getAwsUserAttributesByEmail } = require('../../../utils/aws/user');

const UserAccess = require('../../model/UserAccess');

const AccessRole = require('../../../utils/enums/AccessRole');

const { cognitoISP } = config;

const getAwsUserAttributesByEmail = async (email) => {
const poolId = await config.awsUserPoolIdPromise;
const user = await cognitoISP.adminGetUser({ UserPoolId: poolId, Username: email }).promise();
return user.UserAttributes;
};

const getUserRoles = async (experimentId) => {
const getExperimentUsers = async (experimentId) => {
const userData = await new UserAccess().getExperimentUsers(experimentId);

// Remove admin from user list
Expand All @@ -39,4 +31,4 @@ const getUserRoles = async (experimentId) => {
return experimentUsers;
};

module.exports = getUserRoles;
module.exports = getExperimentUsers;
12 changes: 12 additions & 0 deletions src/api.v2/helpers/access/removeAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const { getAwsUserAttributesByEmail } = require('../../../utils/aws/user');

const UserAccess = require('../../model/UserAccess');

const removeAccess = async (experimentId, userEmail) => {
const userAttributes = await getAwsUserAttributesByEmail(userEmail);
const userId = userAttributes.find((attr) => attr.Name === 'sub').Value;

new UserAccess().removeAccess(userId, experimentId);
};

module.exports = removeAccess;
1 change: 1 addition & 0 deletions src/api.v2/helpers/s3/bucketNames.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const config = require('../../../config');

const bucketNames = {
SAMPLE_FILES: `biomage-originals-${config.clusterEnv}`,
CELL_SETS: `cell-sets-${config.clusterEnv}`,
};

module.exports = bucketNames;
27 changes: 27 additions & 0 deletions src/api.v2/helpers/s3/getObject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const getS3Client = require('./getS3Client');
const NotFoundError = require('../../../utils/responses/NotFoundError');

const getObject = async (params) => {
if (!params.Bucket) throw new Error('Bucket is required');
if (!params.Key) throw new Error('Key is required');

const s3 = getS3Client();

try {
const outputObject = await s3.getObject(params).promise();
const data = outputObject.Body.toString();
return data;
} catch (e) {
if (e.code === 'NoSuchKey') {
throw new NotFoundError(`Couldn't find object with key: ${params.Key}`);
}

if (e.code === 'NoSuchBucket') {
throw new NotFoundError(`Couldn't find bucket with key: ${params.Bucket}`);
}

throw e;
}
};

module.exports = getObject;
18 changes: 18 additions & 0 deletions src/api.v2/helpers/s3/getS3Client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const AWS = require('../../../utils/requireAWS');
const config = require('../../../config');

// Wanted to make this a wrapper class that extends S3,
// but it's not advisable to do so:
// https://github.com/aws/aws-sdk-js/issues/2006
const getS3Client = (options) => {
const S3Config = {
apiVersion: '2006-03-01',
signatureVersion: 'v4',
region: config.awsRegion,
...options,
};

return new AWS.S3(S3Config);
};

module.exports = getS3Client;
39 changes: 39 additions & 0 deletions src/api.v2/helpers/s3/patchCellSetsObject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const jsonMerger = require('json-merger');

const bucketNames = require('./bucketNames');
const getObject = require('./getObject');
const putObject = require('./putObject');

const validateRequest = require('../../../utils/schema-validator');

const patchCellSetsObject = async (experimentId, patch) => {
const currentCellSet = await getObject({
Bucket: bucketNames.CELL_SETS,
Key: experimentId,
});

const { cellSets: prePatchCellSets } = currentCellSet;

/**
* The $remove operation will replace the element in the array with an
* undefined value. We will therefore remove this from the array.
*
* We use the $remove operation in the worker to update cell clusters,
* and we may end up using it in other places in the future.
*/
aerlaut marked this conversation as resolved.
Show resolved Hide resolved
const patchedCellSetslist = jsonMerger.mergeObjects(
[prePatchCellSets, patch],
).filter((x) => x !== undefined);

const patchedCellSets = { cellSets: patchedCellSetslist };

await validateRequest(patchedCellSets, 'cell-sets-bodies/CellSets.v2.yaml');

await putObject({
Bucket: bucketNames.CELL_SETS,
Key: experimentId,
Body: JSON.stringify(patchedCellSets),
});
};

module.exports = patchCellSetsObject;
22 changes: 22 additions & 0 deletions src/api.v2/helpers/s3/putObject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const NotFoundError = require('../../../utils/responses/NotFoundError');
const getS3Client = require('./getS3Client');

const putObject = async (params) => {
if (!params.Bucket) throw new Error('Bucket is required');
if (!params.Key) throw new Error('Key is required');
if (!params.Body) throw new Error('Body is required');

const s3 = getS3Client();

try {
await s3.putObject(params).promise();
} catch (e) {
if (e.code === 'NoSuchBucket') {
throw new NotFoundError(`Couldn't find bucket with key: ${params.Bucket}`);
}

throw e;
}
};

module.exports = putObject;
25 changes: 19 additions & 6 deletions src/api.v2/model/UserAccess.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,34 @@ class UserAccess extends BasicModel {
return experimentUsers;
}

async addToInviteAccess(userId, experimentId, role) {
return await this.sql
.insert({ user_id: userId, experiment_id: experimentId, access_role: role })
.into(tableNames.INVITE_ACCESS);
}

async grantAccess(userId, experimentId, role) {
return await this.create(
{ user_id: userId, experiment_id: experimentId, access_role: role },
);
}

async removeAccess(userId, experimentId) {
return await this.delete({ experiment_id: experimentId, user_id: userId });
}

async createNewExperimentPermissions(userId, experimentId) {
logger.log('Setting up access permissions for experiment');

await this.create(
{ user_id: config.adminSub, experiment_id: experimentId, access_role: AccessRole.ADMIN },
);
// Create admin permissions
await this.grantAccess(config.adminSub, experimentId, AccessRole.ADMIN);

if (userId === config.adminSub) {
logger.log('User is the admin, so only creating admin access');
return;
}

await this.create(
{ user_id: userId, experiment_id: experimentId, access_role: AccessRole.OWNER },
);
await this.grantAccess(userId, experimentId, AccessRole.OWNER);
}

async canAccessExperiment(userId, experimentId, url, method) {
Expand Down
14 changes: 12 additions & 2 deletions src/api.v2/routes/access.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
const {
getExperimentUsers,
getUserAccess,
inviteUser,
revokeAccess,
} = require('../controllers/accessController');

const { expressAuthorizationMiddleware } = require('../middlewares/authMiddlewares');

module.exports = {
'access#getExperimentUsers': [
expressAuthorizationMiddleware,
(req, res, next) => getExperimentUsers(req, res).catch(next),
(req, res, next) => getUserAccess(req, res).catch(next),
],
'access#inviteUser': [
expressAuthorizationMiddleware,
(req, res, next) => inviteUser(req, res).catch(next),
],
'access#revokeAccess': [
expressAuthorizationMiddleware,
(req, res, next) => revokeAccess(req, res).catch(next),
],
};
Loading