Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
ConditionalCheckFailedException,
TransactionCanceledException,
} from '@aws-sdk/client-dynamodb';
import { CreateUserInviteSchema } from '@easy-genomics/shared-lib/src/app/schema/easy-genomics/user-invite';
import { Organization } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/organization';
import { OrganizationUser } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/organization-user';
import { User } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/user';
import { CreateUserInvite } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/user-invite';
import { buildResponse } from '@easy-genomics/shared-lib/src/app/utils/common';
import { APIGatewayProxyResult, APIGatewayProxyWithCognitoAuthorizerEvent, Handler } from 'aws-lambda';
import { CognitoUserService } from '../../../services/easy-genomics/cognito-user-service';
import { OrganizationService } from '../../../services/easy-genomics/organization-service';
import { OrganizationUserService } from '../../../services/easy-genomics/organization-user-service';
import { PlatformUserService } from '../../../services/easy-genomics/platform-user-service';
import { UserService } from '../../../services/easy-genomics/user-service';

const cognitoUserService = new CognitoUserService({ userPoolId: process.env.COGNITO_USER_POOL_ID });
const organizationService = new OrganizationService();
const organizationUserService = new OrganizationUserService();
const platformUserService = new PlatformUserService();
const userService = new UserService();

export const handler: Handler = async (
event: APIGatewayProxyWithCognitoAuthorizerEvent,
): Promise<APIGatewayProxyResult> => {
console.log('EVENT: \n' + JSON.stringify(event, null, 2));
try {
const currentUserId: string = event.requestContext.authorizer.claims['cognito:username'];
// Post Request Body
const request: CreateUserInvite = (
event.isBase64Encoded ? JSON.parse(atob(event.body!)) : JSON.parse(event.body!)
);

// Data validation safety check
if (!CreateUserInviteSchema.safeParse(request).success) throw new Error('Invalid request');

// Check if Organization & User records exists
const organization: Organization = await organizationService.get(request.OrganizationId); // Throws error if not found
const user: User | undefined = (await userService.queryByEmail(request.Email)).shift();

if (!user) {
// Attempt to create new Cognito User account
const newUserId: string = await cognitoUserService.addNewUserToPlatform(request.Email);

// Create new User and invite to the Organization and Platform
const newUser: User = getNewUser(request.Email, newUserId, currentUserId);
const newOrganizationUser: OrganizationUser = getNewOrganizationUser(organization.OrganizationId, newUser.UserId, currentUserId);

try {
// Attempt to add the new User record, and add the Organization-User access mapping in one transaction
if (await platformUserService.inviteNewUserToOrganization(newUser, newOrganizationUser)) {
// TODO: Send email
return buildResponse(200, JSON.stringify({ Status: 'Success' }), event);
}
} catch (error: unknown) {
// Clean up the created Cognito User account due to failure in creating new User record / Organization-User access mapping.
await cognitoUserService.deleteUserFromPlatform(request.Email);
return buildResponse(200, JSON.stringify({ Status: 'Error', Message: error.message }), event);
}
} else {
// Invite existing User to the Organization
if (user.Status === 'Inactive') {
throw new Error(`Unable to invite User to Organization "${organization.Name}": User Status is "Inactive"`);
} else {
const existingOrganizationUser: OrganizationUser | void =
await organizationUserService.get(organization.OrganizationId, user.UserId).catch((error: any) => {
if (error.message.endsWith('Resource not found')) { // TODO - improve error to handle ResourceNotFoundException instead of checking error message
// Do nothing - allow new Organization-User access mapping to proceed.
} else {
throw error;
}
});

if (existingOrganizationUser && existingOrganizationUser.Status === 'Invited') {
// Check if existing Organization-User's Status is still Invited to resend invitation
// TODO: Re-send email
return buildResponse(200, JSON.stringify({ Status: 'Re-inviting' }), event);
} else {
// Create new Organization-User access mapping record
const newOrganizationUser = getNewOrganizationUser(organization.OrganizationId, user.UserId, currentUserId);

// Attempt to add the User to the Organization in one transaction
if (await platformUserService.inviteExistingUserToOrganization(user, newOrganizationUser)) {
// TODO: Re-send email
return buildResponse(200, JSON.stringify({ Status: 'Success' }), event);
}
}
}
}
} catch (err: any) {
console.error(err);
return {
statusCode: 400,
body: JSON.stringify({
Error: getErrorMessage(err),
}),
};
}
};

/**
* Helper function to create a new User record.
* @param email
* @param userId
* @param createdBy
*/
function getNewUser(email: string, userId: string, createdBy?: string): User {
const newUser: User = {
UserId: userId,
Email: email,
Status: 'Invited',
CreatedAt: new Date().toISOString(),
CreatedBy: createdBy,
};
return newUser;
}

/**
* Helper function to create a new Organization-User access mapping record.
* @param organizationId
* @param userId
* @param createdBy
*/
function getNewOrganizationUser(organizationId, userId: string, createdBy: string): OrganizationUser {
const organizationUser: OrganizationUser = {
OrganizationId: organizationId,
UserId: userId,
Status: 'Invited',
OrganizationAdmin: false,
CreatedAt: new Date().toISOString(),
CreatedBy: createdBy,
};
return organizationUser;
}

// Used for customising error messages by exception types
function getErrorMessage(err: any) {
if (err instanceof ConditionalCheckFailedException) {
return `Create User Invite to Organization failed: ${err.message}`;
} else if (err instanceof TransactionCanceledException) {
return `Create User Invite to Organization failed: ${err.message}`;
} else {
return err.message;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
AdminCreateUserCommand,
AdminCreateUserCommandOutput,
AdminDeleteUserCommand,
AdminDeleteUserCommandOutput,
CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider';

export interface CognitoUserServiceProps {
userPoolId: string;
}

export class CognitoUserService {
private readonly props: CognitoUserServiceProps;
private readonly cognitoClient: CognitoIdentityProviderClient;

public constructor(props: CognitoUserServiceProps) {
this.props = props;
this.cognitoClient = new CognitoIdentityProviderClient();
}

/**
* This function creates a new Cognito User account for the new User, and
* returns the Cognito Username.
*
* @param email
*/
async addNewUserToPlatform(email: string): Promise<string> {
const logRequestMessage = `Add New User Email=${email} to Platform request`;
console.info(logRequestMessage);

const adminCreateUserCommand: AdminCreateUserCommand = new AdminCreateUserCommand({
MessageAction: 'SUPPRESS',
DesiredDeliveryMediums: ['EMAIL'],
Username: email,
UserAttributes: [
{ Name: 'email', Value: email },
{ Name: 'email_verified', Value: 'false' },
],
UserPoolId: this.props.userPoolId,
});
const response: AdminCreateUserCommandOutput = await this.cognitoClient.send<AdminCreateUserCommand>(adminCreateUserCommand);

if (response.$metadata.httpStatusCode === 200 && response.User && response.User.Username) {
return response.User.Username;
} else {
throw new Error(`${logRequestMessage} unsuccessful: HTTP Status Code=${response.$metadata.httpStatusCode}`);
}
}

/**
* This function deletes an existing Cognito User account, and is intended for
* use as part of the User off-boarding / clean up process.
* @param email
*/
async deleteUserFromPlatform(email: string): Promise<boolean> {
const logRequestMessage = `Delete User Email=${email} to Platform request`;
console.info(logRequestMessage);

const adminDeleteUserCommand: AdminDeleteUserCommand = new AdminDeleteUserCommand({
Username: email,
UserPoolId: this.props.userPoolId,
});
const response: AdminDeleteUserCommandOutput = await this.cognitoClient.send<AdminDeleteUserCommand>(adminDeleteUserCommand);

if (response.$metadata.httpStatusCode === 200) {
return true;
} else {
throw new Error(`${logRequestMessage} unsuccessful: HTTP Status Code=${response.$metadata.httpStatusCode}`);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { marshall } from '@aws-sdk/util-dynamodb';
import { OrganizationUser } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/organization-user';
import { User } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/user';
import { DynamoDBService } from '../dynamodb-service';

export class PlatformUserService extends DynamoDBService {
readonly USER_TABLE_NAME: string = `${process.env.NAME_PREFIX}-user-table`;
readonly UNIQUE_REFERENCE_TABLE_NAME: string = `${process.env.NAME_PREFIX}-unique-reference-table`;
readonly ORGANIZATION_USER_TABLE_NAME: string = `${process.env.NAME_PREFIX}-organization-user-table`;

public constructor() {
super();
}

/**
* This function creates a DynamoDB transaction to:
* - add a new User record
* - add an Unique-Reference record to reserve the new User's email as taken
* - add an Organization-User access mapping record for the new User to access the Organization
*
* If any part of the transaction fails, the whole transaction will be rejected in order to avoid data inconsistency.
*
* @param user
* @param organizationUser
*/
async inviteNewUserToOrganization(user: User, organizationUser: OrganizationUser): Promise<Boolean> {
const logRequestMessage = `Invite New User To Organization UserId=${user.UserId} to OrganizationId=${organizationUser.OrganizationId} request`;
console.info(logRequestMessage);

const response = await this.transactWriteItems({
TransactItems: [
{
Put: {
TableName: this.USER_TABLE_NAME,
ConditionExpression: 'attribute_not_exists(#UserId)',
ExpressionAttributeNames: {
'#UserId': 'UserId',
},
Item: marshall(user),
},
},
{
Put: {
TableName: this.UNIQUE_REFERENCE_TABLE_NAME,
ConditionExpression: 'attribute_not_exists(#Value) AND attribute_not_exists(#Type)',
ExpressionAttributeNames: {
'#Value': 'Value',
'#Type': 'Type',
},
Item: marshall({
Value: user.Email,
Type: 'user-email',
}),
},
},
{
Put: {
TableName: this.ORGANIZATION_USER_TABLE_NAME,
ConditionExpression: 'attribute_not_exists(#OrganizationId) AND attribute_not_exists(#UserId)',
ExpressionAttributeNames: {
'#OrganizationId': 'OrganizationId',
'#UserId': 'UserId',
},
Item: marshall(organizationUser),
},
},
],
});

if (response.$metadata.httpStatusCode === 200) {
return true;
} else {
throw new Error(`${logRequestMessage} unsuccessful: HTTP Status Code=${response.$metadata.httpStatusCode}`);
}
}

/**
* This function creates a DynamoDB transaction to:
* - add an Organization-User access mapping record for the existing User to access the Organization
*
* If any part of the transaction fails, the whole transaction will be rejected in order to avoid
* data inconsistency.
* @param user
* @param organizationUser
*/
async inviteExistingUserToOrganization(user: User, organizationUser: OrganizationUser): Promise<Boolean> {
const logRequestMessage = `Invite Existing User To Organization UserId=${user.UserId} to OrganizationId=${organizationUser.OrganizationId} request`;
console.info(logRequestMessage);

const response = await this.transactWriteItems({
TransactItems: [
{
Put: {
TableName: this.ORGANIZATION_USER_TABLE_NAME,
ConditionExpression: 'attribute_not_exists(#OrganizationId) AND attribute_not_exists(#UserId)',
ExpressionAttributeNames: {
'#OrganizationId': 'OrganizationId',
'#UserId': 'UserId',
},
Item: marshall(organizationUser),
},
},
],
});

if (response.$metadata.httpStatusCode === 200) {
return true;
} else {
throw new Error(`${logRequestMessage} unsuccessful: HTTP Status Code=${response.$metadata.httpStatusCode}`);
}
}

}
35 changes: 34 additions & 1 deletion packages/back-end/src/app/services/easy-genomics/user-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { BatchGetItemCommandOutput, GetItemCommandOutput, ScanCommandOutput } from '@aws-sdk/client-dynamodb';
import {
BatchGetItemCommandOutput,
GetItemCommandOutput,
QueryCommandOutput,
ScanCommandOutput,
} from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
import { UserSchema } from '@easy-genomics/shared-lib/src/app/schema/easy-genomics/user';
import { User } from '@easy-genomics/shared-lib/src/app/types/easy-genomics/user';
Expand Down Expand Up @@ -112,6 +117,34 @@ export class UserService extends DynamoDBService implements Service {
}
}

public queryByEmail = async (email: string): Promise<User[]> => {
const logRequestMessage = `Query Users by Email=${email} request`;
console.info(logRequestMessage);

const response: QueryCommandOutput = await this.queryItems({
TableName: this.USER_TABLE_NAME,
IndexName: 'Email_Index', // Global Secondary Index
KeyConditionExpression: '#Email = :email',
ExpressionAttributeNames: {
'#Email': 'Email',
},
ExpressionAttributeValues: {
':email': { S: email },
},
ScanIndexForward: false,
});

if (response.$metadata.httpStatusCode === 200) {
if (response.Items) {
return response.Items.map(item => <User>unmarshall(item));
} else {
throw new Error(`${logRequestMessage} unsuccessful: Resource not found`);
}
} else {
throw new Error(`${logRequestMessage} unsuccessful: HTTP Status Code=${response.$metadata.httpStatusCode}`);
}
};

async update(user: User, existing?: User): Promise<User> {
throw new Error('TBD');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ import {
} from 'aws-cdk-lib/aws-cognito';
import { IFunction } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import { AuthNestedStackProps } from '../types/back-end-stack';

export interface CognitoIDPConstructProps {
constructNamespace: string;
devEnv?: boolean;
export interface CognitoIDPConstructProps extends AuthNestedStackProps {
authLambdaFunctions?: Map<string, IFunction>;
}

Expand Down
Loading