diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js index ebbc05edf3..cf79a9f174 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js @@ -30,6 +30,7 @@ const Project = types updatedAt: '', updatedBy: '', projectAdmins: types.optional(types.array(types.string), []), + isAppStreamConfigured: types.optional(types.boolean, false), }) .actions(self => ({ setProject(rawProject) { diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/ScEnvironmentsList.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/ScEnvironmentsList.js index cc073017cb..4f445a7ab0 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/ScEnvironmentsList.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/ScEnvironmentsList.js @@ -25,6 +25,7 @@ import ScEnvironmentsFilterButtons from './parts/ScEnvironmentsFilterButtons'; // expected props // - scEnvironmentsStore (via injection) // - envTypesStore (via injection) +// - projectsStore (via injection) class ScEnvironmentsList extends React.Component { constructor(props) { super(props); @@ -33,6 +34,7 @@ class ScEnvironmentsList extends React.Component { const name = storage.getItem(key) || filterNames.ALL; storage.setItem(key, name); this.selectedFilter = name; + this.provisionDisabled = false; }); } @@ -53,6 +55,10 @@ class ScEnvironmentsList extends React.Component { store.stopHeartbeat(); } + get isAppStreamEnabled() { + return process.env.REACT_APP_IS_APP_STREAM_ENABLED === 'true'; + } + get envTypesStore() { return this.props.envTypesStore; } @@ -61,6 +67,17 @@ class ScEnvironmentsList extends React.Component { return this.props.scEnvironmentsStore; } + getProjects() { + const store = this.getProjectsStore(); + return store.list; + } + + getProjectsStore() { + const store = this.props.projectsStore; + store.load(); + return store; + } + handleCreateEnvironment = event => { event.preventDefault(); event.stopPropagation(); @@ -78,6 +95,15 @@ class ScEnvironmentsList extends React.Component { render() { const store = this.envsStore; let content = null; + const projects = this.getProjects(); + const appStreamProjectIds = _.map( + _.filter(projects, proj => proj.isAppStreamConfigured), + 'id', + ); + + runInAction(() => { + if (this.isAppStreamEnabled && _.isEmpty(appStreamProjectIds)) this.provisionDisabled = true; + }); if (isStoreError(store)) { content = ; @@ -86,7 +112,7 @@ class ScEnvironmentsList extends React.Component { } else if (isStoreEmpty(store)) { content = this.renderEmpty(); } else if (isStoreNotEmpty(store)) { - content = this.renderMain(); + content = this.renderMain(appStreamProjectIds); } else { content = null; } @@ -94,15 +120,34 @@ class ScEnvironmentsList extends React.Component { return ( {this.renderTitle()} + {this.provisionDisabled && this.renderMissingAppStreamConfig()} {content} ); } - renderMain() { + renderMissingAppStreamConfig() { + return ( + <> + +
+ + Missing association with AppStream projects + + Since your projects are not associated to an AppStream-configured account, creating a new workspace is + disabled. Please contact your administrator. + +
+
+ + ); + } + + renderMain(appStreamProjectIds) { const store = this.envsStore; const selectedFilter = this.selectedFilter; - const list = store.filtered(selectedFilter); + let list = store.filtered(selectedFilter); + list = this.isAppStreamEnabled ? _.filter(list, env => _.includes(appStreamProjectIds, env.projectId)) : list; const isEmpty = _.isEmpty(list); return ( @@ -155,6 +200,7 @@ class ScEnvironmentsList extends React.Component { data-testid="create-workspace" color="blue" size="medium" + disabled={this.provisionDisabled} basic onClick={this.handleCreateEnvironment} > @@ -176,10 +222,15 @@ class ScEnvironmentsList extends React.Component { // see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da decorate(ScEnvironmentsList, { selectedFilter: observable, + provisionDisabled: observable, envsStore: computed, envTypesStore: computed, handleCreateEnvironment: action, handleSelectedFilter: action, }); -export default inject('scEnvironmentsStore', 'envTypesStore')(withRouter(observer(ScEnvironmentsList))); +export default inject( + 'scEnvironmentsStore', + 'projectsStore', + 'envTypesStore', +)(withRouter(observer(ScEnvironmentsList))); diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/CreateInternalEnvForm.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/CreateInternalEnvForm.js index d6a28ca860..e05370d7d4 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/CreateInternalEnvForm.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/CreateInternalEnvForm.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import React from 'react'; import { decorate, computed, runInAction, observable, action } from 'mobx'; import { observer, inject } from 'mobx-react'; -import { Segment, Button, Header } from 'semantic-ui-react'; +import { Segment, Button, Header, Icon } from 'semantic-ui-react'; import { displayError } from '@aws-ee/base-ui/dist/helpers/notification'; import Dropdown from '@aws-ee/base-ui/dist/parts/helpers/fields/DropDown'; import Form from '@aws-ee/base-ui/dist/parts/helpers/fields/Form'; @@ -21,20 +21,37 @@ import SelectConfigurationCards from './SelectConfigurationCards'; // - defaultCidr (via props) // - clientInformationStore (via injection) // - userStore (via injection) +// - projectsStore (via injection) class CreateInternalEnvForm extends React.Component { constructor(props) { super(props); runInAction(() => { this.form = getCreateInternalEnvForm({ - projectIdOptions: this.projectIdOptions, + projectIdOptions: this.getProjectIdOptions(), cidr: this.props.defaultCidr, }); }); } - get projectIdOptions() { + // The list of projects assigned to the user might be broader than the + // list of projects actually available for environment provisioning + // For example: Projects not fully configured with AppStream need to be filtered out + getProjectIdOptions() { const store = this.userStore; - return store.projectIdDropdown; + if (!this.isAppStreamEnabled) return store.projectIdDropdown; + + const projects = this.getProjects(); + const filteredProjects = _.filter(projects, proj => proj.isAppStreamConfigured); + if (_.isEmpty(filteredProjects)) return []; + + const filteredProjectIds = _.map(filteredProjects, proj => proj.id); + const retVal = _.filter(store.projectIdDropdown, proj => _.includes(filteredProjectIds, proj.key)); + + return retVal; + } + + get isAppStreamEnabled() { + return process.env.REACT_APP_IS_APP_STREAM_ENABLED === 'true'; } get envTypeId() { @@ -49,6 +66,17 @@ class CreateInternalEnvForm extends React.Component { return this.props.userStore; } + getProjects() { + const store = this.getProjectsStore(); + return store.list; + } + + getProjectsStore() { + const store = this.props.projectsStore; + store.load(); + return store; + } + // eslint-disable-next-line consistent-return handlePrevious = () => { if (_.isFunction(this.props.onPrevious)) return this.props.onPrevious(); @@ -77,11 +105,59 @@ class CreateInternalEnvForm extends React.Component { ); } + renderButtons() { + return ( +
+
+ ); + } + + renderMissingAppStreamConfig() { + return ( + <> + +
+ + Missing association with AppStream projects + + Your projects are not associated to an AppStream-configured account. Please contact your administrator. + +
+
+ {this.renderButtons()} + + ); + } + renderForm() { const form = this.form; const askForCidr = !_.isUndefined(this.props.defaultCidr); const configurations = this.configurations; + // we show the AppStream configuration warning when the feature is enabled, + // and the user's projects are not linked to AppStream-configured accounts + const projects = this.getProjectIdOptions(); + if (this.isAppStreamEnabled && _.isEmpty(projects)) { + return this.renderMissingAppStreamConfig(); + } + return (
@@ -125,8 +201,8 @@ decorate(CreateInternalEnvForm, { envTypeId: computed, configurations: computed, userStore: computed, - projectIdOptions: computed, + isAppStreamEnabled: computed, handlePrevious: action, }); -export default inject('userStore', 'clientInformationStore')(observer(CreateInternalEnvForm)); +export default inject('userStore', 'projectsStore', 'clientInformationStore')(observer(CreateInternalEnvForm)); diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/ScEnvironmentSetup.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/ScEnvironmentSetup.js index d2638bd151..d4d21f1ee9 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/ScEnvironmentSetup.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/environments-sc/setup/ScEnvironmentSetup.js @@ -198,7 +198,7 @@ class ScEnvironmentSetup extends React.Component { Missing association with projects - You currently do not have permissions to use any projects for the workspace. please contact your + You currently do not have permissions to use any projects for the workspace. Please contact your administrator. diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/__tests__/environment-sc-service.test.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/__tests__/environment-sc-service.test.js index b0347a2b58..989d400f2d 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/__tests__/environment-sc-service.test.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/__tests__/environment-sc-service.test.js @@ -1239,6 +1239,44 @@ describe('EnvironmentSCService', () => { }); }); + describe('filterAppStreamProjectEnvs function', () => { + it('should filter out envs by AppStream config', async () => { + // BUILD + const requestContext = { + principal: { + isExternalUser: true, + }, + }; + const envs = [ + { + id: 'env-1', + projectId: 'proj-1', + }, + { + id: 'env-2', + projectId: 'proj-2', + }, + ]; + const projects = [ + { id: 'proj-1', isAppStreamConfigured: true }, + { id: 'proj-2', isAppStreamConfigured: false }, + ]; + projectService.list = jest.fn(() => projects); + const expected = [ + { + id: 'env-1', + projectId: 'proj-1', + }, + ]; + + // OPERATE + const retVal = await service.filterAppStreamProjectEnvs(requestContext, envs); + + // CHECK + expect(retVal).toEqual(expected); + }); + }); + describe('getSecurityGroupDetails function', () => { it('should send filtered security group rules as expected', async () => { // BUILD diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-cidr-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-cidr-service.js index 8cc8d02a6a..cc4bb6c41d 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-cidr-service.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-cidr-service.js @@ -101,6 +101,9 @@ class EnvironmentScCidrService extends Service { { ...existingEnvironment, updateRequest }, ); + // Verify environment is linked to an AppStream project when application has AppStream enabled + await environmentScService.verifyAppStreamConfig(requestContext, id); + await lockService.tryWriteLockAndRun({ id: `${id}-CidrUpdate` }, async () => { // Calculate diff and update CIDR ranges in ingress rules const { currentIngressRules, securityGroupId } = await environmentScService.getSecurityGroupDetails( diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-connection-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-connection-service.js index bdcfaea40b..9322188b57 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-connection-service.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-connection-service.js @@ -145,6 +145,9 @@ class EnvironmentScConnectionService extends Service { return connection; } + // Verify environment is linked to an AppStream project when application has AppStream enabled + await environmentScService.verifyAppStreamConfig(requestContext, envId); + if (_.toLower(_.get(connection, 'type', '')) === 'sagemaker') { const sagemaker = await environmentScService.getClientSdkWithEnvMgmtRole( requestContext, @@ -237,6 +240,9 @@ class EnvironmentScConnectionService extends Service { // Validate input await validationService.ensureValid(sshConnectionInfo, sshConnectionInfoSchema); + // Verify environment is linked to an AppStream project when application has AppStream enabled + await environmentScService.verifyAppStreamConfig(requestContext, envId); + // The following will succeed only if the user has permissions to access the specified environment const connection = await this.mustFindConnection(requestContext, envId, connectionId); @@ -306,6 +312,9 @@ class EnvironmentScConnectionService extends Service { 'environmentScKeypairService', ]); + // Verify environment is linked to an AppStream project when application has AppStream enabled + await environmentScService.verifyAppStreamConfig(requestContext, envId); + // The following will succeed only if the user has permissions to access the specified environment // and connection const connection = await this.mustFindConnection(requestContext, envId, connectionId); diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-service.js index 21ea7b1388..8362698d97 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-service.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/service-catalog/environment-sc-service.js @@ -29,6 +29,7 @@ const { hasAccess, accessLevels } = require('../../study/helpers/entities/study- const settingKeys = { tableName: 'dbEnvironmentsSc', + isAppStreamEnabled: 'isAppStreamEnabled', }; const workflowIds = { create: 'wf-provision-environment-sc', @@ -68,6 +69,7 @@ class EnvironmentScService extends Service { await super.init(); const [dbService, environmentAuthzService] = await this.service(['dbService', 'environmentAuthzService']); const table = this.settings.get(settingKeys.tableName); + this.isAppStreamEnabled = this.settings.get(settingKeys.isAppStreamEnabled); this._getter = () => dbService.helper.getter().table(table); this._query = () => dbService.helper.query().table(table); @@ -85,7 +87,7 @@ class EnvironmentScService extends Service { // The following will result in checking permissions by calling the condition function "this._allowAuthorized" first await this.assertAuthorized(requestContext, { action: 'list-sc', conditions: [this._allowAuthorized] }); - const envs = await this._scanner() + let envs = await this._scanner() .limit(limit) .scan() .then(environments => { @@ -95,9 +97,24 @@ class EnvironmentScService extends Service { return environments.filter(env => isCurrentUser(requestContext, { uid: env.createdBy })); }); + if (this.isAppStreamEnabled) { + envs = await this.filterAppStreamProjectEnvs(requestContext, envs); + } + return this.augmentWithConnectionInfo(requestContext, envs); } + async filterAppStreamProjectEnvs(requestContext, envs) { + const projectService = await this.service('projectService'); + const projects = await projectService.list(requestContext); + const appStreamProjectIds = _.map( + _.filter(projects, proj => proj.isAppStreamConfigured), + 'id', + ); + + return _.filter(envs, env => _.includes(appStreamProjectIds, env.projectId)); + } + async pollAndSyncWsStatus(requestContext) { const [indexesService, awsAccountsService] = await this.service(['indexesService', 'awsAccountsService']); this.log.info('Start DB scan for status poll and sync.'); @@ -327,6 +344,9 @@ class EnvironmentScService extends Service { await this.assertAuthorized(requestContext, { action: 'get-sc', conditions: [this._allowAuthorized] }, result); } + // Verify environment is linked to an AppStream project when application has AppStream enabled + await this.verifyAppStreamConfig(requestContext, id); + const env = this._fromDbToDataObject(result); // We only check for the ingress rules of a successfully provisioned environment not in failure state @@ -385,7 +405,15 @@ class EnvironmentScService extends Service { const { envTypeId, envTypeConfigId, projectId } = environment; // Lets find the index id, by looking at the project and then get the index id - const { indexId } = await projectService.mustFind(requestContext, { id: projectId, fields: ['indexId'] }); + // The isAppStreamConfigured attribute value will be returned by project service. No other fields needed to be added + const { indexId, isAppStreamConfigured } = await projectService.mustFind(requestContext, { + id: projectId, + fields: ['indexId'], + }); + + // If the AppStream feature is enabled, verify the project linked to the environment has it configured + if (this.isAppStreamEnabled && !isAppStreamConfigured) + throw this.boom.badRequest('Please select an AppStream-configured project', true); // Save environment to db and trigger the workflow const by = _.get(requestContext, 'principalIdentifier.uid'); @@ -442,6 +470,21 @@ class EnvironmentScService extends Service { return dbResult; } + async verifyAppStreamConfig(requestContext, envId) { + // If the AppStream feature is enabled, verify the project linked to the environment has it configured + if (this.isAppStreamEnabled) { + const { projectId } = await this.mustFind(requestContext, { id: envId }); + const projectService = await this.service('projectService'); + // The isAppStreamConfigured attribute value will be returned by project service. indexId field is enough for filtering + const { isAppStreamConfigured } = await projectService.mustFind(requestContext, { + id: projectId, + fields: ['indexId'], + }); + if (!isAppStreamConfigured) + throw this.boom.badRequest('Please select an environment with an AppStream-configured project', true); + } + } + async update(requestContext, environment, ipAllowListAction = {}) { // Validate input const [validationService, storageGatewayService] = await this.service([ @@ -461,6 +504,9 @@ class EnvironmentScService extends Service { existingEnvironment, ); + // Verify environment is linked to an AppStream project when application has AppStream enabled + await this.verifyAppStreamConfig(requestContext, existingEnvironment.id); + const by = _.get(requestContext, 'principalIdentifier.uid'); const { id, rev } = environment; @@ -613,6 +659,9 @@ class EnvironmentScService extends Service { existingEnvironment, ); + // Verify environment is linked to an AppStream project when application has AppStream enabled + await this.verifyAppStreamConfig(requestContext, existingEnvironment.id); + const { status, outputs, projectId } = existingEnvironment; // expected environment run state based on operation @@ -851,6 +900,9 @@ class EnvironmentScService extends Service { existingEnvironment, ); + // Verify environment is linked to an AppStream project when application has AppStream enabled + await this.verifyAppStreamConfig(requestContext, existingEnvironment.id); + await this.update(requestContext, { id, rev: existingEnvironment.rev, diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/project/__tests__/project-service.test.js b/addons/addon-base-raas/packages/base-raas-services/lib/project/__tests__/project-service.test.js index 545dc0f3ce..8b19698833 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/project/__tests__/project-service.test.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/project/__tests__/project-service.test.js @@ -32,11 +32,19 @@ const SettingsServiceMock = require('@aws-ee/base-services/lib/settings/env-sett jest.mock('@aws-ee/base-services/lib/user/user-service'); const UserServiceMock = require('@aws-ee/base-services/lib/user/user-service'); +jest.mock('../../aws-accounts/aws-accounts-service'); +const AwsAccountsServiceMock = require('../../aws-accounts/aws-accounts-service'); + +jest.mock('../../indexes/indexes-service'); +const IndexesServiceMock = require('../../indexes/indexes-service'); + const ProjectService = require('../project-service'); describe('ProjectService', () => { let service = null; let dbService = null; + let indexesService = null; + let awsAccountsService = null; beforeAll(async () => { // Initialize services container and register dependencies const container = new ServicesContainer(); @@ -47,12 +55,16 @@ describe('ProjectService', () => { container.register('auditWriterService', new AuditServiceMock()); container.register('settings', new SettingsServiceMock()); container.register('userService', new UserServiceMock()); + container.register('awsAccountsService', new AwsAccountsServiceMock()); + container.register('indexesService', new IndexesServiceMock()); await container.initServices(); // Get instance of the service we are testing service = await container.find('projectService'); dbService = await container.find('dbService'); + indexesService = await container.find('indexesService'); + awsAccountsService = await container.find('awsAccountsService'); // Skip authorization service.assertAuthorized = jest.fn(); @@ -124,6 +136,30 @@ describe('ProjectService', () => { }); }); + it('should fail if rev is empty', async () => { + const project = { + id: 'my-new-project', + description: 'Some relevant description', + indexId: '123', + // empty rev should cause error + }; + + try { + await service.update({}, project); + expect.hasAssertions(); + } catch (err) { + expect(err.payload).toBeDefined(); + const error = err.payload.validationErrors[0]; + expect(error).toMatchObject({ + keyword: 'required', + dataPath: '', + schemaPath: '#/required', + params: { missingProperty: 'rev' }, + message: "should have required property 'rev'", + }); + } + }); + describe('update', () => { it('should NOT fail for all required properties present', async () => { const project = { @@ -160,28 +196,149 @@ describe('ProjectService', () => { }); }); - it('should fail if rev is empty', async () => { - const project = { - id: 'my-new-project', - description: 'Some relevant description', - indexId: '123', - // empty rev should cause error - }; + describe('updateWithAppStreamConfig', () => { + it('should return list of projects with appropriate isAppStreamConfigured bool', async () => { + // BUILD + const input = [ + { + id: 'my-appstream-project', + description: 'Some relevant description', + indexId: 'index-1', + rev: 1, + }, + { + id: 'my-non-appstream-project', + description: 'Some relevant description', + indexId: 'index-2', + rev: 1, + }, + ]; + awsAccountsService.list = jest.fn(() => { + return [ + { + id: 'awsAccountId-1', + appStreamFleetName: 'sampleAppStreamFleetName', + appStreamSecurityGroupId: 'sampleAppStreamSecurityGroupId', + appStreamStackName: 'sampleAppStreamStackName', + }, + { id: 'awsAccountId-2' }, + { id: 'awsAccountId-3' }, + ]; + }); + indexesService.list = jest.fn(() => { + return [ + { id: 'index-1', awsAccountId: 'awsAccountId-1' }, + { id: 'index-2', awsAccountId: 'awsAccountId-2' }, + { id: 'index-3', awsAccountId: 'awsAccountId-3' }, + ]; + }); + const expectedRetVal = [ + { + id: 'my-appstream-project', + description: 'Some relevant description', + indexId: 'index-1', + rev: 1, + isAppStreamConfigured: true, + }, + { + id: 'my-non-appstream-project', + description: 'Some relevant description', + indexId: 'index-2', + rev: 1, + isAppStreamConfigured: false, + }, + ]; - try { - await service.update({}, project); - expect.hasAssertions(); - } catch (err) { - expect(err.payload).toBeDefined(); - const error = err.payload.validationErrors[0]; - expect(error).toMatchObject({ - keyword: 'required', - dataPath: '', - schemaPath: '#/required', - params: { missingProperty: 'rev' }, - message: "should have required property 'rev'", + // EXECUTE + const retVal = await service.updateWithAppStreamConfig(input); + + // CHECK + await expect(retVal).toEqual(expectedRetVal); + }); + + it('should return project with appropriate isAppStreamConfigured bool', async () => { + // BUILD + const input = { + id: 'my-appstream-project', + description: 'Some relevant description', + indexId: 'index-1', + rev: 1, + }; + awsAccountsService.list = jest.fn(() => { + return [ + { + id: 'awsAccountId-1', + appStreamFleetName: 'sampleAppStreamFleetName', + appStreamSecurityGroupId: 'sampleAppStreamSecurityGroupId', + appStreamStackName: 'sampleAppStreamStackName', + }, + { id: 'awsAccountId-2' }, + { id: 'awsAccountId-3' }, + ]; }); - } + indexesService.list = jest.fn(() => { + return [ + { id: 'index-1', awsAccountId: 'awsAccountId-1' }, + { id: 'index-2', awsAccountId: 'awsAccountId-2' }, + { id: 'index-3', awsAccountId: 'awsAccountId-3' }, + ]; + }); + const expectedRetVal = { + id: 'my-appstream-project', + description: 'Some relevant description', + indexId: 'index-1', + rev: 1, + isAppStreamConfigured: true, + }; + + // EXECUTE + const retVal = await service.updateWithAppStreamConfig(input); + + // CHECK + await expect(retVal).toEqual(expectedRetVal); + }); + + it('should return project with unset isAppStreamConfigured for incomplete AppStream configuration', async () => { + // BUILD + const input = { + id: 'my-appstream-project', + description: 'Some relevant description', + indexId: 'index-1', + rev: 1, + }; + awsAccountsService.list = jest.fn(() => { + return [ + { + id: 'awsAccountId-1', + appStreamFleetName: 'sampleAppStreamFleetName', + appStreamSecurityGroupId: 'sampleAppStreamSecurityGroupId', + // appStreamStackName: 'sampleAppStreamStackName', // missing config + }, + { id: 'awsAccountId-2' }, + { id: 'awsAccountId-3' }, + ]; + }); + indexesService.list = jest.fn(() => { + return [ + { id: 'index-1', awsAccountId: 'awsAccountId-1' }, + { id: 'index-2', awsAccountId: 'awsAccountId-2' }, + { id: 'index-3', awsAccountId: 'awsAccountId-3' }, + ]; + }); + const expectedRetVal = { + id: 'my-appstream-project', + description: 'Some relevant description', + indexId: 'index-1', + rev: 1, + isAppStreamConfigured: false, + }; + + // EXECUTE + const retVal = await service.updateWithAppStreamConfig(input); + + // CHECK + await expect(retVal).toEqual(expectedRetVal); + }); }); describe('delete', () => { 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 index 7b2d909e25..e99a2c37ba 100644 --- 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 @@ -17,14 +17,16 @@ 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 { getSystemRequestContext } = require('@aws-ee/base-services/lib/helpers/system-context'); -const { isExternalGuest, isExternalResearcher, isInternalGuest } = require('../helpers/is-role'); +const { isExternalGuest, isExternalResearcher, isInternalGuest, isAdmin, isSystem } = require('../helpers/is-role'); const createSchema = require('../schema/create-project'); const updateSchema = require('../schema/update-project'); const settingKeys = { tableName: 'dbProjects', environmentTableName: 'dbEnvironments', + isAppStreamEnabled: 'isAppStreamEnabled', }; class ProjectService extends Service { @@ -36,6 +38,8 @@ class ProjectService extends Service { 'dbService', 'auditWriterService', 'userService', + 'awsAccountsService', + 'indexesService', ]); } @@ -44,6 +48,7 @@ class ProjectService extends Service { const [dbService] = await this.service(['dbService']); const table = this.settings.get(settingKeys.tableName); this.userService = await this.service('userService'); + this.isAppStreamEnabled = this.settings.get(settingKeys.isAppStreamEnabled); this._getter = () => dbService.helper.getter().table(table); this._updater = () => dbService.helper.updater().table(table); @@ -58,13 +63,23 @@ class ProjectService extends Service { if (restrict) return undefined; - // Future task: return undefined if the user is not associated with this project, unless they are admin + // Throw unauthorized error if the user is not associated with this project, unless they are admin + if ( + !isAdmin(requestContext) && + !isSystem(requestContext) && + !(await this.verifyUserProjectAssociation(_.get(requestContext, 'principalIdentifier.uid'), id)) + ) + throw this.boom.forbidden(`You're not authorized to access project "${id}"`, true); - const result = await this._getter() + let result = await this._getter() .key({ id }) .projection(fields) .get(); + if (this.isAppStreamEnabled) { + result = this.updateWithAppStreamConfig(result); + } + return this._fromDbToDataObject(result); } @@ -209,13 +224,74 @@ class ProjectService extends Service { 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() + let projects = await this._scanner() .limit(1000) .projection(fields) .scan(); + + if (this.isAppStreamEnabled) { + projects = await this.updateWithAppStreamConfig(projects); + } + + // Only return projects that the user has been associated with unless user is an admin + if (isAdmin(requestContext) || isSystem(requestContext)) return projects; + + const by = _.get(requestContext, 'principalIdentifier.uid'); + const retVal = await Promise.all( + _.map(projects, async proj => { + if (await this.verifyUserProjectAssociation(by, proj.id)) return proj; + return undefined; + }), + ); + return _.filter(retVal, val => !_.isUndefined(val)); + } + + /** + * Returns the same object type as the input with an added isAppStreamConfigured boolean flag for each project entity + * + * IMPORTANT: The system context is used instead of the user's request context. + * This is because non-admin users do not have access to resources like indexes and awsAccounts, + * which are required for config verification. No other information from these resources are made available to non-admins + * + * @param input The Project entity, or list of Project entities + */ + async updateWithAppStreamConfig(input) { + try { + const systemContext = getSystemRequestContext(); + const [awsAccountsService, indexesService] = await this.service(['awsAccountsService', 'indexesService']); + const accounts = await awsAccountsService.list(systemContext, { + fields: ['id', 'appStreamFleetName', 'appStreamSecurityGroupId', 'appStreamStackName'], + }); + const indexes = await indexesService.list(systemContext, { fieldsToGet: ['id', 'awsAccountId'] }); + const accountIdsWithAppStream = _.map( + _.filter( + accounts, + account => + !_.isUndefined(account.appStreamFleetName) && + !_.isUndefined(account.appStreamSecurityGroupId) && + !_.isUndefined(account.appStreamStackName), + ), + 'id', + ); + const indexIdsWithAppStream = _.map( + _.filter(indexes, index => _.includes(accountIdsWithAppStream, index.awsAccountId)), + 'id', + ); + + if (_.isArray(input)) { + return _.map(input, project => { + project.isAppStreamConfigured = _.includes(indexIdsWithAppStream, project.indexId); + return project; + }); + } + + const project = input; + project.isAppStreamConfigured = _.includes(indexIdsWithAppStream, project.indexId); + return project; + } catch (err) { + throw this.boom.badRequest(`There was an error filtering AppStream projects: ${err}`, true); + } } /** diff --git a/main/end-to-end-tests/cypress.local.example.json b/main/end-to-end-tests/cypress.local.example.json index 14a34e47e9..1c9330847f 100644 --- a/main/end-to-end-tests/cypress.local.example.json +++ b/main/end-to-end-tests/cypress.local.example.json @@ -6,6 +6,8 @@ "env": { "researcherEmail": "thingut+researcher@amazon.com", "researcherPassword": "abcd1234", + "restrictedResearcherEmail": "dummyResearcher@amazon.com", + "restrictedResearcherPassword": "abcd1234", "isCognitoEnabled": false, "workspaces": { "sagemaker": { diff --git a/main/end-to-end-tests/cypress/integration/workspaces.appstream.spec.js b/main/end-to-end-tests/cypress/integration/workspaces.appstream.spec.js index b32c6d40a3..b28a37f856 100644 --- a/main/end-to-end-tests/cypress/integration/workspaces.appstream.spec.js +++ b/main/end-to-end-tests/cypress/integration/workspaces.appstream.spec.js @@ -122,3 +122,23 @@ describe('Launch new workspaces', () => { return workspaceName; }; }); + +describe('Verify workspace creation button disabled', () => { + before(() => { + // We use the restricted researcher credentials for this test. + // This user should not be assigned to a project with AppStream configuration + cy.login('restrictedResearcher'); + navigateToWorkspaces(); + }); + + const navigateToWorkspaces = () => { + cy.get('.left.menu') + .contains('Workspaces') + .click(); + cy.get('[data-testid=workspaces]'); + }; + + it('should launch show create workspace button as disabled', () => { + cy.get('button[data-testid=create-workspace]').should('be.disabled'); + }); +}); diff --git a/main/end-to-end-tests/cypress/support/commands.js b/main/end-to-end-tests/cypress/support/commands.js index 7a1b2f84eb..7ea537f022 100644 --- a/main/end-to-end-tests/cypress/support/commands.js +++ b/main/end-to-end-tests/cypress/support/commands.js @@ -51,6 +51,12 @@ Cypress.Commands.add('login', role => { email: Cypress.env('researcherEmail'), password: Cypress.env('researcherPassword'), }; + } + if (role === 'restrictedResearcher') { + loginInfo = { + email: Cypress.env('restrictedResearcherEmail'), + password: Cypress.env('restrictedResearcherPassword'), + }; } else if (role === 'admin') { loginInfo = { email: Cypress.env('adminEmail'),