+ );
+ }
+}
+
+// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da
+decorate(EnvironmentDetailPage, {
+ windowsPassword: observable,
+ updateSharedWithUsers: observable,
+ formProcessing: observable,
+ handleSharedWithUsersSelection: action,
+});
+
+export default inject('environmentsStore', 'userStore', 'usersStore')(withRouter(observer(EnvironmentDetailPage)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentSetup.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentSetup.js
new file mode 100644
index 0000000000..c2f3fd06d4
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentSetup.js
@@ -0,0 +1,99 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { decorate, runInAction, observable, action } from 'mobx';
+import { observer, inject } from 'mobx-react';
+import { withRouter } from 'react-router-dom';
+import { Icon, Container, Header } from 'semantic-ui-react';
+import { gotoFn } from '@aws-ee/base-ui/dist/helpers/routing';
+import { displaySuccess } from '@aws-ee/base-ui/dist/helpers/notification';
+
+import { CurrentStep } from '../compute/helpers/CurrentStep';
+import ComputePlatformSetup from '../compute/ComputePlatformSetup';
+import SetupStepsProgress from './SetupStepsProgress';
+
+// expected props
+class EnvironmentSetup extends React.Component {
+ constructor(props) {
+ super(props);
+ runInAction(() => {
+ this.currentStep = CurrentStep.create({ step: 'selectComputePlatform' });
+ });
+ }
+
+ componentDidMount() {
+ window.scrollTo(0, 0);
+ }
+
+ goto(pathname) {
+ const goto = gotoFn(this);
+ goto(pathname);
+ }
+
+ handlePrevious = () => {
+ this.goto('/workspaces');
+ };
+
+ // eslint-disable-next-line no-unused-vars
+ handleCompleted = async environment => {
+ displaySuccess('The research workspace is being provisioned');
+ this.goto('/workspaces');
+ };
+
+ render() {
+ return (
+
+ {this.renderTitle()}
+ {this.renderStepsProgress()}
+ {this.renderContent()}
+
+ );
+ }
+
+ renderTitle() {
+ return (
+
+
+
+ Research Workspaces
+
+
+ );
+ }
+
+ renderStepsProgress() {
+ return ;
+ }
+
+ renderContent() {
+ return (
+
+ );
+ }
+}
+
+// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da
+decorate(EnvironmentSetup, {
+ handlePrevious: action,
+ handleCompleted: action,
+ currentStep: observable,
+});
+
+export default inject()(withRouter(observer(EnvironmentSetup)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentStatusIcon.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentStatusIcon.js
new file mode 100644
index 0000000000..bf6aeb2bfc
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentStatusIcon.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { Icon, Label } from 'semantic-ui-react';
+import { observer } from 'mobx-react';
+
+class EnvironmentStatusIcon extends React.Component {
+ getEnvironment() {
+ return this.props.environment;
+ }
+
+ render() {
+ const status = this.getEnvironment().status;
+ if (status === 'COMPLETED') {
+ return (
+
+ );
+ }
+ if (status === 'TERMINATED') {
+ return (
+
+ );
+ }
+ if (status === 'FAILED') {
+ return (
+
+ );
+ }
+ if (status === 'TERMINATING_FAILED') {
+ return (
+
+ );
+ }
+ if (status === 'TERMINATING') {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ );
+ }
+}
+
+export default observer(EnvironmentStatusIcon);
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentsList.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentsList.js
new file mode 100644
index 0000000000..46c3f10aba
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments/EnvironmentsList.js
@@ -0,0 +1,218 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import React from 'react';
+import { decorate, action, observable, runInAction } from 'mobx';
+import { observer, inject } from 'mobx-react';
+import { withRouter } from 'react-router-dom';
+import { Header, Icon, Segment, Container, Label, Button } from 'semantic-ui-react';
+
+import { gotoFn } from '@aws-ee/base-ui/dist/helpers/routing';
+import { storage, swallowError } from '@aws-ee/base-ui/dist/helpers/utils';
+import { isStoreLoading, isStoreEmpty, isStoreNotEmpty, isStoreError } from '@aws-ee/base-ui/dist/models/BaseStore';
+import ErrorBox from '@aws-ee/base-ui/dist/parts/helpers/ErrorBox';
+import ProgressPlaceHolder from '@aws-ee/base-ui/dist/parts/helpers/BasicProgressPlaceholder';
+
+import EnvironmentCard from './EnvironmentCard';
+import UserOnboarding from '../users/UserOnboarding';
+import UserPinModal from '../users/UserPinModal';
+import localStorageKeys from '../../models/constants/local-storage-keys';
+
+// expected props
+// - environmentsStore (via injection)
+// - location (from react router)
+class EnvironmentsList extends React.Component {
+ constructor(props) {
+ super(props);
+ const user = this.getUserStore.user;
+
+ runInAction(() => {
+ this.user = user;
+ this.onboardingOpen = false;
+ this.pinModalOpen = user.isExternalUser && _.isEmpty(storage.getItem(localStorageKeys.pinToken));
+ });
+ }
+
+ componentDidMount() {
+ const store = this.getStore();
+ swallowError(store.load());
+ store.startHeartbeat();
+ }
+
+ componentWillUnmount() {
+ const store = this.getStore();
+ store.stopHeartbeat();
+ }
+
+ getStore() {
+ return this.props.environmentsStore;
+ }
+
+ get getUserStore() {
+ return this.props.userStore;
+ }
+
+ setOnboarding = value => {
+ this.onboardingOpen = value;
+ };
+
+ hidePinModal = () => {
+ this.pinModalOpen = false;
+ };
+
+ handleDetailClick = event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // see https://reactjs.org/docs/events.html and https://github.com/facebook/react/issues/5733
+ const instanceId = event.currentTarget.dataset.instance;
+ const goto = gotoFn(this);
+ goto(`/workspaces/id/${instanceId}`);
+ };
+
+ handleCreateEnvironment = event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const goto = gotoFn(this);
+ goto(`/workspaces/create`);
+ };
+
+ handleConfigureCredentials = event => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setOnboarding(true);
+ };
+
+ needsAWSCredentials = () => this.user.isExternalResearcher && !this.user.hasCredentials;
+
+ render() {
+ const store = this.getStore();
+ let content = null;
+
+ if (this.needsAWSCredentials()) {
+ content = this.renderConfigureAWS();
+ } else if (isStoreError(store)) {
+ content = ;
+ } else if (isStoreLoading(store)) {
+ content = ;
+ } else if (isStoreEmpty(store)) {
+ content = this.renderEmpty();
+ } else if (isStoreNotEmpty(store)) {
+ content = this.renderMain();
+ } else {
+ content = null;
+ }
+
+ return (
+
+ {this.renderTitle()}
+ {content}
+ {this.onboardingOpen && this.setOnboarding(false)} />}
+
+ );
+ }
+
+ renderEmpty() {
+ return (
+
+
+
+ No research workspaces
+ To create a research workspace, click Create Research Workspace.
+
+
+ );
+ }
+
+ renderConfigureAWS() {
+ return (
+
+
+
+ No AWS credentials
+ To manage research workspaces, click Configure AWS Credentials.
+
+
+ );
+ }
+
+ renderTitle() {
+ return (
+
+));
+
+export default FileUploadTable;
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/Age.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/Age.js
new file mode 100644
index 0000000000..075be03abe
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/Age.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import React from 'react';
+import { observer } from 'mobx-react';
+import c from 'classnames';
+import TimeAgo from 'react-timeago';
+
+// expected props
+// - date (via props)
+// - emptyMessage (via props) (a message to display when the date is empty)
+// - className (via props)
+const Component = observer(({ date, className, emptyMessage = 'Not Provided' }) => {
+ if (_.isEmpty(date)) return {emptyMessage};
+ const formatter = (_value, _unit, _suffix, _epochSeconds, nextFormatter) =>
+ (nextFormatter() || '').replace(/ago$/, 'old');
+ return (
+
+
+
+ );
+});
+
+export default Component;
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/By.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/By.js
new file mode 100644
index 0000000000..430d1a26bb
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/By.js
@@ -0,0 +1,50 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { observer, inject } from 'mobx-react';
+import { decorate } from 'mobx';
+import { withRouter } from 'react-router-dom';
+import c from 'classnames';
+
+// expected props
+// - user (via props)
+// - userDisplayName (via injection)
+// - className (via props)
+class By extends React.Component {
+ get user() {
+ return this.props.user;
+ }
+
+ get userDisplayNameService() {
+ return this.props.userDisplayName;
+ }
+
+ render() {
+ const user = this.user;
+ const displayNameService = this.userDisplayNameService;
+ const isSystem = displayNameService.isSystem(user);
+ return isSystem ? (
+ ''
+ ) : (
+ by {displayNameService.getDisplayName(user)}
+ );
+ }
+}
+
+// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da
+decorate(By, {});
+
+export default inject('userDisplayName')(withRouter(observer(By)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/CfnStackOutputContainer/CfnStackOutput.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/CfnStackOutputContainer/CfnStackOutput.js
new file mode 100644
index 0000000000..efa56a6459
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/CfnStackOutputContainer/CfnStackOutput.js
@@ -0,0 +1,66 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React, { PureComponent } from 'react';
+import { Message, Icon, List } from 'semantic-ui-react';
+import PropTypes from 'prop-types';
+
+const LOADING_ICON = 'circle notched';
+const MESSAGE_POS = 'positive';
+const MESSAGE_NEG = 'negative';
+const MESSAGE_INFO = 'info';
+
+export default class CfnStackOutput extends PureComponent {
+ render() {
+ let messageType = MESSAGE_POS;
+ let iconType = 'check';
+ if (this.props.isStackExecuting) {
+ messageType = MESSAGE_INFO;
+ iconType = LOADING_ICON;
+ } else if (this.props.errorMessage) {
+ messageType = MESSAGE_NEG;
+ iconType = 'dont';
+ }
+
+ return (
+
+
+
+ Results from the creating the stack
+
+ {this.props.outputs.map((item, index) => {
+ // eslint-disable-next-line react/no-array-index-key
+ return {item};
+ })}
+
+ {this.props.errorMessage}
+
+
+ );
+ }
+}
+CfnStackOutput.propTypes = {
+ isStackExecuting: PropTypes.bool.isRequired,
+ outputs: PropTypes.arrayOf(PropTypes.string).isRequired,
+ errorMessage: PropTypes.string,
+};
+CfnStackOutput.defaultProps = {
+ errorMessage: '',
+};
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/CfnStackOutputContainer/index.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/CfnStackOutputContainer/index.js
new file mode 100644
index 0000000000..41c9777fca
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/CfnStackOutputContainer/index.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import CfnStackOutput from './CfnStackOutput';
+
+export default class CfnStackOutputContainer extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isStackExecuting: true,
+ errorMessage: '',
+ outputs: [],
+ };
+ }
+
+ componentDidMount() {
+ this.timer = setInterval(this.describeCfnStack, 15000);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ }
+
+ describeCfnStack = async () => {
+ if (this.state.isStackExecuting === false) {
+ clearInterval(this.timer);
+ return;
+ }
+ this.setState({
+ isStackExecuting: true,
+ });
+ const results = await this.props.cfn.describeStack(this.props.stackName);
+
+ this.setState({
+ // eslint-disable-next-line react/no-access-state-in-setstate
+ outputs: [...this.state.outputs, JSON.stringify(results)],
+ isStackExecuting: !results.isDone,
+ });
+ };
+
+ render() {
+ return ;
+ }
+}
+CfnStackOutputContainer.propTypes = {
+ stackName: PropTypes.string.isRequired,
+ // eslint-disable-next-line react/forbid-prop-types
+ cfn: PropTypes.object.isRequired,
+};
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/PinInput.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/PinInput.js
new file mode 100644
index 0000000000..3c1f1c6c5d
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/helpers/PinInput.js
@@ -0,0 +1,24 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import Input from '@aws-ee/base-ui/dist/parts/helpers/fields/Input';
+
+export default class PinInput extends React.PureComponent {
+ render() {
+ const pin = this.props.form.$('pin');
+ return ;
+ }
+}
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/projects/AddProject.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/projects/AddProject.js
new file mode 100644
index 0000000000..71afe57ef7
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/projects/AddProject.js
@@ -0,0 +1,213 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { inject, observer } from 'mobx-react';
+import { withRouter } from 'react-router-dom';
+import { decorate, observable, action, runInAction } from 'mobx';
+import { Button, Dimmer, Header, List, Loader, Dropdown, Segment } from 'semantic-ui-react';
+import _ from 'lodash';
+
+import { displayError } from '@aws-ee/base-ui/dist/helpers/notification';
+import { createLink } from '@aws-ee/base-ui/dist/helpers/routing';
+import validate from '@aws-ee/base-ui/dist/models/forms/Validate';
+
+import { getAddProjectForm, getAddProjectFormFields } from '../../models/forms/AddProjectForm';
+
+class AddProject extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ // eslint-disable-next-line react/no-unused-state
+ role: 'guest',
+ // eslint-disable-next-line react/no-unused-state
+ status: 'active',
+ indexId: '',
+ };
+
+ runInAction(() => {
+ this.formProcessing = false;
+ this.validationErrors = new Map();
+ this.project = {};
+ });
+ this.form = getAddProjectForm();
+ this.addProjectFormFields = getAddProjectFormFields();
+ }
+
+ goto(pathname) {
+ const location = this.props.location;
+ const link = createLink({ location, pathname });
+ this.props.history.push(link);
+ }
+
+ render() {
+ return (
+
+ );
+ }
+
+ handleCancel = action(event => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.formProcessing = false;
+ this.goto('/accounts');
+ });
+
+ handleSubmit = action(async () => {
+ this.formProcessing = true;
+ try {
+ // Perform client side validations first
+ const validationResult = await validate(this.project, this.addProjectFormFields);
+ // if there are any client side validation errors then do not attempt to make API call
+ if (validationResult.fails()) {
+ runInAction(() => {
+ this.validationErrors = validationResult.errors;
+ this.formProcessing = false;
+ });
+ } else {
+ // There are no client side validation errors so ask the store to add user (which will make API call to server to add the user)
+ this.project.indexId = this.state.indexId;
+ await this.props.projectsStore.addProject(this.project);
+ runInAction(() => {
+ this.formProcessing = false;
+ });
+ this.goto('/accounts');
+ }
+ } catch (error) {
+ runInAction(() => {
+ this.formProcessing = false;
+ });
+ displayError(error);
+ }
+ });
+
+ getStore() {
+ return this.props.usersStore;
+ }
+}
+
+// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da
+decorate(AddProject, {
+ formProcessing: observable,
+ user: observable,
+ validationErrors: observable,
+});
+export default inject('usersStore', 'indexesStore', 'projectsStore')(withRouter(observer(AddProject)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/projects/ProjectsList.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/projects/ProjectsList.js
new file mode 100644
index 0000000000..78ea7956c2
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/projects/ProjectsList.js
@@ -0,0 +1,131 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { Button, Container, Header, Icon, Label } from 'semantic-ui-react';
+import { withRouter } from 'react-router-dom';
+import { inject, observer } from 'mobx-react';
+import ReactTable from 'react-table';
+
+import { isStoreError, isStoreLoading } from '@aws-ee/base-ui/dist/models/BaseStore';
+import ErrorBox from '@aws-ee/base-ui/dist/parts/helpers/ErrorBox';
+import { createLink } from '@aws-ee/base-ui/dist/helpers/routing';
+import BasicProgressPlaceholder from '@aws-ee/base-ui/dist/parts/helpers/BasicProgressPlaceholder';
+
+class ProjectsList extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {};
+ }
+
+ getProjectsStore() {
+ const store = this.props.projectsStore;
+ store.load();
+ return store;
+ }
+
+ getProjects() {
+ const store = this.getProjectsStore();
+ return store.list;
+ }
+
+ renderMain() {
+ const projectsData = this.getProjects();
+ const pageSize = projectsData.length;
+ const pagination = projectsData.length > pageSize;
+ return (
+
+ );
+ }
+
+ renderStepsProgress() {
+ return ;
+ }
+
+ renderStudyTabs() {
+ const isExternalUser = this.isExternalUser;
+ const getMenuItemLabel = category => {
+ const store = this.getStudiesStore(category);
+ const emptySpan = null;
+ if (!store) return emptySpan;
+ if (isStoreError(store)) return emptySpan;
+ if (isStoreNew(store)) return emptySpan;
+ if (isStoreLoading(store)) return emptySpan;
+ return ;
+ };
+
+ // Create tab panes for each study category. If the user is not external user, then myStudies pane should not be shown
+ const applicableCategories = _.filter(categories, category => {
+ if (category.id === 'my-studies' && isExternalUser) return false;
+ return true;
+ });
+
+ const studyPanes = _.map(applicableCategories, category => ({
+ menuItem: (
+
+ {category.name} {getMenuItemLabel(category)}
+
+ ),
+ render: () => (
+
+ {() => }
+
+ ),
+ }));
+
+ return ;
+ }
+
+ renderSelection() {
+ const selection = this.props.filesSelection;
+ const empty = selection.empty;
+ const count = selection.count;
+ const canCreateStudy = this.canCreateStudy;
+ const canSelectStudy = this.canSelectStudy;
+ const hasProjects = this.hasProjects;
+
+ if (empty && canCreateStudy && canSelectStudy && hasProjects) {
+ return this.renderWarningWithButton({
+ content: (
+ <>
+ Select one or more studies to proceed to the next step or create a study by clicking on Create Study{' '}
+ button at the top.
+ >
+ ),
+ });
+ }
+
+ if (empty && canCreateStudy && canSelectStudy && !hasProjects) {
+ return this.renderWarning({
+ header: 'Missing association with one or more projects!',
+ content:
+ "You won't be able to select or create studies because you currently don't have any association with one or more projects, please contact your administrator.",
+ });
+ }
+
+ if (empty && canSelectStudy && !canCreateStudy) {
+ return this.renderWarningWithButton({
+ content: 'Select one or more studies to proceed to the next step.',
+ });
+ }
+
+ if (empty) {
+ return this.renderWarning({
+ header: 'Limited access',
+ content:
+ 'You currently have limited access and will not be able to select studies to proceed to the next step.',
+ });
+ }
+
+ return (
+
+
+
+ );
+ }
+}
+decorate(DragDrop, {
+ authenticationProviderConfigsStore: computed,
+ userRolesStore: computed,
+ usersStore: computed,
+ userStore: computed,
+ formProcessing: observable,
+});
+
+export default inject(
+ 'userStore',
+ 'usersStore',
+ 'userRolesStore',
+ 'authenticationProviderConfigsStore',
+)(withRouter(observer(DragDrop)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/RolesList.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/RolesList.js
new file mode 100644
index 0000000000..81c2f1d1b5
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/RolesList.js
@@ -0,0 +1,141 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { Container, Header, Icon, Label } from 'semantic-ui-react';
+import { withRouter } from 'react-router-dom';
+import { decorate, observable, runInAction } from 'mobx';
+import { inject, observer } from 'mobx-react';
+import ReactTable from 'react-table';
+
+import { isStoreError, isStoreLoading } from '@aws-ee/base-ui/dist/models/BaseStore';
+import ErrorBox from '@aws-ee/base-ui/dist/parts/helpers/ErrorBox';
+import { createLink } from '@aws-ee/base-ui/dist/helpers/routing';
+import BasicProgressPlaceholder from '@aws-ee/base-ui/dist/parts/helpers/BasicProgressPlaceholder';
+
+class RolesList extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {};
+ runInAction(() => {
+ // An object that keeps track of which user is being edited
+ // Each key in the object below has key as user's unique id (/)
+ // and value as flag indicating whether to show the editor for the user
+ this.mapOfUsersBeingEdited = {};
+ this.formProcessing = false;
+ });
+ }
+
+ getUserRolesStore() {
+ const store = this.props.userRolesStore;
+ return store;
+ }
+
+ getUserRoles() {
+ const store = this.getUserRolesStore();
+ return store.list;
+ }
+
+ renderMain() {
+ const userRolesData = this.getUserRoles();
+ const pageSize = userRolesData.length;
+ const showPagination = userRolesData.length > pageSize;
+ return (
+
+ Please execute the following steps to create a new IAM User that you will use to supply credentials to the
+ Research Portal so that we can launch workspaces in your AWS account:
+
+ Please execute the following steps to create a new IAM Policy that will provide permissions to the Research
+ Portal so that we can launch workspaces in your AWS account:
+
+ Please attach the credentials file that you downloaded earlier. This contains your new IAM User credentials.
+ The default name for this file is credentials.csv but your browser may have saved it under a different name.
+
+ Finally, we will locally encrypt your AWS credentials with a PIN and then store the encrypted credentials in
+ the Research Portal. This will allow you to launch research workspaces using just a PIN.
+
+ {ListOnboardingEncrypt}
+
+
+
+ );
+ }
+
+ renderOnboardingStep(step) {
+ switch (step) {
+ case steps.IAM_USER:
+ return this.renderOnboardingIAMUser();
+ case steps.CREDENTIALS:
+ return this.renderOnboardingCredentials();
+ case steps.IAM_POLICY:
+ return this.renderOnboardingIAMPolicy();
+ case steps.ENCRYPT:
+ return this.renderOnboardingEncrypt();
+ default:
+ return `Unexpected step: ${step}`;
+ }
+ }
+
+ render() {
+ return (
+ <>
+
+ Configure AWS Credentials
+
+
+
+ {this.renderOnboardingStep(this.onboardingStep)}
+ {this.renderOnboardingButtons()}
+
+ >
+ );
+ }
+}
+
+decorate(UserOnboarding, {
+ onboardingNext: action,
+ onboardingPrev: action,
+ resetOnboarding: action,
+ togglePolicy: action,
+ handleCopyPolicy: action,
+ handleCredentialsReset: action,
+ onCredentialsDrop: action,
+ onRegionChange: action,
+ user: observable,
+ credentials: observable,
+ credentialsValid: observable,
+ activePolicy: observable,
+ copiedPolicy: observable,
+ onboardingStep: observable,
+ onboardingSteps: observable,
+});
+export default inject('userStore', 'usersStore')(withRouter(observer(UserOnboarding)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UserPinModal.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UserPinModal.js
new file mode 100644
index 0000000000..5a6127925b
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UserPinModal.js
@@ -0,0 +1,94 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { decorate, observable, runInAction } from 'mobx';
+import { observer } from 'mobx-react';
+import { Modal, Form, Header, Button } from 'semantic-ui-react';
+import PropTypes from 'prop-types';
+
+class UserPinModal extends React.Component {
+ constructor(props) {
+ super(props);
+
+ runInAction(() => {
+ this.errorMsg = undefined;
+ });
+ }
+
+ handlePinSubmission = async e => {
+ e.preventDefault();
+ e.persist();
+
+ // Will throw error if PIN is incorrect
+ try {
+ await this.props.user.unencryptedCreds(e.target.pin.value);
+ runInAction(() => {
+ this.errorMsg = undefined;
+ });
+ this.props.hideModal();
+ } catch (error) {
+ runInAction(() => {
+ this.errorMsg = error.message;
+ });
+ }
+ };
+
+ render() {
+ return (
+
+
+
+ {this.props.message}
+
+
+
+
+
+
+
+ );
+ }
+}
+UserPinModal.propTypes = {
+ show: PropTypes.bool.isRequired,
+ hideModal: PropTypes.func.isRequired,
+ // eslint-disable-next-line react/forbid-prop-types
+ user: PropTypes.object.isRequired,
+ message: PropTypes.string,
+};
+UserPinModal.defaultProps = {
+ message: '',
+};
+
+decorate(UserPinModal, {
+ errorMsg: observable,
+});
+
+export default observer(UserPinModal);
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UserTable.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UserTable.js
new file mode 100644
index 0000000000..ed11041c17
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UserTable.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { withRouter } from 'react-router-dom';
+import { observer } from 'mobx-react';
+// Import React Table
+import ReactTable from 'react-table';
+
+// eslint-disable-next-line react/prefer-stateless-function
+class UserTable extends React.Component {
+ render() {
+ const { userData } = this.props;
+ return (
+
+
+
+
+ );
+ }
+}
+
+export default withRouter(observer(UserTable));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UsersList.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UsersList.js
new file mode 100644
index 0000000000..4343b2fe37
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/users/UsersList.js
@@ -0,0 +1,333 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import { Button, Container, Header, Icon, Label, Dimmer, Loader, Segment, Popup } from 'semantic-ui-react';
+import { withRouter } from 'react-router-dom';
+import { decorate, observable, runInAction, action } from 'mobx';
+import { inject, observer } from 'mobx-react';
+import ReactTable from 'react-table';
+import { swallowError } from '@aws-ee/base-ui/dist/helpers/utils';
+import { displayWarning } from '@aws-ee/base-ui/dist/helpers/notification';
+import { isStoreError, isStoreLoading, isStoreReady } from '@aws-ee/base-ui/dist/models/BaseStore';
+import { createLink } from '@aws-ee/base-ui/dist/helpers/routing';
+import ErrorBox from '@aws-ee/base-ui/dist/parts/helpers/ErrorBox';
+import BasicProgressPlaceholder from '@aws-ee/base-ui/dist/parts/helpers/BasicProgressPlaceholder';
+
+import UpdateUser from './UpdateUser';
+
+class UsersList extends React.Component {
+ constructor(props) {
+ super(props);
+ this.notifyNonRootUsers = true;
+ this.state = {
+ // eslint-disable-next-line react/no-unused-state
+ selectedRole: '',
+ // eslint-disable-next-line react/no-unused-state
+ projectId: [],
+ // eslint-disable-next-line react/no-unused-state
+ identityProviderName: '',
+ // eslint-disable-next-line react/no-unused-state
+ isIdentityProviderNameChanged: false,
+ // eslint-disable-next-line react/no-unused-state
+ unchangedIdentityProviderName: '',
+ };
+ runInAction(() => {
+ // An object that keeps track of which user is being edited
+ // Each key in the object below has key as user's unique id (/)
+ // and value as flag indicating whether to show the editor for the user
+ this.mapOfUsersBeingEdited = {};
+ this.formProcessing = false;
+ });
+ }
+
+ componentDidMount() {
+ const store = this.getStore();
+ swallowError(store.load());
+ store.startHeartbeat();
+ }
+
+ componentWillUnmount() {
+ const store = this.getStore();
+ store.stopHeartbeat();
+ }
+
+ getStore() {
+ return this.props.usersStore;
+ }
+
+ goto(pathname) {
+ const { location, history } = this.props;
+ const link = createLink({ location, pathname });
+ history.push(link);
+ }
+
+ handleAddUser = () => {
+ this.goto('/users/add');
+ };
+
+ handleAddLocalUser = () => {
+ this.goto('/users/add/local');
+ };
+
+ handleAddAuthenticationProvider = () => {
+ this.goto('/authentication-providers');
+ };
+
+ getAwsAccountOptions() {
+ const accountStore = this.props.awsAccountsStore;
+ return accountStore.dropdownOptions;
+ }
+
+ renderHeader() {
+ return (
+
+
+
+
+ Users
+ {this.renderTotal()}
+
+
+
+
+
+ );
+ }
+
+ renderTotal() {
+ const store = this.getStore();
+ if (isStoreError(store) || isStoreLoading(store)) return null;
+ const nonRootUsers = store.nonRootUsers;
+ const count = nonRootUsers.length;
+
+ return ;
+ }
+
+ renderMain() {
+ return this.renderUsers();
+ }
+
+ renderUsers() {
+ // Read "this.mapOfUsersBeingEdited" in the "render" method here
+ // The usersBeingEditedMap is then used in the ReactTable
+ // If we directly use this.mapOfUsersBeingEdited in the ReactTable's cell method, MobX does not
+ // realize that it is being used in the outer component's "render" method's scope
+ // Due to this, MobX does not re-render the component when observable state changes.
+ // To make this work correctly, we need to access "this.mapOfUsersBeingEdited" out side of ReactTable once
+
+ const store = this.getStore();
+ const usersList = store.list;
+ const pageSize = usersList.length;
+ const showPagination = usersList.length > pageSize;
+ const processing = this.formProcessing;
+
+ return (
+ // TODO: add api token stats and active flag here in the table
+
+
+ Updating
+
+ {
+ const columnValue = String(row[filter.id]).toLowerCase();
+ const filterValue = filter.value.toLowerCase();
+ return columnValue.indexOf(filterValue) >= 0;
+ }}
+ columns={[
+ {
+ Header: 'Name',
+ accessor: 'username',
+ width: 200,
+ },
+ {
+ Header: 'Email',
+ accessor: 'email',
+ width: 200,
+ },
+ {
+ Header: 'Identity Provider',
+ accessor: 'identityProviderName',
+ Cell: row => {
+ const user = row.original;
+ return user.identityProviderName || 'internal';
+ },
+ },
+ {
+ Header: 'Type',
+ accessor: 'isExternalUser',
+ width: 100,
+ Cell: row => {
+ const user = row.original;
+ return user.isExternalUser ? 'External' : 'Internal';
+ },
+ filterMethod: filter => {
+ if (filter.value.toLowerCase().includes('ex')) {
+ return false;
+ }
+ return true;
+ },
+ },
+ {
+ Header: 'Role',
+ accessor: 'userRole',
+ width: 100,
+ style: { whiteSpace: 'unset' },
+ Cell: row => {
+ const user = row.original;
+ return user.userRole || 'N/A';
+ },
+ },
+ {
+ Header: 'Project',
+ style: { whiteSpace: 'unset' },
+ Cell: row => {
+ const user = row.original;
+ return user.projectId.join(', ') || '<>';
+ },
+ },
+ {
+ Header: 'Status',
+ accessor: 'isActive',
+ width: 100,
+ Cell: row => {
+ const user = row.original;
+ let lable = null;
+ if (user.status === 'active') {
+ lable = (
+
+
+
+ );
+ } else if (user.status === 'inactive') {
+ lable = (
+
+
+
+ );
+ } else {
+ lable = (
+
+
+
+ );
+ }
+ return lable;
+ },
+ filterMethod: (filter, row) => {
+ if (row._original.status.indexOf(filter.value.toLowerCase()) >= 0) {
+ return true;
+ }
+ return false;
+ },
+ },
+ {
+ Header: '',
+ filterable: false,
+ Cell: cell => {
+ const user = cell.original;
+ return (
+
+
+
+ }
+ />
+
+
+ );
+ },
+ },
+ ]}
+ />
+
+ );
+ }
+
+ render() {
+ const store = this.getStore();
+ let content = null;
+ if (isStoreError(store)) {
+ content = ;
+ } else if (isStoreLoading(store)) {
+ content = ;
+ } else if (isStoreReady(store)) {
+ if (!store.hasNonRootUsers) {
+ if (this.notifyNonRootUsers) {
+ this.notifyNonRootUsers = false;
+ displayWarning(
+ 'Please add users in the Data Lake. May need to configure authentication provider in the Auth tab at left. Then login as a regular non-root User.',
+ );
+ }
+ }
+ content = this.renderMain();
+ }
+
+ return (
+
+ {this.renderHeader()}
+ {content}
+
+ );
+ }
+}
+
+// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da
+decorate(UsersList, {
+ mapOfUsersBeingEdited: observable,
+ formProcessing: observable,
+ handleAddUser: action,
+ handleAddAuthenticationProvider: action,
+ handleAddLocalUser: action,
+});
+
+export default inject(
+ 'userStore',
+ 'usersStore',
+ 'userRolesStore',
+ 'awsAccountsStore',
+ 'projectsStore',
+)(withRouter(observer(UsersList)));
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/app-component-plugin.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/app-component-plugin.js
new file mode 100644
index 0000000000..c286c6b0fb
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/app-component-plugin.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import UserApplication from '../parts/UserApplication';
+
+// eslint-disable-next-line consistent-return, no-unused-vars
+function getAppComponent({ location, appContext }) {
+ const app = appContext.app || {};
+ // We are only going to return an App react component if the user is authenticated
+ // and not registered, otherwise we return undefined which means that the base ui
+ // plugin will provide its default App react component.
+ if (app.userAuthenticated && !app.userRegistered) {
+ return UserApplication;
+ }
+}
+
+const plugin = {
+ getAppComponent,
+};
+
+export default plugin;
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/app-context-items-plugin.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/app-context-items-plugin.js
new file mode 100644
index 0000000000..0b6dd685b3
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/app-context-items-plugin.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import * as app from '../models/App';
+import * as userStore from '../models/users/UserStore';
+import * as usersStore from '../models/users/UsersStore';
+import * as accountsStore from '../models/accounts/AccountsStore';
+import * as awsAccountsStore from '../models/aws-accounts/AwsAccountsStore';
+import * as clientInformationStore from '../models/client-info/ClientInformationStore';
+import * as environment from '../models/environments/Environment';
+import * as environmentConfigurationsStore from '../models/environments/EnvironmentConfigurationsStore';
+import * as environmentsStore from '../models/environments/EnvironmentsStore';
+import * as fileUploadsStore from '../models/files/FileUploadsStore';
+import * as indexesStore from '../models/indexes/IndexesStore';
+import * as projectsStore from '../models/projects/ProjectsStore';
+import * as filesSelection from '../models/selections/FilesSelection';
+import * as studiesStore from '../models/studies/StudiesStore';
+import * as userRolesStore from '../models/user-roles/UserRolesStore';
+import * as computePlatformsStore from '../models/compute/ComputePlatformsStore';
+
+// eslint-disable-next-line no-unused-vars
+function registerAppContextItems(appContext) {
+ app.registerContextItems(appContext);
+ userStore.registerContextItems(appContext);
+ usersStore.registerContextItems(appContext);
+ accountsStore.registerContextItems(appContext);
+ awsAccountsStore.registerContextItems(appContext);
+ clientInformationStore.registerContextItems(appContext);
+ environment.registerContextItems(appContext);
+ environmentConfigurationsStore.registerContextItems(appContext);
+ environmentsStore.registerContextItems(appContext);
+ fileUploadsStore.registerContextItems(appContext);
+ indexesStore.registerContextItems(appContext);
+ projectsStore.registerContextItems(appContext);
+ filesSelection.registerContextItems(appContext);
+ studiesStore.registerContextItems(appContext);
+ userRolesStore.registerContextItems(appContext);
+ computePlatformsStore.registerContextItems(appContext);
+}
+
+// eslint-disable-next-line no-unused-vars
+function postRegisterAppContextItems(appContext) {
+ // No impl at this level
+}
+
+const plugin = {
+ registerAppContextItems,
+ postRegisterAppContextItems,
+};
+
+export default plugin;
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/initialization-plugin.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/initialization-plugin.js
new file mode 100644
index 0000000000..663e93bff4
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/initialization-plugin.js
@@ -0,0 +1,48 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+
+/**
+ * This is where we run the post initialization logic that is specific to RaaS.
+ *
+ * @param payload A free form object. This function expects a property named 'tokenInfo' to be available on the payload object.
+ * @param appContext An application context object containing various Mobx Stores, Models etc.
+ *
+ * @returns {Promise}
+ */
+async function postInit(payload, appContext) {
+ const tokenNotExpired = _.get(payload, 'tokenInfo.status') === 'notExpired';
+ if (!tokenNotExpired) return; // Continue only if we have a token that is not expired
+
+ const { userStore, usersStore, awsAccountsStore, userRolesStore, indexesStore, projectsStore } = appContext;
+
+ // TODO: Load these stores as needed instead of on startup
+ if (userStore.user.status === 'active') {
+ await Promise.all([
+ usersStore.load(),
+ awsAccountsStore.load(),
+ userRolesStore.load(),
+ indexesStore.load(),
+ projectsStore.load(),
+ ]);
+ }
+}
+
+const plugin = {
+ postInit,
+};
+
+export default plugin;
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/menu-items-plugin.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/menu-items-plugin.js
new file mode 100644
index 0000000000..4ff6c1cbe9
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/menu-items-plugin.js
@@ -0,0 +1,54 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+/**
+ * Adds navigation menu items to the given itemsMap.
+ *
+ * @param itemsMap A Map containing navigation items. This object is a Map that has route paths (urls) as
+ * keys and menu item object with the following shape
+ *
+ * {
+ * title: STRING, // Title for the navigation menu item
+ * icon: STRING, // semantic ui icon name fot the navigation menu item
+ * shouldShow: BOOLEAN || FUNCTION, // A flag or a function that returns a flag indicating whether to show the item or not (useful when showing menu items conditionally)
+ * render: OPTIONAL FUNCTION, // Optional function that returns rendered menu item component. Use this ONLY if you want to control full rendering of the menu item.
+ * }
+ *
+ * @param context A context object containing all various stores
+ *
+ * @returns Map<*> Returns A Map containing navigation menu items with the same shape as "itemsMap"
+ */
+// eslint-disable-next-line no-unused-vars
+function registerMenuItems(itemsMap, { location, appContext }) {
+ const isAdmin = _.get(appContext, 'userStore.user.isAdmin');
+ const canCreateWorkspaces = _.get(appContext, 'userStore.user.capabilities.canCreateWorkspace');
+ const canViewDashboard = _.get(appContext, 'userStore.user.capabilities.canViewDashboard');
+ const items = new Map([
+ ..._.filter([...itemsMap], item => {
+ if (item[0] === '/dashboard' && !canViewDashboard) return false;
+ return true;
+ }),
+ ['/accounts', { title: 'Accounts', icon: 'sitemap', shouldShow: isAdmin }],
+ ['/studies', { title: 'Studies', icon: 'book', shouldShow: true }],
+ ['/workspaces', { title: 'Workspaces', icon: 'server', shouldShow: canCreateWorkspaces }],
+ ]);
+
+ return items;
+}
+const plugin = {
+ registerMenuItems,
+};
+export default plugin;
diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/routes-plugin.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/routes-plugin.js
new file mode 100644
index 0000000000..cc90053005
--- /dev/null
+++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/plugins/routes-plugin.js
@@ -0,0 +1,100 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import withAuth from '@aws-ee/base-ui/dist/withAuth';
+
+import User from '../parts/users/User';
+import Accounts from '../parts/accounts/Accounts';
+import AddUser from '../parts/users/AddUser';
+import AddIndex from '../parts/accounts/AddIndex';
+import Dashboard from '../parts/dashboard/Dashboard';
+import StudiesPage from '../parts/studies/StudiesPage';
+import StudyEnvironmentSetup from '../parts/studies/StudyEnvironmentSetup';
+import EnvironmentsList from '../parts/environments/EnvironmentsList';
+import EnvironmentDetailPage from '../parts/environments/EnvironmentDetailPage';
+import AddAwsAccount from '../parts/accounts/AddAwsAccount';
+import CreateAwsAccount from '../parts/accounts/CreateAwsAccount';
+import EnvironmentSetup from '../parts/environments/EnvironmentSetup';
+import AddProject from '../parts/projects/AddProject';
+import AddSingleLocalUser from '../parts/users/AddSingleLocalUser';
+
+/**
+ * Adds routes to the given routesMap.
+ * @param routesMap A Map containing routes. This object is a Map that has route paths as
+ * keys and React Component as value.
+ *
+ * @returns {Promise<*>} Returns a Map with the mapping of base routes vs React Component
+ */
+// eslint-disable-next-line no-unused-vars
+function registerRoutes(routesMap, { location, appContext }) {
+ // Temporary solution for the routes ordering issue
+ routesMap.delete('/users/add');
+ routesMap.delete('/users');
+
+ const routes = new Map([
+ ...routesMap,
+ ['/users/add/local', withAuth(AddSingleLocalUser)],
+ ['/users/add', withAuth(AddUser)],
+ ['/users', withAuth(User)],
+ ['/indexes/add', withAuth(AddIndex)],
+ ['/aws-accounts/add', withAuth(AddAwsAccount)],
+ ['/aws-accounts/create', withAuth(CreateAwsAccount)],
+ ['/accounts', withAuth(Accounts)],
+ ['/dashboard', withAuth(Dashboard)],
+ ['/studies/setup-workspace', withAuth(StudyEnvironmentSetup)],
+ ['/studies', withAuth(StudiesPage)],
+ ['/workspaces/create', withAuth(EnvironmentSetup)],
+ ['/workspaces/id/:instanceId', withAuth(EnvironmentDetailPage)],
+ ['/workspaces', withAuth(EnvironmentsList)],
+ ['/projects/add', withAuth(AddProject)],
+ ]);
+
+ return routes;
+}
+
+function getDefaultRouteLocation({ location, appContext }) {
+ const userStore = appContext.userStore;
+ let defaultRoute = '/dashboard';
+ const isRootUser = _.get(userStore, 'user.isRootUser');
+ const isExternalResearcher = _.get(userStore, 'user.isExternalResearcher');
+ const isInternalGuest = _.get(userStore, 'user.isInternalGuest');
+ const isExternalGuest = _.get(userStore, 'user.isExternalGuest');
+
+ if (isRootUser) {
+ defaultRoute = '/users';
+ } else if (isExternalResearcher) {
+ defaultRoute = '/workspaces';
+ } else if (isInternalGuest || isExternalGuest) {
+ defaultRoute = '/studies';
+ }
+
+ // See https://reacttraining.com/react-router/web/api/withRouter
+ const defaultLocation = {
+ pathname: defaultRoute,
+ search: location.search, // we want to keep any query parameters
+ hash: location.hash,
+ state: location.state,
+ };
+
+ return defaultLocation;
+}
+
+const plugin = {
+ registerRoutes,
+ getDefaultRouteLocation,
+};
+
+export default plugin;
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/.babelrc b/addons/addon-base-raas/packages/base-raas-cfn-templates/.babelrc
new file mode 100644
index 0000000000..ce25841fe7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/.babelrc
@@ -0,0 +1,9 @@
+{
+ "plugins": [
+ ["babel-plugin-inline-import", {
+ "extensions": [
+ ".cfn.yml"
+ ]
+ }]
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/.eslintrc.json b/addons/addon-base-raas/packages/base-raas-cfn-templates/.eslintrc.json
new file mode 100644
index 0000000000..c700cf03a8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/.eslintrc.json
@@ -0,0 +1,26 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0,
+ "import/no-unresolved": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/.gitignore b/addons/addon-base-raas/packages/base-raas-cfn-templates/.gitignore
new file mode 100644
index 0000000000..dd5b3275fd
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/.gitignore
@@ -0,0 +1,19 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# transpiled code
+dist
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/.prettierrc.json b/addons/addon-base-raas/packages/base-raas-cfn-templates/.prettierrc.json
new file mode 100644
index 0000000000..4ee7b34147
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/.prettierrc.json
@@ -0,0 +1,8 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
+
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/jest.config.js b/addons/addon-base-raas/packages/base-raas-cfn-templates/jest.config.js
new file mode 100644
index 0000000000..3f7ffc8068
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/jsconfig.json b/addons/addon-base-raas/packages/base-raas-cfn-templates/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/package.json b/addons/addon-base-raas/packages/base-raas-cfn-templates/package.json
new file mode 100644
index 0000000000..a0d21f682b
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@aws-ee/base-raas-cfn-templates",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Contains base RaaS CFN templates",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "@babel/cli": "^7.8.4",
+ "@babel/core": "^7.9.0",
+ "babel-plugin-inline-import": "^3.0.0",
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb": "^18.1.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "babel": "babel src/ --out-dir dist/ --source-maps",
+ "babel:watch": "babel src/ --out-dir dist/ --source-maps --watch",
+ "build": "pnpm run babel",
+ "build:watch": "pnpm run babel:watch",
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "prepare": "pnpm run build"
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/plugins/cfn-templates-plugin.js b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/plugins/cfn-templates-plugin.js
new file mode 100644
index 0000000000..f137ca8d9b
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/plugins/cfn-templates-plugin.js
@@ -0,0 +1,42 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// We are using Babel in this module to allow importing ".cfn.yml" files as plain text instead of parsing them webpack
+// "yaml-loader", because in this case we want the text value not the parsed yaml as an object.
+import ec2LinuxInstance from '../templates/ec2-linux-instance.cfn.yml';
+import ec2WindowsInstance from '../templates/ec2-windows-instance.cfn.yml';
+import sagemakerInstance from '../templates/sagemaker-notebook-instance.cfn.yml';
+import emrCluster from '../templates/emr-cluster.cfn.yml';
+import onboardAccount from '../templates/onboard-account.cfn.yml';
+
+const add = (name, yaml) => ({ name, yaml });
+
+// The order is important, add your templates here
+const templates = [
+ add('ec2-linux-instance', ec2LinuxInstance),
+ add('ec2-windows-instance', ec2WindowsInstance),
+ add('sagemaker-notebook-instance', sagemakerInstance),
+ add('emr-cluster', emrCluster),
+ add('onboard-account', onboardAccount),
+];
+
+async function registerCfnTemplates(registry) {
+ // eslint-disable-next-line no-restricted-syntax
+ for (const template of templates) {
+ await registry.add(template); // eslint-disable-line no-await-in-loop
+ }
+}
+
+module.exports = { registerCfnTemplates };
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/ec2-linux-instance.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/ec2-linux-instance.cfn.yml
new file mode 100644
index 0000000000..b2a52e6f5e
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/ec2-linux-instance.cfn.yml
@@ -0,0 +1,182 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway EC2-Linux
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+ AmiId:
+ Type: String
+ Description: Amazon Machine Image for the EC2 instance
+ InstanceType:
+ Type: String
+ Description: EC2 instance type to launch
+ Default: t3.xlarge
+ KeyName:
+ Type: String
+ Description: Keypair name for SSH access
+ AccessFromCIDRBlock:
+ Type: String
+ Description: The CIDR used to access the ec2 instances.
+ S3Mounts:
+ Type: String
+ Description: A JSON array of objects with name, bucket, and prefix properties used to mount data
+ IamPolicyDocument:
+ Type: String
+ Description: The IAM policy to be associated with the launched workstation
+ VPC:
+ Description: The VPC in which the EC2 instance will reside
+ Type: AWS::EC2::VPC::Id
+ Subnet:
+ Description: The VPC subnet in which the EC2 instance will reside
+ Type: AWS::EC2::Subnet::Id
+ EnvironmentInstanceFiles:
+ Type: String
+ Description: >-
+ An S3 URI (starting with "s3://") that specifies the location of files to be copied to
+ the environment instance, including any bootstrap scripts
+ EncryptionKeyArn:
+ Type: String
+ Description: The ARN of the KMS encryption Key used to encrypt data in the instance
+
+Conditions:
+ IamPolicyEmpty: !Equals [!Ref IamPolicyDocument, '{}']
+
+Resources:
+ IAMRole:
+ Type: 'AWS::IAM::Role'
+ Properties:
+ RoleName: !Join ['-', [Ref: Namespace, 'ec2-role']]
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Principal:
+ Service:
+ - 'ec2.amazonaws.com'
+ Action:
+ - 'sts:AssumeRole'
+ Policies:
+ - !If
+ - IamPolicyEmpty
+ - !Ref 'AWS::NoValue'
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-studydata-policy']]
+ PolicyDocument: !Ref IamPolicyDocument
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-bootstrap-script-policy']]
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Action: 's3:GetObject'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Location}/*'
+ # Remove "s3://" prefix from EnvironmentInstanceFiles
+ - S3Location: !Select [1, !Split ['s3://', !Ref EnvironmentInstanceFiles]]
+ - Effect: 'Allow'
+ Action: 's3:ListBucket'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Bucket}'
+ - S3Bucket: !Select [2, !Split ['/', !Ref EnvironmentInstanceFiles]]
+ Condition:
+ StringLike:
+ s3:prefix: !Sub
+ - '${S3Prefix}/*'
+ - S3Prefix: !Select [3, !Split ['/', !Ref EnvironmentInstanceFiles]]
+
+ InstanceProfile:
+ Type: 'AWS::IAM::InstanceProfile'
+ Properties:
+ InstanceProfileName: !Join ['-', [Ref: Namespace, 'ec2-profile']]
+ Path: '/'
+ Roles:
+ - Ref: IAMRole
+
+ SecurityGroup:
+ Type: 'AWS::EC2::SecurityGroup'
+ Properties:
+ GroupDescription: EC2 workspace security group
+ SecurityGroupEgress:
+ - IpProtocol: tcp
+ FromPort: 0
+ ToPort: 65535
+ CidrIp: 0.0.0.0/0
+ - IpProtocol: icmp
+ FromPort: -1
+ ToPort: -1
+ CidrIp: !Ref AccessFromCIDRBlock
+ SecurityGroupIngress:
+ - IpProtocol: tcp
+ FromPort: 22
+ ToPort: 22
+ CidrIp: !Ref AccessFromCIDRBlock
+ - IpProtocol: tcp
+ FromPort: 80
+ ToPort: 80
+ CidrIp: !Ref AccessFromCIDRBlock
+ - IpProtocol: tcp
+ FromPort: 443
+ ToPort: 443
+ CidrIp: !Ref AccessFromCIDRBlock
+ Tags:
+ - Key: Name
+ Value: !Join ['-', [Ref: Namespace, 'ec2-sg']]
+ - Key: Description
+ Value: EC2 workspace security group
+ VpcId: !Ref VPC
+
+ EC2Instance:
+ Type: 'AWS::EC2::Instance'
+ CreationPolicy:
+ ResourceSignal:
+ Timeout: 'PT20M'
+ Properties:
+ ImageId: !Ref AmiId
+ InstanceType: !Ref InstanceType
+ IamInstanceProfile: !Ref InstanceProfile
+ KeyName: !Ref KeyName
+ BlockDeviceMappings:
+ - DeviceName: /dev/xvda
+ Ebs:
+ VolumeSize: 8
+ Encrypted: true
+ KmsKeyId: !Ref EncryptionKeyArn
+ NetworkInterfaces:
+ - AssociatePublicIpAddress: 'true'
+ DeviceIndex: '0'
+ GroupSet:
+ - !Ref SecurityGroup
+ SubnetId: !Ref Subnet
+ Tags:
+ - Key: Name
+ Value: !Join ['-', [Ref: Namespace, 'ec2-linux']]
+ - Key: Description
+ Value: EC2 workspace instance
+ UserData:
+ Fn::Base64: !Sub |
+ #!/usr/bin/env bash
+ # Download and execute bootstrap script
+ aws s3 cp "${EnvironmentInstanceFiles}/get_bootstrap.sh" "/tmp"
+ chmod 500 "/tmp/get_bootstrap.sh"
+ /tmp/get_bootstrap.sh "${EnvironmentInstanceFiles}" '${S3Mounts}'
+
+ # Signal result to CloudFormation
+ /opt/aws/bin/cfn-signal -e $? --stack "${AWS::StackName}" --resource "EC2Instance" --region "${AWS::Region}"
+
+Outputs:
+ Ec2WorkspaceDnsName:
+ Description: Public DNS name of the EC2 workspace instance
+ Value: !GetAtt [EC2Instance, PublicDnsName]
+
+ Ec2WorkspacePublicIp:
+ Description: Public IP address of the EC2 workspace instance
+ Value: !GetAtt [EC2Instance, PublicIp]
+
+ Ec2WorkspaceInstanceId:
+ Description: Instance Id for the EC2 workspace instance
+ Value: !Ref EC2Instance
+
+ WorkspaceInstanceRoleArn:
+ Description: IAM role assumed by the EC2 workspace instance
+ Value: !GetAtt IAMRole.Arn
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/ec2-windows-instance.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/ec2-windows-instance.cfn.yml
new file mode 100644
index 0000000000..c7ef80844b
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/ec2-windows-instance.cfn.yml
@@ -0,0 +1,148 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway EC2-Windows
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+ AmiId:
+ Type: String
+ Description: Amazon Machine Image for the EC2 instance
+ InstanceType:
+ Type: String
+ Description: EC2 instance type to launch
+ Default: t3.xlarge
+ KeyName:
+ Type: String
+ Description: Keypair name for admin password encryption/decryption
+ AccessFromCIDRBlock:
+ Type: String
+ Description: The CIDR used to access the ec2 instances.
+ VPC:
+ Description: The VPC in which the EC2 instance will reside
+ Type: AWS::EC2::VPC::Id
+ Subnet:
+ Description: The VPC subnet in which the EC2 instance will reside
+ Type: AWS::EC2::Subnet::Id
+ EncryptionKeyArn:
+ Type: String
+ Description: The ARN of the KMS encryption Key used to encrypt data in the instance
+
+Resources:
+ IAMRole:
+ Type: 'AWS::IAM::Role'
+ Properties:
+ RoleName: !Join ['-', [Ref: Namespace, 'ec2-role']]
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Principal:
+ Service:
+ - 'ec2.amazonaws.com'
+ Action:
+ - 'sts:AssumeRole'
+ Policies:
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-policy']]
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Action:
+ - 's3:*'
+ Resource:
+ - '*'
+
+ InstanceProfile:
+ Type: 'AWS::IAM::InstanceProfile'
+ Properties:
+ InstanceProfileName: !Join ['-', [Ref: Namespace, 'ec2-profile']]
+ Path: '/'
+ Roles:
+ - Ref: IAMRole
+
+ SecurityGroup:
+ Type: 'AWS::EC2::SecurityGroup'
+ Properties:
+ GroupDescription: EC2 workspace security group
+ SecurityGroupEgress:
+ - IpProtocol: tcp
+ FromPort: 0
+ ToPort: 65535
+ CidrIp: 0.0.0.0/0
+ - IpProtocol: icmp
+ FromPort: -1
+ ToPort: -1
+ CidrIp: !Ref AccessFromCIDRBlock
+ SecurityGroupIngress:
+ - IpProtocol: tcp
+ FromPort: 3389
+ ToPort: 3389
+ CidrIp: !Ref AccessFromCIDRBlock
+ - IpProtocol: tcp
+ FromPort: 80
+ ToPort: 80
+ CidrIp: !Ref AccessFromCIDRBlock
+ - IpProtocol: tcp
+ FromPort: 443
+ ToPort: 443
+ CidrIp: !Ref AccessFromCIDRBlock
+ Tags:
+ - Key: Name
+ Value: !Join ['-', [Ref: Namespace, 'ec2-sg']]
+ - Key: Description
+ Value: EC2 workspace security group
+ VpcId: !Ref VPC
+
+ EC2Instance:
+ Type: 'AWS::EC2::Instance'
+ CreationPolicy:
+ ResourceSignal:
+ Timeout: 'PT20M'
+ Properties:
+ ImageId: !Ref AmiId
+ InstanceType: !Ref InstanceType
+ IamInstanceProfile: !Ref InstanceProfile
+ KeyName: !Ref KeyName
+ BlockDeviceMappings:
+ - DeviceName: /dev/sda1
+ Ebs:
+ VolumeSize: 30
+ Encrypted: true
+ KmsKeyId: !Ref EncryptionKeyArn
+ NetworkInterfaces:
+ - AssociatePublicIpAddress: 'true'
+ DeviceIndex: '0'
+ GroupSet:
+ - !Ref SecurityGroup
+ SubnetId: !Ref Subnet
+ Tags:
+ - Key: Name
+ Value: !Join ['-', [Ref: Namespace, 'ec2-windows']]
+ - Key: Description
+ Value: EC2 workspace instance
+ UserData:
+ Fn::Base64: !Sub |
+
+ cmd /c "exit 0" # Automatically return success; remove this line if actual bootstrapping logic is added
+ cfn-signal.exe -e $lastexitcode --stack ${AWS::StackId} --resource EC2Instance --region ${AWS::Region}
+
+
+Outputs:
+ Ec2WorkspaceDnsName:
+ Description: Public DNS name of the EC2 workspace instance
+ Value: !GetAtt [EC2Instance, PublicDnsName]
+
+ Ec2WorkspacePublicIp:
+ Description: Public IP address of the EC2 workspace instance
+ Value: !GetAtt [EC2Instance, PublicIp]
+
+ Ec2WorkspaceInstanceId:
+ Description: Instance Id for the EC2 workspace instance
+ Value: !Ref EC2Instance
+
+ WorkspaceInstanceRoleArn:
+ Description: IAM role assumed by the EC2 workspace instance
+ Value: !GetAtt IAMRole.Arn
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/emr-cluster.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/emr-cluster.cfn.yml
new file mode 100644
index 0000000000..a3eeddf6ab
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/emr-cluster.cfn.yml
@@ -0,0 +1,317 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway EMR-Hail-Jupyter
+
+Metadata:
+ AWS::CloudFormation::Interface:
+ ParameterGroups:
+ - Label:
+ default: EMR Options
+ Parameters:
+ - Namespace
+ - KeyName
+ - VPC
+ - Subnet
+ - CoreNodeCount
+ - DiskSizeGB
+ - MasterInstanceType
+ - WorkerInstanceType
+ - WorkerBidPrice
+ - AccessFromCIDRBlock
+ - AmiId
+ - Label:
+ default: Tags
+ Parameters:
+ - NameTag
+ - OwnerTag
+ - PurposeTag
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+ KeyName:
+ Description: SSH key pair to use for EMR node login
+ Type: AWS::EC2::KeyPair::KeyName
+ VPC:
+ Description: VPC for EMR nodes.
+ Type: AWS::EC2::VPC::Id
+ Subnet:
+ Description: Subnet for EMR nodes, from the VPC selected above
+ Type: AWS::EC2::Subnet::Id
+ CoreNodeCount:
+ Description: Number of core nodes to provision (1-80)
+ Type: Number
+ MinValue: '1'
+ MaxValue: '80'
+ Default: '5'
+ DiskSizeGB:
+ Description: EBS Volume size (GB) for each node
+ Type: Number
+ MinValue: '10'
+ MaxValue: '1000'
+ Default: '20'
+ MasterInstanceType:
+ Type: String
+ Default: m5.xlarge
+ Description: EMR node ec2 instance type.
+ WorkerInstanceType:
+ Type: String
+ Default: m5.xlarge
+ Description: EMR node ec2 instance type.
+ Market:
+ Type: String
+ Default: ON_DEMAND
+ Description: Which market to purchase workers on - ON_DEMAND or SPOT.
+ WorkerBidPrice:
+ Type: String
+ Description: Bid price for the worker spot nodes.
+ AccessFromCIDRBlock:
+ Type: String
+ MinLength: 9
+ Description: Restrict WebUI access to specified address or range
+ AmiId:
+ Type: String
+ Description: Ami Id to use for the cluster
+ EnvironmentInstanceFiles:
+ Type: String
+ Description: >-
+ An S3 URI (starting with "s3://") that specifies the location of files to be copied to
+ the environment instance, including any bootstrap scripts
+ S3Mounts:
+ Type: String
+ Description: A JSON array of objects with name, bucket and prefix properties used to mount data
+ IamPolicyDocument:
+ Type: String
+ Description: The IAM policy to be associated with the launched workstation
+ EncryptionKeyArn:
+ Type: String
+ Description: The ARN of the KMS encryption Key used to encrypt data in the cluster
+
+Conditions:
+ IamPolicyEmpty: !Equals [!Ref IamPolicyDocument, '{}']
+ IsOnDemandCondition:
+ !Equals [!Ref Market, ON_DEMAND]
+
+Resources:
+
+ # TODO: Use one bucket for EMR logs per account, so shift deployment to account on-boarding and pass here as param
+ LogBucket:
+ Type: AWS::S3::Bucket
+ DeletionPolicy: Retain
+ Properties:
+ PublicAccessBlockConfiguration: # Block all public access configuration for the S3 bucket
+ BlockPublicAcls: true
+ BlockPublicPolicy: true
+ IgnorePublicAcls: true
+ RestrictPublicBuckets: true
+
+ MasterSecurityGroup:
+ Type: AWS::EC2::SecurityGroup
+ Properties:
+ GroupDescription: Jupyter
+ VpcId:
+ Ref: VPC
+ SecurityGroupIngress:
+ - IpProtocol: tcp
+ FromPort: 8192
+ ToPort: 8192
+ CidrIp:
+ Ref: AccessFromCIDRBlock
+
+ InstanceProfile:
+ Properties:
+ Path: "/"
+ Roles:
+ - Ref: Ec2Role
+ Type: AWS::IAM::InstanceProfile
+
+ Ec2Role:
+ Type: 'AWS::IAM::Role'
+ Properties:
+ RoleName: !Join ['-', [Ref: Namespace, 'ec2-role']]
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Principal:
+ Service:
+ - 'ec2.amazonaws.com'
+ Action:
+ - 'sts:AssumeRole'
+ Policies:
+ - !If
+ - IamPolicyEmpty
+ - !Ref 'AWS::NoValue'
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-studydata-policy']]
+ PolicyDocument: !Ref IamPolicyDocument
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-bootstrap-script-policy']]
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Action: 's3:GetObject'
+ Resource:
+ - 'arn:aws:s3:::us-east-1.elasticmapreduce/bootstrap-actions/run-if'
+ - !Sub
+ - 'arn:aws:s3:::${S3Location}/*'
+ # Remove "s3://" prefix from EnvironmentInstanceFiles
+ - S3Location: !Select [ 1, !Split [ 's3://', !Ref EnvironmentInstanceFiles ] ]
+ - Effect: 'Allow'
+ Action: 's3:ListBucket'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Bucket}'
+ - S3Bucket: !Select [ 2, !Split [ '/', !Ref EnvironmentInstanceFiles ]]
+ Condition:
+ StringLike:
+ s3:prefix: !Sub
+ - '${S3Prefix}/*'
+ - S3Prefix: !Select [ 3, !Split [ '/', !Ref EnvironmentInstanceFiles ]]
+
+ ServiceRole:
+ Type: AWS::IAM::Role
+ Properties:
+ Path: "/"
+ AssumeRolePolicyDocument:
+ Statement:
+ - Action:
+ - sts:AssumeRole
+ Effect: Allow
+ Principal:
+ Service:
+ - elasticmapreduce.amazonaws.com
+ Version: '2012-10-17'
+ ManagedPolicyArns:
+ - arn:aws:iam::aws:policy/service-role/AmazonElasticMapReduceRole
+
+
+ EmrSecurityConfiguration:
+ Type: AWS::EMR::SecurityConfiguration
+ Properties:
+ SecurityConfiguration: {
+ "EncryptionConfiguration": {
+ "AtRestEncryptionConfiguration": {
+ "LocalDiskEncryptionConfiguration": {
+ "EncryptionKeyProviderType": "AwsKms",
+ "AwsKmsKey": { "Ref": "EncryptionKeyArn" },
+ "EnableEbsEncryption": true
+ }
+ },
+ "EnableInTransitEncryption": false,
+ "EnableAtRestEncryption": true
+ }
+ }
+
+
+ # TODO: customise jupyter password from launch ui
+ # TODO: Add security configuration to cluster
+ # TODO: Can we make the jupyter use https?
+ # TODO: Also change notebook owner to hadoop on launch
+ EmrCluster:
+ Type: AWS::EMR::Cluster
+ Properties:
+ Applications:
+ - Name: Hadoop
+ - Name: Hive
+ - Name: Spark
+ BootstrapActions:
+ - Name: Run-Python-Jupyter
+ ScriptBootstrapAction:
+ Path: s3://us-east-1.elasticmapreduce/bootstrap-actions/run-if
+ Args:
+ - "instance.isMaster=true"
+ - "/opt/hail-on-AWS-spot-instances/src/jupyter_run.sh"
+ - Name: Mount-S3-Resources
+ ScriptBootstrapAction:
+ Path: !Sub "${EnvironmentInstanceFiles}/get_bootstrap.sh"
+ Args:
+ - !Ref EnvironmentInstanceFiles
+ - !Ref S3Mounts
+ CustomAmiId:
+ Ref: AmiId
+ Configurations:
+ - Classification: spark
+ ConfigurationProperties:
+ maximizeResourceAllocation: true
+ - Classification: yarn-site
+ ConfigurationProperties:
+ yarn.nodemanager.vmem-check-enabled: false
+ - Classification: spark-defaults
+ ConfigurationProperties:
+ spark.hadoop.io.compression.codecs: "org.apache.hadoop.io.compress.DefaultCodec,is.hail.io.compress.BGzipCodec,org.apache.hadoop.io.compress.GzipCodec"
+ spark.serializer: "org.apache.spark.serializer.KryoSerializer"
+ spark.hadoop.parquet.block.size: "1099511627776"
+ spark.sql.files.maxPartitionBytes: "1099511627776"
+ spark.sql.files.openCostInBytes: "1099511627776"
+ Configurations: []
+ Instances:
+ AdditionalMasterSecurityGroups:
+ - Fn::GetAtt:
+ - MasterSecurityGroup
+ - GroupId
+ Ec2KeyName:
+ Ref: KeyName
+ Ec2SubnetId:
+ Ref: Subnet
+ MasterInstanceGroup:
+ InstanceCount: 1
+ InstanceType:
+ Ref: MasterInstanceType
+ CoreInstanceGroup:
+ !If
+ - IsOnDemandCondition
+ -
+ InstanceCount:
+ Ref: CoreNodeCount
+ InstanceType:
+ Ref: WorkerInstanceType
+ Market:
+ Ref: Market
+ EbsConfiguration:
+ EbsOptimized: true
+ EbsBlockDeviceConfigs:
+ - VolumeSpecification:
+ SizeInGB:
+ Ref: DiskSizeGB
+ VolumeType: gp2
+ -
+ InstanceCount:
+ Ref: CoreNodeCount
+ InstanceType:
+ Ref: WorkerInstanceType
+ Market:
+ Ref: Market
+ BidPrice:
+ Ref: WorkerBidPrice
+ EbsConfiguration:
+ EbsOptimized: true
+ EbsBlockDeviceConfigs:
+ - VolumeSpecification:
+ SizeInGB:
+ Ref: DiskSizeGB
+ VolumeType: gp2
+ JobFlowRole:
+ Ref: InstanceProfile
+ Name: !Sub "${Namespace}-emr"
+ Tags: # Add Name tag so EC2 instances are easily identifiable
+ - Key: Name
+ Value: !Sub "${Namespace}-emr"
+ ServiceRole:
+ Ref: ServiceRole
+ ReleaseLabel: emr-5.27.0
+ # This has to be true because we assume a new user each time.
+ VisibleToAllUsers: true
+ SecurityConfiguration: !Ref EmrSecurityConfiguration
+ LogUri: !Sub "s3://${LogBucket}"
+
+Outputs:
+ JupyterUrl:
+ Description: Open Jupyter on your new EMR cluster
+ Value: !Sub "http://${EmrCluster.MasterPublicDNS}:8192"
+ LogBucket:
+ Description: EMR Scratch data and Logs bucket
+ Value: !Ref LogBucket
+ WorkspaceInstanceRoleArn:
+ Description: IAM role assumed by the EMR workspace instances
+ Value: !GetAtt Ec2Role.Arn
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/external/emr.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/external/emr.cfn.yml
new file mode 100644
index 0000000000..2095e768d7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/external/emr.cfn.yml
@@ -0,0 +1,336 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway EMR-Hail-Jupyter
+
+Metadata:
+ AWS::CloudFormation::Interface:
+ ParameterGroups:
+ - Label:
+ default: EMR Options
+ Parameters:
+ - Namespace
+ - KeyName
+ - VPC
+ - Subnet
+ - CoreNodeCount
+ - DiskSizeGB
+ - MasterInstanceType
+ - WorkerInstanceType
+ - WorkerBidPrice
+ - AccessFromCIDRBlock
+ - AmiId
+ - Label:
+ default: Tags
+ Parameters:
+ - NameTag
+ - OwnerTag
+ - PurposeTag
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+ KeyName:
+ Description: SSH key pair to use for EMR node login
+ Type: AWS::EC2::KeyPair::KeyName
+ VPC:
+ Description: VPC for EMR nodes.
+ Type: AWS::EC2::VPC::Id
+ Subnet:
+ Description: Subnet for EMR nodes, from the VPC selected above
+ Type: AWS::EC2::Subnet::Id
+ CoreNodeCount:
+ Description: Number of core nodes to provision (1-80)
+ Type: Number
+ MinValue: '1'
+ MaxValue: '80'
+ Default: '5'
+ DiskSizeGB:
+ Description: EBS Volume size (GB) for each node
+ Type: Number
+ MinValue: '10'
+ MaxValue: '1000'
+ Default: '20'
+ MasterInstanceType:
+ Type: String
+ Default: m5.xlarge
+ Description: EMR node ec2 instance type.
+ WorkerInstanceType:
+ Type: String
+ Default: m5.xlarge
+ Description: EMR node ec2 instance type.
+ Market:
+ Type: String
+ Default: ON_DEMAND
+ Description: Which market to purchase workers on - ON_DEMAND or SPOT.
+ WorkerBidPrice:
+ Type: String
+ Description: Bid price for the worker spot nodes.
+ AccessFromCIDRBlock:
+ Type: String
+ MinLength: 9
+ Description: Restrict WebUI access to specified address or range
+ AmiId:
+ Type: String
+ Description: Ami Id to use for the cluster
+ EnvironmentInstanceFiles:
+ Type: String
+ Description: >-
+ An S3 URI (starting with "s3://") that specifies the location of files to be copied to
+ the environment instance, including any bootstrap scripts
+ S3Mounts:
+ Type: String
+ Description: A JSON array of objects with name, bucket and prefix properties used to mount data
+ IamPolicyDocument:
+ Type: String
+ Description: The IAM policy to be associated with the launched workstation
+
+Conditions:
+ IamPolicyEmpty: !Equals [!Ref IamPolicyDocument, '{}']
+ IsOnDemandCondition: !Equals [!Ref Market, ON_DEMAND]
+
+Resources:
+ # TODO: Use one bucket for EMR logs per account, so shift deployment to account on-boarding and pass here as param
+ LogBucket:
+ Type: AWS::S3::Bucket
+ DeletionPolicy: Retain
+ Properties:
+ PublicAccessBlockConfiguration: # Block all public access configuration for the S3 bucket
+ BlockPublicAcls: true
+ BlockPublicPolicy: true
+ IgnorePublicAcls: true
+ RestrictPublicBuckets: true
+
+ EncryptionKey:
+ Type: AWS::KMS::Key
+ Properties:
+ Description: 'This is the key used to secure resources in this account'
+ EnableKeyRotation: True
+ KeyPolicy:
+ Version: '2012-10-17'
+ Statement:
+ - Sid: Allow root access
+ Effect: 'Allow'
+ Principal:
+ AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
+ Action:
+ - 'kms:*'
+ Resource: '*'
+ - Sid: Allow use of the key by this account
+ Effect: 'Allow'
+ Principal:
+ AWS: '*'
+ Action:
+ - 'kms:DescribeKey'
+ - 'kms:Encrypt'
+ - 'kms:Decrypt'
+ - 'kms:ReEncrypt*'
+ - 'kms:GenerateDataKey'
+ - 'kms:GenerateDataKeyWithoutPlaintext'
+ - 'kms:CreateGrant'
+ - 'kms:RevokeGrant'
+ Resource: '*'
+ Condition:
+ StringEquals:
+ kms:CallerAccount: !Ref 'AWS::AccountId'
+
+ MasterSecurityGroup:
+ Type: AWS::EC2::SecurityGroup
+ Properties:
+ GroupDescription: Jupyter
+ VpcId:
+ Ref: VPC
+ SecurityGroupIngress:
+ - IpProtocol: tcp
+ FromPort: 8192
+ ToPort: 8192
+ CidrIp:
+ Ref: AccessFromCIDRBlock
+
+ InstanceProfile:
+ Properties:
+ Path: '/'
+ Roles:
+ - Ref: Ec2Role
+ Type: AWS::IAM::InstanceProfile
+
+ Ec2Role:
+ Type: 'AWS::IAM::Role'
+ Properties:
+ RoleName: !Join ['-', [Ref: Namespace, 'ec2-role']]
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Principal:
+ Service:
+ - 'ec2.amazonaws.com'
+ Action:
+ - 'sts:AssumeRole'
+ Policies:
+ - !If
+ - IamPolicyEmpty
+ - !Ref 'AWS::NoValue'
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-studydata-policy']]
+ PolicyDocument: !Ref IamPolicyDocument
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-bootstrap-script-policy']]
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Action: 's3:GetObject'
+ Resource:
+ - 'arn:aws:s3:::us-east-1.elasticmapreduce/bootstrap-actions/run-if'
+ - !Sub
+ - 'arn:aws:s3:::${S3Location}/*'
+ # Remove "s3://" prefix from EnvironmentInstanceFiles
+ - S3Location: !Select [1, !Split ['s3://', !Ref EnvironmentInstanceFiles]]
+ - Effect: 'Allow'
+ Action: 's3:ListBucket'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Bucket}'
+ - S3Bucket: !Select [2, !Split ['/', !Ref EnvironmentInstanceFiles]]
+ Condition:
+ StringLike:
+ s3:prefix: !Sub
+ - '${S3Prefix}/*'
+ - S3Prefix: !Select [3, !Split ['/', !Ref EnvironmentInstanceFiles]]
+
+ ServiceRole:
+ Type: AWS::IAM::Role
+ Properties:
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Statement:
+ - Action:
+ - sts:AssumeRole
+ Effect: Allow
+ Principal:
+ Service:
+ - elasticmapreduce.amazonaws.com
+ Version: '2012-10-17'
+ ManagedPolicyArns:
+ - arn:aws:iam::aws:policy/service-role/AmazonElasticMapReduceRole
+
+ EmrSecurityConfiguration:
+ Type: AWS::EMR::SecurityConfiguration
+ Properties:
+ SecurityConfiguration:
+ EncryptionConfiguration:
+ EnableInTransitEncryption: false
+ EnableAtRestEncryption: true
+ AtRestEncryptionConfiguration:
+ LocalDiskEncryptionConfiguration:
+ EncryptionKeyProviderType: AwsKms
+ AwsKmsKey: !GetAtt EncryptionKey.Arn
+ EnableEbsEncryption: true
+
+ # TODO: customise jupyter password from launch ui
+ # TODO: Add security configuration to cluster
+ # TODO: Can we make the jupyter use https?
+ # TODO: Also change notebook owner to hadoop on launch
+ EmrCluster:
+ Type: AWS::EMR::Cluster
+ Properties:
+ Applications:
+ - Name: Hadoop
+ - Name: Hive
+ - Name: Spark
+ BootstrapActions:
+ - Name: Run-Python-Jupyter
+ ScriptBootstrapAction:
+ Path: s3://us-east-1.elasticmapreduce/bootstrap-actions/run-if
+ Args:
+ - 'instance.isMaster=true'
+ - '/opt/hail-on-AWS-spot-instances/src/jupyter_run.sh'
+ - Name: Mount-S3-Resources
+ ScriptBootstrapAction:
+ Path: !Sub '${EnvironmentInstanceFiles}/get_bootstrap.sh'
+ Args:
+ - !Ref EnvironmentInstanceFiles
+ - !Ref S3Mounts
+ CustomAmiId:
+ Ref: AmiId
+ Configurations:
+ - Classification: spark
+ ConfigurationProperties:
+ maximizeResourceAllocation: true
+ - Classification: yarn-site
+ ConfigurationProperties:
+ yarn.nodemanager.vmem-check-enabled: false
+ - Classification: spark-defaults
+ ConfigurationProperties:
+ spark.hadoop.io.compression.codecs: 'org.apache.hadoop.io.compress.DefaultCodec,is.hail.io.compress.BGzipCodec,org.apache.hadoop.io.compress.GzipCodec'
+ spark.serializer: 'org.apache.spark.serializer.KryoSerializer'
+ spark.hadoop.parquet.block.size: '1099511627776'
+ spark.sql.files.maxPartitionBytes: '1099511627776'
+ spark.sql.files.openCostInBytes: '1099511627776'
+ Configurations: []
+ Instances:
+ AdditionalMasterSecurityGroups:
+ - Fn::GetAtt:
+ - MasterSecurityGroup
+ - GroupId
+ Ec2KeyName:
+ Ref: KeyName
+ Ec2SubnetId:
+ Ref: Subnet
+ MasterInstanceGroup:
+ InstanceCount: 1
+ InstanceType:
+ Ref: MasterInstanceType
+ CoreInstanceGroup: !If
+ - IsOnDemandCondition
+ - InstanceCount:
+ Ref: CoreNodeCount
+ InstanceType:
+ Ref: WorkerInstanceType
+ Market:
+ Ref: Market
+ EbsConfiguration:
+ EbsOptimized: true
+ EbsBlockDeviceConfigs:
+ - VolumeSpecification:
+ SizeInGB:
+ Ref: DiskSizeGB
+ VolumeType: gp2
+ - InstanceCount:
+ Ref: CoreNodeCount
+ InstanceType:
+ Ref: WorkerInstanceType
+ Market:
+ Ref: Market
+ BidPrice:
+ Ref: WorkerBidPrice
+ EbsConfiguration:
+ EbsOptimized: true
+ EbsBlockDeviceConfigs:
+ - VolumeSpecification:
+ SizeInGB:
+ Ref: DiskSizeGB
+ VolumeType: gp2
+ JobFlowRole:
+ Ref: InstanceProfile
+ Name: !Sub '${Namespace}-emr'
+ Tags: # Add Name tag so EC2 instances are easily identifiable
+ - Key: Name
+ Value: !Sub '${Namespace}-emr'
+ ServiceRole:
+ Ref: ServiceRole
+ ReleaseLabel: emr-5.27.0
+ # This has to be true because we assume a new user each time.
+ VisibleToAllUsers: true
+ SecurityConfiguration: !Ref EmrSecurityConfiguration
+ LogUri: !Sub 's3://${LogBucket}'
+
+Outputs:
+ JupyterUrl:
+ Description: Open Jupyter on your new EMR cluster
+ Value: !Sub 'http://${EmrCluster.MasterPublicDNS}:8192'
+ LogBucket:
+ Description: EMR Scratch data and Logs bucket
+ Value: !Ref LogBucket
+ WorkspaceInstanceRoleArn:
+ Description: IAM role assumed by the EMR workspace instances
+ Value: !GetAtt Ec2Role.Arn
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/external/sagemaker.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/external/sagemaker.cfn.yml
new file mode 100644
index 0000000000..648a801a61
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/external/sagemaker.cfn.yml
@@ -0,0 +1,158 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway SageMaker-Jupyter
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+ InstanceType:
+ Type: String
+ Description: EC2 instance type to launch
+ Default: ml.t3.xlarge
+ VPC:
+ Description: VPC for EMR nodes.
+ Type: AWS::EC2::VPC::Id
+ Subnet:
+ Description: Subnet for EMR nodes, from the VPC selected above
+ Type: AWS::EC2::Subnet::Id
+ S3Mounts:
+ Type: String
+ Description: A JSON array of objects with name, bucket and prefix properties used to mount data
+ IamPolicyDocument:
+ Type: String
+ Description: The IAM policy to be associated with the launched workstation
+ EnvironmentInstanceFiles:
+ Type: String
+ Description: >-
+ An S3 URI (starting with "s3://") that specifies the location of files to be copied to
+ the environment instance, including any bootstrap scripts
+
+Conditions:
+ IamPolicyEmpty: !Equals [!Ref IamPolicyDocument, '{}']
+
+Resources:
+ EncryptionKey:
+ Type: AWS::KMS::Key
+ Properties:
+ Description: 'This is the key used to secure resources in this account'
+ EnableKeyRotation: True
+ KeyPolicy:
+ Version: '2012-10-17'
+ Statement:
+ - Sid: Allow root access
+ Effect: 'Allow'
+ Principal:
+ AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
+ Action:
+ - 'kms:*'
+ Resource: '*'
+ - Sid: Allow use of the key by this account
+ Effect: 'Allow'
+ Principal:
+ AWS: '*'
+ Action:
+ - 'kms:DescribeKey'
+ - 'kms:Encrypt'
+ - 'kms:Decrypt'
+ - 'kms:ReEncrypt*'
+ - 'kms:GenerateDataKey'
+ - 'kms:GenerateDataKeyWithoutPlaintext'
+ - 'kms:CreateGrant'
+ - 'kms:RevokeGrant'
+ Resource: '*'
+ Condition:
+ StringEquals:
+ kms:CallerAccount: !Ref 'AWS::AccountId'
+
+ SecurityGroup:
+ Type: AWS::EC2::SecurityGroup
+ Properties:
+ GroupDescription: SageMaker Notebook Instance
+ VpcId:
+ Ref: VPC
+
+ IAMRole:
+ Type: 'AWS::IAM::Role'
+ Properties:
+ RoleName: !Join ['-', [Ref: Namespace, 'sagemaker-notebook-role']]
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Principal:
+ Service:
+ - 'sagemaker.amazonaws.com'
+ Action:
+ - 'sts:AssumeRole'
+ Policies:
+ - !If
+ - IamPolicyEmpty
+ - !Ref 'AWS::NoValue'
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-studydata-policy']]
+ PolicyDocument: !Ref IamPolicyDocument
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-bootstrap-script-policy']]
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Action: 's3:GetObject'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Location}/*'
+ # Remove "s3://" prefix from EnvironmentInstanceFiles
+ - S3Location: !Select [1, !Split ['s3://', !Ref EnvironmentInstanceFiles]]
+ - Effect: 'Allow'
+ Action: 's3:ListBucket'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Bucket}'
+ - S3Bucket: !Select [2, !Split ['/', !Ref EnvironmentInstanceFiles]]
+ Condition:
+ StringLike:
+ s3:prefix: !Sub
+ - '${S3Prefix}/*'
+ - S3Prefix: !Select [3, !Split ['/', !Ref EnvironmentInstanceFiles]]
+
+ - PolicyName: cw-logs
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - logs:CreateLogStream
+ - logs:DescribeLogStreams
+ - logs:PutLogEvents
+ - logs:CreateLogGroup
+ Resource:
+ - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/sagemaker/*
+ # TODO: Consider also passing DefaultCodeRepository to allow persisting notebook data
+ BasicNotebookInstance:
+ Type: 'AWS::SageMaker::NotebookInstance'
+ Properties:
+ InstanceType: !Ref InstanceType
+ RoleArn: !GetAtt IAMRole.Arn
+ SubnetId: !Ref Subnet
+ SecurityGroupIds:
+ - !Ref SecurityGroup
+ LifecycleConfigName: !GetAtt BasicNotebookInstanceLifecycleConfig.NotebookInstanceLifecycleConfigName
+ KmsKeyId: !GetAtt EncryptionKey.Arn
+
+ BasicNotebookInstanceLifecycleConfig:
+ Type: 'AWS::SageMaker::NotebookInstanceLifecycleConfig'
+ Properties:
+ OnCreate:
+ - Content:
+ Fn::Base64: !Sub |
+ #!/usr/bin/env bash
+ # Download and execute bootstrap script
+ aws s3 cp "${EnvironmentInstanceFiles}/get_bootstrap.sh" "/tmp"
+ chmod 500 "/tmp/get_bootstrap.sh"
+ /tmp/get_bootstrap.sh "${EnvironmentInstanceFiles}" '${S3Mounts}'
+
+Outputs:
+ NotebookInstanceName:
+ Description: The name of the SageMaker notebook instance.
+ Value: !GetAtt [BasicNotebookInstance, NotebookInstanceName]
+
+ WorkspaceInstanceRoleArn:
+ Description: IAM role assumed by the SageMaker workspace instance
+ Value: !GetAtt IAMRole.Arn
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml
new file mode 100644
index 0000000000..a2706d45c7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml
@@ -0,0 +1,296 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway Research-Account
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+
+ CentralAccountId:
+ Type: String
+ Description: The account id of the newly created account.
+
+ ExternalId:
+ Type: String
+ Description: A unique ID used to identify this account
+
+ VpcCidr:
+ Description: Please enter the IP range (CIDR notation) for this VPC
+ Type: String
+ Default: 10.0.0.0/16
+
+ ApiHandlerArn:
+ Type: String
+ Description: The arn of apiHandler role
+
+ WorkflowRoleArn:
+ Type: String
+ Description: The arn of workflowRunner role
+
+ # Generous subnet allocation of 8192 addresses (ie room for a few concurrent EMR clusters)
+ # ending at 10.0.31.255
+ VpcPublicSubnet1Cidr:
+ Description: Please enter the IP range (CIDR notation) for the public subnet in the 1st Availability Zone
+ Type: String
+ Default: 10.0.0.0/19
+
+Metadata:
+ AWS::CloudFormation::Interface:
+ ParameterGroups:
+ - Label:
+ default: Shared Configuration
+ Parameters:
+ - Namespace
+ - Label:
+ default: Account Configuration
+ Parameters:
+ - CentralAccountId
+ - ExternalId
+ - Label:
+ default: Deployment Configuration
+ Parameters:
+ - VpcCidr
+ - VpcPublicSubnet1Cidr
+
+Resources:
+ # TODO lock these permissions down further
+ CrossAccountExecutionRole:
+ Type: 'AWS::IAM::Role'
+ Properties:
+ RoleName: !Join ['-', [Ref: Namespace, 'cross-account-role']]
+ Path: '/'
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Principal:
+ AWS:
+ - !Join [':', ['arn:aws:iam:', Ref: CentralAccountId, 'root']]
+ - !Ref ApiHandlerArn
+ - !Ref WorkflowRoleArn
+ Action:
+ - 'sts:AssumeRole'
+ Condition:
+ StringEquals:
+ sts:ExternalId: !Ref ExternalId
+ Policies:
+ - PolicyName: cfn-access
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Action:
+ - cloudformation:CreateStack
+ - cloudformation:DeleteStack
+ - cloudformation:DescribeStacks
+ - cloudformation:DescribeStackEvents
+ Resource: '*'
+ - PolicyName: sagemaker-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - sagemaker:*
+ Resource: '*'
+ - PolicyName: iam-role-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - iam:GetRole
+ - iam:CreateRole
+ - iam:TagRole
+ - iam:GetRolePolicy
+ - iam:PutRolePolicy
+ - iam:DeleteRolePolicy
+ - iam:DeleteRole
+ - iam:PassRole
+ Resource:
+ - !Sub 'arn:aws:iam::${AWS::AccountId}:role/analysis-*'
+ - PolicyName: iam-instance-profile-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - iam:AddRoleToInstanceProfile
+ - iam:CreateInstanceProfile
+ - iam:GetInstanceProfile
+ - iam:DeleteInstanceProfile
+ - iam:RemoveRoleFromInstanceProfile
+ Resource:
+ - !Sub 'arn:aws:iam::${AWS::AccountId}:instance-profile/analysis-*'
+ - PolicyName: iam-role-service-policy-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - iam:AttachRolePolicy
+ - iam:DetachRolePolicy
+ Resource:
+ - !Sub 'arn:aws:iam::${AWS::AccountId}:role/analysis-*'
+ Condition:
+ ArnLike:
+ iam:PolicyARN: arn:aws:iam::aws:policy/service-role/AmazonElasticMapReduceRole
+ - PolicyName: iam-service-linked-role-create-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - iam:CreateServiceLinkedRole
+ - iam:PutRolePolicy
+ Resource: arn:aws:iam::*:role/aws-service-role/elasticmapreduce.amazonaws.com*/AWSServiceRoleForEMRCleanup*
+ Condition:
+ StringLike:
+ iam:AWSServiceName:
+ - elasticmapreduce.amazonaws.com
+ - elasticmapreduce.amazonaws.com.cn
+ - PolicyName: cost-explorer-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - ce:*
+ Resource: '*'
+ - PolicyName: s3-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - s3:*
+ Resource: '*'
+ - PolicyName: ec2-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - ec2:*
+ Resource: '*'
+ - PolicyName: ssm-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - ssm:*
+ Resource: '*'
+ - PolicyName: emr-access
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - elasticmapreduce:*
+ Resource: '*'
+
+ # VPC for launching EMR clusters into
+ # Just one AZ as we're aiming for transient low-cost clusters rather than HA
+ VPC:
+ Type: AWS::EC2::VPC
+ Properties:
+ CidrBlock: !Ref VpcCidr
+ EnableDnsSupport: true
+ EnableDnsHostnames: true
+ Tags:
+ - Key: Name
+ Value: !Sub ${Namespace} vpc
+
+ InternetGateway:
+ Type: AWS::EC2::InternetGateway
+ Properties:
+ Tags:
+ - Key: Name
+ Value: !Sub ${Namespace} igw
+
+ InternetGatewayAttachment:
+ Type: AWS::EC2::VPCGatewayAttachment
+ Properties:
+ InternetGatewayId: !Ref InternetGateway
+ VpcId: !Ref VPC
+
+ PublicSubnet1:
+ Type: AWS::EC2::Subnet
+ Properties:
+ VpcId: !Ref VPC
+ AvailabilityZone: !Select [0, !GetAZs ]
+ CidrBlock: !Ref VpcPublicSubnet1Cidr
+ MapPublicIpOnLaunch: true
+ Tags:
+ - Key: Name
+ Value: !Sub ${Namespace} public subnet 1
+
+ PublicRouteTable:
+ Type: AWS::EC2::RouteTable
+ Properties:
+ VpcId: !Ref VPC
+ Tags:
+ - Key: Name
+ Value: !Sub ${Namespace} public routes
+
+ DefaultPublicRoute:
+ Type: AWS::EC2::Route
+ DependsOn: InternetGatewayAttachment
+ Properties:
+ RouteTableId: !Ref PublicRouteTable
+ DestinationCidrBlock: 0.0.0.0/0
+ GatewayId: !Ref InternetGateway
+
+ PublicSubnet1RouteTableAssociation:
+ Type: AWS::EC2::SubnetRouteTableAssociation
+ Properties:
+ RouteTableId: !Ref PublicRouteTable
+ SubnetId: !Ref PublicSubnet1
+
+ EncryptionKey:
+ Type: AWS::KMS::Key
+ Properties:
+ Description: 'This is the key used to secure resources in this account'
+ EnableKeyRotation: True
+ KeyPolicy:
+ Version: '2012-10-17'
+ Statement:
+ - Sid: Allow root access
+ Effect: 'Allow'
+ Principal:
+ AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
+ Action:
+ - 'kms:*'
+ Resource: '*'
+ - Sid: Allow use of the key by this account
+ Effect: 'Allow'
+ Principal:
+ AWS: '*'
+ Action:
+ - 'kms:DescribeKey'
+ - 'kms:Encrypt'
+ - 'kms:Decrypt'
+ - 'kms:ReEncrypt*'
+ - 'kms:GenerateDataKey'
+ - 'kms:GenerateDataKeyWithoutPlaintext'
+ - 'kms:CreateGrant'
+ - 'kms:RevokeGrant'
+ Resource: '*'
+ Condition:
+ StringEquals:
+ kms:CallerAccount: !Ref 'AWS::AccountId'
+
+ EncryptionKeyAlias:
+ Type: AWS::KMS::Alias
+ Properties:
+ AliasName: !Join ['', ['alias/', Ref: Namespace, '-encryption-key']]
+ TargetKeyId: !Ref EncryptionKey
+
+Outputs:
+ CrossAccountExecutionRoleArn:
+ Description: The arn of the cross account role.
+ Value: !GetAtt [CrossAccountExecutionRole, Arn]
+
+ VPC:
+ Description: VPC ID
+ Value: !Ref VPC
+
+ VpcPublicSubnet1:
+ Description: A reference to the public subnet in the 1st Availability Zone
+ Value: !Ref PublicSubnet1
+
+ EncryptionKeyArn:
+ Description: KMS Encryption Key Arn
+ Value: !GetAtt [EncryptionKey, Arn]
diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/sagemaker-notebook-instance.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/sagemaker-notebook-instance.cfn.yml
new file mode 100644
index 0000000000..135b38760a
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/sagemaker-notebook-instance.cfn.yml
@@ -0,0 +1,130 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: Galileo-Gateway SageMaker-Jupyter
+
+Parameters:
+ Namespace:
+ Type: String
+ Description: An environment name that will be prefixed to resource names
+ InstanceType:
+ Type: String
+ Description: EC2 instance type to launch
+ Default: ml.t3.xlarge
+ VPC:
+ Description: VPC for EMR nodes.
+ Type: AWS::EC2::VPC::Id
+ Subnet:
+ Description: Subnet for EMR nodes, from the VPC selected above
+ Type: AWS::EC2::Subnet::Id
+ S3Mounts:
+ Type: String
+ Description: A JSON array of objects with name, bucket and prefix properties used to mount data
+ IamPolicyDocument:
+ Type: String
+ Description: The IAM policy to be associated with the launched workstation
+ EnvironmentInstanceFiles:
+ Type: String
+ Description: >-
+ An S3 URI (starting with "s3://") that specifies the location of files to be copied to
+ the environment instance, including any bootstrap scripts
+ EncryptionKeyArn:
+ Type: String
+ Description: The ARN of the KMS encryption Key used to encrypt data in the notebook
+
+Conditions:
+ IamPolicyEmpty: !Equals [!Ref IamPolicyDocument, '{}']
+
+Resources:
+ SecurityGroup:
+ Type: AWS::EC2::SecurityGroup
+ Properties:
+ GroupDescription: SageMaker Notebook Instance
+ VpcId:
+ Ref: VPC
+
+ IAMRole:
+ Type: "AWS::IAM::Role"
+ Properties:
+ RoleName: !Join ["-", [Ref: Namespace, "sagemaker-notebook-role"]]
+ Path: "/"
+ AssumeRolePolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ -
+ Effect: "Allow"
+ Principal:
+ Service:
+ - "sagemaker.amazonaws.com"
+ Action:
+ - "sts:AssumeRole"
+ Policies:
+ - !If
+ - IamPolicyEmpty
+ - !Ref 'AWS::NoValue'
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-studydata-policy']]
+ PolicyDocument: !Ref IamPolicyDocument
+ - PolicyName: !Join ['-', [Ref: Namespace, 's3-bootstrap-script-policy']]
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: 'Allow'
+ Action: 's3:GetObject'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Location}/*'
+ # Remove "s3://" prefix from EnvironmentInstanceFiles
+ - S3Location: !Select [ 1, !Split [ 's3://', !Ref EnvironmentInstanceFiles ] ]
+ - Effect: 'Allow'
+ Action: 's3:ListBucket'
+ Resource: !Sub
+ - 'arn:aws:s3:::${S3Bucket}'
+ - S3Bucket: !Select [ 2, !Split [ '/', !Ref EnvironmentInstanceFiles ]]
+ Condition:
+ StringLike:
+ s3:prefix: !Sub
+ - '${S3Prefix}/*'
+ - S3Prefix: !Select [ 3, !Split [ '/', !Ref EnvironmentInstanceFiles ]]
+
+ - PolicyName: cw-logs
+ PolicyDocument:
+ Statement:
+ Effect: Allow
+ Action:
+ - logs:CreateLogStream
+ - logs:DescribeLogStreams
+ - logs:PutLogEvents
+ - logs:CreateLogGroup
+ Resource:
+ - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/sagemaker/*
+ # TODO: Consider also passing DefaultCodeRepository to allow persisting notebook data
+ BasicNotebookInstance:
+ Type: "AWS::SageMaker::NotebookInstance"
+ Properties:
+ InstanceType: !Ref InstanceType
+ RoleArn: !GetAtt IAMRole.Arn
+ SubnetId: !Ref Subnet
+ SecurityGroupIds:
+ - !Ref SecurityGroup
+ LifecycleConfigName: !GetAtt BasicNotebookInstanceLifecycleConfig.NotebookInstanceLifecycleConfigName
+ KmsKeyId: !Ref EncryptionKeyArn
+
+ BasicNotebookInstanceLifecycleConfig:
+ Type: "AWS::SageMaker::NotebookInstanceLifecycleConfig"
+ Properties:
+ OnCreate:
+ - Content:
+ Fn::Base64: !Sub |
+ #!/usr/bin/env bash
+ # Download and execute bootstrap script
+ aws s3 cp "${EnvironmentInstanceFiles}/get_bootstrap.sh" "/tmp"
+ chmod 500 "/tmp/get_bootstrap.sh"
+ /tmp/get_bootstrap.sh "${EnvironmentInstanceFiles}" '${S3Mounts}'
+
+Outputs:
+
+ NotebookInstanceName:
+ Description: The name of the SageMaker notebook instance.
+ Value: !GetAtt [ BasicNotebookInstance, NotebookInstanceName ]
+
+ WorkspaceInstanceRoleArn:
+ Description: IAM role assumed by the SageMaker workspace instance
+ Value: !GetAtt IAMRole.Arn
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/.eslintrc.json b/addons/addon-base-raas/packages/base-raas-post-deployment/.eslintrc.json
new file mode 100644
index 0000000000..c700cf03a8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/.eslintrc.json
@@ -0,0 +1,26 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0,
+ "import/no-unresolved": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/.gitignore b/addons/addon-base-raas/packages/base-raas-post-deployment/.gitignore
new file mode 100644
index 0000000000..659959de8f
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/.prettierrc.json b/addons/addon-base-raas/packages/base-raas-post-deployment/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/jest.config.js b/addons/addon-base-raas/packages/base-raas-post-deployment/jest.config.js
new file mode 100644
index 0000000000..70544476d7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/jest.config.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+};
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/jsconfig.json b/addons/addon-base-raas/packages/base-raas-post-deployment/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/lib/plugins/steps-plugin.js b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/plugins/steps-plugin.js
new file mode 100644
index 0000000000..160ab4acb7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/plugins/steps-plugin.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const CreateUserRoles = require('../steps/create-user-roles');
+const InjectServiceEndpoint = require('../steps/inject-service-endpoint');
+const CreateCloudFrontInterceptor = require('../steps/create-cloudfront-interceptor');
+/**
+ * Returns a map of post deployment steps
+ *
+ * @param existingStepsMap Map of existing post deployment steps
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise<*>}
+ */
+// eslint-disable-next-line no-unused-vars
+async function getSteps(existingStepsMap, pluginRegistry) {
+ const stepsMap = new Map([
+ // The userService implementation of RaaS requires the roles to be created first before a user can be created
+ // One of the steps from the "existingStepsMap" tries to create root user.
+ // Make sure the CreateUserRoles gets executed first before executing other post deployment steps otherwise the root
+ // user creation step will fail
+ ['createUserRoles', new CreateUserRoles()],
+ ...existingStepsMap,
+ ['injectServiceEndpoint', new InjectServiceEndpoint()],
+ ['createCloudFrontInterceptor', new CreateCloudFrontInterceptor()],
+ ]);
+
+ return stepsMap;
+}
+
+const plugin = {
+ getSteps,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/create-cloudfront-interceptor.js b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/create-cloudfront-interceptor.js
new file mode 100644
index 0000000000..24f0acaac1
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/create-cloudfront-interceptor.js
@@ -0,0 +1,146 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+/**
+ * Post deployment step implementation that configures cloudFront interceptor (Lambda@Edge) to the website
+ * cloudFront distribution.
+ */
+class CreateCloudFrontInterceptor extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws']);
+ }
+
+ async init() {
+ await super.init();
+ }
+
+ async execute() {
+ /*
+ * Pseudo Code:
+ * -- Get latest Lambda@Edge function ARN that needs to be configured from the settings
+ * -- Get existing Lambda@Edge function version ARN configured on CloudFront
+ * -- If no Lambda@Edge function is configured on CloudFront yet, then skip all checks and
+ * -- Publish new version of the Lambda@Edge function and
+ * -- Configure the new lambda version ARN on CloudFront and exit.
+ * -- If some Lambda@Edge function version is configured on CloudFront then get CodeSha256 for that version
+ * -- Get latest CodeSha256 value for the Lambda
+ * -- If the CodeSha256 value for the latest Lambda@Edge matches the one that's configured on CloudFront, then
+ * -- There is nothing to do. The latest Lambda is already configured on CloudFront. Just return.
+ * -- If the latest lambda@edge code Sha256 is different from what's configured then
+ * -- Publish new version of the Lambda@Edge function with latest Lambda code and
+ * -- Configure the new lambda version ARN on CloudFront
+ *
+ * Note: We need to publish Lambda Version using Lambda SDK here instead of simply using "AWS::Lambda::Version"
+ * CloudFormation resource in the "edge-lambda" stack to publish Lambda. That's because the "AWS::Lambda::Version"
+ * will end up publishing new version of the Lambda function every time. We want to publish only if the lambda@edge
+ * code has changed.
+ */
+ const aws = await this.service('aws');
+ const cloudFrontApi = new aws.sdk.CloudFront({ apiVersion: '2019-03-26' });
+ const cloudFrontId = this.settings.get('cloudFrontId');
+
+ // -- Get latest Lambda@Edge function ARN that needs to be configured from the settings
+ const latestEdgeLambdaArn = this.settings.get('edgeLambdaArn');
+
+ // -- Get existing Lambda@Edge function version ARN configured on CloudFront
+ const cloudFrontConfig = await cloudFrontApi.getDistributionConfig({ Id: cloudFrontId }).promise();
+ const existingLambdaArn = _.get(
+ cloudFrontConfig,
+ 'DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations.Items.0.LambdaFunctionARN',
+ );
+
+ // -- If no Lambda@Edge function is configured on CloudFront yet, then skip all checks and
+ // -- Publish new version of the Lambda@Edge function and
+ // -- Configure the new lambda version ARN on CloudFront and exit
+ if (_.isEmpty(existingLambdaArn)) {
+ this.log.info(`No edge lambda configured for cloudfront distribution "${cloudFrontId}"".`);
+ const publishedVersionArn = await this.publishNewLambdaVersion(latestEdgeLambdaArn);
+ await this.updateCloudFrontConfig(cloudFrontId, cloudFrontConfig, publishedVersionArn);
+ return;
+ }
+
+ // -- If some Lambda@Edge function version is configured on CloudFront then get CodeSha256 for that version
+ const existingSha256 = existingLambdaArn && (await this.getLambdaCodeSha256(existingLambdaArn));
+ // -- Get latest CodeSha256 value for the Lambda
+ const latestSha256 = await this.getLambdaCodeSha256(latestEdgeLambdaArn);
+
+ // -- If the CodeSha256 value for the latest Lambda@Edge matches the one that's configured on CloudFront, then
+ // -- There is nothing to do. The latest Lambda is already configured on CloudFront. Just return.
+ if (existingSha256 === latestSha256) {
+ this.log.info(
+ `Skip updating cloudfront distribution "${cloudFrontId}"". The Lambda@Edge version "${latestEdgeLambdaArn}" is already configured and has latest code.`,
+ );
+ return;
+ }
+
+ // -- If the latest lambda@edge code Sha256 is different from what's configured then
+ // -- Publish new version of the Lambda@Edge function with latest Lambda code and
+ // -- Configure the new lambda version ARN on CloudFront
+ const publishedVersionArn = await this.publishNewLambdaVersion(latestEdgeLambdaArn);
+ await this.updateCloudFrontConfig(cloudFrontId, cloudFrontConfig, publishedVersionArn);
+ }
+
+ async getLambdaCodeSha256(lambdaArn) {
+ const aws = await this.service('aws');
+ const lambdaApi = new aws.sdk.Lambda({ apiVersion: '2015-03-31', region: 'us-east-1' });
+ const lambdaInfo = await lambdaApi.getFunction({ FunctionName: lambdaArn }).promise();
+
+ return lambdaInfo.Configuration.CodeSha256;
+ }
+
+ async publishNewLambdaVersion(lambdaArn) {
+ const aws = await this.service('aws');
+ const lambdaApi = new aws.sdk.Lambda({ apiVersion: '2015-03-31', region: 'us-east-1' });
+ const lambdaInfo = await lambdaApi.publishVersion({ FunctionName: lambdaArn }).promise();
+
+ // Return ARN pointing to the new Lambda version we just published
+ return lambdaInfo.FunctionArn;
+ }
+
+ async updateCloudFrontConfig(cloudFrontId, cloudFrontConfig, lambdaVersionArn) {
+ const aws = await this.service('aws');
+ const cloudFrontApi = new aws.sdk.CloudFront({ apiVersion: '2019-03-26' });
+
+ this.log.info(`Updating cloudfront distribution "${cloudFrontId}"`);
+
+ // Prepare updateDistribution parameters
+ // 1. Set cloudFrontID
+ // 2. Set IfMatch to the value of ETag
+ // 3. Remove ETag
+ // See "https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFront.html#updateDistribution-property" for details
+ cloudFrontConfig.Id = cloudFrontId;
+ cloudFrontConfig.IfMatch = cloudFrontConfig.ETag;
+ delete cloudFrontConfig.ETag;
+
+ // 4. Add Lambda@Edge's ARN
+ _.set(cloudFrontConfig, 'DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations', {
+ Quantity: 1, // The number of Lambda function associations for this cache behavior. This needs to be same as Items.length
+ Items: [
+ {
+ LambdaFunctionARN: lambdaVersionArn,
+ EventType: 'origin-response',
+ },
+ ],
+ });
+
+ return cloudFrontApi.updateDistribution(cloudFrontConfig).promise();
+ }
+}
+
+module.exports = CreateCloudFrontInterceptor;
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/create-user-roles.js b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/create-user-roles.js
new file mode 100644
index 0000000000..9dbc323daa
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/create-user-roles.js
@@ -0,0 +1,115 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { getSystemRequestContext } = require('@aws-ee/base-services/lib/helpers/system-context');
+
+const userRoleItems = [
+ {
+ id: 'admin', // Don't delete this; the root is created with this role
+ description: 'Administrator',
+ userType: 'INTERNAL',
+ },
+ {
+ id: 'guest', // Don't delete this; external self-registered users start as this
+ description: 'External Guest',
+ userType: 'EXTERNAL',
+ },
+ {
+ id: 'internal-guest', // Don't delete this
+ description: 'Internal Guest',
+ userType: 'INTERNAL',
+ },
+ {
+ id: 'researcher',
+ description: 'Internal Researcher',
+ userType: 'INTERNAL',
+ },
+ {
+ id: 'external-researcher',
+ description: 'External Researcher',
+ userType: 'EXTERNAL',
+ },
+];
+
+const settingKeys = {
+ enableExternalResearchers: 'enableExternalResearchers',
+};
+
+class CreateUserRolesService extends Service {
+ constructor() {
+ super();
+ this.dependency(['userRolesService', 'aws']);
+ }
+
+ async createUserRoles() {
+ const [userRolesService] = await this.service(['userRolesService']);
+
+ const enableExternalResearchers = this.settings.optionalBoolean(settingKeys.enableExternalResearchers, false);
+ const disabledRoles = enableExternalResearchers ? [] : [{ id: 'external-researcher' }];
+ const rolesToCreate = _.differenceBy(userRoleItems, disabledRoles, 'id');
+ const requestContext = getSystemRequestContext();
+
+ const creationPromises = rolesToCreate.map(async role => {
+ try {
+ // check if the userRole already exists, do not create or update the item info
+ const userRole = await userRolesService.find(requestContext, { id: role.id });
+ if (!userRole) {
+ await userRolesService.create(requestContext, role);
+ this.log.info({ message: `Created user role ${role.id}`, userRole: role });
+ }
+ } catch (err) {
+ if (err.code === 'alreadyExists') {
+ // The user role already exists. Nothing to do.
+ this.log.info(`The userRole ${role.id} already exists. Did NOT overwrite that userRole's information.`);
+ } else {
+ // In case of any other error let it bubble up
+ throw err;
+ }
+ }
+ });
+ await Promise.all(creationPromises);
+
+ // Make sure there are no disabled roles in db.
+ // This can happen if the solution was deployed first with the roles enabled but then re-deployed after disabling
+ // certain roles
+ await this.deleteRoles(requestContext, disabledRoles);
+
+ this.log.info(`Finished creating user roles`);
+ }
+
+ async deleteRoles(requestContext, roles) {
+ const [userRolesService] = await this.service(['userRolesService']);
+ const deletionPromises = roles.map(async role => {
+ try {
+ await userRolesService.delete(requestContext, { id: role.id });
+ } catch (err) {
+ // The user role does not exist. Nothing to delete in that case
+ if (err.code !== 'notFound') {
+ // In case of any other error let it bubble up
+ throw err;
+ }
+ }
+ });
+ return Promise.all(deletionPromises);
+ }
+
+ async execute() {
+ return this.createUserRoles();
+ }
+}
+
+module.exports = CreateUserRolesService;
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/inject-service-endpoint.js b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/inject-service-endpoint.js
new file mode 100644
index 0000000000..7dfcd00f00
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/lib/steps/inject-service-endpoint.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+/* eslint-disable no-await-in-loop */
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+class InjectServiceEndpoint extends Service {
+ constructor() {
+ // eslint-disable-line no-useless-constructor
+ super();
+ this.dependency(['aws']);
+ }
+
+ async init() {
+ await super.init();
+ }
+
+ async execute() {
+ const [aws] = await this.service(['aws']);
+
+ const lambdaName = this.settings.get('workflowLambdaName');
+ const backendStackName = this.settings.get('backendStackName');
+ const cfn = new aws.sdk.CloudFormation();
+ const backendStack = await cfn.describeStacks({ StackName: backendStackName }).promise();
+ const serviceEndpoint = _.find(backendStack.Stacks[0].Outputs, { OutputKey: 'ServiceEndpoint' }).OutputValue;
+
+ const lambda = new aws.sdk.Lambda();
+ const workflowLambda = await lambda.getFunction({ FunctionName: lambdaName }).promise();
+ const existingEnvironmentsVariables = workflowLambda.Configuration.Environment.Variables;
+ existingEnvironmentsVariables.APP_API_ENDPOINT = serviceEndpoint;
+ const updateParams = {
+ FunctionName: lambdaName,
+ Environment: {
+ Variables: existingEnvironmentsVariables,
+ },
+ };
+ await lambda.updateFunctionConfiguration(updateParams).promise();
+ this.log.info(`Created APP_API_ENDPOINT:${serviceEndpoint} as a parameter to ${lambdaName}`);
+ }
+}
+
+module.exports = InjectServiceEndpoint;
diff --git a/addons/addon-base-raas/packages/base-raas-post-deployment/package.json b/addons/addon-base-raas/packages/base-raas-post-deployment/package.json
new file mode 100644
index 0000000000..ef20d27433
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-post-deployment/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@aws-ee/base-raas-post-deployment",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A collection of base RaaS post deployment steps",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-services": "workspace:*",
+ "@aws-ee/base-services-container": "workspace:*",
+ "@aws-ee/base-raas-services": "workspace:*",
+ "lodash": "^4.17.15"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb": "^18.1.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "pretty-quick": "^1.11.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/.eslintrc.json b/addons/addon-base-raas/packages/base-raas-rest-api/.eslintrc.json
new file mode 100644
index 0000000000..c700cf03a8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/.eslintrc.json
@@ -0,0 +1,26 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0,
+ "import/no-unresolved": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/.gitignore b/addons/addon-base-raas/packages/base-raas-rest-api/.gitignore
new file mode 100644
index 0000000000..659959de8f
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/.prettierrc.json b/addons/addon-base-raas/packages/base-raas-rest-api/.prettierrc.json
new file mode 100644
index 0000000000..d3846d96f3
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/.prettierrc.json
@@ -0,0 +1,16 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all",
+ "overrides": [
+ {
+ "files": ["*.yml", "*.yaml"],
+ "options": {
+ "singleQuote": false
+ }
+ }
+ ]
+}
+
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/jest.config.js b/addons/addon-base-raas/packages/base-raas-rest-api/jest.config.js
new file mode 100644
index 0000000000..70544476d7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/jest.config.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+};
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/jsconfig.json b/addons/addon-base-raas/packages/base-raas-rest-api/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/accounts-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/accounts-controller.js
new file mode 100644
index 0000000000..0028d58062
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/accounts-controller.js
@@ -0,0 +1,104 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const boom = context.boom;
+
+ const accountService = await context.service('accountService');
+
+ // ===============================================================
+ // GET / (mounted to /api/accounts)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+
+ const result = await accountService.list(requestContext);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/accounts)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ const result = await accountService.mustFind(requestContext, { id });
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/accounts)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = await accountService.provisionAccount(requestContext, possibleBody);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:id (mounted to /api/accounts)
+ // ===============================================================
+ router.put(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+ const body = req.body || {};
+
+ if (body.id !== id)
+ throw boom.badRequest(
+ 'The accounts id provided in the url does not match the one in the submitted json object',
+ true,
+ );
+
+ const result = await accountService.update(requestContext, body);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // DELETE /:id (mounted to /api/accounts)
+ // ===============================================================
+ router.delete(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await accountService.delete(requestContext, { id });
+ res.status(200).json({});
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/aws-accounts-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/aws-accounts-controller.js
new file mode 100644
index 0000000000..a7a0081d02
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/aws-accounts-controller.js
@@ -0,0 +1,84 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const boom = context.boom;
+
+ const awsAccountsService = await context.service('awsAccountsService');
+ const accountService = await context.service('accountService');
+
+ // ===============================================================
+ // GET / (mounted to /api/aws-accounts)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+
+ const awsAccounts = await awsAccountsService.list(requestContext);
+ res.status(200).json(awsAccounts);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/aws-accounts)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ const result = await awsAccountsService.mustFind(requestContext, { id });
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/aws-accounts)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = await awsAccountsService.create(requestContext, possibleBody);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/aws-accounts)
+ // ===============================================================
+ router.post(
+ '/provision',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ await accountService.provisionAccount(requestContext, possibleBody);
+
+ res.status(200).json({ message: 'account creating' });
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/compute-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/compute-controller.js
new file mode 100644
index 0000000000..0872b3adac
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/compute-controller.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ const computePlatformService = await context.service('computePlatformService');
+
+ // ===============================================================
+ // GET /platforms (mounted to /api/compute)
+ // ===============================================================
+ router.get(
+ '/platforms',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+
+ const platforms = await computePlatformService.listPlatforms(requestContext);
+ res.status(200).json(platforms);
+ }),
+ );
+
+ // ===============================================================
+ // GET /platforms/:id/configurations (mounted to /api/compute)
+ // ===============================================================
+ router.get(
+ '/platforms/:id/configurations',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const id = req.params.id;
+ const platforms = await computePlatformService.listConfigurations(requestContext, {
+ platformId: id,
+ includePrice: true,
+ });
+ res.status(200).json(platforms);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/costs-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/costs-controller.js
new file mode 100644
index 0000000000..978638088d
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/costs-controller.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const mime = require('mime');
+// var fs = require('fs');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ const [costsService] = await context.service(['costsService']);
+
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const result = await costsService.getIndividualEnvironmentOrProjCost(requestContext, req.query);
+ res.status(200).json(result);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/environment-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/environment-controller.js
new file mode 100644
index 0000000000..99694cef11
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/environment-controller.js
@@ -0,0 +1,167 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const mime = require('mime');
+// var fs = require('fs');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ const [
+ environmentService,
+ environmentKeypairService,
+ environmentNotebookUrlService,
+ environmentSpotPriceHistoryService,
+ ] = await context.service([
+ 'environmentService',
+ 'environmentKeypairService',
+ 'environmentNotebookUrlService',
+ 'environmentSpotPriceHistoryService',
+ ]);
+
+ // ===============================================================
+ // GET / (mounted to /api/workspaces)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+
+ const result = await environmentService.list(requestContext);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/workspaces)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ const result = await environmentService.mustFind(requestContext, { id });
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/workspaces)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = requestContext.principal.isExternalUser
+ ? await environmentService.createExternal(requestContext, possibleBody)
+ : await environmentService.create(requestContext, possibleBody);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // PUT / (mounted to /api/workspaces)
+ // ===============================================================
+ router.put(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = await environmentService.update(requestContext, possibleBody);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // DELETE /:id (mounted to /api/workspaces)
+ // ===============================================================
+ router.delete(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await environmentService.delete(requestContext, { id });
+ res.status(200).json({});
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id/keypair (mounted to /api/workspaces)
+ // ===============================================================
+ router.get(
+ '/:id/keypair',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const id = req.params.id;
+
+ const result = await environmentKeypairService.mustFind(requestContext, id);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id/password (mounted to /api/workspaces)
+ // ===============================================================
+ router.get(
+ '/:id/password',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const id = req.params.id;
+
+ const result = await environmentService.getWindowsPasswordData(requestContext, { id });
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id/url (mounted to /api/workspaces)
+ // ===============================================================
+ router.get(
+ '/:id/url',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const id = req.params.id;
+ const result = await environmentNotebookUrlService.getNotebookPresignedUrl(requestContext, id);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /pricing/:type (mounted to /api/workspaces)
+ // ===============================================================
+ router.get(
+ '/pricing/:type',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const type = req.params.type;
+ const result = await environmentSpotPriceHistoryService.getPriceHistory(requestContext, type);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/indexes-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/indexes-controller.js
new file mode 100644
index 0000000000..df8b29c075
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/indexes-controller.js
@@ -0,0 +1,104 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const boom = context.boom;
+
+ const indexesService = await context.service('indexesService');
+
+ // ===============================================================
+ // GET / (mounted to /api/indexes)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+
+ const result = await indexesService.list(requestContext);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/indexes)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ const result = await indexesService.mustFind(requestContext, { id });
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/indexes)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = await indexesService.create(requestContext, possibleBody);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:id (mounted to /api/indexes)
+ // ===============================================================
+ router.put(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+ const body = req.body || {};
+
+ if (body.id !== id)
+ throw boom.badRequest(
+ 'The indexes id provided in the url does not match the one in the submitted json object',
+ true,
+ );
+
+ const result = await indexesService.update(requestContext, body);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // DELETE /:id (mounted to /api/indexes)
+ // ===============================================================
+ router.delete(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await indexesService.delete(requestContext, { id });
+ res.status(200).json({});
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/ip-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/ip-controller.js
new file mode 100644
index 0000000000..2a1e065d3a
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/ip-controller.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ // ===============================================================
+ // GET / (mounted to /api/ip)
+ // ===============================================================
+ router.get(
+ '',
+ wrap(async (req, res) => {
+ const ipString =
+ req.header('X-Forwarded-For') ||
+ _.get(req, 'requestContext.identity.sourceIp') ||
+ _.get(req, 'connection.remoteAddress');
+
+ // Note, by definition, the information in the 'X-Forwarded-For' should never
+ // be trusted as authoritative and should never be used for any kind of verification.
+ // Sometime 'X-Forwarded-For' can be an string with ', ' if there were multiple forwards.
+ // We take the first element.
+ res.status(200).json({
+ ipAddress: _.trim(_.first(_.split(ipString, ','))),
+ });
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/project-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/project-controller.js
new file mode 100644
index 0000000000..23c36ecd40
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/project-controller.js
@@ -0,0 +1,104 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const boom = context.boom;
+
+ const projectService = await context.service('projectService');
+
+ // ===============================================================
+ // GET / (mounted to /api/projects)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+
+ const result = await projectService.list(requestContext);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/projects)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ const result = await projectService.mustFind(requestContext, { id });
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/projects)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = await projectService.create(requestContext, possibleBody);
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:id (mounted to /api/projects)
+ // ===============================================================
+ router.put(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+ const body = req.body || {};
+
+ if (body.id !== id)
+ throw boom.badRequest(
+ 'The project id provided in the url does not match the one in the submitted json object',
+ true,
+ );
+
+ const result = await projectService.update(requestContext, body);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // DELETE /:id (mounted to /api/projects)
+ // ===============================================================
+ router.delete(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await projectService.delete(requestContext, { id });
+ res.status(200).json({});
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/study-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/study-controller.js
new file mode 100644
index 0000000000..5814910deb
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/study-controller.js
@@ -0,0 +1,146 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ const [studyService, studyPermissionService] = await context.service(['studyService', 'studyPermissionService']);
+
+ // ===============================================================
+ // GET / (mounted to /api/studies)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const { category } = req.query;
+
+ const result = await studyService.list(requestContext, category);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/studies)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const id = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await studyPermissionService.verifyRequestorAccess(requestContext, id, req.method);
+
+ const result = await studyService.mustFind(requestContext, id);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/studies)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const possibleBody = req.body;
+ const result = await studyService.create(requestContext, possibleBody);
+
+ // TODO we should move this call to the studyService itself, otherwise we need to do result.access = 'admin';
+ await studyPermissionService.create(requestContext, result.id);
+ result.access = 'admin'; // TODO see the todo comment above
+
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id/files (mounted to /api/studies)
+ // ===============================================================
+ router.get(
+ '/:id/files',
+ wrap(async (req, res) => {
+ const studyId = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await studyPermissionService.verifyRequestorAccess(requestContext, studyId, req.method);
+
+ const result = await studyService.listFiles(requestContext, studyId);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id/upload-requests (mounted to /api/studies)
+ // ===============================================================
+ router.get(
+ '/:id/upload-requests',
+ wrap(async (req, res) => {
+ const studyId = req.params.id;
+ const requestContext = res.locals.requestContext;
+ const filenames = req.query.filenames.split(',');
+
+ // Check permissions against a PUT request since uploading files to the study
+ // is a mutating action
+ await studyPermissionService.verifyRequestorAccess(requestContext, studyId, 'PUT');
+
+ const result = await studyService.createPresignedPostRequests(requestContext, studyId, filenames);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id/permissions (mounted to /api/studies)
+ // ===============================================================
+ router.get(
+ '/:id/permissions',
+ wrap(async (req, res) => {
+ const studyId = req.params.id;
+ const requestContext = res.locals.requestContext;
+
+ await studyPermissionService.verifyRequestorAccess(requestContext, studyId, req.method);
+
+ const result = await studyPermissionService.findByStudy(requestContext, studyId);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:id/permissions (mounted to /api/studies)
+ // ===============================================================
+ router.put(
+ '/:id/permissions',
+ wrap(async (req, res) => {
+ const studyId = req.params.id;
+ const requestContext = res.locals.requestContext;
+ const updateRequest = req.body;
+
+ // Validate permissions and usage
+ await studyPermissionService.verifyRequestorAccess(requestContext, studyId, req.method);
+ const study = await studyService.mustFind(requestContext, studyId);
+ if (study.category === 'My Studies') {
+ throw context.boom.forbidden('Permissions cannot be set for studies in the "My Studies" category', true);
+ }
+
+ const result = await studyPermissionService.update(requestContext, studyId, updateRequest);
+ res.status(200).json(result);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/template-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/template-controller.js
new file mode 100644
index 0000000000..29adb194a5
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/template-controller.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ const [externalCfnTemplateService] = await context.service(['externalCfnTemplateService']);
+
+ // ===============================================================
+ // GET /:id (mounted to /api/template)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const key = req.params.id;
+ const result = await externalCfnTemplateService.mustGetSignS3Url(key);
+ res.status(200).json(result);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/user-roles-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/user-roles-controller.js
new file mode 100644
index 0000000000..3fe60e5ad8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/user-roles-controller.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// const authProviderConstants = require('@aws-ee/base-services/lib/authentication-providers/constants')
+// .authenticationProviders;
+// const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const boom = context.boom;
+ const [userRolesService] = await context.service(['userRolesService']);
+
+ // ===============================================================
+ // GET / (mounted to /api/user-roles)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const userRoles = await userRolesService.list();
+ res.status(200).json(userRoles);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/users-controller.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/users-controller.js
new file mode 100644
index 0000000000..d4f15733b2
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/controllers/users-controller.js
@@ -0,0 +1,116 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const boom = context.boom;
+ const [userService, dbPasswordService] = await context.service(['userService', 'dbPasswordService']);
+
+ // ===============================================================
+ // GET / (mounted to /api/users)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const users = await userService.listUsers(requestContext);
+ res.status(200).json(users);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/users)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const createdUser = await userService.createUser(requestContext, req.body);
+ res.status(200).json(createdUser);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/users/bulk)
+ // ===============================================================
+ router.post(
+ '/bulk',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const users = req.body;
+ const defaultAuthNProviderId = req.query.authenticationProviderId;
+ const result = await userService.createUsers(requestContext, users, defaultAuthNProviderId);
+ res.status(200).json(result);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:username (mounted to /api/users)
+ // ===============================================================
+ router.put(
+ '/:username',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const username = req.params.username;
+ const userInBody = req.body || {};
+ const user = await userService.updateUser(requestContext, {
+ ...userInBody,
+ username,
+ });
+ res.status(200).json(user);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:username/password (mounted to /api/users)
+ // ===============================================================
+ router.put(
+ '/:username/password',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const username = req.params.username;
+ const { password } = req.body;
+
+ // Save password salted hash for the user in internal auth provider (i.e., in passwords table)
+ await dbPasswordService.savePassword(requestContext, { username, password });
+ res.status(200).json({ username, message: `Password successfully updated for user ${username}` });
+ }),
+ );
+
+ // ===============================================================
+ // DELETE /:id (mounted to /api/users)
+ // ===============================================================
+ router.delete(
+ '/:username',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const { username } = req.params;
+ const { authenticationProviderId, identityProviderName } = req.body;
+ await userService.deleteUser(requestContext, {
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ });
+ res.status(200).json({ message: `user ${username} deleted` });
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/authentication-plugin.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/authentication-plugin.js
new file mode 100644
index 0000000000..efbc089e98
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/authentication-plugin.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const { getSystemRequestContext } = require('@aws-ee/base-services/lib/helpers/system-context');
+
+/**
+ * The main authentication plugin function. This plugin implementation adds customization by
+ * checking for user's role. The authentication plugins are used by the "authentication-service.js".
+ *
+ * @param authenticationPluginPayload A plugin's payload. This has the shape { token, container, authResult }.
+ * @param authenticationPluginPayload.token The JWT token being used for authentication.
+ * @param authenticationPluginPayload.container Services container that provides service implementations for registered services
+ * @param authenticationPluginPayload.authResult The current authentication result containing authentication decision evaluated so far
+ * (by previous plugins or the original decision from the authenticationService).
+ *
+ * @returns {Promise<{container: *, authResult: {authenticated: boolean}, token: *}|*>}
+ */
+async function authenticate(authenticationPluginPayload) {
+ const { token, container, authResult } = authenticationPluginPayload;
+ const notAuthenticated = claims => ({ token, container, authResult: { ...claims, authenticated: false } });
+ const isAuthenticated = _.get(authResult, 'authenticated', false);
+
+ // if the current authentication decision is "not authenticated" then return right away
+ if (!isAuthenticated) return authenticationPluginPayload;
+
+ const logger = await container.find('log');
+ try {
+ const { username, authenticationProviderId, identityProviderName } = authResult;
+ const userService = await container.find('userService');
+ const user = await userService.mustFindUser({
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ });
+ const userRoleId = _.get(user, 'userRole');
+ if (!userRoleId) {
+ // no user role, don't know what kind of user is this, return not authenticated
+ return notAuthenticated(...authResult);
+ }
+
+ const userRolesService = await container.find('userRolesService');
+ // Make sure the user's role exists
+ // It is possible that the user was created before with some role and then that role was disabled
+ // For example, if a user with an "external-researcher" role was created before but then the external-researcher role was
+ // disabled (by setting the enableExternalResearchers = false) in that case the "external-researcher" may no longer
+ // be a valid role but there may still be some existing users with that role. Those users should no longer be able
+ // to login.
+ await userRolesService.mustFind(getSystemRequestContext(), { id: userRoleId });
+ } catch (e) {
+ logger.error('Error authenticating the user');
+ logger.error(e);
+ return notAuthenticated(...authResult);
+ }
+ return authenticationPluginPayload;
+}
+
+const plugin = {
+ authenticate,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/authn-handler-services-plugin.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/authn-handler-services-plugin.js
new file mode 100644
index 0000000000..5bda73b7fb
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/authn-handler-services-plugin.js
@@ -0,0 +1,77 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const PluginRegistryService = require('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service');
+const UserService = require('@aws-ee/base-raas-services/lib/user/user-service');
+const UserAuthzService = require('@aws-ee/base-raas-services/lib/user/user-authz-service');
+const UserRoleService = require('@aws-ee/base-raas-services/lib/user-roles/user-roles-service');
+const UserAttributesMapperService = require('@aws-ee/base-raas-services/lib/user/user-attributes-mapper-service');
+
+const settingKeys = {
+ tablePrefix: 'dbTablePrefix',
+};
+
+/**
+ * Registers the services needed by the workflow loop runner lambda function
+ * @param container An instance of ServicesContainer to register services to
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise}
+ */
+// eslint-disable-next-line no-unused-vars
+async function registerServices(container, pluginRegistry) {
+ container.register('userService', new UserService());
+ // The base authn provider uses username by concatenating username with auth provider name and idp name
+ // In RaaS, the email address should be used as username so register custom UserAttributesMapperService that maps
+ // attribs from decoded token to user
+ container.register('userAttributesMapperService', new UserAttributesMapperService());
+ container.register('userRolesService', new UserRoleService());
+ container.register('pluginRegistryService', new PluginRegistryService(pluginRegistry), { lazy: false });
+ container.register('raasUserAuthzService', new UserAuthzService());
+}
+
+/**
+ * Registers static settings required by the workflow loop runner lambda function
+ * @param existingStaticSettings An existing static settings plain javascript object containing settings as key/value contributed by other plugins
+ * @param settings Default instance of settings service that resolves settings from environment variables
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point
+ *
+ * @returns {Promise<*>} A promise that resolves to static settings object
+ */
+// eslint-disable-next-line no-unused-vars
+function getStaticSettings(existingStaticSettings, settings, pluginRegistry) {
+ const staticSettings = {
+ ...existingStaticSettings,
+ };
+
+ // Register all dynamodb table names used by the base rest api addon
+ const tablePrefix = settings.get(settingKeys.tablePrefix);
+ const table = (key, suffix) => {
+ staticSettings[key] = `${tablePrefix}-${suffix}`;
+ };
+ table('dbTableUserRoles', 'DbUserRoles');
+
+ return staticSettings;
+}
+
+const plugin = {
+ getStaticSettings,
+ // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerSettingsService, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerLoggerService, // not implemented, the default behavior provided by addon-base is sufficient
+ registerServices,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/routes-plugin.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/routes-plugin.js
new file mode 100644
index 0000000000..39b94b152c
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/routes-plugin.js
@@ -0,0 +1,122 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const setupAuthContext = require('@aws-ee/base-controllers/lib/middlewares/setup-auth-context');
+const prepareContext = require('@aws-ee/base-controllers/lib/middlewares/prepare-context');
+const ensureActive = require('@aws-ee/base-controllers/lib/middlewares/ensure-active');
+const ensureAdmin = require('@aws-ee/base-controllers/lib/middlewares/ensure-admin');
+const userController = require('@aws-ee/base-controllers/lib/user-controller');
+
+const usersController = require('../controllers/users-controller');
+const studyController = require('../controllers/study-controller');
+const environmentController = require('../controllers/environment-controller');
+const userRolesController = require('../controllers/user-roles-controller');
+const awsAccountsController = require('../controllers/aws-accounts-controller');
+const costsController = require('../controllers/costs-controller');
+const indexesController = require('../controllers/indexes-controller');
+const projectController = require('../controllers/project-controller');
+const accountsController = require('../controllers/accounts-controller');
+const templateController = require('../controllers/template-controller');
+const computeController = require('../controllers/compute-controller');
+const ipController = require('../controllers/ip-controller');
+
+/**
+ * Adds routes to the given routesMap.
+ *
+ * @param routesMap A Map containing routes. This object is a Map that has route paths as
+ * keys and an array of functions that configure the router as value. Each function in the
+ * array is expected have the following signature. The function accepts context and router
+ * arguments and returns a configured router.
+ *
+ * (context, router) => configured router
+ *
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise<*>} Returns a Map with the mapping of the routes vs their router configurer functions
+ */
+// eslint-disable-next-line no-unused-vars
+async function getRoutes(routesMap, pluginRegistry) {
+ // PROTECTED APIS for workflows accessible only to logged in ADMIN users. These routes are already registered by
+ // the workflow's api plugin (addons/addon-base-workflow-api/packages/base-worklfow-api/lib/plugins/routes-plugin.js)
+ // but are made available to all users.
+ // For RaaS, we want these APIs to be ONLY available to admin users. So append ensureAdmin middleware to existing
+ // routes middlewares
+ appendMiddleware(routesMap, '/api/step-templates', ensureAdmin);
+ appendMiddleware(routesMap, '/api/workflow-templates', ensureAdmin);
+ appendMiddleware(routesMap, '/api/workflows', ensureAdmin);
+
+ const routes = new Map([
+ ...routesMap,
+ // PROTECTED APIS accessible only to logged in users
+ ['/api/user', [setupAuthContext, prepareContext, userController]],
+
+ // PROTECTED APIS accessible only to logged in active users
+ ['/api/users', [setupAuthContext, prepareContext, ensureActive, usersController]],
+ ['/api/studies', [setupAuthContext, prepareContext, ensureActive, studyController]],
+ ['/api/workspaces', [setupAuthContext, prepareContext, ensureActive, environmentController]],
+ ['/api/user-roles', [setupAuthContext, prepareContext, ensureActive, userRolesController]],
+ ['/api/aws-accounts', [setupAuthContext, prepareContext, ensureActive, awsAccountsController]],
+ ['/api/costs', [setupAuthContext, prepareContext, ensureActive, costsController]],
+ ['/api/indexes', [setupAuthContext, prepareContext, ensureActive, indexesController]],
+ ['/api/projects', [setupAuthContext, prepareContext, ensureActive, projectController]],
+ ['/api/template', [setupAuthContext, prepareContext, ensureActive, templateController]],
+ ['/api/compute', [setupAuthContext, prepareContext, ensureActive, computeController]],
+ ['/api/ip', [setupAuthContext, prepareContext, ensureActive, ipController]],
+
+ // PROTECTED APIS accessible only to logged in active, admin users
+ ['/api/accounts', [setupAuthContext, prepareContext, ensureActive, ensureAdmin, accountsController]],
+ ]);
+ return routes;
+}
+
+/**
+ * A private utility function to append a middleware function for an existing route in the specified routesMap.
+ *
+ * For example, if the specified route has existing middlewares as [middleware1, middleware2, controller] and if
+ * middleware to append is "middleware3" then this function will modify the specified route as
+ * [middleware1, middleware2, middleware3, controller]
+ *
+ * @param routesMap
+ * @param route
+ * @param middleware
+ * @returns {*}
+ */
+function appendMiddleware(routesMap, route, middleware) {
+ // the existingMiddlewares is expected to be an array in [middleware1, middleware2, controler] form
+ const existingMiddlewares = routesMap.get(route);
+
+ if (!existingMiddlewares) {
+ throw new Error(`Cannot append a middleware no route found for path "${route}"`);
+ }
+ if (_.isEmpty(existingMiddlewares)) {
+ throw new Error(
+ `Cannot append a middleware. The route "${route}" needs to contain at least one controller function`,
+ );
+ }
+ const updatedMiddlewares = [
+ ...existingMiddlewares.slice(0, existingMiddlewares.length - 1),
+ middleware,
+ existingMiddlewares[existingMiddlewares.length - 1],
+ ];
+ routesMap.set(route, updatedMiddlewares);
+ return routesMap;
+}
+
+const plugin = {
+ getRoutes,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/services-plugin.js b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/services-plugin.js
new file mode 100644
index 0000000000..99316cb261
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/lib/plugins/services-plugin.js
@@ -0,0 +1,125 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const PluginRegistryService = require('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service');
+const UserAuthzService = require('@aws-ee/base-raas-services/lib/user/user-authz-service');
+const UserService = require('@aws-ee/base-raas-services/lib/user/user-service');
+const UserAttributesMapperService = require('@aws-ee/base-raas-services/lib/user/user-attributes-mapper-service');
+const StudyService = require('@aws-ee/base-raas-services/lib/study/study-service');
+const StudyPermissionService = require('@aws-ee/base-raas-services/lib/study/study-permission-service');
+const EnvironmentService = require('@aws-ee/base-raas-services/lib/environment/environment-service');
+const EnvironmentKeypairService = require('@aws-ee/base-raas-services/lib/environment/environment-keypair-service');
+const EnvironmentAmiService = require('@aws-ee/base-raas-services/lib/environment/environment-ami-service');
+const EnvironmentNotebookUrlService = require('@aws-ee/base-raas-services/lib/environment/environment-notebook-url-service');
+const EnvironmentSpotPriceHistoryService = require('@aws-ee/base-raas-services/lib/environment/environment-spot-price-history-service');
+const UserRolesService = require('@aws-ee/base-raas-services/lib/user-roles/user-roles-service');
+const AwsAccountsService = require('@aws-ee/base-raas-services/lib/aws-accounts/aws-accounts-service');
+const CostsService = require('@aws-ee/base-raas-services/lib/costs/costs-service');
+const CostApiCacheService = require('@aws-ee/base-raas-services/lib/cost-api-cache/cost-api-cache-service');
+const IndexesService = require('@aws-ee/base-raas-services/lib/indexes/indexes-service');
+const ProjectService = require('@aws-ee/base-raas-services/lib/project/project-service');
+const AccountService = require('@aws-ee/base-raas-services/lib/account/account-service');
+const CfnTemplateService = require('@aws-ee/base-raas-services/lib/cfn-templates/cfn-template-service');
+const ExternalCfnTemplateService = require('@aws-ee/base-raas-services/lib/external-cfn-template/external-cfn-template-service');
+const ComputePlatformService = require('@aws-ee/base-raas-services/lib/compute/compute-platform-service');
+const ComputePriceService = require('@aws-ee/base-raas-services/lib/compute/compute-price-service');
+const EnvironmentAuthzService = require('@aws-ee/base-raas-services/lib/environment/environment-authz-service');
+const EnvironmentMountService = require('@aws-ee/base-raas-services/lib/environment/environment-mount-service');
+
+const settingKeys = {
+ tablePrefix: 'dbTablePrefix',
+};
+
+/**
+ * Registers the services needed by the workflow loop runner lambda function
+ * @param container An instance of ServicesContainer to register services to
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise}
+ */
+// eslint-disable-next-line no-unused-vars
+async function registerServices(container, pluginRegistry) {
+ container.register('userService', new UserService());
+ // The base authn provider uses username by concatenating username with auth provider name and idp name
+ // In RaaS, the email address should be used as username so register custom UserAttributesMapperService that maps
+ // attribs from decoded token to user
+ container.register('userAttributesMapperService', new UserAttributesMapperService());
+ container.register('userRolesService', new UserRolesService());
+ container.register('studyService', new StudyService());
+ container.register('studyPermissionService', new StudyPermissionService());
+ container.register('environmentService', new EnvironmentService());
+ container.register('environmentKeypairService', new EnvironmentKeypairService());
+ container.register('environmentAmiService', new EnvironmentAmiService());
+ container.register('environmentNotebookUrlService', new EnvironmentNotebookUrlService());
+ container.register('environmentSpotPriceHistoryService', new EnvironmentSpotPriceHistoryService());
+ container.register('environmentMountService', new EnvironmentMountService());
+ container.register('cfnTemplateService', new CfnTemplateService());
+ container.register('awsAccountsService', new AwsAccountsService());
+ container.register('costsService', new CostsService());
+ container.register('costApiCacheService', new CostApiCacheService());
+ container.register('indexesService', new IndexesService());
+ container.register('projectService', new ProjectService());
+ container.register('accountService', new AccountService());
+ container.register('externalCfnTemplateService', new ExternalCfnTemplateService());
+ container.register('computePlatformService', new ComputePlatformService());
+ container.register('computePriceService', new ComputePriceService());
+ container.register('pluginRegistryService', new PluginRegistryService(pluginRegistry), { lazy: false });
+
+ // Authorization Services from raas addon
+ container.register('raasUserAuthzService', new UserAuthzService());
+ container.register('environmentAuthzService', new EnvironmentAuthzService());
+}
+
+/**
+ * Registers static settings required by the workflow loop runner lambda function
+ * @param existingStaticSettings An existing static settings plain javascript object containing settings as key/value contributed by other plugins
+ * @param settings Default instance of settings service that resolves settings from environment variables
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point
+ *
+ * @returns {Promise<*>} A promise that resolves to static settings object
+ */
+// eslint-disable-next-line no-unused-vars
+function getStaticSettings(existingStaticSettings, settings, pluginRegistry) {
+ const staticSettings = {
+ ...existingStaticSettings,
+ };
+
+ // Register all dynamodb table names used by the base rest api addon
+ const tablePrefix = settings.get(settingKeys.tablePrefix);
+ const table = (key, suffix) => {
+ staticSettings[key] = `${tablePrefix}-${suffix}`;
+ };
+ table('dbTableStudies', 'DbStudies');
+ table('dbTableEnvironments', 'DbEnvironments');
+ table('dbTableUserRoles', 'DbUserRoles');
+ table('dbTableAwsAccounts', 'DbAwsAccounts');
+ table('dbTableIndexes', 'DbIndexes');
+ table('dbTableCostApiCaches', 'DbCostApiCaches');
+ table('dbTableAccounts', 'DbAccounts');
+ table('dbTableProjects', 'DbProjects');
+ table('dbTableStudyPermissions', 'DbStudyPermissions');
+
+ return staticSettings;
+}
+
+const plugin = {
+ getStaticSettings,
+ // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerSettingsService, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerLoggerService, // not implemented, the default behavior provided by addon-base is sufficient
+ registerServices,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-raas/packages/base-raas-rest-api/package.json b/addons/addon-base-raas/packages/base-raas-rest-api/package.json
new file mode 100644
index 0000000000..c48d6bcc06
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-rest-api/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@aws-ee/base-raas-rest-api",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A library containing a set of base RaaS related controllers",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-controllers": "workspace:*",
+ "@aws-ee/base-raas-services": "workspace:*",
+ "@aws-ee/base-services": "workspace:*",
+ "lodash": "^4.17.15"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "pretty-quick": "^1.11.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/.eslintrc.json b/addons/addon-base-raas/packages/base-raas-services/.eslintrc.json
new file mode 100644
index 0000000000..a9e56eda24
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/.gitignore b/addons/addon-base-raas/packages/base-raas-services/.gitignore
new file mode 100644
index 0000000000..05bd7c6845
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/.gitignore
@@ -0,0 +1,20 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+**/.webpack
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+/coverage/
+.build
diff --git a/addons/addon-base-raas/packages/base-raas-services/.prettierrc.json b/addons/addon-base-raas/packages/base-raas-services/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/jest.config.js b/addons/addon-base-raas/packages/base-raas-services/jest.config.js
new file mode 100644
index 0000000000..60c221fc1d
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/jest.config.js
@@ -0,0 +1,25 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/jsconfig.json b/addons/addon-base-raas/packages/base-raas-services/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/account/account-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/account/account-service.js
new file mode 100644
index 0000000000..97ece4fb39
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/account/account-service.js
@@ -0,0 +1,303 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+const { allowIfActive, allowIfAdmin } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+
+const { isExternalGuest, isExternalResearcher, isInternalGuest } = require('../helpers/is-role');
+const createSchema = require('../schema/create-account');
+const updateSchema = require('../schema/update-account');
+
+const settingKeys = {
+ tableName: 'dbTableAccounts',
+ apiHandlerArn: 'apiHandlerArn',
+ workflowRoleArn: 'workflowRoleArn',
+};
+
+class AccountService extends Service {
+ constructor() {
+ super();
+ this.dependency([
+ 'jsonSchemaValidationService',
+ 'dbService',
+ 'aws',
+ 'authorizationService',
+ 'workflowTriggerService',
+ 'auditWriterService',
+ ]);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+ }
+
+ async find(requestContext, { id, fields = [] }) {
+ // ensure that the caller has permissions to read this account information
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(requestContext, { action: 'read', conditions: [allowIfActive, allowIfAdmin] }, { id });
+
+ const result = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { id, fields = [] }) {
+ const result = await this.find(requestContext, { id, fields });
+ if (!result) throw this.boom.notFound(`Accounts with id "${id}" does not exist`, true);
+ return result;
+ }
+
+ async provisionAccount(requestContext, rawData) {
+ // ensure that the caller has permissions to provision the account
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'provision', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // TODO: prepare all params and pass them down to step and create ready account there
+ const [validationService, workflowTriggerService] = await this.service([
+ 'jsonSchemaValidationService',
+ 'workflowTriggerService',
+ ]);
+
+ // Validate input
+ await validationService.ensureValid(rawData, createSchema);
+
+ const { accountName, accountEmail, masterRoleArn, externalId, description } = rawData;
+
+ // Check launch pre-requisites
+ if (!(accountName && accountEmail && masterRoleArn && externalId && description)) {
+ const cause = this.getConfigError(accountName, accountEmail, masterRoleArn, externalId, description);
+ throw this.boom.badRequest(
+ `Creating AWS account process has not been correctly configured: missing ${cause}.`,
+ true,
+ );
+ }
+ const workflowRoleArn = this.settings.get(settingKeys.workflowRoleArn);
+ const apiHandlerArn = this.settings.get(settingKeys.apiHandlerArn);
+
+ // trigger the provision environment workflow
+ // TODO: remove CIDR default once its in the gui and backend
+ const input = {
+ requestContext,
+ accountName,
+ accountEmail,
+ masterRoleArn,
+ externalId,
+ description,
+ workflowRoleArn,
+ apiHandlerArn,
+ };
+ await workflowTriggerService.triggerWorkflow(requestContext, { workflowId: 'wf-provision-account' }, input);
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'provision-account', body: { accountName, accountEmail, description } });
+ }
+
+ async saveAccountToDb(requestContext, rawData, id, status = 'PENDING') {
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ // Prepare the db object
+ const date = new Date().toISOString();
+ const dbObject = this._fromRawToDbObject(rawData, {
+ status,
+ rev: 0,
+ createdBy: by,
+ updatedBy: by,
+ createdAt: date,
+ updatedAt: date,
+ });
+ // Time to save the the db object
+ let dbResult;
+ try {
+ dbResult = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`account with id "${id}" already exists`, true);
+ },
+ );
+ } catch (error) {
+ this.log.log(error);
+ }
+ return dbResult;
+ }
+
+ async update(requestContext, rawData) {
+ // ensure that the caller has permissions to update the account
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'update', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate the input
+ const jsonSchemaValidationService = await this.service('jsonSchemaValidationService');
+ await jsonSchemaValidationService.ensureValid(rawData, updateSchema);
+
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+
+ // Prepare the db object
+ const existingAccount = await this.mustFind(requestContext, { id: rawData.id });
+ const mergedCfnInfo = _.assign({}, existingAccount.cfnInfo, rawData.cfnInfo);
+ const updatedAccount = _.omit(_.assign({}, existingAccount, rawData, { cfnInfo: mergedCfnInfo }), ['id']);
+ const dbObject = this._fromRawToDbObject(updatedAccount, {
+ updatedBy: by,
+ updatedAt: new Date().toISOString(),
+ });
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id: rawData.id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.notFound(`environment with id "${rawData.id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-account', body: rawData });
+
+ return result;
+ }
+
+ async delete(requestContext, { id }) {
+ // ensure that the caller has permissions to delete the account
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'delete', conditions: [allowIfActive, allowIfAdmin] },
+ { id },
+ );
+
+ // Lets now remove the item from the database
+ const result = await runAndCatch(
+ async () => {
+ return this._deleter()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .delete();
+ },
+ async () => {
+ throw this.boom.notFound(`awsAccounts with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-account', body: { id } });
+
+ return result;
+ }
+
+ getConfigError(cfnExecutionRole, roleExternalId, accountId, vpcId, subnetId) {
+ const causes = [];
+
+ if (!cfnExecutionRole) causes.push('IAM role');
+ if (!roleExternalId) causes.push('External ID');
+ if (!accountId) causes.push('AWS account ID');
+ if (!vpcId) causes.push('VPC ID');
+ if (!subnetId) causes.push('VPC Subnet ID');
+
+ if (causes.length > 1) {
+ const last = causes.pop();
+ return `${causes.join(', ')} and ${last}`;
+ }
+ if (causes.length > 0) {
+ return causes[0];
+ }
+ return undefined;
+ }
+
+ async list(requestContext, { fields = [] } = {}) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return [];
+
+ // Future task: add further checks
+
+ // Remember doing a scan is not a good idea if you billions of rows
+ return this._scanner()
+ .limit(1000)
+ .projection(fields)
+ .scan();
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'account-authz', action, conditions },
+ ...args,
+ );
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = AccountService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-accounts-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-accounts-service.js
new file mode 100644
index 0000000000..c42e164126
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-accounts-service.js
@@ -0,0 +1,367 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const uuid = require('uuid/v1');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+const { allowIfActive, allowIfAdmin } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+
+const { isExternalGuest, isExternalResearcher, isInternalGuest } = require('../helpers/is-role');
+const createSchema = require('../schema/create-aws-accounts');
+const ensureExternalSchema = require('../schema/ensure-external-aws-accounts');
+const updateSchema = require('../schema/update-aws-accounts');
+
+const settingKeys = {
+ tableName: 'dbTableAwsAccounts',
+ environmentInstanceFiles: 'environmentInstanceFiles',
+};
+
+class AwsAccountsService extends Service {
+ constructor() {
+ super();
+ this.dependency([
+ 'jsonSchemaValidationService',
+ 'authorizationService',
+ 'dbService',
+ 'lockService',
+ 's3Service',
+ 'auditWriterService',
+ ]);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+ }
+
+ async find(requestContext, { id, fields = [] }) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return undefined;
+
+ // Future task: add further checks
+
+ const result = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { id, fields = [] }) {
+ const result = await this.find(requestContext, { id, fields });
+ if (!result) throw this.boom.notFound(`awsAccounts with id "${id}" does not exist`, true);
+ return result;
+ }
+
+ async updateEnvironmentInstanceFilesBucketPolicy() {
+ const [s3Service, lockService] = await this.service(['s3Service', 'lockService']);
+ const environmentInstanceUri = this.settings.get(settingKeys.environmentInstanceFiles);
+ const { s3BucketName, s3Key: s3Prefix } = s3Service.parseS3Details(environmentInstanceUri);
+
+ const accountList = await this.list({ fields: ['accountId'] });
+
+ const accountArns = accountList.map(({ accountId }) => `arn:aws:iam::${accountId}:root`);
+
+ // Update S3 bucket policy
+ const s3Client = s3Service.api;
+ const s3LockKey = `s3|bucket-policy|${s3BucketName}`;
+ await lockService.tryWriteLockAndRun({ id: s3LockKey }, async () => {
+ const listSid = `List:${s3Prefix}`;
+ const getSid = `Get:${s3Prefix}`;
+
+ const securityStatements = [
+ {
+ Sid: 'Deny requests that do not use TLS',
+ Effect: 'Deny',
+ Principal: '*',
+ Action: 's3:*',
+ Resource: `arn:aws:s3:::${s3BucketName}/*`,
+ Condition: { Bool: { 'aws:SecureTransport': false } },
+ },
+ {
+ Sid: 'Deny requests that do not use SigV4',
+ Effect: 'Deny',
+ Principal: '*',
+ Action: 's3:*',
+ Resource: `arn:aws:s3:::${s3BucketName}/*`,
+ Condition: {
+ StringNotEquals: {
+ 's3:signatureversion': 'AWS4-HMAC-SHA256',
+ },
+ },
+ },
+ ];
+ const listStatement = {
+ Sid: listSid,
+ Effect: 'Allow',
+ Principal: { AWS: accountArns },
+ Action: 's3:ListBucket',
+ Resource: `arn:aws:s3:::${s3BucketName}`,
+ Condition: {
+ StringLike: {
+ 's3:prefix': [`${s3Prefix}*`],
+ },
+ },
+ };
+ const getStatement = {
+ Sid: getSid,
+ Effect: 'Allow',
+ Principal: { AWS: accountArns },
+ Action: ['s3:GetObject'],
+ Resource: [`arn:aws:s3:::${s3BucketName}/${s3Prefix}*`],
+ };
+
+ const Policy = JSON.stringify({
+ Version: '2012-10-17',
+ Statement: [...securityStatements, listStatement, getStatement],
+ });
+
+ return s3Client.putBucketPolicy({ Bucket: s3BucketName, Policy }).promise();
+ });
+ }
+
+ async create(requestContext, rawData) {
+ // ensure that the caller has permissions to create the account
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'create', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, createSchema);
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const id = uuid();
+
+ // Prepare the db object
+ const dbObject = this._fromRawToDbObject(rawData, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`awsAccounts with id "${id}" already exists`, true);
+ },
+ );
+
+ await this.updateEnvironmentInstanceFilesBucketPolicy();
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-aws-account', body: result });
+
+ return result;
+ }
+
+ async ensureExternalAccount(requestContext, rawData) {
+ // TODO: ensure that the caller is an external user
+ // await this.assertAuthorized(requestContext, 'create', rawData);
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, ensureExternalSchema);
+
+ // TODO: setup a GSI and query that for the accountId
+ const accounts = await this.list({ fields: ['accountId'] });
+ const account = accounts.find(({ accountId }) => accountId === rawData.accountId);
+ // If the account has already been added don't add again
+ if (account) {
+ return account;
+ }
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const id = uuid();
+
+ rawData.description = `External account for user ${by.username}`;
+
+ // Prepare the db object
+ const dbObject = this._fromRawToDbObject(rawData, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`awsAccounts with id "${id}" already exists`, true);
+ },
+ );
+
+ await this.updateEnvironmentInstanceFilesBucketPolicy();
+ return result;
+ }
+
+ async update(requestContext, rawData) {
+ // ensure that the caller has permissions to update the account
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'update', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, updateSchema);
+
+ // For now, we assume that 'updatedBy' is always a user and not a group
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { id, rev } = rawData;
+
+ // Prepare the db object
+ const dbObject = _.omit(this._fromRawToDbObject(rawData, { updatedBy: by }), ['rev']);
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .rev(rev)
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ // There are two scenarios here:
+ // 1 - The awsaccounts does not exist
+ // 2 - The "rev" does not match
+ // We will display the appropriate error message accordingly
+ const existing = await this.find(requestContext, { id, fields: ['id', 'updatedBy'] });
+ if (existing) {
+ throw this.boom.badRequest(
+ `awsAccounts information changed by "${
+ (existing.updatedBy || {}).username
+ }" just before your request is processed, please try again`,
+ true,
+ );
+ }
+ throw this.boom.notFound(`awsAccounts with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-aws-account', body: result });
+
+ return result;
+ }
+
+ async delete(requestContext, { id }) {
+ // ensure that the caller has permissions to delete the account
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'delete', conditions: [allowIfActive, allowIfAdmin] },
+ { id },
+ );
+
+ // Lets now remove the item from the database
+ const result = await runAndCatch(
+ async () => {
+ return this._deleter()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .delete();
+ },
+ async () => {
+ throw this.boom.notFound(`awsAccounts with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-aws-account', body: { id } });
+
+ return result;
+ }
+
+ async list(requestContext, { fields = [] } = {}) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return [];
+
+ // Future task: add further checks
+
+ // Remember doing a scan is not a good idea if you billions of rows
+ return this._scanner()
+ .limit(1000)
+ .projection(fields)
+ .scan();
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'aws-account-authz', action, conditions },
+ ...args,
+ );
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = AwsAccountsService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/cfn-templates/cfn-template-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/cfn-templates/cfn-template-service.js
new file mode 100644
index 0000000000..7c050a45a4
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/cfn-templates/cfn-template-service.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+class CfnTemplateService extends Service {
+ constructor() {
+ super();
+ this.dependency(['pluginRegistryService']);
+ }
+
+ async init() {
+ await super.init();
+ this.store = []; // an array of objects of this shape: { key: , value: { yaml } }
+ const registry = await this.service('pluginRegistryService');
+ // We loop through each plugin and ask it to register its cfn templates
+ const plugins = await registry.getPlugins('cfn-templates');
+ // eslint-disable-next-line no-restricted-syntax
+ for (const plugin of plugins) {
+ // eslint-disable-next-line no-await-in-loop
+ await plugin.registerCfnTemplates(this);
+ }
+ }
+
+ async add({ name, yaml }) {
+ const existing = await this.getTemplate(name);
+ if (existing)
+ throw this.boom.badRequest(
+ `You tried to register a cfn template, but a cfn template with the same name "${name}" already exists`,
+ true,
+ );
+
+ this.store.push({ key: name, value: yaml });
+ }
+
+ async getTemplate(name) {
+ const entry = _.find(this.store, ['key', name]);
+ return entry ? entry.value : undefined;
+ }
+}
+
+module.exports = CfnTemplateService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/compute-platform-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/compute-platform-service.js
new file mode 100644
index 0000000000..bf78018c94
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/compute-platform-service.js
@@ -0,0 +1,64 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { processInBatches } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const { getPlatforms } = require('./data/compute-platforms');
+const { getConfigurations } = require('./data/compute-configurations');
+
+/**
+ * The purpose of this service is to answer questions like:
+ * - What are the available compute platforms given a user?
+ * - What are the available compute configurations for a given compute platform given a user?
+ * - For certain compute configurations, pricing is also computed on the fly rather than hard coded.
+ */
+class ComputePlatformService extends Service {
+ constructor() {
+ super();
+ this.dependency(['computePriceService']);
+ }
+
+ // This method expects requestContext.principal object to be fully populated
+ // eslint-disable-next-line no-unused-vars
+ async listPlatforms(requestContext) {
+ return getPlatforms(requestContext.principal);
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async listConfigurations(requestContext, { platformId = '', includePrice = false } = {}) {
+ const [priceService] = await this.service(['computePriceService']);
+ const user = requestContext.principal;
+
+ // Check if the user can view the platformId before returning the configurations
+ const allowedPlatforms = getPlatforms(user) || [];
+ if (!_.some(allowedPlatforms, ['id', platformId])) return [];
+
+ const configurations = getConfigurations(platformId, user);
+ const doWork = async configuration => {
+ const priceInfo = await priceService.calculatePriceInfo(configuration);
+ configuration.priceInfo = priceInfo;
+ };
+
+ if (includePrice) {
+ await processInBatches(configurations, 10, doWork);
+ }
+
+ return configurations;
+ }
+}
+
+module.exports = ComputePlatformService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/compute-price-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/compute-price-service.js
new file mode 100644
index 0000000000..90d6149b49
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/compute-price-service.js
@@ -0,0 +1,128 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const settingKeys = {
+ awsRegion: 'awsRegion',
+};
+
+// WARNING WARNING WARNING WARNING
+// This service has very limited functionality at the moment.
+// Most of the prices are hard coded in the configuration and not calculated by
+// this service. This service needs to be modified to allow for extension points, etc.
+// At this time (05/12/20), this service only calculates the average of the spot prices
+// for ec2 instances used in EMR.
+class ComputePriceService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws']);
+ }
+
+ // Looks at the existing computeConfiguration.princeInfo object and returns a new one
+ // with calculated prices (when applicable). IMPORTANT: please read the class top comments.
+ async calculatePriceInfo(computeConfiguration) {
+ // We don't check for any permissions here. This service is considered lower level service,
+ // and most of the time it is not directly interacting with controllers.
+ //
+ // Future enhancement should be to add extension points
+
+ const region = this.settings.get(settingKeys.awsRegion);
+ const configType = computeConfiguration.type;
+ const originalPrinceInfo = computeConfiguration.priceInfo || {};
+ let priceInfo;
+
+ // This switch logic should be eventually turned into an extension point
+ switch (configType) {
+ case 'emr':
+ priceInfo = await this.computeEmrPrice(computeConfiguration);
+ break;
+ default:
+ priceInfo = { ...originalPrinceInfo, region };
+ }
+
+ return priceInfo;
+ }
+
+ // LIMITATION: this method calls ec2.describeSpotPriceHistory API which is throttle (100 requests per second)
+ // https://docs.aws.amazon.com/AWSEC2/latest/APIReference/throttling.html
+ async computeEmrPrice(configuration) {
+ // example of priceInfo => { value: 0.504, unit: 'USD', timeUnit: 'hour', type: 'onDemand', region: 'us-east-1', spotBidPrice: (if type is 'spot') },
+ const originalPrinceInfo = configuration.priceInfo || {};
+ if (originalPrinceInfo.timeUnit !== 'hour')
+ return this.boom.badRequest('Pricing with a time unit other than "hour" is not supported', true);
+
+ const region = this.settings.get(settingKeys.awsRegion);
+ const priceType = originalPrinceInfo.type; // can be onDemand or spot
+ const getParam = name => {
+ // First we see if the paramter is considered immutable, if so, we return its immutable value
+ // otherwise we return the mutable value if it exists
+ const immutable = _.get(configuration, ['params', 'immutable', name]);
+ if (!_.isUndefined(immutable)) return immutable;
+ return _.get(configuration, ['params', 'mutable', name]);
+ };
+ const emr = getParam('emr') || {};
+ const ec2Type = emr.workerInstanceSize;
+ const ec2Count = emr.workerInstanceCount;
+ const ec2OnDemandPrice = emr.workerInstanceOnDemandPrice;
+ const ec2MasterOnDemandPrice = emr.masterInstanceOnDemandPrice;
+ const spotBidMultiplier = getParam('spotBidMultiplier');
+
+ if (priceType === 'onDemand') {
+ return { ...originalPrinceInfo, region, value: ec2MasterOnDemandPrice + ec2Count * ec2OnDemandPrice };
+ }
+
+ if (priceType !== 'spot') return originalPrinceInfo;
+
+ const priceHistory = await this.getSpotPriceHistory(ec2Type, region);
+ let bidPrice = ec2OnDemandPrice;
+ if (!_.isEmpty(priceHistory)) {
+ const average = _.sum(_.map(priceHistory, item => item.spotPrice)) / priceHistory.length;
+ bidPrice = average * spotBidMultiplier;
+ }
+
+ return {
+ ...originalPrinceInfo,
+ region,
+ value: ec2MasterOnDemandPrice + ec2Count * bidPrice,
+ spotBidPrice: bidPrice,
+ spotBidMultiplier,
+ };
+ }
+
+ async getSpotPriceHistory(ec2Type, region = 'us-east-1') {
+ const aws = await this.service('aws');
+
+ const ec2 = new aws.sdk.EC2({
+ region,
+ });
+
+ const { SpotPriceHistory } = await ec2
+ .describeSpotPriceHistory({
+ InstanceTypes: [ec2Type],
+ ProductDescriptions: ['Linux/UNIX'],
+ StartTime: new Date(),
+ })
+ .promise();
+
+ return SpotPriceHistory.map(({ AvailabilityZone, SpotPrice }) => ({
+ availabilityZone: AvailabilityZone,
+ spotPrice: parseFloat(SpotPrice),
+ }));
+ }
+}
+
+module.exports = ComputePriceService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/compute-configurations.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/compute-configurations.js
new file mode 100644
index 0000000000..637de63868
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/compute-configurations.js
@@ -0,0 +1,31 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute platforms and their configurations
+const sagemaker = require('./sagemaker/configurations');
+const emr = require('./emr/configurations');
+const ec2 = require('./ec2/configurations');
+
+const getConfigurations = (platformId, user = {}) => {
+ return [
+ ...sagemaker.getConfigurations(platformId, user),
+ ...emr.getConfigurations(platformId, user),
+ ...ec2.getConfigurations(platformId, user),
+ ];
+};
+
+module.exports = {
+ getConfigurations,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/compute-platforms.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/compute-platforms.js
new file mode 100644
index 0000000000..6a28edc59d
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/compute-platforms.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute platforms and their configurations
+const sagemaker = require('./sagemaker/platforms');
+const emr = require('./emr/platforms');
+const ec2 = require('./ec2/platforms');
+
+const getPlatforms = (user = {}) => {
+ return [...sagemaker.getPlatforms(user), ...emr.getPlatforms(user), ...ec2.getPlatforms(user)];
+};
+
+module.exports = {
+ getPlatforms,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/ec2/configurations.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/ec2/configurations.js
new file mode 100644
index 0000000000..ec23b83814
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/ec2/configurations.js
@@ -0,0 +1,192 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute configurations
+const _ = require('lodash');
+
+const configurations = [
+ {
+ id: 'ec2-linux_small',
+ type: 'ec2-linux',
+ title: 'Small',
+ displayOrder: 1,
+ priceInfo: { value: 0.504, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc:
+ 'A small environment is meant for prototyping and proving out scripts before scaling up to a larger. It costs the least amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '8',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '64',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'r5.2xlarge',
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+ {
+ id: 'ec2-linux_medium',
+ type: 'ec2-linux',
+ title: 'Medium',
+ displayOrder: 2,
+ priceInfo: { value: 2.016, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc: 'A medium environment is meant for average sized problems.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '32',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '256',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'r5.8xlarge',
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+ {
+ id: 'ec2-linux_large',
+ type: 'ec2-linux',
+ title: 'Large',
+ displayOrder: 3,
+ priceInfo: { value: 4.032, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc: 'A large environment is meant for the largest of problems. It costs the most amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '64',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '512',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'r5.16xlarge',
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+ {
+ id: 'ec2-windows_small',
+ type: 'ec2-windows',
+ title: 'Small',
+ displayOrder: 4,
+ priceInfo: { value: 0.872, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc:
+ 'A small environment is meant for prototyping and proving out scripts before scaling up to a larger. It costs the least amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '8',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '64',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'r5.2xlarge',
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+ {
+ id: 'ec2-windows_medium',
+ type: 'ec2-windows',
+ title: 'Medium',
+ displayOrder: 5,
+ priceInfo: { value: 3.488, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc: 'A medium environment is meant for average sized problems.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '32',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '256',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'r5.8xlarge',
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+ {
+ id: 'ec2-windows_large',
+ type: 'ec2-windows',
+ title: 'Large',
+ displayOrder: 1,
+ priceInfo: { value: 6.976, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc: 'A large environment is meant for the largest of problems. It costs the most amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '64',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '512',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'r5.16xlarge',
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+];
+
+const filterByType = platformId => {
+ const map = { 'ec2-linux-1': 'ec2-linux', 'ec2-windows-1': 'ec2-windows' };
+ const type = map[platformId];
+ return _.filter(configurations, ['type', type]);
+};
+
+// Which user can view which configuration
+const getConfigurations = (platformId, user) =>
+ _.get(user, 'userRole') !== 'external-researcher' ? filterByType(platformId) : []; // external researchers can't view ec2 configurations for now
+
+module.exports = {
+ getConfigurations,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/ec2/platforms.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/ec2/platforms.js
new file mode 100644
index 0000000000..6a439672d4
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/ec2/platforms.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute platforms
+const _ = require('lodash');
+
+const platforms = [
+ {
+ id: 'ec2-linux-1',
+ type: 'ec2-linux',
+ title: 'EC2 - Linux',
+ displayOrder: 3,
+ desc: `Secure, resizable compute in the cloud`,
+ },
+ {
+ id: 'ec2-windows-1',
+ type: 'ec2-windows',
+ title: 'EC2 - Windows',
+ displayOrder: 4,
+ desc: `Secure, resizable compute in the cloud`,
+ },
+];
+
+// Which user can view which type
+const getPlatforms = user => (_.get(user, 'userRole') !== 'external-researcher' ? _.slice(platforms) : []); // external researchers can't view ec2 platforms for now
+
+module.exports = {
+ getPlatforms,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/emr/configurations.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/emr/configurations.js
new file mode 100644
index 0000000000..6fa96b372b
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/emr/configurations.js
@@ -0,0 +1,268 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute configurations
+const _ = require('lodash');
+
+// IMPORTANT - IMPORTANT - IMPORTANT
+// All spot priceInfo will be calculated by the pricing service
+const configurations = [
+ {
+ id: 'emr_small',
+ type: 'emr',
+ title: 'Small - On Demand',
+ displayOrder: 1,
+ priceInfo: { value: undefined, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc:
+ 'A small research workspace meant for prototyping and proving out scripts before scaling up to a larger. It costs the least amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '4',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '16',
+ },
+ {
+ key: 'Worker nodes',
+ value: '1',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'm5.xlarge',
+ emr: {
+ masterInstanceOnDemandPrice: 0.192,
+ workerInstanceSize: 'm5.xlarge',
+ workerInstanceCount: 1,
+ workerInstanceOnDemandPrice: 0.192,
+ diskSizeGb: 10,
+ },
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+
+ {
+ id: 'emr_small_spot',
+ type: 'emr',
+ title: 'Small - Spot',
+ displayOrder: 2,
+ priceInfo: { value: undefined, unit: 'USD', timeUnit: 'hour', type: 'spot' },
+ desc:
+ 'A small research workspace meant for prototyping and proving out scripts before scaling up to a larger. It costs the least amount per hour. This research workspace uses spot pricing for worker nodes with a maximum price of 1.3x the spot history price.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '4',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '16',
+ },
+ {
+ key: 'Worker nodes',
+ value: '1',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'm5.xlarge',
+ spotBidMultiplier: 1.3,
+ emr: {
+ masterInstanceOnDemandPrice: 0.192,
+ workerInstanceSize: 'm5.xlarge',
+ workerInstanceCount: 1,
+ workerInstanceOnDemandPrice: 0.192,
+ diskSizeGb: 10,
+ },
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+
+ {
+ id: 'emr_medium',
+ type: 'emr',
+ title: 'Medium - On Demand',
+ displayOrder: 3,
+ priceInfo: { value: undefined, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc:
+ 'A medium research workspace meant for average sized problems. This research workspace uses spot pricing for worker nodes with a maximum price of the on demand price.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '4',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '16',
+ },
+ {
+ key: 'Worker nodes',
+ value: '8',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'm5.xlarge',
+ emr: {
+ masterInstanceOnDemandPrice: 0.192,
+ workerInstanceSize: 'm5.xlarge',
+ workerInstanceCount: 8,
+ workerInstanceOnDemandPrice: 0.192,
+ diskSizeGb: 10,
+ },
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+
+ {
+ id: 'emr_medium_spot',
+ type: 'emr',
+ title: 'Medium - Spot',
+ displayOrder: 4,
+ priceInfo: { value: undefined, unit: 'USD', timeUnit: 'hour', type: 'spot' },
+ desc:
+ 'A medium research workspace meant for average sized problems. This research workspace uses spot pricing for worker nodes with a maximum price of 1.3x the spot history price.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '4',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '16',
+ },
+ {
+ key: 'Worker nodes',
+ value: '8',
+ },
+ ],
+ params: {
+ immutable: {
+ spotBidMultiplier: 1.3,
+ size: 'm5.xlarge',
+ emr: {
+ masterInstanceOnDemandPrice: 0.192,
+ workerInstanceSize: 'm5.xlarge',
+ workerInstanceCount: 8,
+ workerInstanceOnDemandPrice: 0.192,
+ diskSizeGb: 10,
+ },
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+
+ {
+ id: 'emr_large',
+ type: 'emr',
+ title: 'Large - On Demand',
+ displayOrder: 5,
+ priceInfo: { value: undefined, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc:
+ 'A large research workspace meant for the largest of problems. It costs the most amount per hour. This research workspace uses spot pricing for worker nodes with a maximum price of the on demand price.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '96',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '384',
+ },
+ {
+ key: 'Worker nodes',
+ value: '8',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'm5.24xlarge',
+ emr: {
+ masterInstanceOnDemandPrice: 0.192,
+ workerInstanceSize: 'm5.24xlarge',
+ workerInstanceCount: 8,
+ workerInstanceOnDemandPrice: 4.608,
+ diskSizeGb: 10,
+ },
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+
+ {
+ id: 'emr_large_spot',
+ type: 'emr',
+ title: 'Large - Spot',
+ displayOrder: 6,
+ priceInfo: { value: undefined, unit: 'USD', timeUnit: 'hour', type: 'spot' },
+ desc:
+ 'A large research workspace meant for the largest of problems. It costs the most amount per hour. This research workspace uses spot pricing for worker nodes with a maximum price of 1.3x the spot history price.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '96',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '384',
+ },
+ {
+ key: 'Worker nodes',
+ value: '8',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'm5.24xlarge',
+ spotBidMultiplier: 1.3,
+ emr: {
+ masterInstanceOnDemandPrice: 0.192,
+ workerInstanceSize: 'm5.24xlarge',
+ workerInstanceCount: 8,
+ workerInstanceOnDemandPrice: 4.608,
+ diskSizeGb: 10,
+ },
+ },
+ mutable: {
+ cidr: '',
+ },
+ },
+ },
+];
+
+// These configurations belong to which compute platform ids
+const filterByType = platformId => (['emr-1'].includes(platformId) ? _.slice(configurations) : []);
+
+// Which user can view which configuration
+const getConfigurations = (platformId, _user) => filterByType(platformId); // All users can see all configurations
+
+module.exports = {
+ getConfigurations,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/emr/platforms.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/emr/platforms.js
new file mode 100644
index 0000000000..d255849d40
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/emr/platforms.js
@@ -0,0 +1,39 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute platforms
+const _ = require('lodash');
+
+const platforms = [
+ {
+ id: 'emr-1',
+ type: 'emr',
+ title: 'EMR',
+ displayOrder: 2,
+ desc: `An Amazon EMR research workspace that comes with:
+ * Hail 0.2
+ * Jupyter Lab
+ * Spark 2.4.4
+ * Hadoop 2.8.5
+`,
+ },
+];
+
+// Which user can view which type
+const getPlatforms = () => _.slice(platforms); // all users can see all emr platforms
+
+module.exports = {
+ getPlatforms,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/sagemaker/configurations.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/sagemaker/configurations.js
new file mode 100644
index 0000000000..29e047da85
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/sagemaker/configurations.js
@@ -0,0 +1,107 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute configurations
+const _ = require('lodash');
+
+const configurations = [
+ {
+ id: 'sagemaker__small',
+ type: 'sagemaker',
+ title: 'Small',
+ displayOrder: 1,
+ priceInfo: { value: 0.0464, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc:
+ 'A small research workspace meant for prototyping and proving out scripts before scaling up to a larger. It costs the least amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '4',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '16',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'ml.t2.medium',
+ cidr: '0.0.0.0/0',
+ },
+ mutable: {},
+ },
+ },
+ {
+ id: 'sagemaker__medium',
+ type: 'sagemaker',
+ title: 'Medium',
+ displayOrder: 2,
+ price: 1.075,
+ priceInfo: { value: 1.075, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc: 'A medium research workspace meant for average sized problems.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '32',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '128',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'ml.m5.4xlarge',
+ cidr: '0.0.0.0/0',
+ },
+ mutable: {},
+ },
+ },
+ {
+ id: 'sagemaker__large',
+ type: 'sagemaker',
+ title: 'Large',
+ displayOrder: 3,
+ priceInfo: { value: 6.451, unit: 'USD', timeUnit: 'hour', type: 'onDemand' },
+ desc: 'A large research workspace meant for the largest of problems. It costs the most amount per hour.',
+ displayProps: [
+ {
+ key: 'vCPU',
+ value: '96',
+ },
+ {
+ key: 'Memory (GiB)',
+ value: '384',
+ },
+ ],
+ params: {
+ immutable: {
+ size: 'ml.m5.24xlarge',
+ cidr: '0.0.0.0/0',
+ },
+ mutable: {},
+ },
+ },
+];
+
+// These configurations belong to which compute platform ids
+const filterByType = platformId => (['sagemaker-1'].includes(platformId) ? _.slice(configurations) : []);
+
+// Which user can view which configuration
+const getConfigurations = platformId => filterByType(platformId); // All users can see all configurations
+
+module.exports = {
+ getConfigurations,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/sagemaker/platforms.js b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/sagemaker/platforms.js
new file mode 100644
index 0000000000..7bccbf7e57
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/compute/data/sagemaker/platforms.js
@@ -0,0 +1,38 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// A temporarily place to keep the information about the compute platforms
+const _ = require('lodash');
+
+const platforms = [
+ {
+ id: 'sagemaker-1',
+ type: 'sagemaker',
+ title: 'SageMaker',
+ displayOrder: 1,
+ desc: `An Amazon SageMaker Jupyter Notebook that comes with:
+ * TensorFlow
+ * Apache MXNet
+ * Scikit-learn
+`,
+ },
+];
+
+// Which user can view which type
+const getPlatforms = () => _.slice(platforms); // All users can see all platforms
+
+module.exports = {
+ getPlatforms,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/cost-api-cache/cost-api-cache-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/cost-api-cache/cost-api-cache-service.js
new file mode 100644
index 0000000000..c29addfc1a
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/cost-api-cache/cost-api-cache-service.js
@@ -0,0 +1,151 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const createSchema = require('../schema/create-cost-api-cache');
+const updateSchema = require('../schema/update-cost-api-cache');
+
+const settingKeys = {
+ tableName: 'dbTableCostApiCaches',
+};
+
+class CostApiCacheService extends Service {
+ constructor() {
+ super();
+ this.dependency(['jsonSchemaValidationService', 'dbService']);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+ }
+
+ async find(requestContext, { indexId, query, fields = [] }) {
+ const result = await this._getter()
+ .key({ indexId, query })
+ .projection(fields)
+ .get();
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { indexId, query, fields = [] }) {
+ const result = await this.find(requestContext, { indexId, query, fields });
+ if (!result) throw this.boom.notFound(`costApiCache with id "${indexId}" does not exist`, true);
+ return result;
+ }
+
+ async create(requestContext, rawData) {
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+
+ // Validate input
+ await validationService.ensureValid(rawData, createSchema);
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { indexId, query } = rawData;
+
+ // Prepare the db object
+ const dbObject = this._fromRawToDbObject(rawData, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Time to save the the db object
+ const result = await runAndCatch(async () => {
+ return this._updater()
+ .key({ indexId, query })
+ .item(dbObject)
+ .update();
+ });
+
+ return result;
+ }
+
+ async update(requestContext, rawData) {
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+
+ // Validate input
+ await validationService.ensureValid(rawData, updateSchema);
+
+ // For now, we assume that 'updatedBy' is always a user and not a group
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { indexId, rev } = rawData;
+
+ // Prepare the db object
+ const dbObject = _.omit(this._fromRawToDbObject(rawData, { updatedBy: by }), ['rev']);
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .key({ indexId })
+ .rev(rev)
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ // There are two scenarios here:
+ // 1 - The costapicache does not exist
+ // 2 - The "rev" does not match
+ // We will display the appropriate error message accordingly
+ const existing = await this.find(requestContext, { indexId, fields: ['indexId', 'updatedBy'] });
+ if (existing) {
+ throw this.boom.badRequest(
+ `costApiCache information changed by "${
+ (existing.updatedBy || {}).username
+ }" just before your request is processed, please try again`,
+ true,
+ );
+ }
+ throw this.boom.notFound(`costApiCache with indexId "${indexId}" does not exist`, true);
+ },
+ );
+
+ return result;
+ }
+
+ async list({ fields = [] } = {}) {
+ // Remember doing a scanning is not a good idea if you billions of rows
+ return this._scanner()
+ .limit(1000)
+ .projection(fields)
+ .scan();
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+}
+
+module.exports = CostApiCacheService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/costs/costs-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/costs/costs-service.js
new file mode 100644
index 0000000000..0e0c9ea3bc
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/costs/costs-service.js
@@ -0,0 +1,219 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+const { allowIfActive } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+const { allowIfHasRole } = require('../user/helpers/user-authz-utils');
+
+class CostsService extends Service {
+ constructor() {
+ super();
+ this.dependency([
+ 'aws',
+ 'awsAccountsService',
+ 'environmentService',
+ 'indexesService',
+ 'costApiCacheService',
+ 'authorizationService',
+ ]);
+ }
+
+ async init() {
+ await super.init();
+ }
+
+ async getIndividualEnvironmentOrProjCost(requestContext, query) {
+ // ensure that the caller has permissions to read the cost
+ // Perform default condition checks to make sure the user is active and has allowed roles
+ const allowIfHasCorrectRoles = (reqContext, { action }) =>
+ allowIfHasRole(reqContext, { action, resource: 'environment-or-project-cost' }, ['admin', 'researcher']);
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'read', conditions: [allowIfActive, allowIfHasCorrectRoles] },
+ query,
+ );
+
+ const { env, proj, groupByUser, groupByEnv, groupByService, numberOfDaysInPast } = query;
+ const [environmentService, costApiCacheService] = await this.service(['environmentService', 'costApiCacheService']);
+
+ if (groupByUser === 'true' && groupByEnv === 'true' && groupByService === 'true') {
+ return 'Can not groupByUser, groupByEnv, and groupByService. Please pick at most 2 out of the 3.';
+ }
+ let indexId = '';
+
+ if (proj) {
+ indexId = proj;
+ } else {
+ // The following will only succeed if the user has permissions to access the specified environment
+ const result = await environmentService.mustFind(requestContext, { id: env });
+ indexId = result.indexId;
+ }
+
+ const cacheResponse = await costApiCacheService.find(requestContext, { indexId, query: JSON.stringify(query) });
+ if (cacheResponse) {
+ const updatedAt = new Date(cacheResponse.updatedAt);
+ const now = new Date();
+ const elapsedHours = (now - updatedAt) / 1000 / 60 / 60;
+ if (elapsedHours < 12) {
+ return JSON.parse(cacheResponse.result);
+ }
+ }
+
+ let filter = {};
+ if (proj) {
+ filter = {
+ Tags: {
+ Key: 'Proj',
+ Values: [proj],
+ },
+ };
+ } else {
+ filter = {
+ Tags: {
+ Key: 'Env',
+ Values: [env],
+ },
+ };
+ }
+
+ const groupBy = [];
+ if (groupByService === 'true') {
+ groupBy.push({
+ Type: 'DIMENSION',
+ Key: 'SERVICE',
+ });
+ }
+ if (groupByUser === 'true') {
+ groupBy.push({
+ Type: 'TAG',
+ Key: 'CreatedBy',
+ });
+ }
+ if (groupByEnv === 'true') {
+ groupBy.push({
+ Type: 'TAG',
+ Key: 'Env',
+ });
+ }
+
+ const response = await this.callAwsCostExplorerApi(requestContext, indexId, numberOfDaysInPast, filter, groupBy);
+
+ const rawCacheData = {
+ indexId,
+ query: JSON.stringify(query),
+ result: JSON.stringify(response),
+ };
+ costApiCacheService.create(requestContext, rawCacheData);
+
+ return response;
+ }
+
+ async callAwsCostExplorerApi(requestContext, indexId, numberOfDaysInPast, filter, groupBy) {
+ const [aws] = await this.service(['aws']);
+ const { accessKeyId, secretAccessKey, sessionToken } = await this.getCredentials(requestContext, indexId);
+
+ const costExplorer = new aws.sdk.CostExplorer({
+ apiVersion: '2017-10-25',
+ region: 'us-east-1',
+ accessKeyId,
+ secretAccessKey,
+ sessionToken,
+ });
+ const now = new Date();
+ const startDate = new Date();
+ startDate.setDate(now.getDate() - numberOfDaysInPast);
+
+ const result = await costExplorer
+ .getCostAndUsage({
+ TimePeriod: {
+ Start: startDate.toISOString().split('T')[0],
+ End: now.toISOString().split('T')[0],
+ },
+ Granularity: 'DAILY',
+ Metrics: ['BlendedCost'],
+ Filter: filter,
+ GroupBy: groupBy,
+ })
+ .promise();
+
+ const response = result.ResultsByTime.map(item => {
+ const costItems = {};
+ item.Groups.forEach(group => {
+ if (group.Metrics.BlendedCost.Amount > 0) {
+ costItems[group.Keys] = {
+ amount: Math.round(group.Metrics.BlendedCost.Amount * 100) / 100,
+ unit: group.Metrics.BlendedCost.Unit,
+ };
+ }
+ });
+ return {
+ startDate: item.TimePeriod.Start,
+ cost: costItems,
+ };
+ });
+
+ return response;
+ }
+
+ async getCredentials(requestContext, indexId) {
+ const [aws, awsAccountsService, indexesService] = await this.service([
+ 'aws',
+ 'awsAccountsService',
+ 'indexesService',
+ ]);
+ const { roleArn: RoleArn, externalId: ExternalId } = await runAndCatch(
+ async () => {
+ const { awsAccountId } = await indexesService.mustFind(requestContext, { id: indexId });
+
+ return awsAccountsService.mustFind(requestContext, { id: awsAccountId });
+ },
+ async () => {
+ throw this.boom.badRequest(`account with id "${indexId} is not available`);
+ },
+ );
+
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const sts = new aws.sdk.STS({ region: 'us-east-1' });
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${by.username}`,
+ ExternalId,
+ })
+ .promise();
+
+ return { accessKeyId, secretAccessKey, sessionToken };
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'cost-authz', action, conditions },
+ ...args,
+ );
+ }
+}
+
+module.exports = CostsService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-ami-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-ami-service.js
new file mode 100644
index 0000000000..520de60b8a
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-ami-service.js
@@ -0,0 +1,95 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const NodeCache = require('node-cache');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+class EnvironmentAmiService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws']);
+ this.cacheService = new NodeCache();
+ }
+
+ async init() {
+ await super.init();
+ const aws = await this.service('aws');
+ this.ec2 = new aws.sdk.EC2();
+ }
+
+ async getLatest(amiPrefix) {
+ const cacheKey = `${amiPrefix}-latestAMI`;
+ const latest = this.cacheService.get(cacheKey);
+ if (_.isEmpty(latest)) {
+ const results = await this.list(amiPrefix);
+ if (_.isEmpty(results)) {
+ throw this.boom.notFound(
+ `Unable to find the latest AMI with prefix ${amiPrefix} to create the environment`,
+ true,
+ );
+ }
+ const result = results[0];
+ this.cacheService.set(cacheKey, result, 60 * 5);
+ return result;
+ }
+ return latest;
+ }
+
+ async list(amiPrefix) {
+ const params = {
+ Filters: [
+ {
+ Name: 'name',
+ Values: [`${amiPrefix}*`],
+ },
+ ],
+ };
+ const images = await this.ec2.describeImages(params).promise();
+ const results = images.Images.map(image => {
+ return {
+ imageId: image.ImageId,
+ createdAt: new Date(image.CreationDate),
+ name: image.Name,
+ };
+ });
+
+ return _.reverse(_.sortBy(results, ['createdAt']));
+ }
+
+ async ensurePermissions({ imageId, accountId }) {
+ const params = {
+ ImageId: imageId,
+ LaunchPermission: {
+ Add: [
+ {
+ UserId: accountId,
+ },
+ ],
+ },
+ };
+ const result = await (async () => {
+ try {
+ const attributes = await this.ec2.modifyImageAttribute(params).promise();
+ return attributes;
+ } catch (_e) {
+ throw this.boom.badRequest(`Unable to modify permissions on the software image for the selected index.`, true);
+ }
+ })();
+ return result;
+ }
+}
+
+module.exports = EnvironmentAmiService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-authz-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-authz-service.js
new file mode 100644
index 0000000000..a7175d777e
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-authz-service.js
@@ -0,0 +1,127 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const {
+ allow,
+ deny,
+ isDeny,
+ allowIfActive,
+ allowIfCurrentUserOrAdmin,
+} = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+
+const { allowIfHasRole } = require('../user/helpers/user-authz-utils');
+
+class EnvironmentAuthzService extends Service {
+ async authorize(requestContext, { resource, action, effect, reason }, ...args) {
+ let permissionSoFar = { effect };
+ // if effect is "deny" already (due to any of the previous plugins returning "deny") then return "deny" right away
+ if (isDeny(permissionSoFar)) return { resource, action, effect, reason };
+
+ // Make sure the caller is active. This basic check is required irrespective of "action" so checking it here
+ permissionSoFar = await allowIfActive(requestContext, { action });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ switch (action) {
+ case 'get':
+ case 'update':
+ case 'delete':
+ return this.allowIfOwnerOrAdmin(requestContext, { action }, ...args);
+ case 'list':
+ return this.authorizeList(requestContext, { action }, ...args);
+ case 'create':
+ return this.authorizeCreate(requestContext, { action }, ...args);
+ case 'create-external':
+ return this.authorizeCreateExternal(requestContext, { action }, ...args);
+ default:
+ // This authorizer does not know how to perform authorization for the specified action.
+ // Return with the current authorization decision collected so far (from other plugins, if any)
+ return { effect };
+ }
+ }
+
+ async allowIfOwnerOrAdmin(requestContext, { action }, environment) {
+ const envCreator = _.get(environment, 'createdBy');
+ if (_.isEmpty(envCreator)) {
+ return deny(`Cannot ${action} the workspace. Workspace creator information is not available`);
+ }
+
+ // Allow if the caller is the environment creator (owner) or admin
+ let permissionSoFar = await allowIfCurrentUserOrAdmin(requestContext, { action }, envCreator);
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ // Even if the user is owner (creator) of the env his/her role may have changed (e.g., to guest or internal-guest)
+ // that may not allow it to perform the specified action on the environment (after the environment was created initially)
+ permissionSoFar = allowIfHasRole(requestContext, { action, resource: 'environment' }, [
+ 'admin',
+ 'researcher',
+ 'external-researcher',
+ ]);
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ return permissionSoFar;
+ }
+
+ async authorizeList(requestContext, { action }) {
+ // Make sure the current user role allows listing an environment
+ const permissionSoFar = allowIfHasRole(requestContext, { action, resource: 'environment' }, [
+ 'admin',
+ 'researcher',
+ 'external-researcher',
+ ]);
+ return permissionSoFar;
+ }
+
+ async authorizeCreateExternal(requestContext, { action }, environment) {
+ // Make sure the current user role allows creating an external environment
+ const permissionSoFar = allowIfHasRole(requestContext, { action, resource: 'environment' }, [
+ 'external-researcher',
+ ]);
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ // Make sure the projectId is not specified, as external environments do not belong to any projects
+ const projectId = _.get(environment, 'projectId');
+ if (projectId) {
+ return deny(`Cannot ${action} external workspace. External workspace cannot be associated to a project`, false);
+ }
+ return allow();
+ }
+
+ async authorizeCreate(requestContext, { action }, environment) {
+ // Make sure the current user role allows creating an environment
+ const permissionSoFar = allowIfHasRole(requestContext, { action, resource: 'environment' }, [
+ 'admin',
+ 'researcher',
+ ]);
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ // Make sure the user has access to the project
+ const projectId = _.get(environment, 'projectId');
+ const projectIds = _.get(requestContext, 'principal.projectId'); // The 'projectId' field on principal is actually an array of project ids
+ if (!projectId) {
+ return deny(`Cannot ${action} workspace. No project is specified`, true);
+ }
+ if (!_.includes(projectIds, projectId)) {
+ return deny(
+ `Cannot ${action} workspace. You do not have access to project "${projectId}". Please contact your administrator.`,
+ true,
+ );
+ }
+ return allow();
+ }
+}
+module.exports = EnvironmentAuthzService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-keypair-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-keypair-service.js
new file mode 100644
index 0000000000..e8c7c217e8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-keypair-service.js
@@ -0,0 +1,117 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const settingKeys = {
+ paramStoreRoot: 'paramStoreRoot',
+};
+
+class EnvironmentKeypairService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws', 'environmentService', 'auditWriterService']);
+ }
+
+ async create(requestContext, id, credentials) {
+ const [aws, environmentService] = await this.service(['aws', 'environmentService']);
+
+ // The call below will only succeed if the user has access to the specified environment and if the environment exists.
+ // This is to prevent users from creating keypairs for non-existing environments or environments they do not have
+ // access to
+ const environment = await environmentService.mustFind(requestContext, { id });
+
+ const ec2 = new aws.sdk.EC2(credentials);
+
+ // The "id" is environment id as well as the ec2 keypair name
+ const keyPair = await ec2.createKeyPair({ KeyName: id }).promise();
+
+ const ssm = new aws.sdk.SSM(credentials);
+ const parameterName = `/${this.settings.get(settingKeys.paramStoreRoot)}/environments/${id}`;
+ await ssm
+ .putParameter({
+ Name: parameterName,
+ Type: 'SecureString',
+ Value: keyPair.KeyMaterial,
+ Description: `ssh key for environment ${environment.id}`,
+ Overwrite: true,
+ })
+ .promise();
+
+ const keyName = keyPair.KeyName;
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-environment-keypair', body: { keyName } });
+
+ return keyName;
+ }
+
+ async mustFind(requestContext, id) {
+ const [aws, environmentService] = await this.service(['aws', 'environmentService']);
+
+ // The "environmentService.credsForAccountWithEnvironment" call below will only succeed, if the user has permissions
+ // to access the specified environment.
+ // The "id" is environment id as well as the ec2 keypair name
+ const ssm = new aws.sdk.SSM(await environmentService.credsForAccountWithEnvironment(requestContext, { id }));
+ const parameterName = `/${this.settings.get(settingKeys.paramStoreRoot)}/environments/${id}`;
+ const privateKey = await ssm
+ .getParameter({
+ Name: parameterName,
+ WithDecryption: true,
+ })
+ .promise();
+
+ return { privateKey: privateKey.Parameter.Value };
+ }
+
+ async delete(requestContext, id, credentials) {
+ const [aws, environmentService] = await this.service(['aws', 'environmentService']);
+
+ // The call below will only succeed if the user has access to the specified environment and if the environment exists.
+ // This is to prevent users from deleting any random key-pairs.
+ // They are allowed to delete only key-pairs for environments they have access to
+ // The "id" is environment id as well as the ec2 key-pair name
+ const environment = await environmentService.mustFind(requestContext, { id });
+
+ const ec2 = new aws.sdk.EC2(credentials);
+ const ssm = new aws.sdk.SSM(credentials);
+ const parameterName = `/${this.settings.get(settingKeys.paramStoreRoot)}/environments/${id}`;
+
+ // The "id" is environment id as well as the ec2 keypair name
+ await ec2.deleteKeyPair({ KeyName: environment.id }).promise();
+
+ await ssm
+ .deleteParameter({
+ Name: parameterName,
+ })
+ .promise();
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-environment-keypair', body: { keyName: environment.id } });
+
+ return true;
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = EnvironmentKeypairService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-mount-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-mount-service.js
new file mode 100644
index 0000000000..75aec2519d
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-mount-service.js
@@ -0,0 +1,378 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const settingKeys = {
+ environmentInstanceFiles: 'environmentInstanceFiles',
+ studyDataBucketName: 'studyDataBucketName',
+ studyDataKmsKeyAlias: 'studyDataKmsKeyAlias',
+ studyDataKmsKeyArn: 'studyDataKmsKeyArn',
+ studyDataKmsPolicyWorkspaceSid: 'studyDataKmsPolicyWorkspaceSid',
+};
+
+const parseS3Arn = arn => {
+ const path = arn.slice('arn:aws:s3:::'.length);
+ const slashIndex = path.indexOf('/');
+ return slashIndex !== -1
+ ? {
+ bucket: path.slice(0, slashIndex),
+ prefix: arn.slice(arn.indexOf('/') + 1),
+ }
+ : {
+ bucket: path,
+ prefix: '/',
+ };
+};
+
+class EnvironmentMountService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws', 'lockService', 'studyService', 'studyPermissionService']);
+ }
+
+ async getCfnMountParameters(requestContext, rawDataV1) {
+ const studyInfo = await this._getStudyInfo(requestContext, rawDataV1);
+ await this._validateStudyPermissions(requestContext, studyInfo);
+ const s3Mounts = this._prepareS3Mounts(studyInfo);
+ const iamPolicyDocument = await this._generateIamPolicyDoc(studyInfo);
+
+ return {
+ s3Mounts: JSON.stringify(s3Mounts.map(({ id, bucket, prefix }) => ({ id, bucket, prefix }))),
+ iamPolicyDocument: JSON.stringify(iamPolicyDocument),
+ environmentInstanceFiles: this.settings.get(settingKeys.environmentInstanceFiles),
+ s3Prefixes: s3Mounts.filter(({ category }) => category !== 'Open Data').map(mount => mount.prefix),
+ };
+ }
+
+ async addRoleArnToLocalResourcePolicies(workspaceRoleArn, s3Prefixes) {
+ // Define function to handle updating resource policy principals where the current principals
+ // may be an array or a string
+ const updateAwsPrincipals = (awsPrincipals, newPrincipal) => {
+ if (Array.isArray(awsPrincipals)) {
+ awsPrincipals.push(newPrincipal);
+ } else {
+ awsPrincipals = [awsPrincipals, newPrincipal];
+ }
+ return awsPrincipals;
+ };
+
+ return this._updateResourcePolicies({ updateAwsPrincipals, workspaceRoleArn, s3Prefixes });
+ }
+
+ async removeRoleArnFromLocalResourcePolicies(workspaceRoleArn, s3Prefixes) {
+ // Define function to handle updating resource policy principals where the current principals
+ // may be an array or a string
+ const updateAwsPrincipals = (awsPrincipals, removedPrincipal) => {
+ if (Array.isArray(awsPrincipals)) {
+ awsPrincipals = awsPrincipals.filter(principal => principal !== removedPrincipal);
+ } else {
+ awsPrincipals = [];
+ }
+ return awsPrincipals;
+ };
+
+ return this._updateResourcePolicies({ updateAwsPrincipals, workspaceRoleArn, s3Prefixes });
+ }
+
+ async _updateResourcePolicies({ updateAwsPrincipals, workspaceRoleArn, s3Prefixes }) {
+ if (s3Prefixes.length === 0) {
+ return;
+ }
+
+ // Get S3 and KMS resource names
+ const s3BucketName = this.settings.get(settingKeys.studyDataBucketName);
+ let kmsKeyAlias = this.settings.get(settingKeys.studyDataKmsKeyAlias);
+ if (!kmsKeyAlias.startsWith('alias/')) {
+ kmsKeyAlias = `alias/${kmsKeyAlias}`;
+ }
+
+ // Setup services and SDK clients
+ const [aws, lockService] = await this.service(['aws', 'lockService']);
+ const s3Client = new aws.sdk.S3();
+ const kmsClient = new aws.sdk.KMS();
+
+ // Perform locked updates to prevent inconsistencies from race conditions
+ const s3LockKey = `s3|bucket-policy|${s3BucketName}`;
+ const kmsLockKey = `kms|key-policy|${kmsKeyAlias}`;
+ await Promise.all([
+ // Update S3 bucket policy
+ lockService.tryWriteLockAndRun({ id: s3LockKey }, async () => {
+ // Get existing policy
+ const s3Policy = JSON.parse((await s3Client.getBucketPolicy({ Bucket: s3BucketName }).promise()).Policy);
+
+ // Get statements for listing and reading study data, respectively
+ const statements = s3Policy.Statement;
+ s3Prefixes.forEach(prefix => {
+ const listSid = `List:${prefix}`;
+ const getSid = `Get:${prefix}`;
+
+ // Define default statements to be used if we can't find existing ones
+ let listStatement = {
+ Sid: listSid,
+ Effect: 'Allow',
+ Principal: { AWS: [] },
+ Action: 's3:ListBucket',
+ Resource: `arn:aws:s3:::${s3BucketName}`,
+ Condition: {
+ StringLike: {
+ 's3:prefix': [`${prefix}*`],
+ },
+ },
+ };
+ let getStatement = {
+ Sid: getSid,
+ Effect: 'Allow',
+ Principal: { AWS: [] },
+ Action: ['s3:GetObject'],
+ Resource: [`arn:aws:s3:::${s3BucketName}/${prefix}*`],
+ };
+
+ // Pull out existing statements if available
+ statements.forEach(statement => {
+ if (statement.Sid === listSid) {
+ listStatement = statement;
+ } else if (statement.Sid === getSid) {
+ getStatement = statement;
+ }
+ });
+
+ // Update statement and policy
+ // NOTE: The S3 API *should* remove duplicate principals, if any
+ listStatement.Principal.AWS = updateAwsPrincipals(listStatement.Principal.AWS, workspaceRoleArn);
+ getStatement.Principal.AWS = updateAwsPrincipals(getStatement.Principal.AWS, workspaceRoleArn);
+
+ s3Policy.Statement = s3Policy.Statement.filter(statement => ![listSid, getSid].includes(statement.Sid));
+ [listStatement, getStatement].forEach(statement => {
+ // Only add updated statement if it contains principals (otherwise leave it out)
+ if (statement.Principal.AWS.length > 0) {
+ s3Policy.Statement.push(statement);
+ }
+ });
+ });
+
+ // Update policy
+ await s3Client.putBucketPolicy({ Bucket: s3BucketName, Policy: JSON.stringify(s3Policy) }).promise();
+ }),
+
+ // Update KMS key policy
+ lockService.tryWriteLockAndRun({ id: kmsLockKey }, async () => {
+ // Get existing policy
+ const keyId = (await kmsClient.describeKey({ KeyId: kmsKeyAlias }).promise()).KeyMetadata.KeyId;
+ const kmsPolicy = JSON.parse(
+ (await kmsClient.getKeyPolicy({ KeyId: keyId, PolicyName: 'default' }).promise()).Policy,
+ );
+
+ // Get statement
+ const sid = this.settings.get(settingKeys.studyDataKmsPolicyWorkspaceSid);
+ let environmentStatement = kmsPolicy.Statement.find(statement => statement.Sid === sid);
+ if (!environmentStatement) {
+ // Create new statement if it doesn't already exist
+ environmentStatement = {
+ Sid: sid,
+ Effect: 'Allow',
+ Principal: { AWS: [] },
+ Action: ['kms:Encrypt', 'kms:Decrypt', 'kms:ReEncrypt*', 'kms:GenerateDataKey*', 'kms:DescribeKey'],
+ Resource: '*', // Only refers to this key since it's a resource policy
+ };
+ }
+
+ // Update policy
+ // NOTE: The S3 API *should* remove duplicate principals, if any
+ environmentStatement.Principal.AWS = updateAwsPrincipals(environmentStatement.Principal.AWS, workspaceRoleArn);
+
+ kmsPolicy.Statement = kmsPolicy.Statement.filter(statement => statement.Sid !== sid);
+ if (environmentStatement.Principal.AWS.length > 0) {
+ // Only add updated statement if it contains principals (otherwise leave it out)
+ kmsPolicy.Statement.push(environmentStatement);
+ }
+
+ await kmsClient
+ .putKeyPolicy({ KeyId: keyId, PolicyName: 'default', Policy: JSON.stringify(kmsPolicy) })
+ .promise();
+ }),
+ ]);
+ }
+
+ async _getStudyInfo(requestContext, environment) {
+ let studyInfo = [];
+ const studyIds = environment.instanceInfo.files;
+ if (studyIds && studyIds.length) {
+ const studyService = await this.service('studyService');
+ studyInfo = await Promise.all(
+ studyIds.map(async studyId => {
+ try {
+ const { id, name, category, resources } = await studyService.mustFind(requestContext, studyId);
+ return { id, name, category, resources };
+ } catch (error) {
+ // Because the studies update periodically we cannot guarantee consistency
+ // so filter anything invalid here
+ console.error(error);
+ return { name: '', resources: [] };
+ }
+ }),
+ );
+ }
+
+ return studyInfo;
+ }
+
+ async _validateStudyPermissions(requestContext, studyInfo) {
+ let permissions = {};
+ if (studyInfo.length) {
+ // Get requested study IDs
+ const requestedStudyIds = studyInfo.map(study => study.id);
+
+ // Retrieve and verify user's study permissions
+ const studyPermissionService = await this.service('studyPermissionService');
+ const storedPermissions = await studyPermissionService.getRequestorPermissions(requestContext);
+
+ // If there are no stored permissions, use a empty permissions object
+ permissions = storedPermissions || studyPermissionService.getEmptyUserPermissions();
+
+ // Add Open Data read access for everyone
+ permissions.readonlyAccess = permissions.readonlyAccess.concat(
+ studyInfo.filter(study => study.category === 'Open Data').map(study => study.id),
+ );
+
+ // Determine whether any forbidden studies were requested
+ const allowedStudies = permissions.adminAccess.concat(permissions.readonlyAccess);
+ const forbiddenStudies = _.difference(requestedStudyIds, allowedStudies);
+
+ if (forbiddenStudies.length) {
+ throw new Error(`Studies not found: ${forbiddenStudies.join(',')}`);
+ }
+ }
+ return permissions;
+ }
+
+ _prepareS3Mounts(studyInfo) {
+ let mounts = [];
+ if (studyInfo.length) {
+ // There might be multiple resources. In the future we may flatMap, for now...
+ mounts = studyInfo.reduce(
+ (result, { id, resources, category }) =>
+ result.concat(
+ resources.map(resource => {
+ const { bucket, prefix } = parseS3Arn(resource.arn);
+ return { id, bucket, prefix, category };
+ }),
+ ),
+ [],
+ );
+ }
+
+ return mounts;
+ }
+
+ async _generateIamPolicyDoc(studyInfo) {
+ let policyDoc = {};
+ if (studyInfo.length) {
+ const objectLevelActions = ['s3:GetObject'];
+
+ // Collect study resources
+ const objectPathArns = _.flatten(
+ studyInfo.map(info =>
+ info.resources
+ // Pull out resource ARNs
+ .map(resource => resource.arn)
+ // Only grab S3 ARNs
+ .filter(arn => arn.startsWith('arn:aws:s3:'))
+ // Normalize the ARNs by ensuring they end with "/*"
+ .map(arn => {
+ switch (arn.slice(-1)) {
+ case '*':
+ break;
+ case '/':
+ arn += '*';
+ break;
+ default:
+ arn += '/*';
+ }
+
+ return arn;
+ }),
+ ),
+ );
+
+ // Build policy statements for object-level permissions
+ const statements = [];
+ statements.push({
+ Sid: 'S3StudyReadAccess',
+ Effect: 'Allow',
+ Action: objectLevelActions,
+ Resource: objectPathArns,
+ });
+
+ // Create map of buckets whose paths need list access
+ const bucketPaths = {};
+ objectPathArns.forEach(arn => {
+ const { bucket, prefix } = parseS3Arn(arn);
+ if (!(bucket in bucketPaths)) {
+ bucketPaths[bucket] = [];
+ }
+ bucketPaths[bucket].push(prefix);
+ });
+
+ // Add bucket list permissions to statements
+ let bucketCtr = 1;
+ Object.keys(bucketPaths).forEach(bucketName => {
+ statements.push({
+ Sid: `studyListS3Access${bucketCtr}`,
+ Effect: 'Allow',
+ Action: 's3:ListBucket',
+ Resource: `arn:aws:s3:::${bucketName}`,
+ Condition: {
+ StringLike: {
+ 's3:prefix': bucketPaths[bucketName],
+ },
+ },
+ });
+ bucketCtr += 1;
+ });
+
+ // // Add KMS Permissions
+ const studyDataKmsAliasArn = this.settings.get(settingKeys.studyDataKmsKeyArn);
+
+ // Get KMS Key ARN from KMS Alias ARN
+ // The "Decrypt","DescribeKey","GenerateDataKey" etc require KMS KEY ARN and not ALIAS ARN
+ const [aws] = await this.service(['aws']);
+ const kmsClient = new aws.sdk.KMS();
+ const data = await kmsClient
+ .describeKey({
+ KeyId: studyDataKmsAliasArn,
+ })
+ .promise();
+ const studyDataKmsKeyArn = data.KeyMetadata.Arn;
+ statements.push({
+ Sid: 'studyKMSAccess',
+ Action: ['kms:Decrypt', 'kms:DescribeKey', 'kms:Encrypt', 'kms:GenerateDataKey', 'kms:ReEncrypt*'],
+ Effect: 'Allow',
+ Resource: studyDataKmsKeyArn,
+ });
+
+ // Build final policyDoc
+ policyDoc = {
+ Version: '2012-10-17',
+ Statement: statements,
+ };
+ }
+
+ return policyDoc;
+ }
+}
+
+module.exports = EnvironmentMountService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-notebook-url-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-notebook-url-service.js
new file mode 100644
index 0000000000..1ac9905ecf
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-notebook-url-service.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+class EnvironmentNotebookUrlService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws', 'environmentService', 'auditWriterService']);
+ }
+
+ async init() {
+ await super.init();
+ }
+
+ async getNotebookPresignedUrl(requestContext, id) {
+ const [aws, environmentService] = await this.service(['aws', 'environmentService']);
+
+ // The following will succeed only if the user has permissions to access the specified environment
+ const { instanceInfo } = await environmentService.mustFind(requestContext, { id });
+
+ const params = {
+ NotebookInstanceName: instanceInfo.NotebookInstanceName,
+ };
+ const sagemaker = new aws.sdk.SageMaker(
+ await environmentService.credsForAccountWithEnvironment(requestContext, { id }),
+ );
+ const url = await sagemaker.createPresignedNotebookInstanceUrl(params).promise();
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'notebook-presigned-url-requested', body: { id } });
+
+ return url;
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = EnvironmentNotebookUrlService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-service.js
new file mode 100644
index 0000000000..ef712ffda6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-service.js
@@ -0,0 +1,704 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const uuid = require('uuid/v1');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const createSchema = require('../schema/create-environment');
+const updateSchema = require('../schema/update-environment');
+
+const settingKeys = {
+ tableName: 'dbTableEnvironments',
+ awsAccountsTableName: 'dbTableAwsAccounts',
+ ec2LinuxAmiPrefix: 'ec2LinuxAmiPrefix',
+ ec2WindowsAmiPrefix: 'ec2WindowsAmiPrefix',
+ emrAmiPrefix: 'emrAmiPrefix',
+};
+const workflowIds = {
+ create: 'wf-create-environment',
+ delete: 'wf-delete-environment',
+};
+
+class EnvironmentService extends Service {
+ constructor() {
+ super();
+ this.dependency([
+ 'jsonSchemaValidationService',
+ 'dbService',
+ 'workflowTriggerService',
+ 'environmentAmiService',
+ 'environmentMountService',
+ 'aws',
+ 'awsAccountsService',
+ 'indexesService',
+ 'projectService',
+ 'computePlatformService',
+ 'authorizationService',
+ 'environmentAuthzService',
+ 'auditWriterService',
+ 'userService',
+ ]);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService, environmentAuthzService] = await this.service(['dbService', 'environmentAuthzService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+
+ // A private authorization condition function that just delegates to the environmentAuthzService
+ this._allowAuthorized = (requestContext, { resource, action, effect, reason }, ...args) =>
+ environmentAuthzService.authorize(requestContext, { resource, action, effect, reason }, ...args);
+ }
+
+ async list(requestContext) {
+ // Make sure the user has permissions to "list" environments
+ // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first
+ await this.assertAuthorized(requestContext, { action: 'list', conditions: [this._allowAuthorized] });
+
+ // TODO: Handle pagination
+
+ const environments = await this._scanner()
+ .limit(1000)
+ .scan();
+
+ if (requestContext.principal.isAdmin) {
+ return environments;
+ }
+
+ // map environment ownership
+ const { username: reqUsername, ns: reqNs } = requestContext.principalIdentifier;
+ const envMap = environments.map((env, index) => {
+ const { username: ownerUsername, ns: ownerNs } = env.createdBy;
+ const isOwner = reqUsername === ownerUsername && reqNs === ownerNs;
+
+ const { sharedWithUsers = [] } = env;
+ const isShared = Array.isArray(sharedWithUsers)
+ ? sharedWithUsers.some(u => u.username === reqUsername && u.ns === reqNs)
+ : false;
+
+ return {
+ isOwner,
+ // should not be owner and shared at the same time
+ isShared: !isOwner && isShared,
+ environmentsIndex: index,
+ };
+ });
+
+ // environments owned by user
+ const envOwner = envMap.filter(item => item.isOwner);
+
+ // environments shared with user
+ const envShared = envMap.filter(item => item.isShared);
+
+ // enviroments not owned by user
+ const envNonOwnerOrShared = envMap.filter(item => !item.isOwner && !item.isShared);
+
+ // gather environments where the current user is a project admin
+ const envProjectAdminPromises = envNonOwnerOrShared.map(env =>
+ this.isEnvironmentProjectAdmin(requestContext, environments[env.environmentsIndex]).catch(error => error),
+ );
+ // NOTE refactor to use Promise.allSettled() once on Node JS >= 12
+ const envProjectAdminPromiseResolutions = await Promise.all(envProjectAdminPromises);
+ const envProjectAdmin = envProjectAdminPromiseResolutions
+ .map((isProjectAdmin, index) => {
+ if (isProjectAdmin instanceof Error) {
+ // eslint-disable-next-line no-console
+ console.error('error listing environment checking isProjectAdmin: ', isProjectAdmin);
+ return { isProjectAdmin: false };
+ }
+ return {
+ isProjectAdmin,
+ environmentsIndex: envNonOwnerOrShared[index].environmentsIndex,
+ };
+ })
+ .filter(item => item.isProjectAdmin);
+
+ return [...envOwner, ...envShared, ...envProjectAdmin].map(item => environments[item.environmentsIndex]);
+ }
+
+ async find(requestContext, { id, fields = [] }) {
+ // Make sure 'createdBy' is always returned as that's required for authorizing the 'get' action
+ // If empty "fields" is specified then it means the caller is asking for all fields. No need to append 'createdBy'
+ // in that case.
+ const fieldsToGet = _.isEmpty(fields) ? fields : _.uniq([...fields, 'createdBy']);
+ const result = await this._getter()
+ .key({ id })
+ .projection(fieldsToGet)
+ .get();
+
+ if (result) {
+ // ensure that the caller has permissions to retrieve the specified environment
+ // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first
+ await this.assertAuthorized(requestContext, { action: 'get', conditions: [this._allowAuthorized] }, result);
+ }
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { id, fields = [] }) {
+ const result = await this.find(requestContext, { id, fields });
+ if (!result) throw this.boom.notFound(`environment with id "${id}" does not exist`, true);
+ return result;
+ }
+
+ async saveEnvironmentToDb(requestContext, rawData, id, status = 'PENDING') {
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ // Prepare the db object
+ const date = new Date().toISOString();
+ const dbObject = this._fromRawToDbObject(rawData, {
+ status,
+ rev: 0,
+ createdBy: by,
+ updatedBy: by,
+ createdAt: date,
+ updatedAt: date,
+ });
+
+ // Time to save the the db object
+ const dbResult = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`environment with id "${id}" already exists`, true);
+ },
+ );
+ return dbResult;
+ }
+
+ async ensureAmiAccess(_requestContext, accountId, type) {
+ // TODO - ami prefix should be coming from compute configuration object
+ const amiPrefixKey = {
+ 'ec2-linux': settingKeys.ec2LinuxAmiPrefix,
+ 'ec2-windows': settingKeys.ec2WindowsAmiPrefix,
+ 'emr': settingKeys.emrAmiPrefix,
+ }[type];
+
+ const amiPrefix = this.settings.get(amiPrefixKey);
+
+ const environmentAmiService = await this.service('environmentAmiService');
+
+ const { imageId } = await environmentAmiService.getLatest(amiPrefix);
+ await environmentAmiService.ensurePermissions({ imageId, accountId });
+
+ return imageId;
+ }
+
+ // This method is used to transform a newer environment request object (rawDataV2) to
+ // the existing environment raw request (rawDataV1). This is needed because a complete refactoring has
+ // not been done yet. The newer environment request object has a better modeling approach.
+ //
+ // This method returns rawDataV1 object that can be used inside the create and createExternal methods
+ // The shape of the rawDataV1 is { name, description, indexId, projectId, isExternal, instanceInfo }
+ // where instanceInfo has the shape { type, cidr, size, files, config }
+ async transformToRawDataV1(requestContext, rawDataV2, indexId) {
+ const [computePlatformService] = await this.service(['computePlatformService']);
+
+ // { platformId, configurationId, name, description, projectId, studyIds, params }
+ const { projectId, platformId, configurationId, studyIds } = rawDataV2;
+ const configurations = await computePlatformService.listConfigurations(requestContext, {
+ platformId,
+ includePrice: true,
+ });
+ const configuration = _.find(configurations, ['id', configurationId]);
+
+ const isMutableParam = name => _.has(configuration, ['params', 'mutable', name]);
+ const getParam = name => {
+ // First we see if the paramter is considered immutable, if so, we return its immutable value
+ // otherwise we return the one from the rawDataV2.params if the parameter name is declared
+ // in the configuration as mutable.
+ const immutable = _.get(configuration, ['params', 'immutable', name]);
+ if (!_.isUndefined(immutable)) return immutable;
+ if (!isMutableParam(name)) return undefined;
+ return _.get(rawDataV2, ['params', name]) || _.get(configuration, ['params', 'mutable', name]);
+ };
+
+ if (_.isUndefined(configuration)) {
+ throw this.boom.badRequest('You do not have permissions to create this configuration', true);
+ }
+
+ const instanceInfo = {};
+ const priceInfo = configuration.priceInfo;
+ const addIfDefined = (key, value) => {
+ if (_.isUndefined(value)) return;
+ instanceInfo[key] = value;
+ };
+
+ addIfDefined('type', configuration.type);
+ addIfDefined('cidr', getParam('cidr'));
+ addIfDefined('size', getParam('size'));
+ addIfDefined('files', studyIds); // Yes, rawDataV1 thinks that studyIds are files
+ addIfDefined('config', getParam('emr') || {});
+
+ if (priceInfo.type === 'spot') {
+ _.set(instanceInfo, 'config.spotBidPrice', priceInfo.spotBidPrice);
+ }
+
+ const rawDataV1 = {
+ platformId, // Even though v1 does not have this prop, we are adding it here
+ configurationId, // Even though v1 does not have this prop, we are adding it here
+ priceInfo, // Even though v1 does not have this props, we are adding it here
+ name: rawDataV2.name,
+ description: rawDataV2.description,
+ instanceInfo,
+ isExternal: _.get(requestContext, 'principal.isExternalUser', false),
+ };
+
+ if (projectId) rawDataV1.projectId = projectId;
+ if (indexId) rawDataV1.indexId = indexId;
+
+ return rawDataV1;
+ }
+
+ async createExternal(requestContext, rawDataV2) {
+ // Make sure the user has permissions to create the external environment
+ // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'create-external', conditions: [this._allowAuthorized] },
+ rawDataV2,
+ );
+
+ const [validationService, awsAccountsService, environmentMountService] = await this.service([
+ 'jsonSchemaValidationService',
+ 'awsAccountsService',
+ 'environmentMountService',
+ ]);
+ // Validate input
+ await validationService.ensureValid(rawDataV2, createSchema);
+
+ const { accountId, ...rawData } = rawDataV2;
+ const rawDataV1 = await this.transformToRawDataV1(requestContext, rawData);
+
+ const mountInformation = await environmentMountService.getCfnMountParameters(requestContext, rawDataV1);
+
+ Object.assign(rawDataV1.instanceInfo, mountInformation);
+
+ const savedEnvironment = await this.saveEnvironmentToDb(requestContext, rawDataV1, uuid(), 'PENDING');
+
+ await awsAccountsService.ensureExternalAccount(requestContext, { accountId });
+
+ const {
+ instanceInfo: { type },
+ } = savedEnvironment;
+ // Get AMI configuration where applicable
+ if (['ec2-linux', 'ec2-windows', 'emr'].includes(type)) {
+ const imageId = await this.ensureAmiAccess(requestContext, accountId, type);
+ savedEnvironment.amiImage = imageId;
+ }
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-external-environment', body: rawDataV2 });
+
+ return savedEnvironment;
+ }
+
+ async create(requestContext, rawDataV2) {
+ const [
+ validationService,
+ workflowTriggerService,
+ awsAccountsService,
+ indexesService,
+ projectService,
+ ] = await this.service([
+ 'jsonSchemaValidationService',
+ 'workflowTriggerService',
+ 'awsAccountsService',
+ 'indexesService',
+ 'projectService',
+ ]);
+
+ // Make sure the user has permissions to create the environment
+ // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first
+ await this.assertAuthorized(requestContext, { action: 'create', conditions: [this._allowAuthorized] }, rawDataV2);
+
+ // Validate input
+ await validationService.ensureValid(rawDataV2, createSchema);
+
+ // { platformId, configurationId, name, description, projectId, studyIds, params } => rawDataV2
+ const { projectId } = rawDataV2;
+
+ // Lets find the index id, by looking at the project and then load get the check the index id
+ const { indexId } = await projectService.mustFind(requestContext, { id: projectId });
+
+ // Time to convert the new rawDataV2 to the not so appealing rawDataV1
+ const rawDataV1 = await this.transformToRawDataV1(requestContext, rawDataV2, indexId);
+
+ // Get the aws account information
+ const { awsAccountId } = await indexesService.mustFind(requestContext, { id: indexId });
+ const {
+ roleArn: cfnExecutionRole,
+ externalId: roleExternalId,
+ accountId,
+ vpcId,
+ subnetId,
+ encryptionKeyArn,
+ } = await awsAccountsService.mustFind(requestContext, { id: awsAccountId });
+
+ // Check launch pre-requisites
+ if (!(cfnExecutionRole && roleExternalId && accountId && vpcId && subnetId && encryptionKeyArn)) {
+ const cause = this.getConfigError(cfnExecutionRole, roleExternalId, accountId, vpcId, subnetId, encryptionKeyArn);
+ throw this.boom.badRequest(`Index "${indexId}" has not been correctly configured: missing ${cause}.`, true);
+ }
+
+ // Generate environment ID
+ const id = uuid();
+
+ const { instanceInfo } = rawDataV1;
+ const { type, cidr } = instanceInfo;
+
+ // trigger the provision environment workflow
+ // TODO: remove CIDR default once its in the gui and backend
+ const input = {
+ environmentId: id,
+ requestContext,
+ cfnExecutionRole,
+ roleExternalId,
+ vpcId,
+ subnetId,
+ encryptionKeyArn,
+ type,
+ cidr,
+ };
+
+ // Get AMI configuration where applicable
+ if (['ec2-linux', 'ec2-windows', 'emr'].includes(type)) {
+ const imageId = await this.ensureAmiAccess(requestContext, accountId, type);
+ Object.assign(input, { amiImage: imageId });
+ }
+
+ // Time to save the the db object and trigger the workflow
+ const meta = { workflowId: workflowIds.create };
+
+ const dbResult = await this.saveEnvironmentToDb(requestContext, rawDataV1, id);
+ await workflowTriggerService.triggerWorkflow(requestContext, meta, input);
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-environment', body: rawDataV2 });
+
+ return dbResult;
+ }
+
+ getConfigError(cfnExecutionRole, roleExternalId, accountId, vpcId, subnetId, encryptionKeyArn) {
+ const causes = [];
+
+ if (!cfnExecutionRole) causes.push('IAM role');
+ if (!roleExternalId) causes.push('External ID');
+ if (!accountId) causes.push('AWS account ID');
+ if (!vpcId) causes.push('VPC ID');
+ if (!subnetId) causes.push('VPC Subnet ID');
+ if (!encryptionKeyArn) causes.push('Encryption Key ARN');
+
+ if (causes.length > 1) {
+ const last = causes.pop();
+ return `${causes.join(', ')} and ${last}`;
+ }
+ if (causes.length > 0) {
+ return causes[0];
+ }
+
+ return undefined;
+ }
+
+ async update(requestContext, environment) {
+ const jsonSchemaValidationService = await this.service('jsonSchemaValidationService');
+ await jsonSchemaValidationService.ensureValid(environment, updateSchema);
+
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+
+ // Prepare the db object
+ // const dbObject = _.omit(this._fromRawToDbObject(dataObject, { updatedBy: by }), ['rev']);
+ const existingEnvironment = await this.mustFind(requestContext, { id: environment.id });
+
+ // Make sure the user has permissions to update the environment
+ // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'update', conditions: [this._allowAuthorized] },
+ existingEnvironment,
+ );
+
+ if (existingEnvironment.isExternal) {
+ // If the environment has finished and there are s3Prefixes to mount update the policies
+ if (
+ environment.status === 'COMPLETED' &&
+ existingEnvironment.status === 'PENDING' &&
+ existingEnvironment.instanceInfo.s3Prefixes.length > 0
+ ) {
+ const environmentMountService = await this.service('environmentMountService');
+
+ await environmentMountService.addRoleArnToLocalResourcePolicies(
+ environment.instanceInfo.WorkspaceInstanceRoleArn,
+ existingEnvironment.instanceInfo.s3Prefixes,
+ );
+ }
+ }
+
+ const mergedInstanceInfo = _.assign({}, existingEnvironment.instanceInfo, environment.instanceInfo);
+ const updatedEnvironment = _.omit(
+ _.assign({}, existingEnvironment, environment, { instanceInfo: mergedInstanceInfo }),
+ ['id'],
+ );
+ const dbObject = this._fromRawToDbObject(updatedEnvironment, {
+ updatedBy: by,
+ updatedAt: new Date().toISOString(),
+ });
+
+ // validate sharedWithUsers
+ const { sharedWithUsers } = environment;
+ try {
+ if (Array.isArray(sharedWithUsers)) {
+ const userService = await this.service('userService');
+ await userService.validateUsers(sharedWithUsers);
+ }
+ } catch (error) {
+ if (error.safe) {
+ throw error;
+ }
+ const errorMessage = 'error updating environment - shared with users';
+ // eslint-disable-next-line no-console
+ console.error(errorMessage, error);
+ throw this.boom.internalError(errorMessage, true);
+ }
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id: environment.id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.notFound(`environment with id "${environment.id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-environment', body: environment });
+
+ return result;
+ }
+
+ // use this authorization check function for safe changes that can be done by
+ // project admins and shared environment users
+ // this should not be used for destructive actions such as terminate/delete
+ // or other dangerous updates
+ async isSafeMutationAuthorized(requestContext, environment) {
+ // check if user is admin
+ const isAdmin = requestContext.principal.isAdmin;
+ if (isAdmin) {
+ return true;
+ }
+
+ // check if user is the environment owner
+ const { username: ownerUsername, ns: ownerNs } = environment.createdBy;
+ const { username: reqUsername, ns: reqNs } = requestContext.principalIdentifier;
+ const isOwner = reqUsername === ownerUsername && reqNs === ownerNs;
+ if (isOwner) {
+ return true;
+ }
+
+ // check if environment has been shared with user
+ const { sharedWithUsers = [] } = environment;
+ const isShared = Array.isArray(sharedWithUsers)
+ ? sharedWithUsers.some(u => u.username === reqUsername && u.ns === reqNs)
+ : false;
+ if (isShared) {
+ return true;
+ }
+
+ // check if the project associated with the environment includes the user as a project admin
+ let isProjectAdmin = false;
+ try {
+ isProjectAdmin = await this.isEnvironmentProjectAdmin(requestContext, environment);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('error checking isProjectAdmin in mutation authorization: ', error);
+ }
+ if (isProjectAdmin) {
+ return true;
+ }
+
+ return false;
+ }
+
+ async delete(requestContext, { id }) {
+ const existingEnvironment = await this.mustFind(requestContext, { id });
+
+ // Make sure the user has permissions to delete the environment
+ // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'delete', conditions: [this._allowAuthorized] },
+ existingEnvironment,
+ );
+
+ if (existingEnvironment.status === 'TERMINATING' || existingEnvironment.status === 'TERMINATED') {
+ this.log.info(`environment with id "${existingEnvironment.id}" is already terminating`);
+ return;
+ }
+
+ if (existingEnvironment.isExternal) {
+ // If studies were mounted, update the resoure policies to remove access
+ if (
+ existingEnvironment.instanceInfo.WorkspaceInstanceRoleArn &&
+ existingEnvironment.instanceInfo.s3Prefixes.length > 0
+ ) {
+ const environmentMountService = await this.service('environmentMountService');
+
+ await environmentMountService.removeRoleArnFromLocalResourcePolicies(
+ existingEnvironment.instanceInfo.WorkspaceInstanceRoleArn,
+ existingEnvironment.instanceInfo.s3Prefixes,
+ );
+ }
+
+ existingEnvironment.status = 'TERMINATED';
+ await this.update(requestContext, _.pick(existingEnvironment, ['id', 'status']));
+ return;
+ }
+
+ const [awsAccountsService, indexesService] = await this.service(['awsAccountsService', 'indexesService']);
+ const { indexId } = existingEnvironment;
+ const { awsAccountId } = await indexesService.mustFind(requestContext, { id: indexId });
+ const { roleArn: cfnExecutionRole, externalId: roleExternalId } = await awsAccountsService.mustFind(
+ requestContext,
+ { id: awsAccountId },
+ );
+
+ // trigger the delete environment workflow
+ const input = {
+ environmentId: existingEnvironment.id,
+ requestContext,
+ cfnExecutionRole,
+ roleExternalId,
+ };
+
+ const meta = { workflowId: workflowIds.delete };
+ const workflowTriggerService = await this.service('workflowTriggerService');
+ await workflowTriggerService.triggerWorkflow(requestContext, meta, input);
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-environment', body: { id } });
+ }
+
+ async credsForAccountWithEnvironment(requestContext, { id }) {
+ const [aws, awsAccountsService, indexesService] = await this.service([
+ 'aws',
+ 'awsAccountsService',
+ 'indexesService',
+ ]);
+
+ // The following will succeed only if the user has permissions to access
+ // the specified environment
+ const { status, indexId } = await this.mustFind(requestContext, {
+ id,
+ fields: ['status', 'indexId', 'createdBy'],
+ });
+
+ if (status !== 'COMPLETED') {
+ this.boom.badRequest(`environment with id "${id}" is not ready`, true);
+ }
+
+ const { awsAccountId } = await indexesService.mustFind(requestContext, { id: indexId });
+ const { roleArn: RoleArn, externalId: ExternalId } = await awsAccountsService.mustFind(requestContext, {
+ id: awsAccountId,
+ });
+
+ const sts = new aws.sdk.STS();
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}`,
+ ExternalId,
+ })
+ .promise();
+
+ return { accessKeyId, secretAccessKey, sessionToken };
+ }
+
+ async getWindowsPasswordData(requestContext, { id }) {
+ // Get environment and validate usage
+ // The following will succeed only if the user has permissions to access the specified environment
+ const environment = await this.mustFind(requestContext, { id });
+ if (environment.instanceInfo.type !== 'ec2-windows') {
+ throw this.boom.badRequest('Passwords can only be retrieved for EC2 Windows environments', true);
+ }
+
+ // Retrieve password data
+ const aws = await this.service('aws');
+ const ec2Client = new aws.sdk.EC2(await this.credsForAccountWithEnvironment(requestContext, { id }));
+ const { PasswordData } = await ec2Client
+ .getPasswordData({ InstanceId: environment.instanceInfo.Ec2WorkspaceInstanceId })
+ .promise();
+
+ return { passwordData: PasswordData };
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'environment-authz', action, conditions },
+ ...args,
+ );
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = EnvironmentService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-spot-price-history-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-spot-price-history-service.js
new file mode 100644
index 0000000000..464af8fbaf
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-spot-price-history-service.js
@@ -0,0 +1,82 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+const { allowIfActive } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+const { allowIfHasRole } = require('../user/helpers/user-authz-utils');
+
+class EnvironmentSpotPriceHistoryService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws', 'authorizationService']);
+ }
+
+ async getPriceHistory(requestContext, type) {
+ // ensure that the caller has permissions to get price history
+ // Perform default condition checks to make sure the user is active and has allowed roles
+ const allowIfHasCorrectRoles = (reqContext, { action }) =>
+ allowIfHasRole(reqContext, { action, resource: 'environment-spot-price-history' }, [
+ 'admin',
+ 'researcher',
+ 'external-researcher',
+ ]);
+
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'read', conditions: [allowIfActive, allowIfHasCorrectRoles] },
+ type,
+ );
+
+ const aws = await this.service('aws');
+
+ const ec2 = new aws.sdk.EC2();
+
+ const { SpotPriceHistory } = await runAndCatch(
+ async () =>
+ ec2
+ .describeSpotPriceHistory({
+ InstanceTypes: [type],
+ ProductDescriptions: ['Linux/UNIX'],
+ StartTime: new Date(),
+ })
+ .promise(),
+ async () => {
+ throw this.boom.badRequest(`Price history not available for "${type}" instance type.`);
+ },
+ );
+
+ return SpotPriceHistory.map(({ AvailabilityZone, SpotPrice }) => ({
+ availabilityZone: AvailabilityZone,
+ spotPrice: parseFloat(SpotPrice),
+ }));
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'environment-spot-price-history-authz', action, conditions },
+ ...args,
+ );
+ }
+}
+
+module.exports = EnvironmentSpotPriceHistoryService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/external-cfn-template/external-cfn-template-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/external-cfn-template/external-cfn-template-service.js
new file mode 100644
index 0000000000..b76a1542b9
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/external-cfn-template/external-cfn-template-service.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+class ExternalCfnTemplateService extends Service {
+ constructor() {
+ super();
+ this.dependency(['s3Service']);
+ }
+
+ async init() {
+ await super.init();
+ }
+
+ async getSignS3Url(key) {
+ const [s3Service] = await this.service(['s3Service']);
+ const bucket = this.settings.get('externalCfnTemplatesBucketName');
+ const request = { files: [{ key, bucket }] };
+ const urls = await s3Service.sign(request);
+ return urls[0].signedUrl;
+ }
+
+ async mustGetSignS3Url(key) {
+ const result = await this.getSignS3Url(key);
+ if (!result) throw this.boom.notFound(`template with key "${key}" does not exist`, true);
+ return result;
+ }
+}
+
+module.exports = ExternalCfnTemplateService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/helpers/aws-tags.js b/addons/addon-base-raas/packages/base-raas-services/lib/helpers/aws-tags.js
new file mode 100644
index 0000000000..2a4de92ea3
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/helpers/aws-tags.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const xml = require('xml');
+
+/**
+ * Generates an XML document containing AWS tags.
+ *
+ * @example
+ *
+ * ```javascript
+ * buildTaggingXml(
+ * {
+ * UploadedBy: "me,theuser",
+ * Comment: "]>&xxe;",
+ * },
+ * true, // pretty print
+ * );
+ * ```
+ *
+ * @param {Object=} tags
+ * @param {boolean=} pretty
+ * @returns {string} an xml tagging configuration document
+ */
+const buildTaggingXml = (tags = {}, pretty = false) =>
+ xml(
+ {
+ Tagging: [
+ {
+ TagSet: Object.entries(tags).map(([Key, Value]) => ({ Tag: [{ Key }, { Value }] })),
+ },
+ ],
+ },
+ { indent: pretty ? ' ' : undefined },
+ );
+
+module.exports = {
+ buildTaggingXml,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/helpers/is-role.js b/addons/addon-base-raas/packages/base-raas-services/lib/helpers/is-role.js
new file mode 100644
index 0000000000..9aebfb10e5
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/helpers/is-role.js
@@ -0,0 +1,28 @@
+const _ = require('lodash');
+
+function isRole(requestContext, roleName) {
+ return _.get(requestContext, 'principal.userRole') === roleName;
+}
+
+function isExternalGuest(requestContext) {
+ return isRole(requestContext, 'guest');
+}
+
+function isInternalGuest(requestContext) {
+ return isRole(requestContext, 'internal-guest');
+}
+
+function isExternalResearcher(requestContext) {
+ return isRole(requestContext, 'external-researcher');
+}
+
+function isInternalResearcher(requestContext) {
+ return isRole(requestContext, 'researcher');
+}
+
+module.exports = {
+ isInternalResearcher,
+ isExternalResearcher,
+ isInternalGuest,
+ isExternalGuest,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/indexes/indexes-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/indexes/indexes-service.js
new file mode 100644
index 0000000000..8da65e56f5
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/indexes/indexes-service.js
@@ -0,0 +1,245 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+const { allowIfActive, allowIfAdmin } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+
+const { isExternalGuest, isExternalResearcher, isInternalGuest } = require('../helpers/is-role');
+const createSchema = require('../schema/create-indexes');
+const updateSchema = require('../schema/update-indexes');
+
+const settingKeys = {
+ tableName: 'dbTableIndexes',
+};
+
+class IndexesService extends Service {
+ constructor() {
+ super();
+ this.dependency(['jsonSchemaValidationService', 'authorizationService', 'dbService', 'auditWriterService']);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+ }
+
+ async find(requestContext, { id, fields = [] }) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return undefined;
+
+ // Future task: add further checks
+
+ const result = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { id, fields = [] }) {
+ const result = await this.find(requestContext, { id, fields });
+ if (!result) throw this.boom.notFound(`indexes with id "${id}" does not exist`, true);
+ return result;
+ }
+
+ async create(requestContext, rawData) {
+ // ensure that the caller has permissions to create the index
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'create', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, createSchema);
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const id = rawData.id;
+
+ // Prepare the db object
+ const dbObject = this._fromRawToDbObject(rawData, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`indexes with id "${id}" already exists`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-index', body: result });
+
+ return result;
+ }
+
+ async update(requestContext, rawData) {
+ // ensure that the caller has permissions to update the index
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'update', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, updateSchema);
+
+ // For now, we assume that 'updatedBy' is always a user and not a group
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { id, rev } = rawData;
+
+ // Prepare the db object
+ const dbObject = _.omit(this._fromRawToDbObject(rawData, { updatedBy: by }), ['rev']);
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .rev(rev)
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ // There are two scenarios here:
+ // 1 - The indexes does not exist
+ // 2 - The "rev" does not match
+ // We will display the appropriate error message accordingly
+ const existing = await this.find(requestContext, { id, fields: ['id', 'updatedBy'] });
+ if (existing) {
+ throw this.boom.badRequest(
+ `indexes information changed by "${
+ (existing.updatedBy || {}).username
+ }" just before your request is processed, please try again`,
+ true,
+ );
+ }
+ throw this.boom.notFound(`indexes with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-index', body: result });
+
+ return result;
+ }
+
+ async delete(requestContext, { id }) {
+ // ensure that the caller has permissions to update the index
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'delete', conditions: [allowIfActive, allowIfAdmin] },
+ { id },
+ );
+
+ // Lets now remove the item from the database
+ const result = await runAndCatch(
+ async () => {
+ return this._deleter()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .delete();
+ },
+ async () => {
+ throw this.boom.notFound(`indexes with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-index', body: { id } });
+
+ return result;
+ }
+
+ async list(requestContext, { fields = [] } = {}) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return [];
+
+ // Future task: add further checks
+
+ // Remember doing a scan is not a good idea if you billions of rows
+ return this._scanner()
+ .limit(1000)
+ .projection(fields)
+ .scan();
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'index-authz', action, conditions },
+ ...args,
+ );
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = IndexesService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/plugins/authorization-plugin.js b/addons/addon-base-raas/packages/base-raas-services/lib/plugins/authorization-plugin.js
new file mode 100644
index 0000000000..d0b4032c56
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/plugins/authorization-plugin.js
@@ -0,0 +1,18 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const authorizationPluginFactory = require('@aws-ee/base-services/lib/authorization/authorization-plugin-factory');
+
+module.exports = authorizationPluginFactory('addon/raas/authorizers');
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/project/project-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/project/project-service.js
new file mode 100644
index 0000000000..8e3535574b
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/project/project-service.js
@@ -0,0 +1,245 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+const { allowIfActive, allowIfAdmin } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+
+const { isExternalGuest, isExternalResearcher, isInternalGuest } = require('../helpers/is-role');
+const createSchema = require('../schema/create-project');
+const updateSchema = require('../schema/update-project');
+
+const settingKeys = {
+ tableName: 'dbTableProjects',
+};
+
+class ProjectService extends Service {
+ constructor() {
+ super();
+ this.dependency(['jsonSchemaValidationService', 'authorizationService', 'dbService', 'auditWriterService']);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+ }
+
+ async find(requestContext, { id, fields = [] }) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return undefined;
+
+ // Future task: return undefined if the user is not associated with this project, unless they are admin
+
+ const result = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { id, fields = [] }) {
+ const result = await this.find(requestContext, { id, fields });
+ if (!result) throw this.boom.notFound(`project with id "${id}" does not exist`, true);
+ return result;
+ }
+
+ async create(requestContext, rawData) {
+ // ensure that the caller has permissions to create the index
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'create', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, createSchema);
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const id = rawData.id;
+
+ // Prepare the db object
+ const dbObject = this._fromRawToDbObject(rawData, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`project with id "${id}" already exists`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-project', body: result });
+
+ return result;
+ }
+
+ async update(requestContext, rawData) {
+ // ensure that the caller has permissions to update the index
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'update', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, updateSchema);
+
+ // For now, we assume that 'updatedBy' is always a user and not a group
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { id, rev } = rawData;
+
+ // Prepare the db object
+ const dbObject = _.omit(this._fromRawToDbObject(rawData, { updatedBy: by }), ['rev']);
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .rev(rev)
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ // There are two scenarios here:
+ // 1 - The project does not exist
+ // 2 - The "rev" does not match
+ // We will display the appropriate error message accordingly
+ const existing = await this.find(requestContext, { id, fields: ['id', 'updatedBy'] });
+ if (existing) {
+ throw this.boom.badRequest(
+ `project information changed by "${
+ (existing.updatedBy || {}).username
+ }" just before your request is processed, please try again`,
+ true,
+ );
+ }
+ throw this.boom.notFound(`project with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-project', body: result });
+
+ return result;
+ }
+
+ async delete(requestContext, { id }) {
+ // ensure that the caller has permissions to delete the index
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'delete', conditions: [allowIfActive, allowIfAdmin] },
+ { id },
+ );
+
+ // Lets now remove the item from the database
+ const result = await runAndCatch(
+ async () => {
+ return this._deleter()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .delete();
+ },
+ async () => {
+ throw this.boom.notFound(`project with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-project', body: { id } });
+
+ return result;
+ }
+
+ async list(requestContext, { fields = [] } = {}) {
+ const restrict =
+ isExternalGuest(requestContext) || isExternalResearcher(requestContext) || isInternalGuest(requestContext);
+
+ if (restrict) return [];
+
+ // Future task: only return projects that the user has been associated with unless the user is an admin
+
+ // Remember doing a scan is not a good idea if you billions of rows
+ return this._scanner()
+ .limit(1000)
+ .projection(fields)
+ .scan();
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'project-authz', action, conditions },
+ ...args,
+ );
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = ProjectService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-account.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-account.json
new file mode 100644
index 0000000000..4b35307695
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-account.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "accountName": {
+ "type": "string"
+ },
+ "accountEmail": {
+ "type": "string"
+ },
+ "masterRoleArn": {
+ "type": "string"
+ },
+ "externalId": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": ["accountName", "accountEmail", "masterRoleArn", "externalId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json
new file mode 100644
index 0000000000..ba3917b31d
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "accountId": {
+ "type": "string",
+ "pattern": "^[0-9]{12}$"
+ },
+ "roleArn": {
+ "type": "string",
+ "minLength": 10
+ },
+ "externalId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "vpcId": {
+ "type": "string",
+ "pattern": "^vpc-[a-f0-9]{8,17}$"
+ },
+ "subnetId": {
+ "type": "string",
+ "pattern": "^subnet-[a-f0-9]{8,17}$"
+ },
+ "encryptionKeyArn": {
+ "type": "string",
+ "pattern": "^arn:aws:kms:.*$"
+ }
+ },
+ "required": ["name", "roleArn", "externalId", "accountId", "vpcId", "subnetId", "encryptionKeyArn"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-cost-api-cache.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-cost-api-cache.json
new file mode 100644
index 0000000000..75fa63ca55
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-cost-api-cache.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "indexId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "query": {
+ "type": "string"
+ },
+ "result": {
+ "type": "string"
+ }
+ },
+ "required": ["indexId", "query", "result"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-environment-keypair.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-environment-keypair.json
new file mode 100644
index 0000000000..1341463c52
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-environment-keypair.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "environmentId": {
+ "type": "string"
+ }
+ },
+ "required": ["name", "environmentId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-environment.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-environment.json
new file mode 100644
index 0000000000..ecfb64d9d9
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-environment.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 128,
+ "minLength": 3,
+ "pattern": "^[A-Za-z][A-Za-z0-9-]+$"
+ },
+ "platformId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "configurationId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 300
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "accountId": {
+ "type": "string",
+ "minLength": 12
+ },
+ "projectId": {
+ "type": "string"
+ },
+ "params": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "studyIds": {
+ "type": "array",
+ "items": [
+ {
+ "type": "string",
+ "minLength": 1
+ }
+ ]
+ },
+ "sharedWithUsers": {
+ "type": "array",
+ "items": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "username": {
+ "type": "string",
+ "minLength": 3
+ },
+ "ns": {
+ "type": "string",
+ "minLength": 3
+ }
+ }
+ }
+ ],
+ "default": []
+ }
+ },
+ "required": ["name", "platformId", "configurationId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-indexes.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-indexes.json
new file mode 100644
index 0000000000..cdc6348648
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-indexes.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_]+$"
+ },
+ "awsAccountId": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "awsAccountId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-project.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-project.json
new file mode 100644
index 0000000000..0f3207af1e
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-project.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_]+$"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 3000
+ },
+ "indexId": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "indexId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-study.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-study.json
new file mode 100644
index 0000000000..20f1880ffe
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-study.json
@@ -0,0 +1,53 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "category": {
+ "type": "string",
+ "enum": ["My Studies", "Organization", "Open Data"]
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 8192
+ },
+ "projectId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_]+$"
+ },
+ "uploadLocationEnabled": {
+ "type": "boolean"
+ },
+ "sha": {
+ "type": "string",
+ "maxLength": 64
+ },
+ "resources": {
+ "type": "array",
+ "items": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "arn": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "required": ["id", "category"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-user-roles.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-user-roles.json
new file mode 100644
index 0000000000..e394f9ee89
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-user-roles.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "userType": {
+ "type": "string",
+ "enum": [
+ "INTERNAL", "EXTERNAL"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "userType"
+ ]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-user.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-user.json
new file mode 100644
index 0000000000..baaa7aa011
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-user.json
@@ -0,0 +1,77 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "username": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 300,
+ "pattern": "^[A-Za-z0-9-_.]+$|^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"
+ },
+ "usernameInIdp": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 300,
+ "pattern": "^[A-Za-z0-9-_.]+$|^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"
+ },
+ "password": {
+ "type": "string"
+ },
+ "authenticationProviderId": {
+ "type": "string"
+ },
+ "identityProviderName": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"
+ },
+ "firstName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500,
+ "pattern": "^[A-Za-z0-9 .-]+$"
+ },
+ "lastName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500,
+ "pattern": "^[A-Za-z0-9 .-]+$"
+ },
+ "userType": {
+ "type": "string",
+ "enum": ["root"]
+ },
+ "isSamlAuthenticatedUser": {
+ "type": "boolean"
+ },
+ "isAdmin": {
+ "type": "boolean"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["active", "inactive", "pending"]
+ },
+ "rev": {
+ "type": "number"
+ },
+ "userRole": {
+ "type": "string"
+ },
+ "projectId": {
+ "type": "array"
+ },
+ "isExternalUser": {
+ "type": "boolean"
+ },
+ "encryptedCreds": {
+ "type": "string"
+ },
+ "applyReason": {
+ "type": "string"
+ }
+ },
+ "required": ["username"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/ensure-external-aws-accounts.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/ensure-external-aws-accounts.json
new file mode 100644
index 0000000000..ccc012dae3
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/ensure-external-aws-accounts.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "accountId": {
+ "type": "string",
+ "pattern": "^[0-9]{12}$"
+ }
+ },
+ "required": ["accountId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-account.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-account.json
new file mode 100644
index 0000000000..febfb31c9f
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-account.json
@@ -0,0 +1,52 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_]+$"
+ },
+ "stackId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "cfnInfo": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "vpcId": {
+ "type": "string",
+ "pattern": "^vpc-[a-f0-9]{8,17}$"
+ },
+ "subnetId": {
+ "type": "string",
+ "pattern": "^subnet-[a-f0-9]{8,17}$"
+ },
+ "crossAccountExecutionRoleArn": {
+ "type": "string",
+ "pattern": "^arn:aws:iam::.*$"
+ },
+ "stackId": {
+ "type": "string"
+ },
+ "encryptionKeyArn": {
+ "type": "string",
+ "pattern": "^arn:aws:kms:.*$"
+ }
+ }
+ },
+ "status": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 2048
+ }
+ },
+ "required": ["id"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json
new file mode 100644
index 0000000000..9f8266dad1
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "rev": {
+ "type": "number"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "accountId": {
+ "type": "string",
+ "pattern": "^[0-9]{12}$"
+ },
+ "roleArn": {
+ "type": "string",
+ "minLength": 10
+ },
+ "externalId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "vpcId": {
+ "type": "string",
+ "pattern": "^vpc-[a-f0-9]{8,17}$"
+ },
+ "subnetId": {
+ "type": "string",
+ "pattern": "^subnet-[a-f0-9]{8,17}$"
+ }
+ },
+ "required": ["id", "rev", "identityPoolId", "userRoleAccess", "roleArn", "externalId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-cost-api-cache.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-cost-api-cache.json
new file mode 100644
index 0000000000..9c68a907a1
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-cost-api-cache.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "indexId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "query": {
+ "type": "string"
+ },
+ "result": {
+ "type": "string"
+ }
+ },
+ "required": ["indexId", "query", "result"]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-environment.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-environment.json
new file mode 100644
index 0000000000..abc30d30c2
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-environment.json
@@ -0,0 +1,50 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "status": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "stackId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "instanceInfo": {
+ "type": "object"
+ },
+ "sharedWithUsers": {
+ "type": "array",
+ "items": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "username": {
+ "type": "string",
+ "minLength": 3
+ },
+ "ns": {
+ "type": "string",
+ "minLength": 3
+ }
+ }
+ }
+ ],
+ "default": []
+ },
+ "error": {
+ "type": "string",
+ "maxLength": 2048
+ }
+ },
+ "required": ["id"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-indexes.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-indexes.json
new file mode 100644
index 0000000000..3e02c74c38
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-indexes.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "rev": {
+ "type": "number"
+ },
+ "awsAccountId": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "rev"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-project.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-project.json
new file mode 100644
index 0000000000..724053cef3
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-project.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "rev": {
+ "type": "number"
+ },
+ "indexId": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 3000
+ }
+ },
+ "required": ["id", "rev", "indexId"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-study-permissions.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-study-permissions.json
new file mode 100644
index 0000000000..4c844e1aff
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-study-permissions.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "definitions": {
+ "principalIdentifier": {
+ "type": "object",
+ "properties": {
+ "ns": { "type": "string" },
+ "username": { "type": "string" }
+ },
+ "required": ["ns", "username"]
+ },
+ "permissionLevel": {
+ "type": "string",
+ "enum": ["admin", "readonly"]
+ },
+ "userEntry": {
+ "type": "object",
+ "properties": {
+ "principalIdentifier": { "$ref": "#/definitions/principalIdentifier" },
+ "permissionLevel": { "$ref": "#/definitions/permissionLevel" }
+ },
+ "required": ["principalIdentifier", "permissionLevel"]
+ }
+ },
+ "properties": {
+ "usersToAdd": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/userEntry" }
+ },
+ "usersToRemove": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/userEntry" }
+ }
+ }
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-study.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-study.json
new file mode 100644
index 0000000000..a8f6c29c95
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-study.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "rev": {
+ "type": "number"
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 8192
+ },
+ "sha": {
+ "type": "string",
+ "maxLength": 64
+ },
+ "resources": {
+ "type": "array",
+ "items": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "arn": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "required": ["id", "rev"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-user-roles.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-user-roles.json
new file mode 100644
index 0000000000..fd0e8de9c4
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-user-roles.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[A-Za-z0-9-_ ]+$"
+ },
+ "rev": {
+ "type": "number"
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 2048
+ },
+ "userType": {
+ "type": "string",
+ "enum": [
+ "INTERNAL", "EXTERNAL"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "rev",
+ "userType"
+ ]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-user.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-user.json
new file mode 100644
index 0000000000..9e23450eeb
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-user.json
@@ -0,0 +1,70 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "username": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 300,
+ "pattern": "^[A-Za-z0-9-_.]+$|^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"
+ },
+ "usernameInIdp": {
+ "type": "string",
+ "minLength": 3,
+ "maxLength": 300,
+ "pattern": "^[A-Za-z0-9-_.]+$|^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"
+ },
+ "authenticationProviderId": {
+ "type": "string"
+ },
+ "identityProviderName": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"
+ },
+ "firstName": {
+ "type": "string",
+ "maxLength": 500
+ },
+ "lastName": {
+ "type": "string",
+ "maxLength": 500
+ },
+ "userType": {
+ "type": "string",
+ "enum": ["root"]
+ },
+ "isSamlAuthenticatedUser": {
+ "type": "boolean"
+ },
+ "isAdmin": {
+ "type": "boolean"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["active", "inactive", "pending"]
+ },
+ "rev": {
+ "type": "number"
+ },
+ "userRole": {
+ "type": "string"
+ },
+ "projectId": {
+ "type": "array"
+ },
+ "isExternalUser": {
+ "type": "boolean"
+ },
+ "encryptedCreds": {
+ "type": "string"
+ },
+ "applyReason": {
+ "type": "string"
+ }
+ },
+ "required": ["username", "rev"]
+}
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/study/study-permission-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/study/study-permission-service.js
new file mode 100644
index 0000000000..5d4aad2a37
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/study/study-permission-service.js
@@ -0,0 +1,340 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const updateSchema = require('../schema/update-study-permissions');
+
+const settingKeys = {
+ tableName: 'dbTableStudyPermissions',
+};
+const keyPrefixes = {
+ study: 'Study:',
+ user: 'User:',
+};
+
+class StudyPermissionService extends Service {
+ constructor() {
+ super();
+ this.dependency(['dbService', 'jsonSchemaValidationService', 'lockService', 'userService']);
+ }
+
+ async init() {
+ // Get services
+ this.jsonSchemaValidationService = await this.service('jsonSchemaValidationService');
+ this.lockService = await this.service('lockService');
+ this.userService = await this.service('jsonSchemaValidationService');
+
+ // Setup DB helpers
+ const dbService = await this.service('dbService');
+ this.tableName = this.settings.get(settingKeys.tableName);
+ this._getter = () => dbService.helper.getter().table(this.tableName);
+ this._updater = () => dbService.helper.updater().table(this.tableName);
+ this._query = () => dbService.helper.query().table(this.tableName);
+ this._deleter = () => dbService.helper.deleter().table(this.tableName);
+ this._scanner = () => dbService.helper.scanner().table(this.tableName);
+ }
+
+ /**
+ * Public Methods
+ */
+ async findByStudy(requestContext, studyId, fields = []) {
+ const id = StudyPermissionService.getQualifiedKey(studyId, 'study');
+ const record = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return StudyPermissionService.sanitizeStudyRecord(record);
+ }
+
+ async findByUser(requestContext, username, fields = []) {
+ const id = StudyPermissionService.getQualifiedKey(username, 'user');
+ return this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+ }
+
+ async create(requestContext, studyId) {
+ let result;
+
+ // Build study record
+ const creator = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const studyRecord = {
+ id: StudyPermissionService.getQualifiedKey(studyId, 'study'),
+ recordType: 'study',
+ adminUsers: [creator],
+ readonlyUsers: [],
+ createdBy: creator,
+ };
+
+ // Create DB records
+ await Promise.all([
+ // Create/Update user record
+ this.upsertUserRecord(requestContext, {
+ studyId,
+ principalIdentifier: creator,
+ addOrRemove: 'add',
+ permissionLevel: 'admin',
+ }),
+
+ // Create study record
+ runAndCatch(
+ async () => {
+ result = await this._updater()
+ .condition('attribute_not_exists(id)') // Error if already exists
+ .key({ id: studyRecord.id })
+ .item(studyRecord)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`Permission record for study with id "${studyRecord.id}" already exists`, true);
+ },
+ ),
+ ]);
+
+ // Return study record
+ return StudyPermissionService.sanitizeStudyRecord(result);
+ }
+
+ async update(requestContext, studyId, updateRequest) {
+ let result;
+
+ // Validate input
+ await this.jsonSchemaValidationService.ensureValid(updateRequest, updateSchema);
+
+ // Create DB lock before pulling existing record
+ const lockKey = this.getLockKey(studyId, 'study');
+
+ await this.lockService.tryWriteLockAndRun({ id: lockKey }, async () => {
+ // Get existing record
+ const studyRecord = await this.findByStudy(requestContext, studyId);
+
+ // Build updated study record
+ const updater = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ studyRecord.updatedBy = updater;
+
+ const filterByLevel = filterLevel => userEntry => userEntry.permissionLevel === filterLevel;
+ ['admin', 'readonly'].forEach(permissionLevel => {
+ studyRecord[`${permissionLevel}Users`] = _.pullAllWith(
+ // Existing/Added users
+ _.unionWith(
+ studyRecord[`${permissionLevel}Users`],
+ updateRequest.usersToAdd
+ .filter(filterByLevel(permissionLevel))
+ .map(userEntry => userEntry.principalIdentifier),
+ _.isEqual,
+ ),
+
+ // Removed users
+ updateRequest.usersToRemove
+ .filter(filterByLevel(permissionLevel))
+ .map(userEntry => userEntry.principalIdentifier),
+ _.isEqual,
+ );
+ });
+
+ // Halt the update if all admins have been removed
+ if (studyRecord.adminUsers.length < 1) {
+ throw this.boom.badRequest('At least one Admin must be assigned to the study', true);
+ }
+
+ // Update DB records
+ result = await Promise.all([
+ // Update study record
+ this._updater()
+ .key({ id: StudyPermissionService.getQualifiedKey(studyRecord.id, 'study') })
+ .item(studyRecord)
+ .update(),
+
+ // Update user records
+ ...updateRequest.usersToAdd.map(userEntry =>
+ this.upsertUserRecord(requestContext, {
+ studyId,
+ principalIdentifier: userEntry.principalIdentifier,
+ addOrRemove: 'add',
+ permissionLevel: userEntry.permissionLevel,
+ }),
+ ),
+ ...updateRequest.usersToRemove.map(userEntry =>
+ this.upsertUserRecord(requestContext, {
+ studyId,
+ principalIdentifier: userEntry.principalIdentifier,
+ addOrRemove: 'remove',
+ permissionLevel: userEntry.permissionLevel,
+ }),
+ ),
+ ]);
+ });
+
+ // Return study record
+ return StudyPermissionService.sanitizeStudyRecord(result[0]);
+ }
+
+ async delete(requestContext, studyId) {
+ let result;
+
+ // Create DB lock before pulling existing record
+ const lockKey = this.getLockKey(studyId, 'study');
+ await this.lockService.tryWriteLockAndRun({ id: lockKey }, async () => {
+ // Get record
+ const studyRecord = await this.findByStudy(requestContext, studyId);
+
+ // Delete
+ result = await Promise.all([
+ // Delete study record
+ this._deleter()
+ .key({ id: StudyPermissionService.getQualifiedKey(studyId, 'study') })
+ .delete(),
+
+ // Remove study from user records
+ studyRecord.adminUsers.map(async principalIdentifier =>
+ this.upsertUserRecord(requestContext, {
+ studyId,
+ principalIdentifier,
+ addOrRemove: 'remove',
+ permissionLevel: 'admin',
+ }),
+ ),
+
+ studyRecord.readonlyUsers.map(async principalIdentifier =>
+ this.upsertUserRecord(requestContext, {
+ studyId,
+ principalIdentifier,
+ addOrRemove: 'remove',
+ permissionLevel: 'readonly',
+ }),
+ ),
+ ]);
+ });
+
+ // Return study record
+ return StudyPermissionService.sanitizeStudyRecord(result[0]);
+ }
+
+ async getRequestorPermissions(requestContext) {
+ return this.findByUser(requestContext, requestContext.principalIdentifier.username, [
+ 'adminAccess',
+ 'readonlyAccess',
+ ]);
+ }
+
+ async verifyRequestorAccess(requestContext, studyId, action) {
+ const mutatingActions = ['POST', 'PUT', 'PATCH', 'DELETE'];
+ const nonMutatingActions = ['GET'];
+ const notFoundError = this.boom.notFound(`Study with id "${studyId}" does not exist`, true);
+ const forbiddenError = this.boom.forbidden();
+
+ // Ensure a valid action was passed
+ if (!mutatingActions.concat(nonMutatingActions).includes(action)) {
+ throw this.boom.internalError(`Invalid action passed to verifyRequestorAccess(): ${action}`);
+ }
+
+ // Get user permissions
+ const permissions = await this.getRequestorPermissions(requestContext);
+ if (!permissions) {
+ throw notFoundError;
+ }
+
+ // Check whether user has any access
+ const hasAdminAccess = permissions.adminAccess.some(accessibleId => accessibleId === studyId);
+ const hasReadonlyAccess = permissions.readonlyAccess.some(accessibleId => accessibleId === studyId);
+ if (!(hasAdminAccess || hasReadonlyAccess)) {
+ throw notFoundError;
+ }
+
+ // Deny mutating actions to non-admin users
+ if (!hasAdminAccess && mutatingActions.includes(action)) {
+ throw forbiddenError;
+ }
+ }
+
+ getEmptyUserPermissions() {
+ return { adminAccess: [], readonlyAccess: [] };
+ }
+
+ /**
+ * Private Methods
+ */
+ async upsertUserRecord(requestContext, { studyId, principalIdentifier, addOrRemove, permissionLevel }) {
+ // Create DB lock before pulling existing record
+ let result;
+ const lockKey = this.getLockKey(principalIdentifier.username, 'user');
+ await this.lockService.tryWriteLockAndRun({ id: lockKey }, async () => {
+ // Check for existing record; build new record if necessary
+ let record = await this.findByUser(requestContext, principalIdentifier.username);
+ if (!record) {
+ record = {
+ id: StudyPermissionService.getQualifiedKey(principalIdentifier.username, 'user'),
+ recordType: 'user',
+ principalIdentifier,
+ ...this.getEmptyUserPermissions(),
+ };
+ }
+
+ // Deterrmine permission level
+ if (!['admin', 'readonly'].includes(permissionLevel)) {
+ throw this.boom.internalError('Bad permission level passed to _upsertUserRecord:', permissionLevel);
+ }
+ const permissionLevelKey = `${permissionLevel}Access`;
+
+ // Add or remove permissions from user record
+ switch (addOrRemove) {
+ case 'add':
+ record[permissionLevelKey] = _.union(record[permissionLevelKey], [studyId]);
+ break;
+ case 'remove':
+ _.pull(record[permissionLevelKey], studyId);
+ break;
+ default:
+ throw this.boom.internalError('Badd addOrRemove value passed to _upsertUserRecord:', addOrRemove);
+ }
+
+ // Update database
+ result = await this._updater()
+ .key({ id: record.id })
+ .item(record)
+ .update();
+ });
+
+ return result;
+ }
+
+ static getQualifiedKey(studyOrUserId, recordType) {
+ // recordType must be 'study' or 'user'
+ if (!(recordType in keyPrefixes)) {
+ throw this.boom.internalError('Bad record type passed to getQualifiedKey:', recordType);
+ }
+
+ return `${keyPrefixes[recordType]}${studyOrUserId}`;
+ }
+
+ static sanitizeStudyRecord(record) {
+ // Delete recordType and remove key prefix since they're just used for
+ // internal indexing
+ delete record.recordType;
+ record.id = record.id.slice(keyPrefixes.study.length);
+ return record;
+ }
+
+ getLockKey(studyOrUserId, recordType) {
+ return `${this.tableName}|${StudyPermissionService.getQualifiedKey(studyOrUserId, recordType)}`;
+ }
+}
+
+module.exports = StudyPermissionService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/study/study-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/study/study-service.js
new file mode 100644
index 0000000000..bb596f5d09
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/study/study-service.js
@@ -0,0 +1,383 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const { buildTaggingXml } = require('../helpers/aws-tags');
+const createSchema = require('../schema/create-study');
+const updateSchema = require('../schema/update-study');
+
+const settingKeys = {
+ tableName: 'dbTableStudies',
+ categoryIndexName: 'dbTableStudiesCategoryIndex',
+ studyDataBucketName: 'studyDataBucketName',
+};
+
+class StudyService extends Service {
+ constructor() {
+ super();
+ this.dependency([
+ 'aws',
+ 'jsonSchemaValidationService',
+ 'dbService',
+ 'studyPermissionService',
+ 'projectService',
+ 'auditWriterService',
+ ]);
+ }
+
+ async init() {
+ await super.init();
+ const [aws, dbService, studyPermissionService] = await this.service(['aws', 'dbService', 'studyPermissionService']);
+ this.s3Client = new aws.sdk.S3();
+ this.studyPermissionService = studyPermissionService;
+
+ const table = this.settings.get(settingKeys.tableName);
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+
+ this.categoryIndex = this.settings.get(settingKeys.categoryIndexName);
+ this.studyDataBucket = this.settings.get(settingKeys.studyDataBucketName);
+ }
+
+ /**
+ * Public Methods
+ */
+ async find(requestContext, id, fields = []) {
+ const result = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return this.fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, id, fields = []) {
+ const result = await this.find(requestContext, id, fields);
+ if (!result) throw this.notFoundError(id);
+ return result;
+ }
+
+ async create(requestContext, rawData) {
+ const [validationService, projectService] = await this.service(['jsonSchemaValidationService', 'projectService']);
+
+ // Validate input
+ await validationService.ensureValid(rawData, createSchema);
+
+ // The open data studies do not need to be associated to any project
+ // for everything else make sure projectId is specified
+ if (rawData.category !== 'Open Data') {
+ if (!rawData.projectId) {
+ throw this.boom.badRequest('Missing required projectId');
+ }
+ await projectService.mustFind(requestContext, { id: rawData.projectId });
+ }
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const id = rawData.id;
+
+ // Prepare the db object
+ const dbObject = this.fromRawToDbObject(rawData, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Create file upload location if necessary
+ let studyFileLocation;
+ if (rawData.uploadLocationEnabled) {
+ if (!dbObject.resources) {
+ dbObject.resources = [];
+ }
+ studyFileLocation = this.getFilesPrefix(requestContext, id, rawData.category);
+ dbObject.resources.push({ arn: `arn:aws:s3:::${this.studyDataBucket}/${studyFileLocation}` });
+ }
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // Error if already exists
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`study with id "${id}" already exists`, true);
+ },
+ );
+
+ // Create a zero-byte object for the study in the study bucket if requested
+ if (rawData.uploadLocationEnabled) {
+ await this.s3Client
+ .putObject({
+ Bucket: this.studyDataBucket,
+ Key: studyFileLocation,
+ // ServerSideEncryption: 'aws:kms', // Not required as S3 bucket has default encryption specified
+ Tagging: `projectId=${rawData.projectId}`,
+ })
+ .promise();
+ }
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-study', body: result });
+
+ return result;
+ }
+
+ async update(requestContext, rawData) {
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+
+ // Validate input
+ await validationService.ensureValid(rawData, updateSchema);
+
+ // For now, we assume that 'updatedBy' is always a user and not a group
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { id, rev } = rawData;
+
+ // Prepare the db object
+ const dbObject = _.omit(this.fromRawToDbObject(rawData, { updatedBy: by }), ['rev']);
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .rev(rev)
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ // There are two scenarios here:
+ // 1 - The study does not exist
+ // 2 - The "rev" does not match
+ // We will display the appropriate error message accordingly
+ const existing = await this.find(requestContext, id, ['id', 'updatedBy']);
+ if (existing) {
+ throw this.boom.badRequest(
+ `study information changed by "${
+ (existing.updatedBy || {}).username
+ }" just before your request is processed, please try again`,
+ true,
+ );
+ }
+ throw this.boom.notFound(`study with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-study', body: result });
+
+ return result;
+ }
+
+ async delete(requestContext, id) {
+ // Lets now remove the item from the database
+ const result = await runAndCatch(
+ async () => {
+ return this._deleter()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .delete();
+ },
+ async () => {
+ throw this.boom.notFound(`study with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-study', body: { id } });
+
+ return result;
+ }
+
+ async list(requestContext, category, fields = []) {
+ // Get studies allowed for user
+ let result = [];
+ switch (category) {
+ case 'Open Data':
+ // Readable by all
+ result = this._query()
+ .index(this.categoryIndex)
+ .key('category', category)
+ .limit(1000)
+ .projection(fields)
+ .query();
+ break;
+
+ default: {
+ // Generate results based on access
+ const permissions = await this.studyPermissionService.getRequestorPermissions(requestContext);
+ if (permissions) {
+ // We can't give duplicate keys to the batch get, so ensure that allowedStudies is unique
+ const allowedStudies = _.uniq(permissions.adminAccess.concat(permissions.readonlyAccess));
+ if (allowedStudies.length) {
+ const rawResult = await this._getter()
+ .keys(allowedStudies.map(studyId => ({ id: studyId })))
+ .projection(fields)
+ .get();
+
+ // Filter by category and inject requestor's access level
+ const studyAccessMap = {};
+ ['admin', 'readonly'].forEach(level =>
+ permissions[`${level}Access`].forEach(studyId => {
+ studyAccessMap[studyId] = level;
+ }),
+ );
+ result = rawResult
+ .filter(study => study.category === category)
+ .map(study => ({
+ ...study,
+ access: studyAccessMap[study.id],
+ }));
+ }
+ }
+ }
+ }
+
+ // Return result
+ return result;
+ }
+
+ /**
+ * Creates a presigned post URL and form fields for use in uploading objects to S3 from the browser.
+ * Note: In order for browser uplaod to work, the destination bucket will need to have an appropriate CORS policy configured.
+ * @see https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html
+ *
+ * @param {Object} requestContext the request context provided by @aws-ee/base-services-container/lib/request-context
+ * @param {string} studyId the ID of the study in which the uploaded files should be shored.
+ * @param {string} filenames the filenames that will be used for the upload.
+ * @param {CreatePresignedPostOptions} options additional options.
+ *
+ * @typedef {Object} CreatePresignedPostOptions
+ * @property {boolean} [multiPart] set to true to allow multipart uploading.
+ *
+ * @returns {Promise} the url and fields to use when performing the upload
+ */
+ async createPresignedPostRequests(requestContext, studyId, filenames, encrypt = true, multiPart = true) {
+ // Get study details and check permissinos
+ const study = await this.mustFind(requestContext, studyId);
+
+ // Loop through requested files and generate presigned POST requests
+ const prefix = this.getFilesPrefix(requestContext, study.id, study.category);
+ return Promise.all(
+ filenames.map(filename => {
+ // Prep request
+ /** @type {AWS.S3.PresignedPost.Params} */
+ const params = { Bucket: this.studyDataBucket, Fields: { key: `${prefix}${filename}` } };
+ if (multiPart) {
+ params.Fields.enctype = 'multipart/form-data';
+ }
+ if (encrypt) {
+ // Nothing to do here because the S3 bucket has default encryption specified and has policy that denies any
+ // uploads not using default encryption
+ // If S3 bucket did not enforce default encryption using aws:kms then we must specify
+ // 'x-amz-server-side-encryption' and 'x-amz-server-side-encryption-aws-kms-key-id' here as follows
+ //
+ // params.Fields['x-amz-server-side-encryption'] = 'aws:kms';
+ // Specify the KMS Key ID here for encryption
+ // https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
+ // params.Fields['x-amz-server-side-encryption-aws-kms-key-id'] = arn of the StudyDataEncryptionKey
+ }
+ params.Fields.tagging = buildTaggingXml({
+ uploadedBy: requestContext.principal.username,
+ projectId: study.projectId,
+ });
+
+ // s3.createPresignedPost does not expose a `.promise()` method like other AWS SDK APIs.
+ // Since we want to expose the operation as an asynchronous operation, we have to manually wrap it in a Promise.
+ return new Promise((resolve, reject) =>
+ this.s3Client.createPresignedPost(params, (err, data) => {
+ if (err) {
+ return reject(err);
+ }
+ return resolve(data);
+ }),
+ );
+ }),
+ ).then(requests =>
+ requests.reduce(
+ (allRequests, currRequest, currIdx) => ({
+ // Convert presigned request data to an object key -> data map
+ ...allRequests,
+ [filenames[currIdx]]: currRequest,
+ }),
+ {},
+ ),
+ );
+ }
+
+ async listFiles(requestContext, studyId) {
+ // TODO: Add pagination
+ const study = await this.mustFind(requestContext, studyId, ['category']);
+ const prefix = await this.getFilesPrefix(requestContext, studyId, study.category);
+ const params = {
+ Bucket: this.studyDataBucket,
+ Prefix: prefix,
+ };
+
+ // Return results, removing zero-byte prefix object and only including certain fields
+ return (await this.s3Client.listObjectsV2(params).promise()).Contents.filter(object => object.Key !== prefix).map(
+ object => ({
+ filename: object.Key.slice(prefix.length),
+ size: object.Size,
+ lastModified: object.LastModified,
+ }),
+ );
+ }
+
+ /**
+ * Private Methods
+ */
+ getFilesPrefix(requestContext, studyId, studyCategory) {
+ if (studyCategory === 'My Studies') {
+ return `users/${requestContext.principal.username}/${studyId}/`;
+ }
+ return `studies/${studyCategory}/${studyId}/`;
+ }
+
+ notFoundError(studyId) {
+ return this.boom.notFound(`Study with id "${studyId}" does not exist`, true);
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+}
+
+module.exports = StudyService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/user-roles/user-roles-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/user-roles/user-roles-service.js
new file mode 100644
index 0000000000..ad8cad760c
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/user-roles/user-roles-service.js
@@ -0,0 +1,230 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { allowIfActive, allowIfAdmin } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const createSchema = require('../schema/create-user-roles');
+const updateSchema = require('../schema/update-user-roles');
+
+const settingKeys = {
+ tableName: 'dbTableUserRoles',
+};
+
+class UserRolesService extends Service {
+ constructor() {
+ super();
+ this.dependency(['jsonSchemaValidationService', 'dbService', 'authorizationService', 'auditWriterService']);
+ }
+
+ async init() {
+ await super.init();
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+
+ this._getter = () => dbService.helper.getter().table(table);
+ this._updater = () => dbService.helper.updater().table(table);
+ this._query = () => dbService.helper.query().table(table);
+ this._deleter = () => dbService.helper.deleter().table(table);
+ this._scanner = () => dbService.helper.scanner().table(table);
+ }
+
+ async find(requestContext, { id, fields = [] }) {
+ const result = await this._getter()
+ .key({ id })
+ .projection(fields)
+ .get();
+
+ return this._fromDbToDataObject(result);
+ }
+
+ async mustFind(requestContext, { id, fields = [] }) {
+ const result = await this.find(requestContext, { id, fields });
+ if (!result) throw this.boom.notFound(`userRoles with id "${id}" does not exist`, true);
+ return result;
+ }
+
+ async create(requestContext, userRole) {
+ // ensure that the caller has permissions to create the user role
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'create', conditions: [allowIfActive, allowIfAdmin] },
+ userRole,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(userRole, createSchema);
+
+ // For now, we assume that 'createdBy' and 'updatedBy' are always users and not groups
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { id } = userRole;
+
+ // Prepare the db object
+ const dbObject = this._fromRawToDbObject(userRole, { rev: 0, createdBy: by, updatedBy: by });
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_not_exists(id)') // yes we need this
+ .key({ id })
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ throw this.boom.badRequest(`userRoles with id "${id}" already exists`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-user-role', body: result });
+
+ return result;
+ }
+
+ async update(requestContext, rawData) {
+ // ensure that the caller has permissions to update the user role information
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'update', conditions: [allowIfActive, allowIfAdmin] },
+ rawData,
+ );
+
+ // Validate input
+ const [validationService] = await this.service(['jsonSchemaValidationService']);
+ await validationService.ensureValid(rawData, updateSchema);
+
+ // For now, we assume that 'updatedBy' is always a user and not a group
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const { id, rev } = rawData;
+
+ // Prepare the db object
+ const dbObject = _.omit(this._fromRawToDbObject(rawData, { updatedBy: by }), ['rev']);
+
+ // Time to save the the db object
+ const result = await runAndCatch(
+ async () => {
+ return this._updater()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .rev(rev)
+ .item(dbObject)
+ .update();
+ },
+ async () => {
+ // There are two scenarios here:
+ // 1 - The userroles does not exist
+ // 2 - The "rev" does not match
+ // We will display the appropriate error message accordingly
+ const existing = await this.find(requestContext, { id, fields: ['id', 'updatedBy'] });
+ if (existing) {
+ throw this.boom.badRequest(
+ `userRoles information changed by "${
+ (existing.updatedBy || {}).username
+ }" just before your request is processed, please try again`,
+ true,
+ );
+ }
+ throw this.boom.notFound(`userRoles with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'update-user-role', body: result });
+
+ return result;
+ }
+
+ async delete(requestContext, { id }) {
+ // ensure that the caller has permissions to delete the user role
+ // Perform default condition checks to make sure the user is active and is admin
+ await this.assertAuthorized(
+ requestContext,
+ { action: 'delete', conditions: [allowIfActive, allowIfAdmin] },
+ { id },
+ );
+
+ // Lets now remove the item from the database
+ const result = await runAndCatch(
+ async () => {
+ return this._deleter()
+ .condition('attribute_exists(id)') // yes we need this
+ .key({ id })
+ .delete();
+ },
+ async () => {
+ throw this.boom.notFound(`userRoles with id "${id}" does not exist`, true);
+ },
+ );
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'delete-user-role', body: { id } });
+
+ return result;
+ }
+
+ async list({ fields = [] } = {}) {
+ // Remember doing a scanning is not a good idea if you billions of rows
+ return this._scanner()
+ .limit(1000)
+ .projection(fields)
+ .scan();
+ }
+
+ // Do some properties renaming to prepare the object to be saved in the database
+ _fromRawToDbObject(rawObject, overridingProps = {}) {
+ const dbObject = { ...rawObject, ...overridingProps };
+ return dbObject;
+ }
+
+ // Do some properties renaming to restore the object that was saved in the database
+ _fromDbToDataObject(rawDb, overridingProps = {}) {
+ if (_.isNil(rawDb)) return rawDb; // important, leave this if statement here, otherwise, your update methods won't work correctly
+ if (!_.isObject(rawDb)) return rawDb;
+
+ const dataObject = { ...rawDb, ...overridingProps };
+ return dataObject;
+ }
+
+ async audit(requestContext, auditEvent) {
+ const auditWriterService = await this.service('auditWriterService');
+ // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging
+ // and not fail main call if audit writing fails for some reason
+ // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows
+ // return auditWriterService.write(requestContext, auditEvent);
+ return auditWriterService.writeAndForget(requestContext, auditEvent);
+ }
+
+ async assertAuthorized(requestContext, { action, conditions }, ...args) {
+ const authorizationService = await this.service('authorizationService');
+
+ // The "authorizationService.assertAuthorized" below will evaluate permissions by calling the "conditions" functions first
+ // It will then give a chance to all registered plugins (if any) to perform their authorization.
+ // The plugins can even override the authorization decision returned by the conditions
+ // See "authorizationService.authorize" method for more details
+ await authorizationService.assertAuthorized(
+ requestContext,
+ { extensionPoint: 'user-role-management-authz', action, conditions },
+ ...args,
+ );
+ }
+}
+
+module.exports = UserRolesService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/user/helpers/user-authz-utils.js b/addons/addon-base-raas/packages/base-raas-services/lib/user/helpers/user-authz-utils.js
new file mode 100644
index 0000000000..ce7390630f
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/user/helpers/user-authz-utils.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const { allow, deny } = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+
+function allowIfHasRole(requestContext, { action, resource }, allowedUserRoles) {
+ const userRole = _.get(requestContext, 'principal.userRole');
+ if (!_.includes(allowedUserRoles, userRole)) {
+ const resourceDisplayName = resource || 'resource';
+ return deny(
+ `Cannot ${action} ${resourceDisplayName}. The user's role "${userRole}" is not allowed to ${action} ${resourceDisplayName}`,
+ false,
+ );
+ }
+ return allow();
+}
+
+module.exports = {
+ allowIfHasRole,
+};
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/user/user-attributes-mapper-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-attributes-mapper-service.js
new file mode 100644
index 0000000000..cd56226ac6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-attributes-mapper-service.js
@@ -0,0 +1,29 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const BaseAttribMapperService = require('@aws-ee/base-api-services/lib/authentication-providers/built-in-providers/cogito-user-pool/user-attributes-mapper-service');
+
+class UserAttributesMapperService extends BaseAttribMapperService {
+ mapAttributes(decodedToken) {
+ const userAttributes = super.mapAttributes(decodedToken);
+ // For RaaS solution, the user's email address should be used as his/her username
+ // so set username to be email address
+ userAttributes.username = userAttributes.email;
+
+ return userAttributes;
+ }
+}
+
+module.exports = UserAttributesMapperService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/user/user-authz-plugin.js b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-authz-plugin.js
new file mode 100644
index 0000000000..601ae34530
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-authz-plugin.js
@@ -0,0 +1,18 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const authorizationPluginFactory = require('@aws-ee/base-services/lib/authorization/authorization-plugin-factory');
+
+module.exports = authorizationPluginFactory('raasUserAuthzService');
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/user/user-authz-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-authz-service.js
new file mode 100644
index 0000000000..91fb852581
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-authz-service.js
@@ -0,0 +1,128 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const {
+ isDeny,
+ allowIfActive,
+ allowIfAdmin,
+ allowIfCurrentUserOrAdmin,
+ allowIfRoot,
+ allow,
+ deny,
+} = require('@aws-ee/base-services/lib/authorization/authorization-utils');
+const { toUserNamespace } = require('@aws-ee/base-services/lib/user/helpers/user-namespace');
+
+class UserAuthzService extends Service {
+ async authorize(requestContext, { resource, action, effect, reason }, ...args) {
+ const permissionSoFar = { resource, action, effect, reason };
+
+ // in base-raas-services authorization logic needs to be customized only for "createBulk", "update", and
+ // "updateAttributes" action, for all other actions let it return permissions evaluated by base authorization plugin
+ // See "raas/addons/addon-base/packages/services/lib/plugins/authorization-plugin.js" for base authorization plugin implementation and
+ // See "raas/addons/addon-base/packages/services/lib/user/user-authorization-service.js" that implements base authorization logic for "user" resource
+ switch (action) {
+ case 'createBulk':
+ // if effect is "deny" already (due to any of the previous plugins returning "deny") then return "deny" right away
+ if (isDeny({ effect })) return permissionSoFar;
+ return this.authorizeCreateBulk(requestContext, { action, effect }, ...args);
+ case 'update':
+ // For "update", DO NOT return "deny" if other plugins returned deny, instead use our own authorization logic.
+ // This is because, the base authorization impl denies "update" by non-active users but we need to allow
+ // self-update in "pending" status to support self-enrollment application feature
+ return this.authorizeUpdate(requestContext, { action, effect }, ...args);
+ case 'updateAttributes':
+ // Just like the "update" case, DO NOT return "deny" if other plugins returned deny, instead use our own authorization logic.
+ return this.authorizeUpdateAttributes(requestContext, { resource, action, effect, reason }, ...args);
+ default:
+ return permissionSoFar;
+ }
+ }
+
+ async authorizeUpdate(requestContext, { action, effect }, user) {
+ // Allow update to "pending" status even if the caller is inactive to support self-enrollment application
+ let permissionSoFar = { action, effect };
+ if (user.status !== 'pending') {
+ // When updating user's status to anything other than "pending" make sure the caller is active
+ // Make sure the caller is active
+ permissionSoFar = await allowIfActive(requestContext, { action });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+ }
+
+ // User can update only their own attributes unless the user is an admin
+ const { username, authenticationProviderId, identityProviderName } = user;
+ const ns = toUserNamespace(authenticationProviderId, identityProviderName);
+ permissionSoFar = await allowIfCurrentUserOrAdmin(requestContext, { action }, { username, ns });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ return allow();
+ }
+
+ async authorizeCreateBulk(requestContext, { action }) {
+ // Make sure the caller is active
+ let permissionSoFar = await allowIfActive(requestContext, { action });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ // Only admins can create users in bulk by default
+ permissionSoFar = await allowIfAdmin(requestContext, { action });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ // If code reached here then allow this call
+ return allow();
+ }
+
+ async authorizeUpdateAttributes(requestContext, { action }, user, existingUser) {
+ const isBeingUpdated = attribName => {
+ const oldValue = _.get(existingUser, attribName);
+ const newValue = _.get(user, attribName);
+ // The update ignores undefined values during update (i.e., it retains existing values for those)
+ // so compare for only if the new value is undefined
+ return !_.isUndefined(newValue) && oldValue !== newValue;
+ };
+
+ let permissionSoFar;
+ // In addition to the permissions ascertained by the base class,
+ // make sure that we allow updating "userRole" only by admins
+ if (isBeingUpdated('isExternalUser') || isBeingUpdated('userRole')) {
+ // The "isExternalUser" and "userRole" properties should be updated only by admins
+ permissionSoFar = await allowIfAdmin(requestContext, { action });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+ }
+
+ // Similarly, in addition to the permissions ascertained by the base,
+ // make sure the following properties on root are immutable
+ if (existingUser.userType === 'root') {
+ permissionSoFar = await allowIfRoot(requestContext, { action });
+ if (isDeny(permissionSoFar)) return permissionSoFar; // return if denying
+
+ if (
+ isBeingUpdated('authenticationProviderId') ||
+ isBeingUpdated('identityProviderName') ||
+ isBeingUpdated('isAdmin') ||
+ isBeingUpdated('userRole') ||
+ isBeingUpdated('projectId')
+ ) {
+ return deny('You are not authorized to alter these fields on the root user', true);
+ }
+ }
+
+ // If code reached here then allow this call
+ return allow();
+ }
+}
+
+module.exports = UserAuthzService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/user/user-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-service.js
new file mode 100644
index 0000000000..858761da1d
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/lib/user/user-service.js
@@ -0,0 +1,227 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const { ensureCurrentUser } = require('@aws-ee/base-services/lib/authorization/assertions');
+const { toUserNamespace } = require('@aws-ee/base-services/lib/user/helpers/user-namespace');
+const BaseUserService = require('@aws-ee/base-services/lib/user/user-service');
+const { processInBatches } = require('@aws-ee/base-services/lib/helpers/utils');
+
+const createUserJsonSchema = require('../schema/create-user');
+const updateUserJsonSchema = require('../schema/update-user');
+
+class UserService extends BaseUserService {
+ constructor() {
+ super();
+ this.dependency(['userRolesService']);
+ }
+
+ /**
+ * Method to create users in bulk in the specified number of batches. The method will try to create users in parallel
+ * within a given batch but will not start new batch until a previous batch is complete.
+ *
+ * @param requestContext
+ * @param users
+ * @param defaultAuthNProviderId
+ * @param batchSize
+ * @returns {Promise}
+ */
+ async createUsers(requestContext, users, defaultAuthNProviderId, batchSize = 5) {
+ // ensure that the caller has permissions to create user
+ await this.assertAuthorized(requestContext, { action: 'createBulk' });
+
+ const errors = [];
+ let successCount = 0;
+ let errorCount = 0;
+ const createUser = async curUser => {
+ try {
+ const isAdmin = curUser.isAdmin === true;
+ const authenticationProviderId =
+ curUser.authenticationProviderId ||
+ defaultAuthNProviderId ||
+ requestContext.principal.authenticationProviderId;
+ const name = await this.toDefaultName(curUser.email);
+ const userType = await this.toUserType(requestContext, curUser.userRole);
+ const userToCreate = {
+ firstName: _.isEmpty(curUser.firstName) ? name : curUser.firstName,
+ lastName: _.isEmpty(curUser.lastName) ? name : curUser.lastName,
+ username: curUser.email,
+ email: curUser.email,
+ isAdmin,
+ userRole: curUser.userRole,
+ authenticationProviderId,
+ identityProviderName: curUser.identityProviderName,
+ status: 'active',
+ isExternalUser: userType === 'EXTERNAL',
+ };
+ if (!_.isEmpty(userToCreate.email)) {
+ const user = await this.findUser({
+ username: userToCreate.username,
+ authenticationProviderId: userToCreate.authenticationProviderId,
+ identityProviderName: userToCreate.identityProviderName,
+ });
+ if (user) {
+ throw this.boom.alreadyExists('Cannot add user. The user already exists.', true);
+ } else {
+ await this.createUser(requestContext, userToCreate);
+ successCount += 1;
+ }
+ }
+ } catch (e) {
+ const errorMsg = e.safe // if error is boom error then see if it is safe to propagate it's message
+ ? `Error creating user ${curUser.email}. ${e.message}`
+ : `Error creating user ${curUser.email}`;
+
+ this.log.error(errorMsg);
+ this.log.error(e);
+ errors.push(errorMsg);
+
+ errorCount += 1;
+ }
+ };
+ // Create users in parallel in the specified batches
+ await processInBatches(users, batchSize, createUser);
+ if (!_.isEmpty(errors)) {
+ throw this.boom.internalError(`Errors creating users in bulk`, true).withPayload(errors);
+ }
+
+ // Write audit event
+ await this.audit(requestContext, { action: 'create-users-batch', body: { totalUsers: _.size(users) } });
+
+ return { successCount, errorCount };
+ }
+
+ async listUsers(requestContext, { fields = [] } = {}) {
+ const users = await super.listUsers(requestContext, fields);
+
+ const isAdmin = _.get(requestContext, 'principal.isAdmin', false);
+
+ const fieldsToOmit = isAdmin ? ['encryptedCreds'] : ['encryptedCreds', 'userRole'];
+ const sanitizedUsers = users.map(user => _.omit(user, fieldsToOmit));
+ return sanitizedUsers;
+ }
+
+ async getCreateUserJsonSchema() {
+ return createUserJsonSchema;
+ }
+
+ async getUpdateUserJsonSchema() {
+ return updateUserJsonSchema;
+ }
+
+ async assertValidProjectId(requestContext, input, existingUser = {}) {
+ // in case of updateUser, there may be an existingUser with existing projectId, in that case, the input must
+ // specify a valid internal userRole or also make projectId empty array
+ const user = { ...existingUser, ...input };
+ // if projectId is not specified or if it's empty array then nothing to validate just return
+ if (!user.projectId || _.isEmpty(user.projectId)) {
+ return;
+ }
+
+ // Only internal users (i.e., user with userRole.userType === INTERNAL) can be assigned projects,
+ const userRoleId = input.userRole;
+ if (userRoleId) {
+ if (_.toLower(userRoleId) === 'internal-guest') {
+ throw this.boom.forbidden('Guest users cannot be assigned a project', true);
+ }
+
+ const userRolesService = await this.service('userRolesService');
+ const userRole = await userRolesService.mustFind(requestContext, { id: userRoleId, fields: ['userType'] });
+ if (_.toLower(userRole.userType) !== 'internal') {
+ throw this.boom.forbidden('External users cannot be assigned a project', true);
+ }
+ }
+ }
+
+ async validateCreateUser(requestContext, input) {
+ await super.validateCreateUser(requestContext, input);
+
+ // Make sure that the projectId(s) are not specified for user with any external role
+ await this.assertValidProjectId(requestContext, input);
+ }
+
+ async setDefaultAttributes(requestContext, user) {
+ const setDefaultIf = checkFn => {
+ return (attribName, defaultValue) => {
+ if (checkFn(user[attribName])) {
+ user[attribName] = defaultValue;
+ }
+ };
+ };
+ const setDefaultIfNil = setDefaultIf(_.isNil);
+ const setDefaultIfEmpty = setDefaultIf(_.isEmpty);
+
+ // Set default values for "status", and "userRole" if they are not specified in the user
+ if (user.userType === 'root') {
+ // default userRole to 'admin' if it's root user
+ setDefaultIfNil('userRole', 'admin');
+ } else {
+ // for all other users (non-root users) set "status" to "inactive" by default
+ setDefaultIfNil('status', 'inactive');
+
+ // for all other users (non-root users) set "userRole" to "guest" by default
+ setDefaultIfNil('userRole', 'guest');
+ }
+ const { email, userRole } = user;
+ const name = await this.toDefaultName(email);
+ const userType = await this.toUserType(requestContext, userRole);
+
+ setDefaultIfEmpty('username', email);
+ setDefaultIfEmpty('firstName', name);
+ setDefaultIfEmpty('lastName', name);
+ setDefaultIfEmpty('encryptedCreds', 'N/A');
+ setDefaultIfEmpty('applyReason', 'N/A');
+ setDefaultIfEmpty('projectId', []);
+
+ user.isAdmin = userRole === 'admin';
+ user.isExternalUser = userType === 'EXTERNAL';
+
+ // Give super class a chance to set it's defaults after we are done setting default values
+ await super.setDefaultAttributes(requestContext, user);
+ }
+
+ async validateUpdateAttributes(requestContext, user, existingUser) {
+ // call base impl first
+ await super.validateUpdateAttributes(requestContext, user, existingUser);
+
+ await this.assertValidProjectId(requestContext, user, existingUser);
+ }
+
+ async selfServiceUpdateUser(requestContext, user = {}) {
+ // user can only update his/her own info via self-service update
+ const { username, authenticationProviderId, identityProviderName } = user;
+ const ns = toUserNamespace(authenticationProviderId, identityProviderName);
+ await ensureCurrentUser(requestContext, username, ns);
+
+ return this.updateUser(requestContext, user);
+ }
+
+ // Private methods
+ async toUserType(requestContext, userRoleId) {
+ const userRolesService = await this.service('userRolesService');
+ let userType;
+ if (userRoleId) {
+ const { userType: userTypeInRole } = await userRolesService.mustFind(requestContext, { id: userRoleId });
+ userType = userTypeInRole;
+ }
+ return userType;
+ }
+
+ async toDefaultName(userEmail) {
+ return userEmail ? userEmail.substring(0, userEmail.lastIndexOf('@')) : '';
+ }
+}
+
+module.exports = UserService;
diff --git a/addons/addon-base-raas/packages/base-raas-services/package.json b/addons/addon-base-raas/packages/base-raas-services/package.json
new file mode 100644
index 0000000000..737c896dde
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-services/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@aws-ee/base-raas-services",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A library containing a set of base RaaS related services and utilities",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-services": "workspace:*",
+ "@aws-ee/base-api-services": "workspace:*",
+ "@aws-ee/base-services-container": "workspace:*",
+ "lodash": "^4.17.15",
+ "node-cache": "^4.2.1",
+ "uuid": "^3.4.0",
+ "xml": "^1.0.1"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "pretty-quick": "^1.11.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/.eslintrc.json b/addons/addon-base-raas/packages/base-raas-workflow-steps/.eslintrc.json
new file mode 100644
index 0000000000..c700cf03a8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/.eslintrc.json
@@ -0,0 +1,26 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0,
+ "import/no-unresolved": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/.gitignore b/addons/addon-base-raas/packages/base-raas-workflow-steps/.gitignore
new file mode 100644
index 0000000000..659959de8f
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/.prettierrc.json b/addons/addon-base-raas/packages/base-raas-workflow-steps/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/jest.config.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/jest.config.js
new file mode 100644
index 0000000000..70544476d7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/jest.config.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+};
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/jsconfig.json b/addons/addon-base-raas/packages/base-raas-workflow-steps/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/plugins/workflow-steps-plugin.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/plugins/workflow-steps-plugin.js
new file mode 100644
index 0000000000..7f9b98326c
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/plugins/workflow-steps-plugin.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+/* eslint-disable global-require */
+const deleteEnvironment = require('../steps/delete-environment/delete-environment');
+const deleteEnvironmentYaml = require('../steps/delete-environment/delete-environment.yml');
+const provisionAccount = require('../steps/provision-account/provision-account');
+const provisionAccountYaml = require('../steps/provision-account/provision-account.yml');
+const provisionEnvironment = require('../steps/provision-environment/provision-environment');
+const provisionEnvironmentYaml = require('../steps/provision-environment/provision-environment.yml');
+
+const add = (implClass, yaml) => ({ implClass, yaml });
+
+// The order is important, add your steps here
+const steps = [
+ add(deleteEnvironment, deleteEnvironmentYaml),
+ add(provisionAccount, provisionAccountYaml),
+ add(provisionEnvironment, provisionEnvironmentYaml),
+];
+
+async function registerWorkflowSteps(registry) {
+ // eslint-disable-next-line no-restricted-syntax
+ for (const step of steps) {
+ const { implClass, yaml } = step;
+ await registry.add({ implClass, yaml }); // eslint-disable-line no-await-in-loop
+ }
+}
+
+module.exports = { registerWorkflowSteps };
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/delete-environment/delete-environment.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/delete-environment/delete-environment.js
new file mode 100644
index 0000000000..ac2561a9a3
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/delete-environment/delete-environment.js
@@ -0,0 +1,151 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const StepBase = require('@aws-ee/base-workflow-core/lib/workflow/helpers/step-base');
+
+const STACK_FAILED = [
+ 'CREATE_FAILED',
+ 'ROLLBACK_FAILED',
+ 'DELETE_FAILED',
+ 'UPDATE_ROLLBACK_FAILED',
+ 'ROLLBACK_COMPLETE',
+ 'UPDATE_ROLLBACK_COMPLETE',
+];
+const STACK_SUCCESS = ['CREATE_COMPLETE', 'DELETE_COMPLETE', 'UPDATE_COMPLETE'];
+
+class DeleteEnvironment extends StepBase {
+ async start() {
+ // Get services
+ const [environmentService, environmentMountService] = await this.mustFindServices([
+ 'environmentService',
+ 'environmentMountService',
+ ]);
+
+ const cfn = await this.getCloudFormationService();
+
+ // Get common payload params and pull environment info
+ const [environmentId, requestContext] = await Promise.all([
+ this.payload.string('environmentId'),
+ this.payload.object('requestContext'),
+ ]);
+ const environment = await environmentService.mustFind(requestContext, { id: environmentId });
+
+ // Set initial state
+ this.state.setKey('STATE_ENVIRONMENT_ID', environmentId);
+ this.state.setKey('STATE_REQUEST_CONTEXT', requestContext);
+ this.state.setKey('STATE_STACK_ID', environment.stackId);
+
+ // Confirm that the environment has an associated CFN stack ID
+ this.print(`Deleting stack ${environment.stackId} for environment ${environment.id}`);
+ if (!environment.stackId) {
+ // if there is no stack id, then there's nothing else to clean up.
+ return this.updateEnvironmentStatusToTerminated();
+ }
+
+ // Perform updates/termination
+ // Remove from policies before deleting resources to prevent trying to save the principal ID
+ // because the role was removed before the policy update. See
+ // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html
+ // under IAM Roles
+ await environmentMountService.removeRoleArnFromLocalResourcePolicies(
+ environment.instanceInfo.WorkspaceInstanceRoleArn,
+ environment.instanceInfo.s3Prefixes,
+ );
+ await Promise.all([
+ this.deleteKeypair(),
+ this.updateEnvironmentStatus('TERMINATING'),
+ cfn.deleteStack({ StackName: environment.stackId }).promise(),
+ ]);
+
+ // Poll until the stack has been deleted
+ return this.wait(20)
+ .maxAttempts(120)
+ .until('checkCfnCompleted')
+ .thenCall('updateEnvironmentStatusToTerminated');
+ }
+
+ /**
+ * CloudFormation and Workflow-Related Methods
+ */
+ async checkCfnCompleted() {
+ const stackId = await this.state.string('STATE_STACK_ID');
+ this.print(`stack id is ${stackId}`);
+ const cfn = await this.getCloudFormationService();
+ const stackInfo = (await cfn.describeStacks({ StackName: stackId }).promise()).Stacks[0];
+
+ if (STACK_FAILED.includes(stackInfo.StackStatus)) {
+ throw new Error(`Stack operation failed with message: ${stackInfo.StackStatusReason}`);
+ } else {
+ return !!STACK_SUCCESS.includes(stackInfo.StackStatus);
+ }
+ }
+
+ async getCloudFormationService() {
+ const [aws] = await this.mustFindServices(['aws']);
+ const [requestContext, RoleArn, ExternalId] = await Promise.all([
+ this.payload.object('requestContext'),
+ this.payload.string('cfnExecutionRole'),
+ this.payload.string('roleExternalId'),
+ ]);
+
+ const sts = new aws.sdk.STS();
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}`,
+ ExternalId,
+ })
+ .promise();
+
+ return new aws.sdk.CloudFormation({ accessKeyId, secretAccessKey, sessionToken });
+ }
+
+ async updateEnvironmentStatusToTerminated() {
+ return this.updateEnvironmentStatus('TERMINATED');
+ }
+
+ async updateEnvironmentStatus(status) {
+ const environmentService = await this.mustFindServices('environmentService');
+ const id = await this.state.string('STATE_ENVIRONMENT_ID');
+ const requestContext = await this.state.optionalObject('STATE_REQUEST_CONTEXT');
+ await environmentService.update(requestContext, { id, status });
+ }
+
+ async onFail() {
+ return this.updateEnvironmentStatus('TERMINATING_FAILED');
+ }
+
+ /**
+ * External Resource-Related Methods
+ */
+ async deleteKeypair() {
+ const environmentKeypairService = await this.mustFindServices('environmentKeypairService');
+ const id = await this.state.string('STATE_ENVIRONMENT_ID');
+ const requestContext = await this.state.optionalObject('STATE_REQUEST_CONTEXT');
+ try {
+ await environmentKeypairService.delete(requestContext, id);
+ } catch (error) {
+ // Ignore ParameterNotFound errors from Parameter Store if the key was already
+ // deleted or no key was ever created (e.g., for SageMaker environments)
+ if (!('code' in error) || error.code !== 'ParameterNotFound') {
+ throw error;
+ }
+ }
+ }
+}
+
+module.exports = DeleteEnvironment;
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/delete-environment/delete-environment.yml b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/delete-environment/delete-environment.yml
new file mode 100644
index 0000000000..4de6903677
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/delete-environment/delete-environment.yml
@@ -0,0 +1,8 @@
+id: st-delete-environment
+v: 1
+title: Delete Environment
+desc: |
+ Remove an analytics environment
+
+skippable: true # this means that if there is an error in a previous step, then this step will be skipped
+hidden: true
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js
new file mode 100644
index 0000000000..7bfa29c5f3
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js
@@ -0,0 +1,452 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const StepBase = require('@aws-ee/base-workflow-core/lib/workflow/helpers/step-base');
+
+const STACK_FAILED = [
+ 'CREATE_FAILED',
+ 'ROLLBACK_FAILED',
+ 'DELETE_FAILED',
+ 'UPDATE_ROLLBACK_FAILED',
+ 'ROLLBACK_COMPLETE',
+ 'UPDATE_ROLLBACK_COMPLETE',
+];
+
+const STACK_SUCCESS = ['CREATE_COMPLETE', 'DELETE_COMPLETE', 'UPDATE_COMPLETE'];
+
+const settingKeys = {
+ artifactsBucketName: 'artifactsBucketName',
+};
+
+class ProvisionAccount extends StepBase {
+ // ======================================================
+ // PLEASE NOTE: the following account provision is assuming the account organization is already been setup
+ // TODOs: create organization in master account, the role access limitation needs to be changed as well
+ // ======================================================
+ async start() {
+ this.print('start provisioning accounts');
+ const [requestContext] = await Promise.all([this.payload.object('requestContext')]);
+ this.state.setKey('STATE_REQUEST_CONTEXT', requestContext);
+ // provision new aws account in existing Organization in master account
+ this.print('getting awsorgservice');
+ const awsOrgService = await this.getOrganizationService();
+ this.print('getting accountname and email');
+ const [AccountName, Email] = await Promise.all([
+ this.payload.string('accountName'),
+ this.payload.string('accountEmail'),
+ ]);
+ const params = {
+ AccountName,
+ Email,
+ };
+ this.print(`Attempting to create AWS account with ${JSON.stringify(params)}`);
+ let creationResult;
+ try {
+ // TODO: handle if email address is already been used for another aws account
+ creationResult = await awsOrgService.createAccount(params).promise();
+ this.print(`setting the request id as ${creationResult.CreateAccountStatus.Id}`);
+ this.state.setKey('REQUEST_ID', creationResult.CreateAccountStatus.Id);
+ } catch (e) {
+ this.print(`Error in creating account ${e.stack}`);
+ throw new Error(`Create AWS Account operation error: ${e.stack}`);
+ }
+
+ this.print(`Waiting for Account creation process with requestID: ${await this.state.string('REQUEST_ID')}`);
+ // wait until the account creation is finished
+ return this.wait(10)
+ .maxAttempts(120)
+ .until('checkAccountCreationCompleted')
+ .thenCall('saveAccountToDb');
+ }
+
+ async saveAccountToDb() {
+ this.print('start to save account info into Dynamo');
+ const requestContext = await this.payload.object('requestContext');
+ const accountName = await this.payload.string('accountName');
+ const email = await this.payload.string('accountEmail');
+ // After the account is created
+ await this.describeAccount();
+ const accountId = await this.state.string('ACCOUNT_ID');
+ const accountArn = await this.state.string('ACCOUNT_ARN');
+
+ this.print(`Account creation process finish. New aws accountID: ${accountId}. ARN: ${accountArn}`);
+ const [accountService] = await this.mustFindServices(['accountService']);
+ this.print('got accountservice');
+ const data = {
+ accountName,
+ email,
+ accountArn,
+ // TODO: check if roleName and iamUserAccessToBillion is specified by user, keep them default and skip saving them for now
+ };
+ this.print(`saving account data into dynamo: ${JSON.stringify(data)}`);
+ await accountService.saveAccountToDb(requestContext, data, accountId);
+ // THIS IS NEEDED, we should wait AWS to setup the account, even if we can fetch the account ID
+ this.print('start to wait for 5min for AWS getting the account ready.');
+ return this.wait(60 * 5).thenCall('deployStack');
+ }
+
+ async deployStack() {
+ this.print('start to deploy initial stacks in newly created AWS account');
+
+ const requestContext = await this.payload.object('requestContext');
+ const by = _.get(requestContext, 'principalIdentifier');
+ const ExternalId = await this.payload.string('externalId');
+ const [workflowRoleArn, apiHandlerArn] = await Promise.all([
+ this.payload.string('workflowRoleArn'),
+ this.payload.string('apiHandlerArn'),
+ ]);
+ const centralAccountId = await this.state.string('ACCOUNT_ID');
+ // deploy basic stacks to the account just created
+ const [cfnTemplateService] = await this.mustFindServices(['cfnTemplateService']);
+ const cfn = await this.getCloudFormationService();
+
+ const [template] = await Promise.all([cfnTemplateService.getTemplate('onboard-account')]);
+ const stackName = `initial-stack-${new Date().getTime()}`;
+ const cfnParams = [];
+ const addParam = (key, v) => cfnParams.push({ ParameterKey: key, ParameterValue: v });
+
+ addParam('Namespace', stackName);
+ addParam('CentralAccountId', centralAccountId);
+ addParam('ExternalId', ExternalId);
+ // TODO: consider if following params are needed
+ // addParam('TrustUserArn', userArn);
+ addParam('WorkflowRoleArn', workflowRoleArn);
+ addParam('ApiHandlerArn', apiHandlerArn);
+
+ const input = {
+ StackName: stackName,
+ Parameters: cfnParams,
+ Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'],
+ TemplateBody: template,
+ Tags: [
+ {
+ Key: 'Description',
+ Value: `Created by ${by.username} for newly created AWS account`,
+ },
+ {
+ Key: 'CreatedBy',
+ Value: by.username,
+ },
+ ],
+ };
+ const response = await cfn.createStack(input).promise();
+
+ // Update workflow state and poll for stack creation completion
+ this.state.setKey('STATE_STACK_ID', response.StackId);
+ await this.updateAccount({ stackId: response.StackId });
+ return this.wait(20)
+ .maxAttempts(120)
+ .until('checkCfnCompleted');
+ }
+
+ async checkCfnCompleted() {
+ const requestContext = await this.payload.object('requestContext');
+ const stackId = await this.state.string('STATE_STACK_ID');
+ const cfn = await this.getCloudFormationService();
+ const stackInfo = (await cfn.describeStacks({ StackName: stackId }).promise()).Stacks[0];
+
+ if (STACK_FAILED.includes(stackInfo.StackStatus)) {
+ throw new Error(`Stack operation failed with message: ${stackInfo.StackStatusReason}`);
+ } else if (STACK_SUCCESS.includes(stackInfo.StackStatus)) {
+ // handle the case where the cloudformation is deleted before the creation could finish
+ if (stackInfo.StackStatus !== 'DELETE_COMPLETE') {
+ const cfnOutputs = this.getCfnOutputs(stackInfo);
+ this.print('updating stack deployed completed');
+
+ // create S3 and KMS resources access for newly created account
+ await this.updateLocalResourcePolicies();
+
+ await this.updateAccount({
+ status: 'COMPLETED',
+ cfnInfo: {
+ stackId,
+ vpcId: cfnOutputs.VPC,
+ subnetId: cfnOutputs.VpcPublicSubnet1,
+ crossAccountExecutionRoleArn: cfnOutputs.CrossAccountExecutionRoleArn,
+ encryptionKeyArn: cfnOutputs.EncryptionKeyArn,
+ },
+ });
+ // TODO: after the account is deployed and all useful info is fetched,
+ // try to update aws account table, it might be able to skip that table with all info already
+ // exsiting in the account table instead of aws-account table. but for now, update the info to aws-account
+ // ddb as well for code consistant reason.
+ const [description, externalId, name] = await Promise.all([
+ this.payload.string('description'),
+ this.payload.string('externalId'),
+ this.payload.string('accountName'),
+ ]);
+ this.print('saving account info into aws account table');
+ const accountId = await this.state.string('ACCOUNT_ID');
+ const awsAccountData = {
+ accountId,
+ description,
+ externalId,
+ name,
+ roleArn: cfnOutputs.CrossAccountExecutionRoleArn,
+ subnetId: cfnOutputs.VpcPublicSubnet1,
+ vpcId: cfnOutputs.VPC,
+ encryptionKeyArn: cfnOutputs.EncryptionKeyArn,
+ };
+ await this.addAwsAccountTable(requestContext, awsAccountData);
+ }
+ return true;
+ } // else CFN is still pending
+ return false;
+ }
+
+ async addAwsAccountTable(requestContext, awsAccountData) {
+ const [awsAccountsService] = await this.mustFindServices(['awsAccountsService']);
+ await awsAccountsService.create(requestContext, awsAccountData);
+ }
+
+ async checkAccountCreationCompleted() {
+ this.print('checking if the AWS account is created');
+ const awsOrgService = await this.getOrganizationService();
+
+ const requestId = await this.state.string('REQUEST_ID');
+ const params = {
+ CreateAccountRequestId: requestId,
+ };
+ let CreateAccountStatus;
+ try {
+ CreateAccountStatus = await awsOrgService.describeCreateAccountStatus(params).promise();
+ } catch (e) {
+ throw new Error(`checkAccountCreationCompleted operation error: ${e.stack}`);
+ }
+ /* response example:
+ data = {
+ CreateAccountStatus: {
+ AccountId: "333333333333",
+ Id: "car-exampleaccountcreationrequestid",
+ State: "SUCCEEDED"
+ }
+ }
+ */
+
+ if (CreateAccountStatus.CreateAccountStatus.State === 'SUCCEEDED') {
+ this.print(
+ `account is successfully created with account_id: ${CreateAccountStatus.CreateAccountStatus.AccountId}`,
+ );
+ this.state.setKey('ACCOUNT_ID', CreateAccountStatus.CreateAccountStatus.AccountId);
+ return true;
+ }
+
+ return false;
+ }
+
+ async updateAccount(obj) {
+ const accountService = await this.mustFindServices('accountService');
+ const id = await this.state.string('ACCOUNT_ID');
+ const requestContext = await this.state.optionalObject('STATE_REQUEST_CONTEXT');
+ const account = _.clone(obj);
+ account.id = id;
+ this.print(`updating account table with info: ${JSON.stringify(account)}`);
+ await accountService.update(requestContext, account);
+ }
+
+ async onFail() {
+ await this.updateAccount({ status: 'FAILED' });
+ }
+
+ async describeAccount() {
+ const awsOrgService = await this.getOrganizationService();
+ const AccountId = await this.state.string('ACCOUNT_ID');
+ const params = {
+ AccountId,
+ };
+ let account;
+ try {
+ account = await awsOrgService.describeAccount(params).promise();
+ this.print(`setting account_arn as ${account.Account.Arn}`);
+ this.state.setKey('ACCOUNT_ARN', account.Account.Arn);
+ } catch (e) {
+ throw new Error(`Describe AWS Account operation error: ${e.stack}`);
+ }
+
+ /* response example
+ data = {
+ Account: {
+ Arn: "arn:aws:organizations::111111111111:account/o-exampleorgid/555555555555",
+ Email: "anika@example.com",
+ Id: "555555555555",
+ Name: "Beta Account"
+ }
+ }
+ */
+ }
+
+ async getCloudFormationService() {
+ const [aws] = await this.mustFindServices(['aws']);
+ const credential = await this.getCredentials();
+ const [requestContext, ExternalId] = await Promise.all([
+ this.payload.object('requestContext'),
+ this.payload.string('externalId'),
+ ]);
+ const accountId = await this.state.string('ACCOUNT_ID');
+ // TODO: pass user customized role name, for now it's fixed as OrganizationAccountAccessRole
+ const RoleArn = `arn:aws:iam::${accountId}:role/OrganizationAccountAccessRole`;
+ const sts = new aws.sdk.STS(credential);
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}-CfnRole`,
+ ExternalId,
+ })
+ .promise();
+
+ return new aws.sdk.CloudFormation({ accessKeyId, secretAccessKey, sessionToken });
+ }
+
+ async getOrganizationService() {
+ const [aws] = await this.mustFindServices(['aws']);
+ const [requestContext, ExternalId, RoleArn] = await Promise.all([
+ this.payload.object('requestContext'),
+ this.payload.string('externalId'),
+ this.payload.string('masterRoleArn'),
+ ]);
+ const sts = new aws.sdk.STS();
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}-OrgRole`,
+ ExternalId,
+ })
+ .promise();
+
+ return new aws.sdk.Organizations({ accessKeyId, secretAccessKey, sessionToken });
+ }
+
+ async getCredentials() {
+ const [aws] = await this.mustFindServices(['aws']);
+ const [requestContext, RoleArn, ExternalId] = await Promise.all([
+ this.payload.object('requestContext'),
+ this.payload.string('masterRoleArn'),
+ this.payload.string('externalId'),
+ ]);
+
+ const sts = new aws.sdk.STS();
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}-OrgRole`,
+ ExternalId,
+ })
+ .promise();
+
+ return { accessKeyId, secretAccessKey, sessionToken };
+ }
+
+ getCfnOutputs(stackInfo) {
+ const details = {};
+ stackInfo.Outputs.forEach(option => {
+ _.set(details, option.OutputKey, option.OutputValue);
+ });
+ return details;
+ }
+
+ async getPolicy(s3Client, s3BucketName) {
+ try {
+ return JSON.parse((await s3Client.getBucketPolicy({ Bucket: s3BucketName }).promise()).Policy);
+ } catch (error) {
+ // if no policy yet, return an empty one
+ if (error.code === 'NoSuchBucketPolicy') {
+ return {
+ Id: 'Policy',
+ Version: '2012-10-17',
+ Statement: [],
+ };
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * Post-Launch Tasks
+ */
+ async updateLocalResourcePolicies() {
+ /**
+ * Update S3 bucket policy and KMS key policy to grant access for newly created account
+ * TODO: Consolidate some of this logic with the associated function in the DeleteEnvironment
+ * workflow step if possible
+ */
+ // Get new account ID
+ const accountId = await this.state.string('ACCOUNT_ID');
+ const remoteAccountArn = `arn:aws:iam::${accountId}:root`;
+ // Get S3 and KMS resource names
+ const s3BucketName = this.settings.get(settingKeys.artifactsBucketName);
+
+ // Setup services and SDK clients
+ const [aws, lockService] = await this.mustFindServices(['aws', 'lockService']);
+ const s3Client = new aws.sdk.S3();
+
+ // Define function to handle updating resource policy principals where the current principals
+ // may be an array or a string
+ const updateAwsPrincipals = (awsPrincipals, newPrincipal) => {
+ if (Array.isArray(awsPrincipals)) {
+ awsPrincipals.push(newPrincipal);
+ } else {
+ awsPrincipals = [awsPrincipals, newPrincipal];
+ }
+ return awsPrincipals;
+ };
+
+ // Perform locked updates to prevent inconsistencies from race conditions
+ const s3LockKey = `s3|bucket-policy|${accountId}`;
+ await Promise.all([
+ // Update S3 bucket policy
+ lockService.tryWriteLockAndRun({ id: s3LockKey }, async () => {
+ // Get existing policy
+ const s3Policy = await this.getPolicy(s3Client, s3BucketName);
+ // Get statements for listing and reading study data, respectively
+ const statements = s3Policy.Statement;
+ const accessSid = `accessId:${accountId}`;
+ // Define default statements to be used if we can't find existing ones
+ let accessStatement = {
+ Sid: accessSid,
+ Effect: 'Allow',
+ Principal: { AWS: [] },
+ Action: ['s3:GetObject', 's3:PutObject', 's3:GetObjectAcl'],
+ Resource: [`arn:aws:s3:::${s3BucketName}/*`], // The previous star-slash confuses some syntax highlighers */
+ };
+
+ // Pull out existing statements if available
+ statements.forEach(statement => {
+ if (statement.Sid === accessSid) {
+ accessStatement = statement;
+ }
+ });
+
+ // Update statement and policy
+ // NOTE: The S3 API *should* remove duplicate principals, if any
+ accessStatement.Principal.AWS = updateAwsPrincipals(accessStatement.Principal.AWS, remoteAccountArn);
+
+ s3Policy.Statement = s3Policy.Statement.filter(statement => ![accessSid].includes(statement.Sid));
+ s3Policy.Statement.push(accessStatement);
+
+ // Update policy
+ await s3Client.putBucketPolicy({ Bucket: s3BucketName, Policy: JSON.stringify(s3Policy) }).promise();
+ }),
+ ]);
+ }
+}
+
+module.exports = ProvisionAccount;
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.yml b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.yml
new file mode 100644
index 0000000000..6c45dee9e1
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.yml
@@ -0,0 +1,8 @@
+id: st-provision-account
+v: 1
+title: Provision Account
+desc: |
+ Execute the creation of an AWS Account
+
+skippable: true # this means that if there is an error in a previous step, then this step will be skipped
+hidden: true
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-environment/provision-environment.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-environment/provision-environment.js
new file mode 100644
index 0000000000..e92576c76a
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-environment/provision-environment.js
@@ -0,0 +1,312 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const StepBase = require('@aws-ee/base-workflow-core/lib/workflow/helpers/step-base');
+
+const STACK_FAILED = [
+ 'CREATE_FAILED',
+ 'ROLLBACK_FAILED',
+ 'DELETE_FAILED',
+ 'UPDATE_ROLLBACK_FAILED',
+ 'ROLLBACK_COMPLETE',
+ 'UPDATE_ROLLBACK_COMPLETE',
+];
+const STACK_SUCCESS = ['CREATE_COMPLETE', 'DELETE_COMPLETE', 'UPDATE_COMPLETE'];
+
+class ProvisionEnvironment extends StepBase {
+ async start() {
+ // start workflow that starts the CFN Template, updates status to PROCESSING, waits for CFN to finish, updates env status to COMPLETED/FAILED
+
+ // Get services
+ const [
+ environmentService,
+ cfnTemplateService,
+ environmentKeypairService,
+ environmentMountService,
+ ] = await this.mustFindServices([
+ 'environmentService',
+ 'cfnTemplateService',
+ 'environmentKeypairService',
+ 'environmentMountService',
+ ]);
+
+ // Get common payload params and pull environment info
+ const [type, environmentId, requestContext, vpcId, vpcSubnet, encryptionKeyArn] = await Promise.all([
+ this.payload.string('type'),
+ this.payload.string('environmentId'),
+ this.payload.object('requestContext'),
+ this.payload.string('vpcId'),
+ this.payload.string('subnetId'),
+ this.payload.string('encryptionKeyArn'),
+ ]);
+ const environment = await environmentService.mustFind(requestContext, { id: environmentId });
+ const by = _.get(requestContext, 'principalIdentifier'); // principalIdentifier shape is { username, ns: user.ns }
+ const stackName = `analysis-${new Date().getTime()}`;
+
+ // Set initial state
+ this.state.setKey('STATE_ENVIRONMENT_ID', environmentId);
+ this.state.setKey('STATE_REQUEST_CONTEXT', requestContext);
+
+ // Define array for collecting CloudFormation functions
+ const cfnParams = [];
+ const addParam = (key, value) => cfnParams.push({ ParameterKey: key, ParameterValue: value });
+
+ // Add parameters unique to each environment type
+ let template;
+ switch (type) {
+ case 'ec2-linux':
+ template = await cfnTemplateService.getTemplate('ec2-linux-instance');
+ break;
+ case 'ec2-windows':
+ template = await cfnTemplateService.getTemplate('ec2-windows-instance');
+ break;
+ case 'sagemaker':
+ template = await cfnTemplateService.getTemplate('sagemaker-notebook-instance');
+ break;
+ case 'emr': {
+ template = await cfnTemplateService.getTemplate('emr-cluster');
+
+ addParam('DiskSizeGB', environment.instanceInfo.config.diskSizeGb.toString());
+ addParam('MasterInstanceType', environment.instanceInfo.size);
+ addParam('WorkerInstanceType', environment.instanceInfo.config.workerInstanceSize);
+ addParam('CoreNodeCount', environment.instanceInfo.config.workerInstanceCount.toString());
+
+ // Add parameters to support spot instance pricing if specified
+ // TODO this needs to be parameterized
+ const isOnDemand = !environment.instanceInfo.config.spotBidPrice;
+ // The spot bid price can only have 3 decimal places maximum
+ const spotBidPrice = isOnDemand ? '0' : environment.instanceInfo.config.spotBidPrice.toFixed(3);
+
+ addParam('Market', isOnDemand ? 'ON_DEMAND' : 'SPOT');
+ addParam('WorkerBidPrice', spotBidPrice);
+
+ this.print(
+ isOnDemand
+ ? 'Launching on demand core nodes'
+ : `Launching spot core nodes with a bid price of ${spotBidPrice}`,
+ );
+
+ break;
+ }
+ default:
+ throw new Error(`Unknown environment type requested: ${type}`);
+ }
+
+ // Handle CFN parameters that need to be excluded from certain environment types
+ if (type !== 'ec2-windows') {
+ const {
+ s3Mounts,
+ iamPolicyDocument,
+ environmentInstanceFiles,
+ s3Prefixes,
+ } = await environmentMountService.getCfnMountParameters(requestContext, environment);
+
+ addParam('S3Mounts', s3Mounts);
+ addParam('IamPolicyDocument', iamPolicyDocument);
+ addParam('EnvironmentInstanceFiles', environmentInstanceFiles);
+
+ // Only save the prefixes for the local resources, otherwise we add list and get access for
+ // potentially the whole study bucket
+ this.state.setKey('ENV_S3_STUDY_PREFIXES', s3Prefixes);
+ }
+
+ if (type !== 'sagemaker') {
+ const credential = await this.getCredentials();
+ const [amiImage, cidr, keyName] = await Promise.all([
+ this.payload.string('amiImage'),
+ this.payload.string('cidr'),
+ environmentKeypairService.create(requestContext, environmentId, credential),
+ ]);
+
+ addParam('AmiId', amiImage);
+ addParam('AccessFromCIDRBlock', cidr);
+ addParam('KeyName', keyName);
+ }
+
+ if (type !== 'emr') {
+ addParam('InstanceType', environment.instanceInfo.size);
+ }
+
+ // Add rest of parameters
+ addParam('Namespace', stackName);
+ addParam('VPC', vpcId);
+ addParam('Subnet', vpcSubnet);
+ addParam('EncryptionKeyArn', encryptionKeyArn);
+
+ const input = {
+ StackName: stackName,
+ Parameters: cfnParams,
+ Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'],
+ TemplateBody: template,
+ Tags: [
+ {
+ Key: 'Description',
+ Value: `Created by ${by.username}`,
+ },
+ {
+ Key: 'Env',
+ Value: environmentId,
+ },
+ {
+ Key: 'Proj',
+ Value: environment.indexId,
+ },
+ {
+ Key: 'CreatedBy',
+ Value: by.username,
+ },
+ ],
+ };
+
+ // Create stack
+ const cfn = await this.getCloudFormationService();
+ const response = await cfn.createStack(input).promise();
+
+ // Update workflow state and poll for stack creation completion
+ this.state.setKey('STATE_STACK_ID', response.StackId);
+ await this.updateEnvironment({ stackId: response.StackId });
+ return this.wait(20)
+ .maxAttempts(120)
+ .until('checkCfnCompleted');
+ }
+
+ async getCloudFormationService() {
+ const [aws] = await this.mustFindServices(['aws']);
+ const [requestContext, RoleArn, ExternalId] = await Promise.all([
+ this.payload.object('requestContext'),
+ this.payload.string('cfnExecutionRole'),
+ this.payload.string('roleExternalId'),
+ ]);
+
+ const sts = new aws.sdk.STS();
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}`,
+ ExternalId,
+ })
+ .promise();
+
+ return new aws.sdk.CloudFormation({ accessKeyId, secretAccessKey, sessionToken });
+ }
+
+ async getCredentials() {
+ const [aws] = await this.mustFindServices(['aws']);
+ const [requestContext, RoleArn, ExternalId] = await Promise.all([
+ this.payload.object('requestContext'),
+ this.payload.string('cfnExecutionRole'),
+ this.payload.string('roleExternalId'),
+ ]);
+
+ const sts = new aws.sdk.STS();
+ const {
+ Credentials: { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, SessionToken: sessionToken },
+ } = await sts
+ .assumeRole({
+ RoleArn,
+ RoleSessionName: `RaaS-${requestContext.principalIdentifier.username}-OrgRole`,
+ ExternalId,
+ })
+ .promise();
+
+ return { accessKeyId, secretAccessKey, sessionToken };
+ }
+
+ /**
+ * CloudFormation and Workflow-Related Methods
+ */
+ async checkCfnCompleted() {
+ const environmentMountService = await this.mustFindServices('environmentMountService');
+ const stackId = await this.state.string('STATE_STACK_ID');
+ this.print(`checking status of cfn stack: ${stackId}`);
+
+ // const cfnTemplateService = await this.mustFindServices('')
+ const cfn = await this.getCloudFormationService();
+ const stackInfo = (await cfn.describeStacks({ StackName: stackId }).promise()).Stacks[0];
+
+ if (STACK_FAILED.includes(stackInfo.StackStatus)) {
+ throw new Error(`Stack operation failed with message: ${stackInfo.StackStatusReason}`);
+ } else if (STACK_SUCCESS.includes(stackInfo.StackStatus)) {
+ // handle the case where the cloudformation is deleted before the creation could finish
+ if (stackInfo.StackStatus !== 'DELETE_COMPLETE') {
+ const cfnOutputs = this.getCfnOutputs(stackInfo);
+
+ // Update S3 and KMS resources if needed
+ const s3Prefixes = await this.state.optionalArray('ENV_S3_STUDY_PREFIXES');
+ if (s3Prefixes.length > 0) {
+ await environmentMountService.addRoleArnToLocalResourcePolicies(
+ cfnOutputs.WorkspaceInstanceRoleArn,
+ s3Prefixes,
+ );
+ }
+
+ // Update environment metadata
+ await this.updateEnvironment({
+ status: 'COMPLETED',
+ instanceInfo: {
+ ...cfnOutputs,
+ s3Prefixes,
+ },
+ });
+ }
+ return true;
+ } // else CFN is still pending
+ return false;
+ }
+
+ getCfnOutputs(stackInfo) {
+ const details = {};
+ stackInfo.Outputs.forEach(option => {
+ _.set(details, option.OutputKey, option.OutputValue);
+ });
+ return details;
+ }
+
+ async updateEnvironment(environment) {
+ const environmentService = await this.mustFindServices('environmentService');
+ const id = await this.state.string('STATE_ENVIRONMENT_ID');
+ const requestContext = await this.state.optionalObject('STATE_REQUEST_CONTEXT');
+ environment.id = id;
+ await environmentService.update(requestContext, environment);
+ }
+
+ async onFail() {
+ const error = await this.getDeploymentError();
+ await this.updateEnvironment({ status: 'FAILED', error });
+ }
+
+ async getDeploymentError() {
+ const id = await this.state.string('STATE_ENVIRONMENT_ID');
+ const requestContext = await this.state.optionalObject('STATE_REQUEST_CONTEXT');
+
+ const environmentService = await this.mustFindServices('environmentService');
+ const existingEnvironment = await environmentService.mustFind(requestContext, { id });
+
+ const { stackId } = existingEnvironment;
+ const cfn = await this.getCloudFormationService();
+
+ const events = await cfn.describeStackEvents({ StackName: stackId }).promise();
+ const failReasons = events.StackEvents.filter(e => STACK_FAILED.includes(e.ResourceStatus)).map(
+ e => e.ResourceStatusReason || '',
+ );
+
+ return failReasons.join(' ');
+ }
+}
+
+module.exports = ProvisionEnvironment;
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-environment/provision-environment.yml b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-environment/provision-environment.yml
new file mode 100644
index 0000000000..557e584b05
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-environment/provision-environment.yml
@@ -0,0 +1,8 @@
+id: st-provision-environment
+v: 1
+title: Provision Environment
+desc: |
+ Provision a new environment
+
+skippable: true # this means that if there is an error in a previous step, then this step will be skipped
+hidden: true
diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/package.json b/addons/addon-base-raas/packages/base-raas-workflow-steps/package.json
new file mode 100644
index 0000000000..507027cdfe
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@aws-ee/base-raas-workflow-steps",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A collection of base RaaS workflow steps",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-workflow-core": "workspace:*",
+ "lodash": "^4.17.15",
+ "shortid": "^2.2.15",
+ "slugify": "^1.4.0"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb": "^18.1.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "pretty-quick": "^1.11.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/.eslintrc.json b/addons/addon-base-raas/packages/base-raas-workflows/.eslintrc.json
new file mode 100644
index 0000000000..c700cf03a8
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/.eslintrc.json
@@ -0,0 +1,26 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0,
+ "import/no-unresolved": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/.gitignore b/addons/addon-base-raas/packages/base-raas-workflows/.gitignore
new file mode 100644
index 0000000000..659959de8f
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/.prettierrc.json b/addons/addon-base-raas/packages/base-raas-workflows/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/jest.config.js b/addons/addon-base-raas/packages/base-raas-workflows/jest.config.js
new file mode 100644
index 0000000000..70544476d7
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/jest.config.js
@@ -0,0 +1,22 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+};
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/jsconfig.json b/addons/addon-base-raas/packages/base-raas-workflows/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/lib/plugins/workflows-plugin.js b/addons/addon-base-raas/packages/base-raas-workflows/lib/plugins/workflows-plugin.js
new file mode 100644
index 0000000000..713de528aa
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/lib/plugins/workflows-plugin.js
@@ -0,0 +1,32 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const createEnvironmentYaml = require('../workflows/create-environment.yml');
+const deleteEnvironmentYaml = require('../workflows/delete-environment.yml');
+const provisionAccountYaml = require('../workflows/provision-account.yml');
+
+const add = yaml => ({ yaml });
+
+// The order is important, add your templates here
+const workflows = [add(createEnvironmentYaml), add(deleteEnvironmentYaml), add(provisionAccountYaml)];
+
+async function registerWorkflows(registry) {
+ // eslint-disable-next-line no-restricted-syntax
+ for (const workflow of workflows) {
+ await registry.add(workflow); // eslint-disable-line no-await-in-loop
+ }
+}
+
+module.exports = { registerWorkflows };
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/create-environment.yml b/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/create-environment.yml
new file mode 100644
index 0000000000..f1b57793fa
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/create-environment.yml
@@ -0,0 +1,17 @@
+id: wf-create-environment
+v: 1
+workflowTemplateId: wt-empty
+workflowTemplateVer: 1
+title: Create New Environment
+desc: |
+ Create new environment
+hidden: false
+builtin: true
+instanceTtl: 30 # In days, however, empty value means that it is indefinite
+selectedSteps:
+ - stepTemplateId: st-provision-environment
+ stepTemplateVer: 1
+ id: wf-step_1_1576783029739_20 # Randomly generated id, no need to change it
+ # configs: # add configuration key/value pairs (if needed)
+ # key1: value1
+ # key2: value2
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/delete-environment.yml b/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/delete-environment.yml
new file mode 100644
index 0000000000..07b67dcafc
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/delete-environment.yml
@@ -0,0 +1,17 @@
+id: wf-delete-environment
+v: 1
+workflowTemplateId: wt-empty
+workflowTemplateVer: 1
+title: Delete an Environment
+desc: |
+ Delete an environment
+hidden: false
+builtin: true
+instanceTtl: 30 # In days, however, empty value means that it is indefinite
+selectedSteps:
+ - stepTemplateId: st-delete-environment
+ stepTemplateVer: 1
+ id: wf-step_1_1576786737060_55 # Randomly generated id, no need to change it
+ # configs: # add configuration key/value pairs (if needed)
+ # key1: value1
+ # key2: value2
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/provision-account.yml b/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/provision-account.yml
new file mode 100644
index 0000000000..54b8ba519a
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/lib/workflows/provision-account.yml
@@ -0,0 +1,17 @@
+id: wf-provision-account
+v: 1
+workflowTemplateId: wt-empty
+workflowTemplateVer: 1
+title: Provision Account
+desc: |
+ Provision Amazon Web Service Account
+hidden: false
+builtin: true
+instanceTtl: 30 # In days, however, empty value means that it is indefinite
+selectedSteps:
+ - stepTemplateId: st-provision-account
+ stepTemplateVer: 1
+ id: wf-step_1_1574277701354_97 # Randomly generated id, no need to change it
+ # configs: # add configuration key/value pairs (if needed)
+ # key1: value1
+ # key2: value2
diff --git a/addons/addon-base-raas/packages/base-raas-workflows/package.json b/addons/addon-base-raas/packages/base-raas-workflows/package.json
new file mode 100644
index 0000000000..4c9f42bfde
--- /dev/null
+++ b/addons/addon-base-raas/packages/base-raas-workflows/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@aws-ee/base-raas-workflows",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A collection of base RaaS workflows",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-workflow-core": "workspace:*",
+ "lodash": "^4.17.15",
+ "shortid": "^2.2.15",
+ "slugify": "^1.4.0"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb": "^18.1.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "pretty-quick": "^1.11.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-raas/packages/serverless-packer/.eslintrc.json b/addons/addon-base-raas/packages/serverless-packer/.eslintrc.json
new file mode 100644
index 0000000000..a3d178c4c9
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/.eslintrc.json
@@ -0,0 +1,22 @@
+{
+ "extends": ["airbnb-base", "prettier"],
+ "plugins": ["prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ }
+}
diff --git a/addons/addon-base-raas/packages/serverless-packer/.gitignore b/addons/addon-base-raas/packages/serverless-packer/.gitignore
new file mode 100644
index 0000000000..285328f831
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+/coverage/
+.build
diff --git a/addons/addon-base-raas/packages/serverless-packer/.prettierrc.json b/addons/addon-base-raas/packages/serverless-packer/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-raas/packages/serverless-packer/README.md b/addons/addon-base-raas/packages/serverless-packer/README.md
new file mode 100644
index 0000000000..da04628f93
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/README.md
@@ -0,0 +1,18 @@
+## Prerequisites
+
+#### Tools
+
+- Node 12
+- [Hashicorp Packer](https://www.packer.io/)
+
+#### Project variables
+
+All variables in the settings will be added as -var parameters into the packer build command
+
+## Usage
+
+When installed as a [Serverless plugin](https://serverless.com/framework/docs/providers/aws/guide/plugins/), this provides the following CLI commands:
+
+### `pnpx sls build-image -s [--file]`
+
+By convention, this looks in the `./config/infra` directory for a json file that starts with packer. This file is then used by packer to build the AMI.
diff --git a/addons/addon-base-raas/packages/serverless-packer/index.js b/addons/addon-base-raas/packages/serverless-packer/index.js
new file mode 100644
index 0000000000..24ea4c05f1
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/index.js
@@ -0,0 +1,117 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const fs = require('fs');
+const _ = require('lodash');
+const { runCommand } = require('./lib/utils/command.js');
+
+const PACKER_FILE_DIR = './config/infra';
+
+class ServerlessPackerPlugin {
+ constructor(serverless, options) {
+ this.serverless = serverless;
+ this.options = options;
+
+ this.commands = {
+ 'build-image': {
+ usage: 'Build an AMI using packer',
+ lifecycleEvents: ['build'],
+ options: {
+ file: {
+ usage:
+ 'Override the packer file used to build the AMI' +
+ '(e.g. "--file \'packer.json\'" or "-m \'packer.json\'")',
+ required: false,
+ shortcut: 'f',
+ },
+ },
+ },
+ };
+
+ this.hooks = {
+ 'build-image:build': this.buildImages.bind(this),
+ };
+ }
+
+ async buildImages() {
+ let filePaths;
+ if (this.options.files) {
+ // Parse files passed via CLI arg
+ filePaths = this.options.files.split(',');
+ } else {
+ // Look for files in default location
+ filePaths = await this.getPackerFiles();
+ }
+
+ this.serverless.cli.log(`Building packer images: ${filePaths.join(', ')}`);
+ return Promise.all(
+ filePaths.map(async filePath => {
+ this.serverless.cli.log(`${filePath}: Building packer image`);
+
+ const args = _.concat('build', this.packageVarArgs(), `${PACKER_FILE_DIR}/${filePath}`);
+
+ try {
+ await runCommand({
+ command: 'packer',
+ args,
+ stdout: {
+ log: this.serverless.cli.consoleLog,
+ raw: msg => {
+ this.serverless.cli.log(`${filePath}: ${msg}`);
+ },
+ },
+ });
+ } catch (err) {
+ throw new Error(`${filePath}: Error running packer build command: ${err}`);
+ }
+
+ this.serverless.cli.log(`${filePath}: Finished packer image`);
+ }),
+ );
+ }
+
+ async getPackerFiles() {
+ return new Promise((resolve, reject) => {
+ fs.readdir(PACKER_FILE_DIR, (err, files) => {
+ if (err || !files) {
+ return reject(new Error('Missing config/infra directory.'));
+ }
+
+ const packerFiles = [];
+ files.forEach(file => {
+ if (file.match('packer.*.json')) {
+ packerFiles.push(file);
+ }
+ });
+ if (packerFiles.length > 0) {
+ return resolve(packerFiles);
+ }
+
+ return reject(new Error('No packer file found'));
+ });
+ });
+ }
+
+ packageVarArgs() {
+ const varArgs = [];
+ _.forEach(this.serverless.service.custom.settings, (value, key) => {
+ varArgs.push('-var');
+ varArgs.push(`${key}=${value}`);
+ });
+ return varArgs;
+ }
+}
+
+module.exports = ServerlessPackerPlugin;
diff --git a/addons/addon-base-raas/packages/serverless-packer/jest.config.js b/addons/addon-base-raas/packages/serverless-packer/jest.config.js
new file mode 100644
index 0000000000..3f7ffc8068
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-raas/packages/serverless-packer/lib/utils/command.js b/addons/addon-base-raas/packages/serverless-packer/lib/utils/command.js
new file mode 100644
index 0000000000..d0b1e91ad6
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/lib/utils/command.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+/**
+ * Helpers for executing an external command.
+ * */
+const _ = require('lodash');
+const chalk = require('chalk');
+const spawn = require('cross-spawn');
+
+const runCommand = ({ command, args, successCodes = [0], cwd, stdout }) => {
+ const child = spawn(command, args, { stdio: 'pipe', cwd });
+ stdout.log(`${chalk.bgGreen('>>')} ${command} ${args.join(' ')}`);
+ return new Promise((resolve, reject) => {
+ // we are using _.once() because the error and exit events might be fired one after the other
+ // see https://nodejs.org/api/child_process.html#child_process_event_error
+ const rejectOnce = _.once(reject);
+ const resolveOnce = _.once(resolve);
+ const errors = [];
+
+ child.stdout.on('data', data => {
+ stdout.raw(data.toString().trim());
+ });
+
+ child.stderr.on('data', data => {
+ errors.push(data.toString().trim());
+ });
+
+ child.on('exit', code => {
+ if (successCodes.includes(code)) {
+ callLater(resolveOnce);
+ } else {
+ callLater(rejectOnce, new Error(`process exited with code ${code}: ${errors.join('\n')}`));
+ }
+ });
+ child.on('error', () => callLater(rejectOnce, new Error('Failed to start child process.')));
+ });
+};
+
+// to help avoid unleashing Zalgo see http://blog.izs.me/post/59142742143/designing-apis-for-asynchrony
+const callLater = (callback, ...args) => {
+ setImmediate(() => {
+ callback(...args);
+ });
+};
+
+module.exports = { runCommand };
diff --git a/addons/addon-base-raas/packages/serverless-packer/package.json b/addons/addon-base-raas/packages/serverless-packer/package.json
new file mode 100644
index 0000000000..df4180c049
--- /dev/null
+++ b/addons/addon-base-raas/packages/serverless-packer/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@aws-ee/serverless-packer",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A serverless framework plugin to help with using packer",
+ "main": "index.js",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "chalk": "^2.4.2",
+ "cross-spawn": "^6.0.5",
+ "lodash": "^4.17.15"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.1.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true",
+ "format": "pnpm run format:eslint; yarn run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . || true",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true"
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/.eslintrc.json b/addons/addon-base-rest-api/packages/api-handler-factory/.eslintrc.json
new file mode 100644
index 0000000000..a9e56eda24
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/.gitignore b/addons/addon-base-rest-api/packages/api-handler-factory/.gitignore
new file mode 100644
index 0000000000..659959de8f
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/.prettierrc.json b/addons/addon-base-rest-api/packages/api-handler-factory/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/jest.config.js b/addons/addon-base-rest-api/packages/api-handler-factory/jest.config.js
new file mode 100644
index 0000000000..3f7ffc8068
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/jsconfig.json b/addons/addon-base-rest-api/packages/api-handler-factory/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/lib/app-context.js b/addons/addon-base-rest-api/packages/api-handler-factory/lib/app-context.js
new file mode 100644
index 0000000000..85c51194d3
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/lib/app-context.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const express = require('express');
+const Boom = require('@aws-ee/base-services-container/lib/boom');
+
+class AppContext {
+ constructor({ app, settings, log, servicesContainer }) {
+ this.app = app;
+ this.settings = settings;
+ this.log = log;
+ this.container = servicesContainer;
+ this.boom = new Boom();
+ }
+
+ async service(nameOrNames) {
+ const result = [];
+ /* eslint-disable no-restricted-syntax, no-await-in-loop */
+ for (const name of _.concat(nameOrNames)) {
+ const service = await this.container.find(name);
+ if (!service) throw new Error(`The "${name}" service is not available.`);
+ result.push(service);
+ }
+ /* eslint-enable no-restricted-syntax, no-await-in-loop */
+
+ if (!_.isArray(nameOrNames)) return _.head(result);
+ return result;
+ }
+
+ async optionalService(nameOrNames) {
+ const result = [];
+ /* eslint-disable no-restricted-syntax, no-await-in-loop */
+ for (const name of _.concat(nameOrNames)) {
+ const service = await this.container.find(name);
+ result.push(service);
+ }
+ /* eslint-enable no-restricted-syntax, no-await-in-loop */
+
+ if (!_.isArray(nameOrNames)) return _.head(result);
+ return result;
+ }
+
+ wrap(fn) {
+ return async (req, res, next) => {
+ try {
+ await fn(req, res, next);
+ } catch (err) {
+ next(err);
+ }
+ };
+ }
+
+ router() {
+ return express.Router();
+ }
+}
+
+module.exports = AppContext;
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/lib/error-handler.js b/addons/addon-base-rest-api/packages/api-handler-factory/lib/error-handler.js
new file mode 100644
index 0000000000..4f78853906
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/lib/error-handler.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+
+const logError = console.error; // eslint-disable-line no-console
+
+module.exports = () => (err, req, res, next) => {
+ if (!_.isError(err)) {
+ next();
+ return;
+ }
+
+ const httpStatus = _.get(err, 'status', 500);
+
+ // see https://github.com/dougmoscrop/serverless-http/blob/master/docs/ADVANCED.md
+ const requestId = _.get(req, 'x-request-id', '');
+ const code = _.get(err, 'code', 'UNKNOWN');
+ const root = _.get(err, 'root');
+ const safe = _.get(err, 'safe', false);
+
+ if (httpStatus >= 500) {
+ // we print the error only if it is an internal server error
+ if (root) logError(root);
+ logError(err);
+ }
+ const errorMessage = err.message;
+
+ const responseBody = {
+ requestId,
+ code,
+ // if there is error message and if it is safe to include then include it in http response
+ message: safe && errorMessage ? errorMessage : 'Something went wrong',
+ };
+ const payload = err.payload;
+ // if there is error payload object and if it is safe to include then include it in http response
+ if (safe && payload) {
+ responseBody.payload = payload;
+ }
+
+ res.set('X-Request-Id-2', requestId);
+ res.status(httpStatus).json(responseBody);
+};
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/lib/handler.js b/addons/addon-base-rest-api/packages/api-handler-factory/lib/handler.js
new file mode 100644
index 0000000000..2893f6979c
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/lib/handler.js
@@ -0,0 +1,108 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const express = require('express');
+const serverless = require('serverless-http');
+const compression = require('compression');
+const bodyParser = require('body-parser');
+const cors = require('cors');
+const ServicesContainer = require('@aws-ee/base-services-container/lib/services-container');
+
+const errorHandler = require('./error-handler');
+const AppContext = require('./app-context');
+
+let cachedHandler;
+
+// registerServices = fn (required)
+// registerRoutes = fn (required)
+function handlerFactory({ registerServices, registerRoutes }) {
+ return async (event, context) => {
+ if (cachedHandler) return cachedHandler(event, context);
+
+ const apiRouter = express.Router({ mergeParams: true });
+ const app = express();
+ app.disable('x-powered-by');
+
+ // register services
+ const servicesContainer = new ServicesContainer(['settings', 'log']);
+ await registerServices(servicesContainer);
+ await servicesContainer.initServices();
+
+ // check circular dependencies
+ const servicesList = servicesContainer.validate();
+
+ // resolve settings and log services
+ const logger = await servicesContainer.find('log');
+ const settingsService = await servicesContainer.find('settings');
+
+ // create app context
+ const appContext = new AppContext({ app, settings: settingsService, log: logger, servicesContainer });
+
+ // register routes
+ await registerRoutes(appContext, apiRouter);
+
+ // setup CORS, compression and body parser
+ const isDev = settingsService.get('envType') === 'dev';
+ let whitelist = settingsService.optionalObject('corsWhitelist', []);
+ if (isDev) whitelist = _.concat(whitelist, settingsService.optionalObject('corsWhitelistLocal', []));
+ const corsOptions = {
+ origin: (origin, callback) => {
+ if (whitelist.indexOf(origin) !== -1) {
+ callback(null, true);
+ } else {
+ callback(null, false);
+ }
+ },
+ optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
+ };
+
+ app.use(compression());
+ app.use(cors(corsOptions));
+ app.use(bodyParser.json({ limit: '50mb' })); // see https://stackoverflow.com/questions/19917401/error-request-entity-too-large
+ app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); // for parsing application/x-www-form-urlencoded
+
+ // mount all routes under /
+ app.use('/', apiRouter);
+
+ // add global error handler
+ app.use(errorHandler());
+
+ // allow options for all
+ app.options('*');
+
+ // prepare the handler
+ cachedHandler = serverless(app, {
+ callbackWaitsForEmptyEventLoop: true,
+ request(req, { requestContext = {} }) {
+ // expose the lambda event request context
+ req.context = requestContext;
+ },
+ });
+
+ // print useful information
+ const settingsList = settingsService.entries;
+
+ logger.info('Settings available are :');
+ logger.info(settingsList);
+
+ logger.info('Services available are :');
+ logger.info(servicesList);
+
+ return cachedHandler(event, context);
+ };
+}
+
+module.exports = handlerFactory;
diff --git a/addons/addon-base-rest-api/packages/api-handler-factory/package.json b/addons/addon-base-rest-api/packages/api-handler-factory/package.json
new file mode 100644
index 0000000000..9c141d67ae
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/api-handler-factory/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@aws-ee/base-api-handler-factory",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A library to help prepare the lambda handler function",
+ "author": "aws-ee",
+ "main": "lib/handler",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-services-container": "workspace:*",
+ "body-parser": "^1.19.0",
+ "compression": "^1.7.4",
+ "cors": "^2.8.5",
+ "express": "^4.17.1",
+ "lodash": "^4.17.15",
+ "serverless-http": "^2.3.1"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.0.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "pretty-quick": "^1.11.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --ignore-path .gitignore . ",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' ",
+ "format": "pnpm run format:eslint && pnpm run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . ",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' "
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/.eslintrc.json b/addons/addon-base-rest-api/packages/base-api-handler/.eslintrc.json
new file mode 100644
index 0000000000..a9e56eda24
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/.gitignore b/addons/addon-base-rest-api/packages/base-api-handler/.gitignore
new file mode 100644
index 0000000000..f15d856fe2
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/.gitignore
@@ -0,0 +1,22 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# Webpack generated directories
+.webpack
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# IntelliJ Idea Module Files
+*.iml
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/.prettierrc.json b/addons/addon-base-rest-api/packages/base-api-handler/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/jest.config.js b/addons/addon-base-rest-api/packages/base-api-handler/jest.config.js
new file mode 100644
index 0000000000..3f7ffc8068
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/jsconfig.json b/addons/addon-base-rest-api/packages/base-api-handler/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/lib/plugins/services-plugin.js b/addons/addon-base-rest-api/packages/base-api-handler/lib/plugins/services-plugin.js
new file mode 100644
index 0000000000..dcfd14864b
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/lib/plugins/services-plugin.js
@@ -0,0 +1,115 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const AwsService = require('@aws-ee/base-services/lib/aws/aws-service');
+const DbService = require('@aws-ee/base-services/lib/db-service');
+const DbAuthenticationService = require('@aws-ee/base-api-services/lib/db-authentication-service');
+const JsonSchemaValidationService = require('@aws-ee/base-services/lib/json-schema-validation-service');
+const InputManifestValidationService = require('@aws-ee/base-services/lib/input-manifest/input-manifest-validation-service');
+const S3Service = require('@aws-ee/base-services/lib/s3-service');
+const LockService = require('@aws-ee/base-services/lib/lock/lock-service');
+const PluginRegistryService = require('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service');
+const AuditWriterService = require('@aws-ee/base-services/lib/audit/audit-writer-service');
+const AuthorizationService = require('@aws-ee/base-services/lib/authorization/authorization-service');
+const UserAuthzService = require('@aws-ee/base-services/lib/user/user-authz-service');
+const UserService = require('@aws-ee/base-services/lib/user/user-service');
+const AuthenticationProviderConfigService = require('@aws-ee/base-api-services/lib/authentication-providers/authentication-provider-config-service');
+const AuthenticationProviderTypeService = require('@aws-ee/base-api-services/lib/authentication-providers/authentication-provider-type-service');
+const DbPasswordService = require('@aws-ee/base-services/lib/db-password/db-password-service');
+const JwtService = require('@aws-ee/base-api-services/lib/jwt-service');
+const registerBuiltInAuthProviders = require('@aws-ee/base-api-services/lib/authentication-providers/register-built-in-provider-services');
+const registerBuiltInAuthProvisioners = require('@aws-ee/base-api-services/lib/authentication-providers/register-built-in-provisioner-services');
+const ApiKeyService = require('@aws-ee/base-api-services/lib/authentication-providers/built-in-providers/internal/api-key-service');
+const TokenRevocationService = require('@aws-ee/base-api-services/lib/token-revocation-service');
+
+const settingKeys = {
+ tablePrefix: 'dbTablePrefix',
+};
+
+/**
+ * A function that registers base services required by the base addon for api handler lambda function
+ * @param container An instance of ServicesContainer to register services to
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise}
+ */
+async function registerServices(container, pluginRegistry) {
+ container.register('aws', new AwsService(), { lazy: false });
+
+ container.register('authenticationProviderConfigService', new AuthenticationProviderConfigService());
+ container.register('authenticationProviderTypeService', new AuthenticationProviderTypeService());
+ // Register all the built in authentication providers supported by the base out of the box
+ registerBuiltInAuthProviders(container);
+ registerBuiltInAuthProvisioners(container);
+
+ container.register('dbService', new DbService(), { lazy: false });
+ container.register('dbAuthenticationService', new DbAuthenticationService());
+ container.register('dbPasswordService', new DbPasswordService());
+ container.register('jsonSchemaValidationService', new JsonSchemaValidationService());
+ container.register('inputManifestValidationService', new InputManifestValidationService());
+ container.register('jwtService', new JwtService());
+ container.register('userService', new UserService());
+ container.register('s3Service', new S3Service());
+ container.register('lockService', new LockService());
+ container.register('apiKeyService', new ApiKeyService());
+ container.register('tokenRevocationService', new TokenRevocationService());
+ container.register('auditWriterService', new AuditWriterService());
+ container.register('pluginRegistryService', new PluginRegistryService(pluginRegistry), { lazy: false });
+
+ // Authorization Services from base addon
+ container.register('authorizationService', new AuthorizationService());
+ container.register('userAuthzService', new UserAuthzService());
+}
+
+/**
+ * A function that registers base static settings required by the base addon for api handler lambda function
+ * @param existingStaticSettings An existing static settings plain javascript object containing settings as key/value contributed by other plugins
+ * @param settings Default instance of settings service that resolves settings from environment variables
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point
+ *
+ * @returns {Promise<*>} A promise that resolves to static settings object
+ */
+// eslint-disable-next-line no-unused-vars
+function getStaticSettings(existingStaticSettings, settings, pluginRegistry) {
+ const staticSettings = {
+ ...existingStaticSettings,
+ };
+
+ // Register all dynamodb table names used by the base rest api addon
+ const tablePrefix = settings.get(settingKeys.tablePrefix);
+ const table = (key, suffix) => {
+ staticSettings[key] = `${tablePrefix}-${suffix}`;
+ };
+ table('dbTableAuthenticationProviderTypes', 'DbAuthenticationProviderTypes');
+ table('dbTableAuthenticationProviderConfigs', 'DbAuthenticationProviderConfigs');
+ table('dbTablePasswords', 'DbPasswords');
+ table('dbTableUserApiKeys', 'DbUserApiKeys');
+ table('dbTableRevokedTokens', 'DbRevokedTokens');
+ table('dbTableUsers', 'DbUsers');
+ table('dbTableLocks', 'DbLocks');
+
+ return staticSettings;
+}
+
+const plugin = {
+ getStaticSettings,
+ // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient
+ // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerSettingsService, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerLoggerService, // not implemented, the default behavior provided by addon-base is sufficient
+ registerServices,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/lib/routes-registration-util.js b/addons/addon-base-rest-api/packages/base-api-handler/lib/routes-registration-util.js
new file mode 100644
index 0000000000..222566fa38
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/lib/routes-registration-util.js
@@ -0,0 +1,74 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+
+/**
+ * Configures the given express router by collecting routes contributed by all route plugins.
+ * @param {*} context An instance of AppContext from api-handler-factory
+ * @param {*} router Top level Express router
+ * @param {getPlugins} pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ * Each 'route' plugin in the returned array is an object containing "getRoutes" method.
+ *
+ * @returns {Promise}
+ */
+async function registerRoutes(context, router, pluginRegistry) {
+ // Get all routes plugins from the routes plugin registry
+ // Each plugin is an object containing "getRoutes" method
+ const plugins = await pluginRegistry.getPlugins('route');
+
+ const initialRoutes = new Map();
+ // Ask each plugin to return their routes. Each plugin is passed a Map containing the routes collected so
+ // far from other plugins. The plugins are called in the same order as returned by the registry.
+ // Each plugin gets a chance to add, remove, update, or delete routes by mutating the provided routesMap object.
+ // This routesMap is a Map that has route paths as keys and an array of functions that configure the router as value.
+ // Each function in the array is expected have the following signature. The function accepts context and router
+ // arguments and returns a configured router.
+ //
+ // (context, router) => configured router
+ //
+ const routesMap = await _.reduce(
+ plugins,
+ async (routesSoFarPromise, plugin) => plugin.getRoutes(await routesSoFarPromise, pluginRegistry),
+ Promise.resolve(initialRoutes),
+ );
+
+ const configuredRoutes = [];
+ const entries = Array.from(routesMap || new Map());
+ // Register routes to the parent level express "router" and call each function from the routes configuration
+ // functions to give them a chance to configure their routes by either adding routes directly to the parent router
+ // or by returning a child router
+ for (let i = 0; i < entries.length; i += 1) {
+ const entry = entries[i];
+ const [routePath, routerConfigurers] = entry;
+ for (let j = 0; j < routerConfigurers.length; j += 1) {
+ const configurerFn = routerConfigurers[j];
+ // Need to await configurerFn in sequence so awaiting in loop
+ // eslint-disable-next-line no-await-in-loop
+ const childRouter = await configurerFn(context, router);
+ // The router configurer function may create a child router.
+ // In that case, configure the child router on the parent router.
+ // If the function does not return a router then assume it configured
+ // the route directly on the parent router given to it
+ if (childRouter) {
+ router.use(routePath, childRouter);
+ }
+ configuredRoutes.push(routePath);
+ }
+ }
+ return configuredRoutes;
+}
+
+module.exports = { registerRoutes };
diff --git a/addons/addon-base-rest-api/packages/base-api-handler/package.json b/addons/addon-base-rest-api/packages/base-api-handler/package.json
new file mode 100644
index 0000000000..768842ea4d
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-api-handler/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@aws-ee/base-api-handler",
+ "version": "1.0.0",
+ "private": true,
+ "description": "A library containing some utilities to be used for an api-handler lambda function based on addons",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-api-services": "workspace:*",
+ "@aws-ee/base-services": "workspace:*",
+ "@aws-ee/base-services-container": "workspace:*",
+ "aws-sdk": "^2.647.0",
+ "generate-password": "^1.5.0",
+ "lodash": "^4.17.15"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.0.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --ignore-path .gitignore . ",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' ",
+ "format": "pnpm run format:eslint && pnpm run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . ",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' "
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/.eslintrc.json b/addons/addon-base-rest-api/packages/base-authn-handler/.eslintrc.json
new file mode 100644
index 0000000000..a9e56eda24
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/.gitignore b/addons/addon-base-rest-api/packages/base-authn-handler/.gitignore
new file mode 100644
index 0000000000..f15d856fe2
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/.gitignore
@@ -0,0 +1,22 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# Webpack generated directories
+.webpack
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# IntelliJ Idea Module Files
+*.iml
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/.prettierrc.json b/addons/addon-base-rest-api/packages/base-authn-handler/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/jest.config.js b/addons/addon-base-rest-api/packages/base-authn-handler/jest.config.js
new file mode 100644
index 0000000000..3f7ffc8068
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/jsconfig.json b/addons/addon-base-rest-api/packages/base-authn-handler/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/lib/plugins/services-plugin.js b/addons/addon-base-rest-api/packages/base-authn-handler/lib/plugins/services-plugin.js
new file mode 100644
index 0000000000..de6f88e7f2
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/lib/plugins/services-plugin.js
@@ -0,0 +1,112 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const AwsService = require('@aws-ee/base-services/lib/aws/aws-service');
+const DbService = require('@aws-ee/base-services/lib/db-service');
+const S3Service = require('@aws-ee/base-services/lib/s3-service');
+const JsonSchemaValidationService = require('@aws-ee/base-services/lib/json-schema-validation-service');
+const PluginRegistryService = require('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service');
+const AuditWriterService = require('@aws-ee/base-services/lib/audit/audit-writer-service');
+const AuthorizationService = require('@aws-ee/base-services/lib/authorization/authorization-service');
+const UserAuthzService = require('@aws-ee/base-services/lib/user/user-authz-service');
+const UserService = require('@aws-ee/base-services/lib/user/user-service');
+const AuthenticationService = require('@aws-ee/base-api-services/lib/authentication-service');
+const AuthenticationProviderConfigService = require('@aws-ee/base-api-services/lib/authentication-providers/authentication-provider-config-service');
+const AuthenticationProviderTypeService = require('@aws-ee/base-api-services/lib/authentication-providers/authentication-provider-type-service');
+const DbAuthenticationService = require('@aws-ee/base-api-services/lib/db-authentication-service');
+const DbPasswordService = require('@aws-ee/base-services/lib/db-password/db-password-service');
+const JwtService = require('@aws-ee/base-api-services/lib/jwt-service');
+const TokenRevocationService = require('@aws-ee/base-api-services/lib/token-revocation-service');
+const registerBuiltInAuthProviders = require('@aws-ee/base-api-services/lib/authentication-providers/register-built-in-provider-services');
+const registerBuiltInAuthProvisioners = require('@aws-ee/base-api-services/lib/authentication-providers/register-built-in-provisioner-services');
+
+const settingKeys = {
+ tablePrefix: 'dbTablePrefix',
+};
+
+/**
+ * A function that registers base services required by the base addon for api handler lambda function
+ * @param container An instance of ServicesContainer to register services to
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise}
+ */
+// eslint-disable-next-line no-unused-vars
+async function registerServices(container, pluginRegistry) {
+ container.register('aws', new AwsService());
+ container.register('s3Service', new S3Service());
+
+ container.register('authenticationProviderConfigService', new AuthenticationProviderConfigService());
+ container.register('authenticationProviderTypeService', new AuthenticationProviderTypeService());
+ container.register('authenticationService', new AuthenticationService());
+ container.register('tokenRevocationService', new TokenRevocationService());
+
+ // Register all the built in authentication providers supported by the data lake out of the box
+ registerBuiltInAuthProviders(container);
+ registerBuiltInAuthProvisioners(container);
+
+ container.register('dbService', new DbService(), { lazy: false });
+ container.register('dbAuthenticationService', new DbAuthenticationService());
+ container.register('dbPasswordService', new DbPasswordService());
+ container.register('jsonSchemaValidationService', new JsonSchemaValidationService());
+ container.register('jwtService', new JwtService());
+ container.register('userService', new UserService());
+ container.register('auditWriterService', new AuditWriterService());
+ container.register('pluginRegistryService', new PluginRegistryService(pluginRegistry), { lazy: false });
+
+ // Authorization Services from base addon
+ container.register('authorizationService', new AuthorizationService());
+ container.register('userAuthzService', new UserAuthzService());
+}
+
+/**
+ * A function that registers base static settings required by the base addon for api handler lambda function
+ * @param existingStaticSettings
+ * @param settings
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise<*>} A promise that resolves to static settings object
+ */
+// eslint-disable-next-line no-unused-vars
+function getStaticSettings(existingStaticSettings, settings, pluginRegistry) {
+ const staticSettings = {
+ ...existingStaticSettings,
+ };
+
+ // Register all dynamodb table names used by the base rest api addon
+ const tablePrefix = settings.get(settingKeys.tablePrefix);
+ const table = (key, suffix) => {
+ staticSettings[key] = `${tablePrefix}-${suffix}`;
+ };
+ table('dbTableAuthenticationProviderTypes', 'DbAuthenticationProviderTypes');
+ table('dbTableAuthenticationProviderConfigs', 'DbAuthenticationProviderConfigs');
+ table('dbTablePasswords', 'DbPasswords');
+ table('dbTableUserApiKeys', 'DbUserApiKeys');
+ table('dbTableRevokedTokens', 'DbRevokedTokens');
+ table('dbTableUsers', 'DbUsers');
+ table('dbTableLocks', 'DbLocks');
+
+ return staticSettings;
+}
+
+const plugin = {
+ getStaticSettings,
+ // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient
+ // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerSettingsService, // not implemented, the default behavior provided by addon-base is sufficient
+ // registerLoggerService, // not implemented, the default behavior provided by addon-base is sufficient
+ registerServices,
+};
+module.exports = plugin;
diff --git a/addons/addon-base-rest-api/packages/base-authn-handler/package.json b/addons/addon-base-rest-api/packages/base-authn-handler/package.json
new file mode 100644
index 0000000000..934543ceda
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-authn-handler/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@aws-ee/base-authn-handler",
+ "version": "1.0.0",
+ "private": true,
+ "description": "A library containing some utilities to be used for an custom lambda authorizer lambda function based on addons",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-api-services": "workspace:*",
+ "@aws-ee/base-services": "workspace:*",
+ "@aws-ee/base-services-container": "workspace:*",
+ "aws-sdk": "^2.647.0",
+ "generate-password": "^1.5.0",
+ "lodash": "^4.17.15"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.0.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --ignore-path .gitignore . ",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' ",
+ "format": "pnpm run format:eslint && pnpm run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . ",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' "
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/base-controllers/.eslintrc.json b/addons/addon-base-rest-api/packages/base-controllers/.eslintrc.json
new file mode 100644
index 0000000000..a9e56eda24
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/base-controllers/.gitignore b/addons/addon-base-rest-api/packages/base-controllers/.gitignore
new file mode 100644
index 0000000000..659959de8f
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/.gitignore
@@ -0,0 +1,16 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/addons/addon-base-rest-api/packages/base-controllers/.prettierrc.json b/addons/addon-base-rest-api/packages/base-controllers/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-rest-api/packages/base-controllers/jest.config.js b/addons/addon-base-rest-api/packages/base-controllers/jest.config.js
new file mode 100644
index 0000000000..3f7ffc8068
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/jest.config.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-rest-api/packages/base-controllers/jsconfig.json b/addons/addon-base-rest-api/packages/base-controllers/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/api-key-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/api-key-controller.js
new file mode 100644
index 0000000000..b9db557e0b
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/api-key-controller.js
@@ -0,0 +1,94 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const apiKeyService = await context.service('apiKeyService');
+
+ // ===============================================================
+ // GET / (mounted to /api/api-keys)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const { username, ns } = requestContext.principalIdentifier;
+ // Is user is specified then perform operation for that user or else for current user
+ const usernameToUse = req.query.username || username;
+ const nsToUse = req.query.ns || ns;
+ const apiKeys = await apiKeyService.getApiKeys(requestContext, { username: usernameToUse, ns: nsToUse });
+ res.status(200).json(apiKeys);
+ }),
+ );
+
+ // ===============================================================
+ // GET /:id (mounted to /api/api-keys)
+ // ===============================================================
+ router.get(
+ '/:id',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const { username, ns } = requestContext.principalIdentifier;
+ // Is user is specified then perform operation for that user or else for current user
+ const usernameToUse = req.query.username || username;
+ const nsToUse = req.query.ns || ns;
+ const keyId = req.params.id;
+ const apiKey = await apiKeyService.getApiKey(requestContext, { username: usernameToUse, ns: nsToUse, keyId });
+ res.status(200).json(apiKey);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:id/revoke (mounted to /api/api-keys)
+ // ===============================================================
+ router.put(
+ '/:id/revoke',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const { username, ns } = requestContext.principalIdentifier;
+ // Is user is specified then perform operation for that user or else for current user
+ const usernameToUse = req.query.username || username;
+ const nsToUse = req.query.ns || ns;
+ const keyId = req.params.id;
+ const apiKey = await apiKeyService.revokeApiKey(requestContext, { username: usernameToUse, ns: nsToUse, keyId });
+ res.status(200).json(apiKey);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/api-keys)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const { username, ns } = requestContext.principalIdentifier;
+ // Is user is specified then perform operation for that user or else for current user
+ const usernameToUse = req.query.username || username;
+ const nsToUse = req.query.ns || ns;
+ const apiKey = await apiKeyService.issueApiKey(requestContext, {
+ username: usernameToUse,
+ ns: nsToUse,
+ expiryTime: req.body.expiryTime,
+ });
+ res.status(200).json(apiKey);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/authentication-provider-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/authentication-provider-controller.js
new file mode 100644
index 0000000000..35013d62e5
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/authentication-provider-controller.js
@@ -0,0 +1,143 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const { ensureAdmin } = require('@aws-ee/base-services/lib/authorization/assertions');
+const { newInvoker } = require('@aws-ee/base-api-services/lib/authentication-providers/helpers/invoker');
+const authProviderConstants = require('@aws-ee/base-api-services/lib/authentication-providers/constants')
+ .authenticationProviders;
+
+/**
+ * Function to remove impl information from authentication provider config or authentication provider type configuration as that is not useful on the client side \
+ * and should not be transmitted
+ *
+ * @param authConfigOrTypeConfig Authentication provider config or authentication provider type configuration
+ * @returns {{impl}}
+ */
+const sanitize = authConfigOrTypeConfig => {
+ const sanitizeOne = config => {
+ if (_.get(config, 'config.impl')) {
+ // When the auth provider type config is passed the impl is at 'config.impl' path
+ delete config.config.impl;
+ } else if (_.get(config, 'type.config.impl')) {
+ // When the auth provider config is passed the impl is at 'type.config.impl' path
+ delete config.type.config.impl;
+ }
+ return config;
+ };
+ return _.isArray(authConfigOrTypeConfig)
+ ? _.map(authConfigOrTypeConfig, sanitizeOne)
+ : sanitizeOne(authConfigOrTypeConfig);
+};
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const settings = context.settings;
+ const boom = context.boom;
+ const invoke = newInvoker(context.service.bind(context));
+
+ const [authenticationProviderTypeService, authenticationProviderConfigService] = await context.service([
+ 'authenticationProviderTypeService',
+ 'authenticationProviderConfigService',
+ ]);
+
+ const saveAuthenticationProvider = async (res, req, action) => {
+ const requestContext = res.locals.requestContext;
+ const { providerTypeId, providerConfig } = req.body;
+
+ // Make sure the current user is an admin user
+ // Only admins are allowed to add authentication providers
+ await ensureAdmin(requestContext);
+
+ if (!providerTypeId) {
+ throw boom.badRequest('Missing providerTypeId in the request', true);
+ }
+ if (!providerConfig) {
+ throw boom.badRequest('Missing providerConfig in the request', true);
+ }
+ if (!providerConfig.id) {
+ throw boom.badRequest('Missing id in the providerConfig', true);
+ }
+
+ const providerTypeConfig = await authenticationProviderTypeService.getAuthenticationProviderType(
+ requestContext,
+ providerTypeId,
+ );
+
+ if (_.isEmpty(providerTypeConfig)) {
+ throw boom.badRequest(
+ `Invalid providerTypeId specified. No authentication provider type with id = "${providerTypeId}" found`,
+ true,
+ );
+ }
+
+ const provisionerLocator = _.get(providerTypeConfig, 'config.impl.provisionerLocator');
+ const result = await invoke(provisionerLocator, {
+ providerTypeConfig,
+ providerConfig,
+ action,
+ });
+
+ res.status(200).json(sanitize(result));
+ };
+
+ // ===============================================================
+ // GET /configs (mounted to /api/authentication/provider)
+ // ===============================================================
+ router.get(
+ '/configs',
+ wrap(async (req, res) => {
+ const result = await authenticationProviderConfigService.getAuthenticationProviderConfigs();
+ res.status(200).json(sanitize(result));
+ }),
+ );
+
+ // ===============================================================
+ // POST /configs (mounted to /api/authentication/provider)
+ // ===============================================================
+ router.post(
+ '/configs',
+ wrap(async (req, res) => {
+ await saveAuthenticationProvider(res, req, authProviderConstants.provisioningAction.create);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /configs (mounted to /api/authentication/provider)
+ // ===============================================================
+ router.put(
+ '/configs',
+ wrap(async (req, res) => {
+ await saveAuthenticationProvider(res, req, authProviderConstants.provisioningAction.update);
+ }),
+ );
+
+ // ===============================================================
+ // GET /types (mounted to /api/authentication/provider)
+ // ===============================================================
+ router.get(
+ '/types',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const result = await authenticationProviderTypeService.getAuthenticationProviderTypes(requestContext);
+ res.status(200).json(sanitize(result));
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/authentication-provider-public-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/authentication-provider-public-controller.js
new file mode 100644
index 0000000000..368ce36c93
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/authentication-provider-public-controller.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const cognitoAuthType = require('@aws-ee/base-api-services/lib/authentication-providers/built-in-providers/cogito-user-pool/type')
+ .type;
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const settings = context.settings;
+ // const boom = context.boom;
+
+ const authenticationProviderConfigService = await context.service('authenticationProviderConfigService');
+
+ // ===============================================================
+ // GET / (mounted to /api/authentication/public/provider/configs)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const providers = await authenticationProviderConfigService.getAuthenticationProviderConfigs();
+
+ // Construct/filter results based on info that's needed client-side
+ const result = [];
+ providers.forEach(provider => {
+ const basePublicInfo = {
+ id: provider.config.id,
+ title: provider.config.title,
+ type: provider.config.type.type,
+ credentialHandlingType: provider.config.type.config.credentialHandlingType,
+ signInUri: provider.config.signInUri,
+ signOutUri: provider.config.signOutUri,
+ };
+
+ if (provider.config.type.type !== cognitoAuthType) {
+ // For non-Cognito providers, just return their info as-is
+ result.push(basePublicInfo);
+ } else {
+ // If native users are enabled for a Cognito user pool, add the pool's info
+ // NOTE: The pool info is still needed by the frontend even if native users
+ // are disabled. When a user is federated by Cognito, the JWT issuer
+ // is defined as the user pool itself. The frontend uses the JWT issuer
+ // to determine which provider was used so that it can facilitate logout.
+ const cognitoPublicInfo = {
+ ...basePublicInfo,
+ userPoolId: provider.config.userPoolId,
+ clientId: provider.config.clientId,
+ enableNativeUserPoolUsers: provider.config.enableNativeUserPoolUsers,
+ };
+
+ if (cognitoPublicInfo.enableNativeUserPoolUsers) {
+ cognitoPublicInfo.signInUri = `${basePublicInfo.signInUri}&identity_provider=COGNITO`;
+ } else {
+ delete cognitoPublicInfo.signInUri;
+ }
+
+ result.push(cognitoPublicInfo);
+
+ // Add IdPs federating via Cognito as their own entries
+ provider.config.federatedIdentityProviders.forEach(idp => {
+ result.push({
+ ...basePublicInfo,
+ id: idp.id,
+ title: idp.displayName,
+ type: 'cognito_user_pool_federated_idp',
+ signInUri: `${basePublicInfo.signInUri}&idp_identifier=${idp.id}`,
+ });
+ });
+ }
+ });
+ res.status(200).json(result);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/ensure-active.js b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/ensure-active.js
new file mode 100644
index 0000000000..39b99183fe
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/ensure-active.js
@@ -0,0 +1,42 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const boom = context.boom;
+ const userService = await context.service('userService');
+ // ===============================================================
+ // A middleware
+ // ===============================================================
+ // Ensure the logged in user is Active before allowing this route access
+ router.all(
+ '*',
+ wrap(async (req, res, next) => {
+ const requestContext = res.locals.requestContext;
+
+ const isActive = await userService.isCurrentUserActive(requestContext);
+ if (!isActive) {
+ // Do not allow any access if the logged in user is marked inactive in the system
+ throw boom.unauthorized('Inactive user', true);
+ }
+ next();
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/ensure-admin.js b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/ensure-admin.js
new file mode 100644
index 0000000000..89aa81ae76
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/ensure-admin.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const { ensureAdmin } = require('@aws-ee/base-services/lib/authorization/assertions');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // ===============================================================
+ // A middleware
+ // ===============================================================
+ // Ensure the logged in user is Admin before allowing this route access
+ router.all(
+ '*',
+ wrap(async (req, res, next) => {
+ const requestContext = res.locals.requestContext;
+ await ensureAdmin(requestContext);
+ next();
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/prepare-context.js b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/prepare-context.js
new file mode 100644
index 0000000000..69fd8e943b
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/prepare-context.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const RequestContext = require('@aws-ee/base-services-container/lib/request-context');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const userService = await context.service('userService');
+
+ // ===============================================================
+ // A middleware
+ // ===============================================================
+ // populate request context, if user is authenticated
+ router.all(
+ '*',
+ wrap(async (req, res, next) => {
+ const requestContext = new RequestContext();
+ res.locals.requestContext = requestContext;
+ const authenticated = res.locals.authenticated;
+ const username = res.locals.username;
+ const authenticationProviderId = res.locals.authenticationProviderId;
+ const identityProviderName = res.locals.identityProviderName;
+
+ if (!authenticated || !username) return next();
+
+ const user = await userService.mustFindUser({
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ });
+ requestContext.authenticated = authenticated;
+ requestContext.principal = user;
+ requestContext.principalIdentifier = { username, ns: user.ns };
+
+ return next();
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/setup-auth-context.js b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/setup-auth-context.js
new file mode 100644
index 0000000000..4cfa250ae3
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/middlewares/setup-auth-context.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+module.exports = async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+
+ // ===============================================================
+ // A middleware
+ // ===============================================================
+ router.all(
+ '*',
+ wrap(async (req, res, next) => {
+ res.locals.authenticated = false; // start with false;
+ const { context: { authorizer } = {} } = req;
+ if (authorizer) {
+ const { token, isApiKey, username, identityProviderName, authenticationProviderId } = authorizer;
+ res.locals.token = token;
+ res.locals.isApiKey = isApiKey; // may be undefined if the token is not an api key
+ res.locals.username = username;
+ res.locals.identityProviderName = identityProviderName;
+ res.locals.authenticationProviderId = authenticationProviderId;
+ res.locals.authenticated = true;
+ }
+ next();
+ }),
+ );
+ return router;
+};
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/plugins/routes-plugin.js b/addons/addon-base-rest-api/packages/base-controllers/lib/plugins/routes-plugin.js
new file mode 100644
index 0000000000..2d584c5d02
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/plugins/routes-plugin.js
@@ -0,0 +1,78 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// base middlewares
+const newSetupAuthContextMiddleware = require('../middlewares/setup-auth-context');
+const prepareContextFn = require('../middlewares/prepare-context');
+const ensureActiveFn = require('../middlewares/ensure-active');
+const ensureAdminFn = require('../middlewares/ensure-admin');
+// base controllers
+const authenticationProviderController = require('../authentication-provider-controller');
+const authenticationProviderPublicController = require('../authentication-provider-public-controller');
+const signInController = require('../sign-in-controller');
+const signOutController = require('../sign-out-controller');
+const apiKeyController = require('../api-key-controller');
+const usersController = require('../users-controller');
+const userController = require('../user-controller');
+/**
+ * Adds base routes to the given routesMap.
+ * @param routesMap A Map containing routes. This object is a Map that has route paths as
+ * keys and an array of functions that configure the router as value. Each function in the
+ * array is expected have the following signature. The function accepts context and router
+ * arguments and returns a configured router.
+ *
+ * (context, router) => configured router
+ *
+ * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ *
+ * @returns {Promise<*>} Returns a Map with the mapping of base routes vs their router configurer functions
+ */
+// eslint-disable-next-line no-unused-vars
+async function getBaseRoutes(routesMap, pluginRegistry) {
+ const routes = new Map([
+ ...routesMap,
+ // PUBLIC APIS - No base middlewares to configure
+ ['/api/authentication/id-tokens', [signInController]],
+ ['/api/authentication/public/provider/configs', [authenticationProviderPublicController]],
+
+ // PROTECTED APIS accessible only to logged in admin users
+ [
+ '/api/authentication/provider',
+ [
+ newSetupAuthContextMiddleware,
+ prepareContextFn,
+ ensureActiveFn,
+ ensureAdminFn,
+ authenticationProviderController,
+ ],
+ ],
+
+ // PROTECTED API accessible to logged in (but not necessarily active) users
+ ['/api/authentication/logout', [newSetupAuthContextMiddleware, prepareContextFn, signOutController]],
+
+ // Other PROTECTED APIS accessible only to logged in active users
+ ['/api/api-keys', [newSetupAuthContextMiddleware, prepareContextFn, ensureActiveFn, apiKeyController]],
+ ['/api/users', [newSetupAuthContextMiddleware, prepareContextFn, ensureActiveFn, usersController]],
+ ['/api/user', [newSetupAuthContextMiddleware, prepareContextFn, ensureActiveFn, userController]],
+ ]);
+
+ return routes;
+}
+
+const plugin = {
+ getRoutes: getBaseRoutes,
+};
+
+module.exports = plugin;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/sign-in-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/sign-in-controller.js
new file mode 100644
index 0000000000..aad61aa6d2
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/sign-in-controller.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const { newInvoker } = require('@aws-ee/base-api-services/lib/authentication-providers/helpers/invoker');
+const authProviderConstants = require('@aws-ee/base-api-services/lib/authentication-providers/constants')
+ .authenticationProviders;
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const settings = context.settings;
+ // const boom = context.boom;
+
+ const authenticationProviderConfigService = await context.service('authenticationProviderConfigService');
+ const invoke = newInvoker(context.service.bind(context));
+ // ===============================================================
+ // POST / (mounted to /api/authentication/id-tokens)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const { username, password, authenticationProviderId } = req.body;
+
+ // If no authentication provider id is specified in the request then assume this to be authenticated by the
+ // internal authentication provider
+ const authenticationProviderIdToUse = authenticationProviderId || authProviderConstants.internalAuthProviderId;
+
+ const authProviderConfig = await authenticationProviderConfigService.getAuthenticationProviderConfig(
+ authenticationProviderIdToUse,
+ );
+ const tokenIssuerLocator = _.get(authProviderConfig, 'config.type.config.impl.tokenIssuerLocator');
+ const idToken = await invoke(tokenIssuerLocator, { username, password }, authProviderConfig);
+ res.status(200).json({ idToken });
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/sign-out-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/sign-out-controller.js
new file mode 100644
index 0000000000..a6f2326035
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/sign-out-controller.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const { newInvoker } = require('@aws-ee/base-api-services/lib/authentication-providers/helpers/invoker');
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ // const settings = context.settings;
+ const boom = context.boom;
+
+ const providerConfigService = await context.service('authenticationProviderConfigService');
+ const invoke = newInvoker(context.service.bind(context));
+ // ===============================================================
+ // POST / (mounted to /api/authentication/logout)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ if (res.locals.isApiKey) {
+ throw boom.badRequest('Logout API is not supported using api key', true);
+ }
+ const providerId = res.locals.authenticationProviderId;
+ const token = res.locals.token;
+ const requestContext = res.locals.requestContext;
+
+ const providerConfig = await providerConfigService.getAuthenticationProviderConfig(providerId);
+ const tokenRevokerLocator = _.get(providerConfig, 'config.type.config.impl.tokenRevokerLocator');
+ if (!tokenRevokerLocator) {
+ throw boom.badRequest(
+ `Error logging out. The authentication provider with id = '${providerId}' does not support token revocation`,
+ false,
+ );
+ }
+
+ // invoke the token revoker and pass the token that needs to be revoked
+ await invoke(tokenRevokerLocator, requestContext, { token }, providerConfig);
+ res.status(200).json({ revoked: true });
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/user-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/user-controller.js
new file mode 100644
index 0000000000..19ae42b166
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/user-controller.js
@@ -0,0 +1,57 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const [userService] = await context.service(['userService']);
+
+ // ===============================================================
+ // GET / (mounted to /api/user)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const user = res.locals.requestContext.principal;
+ res.status(200).json(user);
+ }),
+ );
+
+ // ===============================================================
+ // PUT / (mounted to /api/user)
+ // ===============================================================
+ // This is for self-service update
+ router.put(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const currentUser = requestContext.principal;
+ // Get current user's attributes to identify the user in the system
+ const { username, authenticationProviderId, identityProviderName } = currentUser;
+ const userToUpdate = req.body;
+ const updatedUser = await userService.updateUser(requestContext, {
+ ...userToUpdate,
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ });
+ res.status(200).json(updatedUser);
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/lib/users-controller.js b/addons/addon-base-rest-api/packages/base-controllers/lib/users-controller.js
new file mode 100644
index 0000000000..d8a8e65c18
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/lib/users-controller.js
@@ -0,0 +1,126 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const authProviderConstants = require('@aws-ee/base-api-services/lib/authentication-providers/constants')
+ .authenticationProviders;
+
+async function configure(context) {
+ const router = context.router();
+ const wrap = context.wrap;
+ const boom = context.boom;
+ const [userService, dbPasswordService] = await context.service(['userService', 'dbPasswordService']);
+
+ // ===============================================================
+ // GET / (mounted to /api/users)
+ // ===============================================================
+ router.get(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const users = await userService.listUsers(requestContext);
+ res.status(200).json(users);
+ }),
+ );
+
+ // ===============================================================
+ // POST / (mounted to /api/users)
+ // ===============================================================
+ router.post(
+ '/',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const authenticationProviderId =
+ req.query.authenticationProviderId || authProviderConstants.internalAuthProviderId;
+ const identityProviderName = req.query.identityProviderName; // This is currently always null or undefined for "internal" auth provider
+ if (authenticationProviderId !== authProviderConstants.internalAuthProviderId) {
+ throw boom.badRequest(
+ `Cannot create user for authentication provider ${authenticationProviderId}. Currently adding users is only supported for internal authentication provider.`,
+ true,
+ );
+ }
+ const { username, firstName, lastName, email, isAdmin, status, password } = req.body;
+
+ const createdUser = await userService.createUser(requestContext, {
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ firstName,
+ lastName,
+ email,
+ isAdmin: _.isNil(isAdmin) ? false : isAdmin,
+ status,
+ password,
+ });
+
+ res.status(200).json(createdUser);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:username (mounted to /api/users)
+ // ===============================================================
+ router.put(
+ '/:username',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const username = req.params.username;
+ const authenticationProviderId =
+ req.query.authenticationProviderId || authProviderConstants.internalAuthProviderId;
+ const identityProviderName = req.query.identityProviderName;
+ const { firstName, lastName, email, isAdmin, status, rev } = req.body;
+ const user = await userService.updateUser(requestContext, {
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ firstName,
+ lastName,
+ email,
+ isAdmin: _.isNil(isAdmin) ? false : isAdmin,
+ status: _.isNil(status) ? 'active' : status,
+ rev,
+ });
+ res.status(200).json(user);
+ }),
+ );
+
+ // ===============================================================
+ // PUT /:username/password (mounted to /api/users)
+ // ===============================================================
+ router.put(
+ '/:username/password',
+ wrap(async (req, res) => {
+ const requestContext = res.locals.requestContext;
+ const username = req.params.username;
+ const authenticationProviderId =
+ req.query.authenticationProviderId || authProviderConstants.internalAuthProviderId;
+ if (authenticationProviderId !== authProviderConstants.internalAuthProviderId) {
+ throw boom.badRequest(
+ `Cannot create user for authentication provider ${authenticationProviderId}. Currently adding users is only supported for internal authentication provider.`,
+ true,
+ );
+ }
+ const { password } = req.body;
+
+ // Save password salted hash for the user in internal auth provider (i.e., in passwords table)
+ await dbPasswordService.savePassword(requestContext, { username, password });
+ res.status(200).json({ username, message: `Password successfully updated for user ${username}` });
+ }),
+ );
+
+ return router;
+}
+
+module.exports = configure;
diff --git a/addons/addon-base-rest-api/packages/base-controllers/package.json b/addons/addon-base-rest-api/packages/base-controllers/package.json
new file mode 100644
index 0000000000..648f56177d
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/base-controllers/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@aws-ee/base-controllers",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A library containing a set of base controllers",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-services": "workspace:*",
+ "@aws-ee/base-api-services": "workspace:*",
+ "@aws-ee/base-services-container": "workspace:*",
+ "lodash": "^4.17.15"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.0.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --ignore-path .gitignore . ",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' ",
+ "format": "pnpm run format:eslint && pnpm run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . ",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' "
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/services/.eslintrc.json b/addons/addon-base-rest-api/packages/services/.eslintrc.json
new file mode 100644
index 0000000000..a9e56eda24
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"],
+ "plugins": ["jest", "prettier"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0
+ },
+ "env": {
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/services/.gitignore b/addons/addon-base-rest-api/packages/services/.gitignore
new file mode 100644
index 0000000000..05bd7c6845
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/.gitignore
@@ -0,0 +1,20 @@
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+**/.webpack
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+/coverage/
+.build
diff --git a/addons/addon-base-rest-api/packages/services/.prettierrc.json b/addons/addon-base-rest-api/packages/services/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-rest-api/packages/services/jest.config.js b/addons/addon-base-rest-api/packages/services/jest.config.js
new file mode 100644
index 0000000000..60c221fc1d
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/jest.config.js
@@ -0,0 +1,25 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// jest.config.js
+module.exports = {
+ // verbose: true,
+ notify: false,
+ testEnvironment: 'node',
+ // testPathIgnorePatterns: ['service.test.js'],
+ // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports
+ // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html
+ reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]],
+};
diff --git a/addons/addon-base-rest-api/packages/services/jsconfig.json b/addons/addon-base-rest-api/packages/services/jsconfig.json
new file mode 100644
index 0000000000..780d3afae6
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "exclude": [
+ "node_modules",
+ "**/node_modules/*"
+ ]
+}
\ No newline at end of file
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/authentication-provider-config-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/authentication-provider-config-service.js
new file mode 100644
index 0000000000..e1adddc83f
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/authentication-provider-config-service.js
@@ -0,0 +1,103 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+const _ = require('lodash');
+const authProviderConstants = require('./constants').authenticationProviders;
+
+const settingKeys = {
+ tableName: 'dbTableAuthenticationProviderConfigs',
+};
+
+const serializeProviderConfig = providerConfig => JSON.stringify(providerConfig);
+const deSerializeProviderConfig = providerConfigStr => JSON.parse(providerConfigStr);
+
+const toProviderConfig = dbResultItem =>
+ _.assign({}, dbResultItem, {
+ config: dbResultItem && deSerializeProviderConfig(dbResultItem.config),
+ });
+
+class AuthenticationProviderConfigService extends Service {
+ constructor() {
+ super();
+ this.dependency(['dbService', 'jsonSchemaValidationService']);
+ }
+
+ async getAuthenticationProviderConfigs(fields = []) {
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+ const dbResults = await dbService.helper
+ .scanner()
+ .table(table)
+ .projection(fields)
+ .scan();
+ return _.map(dbResults, toProviderConfig);
+ }
+
+ async getAuthenticationProviderConfig(providerId, fields = []) {
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+ const dbResult = await dbService.helper
+ .getter()
+ .table(table)
+ .key({ id: providerId })
+ .projection(fields)
+ .get();
+ return dbResult && toProviderConfig(dbResult);
+ }
+
+ async exists(providerId) {
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+ const item = await dbService.helper
+ .getter()
+ .table(table)
+ .key({ id: providerId })
+ .get();
+
+ if (item === undefined) return false;
+ return true;
+ }
+
+ async saveAuthenticationProviderConfig({
+ providerTypeConfig,
+ providerConfig,
+ status = authProviderConstants.status.initializing,
+ }) {
+ const jsonSchemaValidationService = await this.service('jsonSchemaValidationService');
+ const providerConfigJsonSchema = _.get(providerTypeConfig, 'config.inputSchema');
+
+ // Validate input
+ await jsonSchemaValidationService.ensureValid(providerConfig, providerConfigJsonSchema);
+
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+
+ providerConfig.type = providerTypeConfig;
+
+ const dbResult = await dbService.helper
+ .updater()
+ .table(table)
+ .key({ id: providerConfig.id })
+ // save serialized providerConfig as JSON string
+ // also set the "status" of the authentication provider as "initializing", by default
+ // once the provisioning is complete, the status should be set to "active" by the subclasses
+ .item({ config: serializeProviderConfig(providerConfig), status })
+ .update();
+ return dbResult && toProviderConfig(dbResult);
+ }
+}
+
+module.exports = AuthenticationProviderConfigService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/authentication-provider-type-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/authentication-provider-type-service.js
new file mode 100644
index 0000000000..0a56d398fb
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/authentication-provider-type-service.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+const _ = require('lodash');
+
+const internalAuthenticationProviderType = require('./built-in-providers/internal/type');
+const cognitoUserPoolAuthenticationProviderType = require('./built-in-providers/cogito-user-pool/type');
+
+class AuthenticationProviderTypeService extends Service {
+ constructor() {
+ super();
+ this.dependency(['dbService', 'pluginRegistryService']);
+ }
+
+ async getAuthenticationProviderTypes(requestContext) {
+ const types = [internalAuthenticationProviderType, cognitoUserPoolAuthenticationProviderType];
+
+ // Give all plugins a chance in registering their authentication provider types
+ // Each plugin will receive the following payload object with the shape {requestContext, container, types}
+ const pluginRegistryService = await this.service('pluginRegistryService');
+ const result = await pluginRegistryService.visitPlugins('authentication-provider-type', 'registerTypes', {
+ payload: { requestContext, container: this.container, types },
+ });
+ return result ? result.types : [];
+ }
+
+ async getAuthenticationProviderType(requestContext, providerTypeId) {
+ const providerTypes = await this.getAuthenticationProviderTypes(requestContext);
+ return _.find(providerTypes, { type: providerTypeId });
+ }
+}
+
+module.exports = AuthenticationProviderTypeService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/cognito-token-verifier.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/cognito-token-verifier.js
new file mode 100644
index 0000000000..ecefde5f17
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/cognito-token-verifier.js
@@ -0,0 +1,81 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const request = require('request');
+const jwkToPem = require('jwk-to-pem');
+const _ = require('lodash');
+const jwt = require('jsonwebtoken');
+
+async function getCognitoTokenVerifier(userPoolUri, logger = console) {
+ function toPem(keyDictionary) {
+ const modulus = keyDictionary.n;
+ const exponent = keyDictionary.e;
+ const keyType = keyDictionary.kty;
+ const jwk = { kty: keyType, n: modulus, e: exponent };
+ const pem = jwkToPem(jwk);
+ return pem;
+ }
+
+ // build key cache from cognito user pools
+ const jwtKeySetUri = `${userPoolUri}/.well-known/jwks.json`;
+ const pemKeyCache = await new Promise((resolve, reject) => {
+ request({ url: jwtKeySetUri, json: true }, (error, response, body) => {
+ if (!error && response && response.statusCode === 200) {
+ const keys = body.keys;
+ const keyCache = {};
+ _.forEach(keys, key => {
+ // kid = key id
+ const kid = key.kid;
+ keyCache[kid] = toPem(key);
+ });
+ resolve(keyCache);
+ } else {
+ logger.error('Failed to retrieve the keys from the well known user-pool URI');
+ reject(error);
+ }
+ });
+ });
+
+ const verify = async token => {
+ // First attempt to decode token before attempting to verify the signature
+ const decodedJwt = jwt.decode(token, { complete: true });
+ if (!decodedJwt) {
+ throw new Error('Not valid JWT token. Could not decode the token');
+ }
+
+ // Fail if token is not from your User Pool
+ if (decodedJwt.payload.iss !== userPoolUri) {
+ throw new Error('Not valid JWT token. The token is not issued by the trusted source');
+ }
+
+ // Reject the jwt if it's not an 'Identity Token'
+ if (decodedJwt.payload.token_use !== 'id') {
+ throw new Error('Not valid JWT token. The token is not the identity token');
+ }
+
+ const keyId = decodedJwt.header.kid;
+ const pem = pemKeyCache[keyId];
+ if (!pem) {
+ throw new Error('Not valid JWT token. No valid key available for verifying the token.');
+ }
+
+ const payload = await jwt.verify(token, pem, { issuer: userPoolUri });
+ return payload;
+ };
+
+ return { verify };
+}
+
+module.exports = { getCognitoTokenVerifier };
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/create-cognito-user-pool-input-manifest.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/create-cognito-user-pool-input-manifest.js
new file mode 100644
index 0000000000..898e13d6f8
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/create-cognito-user-pool-input-manifest.js
@@ -0,0 +1,134 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const inputManifestForCreate = {
+ sections: [
+ {
+ title: 'General Information',
+ children: [
+ {
+ name: 'id',
+ type: 'stringInput',
+ title: 'ID',
+ rules: 'required|string|between:2,64|regex:/^[a-zA-Z][a-zA-Z0-9_-]+$/',
+ desc:
+ 'This is a required field. This is used for uniquely identifying the authentication provider. ' +
+ 'It must be between 2 to 64 characters long and must start with an alphabet and may contain alpha numeric ' +
+ 'characters, underscores, and dashes. No other special symbols are allowed.',
+ },
+ {
+ name: 'title',
+ type: 'stringInput',
+ title: 'Title',
+ rules: 'required|between:3,255',
+ desc: 'This is a required field and must be between 3 and 255 characters long.',
+ },
+ ],
+ },
+ {
+ title: 'Congito User Pool Information',
+ children: [
+ {
+ name: 'connectExistingCognitoUserPool',
+ type: 'yesNoInput',
+ title: 'Connect to Existing or Create New',
+ yesLabel: 'Connect to Existing',
+ noLabel: 'Create New',
+ rules: 'required|boolean',
+ desc: 'Do you want to connect to an existing cognito user pool or create a new one?',
+ },
+ {
+ name: 'configureFedIdps',
+ type: 'yesNoInput',
+ title: 'Identity Federation',
+ yesLabel: 'yes',
+ noLabel: 'no',
+ rules: 'required|boolean',
+ desc: 'Do you want to configure SAML identity federation with other SAML identity providers?',
+ },
+ ],
+ },
+ {
+ title: 'Existing Cognito User Pool Information (Optional)',
+ condition: '<%= connectExistingCognitoUserPool === true %>',
+ children: [
+ {
+ name: 'userPoolId',
+ type: 'stringInput',
+ title: 'Cognito User Pool ID',
+ rules: 'required|string',
+ desc: 'Enter the ID of the cognito user pool you want to connect to.',
+ },
+ {
+ name: 'userPoolName',
+ type: 'stringInput',
+ title: 'Cognito User Pool Name',
+ desc: 'Enter name of the cognito user pool you want to connect to.',
+ },
+ ],
+ },
+ {
+ title: 'Configure Identity Federation (Optional)',
+ condition: '<%= configureFedIdps === true %>',
+ // TODO: Add support for array input types in input manifest
+ // this is required for allowing to dynamically configure multiple federatedIdentityProviders
+ // The children in the input manifest sections tree are all expected to be flat key,value pairs
+ // The names below are based on object path
+ // For example, the authentication providers create API expects structure to be
+ // {
+ // ...
+ // federatedIdentityProviders: [
+ // {
+ // id, // This is named federatedIdentitiyProviders_0_id below as this is the "id" of the first element (i.e., at "0" index) in the array "federatedIdentityProviders"
+ // name,
+ // displayName,
+ // metadata
+ // }
+ // ]
+ // }
+ children: [
+ {
+ name: 'federatedIdentityProviders[0].id',
+ type: 'stringInput',
+ title: 'Identity Provider ID (IdP Id)',
+ rules: 'required|string',
+ desc:
+ 'An identifier for the federated identity provider. This will be used for identifying the IdP. Usually this is configured to be same as the domain name of the IdP (E.g., amazonaws.com).',
+ },
+ {
+ name: 'federatedIdentityProviders[0].name',
+ type: 'stringInput',
+ title: 'Identity Provider Name',
+ rules: 'required|string',
+ desc: 'Name for the identity provider.',
+ },
+ {
+ name: 'federatedIdentityProviders[0].displayName',
+ type: 'stringInput',
+ title: 'Identity Provider Display Name',
+ desc: 'Optional display name for the identity provider.',
+ },
+ {
+ name: 'federatedIdentityProviders[0].metadata',
+ type: 'textAreaInput',
+ title: 'Identity Provider SAML Metadata XML',
+ desc: 'Enter identity provider SAML metadata XML document for setting up trust.',
+ },
+ ],
+ },
+ ],
+};
+
+module.exports = { inputManifestForCreate };
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/create-cognito-user-pool-schema.json b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/create-cognito-user-pool-schema.json
new file mode 100644
index 0000000000..aa6d2922bc
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/create-cognito-user-pool-schema.json
@@ -0,0 +1,78 @@
+{
+ "definitions": {},
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "http://example.com/root.json",
+ "type": "object",
+ "required": ["title"],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$id": "#/properties/id",
+ "type": "string"
+ },
+ "title": {
+ "$id": "#/properties/title",
+ "type": "string"
+ },
+ "userPoolName": {
+ "$id": "#/properties/userPoolName",
+ "type": "string"
+ },
+ "userPoolId": {
+ "$id": "#/properties/userPoolId",
+ "type": "string"
+ },
+ "clientName": {
+ "$id": "#/properties/clientName",
+ "type": "string"
+ },
+ "clientId": {
+ "$id": "#/properties/clientId",
+ "type": "string"
+ },
+ "userPoolDomain": {
+ "$id": "#/properties/userPoolDomain",
+ "type": "string"
+ },
+ "signInUri": {
+ "$id": "#/properties/signInUri",
+ "type": "string"
+ },
+ "signOutUri": {
+ "$id": "#/properties/signOutUri",
+ "type": "string"
+ },
+ "enableNativeUserPoolUsers": {
+ "$id": "#/properties/enableNativeUserPoolUsers",
+ "type": "boolean"
+ },
+ "federatedIdentityProviders": {
+ "$id": "#/properties/providerConfig/properties/federatedIdentityProviders",
+ "type": "array",
+ "items": {
+ "$id": "#/properties/providerConfig/properties/federatedIdentityProviders/items",
+ "type": "object",
+ "title": "The Items Schema",
+ "required": ["id", "name", "metadata"],
+ "properties": {
+ "id": {
+ "$id": "#/properties/federatedIdentityProviders/properties/id",
+ "type": "string"
+ },
+ "name": {
+ "$id": "#/properties/federatedIdentityProviders/properties/name",
+ "type": "string"
+ },
+ "displayName": {
+ "$id": "#/properties/federatedIdentityProviders/properties/displayName",
+ "type": "string"
+ },
+ "metadata": {
+ "$id": "#/properties/federatedIdentityProviders/properties/metadata",
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/provider-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/provider-service.js
new file mode 100644
index 0000000000..44b5bbad88
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/provider-service.js
@@ -0,0 +1,153 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { getSystemRequestContext } = require('@aws-ee/base-services/lib/helpers/system-context');
+
+const { getCognitoTokenVerifier } = require('./cognito-token-verifier');
+
+class ProviderService extends Service {
+ constructor() {
+ super();
+ this.dependency(['userService', 'userAttributesMapperService', 'tokenRevocationService']);
+ this.cognitoTokenVerifiersCache = {}; // Cache object containing token verifier objects. Each token verifier is keyed by the userPoolUri
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async validateToken({ token, issuer }, providerConfig) {
+ if (_.isEmpty(token)) {
+ throw this.boom.forbidden('no jwt token was provided', true);
+ }
+ // -- Check if this token is revoked (may be due to an earlier logout)
+ const tokenRevocationService = await this.service('tokenRevocationService');
+ const isRevoked = await tokenRevocationService.isRevoked({ token });
+ if (isRevoked) {
+ throw this.boom.invalidToken('The token is revoked', true);
+ }
+
+ // In case of cognito, the issuer is the cognito userPoolUri
+ const userPoolUri = issuer;
+ let cognitoTokenVerifier = this.cognitoTokenVerifiersCache[userPoolUri];
+ if (!cognitoTokenVerifier) {
+ // No cognitoTokenVerifier in the cache so create a new one
+ cognitoTokenVerifier = await getCognitoTokenVerifier(userPoolUri, this.log);
+ // Add newly created cognitoTokenVerifier to the cache
+ this.cognitoTokenVerifiersCache[userPoolUri] = cognitoTokenVerifier;
+ }
+ // User the cognitoTokenVerifier to validate cognito token
+ const verifiedToken = await cognitoTokenVerifier.verify(token);
+ const { username, identityProviderName } = await this.saveUser(verifiedToken, providerConfig.config.id);
+ return { verifiedToken, username, identityProviderName };
+ }
+
+ async saveUser(decodedToken, authenticationProviderId) {
+ const userAttributesMapperService = await this.service('userAttributesMapperService');
+ // Ask user attributes mapper service to read information from the decoded token and map them to user attributes
+ const userAttributes = await userAttributesMapperService.mapAttributes(decodedToken);
+ if (userAttributes.isSamlAuthenticatedUser) {
+ // If this user is authenticated via SAML then we need to add it to our user table if it doesn't exist already
+ const userService = await this.service('userService');
+
+ const user = await userService.findUser({
+ username: userAttributes.username,
+ authenticationProviderId,
+ identityProviderName: userAttributes.identityProviderName,
+ });
+ if (user) {
+ await this.updateUser(authenticationProviderId, userAttributes, user);
+ } else {
+ await this.createUser(authenticationProviderId, userAttributes);
+ }
+ }
+ return userAttributes;
+ }
+
+ /**
+ * Creates a user in the system based on the user attributes provided by the SAML Identity Provider (IdP)
+ * @param authenticationProviderId ID of the authentication provider
+ * @param userAttributes An object containing attributes mapped from SAML IdP
+ * @returns {Promise}
+ */
+ async createUser(authenticationProviderId, userAttributes) {
+ const userService = await this.service('userService');
+ try {
+ await userService.createUser(getSystemRequestContext(), {
+ authenticationProviderId,
+ ...userAttributes,
+ });
+ } catch (err) {
+ this.log.error(err);
+ throw this.boom.internalError('error creating user');
+ }
+ }
+
+ /**
+ * Updates user in the system based on the user attributes provided by the SAML Identity Provider (IdP).
+ * This base implementation updates only those user attributes in the system which are missing but are available in
+ * the SAML user attributes. Subclasses can override this method to provide different implementation (for example,
+ * update all user attributes in the system if they are updated in SAML IdP etc)
+ *
+ * @param authenticationProviderId ID of the authentication provider
+ * @param userAttributes An object containing attributes mapped from SAML IdP
+ * @param existingUser The existing user in the system
+ *
+ * @returns {Promise}
+ */
+ async updateUser(authenticationProviderId, userAttributes, existingUser) {
+ // Find all attributes present in the userAttributes but missing in existingUser
+ const missingAttribs = {};
+ const keys = _.keys(userAttributes);
+ if (!_.isEmpty(keys)) {
+ _.forEach(keys, key => {
+ const value = userAttributes[key];
+ const existingValue = existingUser[key];
+
+ // check if the attribute is missing in the existingUser object but present in
+ // userAttributes (i.e., the user attributes mapped from SAML assertions)
+ if (_.isNil(existingValue)) {
+ missingAttribs[key] = value;
+ }
+ });
+ }
+
+ // If there are any attributes that are present in the userAttributes but missing in existingUser
+ // then update the user in the system to set the missing attributes
+ if (!_.isEmpty(missingAttribs)) {
+ const userService = await this.service('userService');
+ const { username, identityProviderName, rev } = existingUser;
+ try {
+ await userService.updateUser(getSystemRequestContext(), {
+ username,
+ authenticationProviderId,
+ identityProviderName,
+ rev,
+ ...missingAttribs,
+ });
+ } catch (err) {
+ this.log.error(err);
+ throw this.boom.internalError('error updating user');
+ }
+ }
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async revokeToken(requestContext, { token }, providerConfig) {
+ const tokenRevocationService = await this.service('tokenRevocationService');
+ await tokenRevocationService.revoke(requestContext, { token });
+ }
+}
+
+module.exports = ProviderService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/provisioner-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/provisioner-service.js
new file mode 100644
index 0000000000..83954b01b0
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/provisioner-service.js
@@ -0,0 +1,434 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const authProviderConstants = require('../../constants').authenticationProviders;
+
+const settingKeys = {
+ awsRegion: 'awsRegion',
+ envName: 'envName',
+ envType: 'envType',
+ solutionName: 'solutionName',
+ websiteUrl: 'websiteUrl',
+};
+
+class ProvisionerService extends Service {
+ constructor() {
+ super();
+ this.dependency(['aws', 's3Service', 'jsonSchemaValidationService', 'authenticationProviderConfigService']);
+ this.boom.extend(['authProviderAlreadyExists', 400]);
+ this.boom.extend(['noAuthProviderFound', 400]);
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async provision({ providerTypeConfig, providerConfig, action }) {
+ if (!action) {
+ throw this.boom.badRequest('Can not provision Cognito User Pool. Missing required parameter "action"', false);
+ }
+
+ this.log.info('Provisioning Cognito User Pool Authentication Provider');
+
+ // Validate input
+ const jsonSchemaValidationService = await this.service('jsonSchemaValidationService');
+ const providerConfigJsonSchema = _.get(providerTypeConfig, 'config.inputSchema');
+ await jsonSchemaValidationService.ensureValid(providerConfig, providerConfigJsonSchema);
+
+ const authenticationProviderConfigService = await this.service('authenticationProviderConfigService');
+ let existingProviderConfig;
+ if (providerConfig.id) {
+ existingProviderConfig = await authenticationProviderConfigService.getAuthenticationProviderConfig(
+ providerConfig.id,
+ );
+ }
+ if (action === authProviderConstants.provisioningAction.create && !_.isNil(existingProviderConfig)) {
+ // The authentication provider with same config id already exists.
+ throw this.boom.authProviderAlreadyExists(
+ 'Can not create the specified authentication provider. An authentication provider with the same id already exists',
+ true,
+ );
+ }
+ if (action === authProviderConstants.provisioningAction.update && _.isNil(existingProviderConfig)) {
+ // The authentication provider with the specified config id does not exist.
+ throw this.boom.noAuthProviderFound(
+ 'Can not update the specified authentication provider. No authentication provider with the specified id found',
+ true,
+ );
+ }
+
+ // Each provisioning step function below takes providerConfig and performs it's own provisioning work
+ // and also updates (enriches) providerConfig with additional information (referred to as outputs)
+ // For example, when creating Cognito User Pool, the ID of the created cognito user pool (i.e., userPoolId) is
+ // added to the providerConfig.
+ // The providerConfigWithOutputs variable below is the updated providerConfig with these outputs
+ let providerConfigWithOutputs = providerConfig;
+ providerConfigWithOutputs = await this.saveCognitoUserPool(providerConfigWithOutputs);
+
+ if (existingProviderConfig) {
+ providerConfigWithOutputs.clientId = existingProviderConfig.config.clientId;
+ } else {
+ providerConfigWithOutputs = await this.createUserPoolClient(providerConfigWithOutputs);
+ }
+
+ providerConfigWithOutputs = await this.createUserPoolClient(providerConfigWithOutputs);
+ providerConfigWithOutputs = await this.configureCognitoIdentityProviders(providerConfigWithOutputs);
+ providerConfigWithOutputs = await this.updateUserPoolClient(providerConfigWithOutputs);
+ providerConfigWithOutputs = await this.configureUserPoolDomain(providerConfigWithOutputs);
+
+ const userPoolDomain = providerConfigWithOutputs.userPoolDomain;
+ const awsRegion = this.settings.get(settingKeys.awsRegion);
+ const clientId = providerConfigWithOutputs.clientId;
+ const websiteUrl = this.settings.get(settingKeys.websiteUrl);
+
+ providerConfigWithOutputs.id = `https://cognito-idp.${awsRegion}.amazonaws.com/${providerConfigWithOutputs.userPoolId}`;
+
+ const baseAuthUri = `https://${userPoolDomain}.auth.${awsRegion}.amazoncognito.com`;
+ providerConfigWithOutputs.signInUri = `${baseAuthUri}/oauth2/authorize?response_type=token&client_id=${clientId}&redirect_uri=${websiteUrl}`;
+ providerConfigWithOutputs.signOutUri = `${baseAuthUri}/logout?client_id=${clientId}&logout_uri=${websiteUrl}`;
+
+ this.log.info('Saving Cognito User Pool Authentication Provider Configuration.');
+
+ // Save auth provider configuration and make it active
+ const result = await authenticationProviderConfigService.saveAuthenticationProviderConfig({
+ providerTypeConfig,
+ providerConfig: providerConfigWithOutputs,
+ status: authProviderConstants.status.active,
+ });
+ return result;
+ }
+
+ /* ************** Provisioning Steps ************** */
+ async saveCognitoUserPool(providerConfig) {
+ this.log.info('Creating or configuring Cognito User Pool');
+
+ const aws = await this.service('aws');
+ const cognitoIdentityServiceProvider = new aws.sdk.CognitoIdentityServiceProvider();
+
+ const envType = this.settings.get(settingKeys.envType);
+ const envName = this.settings.get(settingKeys.envName);
+ const solutionName = this.settings.get(settingKeys.solutionName);
+ const userPoolName = providerConfig.userPoolName || `${envName}-${envType}-${solutionName}-userpool`;
+ const params = {
+ AdminCreateUserConfig: { AllowAdminCreateUserOnly: true },
+ AutoVerifiedAttributes: ['email'],
+ Schema: [
+ {
+ Name: 'name',
+ Mutable: true,
+ Required: true,
+ },
+ {
+ Name: 'family_name',
+ Mutable: true,
+ Required: true,
+ },
+ {
+ Name: 'middle_name',
+ Mutable: true,
+ Required: false,
+ },
+ ],
+ };
+ if (providerConfig.userPoolId) {
+ // If userPoolId is specified then this must be for update so make sure it points to a valid cognito user pool
+ try {
+ await cognitoIdentityServiceProvider.describeUserPool({ UserPoolId: providerConfig.userPoolId }).promise();
+ } catch (err) {
+ if (err.code === 'ResourceNotFoundException') {
+ throw this.boom.badRequest(
+ 'Can not update Cognito User Pool. No Cognito User Pool with the given userPoolId exists.',
+ true,
+ );
+ }
+ // In case of any other error, let it propagate
+ throw err;
+ }
+ } else {
+ // userPoolId is not specified so create new user pool
+ params.PoolName = userPoolName;
+ const data = await cognitoIdentityServiceProvider.createUserPool(params).promise();
+ providerConfig.userPoolId = data.UserPool.Id;
+ }
+ providerConfig.userPoolName = userPoolName;
+ return providerConfig;
+ }
+
+ async createUserPoolClient(providerConfig) {
+ this.log.info('Creating or configuring Cognito User Pool Client');
+ if (!providerConfig.userPoolId) {
+ throw this.boom.badRequest('Can not create Cognito User Pool Client. Missing userPoolId.', true);
+ }
+ const aws = await this.service('aws');
+ const cognitoIdentityServiceProvider = new aws.sdk.CognitoIdentityServiceProvider();
+
+ function getUrls() {
+ const websiteUrl = this.settings.get(settingKeys.websiteUrl);
+ const envType = this.settings.get(settingKeys.envType);
+ const callbackUrls = [websiteUrl];
+ const localUrl = 'http://localhost:3000';
+ let defaultRedirectUri;
+ if (envType === 'dev') {
+ defaultRedirectUri = localUrl;
+ // add localhost for callback url for local development in case of 'dev' environment
+ callbackUrls.push(localUrl);
+ } else {
+ defaultRedirectUri = websiteUrl;
+ }
+ // The logout urls are same as callback urls in our case
+ const logoutUrls = callbackUrls;
+ return { callbackUrls, defaultRedirectUri, logoutUrls };
+ }
+
+ let isClientConfiguredAlready = false;
+ if (providerConfig.clientId) {
+ try {
+ // If clientId is specified then make sure it exists in the given user pool
+ const result = await cognitoIdentityServiceProvider
+ .describeUserPoolClient({
+ ClientId: providerConfig.clientId,
+ UserPoolId: providerConfig.userPoolId,
+ })
+ .promise();
+
+ isClientConfiguredAlready = !!result.UserPoolClient;
+ } catch (e) {
+ if (e.code !== 'ResourceNotFoundException') {
+ // Swallow ResourceNotFoundException. In that case, the flag isClientConfiguredAlready will stay false.
+ // Propagate any other exception
+ throw e;
+ }
+ }
+ }
+
+ if (!isClientConfiguredAlready) {
+ // if client is not configured for the given user pool yet then create a new one
+ const { callbackUrls, defaultRedirectUri, logoutUrls } = getUrls.call(this);
+
+ const clientName = providerConfig.clientName || 'DataLakeClient';
+ const params = {
+ ClientName: clientName,
+ UserPoolId: providerConfig.userPoolId,
+ AllowedOAuthFlows: ['implicit'],
+ AllowedOAuthFlowsUserPoolClient: true,
+ AllowedOAuthScopes: ['email', 'openid', 'profile'],
+ CallbackURLs: callbackUrls,
+ DefaultRedirectURI: defaultRedirectUri,
+ ExplicitAuthFlows: ['ADMIN_NO_SRP_AUTH'],
+ LogoutURLs: logoutUrls,
+ // Make certain attributes readable and writable by this client.
+ // See "https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html" for list of attributes Cognito supports by default
+ ReadAttributes: [
+ 'address',
+ 'birthdate',
+ 'family_name',
+ 'gender',
+ 'given_name',
+ 'locale',
+ 'middle_name',
+ 'name',
+ 'nickname',
+ 'phone_number',
+ 'phone_number_verified',
+ 'picture',
+ 'preferred_username',
+ 'profile',
+ 'updated_at',
+ 'website',
+ 'zoneinfo',
+ 'email',
+ 'email_verified',
+ ],
+ WriteAttributes: ['email', 'family_name', 'given_name', 'middle_name', 'name'],
+ RefreshTokenValidity: 7, // Allowing refresh token to be used for one week
+ };
+ const data = await cognitoIdentityServiceProvider.createUserPoolClient(params).promise();
+ providerConfig.clientId = data.UserPoolClient.ClientId;
+ }
+ return providerConfig;
+ }
+
+ async updateUserPoolClient(providerConfig) {
+ this.log.info('Updating Cognito User Pool Client');
+ if (!providerConfig.userPoolId) {
+ throw this.boom.badRequest('Can not update Cognito User Pool Client. Missing userPoolId.', true);
+ }
+ if (!providerConfig.clientId) {
+ throw this.boom.badRequest('Can not update Cognito User Pool Client. Missing clientId.', true);
+ }
+ const aws = await this.service('aws');
+ const cognitoIdentityServiceProvider = new aws.sdk.CognitoIdentityServiceProvider();
+
+ // At this point the cognito client should have already been created.
+ const result = await cognitoIdentityServiceProvider
+ .describeUserPoolClient({
+ ClientId: providerConfig.clientId,
+ UserPoolId: providerConfig.userPoolId,
+ })
+ .promise();
+ const existingClientConfig = result.UserPoolClient;
+
+ let supportedIdpNames = [];
+ if (!_.isEmpty(providerConfig.federatedIdentityProviders)) {
+ // federatedIdentityProviders are provided so assume SAML federation
+ //
+ // federatedIdentityProviders -- an array of federated identity provider info objects with following shape
+ // [{
+ // id: 'some-id-of-the-idp' (such as 'com.amazonaws' etc. The usual practice is to keep this same as the domain name of the idp.)
+ // name: 'some-idp-name' (such as 'com.amazonaws', 'AmazonAWSEmployees' etc)
+ // displayName: 'some-displayable-name-for-the-idp' (such as 'Internal Users', 'External Users' etc)
+ // metadata: 'SAML XML Metadata blob for the identity provider or a URI pointing to a location that will provide the SAML metadata'
+ // }]
+ const idpNames = _.map(providerConfig.federatedIdentityProviders, idp => idp.name);
+ supportedIdpNames = idpNames;
+ }
+
+ // Enable Cognito as an auth provider for the app client if configured
+ if (providerConfig.enableNativeUserPoolUsers) {
+ supportedIdpNames.push('COGNITO');
+ }
+
+ const params = {
+ ClientId: existingClientConfig.ClientId,
+ UserPoolId: existingClientConfig.UserPoolId,
+ AllowedOAuthFlows: existingClientConfig.AllowedOAuthFlows,
+ AllowedOAuthFlowsUserPoolClient: existingClientConfig.AllowedOAuthFlowsUserPoolClient,
+ AllowedOAuthScopes: existingClientConfig.AllowedOAuthScopes,
+ CallbackURLs: existingClientConfig.CallbackURLs,
+ ClientName: existingClientConfig.ClientName,
+ DefaultRedirectURI: existingClientConfig.DefaultRedirectURI,
+ ExplicitAuthFlows: existingClientConfig.ExplicitAuthFlows,
+ LogoutURLs: existingClientConfig.LogoutURLs,
+ ReadAttributes: existingClientConfig.ReadAttributes,
+ RefreshTokenValidity: existingClientConfig.RefreshTokenValidity,
+ WriteAttributes: existingClientConfig.WriteAttributes,
+ SupportedIdentityProviders: supportedIdpNames,
+ };
+ // The following update call with SupportedIdentityProviders must be made only AFTER creating the identity providers (happening in "configureCognitoIdentityProviders")
+ // The below call will fail without that. The idp names specified in SupportedIdentityProviders must match the ones created in "configureCognitoIdentityProviders"
+ await cognitoIdentityServiceProvider.updateUserPoolClient(params).promise();
+ return providerConfig;
+ }
+
+ async configureCognitoIdentityProviders(providerConfig) {
+ // federatedIdentityProviders -- an array of federated identity provider info objects with following shape
+ // [{
+ // id: 'some-id-of-the-idp' (such as 'com.amazonaws' etc. The usual practice is to keep this same as the domain name of the idp.
+ // For example, when connecting with an IdP that has users "user1@domain1.com", "user2@domain1.com" etc then the "id" should
+ // be set to "domain1.com")
+ //
+ // name: 'some-idp-name' (such as 'com.amazonaws', 'AmazonAWSEmployees' etc)
+ //
+ // displayName: 'some-displayable-name-for-the-idp' (such as 'Internal Users', 'External Users' etc)
+ //
+ // metadata: 'SAML XML Metadata blob for the identity provider or a URI pointing to a location that will provide the SAML metadata'
+ // }]
+ if (_.isEmpty(providerConfig.federatedIdentityProviders)) {
+ // No IdPs to add. Just exit.
+ return providerConfig;
+ }
+
+ this.log.info('Configuring Cognito Identity Providers');
+ const aws = await this.service('aws');
+ const cognitoIdentityServiceProvider = new aws.sdk.CognitoIdentityServiceProvider();
+
+ const idpCreationPromises = _.map(providerConfig.federatedIdentityProviders, async idp => {
+ let metadata = idp.metadata;
+
+ if (metadata.startsWith('s3://')) {
+ const s3Service = await this.service('s3Service');
+ const { s3BucketName, s3Key } = s3Service.parseS3Details(metadata);
+ const result = await s3Service.api.getObject({ Bucket: s3BucketName, Key: s3Key }).promise();
+ metadata = result.Body.toString('utf8');
+ }
+
+ const metaDataInfo = { IDPSignout: 'true' };
+ if (/^https?:\/\//.test(metadata)) {
+ metaDataInfo.MetadataURL = metadata;
+ } else {
+ metaDataInfo.MetadataFile = metadata;
+ }
+
+ const params = {
+ ProviderDetails: metaDataInfo,
+ ProviderName: idp.name /* required */,
+ ProviderType: 'SAML' /* required */, // TODO: Add support for other Federation providers
+ UserPoolId: providerConfig.userPoolId /* required */,
+ AttributeMapping: {
+ // TODO: Add support for configurable attributes mapping
+ name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
+ given_name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
+ family_name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
+ email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
+ },
+ IdpIdentifiers: [idp.id],
+ };
+
+ // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CognitoIdentityServiceProvider.html#createIdentityProvider-property
+ try {
+ await cognitoIdentityServiceProvider.createIdentityProvider(params).promise();
+ } catch (err) {
+ if (err.code === 'DuplicateProviderException') {
+ // the identity provider already exists so update it instead of creating it
+ await cognitoIdentityServiceProvider
+ .updateIdentityProvider({
+ ProviderName: params.ProviderName,
+ UserPoolId: params.UserPoolId,
+ AttributeMapping: params.AttributeMapping,
+ IdpIdentifiers: params.IdpIdentifiers,
+ ProviderDetails: params.ProviderDetails,
+ })
+ .promise();
+ } else {
+ // In case of any other error just rethrow
+ throw err;
+ }
+ }
+ });
+ await Promise.all(idpCreationPromises);
+ return providerConfig;
+ }
+
+ async configureUserPoolDomain(providerConfig) {
+ this.log.info('Configuring Cognito User Pool Domain');
+ const userPoolId = providerConfig.userPoolId;
+ // The Domain Name Prefix for the Cogito User Pool. This will be used as a prefix to form the Fully Qualified Domain Name (FQDN)
+ // for the Cognito User Pool. The Conito User Pool FQDN URL is passed to SAML IdP. The SAML IdP then returns the SAML assertion
+ // back by redirecting the client to this URL.
+ const envType = this.settings.get(settingKeys.envType);
+ const envName = this.settings.get(settingKeys.envName);
+ const solutionName = this.settings.get(settingKeys.solutionName);
+ const userPoolDomain = providerConfig.userPoolDomain || `${envName}-${envType}-${solutionName}`;
+ const params = {
+ Domain: userPoolDomain,
+ UserPoolId: userPoolId,
+ };
+
+ const aws = await this.service('aws');
+ const cognitoIdentityServiceProvider = new aws.sdk.CognitoIdentityServiceProvider();
+
+ try {
+ await cognitoIdentityServiceProvider.createUserPoolDomain(params).promise();
+ } catch (err) {
+ if (err.code === 'InvalidParameterException' && err.message.indexOf('already exists') >= 0) {
+ // The domain already exists so nothing to do. Just log and move on.
+ this.log.info(`The Cognito User Pool Domain with Prefix "${userPoolDomain}" already exists. Nothing to do.`);
+ }
+ }
+ providerConfig.userPoolDomain = userPoolDomain;
+ return providerConfig;
+ }
+}
+
+module.exports = ProvisionerService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/type.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/type.js
new file mode 100644
index 0000000000..8870ee183b
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/type.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const { inputManifestForCreate } = require('./create-cognito-user-pool-input-manifest');
+const { inputManifestForUpdate } = require('./update-cognito-user-pool-input-manifest');
+const inputSchema = require('./create-cognito-user-pool-schema');
+
+module.exports = {
+ type: 'cognito_user_pool',
+ title: 'Cognito User Pool',
+ description: 'Authentication provider for Amazon Cognito User Pool',
+ config: {
+ // credentialHandlingType indicating credential handling for the authentication provider
+ // Possible values:
+ // 'submit' -- The credentials should be submitted to the URL provided by the authentication provider
+ // 'redirect' -- The credentials should be NOT be collected and the user should be redirected directly to the
+ // URL provided by the authentication provider. For example, in case of SAML auth, the username/password
+ // should not be collected by the service provider but the user should be redirected to the identity provider
+ credentialHandlingType: 'redirect',
+
+ // "inputSchema": JSON schema representing inputs required from user when configuring an authentication provider of this type.
+ inputSchema,
+
+ // The "inputManifest*" will be used on the UI to ask configuration inputs from the user when registering new
+ // authentication provider
+ inputManifestForCreate,
+ inputManifestForUpdate,
+
+ impl: {
+ // In case of Cognito User Pools, the ID token is issued by the User Pool
+ // the tokenIssuerLocator is not applicable in this case
+ // tokenIssuerLocator: '',
+
+ // Similar to the tokenIssuerLocator mentioned above but used for token validation instead of issuing token.
+ // The token validation locator is used to validate token upon each request.
+ // Unlike the tokenIssuerLocator which is only used for authentication being performed via application APIs, the
+ // tokenValidatorLocator is used in all cases
+ tokenValidatorLocator: 'locator:service:cognitoUserPoolAuthenticationProviderService/validateToken',
+
+ // Similar to the tokenIssuerLocator mentioned above but used for token revocation instead of issuing token.
+ // The token revocation locator is used to revoke a token upon logout.
+ tokenRevokerLocator: 'locator:service:cognitoUserPoolAuthenticationProviderService/revokeToken',
+
+ // Similar to above locators. The provisionerLocator identifies an implementation that takes care of provisioning the authentication provider.
+ // In case of Internal Authentication Provider this "provisioning" step may be as simple as adding authentication provider configuration in Data Base.
+ // In case of other auth providers, this step may be more elaborate (for example, in case of Cognito + SAML, the provisioner has to create Cognito User Pool,
+ // configure cognito client application, configure SAML identity providers in the Cognito User Pool etc.
+ provisionerLocator: 'locator:service:cognitoUserPoolAuthenticationProvisionerService/provision',
+ },
+ },
+};
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/update-cognito-user-pool-input-manifest.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/update-cognito-user-pool-input-manifest.js
new file mode 100644
index 0000000000..03a9ec2115
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/update-cognito-user-pool-input-manifest.js
@@ -0,0 +1,106 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const inputManifestForUpdate = {
+ sections: [
+ {
+ title: 'General Information',
+ children: [
+ {
+ name: 'id',
+ type: 'stringInput',
+ title: 'ID',
+ rules: 'required|string|between:2,64|regex:/^[a-zA-Z][a-zA-Z0-9_-]+$/',
+ desc:
+ 'This is a required field. This is used for uniquely identifying the authentication provider. ' +
+ 'This must be same as the Cognito User Pool Provider URL in ' +
+ '"https://cognito-idp.{aws-region}.amazonaws.com/{user-pool-id}" format',
+ },
+ {
+ name: 'title',
+ type: 'stringInput',
+ title: 'Title',
+ rules: 'required|between:3,255',
+ desc: 'This is a required field and must be between 3 and 255 characters long.',
+ },
+ ],
+ },
+ {
+ title: 'Existing Cognito User Pool Information (Optional)',
+ children: [
+ {
+ name: 'userPoolId',
+ type: 'stringInput',
+ title: 'Cognito User Pool ID',
+ desc: 'Enter the ID of the cognito user pool you want to connect to.',
+ },
+ {
+ name: 'userPoolName',
+ type: 'stringInput',
+ title: 'Cognito User Pool Name',
+ desc: 'Enter name of the cognito user pool you want to connect to.',
+ },
+ ],
+ },
+ {
+ title: 'Configure Identity Federation (Optional)',
+ // TODO: Add support for array input types in input manifest
+ // this is required for allowing to dynamically configure multiple federatedIdentityProviders
+ // The children in the input manifest sections tree are all expected to be flat key,value pairs
+ // The names below are based on object path
+ // For example, the authentication providers create API expects structure to be
+ // {
+ // ...
+ // federatedIdentityProviders: [
+ // {
+ // id, // This is named federatedIdentityProviders_0_id below as this is the "id" of the first element (i.e., at "0" index) in the array "federatedIdentityProviders"
+ // name,
+ // displayName,
+ // metadata
+ // }
+ // ]
+ // }
+ children: [
+ {
+ name: 'federatedIdentityProviders|-0-|/id',
+ type: 'stringInput',
+ title: 'Identity Provider ID (IdP Id)',
+ desc:
+ 'An identifier for the federated identity provider. This will be used for identifying the IdP. Usually this is configured to be same as the domain name of the IdP (E.g., amazonaws.com).',
+ },
+ {
+ name: 'federatedIdentityProviders|-0-|/name',
+ type: 'stringInput',
+ title: 'Identity Provider Name',
+ desc: 'Name for the identity provider.',
+ },
+ {
+ name: 'federatedIdentityProviders|-0-|/displayName',
+ type: 'stringInput',
+ title: 'Identity Provider Display Name',
+ desc: 'Optional display name for the identity provider.',
+ },
+ {
+ name: 'federatedIdentityProviders|-0-|/metadata',
+ type: 'textAreaInput',
+ title: 'Identity Provider SAML Metadata XML',
+ desc: 'Enter identity provider SAML metadata XML document for setting up trust.',
+ },
+ ],
+ },
+ ],
+};
+
+module.exports = { inputManifestForUpdate };
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/user-attributes-mapper-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/user-attributes-mapper-service.js
new file mode 100644
index 0000000000..a1923ebef2
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/cogito-user-pool/user-attributes-mapper-service.js
@@ -0,0 +1,87 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+class UserAttributesMapperService extends Service {
+ mapAttributes(decodedToken) {
+ const { username, usernameInIdp } = this.getUsername(decodedToken);
+ const identityProviderName = this.getIdpName(decodedToken);
+ const isSamlAuthenticatedUser = this.isSamlAuthenticatedUser(decodedToken);
+ const firstName = this.getFirstName(decodedToken);
+ const lastName = this.getLastName(decodedToken);
+ const email = this.getEmail(decodedToken);
+
+ return {
+ username,
+ usernameInIdp,
+ identityProviderName,
+ isSamlAuthenticatedUser,
+
+ firstName,
+ lastName,
+ email,
+ };
+ }
+
+ getEmail(decodedToken) {
+ return decodedToken.email;
+ }
+
+ getLastName(decodedToken) {
+ return decodedToken.family_name;
+ }
+
+ getFirstName(decodedToken) {
+ return decodedToken.given_name;
+ }
+
+ isSamlAuthenticatedUser(decodedToken) {
+ const isSamlAuthenticatedUser =
+ decodedToken.identities &&
+ decodedToken.identities[0] &&
+ _.toUpper(decodedToken.identities[0].providerType) === 'SAML';
+ return isSamlAuthenticatedUser;
+ }
+
+ getIdpName(decodedToken) {
+ let identityProviderName = '';
+ if (decodedToken.identities && decodedToken.identities[0] && decodedToken.identities[0].providerName) {
+ identityProviderName = decodedToken.identities[0].providerName;
+ }
+ return identityProviderName;
+ }
+
+ getUsername(decodedToken) {
+ let username = decodedToken['cognito:username'];
+ let usernameInIdp = username;
+ if (username.indexOf('\\') > -1) {
+ // the cognito username may contain backslash (in case the user is authenticated via some other identity provider
+ // via federation - such as SAML replace backslash with underscore in such case to satisfy various naming
+ // constraints in our code base this is because we use the username for automatically naming various dependent
+ // resources (such as IAM roles, policies, unix user groups etc) The backslash would not work in most of those
+ // cases
+ // Grab raw username on the IDP side. This is needed in certain situations
+ // For example, when creating user home directories on jupyter for LDAP users, the directory name needs to match
+ // username in IDP (i.e., AD or LDAP)
+ usernameInIdp = _.split(username, '\\')[1];
+ username = username.replace('\\', '_');
+ }
+ return { username, usernameInIdp };
+ }
+}
+
+module.exports = UserAttributesMapperService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/api-key-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/api-key-service.js
new file mode 100644
index 0000000000..7b7fd62b80
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/api-key-service.js
@@ -0,0 +1,262 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const uuid = require('uuid');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const { ensureCurrentUserOrAdmin, isCurrentUser } = require('@aws-ee/base-services/lib/authorization/assertions');
+
+const authProviderConstants = require('../../constants').authenticationProviders;
+
+const settingKeys = {
+ tableName: 'dbTableUserApiKeys',
+};
+const maxActiveApiKeysPerUser = 5;
+
+const redactIfNotForCurrentUser = (requestContext, apiKey, username, ns) => {
+ if (!isCurrentUser(requestContext, username, ns)) {
+ // if the api key is issued for some other user then redact the api key material as user should be
+ // only able to read his/her own api key value (even if the user is an admin)
+ apiKey.key = undefined;
+ }
+ return apiKey;
+};
+const encode = (username, ns) => `${ns}/${username}`;
+
+class ApiKeyService extends Service {
+ constructor() {
+ super();
+ this.dependency(['dbService', 'jwtService']);
+ this.boom.extend(['invalidCredentials', 401]);
+ this.boom.extend(['maxApiKeysLimitReached', 400]);
+ }
+
+ async init() {
+ await super.init();
+ const createInternals = async () => {
+ const [dbService] = await this.service(['dbService']);
+ const table = this.settings.get(settingKeys.tableName);
+ const dbGetter = () => dbService.helper.getter().table(table);
+ const dbUpdater = () => dbService.helper.updater().table(table);
+ const dbQuery = () => dbService.helper.query().table(table);
+ const ensureMaxApiKeysLimitNotReached = async (requestContext, { username, ns }) => {
+ const existingApiKeys = await this.getApiKeys(requestContext, {
+ username,
+ ns,
+ });
+ const existingActiveApiKeys = _.filter(existingApiKeys, apiKey => {
+ const isActive = apiKey.status === 'active';
+ const isExpired = apiKey.expiryTime && _.now() > apiKey.expiryTime;
+ return isActive && !isExpired;
+ });
+ if (existingActiveApiKeys.length >= maxActiveApiKeysPerUser) {
+ throw this.boom.maxApiKeysLimitReached(
+ `Cannot create API Key. Maximum ${maxActiveApiKeysPerUser} active API keys per user is allowed.`,
+ true,
+ );
+ }
+ };
+ return {
+ dbGetter,
+ dbUpdater,
+ dbQuery,
+ ensureMaxApiKeysLimitNotReached,
+ };
+ };
+ this.internals = await createInternals();
+ }
+
+ async createApiKeyMaterial(requestContext, { apiKeyId, username, ns, expiryTime }) {
+ if (!username) {
+ throw this.boom.badRequest(
+ "Cannot issue API Key. Missing username. Don't know who to issue the API key for.",
+ true,
+ );
+ }
+ if (expiryTime && !_.isNumber(expiryTime)) {
+ // Make sure if "expiryTime" is specified then it is a valid "NumericDate" i.e., epoch time as number
+ throw this.boom.badRequest(
+ 'Cannot issue API Key. Invalid expiryTime specified. expiryTime is optional. ' +
+ 'If it is specified then it must be a number indicating epoch time',
+ true,
+ );
+ }
+
+ const authenticationProviderId = _.get(requestContext, 'principal.authenticationProviderId');
+ const identityProviderName = _.get(requestContext, 'principal.identityProviderName');
+
+ // The JWT service sets "expiresIn" by default based on the settings.
+ // Make sure JWT service does not set it here as we are controlling that via the "exp" claim directly
+ const apiKeyJwtToken = {
+ 'sub': username,
+ 'iss': authProviderConstants.internalAuthProviderId, // This is validated by internal auth provider so set issuer as internal
+ // Add private claims under "custom:". These claims are then used at the time of verifying token
+ 'custom:tokenType': 'api',
+ 'custom:apiKeyId': apiKeyId,
+ 'custom:userNs': ns,
+ 'custom:authenticationProviderId': authenticationProviderId,
+ 'custom:identityProviderName': identityProviderName,
+ };
+ // If expiryTime is specified then set it
+ if (expiryTime) {
+ // If code reached here then it means valid expiryTime is specified
+ apiKeyJwtToken.exp = expiryTime;
+ }
+
+ const jwtService = await this.service('jwtService');
+ return jwtService.sign(apiKeyJwtToken, { expiresIn: undefined });
+ }
+
+ async revokeApiKey(requestContext, { username, ns, keyId }) {
+ // ensure the caller is asking to revoke api key for him/herself or is admin
+ await ensureCurrentUserOrAdmin(requestContext, username, ns);
+
+ // ensure that the key exists and set it's status to "reovked", if it does
+ const unameWithNs = encode(username, ns);
+ const apiKey = await this.internals
+ .dbGetter()
+ .key({ unameWithNs, id: keyId })
+ .get();
+ if (!apiKey) {
+ throw this.boom.badRequest('Cannot revoke API Key. The API key does not exist.', true);
+ }
+ apiKey.status = 'revoked';
+
+ // Update the key with "revoked" status
+ const revokedApiKey = await this.internals
+ .dbUpdater()
+ .key({ unameWithNs, id: apiKey.id })
+ .item(apiKey)
+ .update();
+
+ return redactIfNotForCurrentUser(requestContext, revokedApiKey, username, ns);
+ }
+
+ async issueApiKey(requestContext, { username, ns, expiryTime }) {
+ // ensure the caller is asking to issue new api key for him/herself or is admin
+ await ensureCurrentUserOrAdmin(requestContext, username, ns);
+
+ // ensure max active api keys limit is not reached before issuing new key
+ await this.internals.ensureMaxApiKeysLimitNotReached(requestContext, {
+ username,
+ ns,
+ });
+
+ // create new api key object
+ const apiKeyId = uuid.v4();
+ const apiKeyMaterial = await this.createApiKeyMaterial(requestContext, {
+ apiKeyId,
+ username,
+ ns,
+ expiryTime,
+ });
+ const unameWithNs = encode(username, ns);
+ // TODO: Add TTL based on revocation status and expiry time
+ const newApiKey = {
+ unameWithNs,
+ username,
+ ns,
+ id: apiKeyId,
+ key: apiKeyMaterial,
+ status: 'active',
+ };
+ if (expiryTime) {
+ newApiKey.expiryTime = expiryTime;
+ }
+
+ // save api key to db
+ const apiKey = await this.internals
+ .dbUpdater()
+ .key({ unameWithNs, id: newApiKey.id })
+ .item(newApiKey)
+ .update();
+
+ // sanitize
+ return redactIfNotForCurrentUser(requestContext, apiKey, username, ns);
+ }
+
+ async validateApiKey(signedApiKey) {
+ // Make sure the key is specified
+ if (_.isEmpty(signedApiKey)) {
+ throw this.boom.forbidden('no api key was provided', true);
+ }
+
+ // Make sure the key is a valid non-expired JWT token and has correct signature
+ const jwtService = await this.service('jwtService');
+ const verifiedApiKeyJwtToken = await jwtService.verify(signedApiKey);
+ const { 'sub': username, 'custom:userNs': ns } = verifiedApiKeyJwtToken;
+ if (_.isEmpty(username)) {
+ throw this.boom.invalidToken('No "sub" is provided in the api key', true);
+ }
+
+ // Make sure the key is an API key (and not the regular JWT token)
+ if (!this.isApiKeyToken(verifiedApiKeyJwtToken)) {
+ throw this.boom.invalidToken('The given key is not valid for API access', true);
+ }
+
+ // Make sure the key is an active key (by checking it against the DB)
+ const apiKeyId = _.get(verifiedApiKeyJwtToken, 'custom:apiKeyId');
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+ const unameWithNs = encode(username, ns);
+ const apiKey = await dbService.helper
+ .getter()
+ .table(table)
+ .key({ unameWithNs, id: apiKeyId })
+ .get();
+
+ if (!apiKey) {
+ throw this.boom.invalidToken('The given key is not valid for API access', true);
+ }
+ if (apiKey.status !== 'active') {
+ throw this.boom.invalidToken('The given API key is not active', true);
+ }
+ // If code reached here then this is a valid key
+ return { verifiedToken: verifiedApiKeyJwtToken, username, ns };
+ }
+
+ async getApiKeys(requestContext, { username, ns }) {
+ // ensure the caller is asking retrieve api keys for him/herself or is admin
+ await ensureCurrentUserOrAdmin(requestContext, username, ns);
+ const unameWithNs = encode(username, ns);
+ const apiKeys = await this.internals
+ .dbQuery()
+ .key('unameWithNs', unameWithNs)
+ .query();
+
+ return _.map(apiKeys, apiKey => redactIfNotForCurrentUser(requestContext, apiKey, username, ns));
+ }
+
+ async getApiKey(requestContext, { username, ns, keyId }) {
+ // ensure the caller is asking to retrieve api key for him/herself or is admin
+ await ensureCurrentUserOrAdmin(requestContext, username, ns);
+
+ const unameWithNs = encode(username, ns);
+ const apiKey = await this.internals
+ .dbGetter()
+ .key({ unameWithNs, id: keyId })
+ .get();
+
+ return redactIfNotForCurrentUser(requestContext, apiKey, username, ns);
+ }
+
+ async isApiKeyToken(decodedToken) {
+ const tokenType = _.get(decodedToken, 'custom:tokenType');
+ const apiKeyId = _.get(decodedToken, 'custom:apiKeyId');
+ return tokenType === 'api' && !!apiKeyId;
+ }
+}
+
+module.exports = ApiKeyService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/provider-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/provider-service.js
new file mode 100644
index 0000000000..25b0b4d3b8
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/provider-service.js
@@ -0,0 +1,80 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const Service = require('@aws-ee/base-services-container/lib/service');
+const authProviderConstants = require('../../constants').authenticationProviders;
+
+class ProviderService extends Service {
+ constructor() {
+ super();
+ this.dependency(['dbAuthenticationService', 'jwtService', 'tokenRevocationService']);
+ }
+
+ async init() {
+ await super.init();
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async issueToken({ username, password }, providerConfig) {
+ const [dbAuthenticationService, jwtService] = await this.service(['dbAuthenticationService', 'jwtService']);
+
+ await dbAuthenticationService.authenticate({
+ username,
+ password,
+ });
+ const idToken = await jwtService.sign({
+ sub: username,
+
+ // The "iss" (i.e., the issuer) below is used for selecting appropriate authentication provider
+ // for validating JWT tokens on subsequent requests.
+ // See "issuer" claim in JWT RFC - https://tools.ietf.org/html/rfc7519#section-4.1 for
+ // information about this claim
+ iss: authProviderConstants.internalAuthProviderId,
+ });
+ return idToken;
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async validateToken({ token, issuer }, providerConfig) {
+ if (_.isEmpty(token)) {
+ throw this.boom.forbidden('no jwt token was provided', true);
+ }
+ const jwtService = await this.service('jwtService');
+ const verifiedToken = await jwtService.verify(token);
+ const { sub: username } = verifiedToken;
+
+ if (_.isEmpty(username)) {
+ throw this.boom.invalidToken('No "sub" is provided in the token', true);
+ }
+
+ // -- Check if this token is revoked (may be due to an earlier logout)
+ const tokenRevocationService = await this.service('tokenRevocationService');
+ const isRevoked = await tokenRevocationService.isRevoked({ token });
+ if (isRevoked) {
+ throw this.boom.invalidToken('The token is revoked', true);
+ }
+
+ return { verifiedToken, username };
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ async revokeToken(requestContext, { token }, providerConfig) {
+ const tokenRevocationService = await this.service('tokenRevocationService');
+ await tokenRevocationService.revoke(requestContext, { token });
+ }
+}
+
+module.exports = ProviderService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/provisioner-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/provisioner-service.js
new file mode 100644
index 0000000000..f3fbab27df
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/provisioner-service.js
@@ -0,0 +1,39 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+const authProviderConstants = require('../../constants').authenticationProviders;
+
+class ProvisionerService extends Service {
+ constructor() {
+ super();
+ this.dependency(['authenticationProviderConfigService']);
+ }
+
+ async provision({ providerTypeConfig, providerConfig }) {
+ // There is nothing to do for internal auth provider provisioning
+ // except for saving the configuration in DB which is handled by authenticationProviderPublicConfigService
+ this.log.info('Provisioning internal authentication provider');
+ const authenticationProviderConfigService = await this.service('authenticationProviderConfigService');
+ const result = await authenticationProviderConfigService.saveAuthenticationProviderConfig({
+ providerTypeConfig,
+ providerConfig,
+ status: authProviderConstants.status.active,
+ });
+ return result;
+ }
+}
+
+module.exports = ProvisionerService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/type.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/type.js
new file mode 100644
index 0000000000..368f502647
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/built-in-providers/internal/type.js
@@ -0,0 +1,165 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+module.exports = {
+ type: 'internal',
+ title: 'Internal',
+ description:
+ 'This is a built-in internal authentication provider. ' +
+ 'The internal authentication provider uses an internal user directory for authenticating ' +
+ 'the users. This provider is only intended to be used for development and testing. It currently ' +
+ 'lacks many features required for production usage such as ability to force password rotations, ability ' +
+ 'to reset passwords, and support "forgot password" etc. For production use, please add other ' +
+ 'authentication provider with identity federation for production use.',
+ config: {
+ // credentialHandlingType indicating credential handling for the authentication provider
+ // Possible values:
+ // 'submit' -- The credentials should be submitted to the URL provided by the authentication provider
+ // 'redirect' -- The credentials should be NOT be collected and the user should be redirected directly to the
+ // URL provided by the authentication provider. For example, in case of SAML auth, the username/password
+ // should not be collected by the service provider but the user should be redirected to the identity provider
+ credentialHandlingType: 'submit',
+
+ // "inputSchema": JSON schema representing inputs required from user when configuring an authentication provider of this type.
+ inputSchema: {
+ definitions: {},
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ $id: 'http://example.com/root.json',
+ type: 'object',
+ required: ['id', 'title', 'signInUri'],
+ properties: {
+ id: {
+ $id: '#/properties/id',
+ type: 'string',
+ },
+ title: {
+ $id: '#/properties/title',
+ type: 'string',
+ },
+ signInUri: {
+ $id: '#/properties/signInUri',
+ type: 'string',
+ },
+ signOutUri: {
+ $id: '#/properties/signOutUri',
+ type: 'string',
+ },
+ },
+ },
+
+ // The "inputManifest" will be used on the UI to ask configuration inputs from the user when registering new
+ // authentication provider
+ inputManifestForCreate: {
+ sections: [
+ {
+ title: 'General Information',
+ children: [
+ {
+ name: 'id',
+ type: 'stringInput',
+ title: 'ID',
+ rules: 'required|string|between:2,64|regex:/^[a-zA-Z][a-zA-Z0-9_-]+$/',
+ desc:
+ 'This is a required field. This is used for uniquely identifying the authentication provider. ' +
+ 'It must be between 2 to 64 characters long and must start with an alphabet and may contain alpha numeric ' +
+ 'characters, underscores, and dashes. No other special symbols are allowed.',
+ },
+ {
+ name: 'title',
+ type: 'stringInput',
+ title: 'Title',
+ rules: 'required|between:3,255',
+ desc: 'This is a required field and must be between 3 and 255 characters long.',
+ },
+ {
+ name: 'signInUri',
+ type: 'stringInput',
+ title: 'Sign In URI',
+ rules: 'required|between:3,255',
+ desc: 'The Sign In URI that accepts username/password for signing in.',
+ },
+ {
+ name: 'signOutUri',
+ type: 'stringInput',
+ title: 'Sign Out URI',
+ rules: 'required|between:3,255',
+ desc: 'The Sign Out URI to log out user.',
+ },
+ ],
+ },
+ ],
+ },
+ inputManifestForUpdate: {
+ sections: [
+ {
+ title: 'General Information',
+ children: [
+ {
+ name: 'id',
+ type: 'stringInput',
+ title: 'ID',
+ rules: 'required|string|between:2,64|regex:/^[a-zA-Z][a-zA-Z0-9_-]+$/',
+ desc:
+ 'This is a required field. This is used for uniquely identifying the authentication provider. ' +
+ 'It must be between 2 to 64 characters long and must start with an alphabet and may contain alpha numeric ' +
+ 'characters, underscores, and dashes. No other special symbols are allowed.',
+ },
+ {
+ name: 'title',
+ type: 'stringInput',
+ title: 'Title',
+ rules: 'required|between:3,255',
+ desc: 'This is a required field and must be between 3 and 255 characters long.',
+ },
+ ],
+ },
+ ],
+ },
+
+ impl: {
+ // A locator that identifies the authentication provider implementation
+ //
+ // The implementation may be internal to the code base or could be provided by some external system via APIs (in future)
+ // In case of internal implementation, the below locator should start with 'locator:service:/' pointing to the
+ // service and method for issuing token. The specified method will be invoked with the authentication request body
+ // and the authentication provide configuration
+ //
+ // In case of external implementation provided via APIs, the below locator could be 'locator:external:' pointing to API for issuing token.
+ //
+ // The tokenIssuerLocator will be used only in cases the APIs are receiving credentials
+ // If the authentication is performed outside of the APIs directly, for example, when submitting
+ // credentials to Cognito User Pools directly or when using Cognito User Pool federation to some external identity providers
+ // (e.g., via SAML) then the JWT token is issued by Cognito User Pool outside of the the application code.
+ // The "tokenIssuerLocator" below will not be used in those cases.
+ tokenIssuerLocator: 'locator:service:internalAuthenticationProviderService/issueToken',
+
+ // Similar to the tokenIssuerLocator mentioned above but used for token validation instead of issuing token.
+ // The token validation locator is used to validate token upon each request.
+ // Unlike the tokenIssuerLocator which is only used for authentication being performed via application APIs, the
+ // tokenValidatorLocator is used in all cases
+ tokenValidatorLocator: 'locator:service:internalAuthenticationProviderService/validateToken',
+
+ // Similar to the tokenIssuerLocator mentioned above but used for token revocation instead of issuing token.
+ // The token revocation locator is used to revoke a token upon logout.
+ tokenRevokerLocator: 'locator:service:internalAuthenticationProviderService/revokeToken',
+
+ // Similar to above locators. The provisionerLocator identifies an implementation that takes care of provisioning the authentication provider.
+ // In case of Internal Authentication Provider this "provisioning" step may be as simple as adding authentication provider configuration in Data Base.
+ // In case of other auth providers, this step may be more elaborate (for example, in case of Cognito + SAML, the provisioner has to create Cognito User Pool,
+ // configure Cognito client application, configure SAML identity providers in the Cognito User Pool etc.)
+ provisionerLocator: 'locator:service:internalAuthenticationProvisionerService/provision',
+ },
+ },
+};
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/constants.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/constants.js
new file mode 100644
index 0000000000..ada4358a85
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/constants.js
@@ -0,0 +1,36 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// TODO - move this to base-services in base addon
+const constants = {
+ authenticationProviders: {
+ internalAuthProviderTypeId: 'internal',
+ internalAuthProviderId: 'internal',
+ cognitoAuthProviderTypeId: 'cognito_user_pool',
+ status: {
+ initializing: 'initializing',
+ active: 'active',
+ inactive: 'inactive',
+ },
+ provisioningAction: {
+ create: 'create',
+ update: 'update',
+ activate: 'activate',
+ deactivate: 'deactivate',
+ },
+ },
+};
+
+module.exports = constants;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/helpers/invoker.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/helpers/invoker.js
new file mode 100644
index 0000000000..12f76deb7d
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/helpers/invoker.js
@@ -0,0 +1,36 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const resolve = require('./resolver').resolve;
+
+function newInvoker(findServiceByName) {
+ return async (locator, ...args) => {
+ const resolvedLocator = resolve(locator);
+ switch (resolvedLocator.type) {
+ case 'service': {
+ const { serviceName, methodName } = resolvedLocator;
+ const instance = await findServiceByName(serviceName);
+ if (!instance) {
+ throw new Error(`unknown service: ${serviceName}`);
+ }
+ return instance[methodName].call(instance, ...args);
+ }
+ default:
+ throw new Error(`unsupported locator type: ${resolve.type}`);
+ }
+ };
+}
+
+module.exports = { newInvoker };
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/helpers/resolver.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/helpers/resolver.js
new file mode 100644
index 0000000000..abef020054
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/helpers/resolver.js
@@ -0,0 +1,68 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+
+function resolve(locator) {
+ // The authentication provider locators would be on of the following formats
+ //
+ // locator:service:/ -- for locator pointing to a method of a specific service
+ // (the service here refers to a service instance loaded by services container)
+ //
+ // locator:external-url: -- for locators pointing to a URL of a specific API
+ //
+ // Make sure the given locator is in one of the expected format
+ if (!_.startsWith(locator, 'locator:service:') && !_.startsWith(locator, 'locator:external-url:')) {
+ throw new Error(`Malformed locator: ${locator}`);
+ }
+
+ const locatorParts = _.split(locator, ':');
+ if (locatorParts.length < 3) {
+ throw new Error(
+ `Malformed locator: ${locator}. Supported locator formats are either
+ - "locator:service:/" or
+ - "locator:external-url:".`,
+ );
+ }
+
+ const typeIndicator = locatorParts[1];
+ const path = locatorParts[2]; // will be / or
+
+ const typeParsers = {
+ 'service': () => {
+ const [serviceName, methodName] = _.split(path, '/');
+ return {
+ type: typeIndicator,
+ serviceName,
+ methodName,
+ };
+ },
+ 'external-url': () => {
+ return {
+ type: typeIndicator,
+ url: path,
+ };
+ },
+ };
+ const typeParserFn = typeParsers[typeIndicator];
+ if (!typeParserFn) {
+ throw new Error(
+ `Malformed locator: ${locator}. Currently only supporting locators that resolve to a 'service' or and 'external-url'.`,
+ );
+ }
+ return typeParserFn();
+}
+
+module.exports = { resolve };
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/register-built-in-provider-services.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/register-built-in-provider-services.js
new file mode 100644
index 0000000000..44a21629de
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/register-built-in-provider-services.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const InternalAuthenticationProviderService = require('./built-in-providers/internal/provider-service');
+const CognitoUserPoolAuthenticationProviderService = require('./built-in-providers/cogito-user-pool/provider-service');
+const UserAttributesMapperService = require('./built-in-providers/cogito-user-pool/user-attributes-mapper-service');
+const ApiKeyService = require('./built-in-providers/internal/api-key-service');
+
+function registerBuiltInAuthProviders(container) {
+ // --- INTERNAL AUTHENTICATION PROVIDER RELATED --- //
+ // internal - provider
+ container.register('internalAuthenticationProviderService', new InternalAuthenticationProviderService());
+ // The api key authentication is always through the internal provider
+ container.register('apiKeyService', new ApiKeyService());
+
+ // --- COGNITO USER POOL AUTHENTICATION PROVIDER RELATED --- //
+ // cognito user pool - provider
+ container.register(
+ 'cognitoUserPoolAuthenticationProviderService',
+ new CognitoUserPoolAuthenticationProviderService(),
+ );
+ container.register('userAttributesMapperService', new UserAttributesMapperService());
+}
+
+module.exports = registerBuiltInAuthProviders;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-providers/register-built-in-provisioner-services.js b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/register-built-in-provisioner-services.js
new file mode 100644
index 0000000000..6b4eb4a2f8
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-providers/register-built-in-provisioner-services.js
@@ -0,0 +1,32 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const InternalAuthenticationProvisionerService = require('./built-in-providers/internal/provisioner-service');
+const CognitoUserPoolAuthenticationProvisionerService = require('./built-in-providers/cogito-user-pool/provisioner-service');
+
+function registerBuiltInAuthProvisioners(container) {
+ // --- INTERNAL AUTHENTICATION PROVIDER RELATED --- //
+ // internal - provisioner
+ container.register('internalAuthenticationProvisionerService', new InternalAuthenticationProvisionerService());
+
+ // --- COGNITO USER POOL AUTHENTICATION PROVIDER RELATED --- //
+ // cognito user pool - provider
+ container.register(
+ 'cognitoUserPoolAuthenticationProvisionerService',
+ new CognitoUserPoolAuthenticationProvisionerService(),
+ );
+}
+
+module.exports = registerBuiltInAuthProvisioners;
diff --git a/addons/addon-base-rest-api/packages/services/lib/authentication-service.js b/addons/addon-base-rest-api/packages/services/lib/authentication-service.js
new file mode 100644
index 0000000000..1712ee77c8
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/authentication-service.js
@@ -0,0 +1,151 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const jwtDecode = require('jwt-decode');
+const _ = require('lodash');
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+const internalAuthProviderId = require('./authentication-providers/constants').authenticationProviders
+ .internalAuthProviderId;
+const { newInvoker } = require('./authentication-providers/helpers/invoker');
+
+const notAuthenticated = claims => ({ ...claims, authenticated: false });
+const authenticated = claims => ({ ...claims, authenticated: true });
+
+class AuthenticationService extends Service {
+ constructor() {
+ super();
+ this.dependency(['authenticationProviderConfigService', 'apiKeyService', 'pluginRegistryService']);
+ }
+
+ async init() {
+ this.invoke = newInvoker(this.container.find.bind(this.container));
+ this.pluginRegistryService = await this.service('pluginRegistryService');
+ }
+
+ /**
+ * type AuthenticationResult = AuthenticationSuccess | AuthenticationFailed;
+ * type AuthenticationSuccess = {
+ * authenticated: true
+ * verifiedToken: Object
+ * username: string
+ * authenticationProviderId: string
+ * identityProviderName?: string
+ * }
+ * type AuthenticationError = {
+ * authenticated: false
+ * error: Error | string
+ * username?: string
+ * authenticationProviderId?: string
+ * identityProviderName?: string
+ * }
+ *
+ * @returns AuthenticationResult
+ */
+ // TODO return username even if authentication fails.
+ async authenticateMain(token) {
+ const [authenticationProviderConfigService, apiKeyService] = await this.service([
+ 'authenticationProviderConfigService',
+ 'apiKeyService',
+ ]);
+ if (!token) {
+ return notAuthenticated({ error: 'empty token' });
+ }
+ let claims;
+ try {
+ claims = jwtDecode(token);
+ } catch (error) {
+ return notAuthenticated({
+ error: `jwt decode error: ${error.toString()}`,
+ });
+ }
+ const isApiKey = await apiKeyService.isApiKeyToken(claims);
+ if (isApiKey) {
+ try {
+ const { verifiedToken, username } = await apiKeyService.validateApiKey(token);
+ return authenticated({
+ token,
+ isApiKey,
+ verifiedToken,
+ username,
+ authenticationProviderId: _.get(claims, 'custom:authenticationProviderId', internalAuthProviderId),
+ identityProviderName: _.get(claims, 'custom:identityProviderName', ''),
+ });
+ } catch (error) {
+ return notAuthenticated({
+ username: claims.sub,
+ authenticationProviderId: _.get(claims, 'custom:authenticationProviderId', internalAuthProviderId),
+ identityProviderName: _.get(claims, 'custom:identityProviderName', ''),
+ error,
+ });
+ }
+ }
+ const providerId = claims.iss || internalAuthProviderId;
+ const providerConfig = await authenticationProviderConfigService.getAuthenticationProviderConfig(providerId);
+ if (!providerConfig) {
+ return notAuthenticated({
+ username: claims.sub,
+ authenticationProviderId: claims.iss,
+ error: `unknown provider id: '${providerId}'`,
+ });
+ }
+ let tokenValidatorLocator;
+ try {
+ tokenValidatorLocator = providerConfig.config.type.config.impl.tokenValidatorLocator;
+ } catch (error) {
+ // exceptional circumstance, throw an actual error
+ throw new Error(`malformed provider config for provider id '${providerId}'`);
+ }
+ try {
+ const { verifiedToken, username, identityProviderName } = await this.invoke(
+ tokenValidatorLocator,
+ { token, issuer: claims.iss },
+ providerConfig,
+ );
+ return authenticated({
+ token,
+ verifiedToken,
+ username,
+ identityProviderName,
+ authenticationProviderId: providerId,
+ });
+ } catch (error) {
+ return notAuthenticated({
+ username: claims.sub,
+ authenticationProviderId: claims.iss,
+ error,
+ });
+ }
+ }
+
+ async authenticate(token) {
+ const originalAuthResult = await this.authenticateMain(token);
+ // Give all plugins a chance to customize the authentication result
+ return this.checkWithPlugins(token, originalAuthResult);
+ }
+
+ async checkWithPlugins(token, authResult) {
+ // Give all plugins a chance to customize the authentication result
+ // This gives plugins registered to 'authentication' a chance to participate in the token
+ // validation/authentication process
+ const result = await this.pluginRegistryService.visitPlugins('authentication', 'authenticate', {
+ payload: { token, container: this.container, authResult },
+ });
+ const effectiveAuthResult = _.get(result, 'authResult', authResult);
+ return effectiveAuthResult;
+ }
+}
+
+module.exports = AuthenticationService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/db-authentication-service.js b/addons/addon-base-rest-api/packages/services/lib/db-authentication-service.js
new file mode 100644
index 0000000000..607e4f3865
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/db-authentication-service.js
@@ -0,0 +1,44 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const inputSchema = require('./schema/username-password-credentials');
+
+class DbAuthenticationService extends Service {
+ constructor() {
+ super();
+ this.boom.extend(['invalidCredentials', 401]);
+ this.dependency(['jsonSchemaValidationService', 'dbPasswordService']);
+ }
+
+ async authenticate(credentials) {
+ const [jsonSchemaValidationService, dbPasswordService] = await this.service([
+ 'jsonSchemaValidationService',
+ 'dbPasswordService',
+ ]);
+
+ // Validate input
+ await jsonSchemaValidationService.ensureValid(credentials, inputSchema);
+
+ const { username, password } = credentials;
+ const exists = await dbPasswordService.exists({ username, password });
+ if (!exists) {
+ throw this.boom.invalidCredentials('Either the password is incorrect or the user does not exist', true);
+ }
+ }
+}
+
+module.exports = DbAuthenticationService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/jwt-service.js b/addons/addon-base-rest-api/packages/services/lib/jwt-service.js
new file mode 100644
index 0000000000..e62af0b67e
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/jwt-service.js
@@ -0,0 +1,106 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+const jwt = require('jsonwebtoken'); // https://github.com/auth0/node-jsonwebtoken/tree/v8.3.0
+const _ = require('lodash');
+
+const settingKeys = {
+ paramStoreJwtSecret: 'paramStoreJwtSecret',
+ jwtOptions: 'jwtOptions',
+};
+
+function removeNils(obj) {
+ return _.transform(
+ obj,
+ (result, value, key) => {
+ if (!_.isNil(value)) {
+ result[key] = value;
+ }
+ },
+ {},
+ );
+}
+
+class JwtService extends Service {
+ constructor() {
+ super();
+ this.boom.extend(['invalidToken', 403]);
+ this.dependency('aws');
+ }
+
+ async init() {
+ await super.init();
+ const keyName = this.settings.get(settingKeys.paramStoreJwtSecret);
+ this.secret = await this.getSecret(keyName);
+ }
+
+ async sign(payload, optionsOverride = {}) {
+ const defaultOptions = this.settings.getObject(settingKeys.jwtOptions);
+
+ // Create resultant options and remove Nil values (null or undefined) from the resultant options object.
+ // This is done to allow removing an option using "optionsOverride"
+ // For example, the defaultOptions "expiresIn": "2 days" but the we want to issue non-expiring token
+ // we can pass optionsOverride with "expiresIn": undefined.
+ // This will result in removing the "expiresIn" from the resultant options
+ const options = removeNils(_.assign({}, defaultOptions, optionsOverride));
+
+ return jwt.sign(payload, this.secret, options);
+ }
+
+ async verify(token) {
+ try {
+ const payload = await jwt.verify(token, this.secret);
+ return payload;
+ } catch (err) {
+ throw this.boom.invalidToken('Invalid Token', true).cause(err);
+ }
+ }
+
+ /**
+ * Decodes a token and either returns the token payload or returns the complete decoded token as
+ * { payload, header, signature } based on the "complete" flag.
+ *
+ * @param token The JWT token to decode
+ *
+ * @param complete A flag indicating whether to return just the payload or return the whole token in
+ * { payload, header, signature } format after decoding. Defaults to true i.e., it returns the whole token.
+ *
+ * @param ignoreExpiration A flag indicating whether the decoding should ignore token expiration. If this flag is
+ * false, the decoding will throw exception if an expired token is being decoded. Defaults to true i.e., it ignores expiration.
+ *
+ * @returns {Promise<*|boolean|undefined>}
+ */
+ async decode(token, { complete = true, ignoreExpiration = true } = {}) {
+ try {
+ // using verify method here instead of "decode" method because the "decode" method does not return signature
+ // we want to return signature also when complete === true
+ return jwt.verify(token, this.secret, { complete, ignoreExpiration });
+ } catch (err) {
+ throw this.boom.invalidToken('Invalid Token', true).cause(err);
+ }
+ }
+
+ async getSecret(keyName) {
+ const aws = await this.service('aws');
+ const ssm = new aws.sdk.SSM({ apiVersion: '2014-11-06' });
+
+ this.log.info(`Getting the "${keyName}" key from the parameter store`);
+ const result = await ssm.getParameter({ Name: keyName, WithDecryption: true }).promise();
+ return result.Parameter.Value;
+ }
+}
+
+module.exports = JwtService;
diff --git a/addons/addon-base-rest-api/packages/services/lib/schema/username-password-credentials.json b/addons/addon-base-rest-api/packages/services/lib/schema/username-password-credentials.json
new file mode 100644
index 0000000000..b4cec06041
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/schema/username-password-credentials.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "username": {
+ "type": "string"
+ },
+ "password": {
+ "type": "string"
+ }
+ },
+ "required": [ "username", "password"]
+}
diff --git a/addons/addon-base-rest-api/packages/services/lib/token-revocation-service.js b/addons/addon-base-rest-api/packages/services/lib/token-revocation-service.js
new file mode 100644
index 0000000000..c27c3c9c28
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/lib/token-revocation-service.js
@@ -0,0 +1,88 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const _ = require('lodash');
+const jwtDecode = require('jwt-decode');
+
+const Service = require('@aws-ee/base-services-container/lib/service');
+
+const settingKeys = {
+ tableName: 'dbTableRevokedTokens',
+};
+class TokenRevocationService extends Service {
+ constructor() {
+ super();
+ this.boom.extend(['invalidToken', 403]);
+ this.dependency(['dbService']);
+ }
+
+ async revoke(requestContext, { token }) {
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+
+ const record = await this.toTokenRevocationRecord(token);
+ return dbService.helper
+ .updater()
+ .table(table)
+ .key({ id: record.id })
+ .item(record)
+ .update();
+ }
+
+ async isRevoked({ token }) {
+ // if the given token exists in the database table of revoked tokens then it is "revoked"
+ return this.exists({ token });
+ }
+
+ async exists({ token }) {
+ const dbService = await this.service('dbService');
+ const table = this.settings.get(settingKeys.tableName);
+ const record = await this.toTokenRevocationRecord(token);
+ const item = await dbService.helper
+ .getter()
+ .table(table)
+ .key({ id: record.id })
+ .get();
+
+ return !!item;
+ }
+
+ /**
+ * A method responsible for translating token into a token revocation record in {id, ttl} format.
+ *
+ * @param token
+ * @returns {Promise<{id, ttl}>}
+ */
+ // All other methods in this class treat token as an opaque string. The knowledge of mapping token to it's identifier
+ // and TTL is abstracted in this method.
+ async toTokenRevocationRecord(token) {
+ try {
+ const payload = jwtDecode(token);
+ const signature = token.split('.')[2];
+
+ // use token's signature as the ID of the record for hash key (partition key)
+ // Note that the max limit for partition key is 2048 bytes.
+ // The JWT signatures are SHA256 (so always 256 bits) which are Base64 URL encoded so should fit in 2048 bytes.
+
+ // Set the record's TTL as the token's expiry (i.e., let DynamoDB clear the record from the revocation table
+ // after it is expired)
+ return { id: signature, ttl: _.get(payload, 'exp', 0) };
+ } catch (error) {
+ throw this.boom.invalidToken('Invalid Token', true).cause(error);
+ }
+ }
+}
+
+module.exports = TokenRevocationService;
diff --git a/addons/addon-base-rest-api/packages/services/package.json b/addons/addon-base-rest-api/packages/services/package.json
new file mode 100644
index 0000000000..25a5627c5b
--- /dev/null
+++ b/addons/addon-base-rest-api/packages/services/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@aws-ee/base-api-services",
+ "private": true,
+ "version": "1.0.0",
+ "description": "A library containing base set of services to be used with solutions based on addons",
+ "author": "Amazon Web Services",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-ee/base-services-container": "workspace:*",
+ "@aws-ee/base-services": "workspace:*",
+ "ajv": "^6.11.0",
+ "aws-sdk": "^2.647.0",
+ "jsonwebtoken": "^8.5.1",
+ "jwk-to-pem": "^2.0.3",
+ "jwt-decode": "^2.2.0",
+ "lodash": "^4.17.15",
+ "request": "^2.88.2",
+ "underscore": "^1.9.2",
+ "uuid": "^3.4.0",
+ "validatorjs": "^3.18.1"
+ },
+ "devDependencies": {
+ "eslint": "^6.8.0",
+ "eslint-config-airbnb-base": "^14.0.0",
+ "eslint-config-prettier": "^6.10.0",
+ "eslint-import-resolver-node": "^0.3.3",
+ "eslint-plugin-import": "^2.20.1",
+ "eslint-plugin-jest": "^22.21.0",
+ "eslint-plugin-prettier": "^3.1.2",
+ "husky": "^3.1.0",
+ "jest": "^24.9.0",
+ "jest-junit": "^10.0.0",
+ "prettier": "^1.19.1",
+ "source-map-support": "^0.5.16"
+ },
+ "scripts": {
+ "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests",
+ "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll",
+ "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
+ "lint:eslint": "eslint --ignore-path .gitignore . ",
+ "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' ",
+ "format": "pnpm run format:eslint && pnpm run format:prettier",
+ "format:eslint": "eslint --fix --ignore-path .gitignore . ",
+ "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' "
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'"
+ }
+ }
+}
diff --git a/addons/addon-base-ui/packages/base-ui/.babelrc b/addons/addon-base-ui/packages/base-ui/.babelrc
new file mode 100644
index 0000000000..83064a1e60
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/.babelrc
@@ -0,0 +1,4 @@
+{
+ "plugins": [["@babel/plugin-proposal-class-properties", { "loose": true }]],
+ "presets": ["@babel/preset-react"]
+}
diff --git a/addons/addon-base-ui/packages/base-ui/.eslintrc.json b/addons/addon-base-ui/packages/base-ui/.eslintrc.json
new file mode 100644
index 0000000000..90ac399dcf
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/.eslintrc.json
@@ -0,0 +1,42 @@
+{
+ "extends": ["airbnb", "prettier", "prettier/react", "plugin:jest/recommended", "eslint-config-prettier"],
+ "parser": "babel-eslint",
+ "plugins": ["jest", "prettier", "react"],
+ "rules": {
+ "prettier/prettier": ["error"],
+ "no-unused-vars": [
+ "error",
+ {
+ "varsIgnorePattern": "^_.+",
+ "argsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_",
+ "args": "after-used",
+ "ignoreRestSiblings": true
+ }
+ ],
+ "prefer-destructuring": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "class-methods-use-this": 0,
+ "no-use-before-define": 0,
+ "no-console": 0,
+ "no-plusplus": 0,
+ "no-nested-ternary": 0,
+ "import/no-named-as-default": 0,
+ "import/no-named-as-default-member": 0,
+ "react/jsx-props-no-spreading": 0,
+ "react/jsx-one-expression-per-line": 0,
+ "react/jsx-filename-extension": 0,
+ "react/destructuring-assignment": 0,
+ "react/sort-comp": 0,
+ "react/prop-types": 0,
+ "jsx-a11y/no-static-element-interactions": 0,
+ "jsx-a11y/click-events-have-key-events": 0
+ },
+ "env": {
+ "browser": true,
+ "node": true,
+ "es6": true,
+ "jest/globals": true
+ }
+}
diff --git a/addons/addon-base-ui/packages/base-ui/.gitignore b/addons/addon-base-ui/packages/base-ui/.gitignore
new file mode 100644
index 0000000000..49cc63674e
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/.gitignore
@@ -0,0 +1,45 @@
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+**/.class
+**/.DS_Store
+**/node_modules
+
+**/npm-debug.log
+**/pnpm-debug.log
+**/npm-debug.log*
+
+# Serverless directories
+.serverless
+
+# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# dependencies
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# transpiled code
+dist
+
+# misc
+.env.local
+.env.development
+.env.development.local
+.env.test.local
+.env.production
+.env.production.local
+
+yarn-debug.log*
+yarn-error.log*
+
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
diff --git a/addons/addon-base-ui/packages/base-ui/.prettierrc.json b/addons/addon-base-ui/packages/base-ui/.prettierrc.json
new file mode 100644
index 0000000000..a333103711
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "tabWidth": 2,
+ "printWidth": 120,
+ "singleQuote": true,
+ "quoteProps": "consistent",
+ "trailingComma": "all"
+}
diff --git a/addons/addon-base-ui/packages/base-ui/images/login-image.gif b/addons/addon-base-ui/packages/base-ui/images/login-image.gif
new file mode 100644
index 0000000000000000000000000000000000000000..ad97652fb3afd187b16691ce147293f3a36f88ed
GIT binary patch
literal 30465
zcmWifc{mha7sqEan9LaazEfi#jD2YsJC!w(GWK0$4Qb4bvCAH!>|11yN{oF;vWKXN
zNT}?oG`+v~``qXLea}7jJm)!|=X*`ej5Rb}Gl4sRp8$ZQq?DYT+<9pkl)S1eMn^_f
z3-g~;wf~d4rj`;~QCJdXa8XZF|Du7uo}|2qthxzC8;8-c(9klG)wwRMeifr_tEzKD
zL;LD^d0SZxFO06Is$Q6!R;aLyDOT4^$Ka9%&P>+8K^teQZtSRFVyS@hQo(s*aDG~*
z-WXh%rfH0pjXw@&h%>aXu&^{TGdDKBYG!lI&d$!++1dW8(`9QbQ%4_%>z>9}BTX)c
zT=(#`_ldgV6Yk;R;qUM7>ErJc5FQ#D8WR%}9vKr95{h@llSucp%oB8N5_K=-8D35@
zvQ06vEi$>9f5|@cnlITooMP{p=Y1#nP7KLAGRyROo!QM+>l>{W?)7$F^_M-KU-fx@
zE%b$F_zUmgHaou|yMPHtzfq^qai@Se2mg21LuZ{sSDfxFUiKRD2pe{beCHlG#aS#ptJZmVk|N7c4GdWw5ph#nmftQqA5eS(iTGVw*%8RuM^&q
z$>g-OG)huRN?LYqZf^0z;`|3i_cOB-3hIjK6s-72CKYv(RUs+k%(9lp_
zQ&(Hx+1A$f@@02t=kunP*7CB_{=RNX_DKGd!J>}wf|lv(hLQS?@mH_<+q*_
zSgw3Jm)d%q*L{-JeDJjM6Sa4!uXnRzgNgKaRd1{X9NC
z{{8#p=gIHWpJyk({`~#>_vh*HkG-7_w3VLTS9jy1#eP8m|CikVxBu4!fQb$`W)?JU
zF6|>RamYB2HkS<~A|=d=O}WQzmmYfM!sdPZV)*OK=O3`j4&D7-
zv@G!acx$Bo>NGdzdRCxg&Gj0;yA}_EoD+_>zr7skY|`to0$+g3vF*C_nEYAW*~z>0
zAsRHIM($?)*fais@f@PqLBu2J{vl|go9~HBa^~U9lWxAoufsVP*Jo3gTE9g9bo!FN
z_yV)`wCSRW+q_K*;{T5sLE}^c49x?wxUCDiS
z>xb0Eg5QT>5cnZuU+R`iD!!?&3YMGQ)&y$S>KmfWx0
zdM;Ic-vegGQ{ZyfwdU>3cbay2l+{A$G~U(KsGRuw_<>mHY?co#==fXCTkAjA&3EZ(
z_K~?{L>*-@fQ(Ej>%gusDsV^z3Hdtv9IkxO?)IT#q@&H8XSBGZ?t>
ze>Bzeucg2qEPF22IZBo+h4XrNKZE<#lohTrZl~OE^>#E6e)eu7f}W*d?CADD+I-4A
z|9LVhfFf?bcuS1C$h9>1*9%@3UU5VI@UZdIclGuejtwpL`u1t@iMZTnU-HK5=%qgGdfZXC
zLn?p4E8j7Z{#$&o%2z$4ZBEO$O20@oasMfEnZS9sKv)1iHuz@Oeq-I8e9wjr;aIV_
zk{0ctKoO1=pL)9Z(bKwmvE@7;Sysb`cRPMxTMoOs;dm72sGO#ljBt6_)|A$mk92jg
zy-Qp0vDR^a3llPGal0Wyp1*1KJ*O`Eu3NyDSNc%mw8ZUPv|yCkR`
z_`WJ>CO}@l5V{+*yePj~<4)$8`##O#HDMLc33ibE=yuC%bFl1f()Zc-c{jOMGn7+k
zF4-Kr@BCul1-c|yX>Hzq!`$KUwx7Te;6)rq_$>AGIAvH>e~&AWx?hzg9n;II56$Zz
zo_wwNWisXIjTiXD%XQ7;^^@-N3`%cbEq2#j^oZUF;yv|`O#P#nq`ny^m>>J&hTk4U
zo2N_Jqo9gx>|vE=5~H4ho^#L4ca`=UnlmejA|Hf
z3}6JeQNb59I#gp&b^DGRdk(Xay3pIL{i}B?ETvQ3g%55>kMf=s;W`7$QL**!5l?1@rv}J1{w6jOy{(3&j;cFlg7datZ`WNC
zo~*8PPUs~qT
zE*le!msG54&x#dXhaXpurG;_6cE4K2UL9>Tccm_x|D7cxwTr7hIb_$okq)}@Rghf=
z^E$5&>r}a`(@`)4e7%p>s&$HHX{b(PSv@f*bfqNM(k*HF1ZMQ@B|8cbAxgz1$9#GxcSsile`w%lb*5ZT2nv(VmXWaTUGq807T1+s3rdJbFdY%>q7_)G|%@~!>X
z7*$(aTA-RkzMwKj_3E;8tDf*QEA}LvW+k}4Wkv6D0poDB1v1o1f*SkRLZji+$A4}W
zLa2V4O=9Z%4~6e6(%!JU^htjhekJ$f#dL$sUD=8YztCb;(O*}K=i8GbX59Hg%S}7K
zyzaQ;t2sHts8g$-;*%*Z&|1}~_uGqLo*
zL9oEgX^GTd+bYg&ci}}UgW7~EBBFg@VY2`#*PfLtVBjx6uN_ufX@NVpS6pM5Nz}Zl
z1P^ML5N>~_nj50T8f%%<=B-l37%zQEOLoTLM$l6t)T2c`X!OZL27%<5^-}MC29?9-
z+egZ^Z`j8*YL@(pJ>UQK^UEdjpV3CNK4hhbBO%Ftv5AF^O7|lN-FxOU?z?0;2?lic
z3w~o_yjP_J4Y$9o|#wbJx9itcBtZ)(F$RpAp`?J~wMx#*BN0xjVSdwOF9pHqeI%a$XWV
zEHK72n7oQQmEhkr{nNPQ3J)Er%Nx4X}E<4P;?#TUm*@3q1
zi%T`}n^zZPwQmMEJjiK~wO<5Q;6AQsuCkuH5j5F8J>QjY(fZ({E~iLFbg%^fqUoK!
zvSg9FdlmdMQH&Q`>0;@@?O=g-G{HNqhnt);XA7+-FugTb&zI{4pMB~~Fs)biz#pR*
zo$gMq^P&y7
ztDjH!ovkmtSXWFR4)668V-ItQbib)vPG~xb+s}kIq3`jk-yN4gz88gmFTL4?h0ReB
z%bAfmD)9!!Ays`L-%8;#Q=Vq~x0kKF7HI03`v`h7;%v`zHWR*!N96rf`b|a5QW1v)
zuQzV+MI`GGH|w0LGwWv+Y5S|U7=prZ_D#0Jv<|VTR#O*4BC$nXz(*>+T4;__RHO1L
z{^;t$XFa+phtImor6x7_%wCf8$h}?U-A<4^j0ax3=evxAvoexr-NhuH>B-l^zfFZm
zFmkyjo!bqDU#WH2L&E2fsb_nLOSK7dkwNkMq(da4i$Ix}Ml4L-DSJSN#BL}A^2)qFEb|__EfU0d3NYX!
z%j@)CREdypF84^z%K7@)0>q{-U&l!u@ND?P8`&!3Xr1zXF(OXFOdtpDvgIOkV5Pj_
zRyla(8xl_bdH+GB=)a*j22I}70oG+C;@N1z3No!_GqpmA^^AwIj7Pah*v^2(e{MM3
zbHl8=N!gP46bxhSKVz1|O)FXii2Lf4XUM?~b1F#i%eny{1zlez0rD^qc2u7q6|3fW
zu%a5Gx~6c>-HvZgI)Rw`u_l+%yAWi3|6q@_rZ3!-D8#kF8P|un_6#xG3`fmzy+gyB
zDe!|&`X&Vtv%!egF(LJS`05k_okWRKV|_=v>(+ID-RlAeJKDCR@Ke9=H~J@aeP5|w
z17V=J!i{J0bY*i}liS9JBE9=+kx{~h2g0hpNO_K1rxHcDjuOuSUeP+qFRsL!Wa&Ho
zq7-YNBtu3VVLzw9EGks@gWLqNr&gnoph??brm@Dh@IvH
zC_A@T8GIERJS~cki(p+0MreI`=y%BT_|u~yw+pDR(tb0_wj(7ALuh_3ep*Pue2p=A
z$=~j$FicYLce}@ln3M|maaQglK0}AxA;T!X$55c;G4cXWzioQI(^|N<_)Ho~joa0{
zx@r{uV=v=JLX1{yv~=T}(=QC`aXm{Eei%T`YWuRt4^Jc{28p?re)RDM*;5-hSh%
zR<{KIo(Y$YR(ScTyyLw8DkpzItBPP=8aC35os%D7V1hm3E|gJesVb56#i%ojMGu*|
z9H69mt8>&aJrxqSo@ti=?Evg1x93zIhHI*0W`2mG+G(moDr4wz~iE
zofk>P7JMXCUdFl;9CA11;jJXrvvBfhK?y`Z_1fq?h*-l?CcK%u&aOY-gRjKiP+yZ%
zlc=gJ*zej8sdvJed2Db-Xz3q;R7+(piT_dwa?ln1Wteftt|rCda<6Ug6GC@NYx)xn+!`
zAe(GmV`82hHzSs?a93_vUNCbe8e+5uHpYT6XmGASIN#!VfdyEP2EHf?F${)eSuvNP
zVN>4lYhG}=e_wb*u$a9>s)@t3G8|F2~LN?@&U{S
z00?FeqzC}ZPl1>S0A^$_GrITSdhfnP@9{$b6xoZ|>-ljGtb&JNMIkwv%!O#!BC;F)
z$#*>2L%0u7iI1Gc7lHRlG(XwzdoNj^RjD~rR*`TEBJ0@&)){KIQC7dq3ep(Dx`c)0
zA(;)Ddss4iPp=O!A*pI;aMA1MB|Fazu@HkjFt+)5wl-J|0RH~4cRvQehz5%6fm8ty
z9FqAljr9XoecLE_b&t)^xQ|;7X}XA55v^cu=?nLUk5G{BtthqJJPOO?>AenF=aC3!
z%R|#EJ
zcv$Hkb1tC=YXw%=1Md3+sv7_-c(C3c>tn*Xdrrtl&+g9cu{rP|wuh(;`y^II4;WkV
zPG&jtCu*%)zk6M1XUxcGasPKDJP!@g+XKM>0MJw~C;(!BANrRMe~)Yvj%O8$$#2SJ
z&a{GHG6$JT0U*M-3ILJ`fUWHDZEqk}t>7Zotn<_e-6*8#X|Pus5^|w@0pQ1EeV_FL
z9OQ*`e~$R^V1h-6^M@5Y7XVQVo&=Txm`cZSSmtUh`~#KsyBhl;p3;ScW$%G^9s&r(
z^Z|@09i>6G~tTMb~=!-H+%03Z#ZiiT9vT1?)s9a+J_-f&-C
zq_I!RtFxD@_*s!%5>)>2**io(g=5W}JzdHCXu8T=S%vG9h5CVn$e`cI@9cHema7uY
zPOm1i(cmvDy@~)xE&;Z)hjChETgXSU{$>7$1*7o*?m#dO&s-VYJn>sv^*3yZhL}aa
zj!HYXK#iNlb1~|t`Z~PBJ`kin^dB)6p`?a9-b9*MUuwO>yWYahW?dmo|0M*ALVpY)
z_Hd+c)i%Y?Vc<#*TZ_^)E$R!~!d)%$pGv}lVvz5VFhjr_Ze6eeo_Xwzu$rsLb!*lo
zBrFpPMi7=2Y2e(iG*76e^0_zuN)LgRrsA
z6X5Np@GNphxY?Qzu_-eQ^Jv^KcTDKTp;Y*+14FieBD3bbs5%})XFAaICOQ2?MPIc|
zZ=gnrH6CU#1%hS*Sg_zMEbN-)-8gwJVZ-1&8d#nN;Qj_Kr6KHID4txZkS=HaguH!d
z1#fCb>}T@4$HL-*QpBLHBDrcHkl68r9PwsGqRsx~%l$(qNWJ)@C1}wNDqC`?>fF
z3s4HOJpJow*Ca<~E{NQC0^0B6$m-YG3MI`)U
zIx?I~>#cYRE6T`rS#&GPgyZ)#nazEkk^7V8HC+$Bz}Dy)c|7C+9zK#f
z2QAyS(d{)x!p<_e1uQIx}<92NTzcX>rAHSH#~wr
z5kBeFrv5I}5EYL9zI#xu>G@|v9#^;!rMu}$uO=B2pNHV;&$||`s|jxTluOu`V!>a|
zK$)4ey${+;SY{RcxQZ1ssPU`)ojYbN0bR7#lE`~%KR1heG>(J&KQ7Bnuv3sX{Yyp=
z-KVgXDgRZwB*UJn-9qccTAi|xCQbJX_rHeL_6xO3bvD=ttlu=1ov}Um#mo^1zyM%x
z9v$l8U|4D|I+z*s?Hf)+q$o@cWsC#tJ=F~p>6s+rnG#L71hqe1=6Wq3
z`}uRw`)lq?`j=i+wmfriJa)RWv3^i8iIy|Y0I|c=rg{zW?_)nAS45fH{(0+e
z`#BjeYvKCoQH^Xrnfh4w0f%>q$3XgzyYcA-vyWT~9&7P=M!&2`+W-=8l*@=TCbrI)
zG&)Hw*$W`%ZKv4bn5kae-a=jzDV8m-hZL2xqy%>G`-m0
zEW(CStRD?1a3MVp6!N1Und_K2m&v+fIOfmxH66zv6?$1GXt#+GYnstrP7~ssL-4wB
z0t55+m&~)GlL?S--~MQ}mG#9O2Mas@#Kp&py^qMAINa3iEDxpotMn?D}+cp5kFQ^M~)BlO;O;qW9Kj
z{Z78E+6wuO8-qkXB&CKsa<_Vvn!G>%WlQNBpgl0
zSgCFGvtJFwVw^0RfGR9mgX`e585s7o<>@T$wB$P*WuWxfqqyy}y748?4n_Kc>uN8Z{P`{Ns
zyLdZf&p#>-FdMWS!*JG;Wb^?*bLZP_cDAndjOT(Av>1ZjH~RE)@dkMF79q6>8VM&vlEA;%IkIJrb#u`u~J(>EcT6UuWnvrBn6L+
zzj|-SzBB%8=6Ets@9^-gDJ%Qaft=_bWNA_Jot)i5C0n%yH`mgyEMkF700U{c
zeUR3E1^=pJqV~dmdSth|&=R@|30_3_o00@}eHazmr;#du*`?=7jgbRN@Q5y{^Xcsr
z&E<8K0)ocvGR{o#4r9MoHLm&WXKHgVD-r>Fv
zOS~-8X}s#LH8YP7QecHPqV1D172xA8{6loeLD&r$N)s>t2B^g}w9pMGpECsJMk(4_
z{Hy`(HYoDYklLo$blWiG*6vKAc6m#Bbc2MTPCFFqate#sLx{Zp58f>+IfV{}TWgh>
zsF`M^-x;iUwAAbyTr4T%tsq;_-ZteJJb3lUBL!>F4l_K(67@jIAM^b>!94K+g)?Vk}5U9#u{Wt
z8iPuQs(pxuGipOBR~&a)ZGGOgU7MAfuBGU-Wu@OMTCe;xYl79cr1=sY1s0oGnh-WN
zKnuqtr9u-5aGAznRK)o{$wd39Qu=hZ#(Bxxj0!LIGx*GL0=vO2+S{FDJ)4}7CQDM+
z6b5BQ2EItNRm@GgeOs@DDxl=E61B*>0d73ni`z^mEgLNXcRbeaWN94gnJSTa2oo>z
zYoW}IXTnjzB+hHk)Zn!Zsuo>%Q2r=JwKXR5E*)}PczJQhbR9T5G@LyMT$MFsX+_v{
zR|v008DZRq_#tpky?3w
zx=n-oo9U>8!5?P*0qI8O=RjX>r=9dVe(aLyd1#JT!-ck5Gdsx(vAC{Hr(8n0vDIOJ
zPv^uP8DU<1wYc>{oHGk{$q)$P$Q-Q50>S(QsDMDu{tJHmb#&iO;SW7z))xyP3Fm$c
zSDlHJQ!v3i_l3Slz)0C~@&)H^5Km?Bj@gpFHw+#qm7NY%kUqN-;kYiCpPZ*_+&X1%
zUQsz4JR*Hdk%FYrl#izw>5q
zYb3JbMF&TjLU>USR5zoJ`9MB&;#11QXt|VM!8XasGd+&*YsL{m==f
zr$B*VLRD0}2(Nl(7vpmr!IIYX5vSsplo;Ghi9ia)wW3K$lwc%=pu|TymGC=dilafh
zTcKGq>_QJ2g9@OA{fz$2P*(po0j&(Egxr2~lE`>F=k27UY|7U{MqehzylmN$Jt);i
zmEW(0r!6m=jiaJ9&Ap7y1hbRyd7kkY$R?qr0~~*zwa1!(tnMNdy2xnXU1U()0;wR$
zDrl0b21(P9q}7hob|z`Xl1*xHs*Yeq0_a>4z${4=CaLdN3K0pes3HLQkwgmsgCBqj
z$n92@O)%#LiQq*Uj^f1t2^VHk%?a_$qvVK1imwkcbd);WCRU?W7lG6$1k|^>CfeE>
zaTg=k29U7;F#)+U^b8}bMHx<3jDjtTK#NPV3#A$~2Rkyp^Vg_Di$^dqE@pS540*eP
zS-QqF&9xBM>rhPB^BLC*vjtds|!7|f;&pIQN+Q9zSi$Uly0N@h)>o@=LgTuWprMsepdM<*xyn)XfOQhzTgk!gtmrgdDFm|i}i@@D9{IVj^{
zW=fPg(y+|UnTazcQD_GHz%WVt?1E22N-UGP9ZCKPn@PVYOOauEb5t_Ky8J|gBlvT7
zDtfT43#!UXGTvfv6NP#ZUU%hMbXh_@cqy*IuRATFo}!6%rt!>#XZ3`{004s-lE_B@
zf|&qdJb}9aC?ZNUt%cZXNCxqiVcJuoGcVjaEwwp?n2Can1mR#S0=YO|L<2-`x!ze^
zL7vt~;4#FcnkwcDP`>iLK6;*%=9lP~^u~vr%){S2aU0oMJeG>BjiRZPBqef5HnYo4
z1_D~T{mt7nbHokn23ujV=yGmtaj<4y8w)k13#!OOGG>Ch)xP#jvhXah@UWu1h<)pU
zz2a#_37LYh04y7yq>R^(T8tVB017ojN_nEvUMNf7f>vK2bPMwEdJY6wDA>Q-18
z5T^!J60V#=EHcUWkRZ`TiEm~A@MwjxAkhM;J-%&jjxV*PF|vwRP53bu=fZrgR6ca4
z@VyzEtWV)485{#ekaK)dK;A9>CW$wC_5?^Ufe!#OttDHRQqJ3_H^u|67K)WM
zB=MIvju?)RfXlOsD&t<1CVh4lMQ>>Vjhg@l=!X#7TG+JTfAC(I>vY7Jy>>zr*%_30`Jgmp;pT{kM$kNXp`H!k2Bn?+@jQo=VW1zz2
zTLJeU7wSoH>uny2T-Pp^htN}HG;@WFEpj|>dW+i1-kkMnc2f@jpmf98yY_7em8|~l
z1H4+;JQ!f5Ym{qw!*;0n-;+dgN6Mo8>epnqPZG(Gl9jxii+ilxj*tVyR6Jz4PzRow@zFl>t}Vo
zplW4h@yNxpC|-^rI@EKJuik9(i^Yfj)p~f+NY;YAeBOixT0J;6*kz
zWsnz*M|MG`<~PK>teeUhgEFB_VXIeA309e8F;Sw(9#Fz@jt@TvviHruJSU3j@sbV&dX)+O2ePCW2$BLXNs&P0
zOuCphxzzwvu~=k0-ChYyqF5zC#7cqrLGfQ)9h$DE=)9SY`eu74K{)!ZrD-$S`qa9k
zesi8dm>yM^c9*suod3KTS(*9}8WGA|n!y;g(_y}l$T8_0%7?-%pGg6B0@?xuGB5_ASJGx5?XQE`vBeF9RYe&6*1?|ukU?fSku{9R!W
z^eQ8s8BGS#cWmntO{Yf9Ya6UJ7{G>9tLcm2;0Zo7NR*c-K_Juf$&frU{ro#I>{s38
zLA|@_#yTL=3CbJkHQid3;obhsAo5P
zL$9$ZPUzO$mk-cKwWrC}GesMK*DJ0)hWXsw^|H>R4A%g+uXQDeUN{iHaBzJw;YKDs
z!3@sY;yb={h=&wPcM|bfj(kLutoV!
z&zas)B}z0yY)1Z&E(nUxAUrQSiMgi{&LIWxY-oofo){+Z%efnHPi-iwPHC>R
z9Dw?oZu+2(*g$19dylgSi9R&wt>!=*#VnIvIf)@m`(E+{UPzlK
z;Le9+y(?;0r}r=IP916yBc9?~>tzeqK9S_8fI1nqq*ulfvgca@pHe
z%FE=zzdLLBFrtr!4coE6t+6PcoG%ON>yFVi7t^Xl)~?Vbw#vwi`cZ0k5>2CogR)x9
z7wtk)U?&U-A_ai-m0l}6VzGr~UMnn9q{1{l(QmINT;s7wF2#>#k9`&zH!7L^P(4R(kGW{z*2OZsszVbbKBtu!Nv?Q{y)tOW1K|a@=$Ml-W=(oN7RlNoz
zCI%i20M99aaj&@HFk|Fb11Wx*c90}TZoLskm_id(A$)2GQ2%#I7$>46E8lvQoxLw!sAUk0_mrkBS`0m%~YkX
zG+tD!_i)mbo=ZagcmV
zExWp8-|{Zk%`dBmm0!*}Z~qVRYbfgB%o|Q}Y5&2a@n$O5F6k#(=UczGOg%(=aNF$t
zE^ffknE>Ibtqd!HVN!|H!O>U~+iz!XJZ;Dg===gTrtXdWz5L?-P)|DG`0z5
+
+
+ {this.getRoutes()}
+
+
+ );
+ }
+
+ render() {
+ return this.renderApp();
+ }
+}
+
+// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da
+decorate(App, {
+ appContext: computed,
+});
+
+export default withAuth(inject('app', 'userStore')(withRouter(observer(App))));
diff --git a/addons/addon-base-ui/packages/base-ui/src/AppContainer.js b/addons/addon-base-ui/packages/base-ui/src/AppContainer.js
new file mode 100644
index 0000000000..aef0edf986
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/AppContainer.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React, { Component } from 'react';
+import _ from 'lodash';
+import { withRouter } from 'react-router-dom';
+import { inject, observer } from 'mobx-react';
+import { getEnv } from 'mobx-state-tree';
+import { Message, Container } from 'semantic-ui-react';
+
+import { branding } from './helpers/settings';
+
+// expected props
+// - pluginRegistry (via injection)
+// - app (via injection)
+// - location (from react router)
+class AppContainer extends Component {
+ componentDidMount() {
+ document.title = branding.page.title;
+ }
+
+ render() {
+ const { location, pluginRegistry, app } = this.props;
+ let plugins = _.reverse(pluginRegistry.getPluginsWithMethod('app-component', 'getAppComponent') || []);
+ let App = this.renderError();
+
+ // We ask each plugin in reverse order if they have the App component
+ _.forEach(plugins, plugin => {
+ const result = plugin.getAppComponent({ location, appContext: getEnv(app) });
+ if (_.isUndefined(result)) return;
+ App = result;
+ // eslint-disable-next-line consistent-return
+ return false; // This will stop lodash from continuing the forEach loop
+ });
+
+ plugins = _.reverse(pluginRegistry.getPluginsWithMethod('app-component', 'getAutoLogoutComponent') || []);
+ let AutoLogout = () => <>>;
+ // We ask each plugin in reverse order if they have the AutoLogout component
+ _.forEach(plugins, plugin => {
+ const result = plugin.getAutoLogoutComponent({ location, appContext: getEnv(app) });
+ if (_.isUndefined(result)) return;
+ AutoLogout = result;
+ // eslint-disable-next-line consistent-return
+ return false; // This will stop lodash from continuing the forEach loop
+ });
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ renderError() {
+ return (
+
+
+ A problem was encountered
+
No plugins provided the App react component!
+
+
+ );
+ }
+}
+
+export default inject('pluginRegistry', 'app')(withRouter(observer(AppContainer)));
diff --git a/addons/addon-base-ui/packages/base-ui/src/app-context/app-context.js b/addons/addon-base-ui/packages/base-ui/src/app-context/app-context.js
new file mode 100644
index 0000000000..41c38424dd
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/app-context/app-context.js
@@ -0,0 +1,57 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import { observable } from 'mobx';
+
+import { PluginRegistry } from '../models/PluginRegistry';
+
+const appContext = observable({});
+
+/**
+ * Initializes the given appContext (application context containing various MobX stores etc) by calling each plugin's
+ * "registerAppContextItems" and "postRegisterAppContextItems" methods.
+ *
+ * @param {Object} pluginRegistry A registry that provides plugins registered by various addons for the specified extension point.
+ * Each 'contextItems' plugin in the returned array is an object containing "registerAppContextItems" and "postRegisterAppContextItems" plugin methods.
+ *
+ * @returns {{intervalIds: {}, disposers: {}, pluginRegistry: {}}}
+ */
+const initializeAppContext = pluginRegistry => {
+ const registry = new PluginRegistry(pluginRegistry);
+ const appContextHolder = {
+ disposers: {},
+ intervalIds: {},
+ pluginRegistry: registry,
+ assets: {
+ images: {},
+ },
+ };
+
+ const registerAppContextItems = registry.getPluginsWithMethod('app-context-items', 'registerAppContextItems');
+ _.forEach(registerAppContextItems, plugin => {
+ plugin.registerAppContextItems(appContextHolder);
+ });
+
+ const postRegisterAppContextItems = registry.getPluginsWithMethod('app-context-items', 'postRegisterAppContextItems');
+ _.forEach(postRegisterAppContextItems, plugin => {
+ plugin.postRegisterAppContextItems(appContextHolder);
+ });
+
+ Object.assign(appContext, appContextHolder); // this is to ensure that it is the same appContext reference whether initializeAppContext is called or not
+ return appContextHolder;
+};
+
+export { appContext, initializeAppContext };
diff --git a/addons/addon-base-ui/packages/base-ui/src/bootstrap-app.js b/addons/addon-base-ui/packages/base-ui/src/bootstrap-app.js
new file mode 100644
index 0000000000..8fab9f0bbb
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/bootstrap-app.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import { configure } from 'mobx';
+import 'mobx-react/batchingForReactDom';
+import { initializeAppContext } from './app-context/app-context';
+
+import * as serviceWorker from './service-worker';
+import AppContainer from './AppContainer';
+
+function bootstrapApp({ renderAppContainer, renderError, renderProgress, pluginRegistry }) {
+ // Disabling service worker
+ serviceWorker.unregister();
+
+ // Enable mobx strict mode, changes to state must be contained in actions
+ configure({ enforceActions: 'always' });
+
+ // Initialize appContext object registering various Mobx stores etc
+ const appContext = initializeAppContext(pluginRegistry);
+
+ // Render page loading message
+ renderProgress();
+
+ // Trigger the app startup sequence
+ (async () => {
+ try {
+ await appContext.appRunner.run();
+ renderAppContainer(AppContainer, appContext);
+ } catch (err) {
+ console.log(err);
+ // TODO - check if the code = tokenExpired, then
+ // - render a notification error
+ // - call cleaner cleanup, this is IMPORTANT
+ // - render the app and skip the rest of the renderError logic
+ // - doing the above logic will help us have a smooth user experience
+ // when the token has expired. NOTE: this might not be applicable for the
+ // cases where the app requires midway before anything is displayed to the user
+ renderError(err);
+ try {
+ appContext.cleaner.cleanup();
+ } catch (error) {
+ // ignore
+ console.log(error);
+ }
+ }
+ })();
+}
+
+export default bootstrapApp;
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/__tests__/utils.test.js b/addons/addon-base-ui/packages/base-ui/src/helpers/__tests__/utils.test.js
new file mode 100644
index 0000000000..b47a21c42c
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/__tests__/utils.test.js
@@ -0,0 +1,107 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import { flattenObject, unFlattenObject } from '../utils';
+
+describe('helpers/utils', () => {
+ describe('flattenObject --- is working fine if,', () => {
+ it('it leaves already flat object of key/value pairs as is', () => {
+ const input = { someKey: 'someValue' };
+ const expectedOutput = { someKey: 'someValue' };
+ const output = flattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it flattens a simple object graph into a flat object with key/value pairs', () => {
+ const input = { someKey: { someNestedKey: 'someValue' } };
+ const expectedOutput = { 'someKey.someNestedKey': 'someValue' };
+ const output = flattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it flattens an object graph with arrays correctly', () => {
+ const input = { someKey: ['someValue1', 'someValue2'] };
+ const expectedOutput = { 'someKey[0]': 'someValue1', 'someKey[1]': 'someValue2' };
+ const output = flattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it flattens an object graph with nested arrays correctly', () => {
+ const input = { someKey: ['someValue1', ['someValue2', 'someValue3'], 'someValue4'] };
+ const expectedOutput = {
+ 'someKey[0]': 'someValue1',
+ 'someKey[1][0]': 'someValue2',
+ 'someKey[1][1]': 'someValue3',
+ 'someKey[2]': 'someValue4',
+ };
+ const output = flattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it flattens an object graph with arrays containing nested object graphs correctly', () => {
+ const input = { someKey: [{ someNestedKey: 'someValue', nestedArr: [1, 2, { nestedArrKey: 'value' }] }] };
+ const expectedOutput = {
+ 'someKey[0].someNestedKey': 'someValue',
+ 'someKey[0].nestedArr[0]': 1,
+ 'someKey[0].nestedArr[1]': 2,
+ 'someKey[0].nestedArr[2].nestedArrKey': 'value',
+ };
+ const output = flattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ });
+ describe('unFlattenObject --- is working fine if it a correct inverse of the flattenObject, it is correct inverse if', () => {
+ it('it leaves object with keys without any delimiters as is', () => {
+ const expectedOutput = { someKey: 'someValue' };
+ const input = flattenObject(expectedOutput);
+ const output = unFlattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it unFlattens a simple object graph into from a flat object with key/value pairs', () => {
+ const expectedOutput = { someKey: { someNestedKey: 'someValue' } };
+ const input = flattenObject(expectedOutput);
+ const output = unFlattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it unFlattens to an object graph with arrays correctly', () => {
+ const expectedOutput = { someKey: ['someValue1', 'someValue2', 'someValue3'] };
+ const input = flattenObject(expectedOutput);
+ const output = unFlattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it unFlattens to an object graph with nested arrays correctly', () => {
+ const expectedOutput = { someKey: ['someValue1', ['someValue2', 'someValue3'], 'someValue4'] };
+ const input = flattenObject(expectedOutput);
+ // input = { "someKey_0": "someValue1", "someKey_1_0": "someValue2", "someKey_1_1": "someValue3", "someKey_2": "someValue4" };
+ const output = unFlattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ it('it unFlattens to an object graph with arrays containing nested object graphs correctly', () => {
+ const expectedOutput = {
+ someKey: [{ someNestedKey: 'someValue', nestedArr: [1, 2, { nestedArrKey: 'value' }] }],
+ };
+ const input = flattenObject(expectedOutput);
+ // input = { 'someKey[0].someNestedKey': 'someValue', 'someKey[0].nestedArr[0]': 1, 'someKey[0].nestedArr[1]': 2, 'someKey[0].nestedArr[2].nestedArrKey': 'value' };
+ const output = unFlattenObject(input);
+
+ expect(output).toEqual(expectedOutput);
+ });
+ });
+});
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/api.js b/addons/addon-base-ui/packages/base-ui/src/helpers/api.js
new file mode 100644
index 0000000000..c6b0c001de
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/api.js
@@ -0,0 +1,312 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import { parseError, delay, removeNulls } from './utils';
+import { apiPath } from './settings';
+
+/* eslint-disable import/no-mutable-exports */
+let config = {
+ apiPath,
+ fetchMode: 'cors',
+ maxRetryCount: 4,
+};
+
+let token;
+let decodedIdToken;
+const authHeader = tok => ({ Authorization: `${tok}` });
+
+function setIdToken(idToken, decodedToken) {
+ token = idToken;
+ decodedIdToken = decodedToken;
+}
+
+function getDecodedIdToken() {
+ return decodedIdToken;
+}
+
+function isTokenExpired() {
+ // Date.now() returns epoch time in MILLISECONDS
+ const expiresAt = _.get(decodedIdToken, 'exp', 0) * 1000;
+ return Date.now() >= expiresAt;
+}
+
+function forgetIdToken() {
+ token = undefined;
+ decodedIdToken = undefined;
+}
+
+function configure(obj) {
+ config = { ...config, ...obj };
+}
+
+function fetchJson(url, options = {}, retryCount = 0) {
+ // see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
+ let isOk = false;
+ let httpStatus;
+
+ const headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ };
+ const body = '';
+ const merged = {
+ method: 'GET',
+ cache: 'no-cache',
+ mode: config.fetchMode,
+ redirect: 'follow',
+ body,
+ ...options,
+ headers: { ...headers, ...options.headers },
+ };
+
+ if (merged.method === 'GET') delete merged.body; // otherwise fetch will throw an error
+
+ if (merged.params) {
+ // if query string parameters are specified then add them to the URL
+ // The merged.params here is just a plain JavaScript object with key and value
+ // For example {key1: value1, key2: value2}
+
+ // Get keys from the params object such as [key1, key2] etc
+ const paramKeys = _.keys(merged.params);
+
+ // Filter out params with undefined or null values
+ const paramKeysToPass = _.filter(paramKeys, key => !_.isNil(_.get(merged.params, key)));
+ const query = _.map(
+ paramKeysToPass,
+ key => `${encodeURIComponent(key)}=${encodeURIComponent(_.get(merged.params, key))}`,
+ ).join('&');
+ url = query ? `${url}?${query}` : url;
+ }
+
+ return Promise.resolve()
+ .then(() => fetch(url, merged))
+ .catch(err => {
+ // this will capture network/timeout errors, because fetch does not consider http Status 5xx or 4xx as errors
+ if (retryCount < config.maxRetryCount) {
+ let backoff = retryCount * retryCount;
+ if (backoff < 1) backoff = 1;
+
+ return Promise.resolve()
+ .then(() => console.log(`Retrying count = ${retryCount}, Backoff = ${backoff}`))
+ .then(() => delay(backoff))
+ .then(() => fetchJson(url, options, retryCount + 1));
+ }
+ throw parseError(err);
+ })
+ .then(response => {
+ isOk = response.ok;
+ httpStatus = response.status;
+ return response;
+ })
+ .then(response => {
+ if (_.isFunction(response.text)) return response.text();
+ return response;
+ })
+ .then(text => {
+ let json;
+ try {
+ if (_.isObject(text)) {
+ json = text;
+ } else {
+ json = JSON.parse(text);
+ }
+ } catch (err) {
+ if (httpStatus >= 400) {
+ if (httpStatus >= 501 && retryCount < config.maxRetryCount) {
+ let backoff = retryCount * retryCount;
+ if (backoff < 1) backoff = 1;
+
+ return Promise.resolve()
+ .then(() => console.log(`Retrying count = ${retryCount}, Backoff = ${backoff}`))
+ .then(() => delay(backoff))
+ .then(() => fetchJson(url, options, retryCount + 1));
+ }
+ throw parseError({
+ message: text,
+ status: httpStatus,
+ });
+ } else {
+ throw parseError(new Error('The server did not return a json response.'));
+ }
+ }
+
+ return json;
+ })
+ .then(json => {
+ if (_.isBoolean(isOk) && !isOk) {
+ throw parseError({ ...json, status: httpStatus });
+ } else {
+ return json;
+ }
+ });
+}
+
+// ---------- helper functions ---------------
+
+function httpApiGet(urlPath, { params } = {}) {
+ return fetchJson(`${config.apiPath}/${urlPath}`, {
+ method: 'GET',
+ headers: authHeader(token),
+ params,
+ });
+}
+
+function httpApiPost(urlPath, { data, params } = {}) {
+ return fetchJson(`${config.apiPath}/${urlPath}`, {
+ method: 'POST',
+ headers: authHeader(token),
+ params,
+ body: JSON.stringify(data),
+ });
+}
+
+function httpApiPut(urlPath, { data, params } = {}) {
+ return fetchJson(`${config.apiPath}/${urlPath}`, {
+ method: 'PUT',
+ headers: authHeader(token),
+ params,
+ body: JSON.stringify(data),
+ });
+}
+
+// eslint-disable-next-line no-unused-vars
+function httpApiDelete(urlPath, { data, params } = {}) {
+ return fetchJson(`${config.apiPath}/${urlPath}`, {
+ method: 'DELETE',
+ headers: authHeader(token),
+ params,
+ body: JSON.stringify(data),
+ });
+}
+
+// ---------- api calls ---------------
+
+function authenticate(authenticationUrl, username, password, authenticationProviderId) {
+ return fetchJson(authenticationUrl, {
+ method: 'POST',
+ body: JSON.stringify({
+ username,
+ password,
+ authenticationProviderId,
+ }),
+ });
+}
+
+function logout() {
+ if (isTokenExpired()) {
+ // if token is already expired then no need to call logout API to revoke token just return
+ return { expired: true, revoked: false };
+ }
+ return httpApiPost('api/authentication/logout');
+}
+
+function getApiKeys({ username, ns } = {}) {
+ return httpApiGet('api/api-keys', { params: { username, ns } });
+}
+
+function createNewApiKey({ username, ns } = {}) {
+ return httpApiPost('api/api-keys', { params: { username, ns } });
+}
+
+function revokeApiKey(apiKeyId, { username, ns } = {}) {
+ return httpApiPut(`api/api-keys/${apiKeyId}/revoke`, { params: { username, ns } });
+}
+
+function getUser() {
+ return httpApiGet('api/user');
+}
+
+function addUser(user) {
+ const params = {};
+ if (user.authenticationProviderId) {
+ params.authenticationProviderId = user.authenticationProviderId;
+ }
+ if (user.identityProviderName) {
+ params.identityProviderName = user.identityProviderName;
+ }
+ const data = removeNulls(_.clone(user));
+ delete data.ns; // Server derives ns based on "authenticationProviderId" and "identityProviderName"
+ // on server side so remove it from request body
+ delete data.createdBy; // Similarly, createdBy and updatedBy are derived on server side
+ delete data.updatedBy;
+ if (!data.userType) {
+ // if userType is specified as empty string then make sure to delete it
+ // the api requires this to be only one of the supported values (currently only supported value is 'root')
+ delete data.userType;
+ }
+ return httpApiPost('api/users', { data, params });
+}
+
+function updateUser(user) {
+ const params = {};
+ if (user.authenticationProviderId) {
+ params.authenticationProviderId = user.authenticationProviderId;
+ }
+ if (user.identityProviderName) {
+ params.identityProviderName = user.identityProviderName;
+ }
+ const data = removeNulls(_.clone(user));
+ delete data.ns; // Server derives ns based on "authenticationProviderId" and "identityProviderName"
+ // on server side so remove it from request body
+ delete data.createdBy; // Similarly, createdBy and updatedBy are derived on server side
+ delete data.updatedBy;
+ if (!data.userType) {
+ // if userType is specified as empty string then make sure to delete it
+ // the api requires this to be only one of the supported values (currently only supported value is 'root')
+ delete data.userType;
+ }
+ return httpApiPut(`api/users/${user.username}`, { data, params });
+}
+
+function getUsers() {
+ return httpApiGet('api/users');
+}
+
+function getAuthenticationProviderPublicConfigs() {
+ return httpApiGet('api/authentication/public/provider/configs');
+}
+function getAuthenticationProviderConfigs() {
+ return httpApiGet('api/authentication/provider/configs');
+}
+
+function updateAuthenticationProviderConfig(authenticationProvider) {
+ return httpApiPut('api/authentication/provider/configs', { data: authenticationProvider });
+}
+
+export {
+ configure,
+ setIdToken,
+ getDecodedIdToken,
+ fetchJson,
+ httpApiGet,
+ httpApiPost,
+ httpApiPut,
+ httpApiDelete,
+ getAuthenticationProviderPublicConfigs,
+ getAuthenticationProviderConfigs,
+ updateAuthenticationProviderConfig,
+ forgetIdToken,
+ getApiKeys,
+ createNewApiKey,
+ revokeApiKey,
+ getUser,
+ addUser,
+ updateUser,
+ getUsers,
+ authenticate,
+ logout,
+ config,
+};
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/errors.js b/addons/addon-base-ui/packages/base-ui/src/helpers/errors.js
new file mode 100644
index 0000000000..37c17c8abc
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/errors.js
@@ -0,0 +1,65 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+
+const codes = ['apiError', 'notFound', 'badRequest', 'tokenExpired', 'incorrectImplementation', 'timeout'];
+
+const boom = {
+ error: (friendlyOrErr, code, friendly = '') => {
+ if (_.isString(friendlyOrErr)) {
+ const e = new Error(friendlyOrErr);
+ e.isBoom = true;
+ e.code = code;
+ e.friendly = friendlyOrErr; // the friendly argument is ignored and friendlyOrErr is used instead
+ return e;
+ }
+ if (_.isError(friendlyOrErr)) {
+ friendlyOrErr.code = code; // eslint-disable-line no-param-reassign
+ friendlyOrErr.isBoom = true; // eslint-disable-line no-param-reassign
+ friendlyOrErr.friendly = friendly || _.startCase(code);
+ return friendlyOrErr;
+ }
+
+ // if we are here, it means that the msgOrErr is an object
+ const err = new Error(JSON.stringify(friendlyOrErr));
+ err.isBoom = true;
+ err.code = code;
+ err.friendly = friendly || _.startCase(code);
+
+ return err;
+ },
+};
+
+// inject all the codes array elements as properties for the boom
+// example 'apiError' injected => produces boom.apiError(errOrFriendlyMsg, friendlyMsg)
+// then you can call boom.apiError(err, 'Error fetching user info')
+codes.forEach(code => {
+ boom[code] = (errOrFriendlyMsg, friendlyMsg) => boom.error(errOrFriendlyMsg, code, friendlyMsg);
+});
+
+const isNotFound = error => {
+ return _.get(error, 'code') === 'notFound';
+};
+
+const isTokenExpired = error => {
+ return _.get(error, 'code') === 'tokenExpired';
+};
+
+const isForbidden = error => {
+ return _.get(error, 'code') === 'forbidden';
+};
+
+export { boom, isNotFound, isTokenExpired, isForbidden };
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/form.js b/addons/addon-base-ui/packages/base-ui/src/helpers/form.js
new file mode 100644
index 0000000000..ce948af8ed
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/form.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import dvr from 'mobx-react-form/lib/validators/DVR';
+import validatorjs from 'validatorjs';
+import MobxReactForm from 'mobx-react-form';
+
+const formPlugins = Object.freeze({
+ dvr: dvr(validatorjs),
+});
+
+const formOptions = Object.freeze({
+ showErrorsOnReset: false,
+});
+
+function createForm(fields, pluginsParam, optionsParam) {
+ const plugins = pluginsParam || formPlugins;
+ const options = optionsParam || formOptions;
+ return new MobxReactForm({ fields }, { plugins, options });
+}
+
+/**
+ * Creates a MobxReactForm specific to the field identified by the specified fieldName from the given fields.
+ * @param fieldName Name of the field to create MobxReactForm for
+ * @param fields An array of MobxReactForm fields OR an object containing the form fields.
+ * See MobxReactForm documentation about fields https://foxhound87.github.io/mobx-react-form/docs/fields/ for more details.
+ *
+ * @param value Optional value for the field
+ * @param pluginsParam Optional plugin parameters for the MobxReactForm
+ * @param optionsParam Optional options parameters for the MobxReactForm
+ */
+function createSingleFieldForm(fieldName, fields, value, pluginsParam, optionsParam) {
+ // An array of MobxReactForm fields OR an object containing the form fields
+ // Find field with the given fieldName from the fields
+ // In case of Array: It has shape [ {fieldName1:field1}, {fieldName2:field2} ]
+ // In case of Object: It has shape { fieldName1:field1, fieldName2:field2 }
+ const fieldsObj = _.isArray(fields) ? _.find(fields, field => _.keys(field)[0] === fieldName) : fields;
+ const fieldOfInterest = _.get(fieldsObj, fieldName);
+
+ if (!fieldOfInterest) {
+ throw new Error(`Field not found. Can not create form for field ${fieldName}.`);
+ }
+ const fieldWithValue = _.assign({}, { value }, fieldOfInterest);
+ const fieldsToUse = { [fieldName]: fieldWithValue };
+ return createForm(fieldsToUse, pluginsParam, optionsParam);
+}
+
+export { formPlugins, formOptions, createForm, createSingleFieldForm };
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/notification.js b/addons/addon-base-ui/packages/base-ui/src/helpers/notification.js
new file mode 100644
index 0000000000..e81cc125e8
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/notification.js
@@ -0,0 +1,127 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import toastr from 'toastr';
+
+function displayError(msg, error, timeOut = '20000') {
+ toastr.error(toMessage(msg, error), 'We have a problem!', { ...toasterErrorOptions, timeOut });
+ if (error) console.error(msg, error);
+ if (_.isError(msg)) console.error(msg);
+}
+
+function displayWarning(msg, error, timeOut = '20000') {
+ toastr.warning(toMessage(msg, error), 'Warning!', { ...toasterWarningOptions, timeOut });
+ if (error) console.error(msg, error);
+ if (_.isError(msg)) console.error(msg);
+}
+
+function displaySuccess(msg, title = 'Submitted!') {
+ toastr.success(toMessage(msg), title, toasterSuccessOptions);
+}
+
+function displayFormErrors(form) {
+ const map = form.errors();
+ const lines = [];
+ Object.keys(map).forEach(key => {
+ if (map[key]) lines.push(map[key]);
+ });
+
+ if (lines.length === 0) return displayError('The form submission has a problem.', undefined, 3000);
+ const isPlural = lines.length > 1;
+ const message = `There ${isPlural ? 'are issues' : 'is an issue'} with the form:`;
+ return displayError([message, ...lines], undefined, 3000);
+}
+
+function toMessage(msg, error) {
+ if (_.isError(msg)) {
+ return `${msg.message || msg.friendly} `;
+ }
+
+ if (_.isError(error)) {
+ return `${msg} - ${error.message} `;
+ }
+
+ if (_.isArray(msg)) {
+ const messages = msg;
+ const size = _.size(messages);
+
+ if (size === 0) {
+ return 'Unknown error ';
+ }
+ if (size === 1) {
+ return `${messages[0]} `;
+ }
+ const result = [];
+ result.push(' ');
+ result.push('
');
+
+ return result.join('');
+ }
+
+ if (_.isEmpty(msg)) return 'Unknown error ';
+
+ return `${msg} `;
+}
+
+// For details of options, see https://github.com/CodeSeven/toastr
+//
+// closeButton: Enable a close button
+// debug: Emit debug logs to the console
+// newestOnTop: Show newest toast at top or bottom (top is default)
+// progressBar: Visually indicate how long before a toast expires
+// positionClass: CSS position style e.g. toast-top-center, toast-bottom-left
+// preventDuplicates: Prevent identical toasts appearing (based on content)
+// timeOut: How long the toast will display without user interaction (ms)
+// extendedTimeOut: How long the toast will display after a user hovers over it (ms)
+
+const toasterErrorOptions = {
+ closeButton: true,
+ debug: false,
+ newestOnTop: true,
+ progressBar: true,
+ positionClass: 'toast-top-right',
+ preventDuplicates: true,
+ timeOut: '20000', // 1000000
+ extendedTimeOut: '50000', // 1000000
+};
+
+const toasterWarningOptions = {
+ closeButton: true,
+ debug: false,
+ newestOnTop: true,
+ progressBar: true,
+ positionClass: 'toast-top-right',
+ preventDuplicates: true,
+ timeOut: '20000', // 1000000
+ extendedTimeOut: '50000', // 1000000
+};
+
+const toasterSuccessOptions = {
+ closeButton: true,
+ debug: false,
+ newestOnTop: true,
+ progressBar: true,
+ positionClass: 'toast-top-right',
+ preventDuplicates: true,
+ timeOut: '3000',
+ extendedTimeOut: '10000',
+};
+
+export { displayError, displayWarning, displaySuccess, displayFormErrors };
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/plugins-util.js b/addons/addon-base-ui/packages/base-ui/src/helpers/plugins-util.js
new file mode 100644
index 0000000000..3a918e789f
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/plugins-util.js
@@ -0,0 +1,86 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import React from 'react';
+import _ from 'lodash';
+import { Route, Switch } from 'react-router-dom';
+
+/**
+ * Configures the given React Router by collecting routes contributed by all route plugins.
+ *
+ * @returns {*} A React.Router or Switch Component
+ */
+// eslint-disable-next-line no-unused-vars
+function getRoutes({ location, appContext }) {
+ const plugins = appContext.pluginRegistry.getPluginsWithMethod('routes', 'registerRoutes');
+ const initial = new Map();
+ // Ask each plugin to return their routes. Each plugin is passed a Map containing the routes collected so
+ // far from other plugins. The plugins are called in the same order as returned by the registry.
+ // Each plugin gets a chance to add, remove, update, or delete routes by mutating the provided routesMap object.
+ // This routesMap is a Map that has route paths as keys and React Component as value.
+ const routesMap = _.reduce(plugins, (routesFar, plugin) => plugin.registerRoutes(routesFar, appContext), initial);
+
+ const entries = Array.from(routesMap || new Map());
+ let routeIdx = 0;
+ return (
+
+ {_.map(entries, ([routePath, reactComponent]) => {
+ routeIdx += 1;
+ return ;
+ })}
+
+ );
+}
+
+/**
+ * Returns menu items for navigation by collecting items contributed by all menu item plugins.
+ *
+ * @param {*} appContext An application context object containing all MobX store objects
+ *
+ * @returns {*}
+ */
+function getMenuItems({ location, appContext }) {
+ const plugins = appContext.pluginRegistry.getPluginsWithMethod('menu-items', 'registerMenuItems');
+ const initial = new Map();
+ // Ask each plugin to return their nav items. Each plugin is passed a Map containing the nav items collected so
+ // far from other plugins. The plugins are called in the same order as returned by the registry.
+ // Each plugin gets a chance to add, remove, update, or delete items by mutating the provided itemsMap object.
+ // This itemsMap is a Map that has route paths (urls) as keys and menu item object as values.
+ const itemsMap = _.reduce(
+ plugins,
+ (itemsSoFar, plugin) => plugin.registerMenuItems(itemsSoFar, { location, appContext }),
+ initial,
+ );
+
+ const entries = Array.from(itemsMap || new Map());
+ return _.map(entries, ([url, menuItem]) => ({ url, ...menuItem }));
+}
+
+function getDefaultRouteLocation({ location, appContext }) {
+ const plugins = _.reverse(appContext.pluginRegistry.getPluginsWithMethod('routes', 'getDefaultRouteLocation') || []);
+ // We ask each plugin in reverse order if they have a default route
+ let defaultRoute;
+ _.forEach(plugins, plugin => {
+ const result = plugin.getDefaultRouteLocation({ location, appContext });
+ if (_.isUndefined(result)) return;
+ defaultRoute = result;
+ // eslint-disable-next-line consistent-return
+ return false; // This will stop lodash from continuing the forEach loop
+ });
+
+ return defaultRoute;
+}
+
+export { getRoutes, getMenuItems, getDefaultRouteLocation };
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/routing.js b/addons/addon-base-ui/packages/base-ui/src/helpers/routing.js
new file mode 100644
index 0000000000..1135d62a0a
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/routing.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+function createLinkWithSearch({ location, pathname, search }) {
+ return {
+ pathname,
+ search: search || location.search,
+ hash: location.hash,
+ state: location.state,
+ };
+}
+
+function createLink({ location, pathname }) {
+ return {
+ pathname,
+ hash: location.hash,
+ state: location.state,
+ };
+}
+
+function reload() {
+ setTimeout(() => {
+ window.location.reload();
+ }, 150);
+}
+
+/**
+ * A generic goto function creator function that returns a go to function bound to the given react component.
+ *
+ * See below snippet as an example for using this function from within some react component
+ * containing "location" and "history" props.
+ *
+ * const goto = gotoFn(this);
+ * goto('/some-path');
+ *
+ * @param reactComponent A react component that has "location" and "history" props as injected via the "withRouter" function.
+ * @returns {{new(...args: any[]): any} | ((...args: any[]) => any) | OmitThisParameter | goto | any | {new(...args: any[]): any} | ((...args: any[]) => any)}
+ */
+function gotoFn(reactComponent) {
+ function goto(pathname) {
+ const location = reactComponent.props.location;
+ const link = createLink({ location, pathname });
+
+ reactComponent.props.history.push(link);
+ }
+ return goto.bind(reactComponent);
+}
+
+export { createLink, createLinkWithSearch, reload, gotoFn };
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/settings.js b/addons/addon-base-ui/packages/base-ui/src/helpers/settings.js
new file mode 100644
index 0000000000..d92105994f
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/settings.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+const isLocalDev = process.env.REACT_APP_LOCAL_DEV === 'true';
+const awsRegion = process.env.REACT_APP_AWS_REGION;
+const apiPath = process.env.REACT_APP_API_URL;
+const websiteUrl = process.env.REACT_APP_WEBSITE_URL;
+const stage = process.env.REACT_APP_STAGE;
+const region = process.env.REACT_APP_REGION;
+const autoLogoutTimeoutInMinutes = process.env.REACT_APP_AUTO_LOGOUT_TIMEOUT_IN_MINUTES || 5;
+
+const branding = {
+ login: {
+ title: process.env.REACT_APP_BRAND_LOGIN_TITLE,
+ subtitle: process.env.REACT_APP_BRAND_LOGIN_SUBTITLE,
+ },
+ main: {
+ title: process.env.REACT_APP_BRAND_MAIN_TITLE,
+ },
+ page: {
+ title: process.env.REACT_APP_BRAND_PAGE_TITLE,
+ },
+};
+
+export { awsRegion, apiPath, isLocalDev, websiteUrl, stage, region, branding, autoLogoutTimeoutInMinutes };
diff --git a/addons/addon-base-ui/packages/base-ui/src/helpers/utils.js b/addons/addon-base-ui/packages/base-ui/src/helpers/utils.js
new file mode 100644
index 0000000000..f12cefd61d
--- /dev/null
+++ b/addons/addon-base-ui/packages/base-ui/src/helpers/utils.js
@@ -0,0 +1,557 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import _ from 'lodash';
+import numeral from 'numeral';
+import { observable } from 'mobx';
+
+/**
+ * Converts the given Map object to an array of values from the map
+ */
+function mapToArray(map) {
+ const result = [];
+ // converting map to result array
+ map.forEach(value => result.push(value));
+ return result;
+}
+
+function parseError(err) {
+ const message = _.get(err, 'message', 'Something went wrong');
+ const code = _.get(err, 'code');
+ const status = _.get(err, 'status');
+ const requestId = _.get(err, 'requestId');
+ const error = new Error(message);
+
+ error.code = code;
+ error.requestId = requestId;
+ error.root = err;
+ error.status = status;
+
+ return error;
+}
+
+function swallowError(promise, fn = () => ({})) {
+ try {
+ return Promise.resolve()
+ .then(() => promise)
+ .catch(err => fn(err));
+ } catch (err) {
+ return fn(err);
+ }
+}
+
+const storage = observable({
+ clear() {
+ try {
+ if (localStorage) return localStorage.clear();
+ return window.localStorage.clear();
+ } catch (err) {
+ console.log(err);
+ try {
+ if (sessionStorage) return sessionStorage.clear();
+ return window.sessionStorage.clear();
+ } catch (error) {
+ // if we get here, it means no support for localStorage nor sessionStorage, which is a problem
+ return console.log(error);
+ }
+ }
+ },
+
+ getItem(key) {
+ try {
+ if (localStorage) return localStorage.getItem(key);
+ return window.localStorage.getItem(key);
+ } catch (err) {
+ console.log(err);
+ try {
+ if (sessionStorage) return sessionStorage.getItem(key);
+ return window.sessionStorage.getItem(key);
+ } catch (error) {
+ // if we get here, it means no support for localStorage nor sessionStorage, which is a problem
+ return console.log(error);
+ }
+ }
+ },
+
+ setItem(key, value) {
+ try {
+ if (localStorage) return localStorage.setItem(key, value);
+ return window.localStorage.setItem(key, value);
+ } catch (err) {
+ console.log(err);
+ try {
+ if (sessionStorage) return sessionStorage.setItem(key, value);
+ return window.sessionStorage.setItem(key, value);
+ } catch (error) {
+ // if we get here, it means no support for localStorage nor sessionStorage, which is a problem
+ return console.log(error);
+ }
+ }
+ },
+
+ removeItem(key) {
+ try {
+ if (localStorage) return localStorage.removeItem(key);
+ return window.localStorage.removeItem(key);
+ } catch (err) {
+ console.log(err);
+ try {
+ if (sessionStorage) return sessionStorage.removeItem(key);
+ return window.sessionStorage.removeItem(key);
+ } catch (error) {
+ // if we get here, it means no support for localStorage nor sessionStorage, which is a problem
+ return console.log(error);
+ }
+ }
+ },
+});
+
+// a promise friendly delay function
+function delay(seconds) {
+ return new Promise(resolve => {
+ _.delay(resolve, seconds * 1000);
+ });
+}
+
+function niceNumber(value) {
+ if (_.isNil(value)) return 'N/A';
+ if (_.isString(value) && _.isEmpty(value)) return 'N/A';
+ return numeral(value).format('0,0');
+}
+
+function nicePrice(value) {
+ if (_.isNil(value)) return 'N/A';
+ if (_.isString(value) && _.isEmpty(value)) return 'N/A';
+ return numeral(value).format('0,0.00');
+}
+
+// super basic plural logic, laughable
+function plural(singleStr, pluralStr, count) {
+ if (count === 1) return singleStr;
+ return pluralStr;
+}
+
+function getQueryParam(location, key) {
+ const queryParams = new URL(location).searchParams;
+ return queryParams.get(key);
+}
+
+function addQueryParams(location, params) {
+ const url = new URL(location);
+ const queryParams = url.searchParams;
+
+ const keys = _.keys(params);
+ keys.forEach(key => {
+ queryParams.append(key, params[key]);
+ });
+
+ let newUrl = url.origin + url.pathname;
+
+ if (queryParams.toString()) {
+ newUrl += `?${queryParams.toString()}`;
+ }
+
+ newUrl += url.hash;
+ return newUrl;
+}
+
+function removeQueryParams(location, keys) {
+ const url = new URL(location);
+ const queryParams = url.searchParams;
+
+ keys.forEach(key => {
+ queryParams.delete(key);
+ });
+
+ let newUrl = url.origin + url.pathname;
+
+ if (queryParams.toString()) {
+ newUrl += `?${queryParams.toString()}`;
+ }
+
+ newUrl += url.hash;
+ return newUrl;
+}
+
+function getFragmentParam(location, key) {
+ const fragmentParams = new URL(location).hash;
+ const hashKeyValues = {};
+ const params = fragmentParams.substring(1).split('&');
+ if (params) {
+ params.forEach(param => {
+ const keyValueArr = param.split('=');
+ const currentKey = keyValueArr[0];
+ const value = keyValueArr[1];
+ if (value) {
+ hashKeyValues[currentKey] = value;
+ }
+ });
+ }
+ return hashKeyValues[key];
+}
+
+function removeFragmentParams(location, keyNamesToRemove) {
+ const url = new URL(location);
+ const fragmentParams = url.hash;
+ let hashStr = '#';
+ const params = fragmentParams.substring(1).split('&');
+ if (params) {
+ params.forEach(param => {
+ const keyValueArr = param.split('=');
+ const currentKey = keyValueArr[0];
+ const value = keyValueArr[1];
+ // Do not include the currentKey if it is the one specified in the array of keyNamesToRemove
+ if (value && _.indexOf(keyNamesToRemove, currentKey) < 0) {
+ hashStr = `${currentKey}${currentKey}=${value}`;
+ }
+ });
+ }
+ return `${url.protocol}//${url.host}${url.search}${hashStr === '#' ? '' : hashStr}`;
+}
+
+function isAbsoluteUrl(url) {
+ // return /^[a-z][a-z\d+.-]*:/.test(url);
+ return /^https?:/.test(url);
+}
+
+function removeNulls(obj = {}) {
+ Object.keys(obj).forEach(key => {
+ if (obj[key] === null) delete obj[key];
+ });
+
+ return obj;
+}
+
+// remove the "end" string from "str" if it exists
+function chopRight(str = '', end = '') {
+ if (!_.endsWith(str, end)) return str;
+ return str.substring(0, str.length - end.length);
+}
+
+const isFloat = n => {
+ return n % 1 !== 0;
+};
+
+// input [ { : { label, desc, ..} }, { : { label, desc } } ]
+// output { : { label, desc, ..}, : { label, desc } }
+function childrenArrayToMap(arr) {
+ const result = {};
+ arr.forEach(item => {
+ const key = _.keys(item)[0];
+ result[key] = item[key];
+ });
+ return result;
+}
+
+let idGeneratorCount = 0;
+
+function generateId(prefix = '') {
+ idGeneratorCount += 1;
+ return `${prefix}_${idGeneratorCount}_${Date.now()}_${_.random(0, 1000)}`;
+}
+
+// Given a Map and an array of items (each item MUST have an "id" prop), consolidate
+// the array in the following manner:
+// - if an existing item in the map is no longer in the array of items, remove the item from the map
+// - if an item in the array is not in the map, then add it to the map using the its "id" prop
+// - if an item in the array is also in the map, then call 'mergeExistingFn' with the existing item
+// and the new item. It is expected that this 'mergeExistingFn', will know how to merge the
+// properties of the new item into the existing item.
+function consolidateToMap(map, itemsArray, mergeExistingFn) {
+ const unprocessedKeys = {};
+
+ map.forEach((_value, key) => {
+ unprocessedKeys[key] = true;
+ });
+
+ itemsArray.forEach(item => {
+ const id = item.id;
+ const hasExisting = map.has(id);
+ const exiting = map.get(id);
+
+ if (!exiting) {
+ map.set(item.id, item);
+ } else {
+ mergeExistingFn(exiting, item);
+ }
+
+ if (hasExisting) {
+ delete unprocessedKeys[id];
+ }
+ });
+
+ _.forEach(unprocessedKeys, (_value, key) => {
+ map.delete(key);
+ });
+}
+
+/**
+ * Converts an object graph into flat object with key/value pairs.
+ * The rules of object graph to flat key value transformation are as follows.
+ * 1. An already flat attribute with primitive will not be transformed.
+ * For example,
+ * input = { someKey: 'someValue' } => output = { someKey: 'someValue' }
+ * 2. A nested object attribute will be flattened by adding full attribute path '.' (the paths are as per lodash's get and set functions)
+ * For example,
+ * input = { someKey: { someNestedKey: 'someValue' } } => output = { 'someKey.someNestedKey': 'someValue' }
+ * 3. An array attribute will be flattened by adding correct path '[]' prefix. (the paths are as per lodash's get and set functions)
+ * For example,
+ * input = { someKey: [ 'someValue1', 'someValue2' ] } => output = { 'someKey[0]': 'someValue1', 'someKey[1]': 'someValue2' }
+ * input = { someKey: [ 'someValue1', ['someValue2','someValue3'], 'someValue4' ] } => output = { 'someKey[0]': 'someValue1', 'someKey[1][0]': 'someValue2', 'someKey[1][1]': 'someValue3', 'someKey[2]': 'someValue4' }
+ * input = { someKey: [{ someNestedKey: 'someValue' }] } => output = { 'someKey[0].someNestedKey': 'someValue' }
+ *
+ * @param obj An object to flatten
+ * @param filterFn An optional filter function that allows filtering out certain attributes from being included in the flattened result object. The filterFn is called with 3 arguments (result, value, key) and is expected to return true to include
+ * the key in the result or false to exclude the key from the result.
+ * @param keyPrefix A optional key prefix to include in all keys in the resultant flattened object.
+ * @param accum An optional accumulator to use when performing the transformation
+ * @returns {*}
+ */
+function flattenObject(obj, filterFn = () => true, keyPrefix = '', accum = {}) {
+ function toFlattenedKey(key, idx) {
+ let flattenedKey;
+ if (_.isNil(idx)) {
+ if (_.isNumber(key)) {
+ flattenedKey = keyPrefix ? `${keyPrefix}[${key}]` : `[${key}]`;
+ } else {
+ flattenedKey = keyPrefix ? `${keyPrefix}.${key}` : key;
+ }
+ } else {
+ flattenedKey = keyPrefix ? `${keyPrefix}.${key}[${idx}]` : `${key}[${idx}]`;
+ }
+ return flattenedKey;
+ }
+
+ return _.transform(
+ obj,
+ (result, value, key) => {
+ if (filterFn(result, value, key)) {
+ if (_.isArray(value)) {
+ let idx = 0;
+ _.forEach(value, element => {
+ const flattenedKey = toFlattenedKey(key, idx);
+ if (_.isObject(element)) {
+ flattenObject(element, filterFn, flattenedKey, result);
+ } else {
+ result[flattenedKey] = element;
+ }
+ ++idx;
+ });
+ } else {
+ const flattenedKey = toFlattenedKey(key);
+ if (_.isObject(value)) {
+ flattenObject(value, filterFn, flattenedKey, result);
+ } else {
+ result[flattenedKey] = value;
+ }
+ }
+ }
+ return result;
+ },
+ accum,
+ );
+}
+
+/**
+ * Converts an object with key/value pairs into object graph. This function is inverse of flattenObject.
+ * i.e., unFlattenObject(flattenObject(obj)) = obj
+ *
+ * The rules of key/value pairs to object graph transformation are as follows.
+ * 1. Key that does not contain delimiter will not be transformed.
+ * For example,
+ * input = { someKey: 'someValue' } => output = { someKey: 'someValue' }
+ * 2. Key/Value containing delimiter will be transformed into object path
+ * For example,
+ * input = { someKey_someNestedKey: 'someValue' } => output = { someKey: { someNestedKey: 'someValue' } }
+ * 3. Key/Value containing delimiter and integer index will be transformed into object containing array.
+ * For example,
+ * input = { someKey_0: 'someValue1', someKey_1: 'someValue2' } => output = { someKey: [ 'someValue1', 'someValue2' ] }
+ * input = { "someKey_0": "someValue1", "someKey_1_0": "someValue2", "someKey_1_1": "someValue3", "someKey_2": "someValue4" } => output = { someKey: [ 'someValue1', ['someValue2','someValue3'], 'someValue4' ] }
+ * input = { someKey_0_someNestedKey: 'someValue' } => output = { someKey: [{ someNestedKey: 'someValue' }] }
+ *
+ * @param obj An object to flatten
+ * @param filterFn An optional filter function that allows filtering out certain attributes from being included in the flattened result object. The filterFn is called with 3 arguments (result, value, key) and is expected to return true to include
+ * the key in the result or false to exclude the key from the result.
+ * @param keyPrefix A optional key prefix to include in all keys in the resultant flattened object.
+ * @returns {*}
+ */
+function unFlattenObject(keyValuePairs, filterFn = () => true) {
+ return _.transform(
+ keyValuePairs,
+ (result, value, key) => {
+ if (filterFn(result, value, key)) {
+ _.set(result, key, value);
+ }
+ return result;
+ },
+ {},
+ );
+}
+
+function toUTCDate(str) {
+ if (_.isEmpty(str)) return str;
+ if (!_.isString(str)) return str;
+ if (_.endsWith(str, 'Z')) return str;
+
+ return `${str}Z`;
+}
+
+/**
+ * Given a list of validatorjs rules for a form element, returns valid options
+ * by returning elements defined by the "in" rule as valid Semantic UI options objects.
+ *
+ * @param {Array} formRules Rules for the validatorjs library
+ * @returns {Array