diff --git a/.circleci/config.yml b/.circleci/config.yml index b0e0931b166..7acd60880db 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1121,6 +1121,14 @@ jobs: environment: TEST_SUITE: src/__tests__/migration/node.function.test.ts CLI_REGION: eu-west-2 + iam-permissions-boundary-amplify_e2e_tests: + working_directory: ~/repo + docker: *ref_1 + resource_class: large + steps: *ref_4 + environment: + TEST_SUITE: src/__tests__/iam-permissions-boundary.test.ts + CLI_REGION: eu-central-1 function_5-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1128,7 +1136,7 @@ jobs: steps: *ref_4 environment: TEST_SUITE: src/__tests__/function_5.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: ap-northeast-1 configure-project-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1136,7 +1144,7 @@ jobs: steps: *ref_4 environment: TEST_SUITE: src/__tests__/configure-project.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-southeast-1 api_4-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1144,7 +1152,7 @@ jobs: steps: *ref_4 environment: TEST_SUITE: src/__tests__/api_4.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: ap-southeast-2 schema-iterative-update-4-amplify_e2e_tests_pkg_linux: working_directory: ~/repo docker: *ref_1 @@ -1805,6 +1813,16 @@ jobs: TEST_SUITE: src/__tests__/migration/node.function.test.ts CLI_REGION: eu-west-2 steps: *ref_5 + iam-permissions-boundary-amplify_e2e_tests_pkg_linux: + working_directory: ~/repo + docker: *ref_1 + resource_class: large + environment: + AMPLIFY_DIR: /home/circleci/repo/out + AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux + TEST_SUITE: src/__tests__/iam-permissions-boundary.test.ts + CLI_REGION: eu-central-1 + steps: *ref_5 function_5-amplify_e2e_tests_pkg_linux: working_directory: ~/repo docker: *ref_1 @@ -1813,7 +1831,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/function_5.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: ap-northeast-1 steps: *ref_5 configure-project-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -1823,7 +1841,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/configure-project.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-southeast-1 steps: *ref_5 api_4-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -1833,7 +1851,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/api_4.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: ap-southeast-2 steps: *ref_5 workflows: version: 2 @@ -1948,19 +1966,19 @@ workflows: - predictions-amplify_e2e_tests - schema-predictions-amplify_e2e_tests - amplify-configure-amplify_e2e_tests - - function_5-amplify_e2e_tests + - iam-permissions-boundary-amplify_e2e_tests - containers-api-amplify_e2e_tests - interactions-amplify_e2e_tests - datastore-modelgen-amplify_e2e_tests - - configure-project-amplify_e2e_tests + - function_5-amplify_e2e_tests - schema-iterative-update-2-amplify_e2e_tests - schema-data-access-patterns-amplify_e2e_tests - init-special-case-amplify_e2e_tests - - api_4-amplify_e2e_tests - - auth_1-amplify_e2e_tests + - configure-project-amplify_e2e_tests - feature-flags-amplify_e2e_tests - schema-versioned-amplify_e2e_tests - plugin-amplify_e2e_tests + - api_4-amplify_e2e_tests - done_with_pkg_linux_e2e_tests: requires: - schema-key-amplify_e2e_tests_pkg_linux @@ -1978,19 +1996,19 @@ workflows: - predictions-amplify_e2e_tests_pkg_linux - schema-predictions-amplify_e2e_tests_pkg_linux - amplify-configure-amplify_e2e_tests_pkg_linux - - function_5-amplify_e2e_tests_pkg_linux + - iam-permissions-boundary-amplify_e2e_tests_pkg_linux - containers-api-amplify_e2e_tests_pkg_linux - interactions-amplify_e2e_tests_pkg_linux - datastore-modelgen-amplify_e2e_tests_pkg_linux - - configure-project-amplify_e2e_tests_pkg_linux + - function_5-amplify_e2e_tests_pkg_linux - schema-iterative-update-2-amplify_e2e_tests_pkg_linux - schema-data-access-patterns-amplify_e2e_tests_pkg_linux - init-special-case-amplify_e2e_tests_pkg_linux - - api_4-amplify_e2e_tests_pkg_linux - - auth_1-amplify_e2e_tests_pkg_linux + - configure-project-amplify_e2e_tests_pkg_linux - feature-flags-amplify_e2e_tests_pkg_linux - schema-versioned-amplify_e2e_tests_pkg_linux - plugin-amplify_e2e_tests_pkg_linux + - api_4-amplify_e2e_tests_pkg_linux - amplify_migration_tests_latest: context: - amplify-ecr-image-pull @@ -2345,7 +2363,7 @@ workflows: filters: *ref_9 requires: - auth_4-amplify_e2e_tests - - function_5-amplify_e2e_tests: + - iam-permissions-boundary-amplify_e2e_tests: context: *ref_7 post-steps: *ref_8 filters: *ref_9 @@ -2405,7 +2423,7 @@ workflows: filters: *ref_9 requires: - migration-api-key-migration1-amplify_e2e_tests - - configure-project-amplify_e2e_tests: + - function_5-amplify_e2e_tests: context: *ref_7 post-steps: *ref_8 filters: *ref_9 @@ -2465,7 +2483,7 @@ workflows: filters: *ref_9 requires: - layer-amplify_e2e_tests - - api_4-amplify_e2e_tests: + - configure-project-amplify_e2e_tests: context: *ref_7 post-steps: *ref_8 filters: *ref_9 @@ -2525,6 +2543,12 @@ workflows: filters: *ref_9 requires: - auth_3-amplify_e2e_tests + - api_4-amplify_e2e_tests: + context: *ref_7 + post-steps: *ref_8 + filters: *ref_9 + requires: + - auth_1-amplify_e2e_tests - schema-iterative-update-4-amplify_e2e_tests_pkg_linux: context: &ref_10 - amplify-ecr-image-pull @@ -2783,7 +2807,7 @@ workflows: filters: *ref_12 requires: - auth_4-amplify_e2e_tests_pkg_linux - - function_5-amplify_e2e_tests_pkg_linux: + - iam-permissions-boundary-amplify_e2e_tests_pkg_linux: context: *ref_10 post-steps: *ref_11 filters: *ref_12 @@ -2847,7 +2871,7 @@ workflows: filters: *ref_12 requires: - migration-api-key-migration1-amplify_e2e_tests_pkg_linux - - configure-project-amplify_e2e_tests_pkg_linux: + - function_5-amplify_e2e_tests_pkg_linux: context: *ref_10 post-steps: *ref_11 filters: *ref_12 @@ -2911,7 +2935,7 @@ workflows: filters: *ref_12 requires: - layer-amplify_e2e_tests_pkg_linux - - api_4-amplify_e2e_tests_pkg_linux: + - configure-project-amplify_e2e_tests_pkg_linux: context: *ref_10 post-steps: *ref_11 filters: *ref_12 @@ -2975,3 +2999,9 @@ workflows: filters: *ref_12 requires: - auth_3-amplify_e2e_tests_pkg_linux + - api_4-amplify_e2e_tests_pkg_linux: + context: *ref_10 + post-steps: *ref_11 + filters: *ref_12 + requires: + - auth_1-amplify_e2e_tests_pkg_linux diff --git a/packages/amplify-cli-core/src/__tests__/permissionsBoundaryState.test.ts b/packages/amplify-cli-core/src/__tests__/permissionsBoundaryState.test.ts new file mode 100644 index 00000000000..1f3dbe9caad --- /dev/null +++ b/packages/amplify-cli-core/src/__tests__/permissionsBoundaryState.test.ts @@ -0,0 +1,76 @@ +import { getPermissionsBoundaryArn, setPermissionsBoundaryArn } from '..'; +import { stateManager } from '../state-manager'; + +jest.mock('../state-manager'); + +const testEnv = 'testEnv'; + +const objKey = 'PermissionsBoundaryPolicyArn'; + +const stateManager_mock = stateManager as jest.Mocked; +stateManager_mock.getLocalEnvInfo.mockReturnValue({ + envName: testEnv, +}); + +const testArn = 'testArn'; + +const tpi_stub = { + [testEnv]: { + awscloudformation: { + [objKey]: testArn, + }, + }, +}; + +describe('get permissions boundary arn', () => { + beforeEach(jest.clearAllMocks); + it('gets arn from team provider info file', () => { + stateManager_mock.getTeamProviderInfo.mockReturnValueOnce(tpi_stub); + expect(getPermissionsBoundaryArn()).toEqual(testArn); + }); + + it('gets arn from preInitTeamProviderInfo', () => { + // setup + setPermissionsBoundaryArn(testArn, testEnv, tpi_stub); + + // test + expect(getPermissionsBoundaryArn()).toEqual(testArn); + + // reset + setPermissionsBoundaryArn(testArn, testEnv); + }); + + it('returns undefined if no value found', () => { + expect(getPermissionsBoundaryArn()).toBeUndefined(); + }); +}); + +describe('set permissions boundary arn', () => { + beforeEach(jest.clearAllMocks); + it('sets the ARN value in tpi file if specified', () => { + stateManager_mock.getTeamProviderInfo.mockReturnValueOnce({}); + setPermissionsBoundaryArn(testArn); + expect(stateManager_mock.setTeamProviderInfo.mock.calls[0][1][testEnv].awscloudformation[objKey]).toEqual(testArn); + }); + + it('sets the ARN for the specified env', () => { + stateManager_mock.getTeamProviderInfo.mockReturnValueOnce({}); + setPermissionsBoundaryArn(testArn, 'otherenv'); + expect(stateManager_mock.setTeamProviderInfo.mock.calls[0][1].otherenv.awscloudformation[objKey]).toEqual(testArn); + }); + + it('removes the ARN value if not specified', () => { + stateManager_mock.getTeamProviderInfo.mockReturnValueOnce(tpi_stub); + setPermissionsBoundaryArn(); + expect(stateManager_mock.setTeamProviderInfo.mock.calls[0][1][testEnv].awscloudformation).toBeDefined(); + expect(stateManager_mock.setTeamProviderInfo.mock.calls[0][1][testEnv].awscloudformation[objKey]).toBeUndefined(); + }); + + it('if tpi object specified, sets arn in object and sets global preInitTeamProviderInfo', () => { + const tpi: Record = {}; + setPermissionsBoundaryArn(testArn, undefined, tpi); + expect(tpi[testEnv].awscloudformation[objKey]).toEqual(testArn); + expect(getPermissionsBoundaryArn()).toEqual(testArn); + delete (global as any).preInitTeamProviderInfo; + }); +}); diff --git a/packages/amplify-cli-core/src/index.ts b/packages/amplify-cli-core/src/index.ts index fc73f0ce98b..1254dfee0b3 100644 --- a/packages/amplify-cli-core/src/index.ts +++ b/packages/amplify-cli-core/src/index.ts @@ -5,6 +5,7 @@ export * from './cliContext'; export * from './cliContextEnvironmentProvider'; export * from './cliEnvironmentProvider'; export * from './feature-flags'; +export * from './permissionsBoundaryState'; export * from './jsonUtilities'; export * from './jsonValidationError'; export * from './serviceSelection'; @@ -182,7 +183,7 @@ interface AmplifyToolkit { getResourceStatus: (category?: $TSAny, resourceName?: $TSAny, providerName?: $TSAny, filteredResources?: $TSAny) => $TSAny; getResourceOutputs: () => $TSAny; getWhen: () => $TSAny; - inputValidation: (input: $TSAny) => $TSAny; + inputValidation: (input: $TSAny) => (value: $TSAny) => boolean | string; listCategories: () => $TSAny; makeId: (n?: number) => string; openEditor: () => $TSAny; diff --git a/packages/amplify-cli-core/src/permissionsBoundaryState.ts b/packages/amplify-cli-core/src/permissionsBoundaryState.ts new file mode 100644 index 00000000000..9a4cab560fc --- /dev/null +++ b/packages/amplify-cli-core/src/permissionsBoundaryState.ts @@ -0,0 +1,56 @@ +import { stateManager } from './state-manager'; +import _ from 'lodash'; +import { $TSObject } from '.'; + +let preInitTeamProviderInfo: any; + +export const getPermissionsBoundaryArn = (env?: string): string | undefined => { + try { + const tpi = preInitTeamProviderInfo ?? stateManager.getTeamProviderInfo(); + // if the pre init team-provider-info only has one env (which should always be the case), default to that one + if (preInitTeamProviderInfo && Object.keys(preInitTeamProviderInfo).length === 1 && !env) { + env = Object.keys(preInitTeamProviderInfo)[0]; + } + return _.get(tpi, teamProviderInfoObjectPath(env)) as string | undefined; + } catch { + // uninitialized project + return undefined; + } +}; + +/** + * Stores the permissions boundary ARN in team-provider-info + * If teamProviderInfo is not specified, the file is read, updated and written back to disk + * If teamProviderInfo is specified, then this function assumes that the env is not initialized + * In this case, the teamProviderInfo object is updated but not written to disk. Instead "preInitTeamProviderInfo" is set + * so that subsequent calls to getPermissionsBoundaryArn will return the permissions boundary arn of the pre-initialized env + * @param arn The permissions boundary arn. If undefined or empty, the permissions boundary is removed + * @param env The Amplify env to update. If not specified, defaults to the current checked out environment + * @param teamProviderInfo The team-provider-info object to update + */ +export const setPermissionsBoundaryArn = (arn?: string, env?: string, teamProviderInfo?: $TSObject): void => { + let tpiGetter = () => stateManager.getTeamProviderInfo(); + let tpiSetter = (tpi: $TSObject) => { + stateManager.setTeamProviderInfo(undefined, tpi); + preInitTeamProviderInfo = undefined; + }; + if (teamProviderInfo) { + tpiGetter = () => teamProviderInfo; + tpiSetter = (tpi: $TSObject) => { + preInitTeamProviderInfo = tpi; + }; + } + const tpi = tpiGetter(); + if (!arn) { + _.unset(tpi, teamProviderInfoObjectPath(env)); + } else { + _.set(tpi, teamProviderInfoObjectPath(env), arn); + } + tpiSetter(tpi); +}; + +const teamProviderInfoObjectPath = (env?: string) => [ + env || (stateManager.getLocalEnvInfo().envName as string), + 'awscloudformation', + 'PermissionsBoundaryPolicyArn', +]; diff --git a/packages/amplify-cli/src/commands/env.ts b/packages/amplify-cli/src/commands/env.ts index 9eaf2822740..e1fd498e822 100644 --- a/packages/amplify-cli/src/commands/env.ts +++ b/packages/amplify-cli/src/commands/env.ts @@ -82,6 +82,10 @@ function displayHelp(context) { name: 'import --name --config [--awsInfo ]', description: 'Imports an already existing Amplify project environment stack to your local backend', }, + { + name: 'update [--permissions-boundary ]', + description: 'Update the environment configuration', + }, { name: 'remove ', description: 'Removes an environment from the Amplify project', diff --git a/packages/amplify-cli/src/commands/env/update.ts b/packages/amplify-cli/src/commands/env/update.ts new file mode 100644 index 00000000000..27b785451d1 --- /dev/null +++ b/packages/amplify-cli/src/commands/env/update.ts @@ -0,0 +1,6 @@ +import { $TSContext } from 'amplify-cli-core'; +import { executeProviderCommand } from '../../extensions/amplify-helpers/get-provider-plugins'; + +export const run = async (context: $TSContext) => { + await executeProviderCommand(context, 'updateEnv'); +}; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/get-deployment-secrets-key.ts b/packages/amplify-cli/src/extensions/amplify-helpers/get-deployment-secrets-key.ts new file mode 100644 index 00000000000..881cd16434f --- /dev/null +++ b/packages/amplify-cli/src/extensions/amplify-helpers/get-deployment-secrets-key.ts @@ -0,0 +1,24 @@ +import { stateManager } from 'amplify-cli-core'; +import { v4 as uuid } from 'uuid'; +import _ from 'lodash'; + +const prePushKeyName = 'prePushDeploymentSecretsKey'; + +export function getDeploymentSecretsKey(): string { + const teamProviderInfo = stateManager.getTeamProviderInfo(); + const { envName } = stateManager.getLocalEnvInfo(); + const envTeamProviderInfo = teamProviderInfo[envName]; + + const prePushKey = envTeamProviderInfo?.awscloudformation?.[prePushKeyName]; + if (prePushKey) { + return prePushKey; + } + const stackId = envTeamProviderInfo?.awscloudformation?.StackId; + if (typeof stackId === 'string') { + return stackId.split('/')[2]; + } + const newKey = uuid(); + _.set(teamProviderInfo, [envName, 'awscloudformation', prePushKeyName], newKey); + stateManager.setTeamProviderInfo(undefined, teamProviderInfo); + return newKey; +} diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/get-provider-plugins.ts b/packages/amplify-cli/src/extensions/amplify-helpers/get-provider-plugins.ts index 8b393ef1e43..f6eba9e70c7 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/get-provider-plugins.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/get-provider-plugins.ts @@ -1,6 +1,7 @@ -import { $TSContext } from 'amplify-cli-core'; +import { $TSContext, stateManager } from 'amplify-cli-core'; +import _ from 'lodash'; -export function getProviderPlugins(context: $TSContext) { +export function getProviderPlugins(context: $TSContext): Record { const providers = {}; context.runtime.plugins.forEach(plugin => { if (plugin.pluginType === 'provider') { @@ -9,3 +10,18 @@ export function getProviderPlugins(context: $TSContext) { }); return providers; } + +export const getConfiguredProviders = (context: $TSContext) => { + const configuredProviders = stateManager.getProjectConfig()?.providers; + if (!Array.isArray(configuredProviders) || configuredProviders.length < 1) { + throw new Error('No providers are configured for the project'); + } + return _.pick(getProviderPlugins(context), configuredProviders) as Record; +}; + +export const executeProviderCommand = async (context: $TSContext, command: string, args: unknown[] = []) => { + const providers = await Promise.all(Object.values(getConfiguredProviders(context)).map(providerPath => import(providerPath))); + await Promise.all( + providers.filter(provider => typeof provider?.[command] === 'function').map(provider => provider[command](context, ...args)), + ); +}; diff --git a/packages/amplify-cli/src/init-steps/s9-onSuccess.ts b/packages/amplify-cli/src/init-steps/s9-onSuccess.ts index 940a772d858..46d2167402a 100644 --- a/packages/amplify-cli/src/init-steps/s9-onSuccess.ts +++ b/packages/amplify-cli/src/init-steps/s9-onSuccess.ts @@ -6,6 +6,7 @@ import { getProviderPlugins } from '../extensions/amplify-helpers/get-provider-p import { insertAmplifyIgnore } from '../extensions/amplify-helpers/git-manager'; import { writeReadMeFile } from '../extensions/amplify-helpers/docs-manager'; import { initializeEnv } from '../initialize-env'; +import _ from 'lodash'; export async function onHeadlessSuccess(context: $TSContext) { const frontendPlugins = getFrontendPlugins(context); @@ -144,7 +145,7 @@ function generateTeamProviderInfoFile(context: $TSContext) { default: {}, }); - Object.assign(teamProviderInfo, context.exeInfo.teamProviderInfo); + _.merge(teamProviderInfo, context.exeInfo.teamProviderInfo); } else { ({ teamProviderInfo } = context.exeInfo); } diff --git a/packages/amplify-e2e-core/src/init/initProjectHelper.ts b/packages/amplify-e2e-core/src/init/initProjectHelper.ts index f129161b888..7d825bd143d 100644 --- a/packages/amplify-e2e-core/src/init/initProjectHelper.ts +++ b/packages/amplify-e2e-core/src/init/initProjectHelper.ts @@ -19,6 +19,7 @@ const defaultSettings = { disableAmplifyAppCreation: true, disableCIDetection: false, providerConfig: undefined, + permissionsBoundaryArn: undefined, }; export function initJSProjectWithProfile(cwd: string, settings: Object): Promise { @@ -39,6 +40,10 @@ export function initJSProjectWithProfile(cwd: string, settings: Object): Promise cliArgs.push('--providers', JSON.stringify(s.providerConfig)); } + if (s.permissionsBoundaryArn) { + cliArgs.push('--permissions-boundary', s.permissionsBoundaryArn); + } + return new Promise((resolve, reject) => { const chain = spawn(getCLIPath(), cliArgs, { cwd, stripColors: true, env, disableCIDetection: s.disableCIDetection }) .wait('Enter a name for the project') @@ -367,6 +372,18 @@ export function amplifyInitSandbox(cwd: string, settings: {}): Promise { }); } +export function amplifyInitYes(cwd: string): Promise { + return new Promise((resolve, reject) => { + spawn(getCLIPath(), ['init', '--yes'], { + cwd, + stripColors: true, + env: { + CLI_DEV_INTERNAL_DISABLE_AMPLIFY_APP_CREATION: '1', + }, + }).run((err: Error) => (err ? reject(err) : resolve())); + }); +} + export function amplifyVersion(cwd: string, expectedVersion: string, testingWithLatestCodebase = false): Promise { return new Promise((resolve, reject) => { spawn(getCLIPath(testingWithLatestCodebase), ['--version'], { cwd, stripColors: true }) diff --git a/packages/amplify-e2e-core/src/utils/sdk-calls.ts b/packages/amplify-e2e-core/src/utils/sdk-calls.ts index 964d7598287..0590769d289 100644 --- a/packages/amplify-e2e-core/src/utils/sdk-calls.ts +++ b/packages/amplify-e2e-core/src/utils/sdk-calls.ts @@ -310,3 +310,8 @@ export const listAttachedRolePolicies = async (roleName: string, region: string) const service = new IAM({ region }); return (await service.listAttachedRolePolicies({ RoleName: roleName }).promise()).AttachedPolicies; }; + +export const getPermissionsBoundary = async (roleName: string, region) => { + const iamClient = new IAM({ region }); + return (await iamClient.getRole({ RoleName: roleName }).promise())?.Role?.PermissionsBoundary?.PermissionsBoundaryArn; +}; diff --git a/packages/amplify-e2e-tests/src/__tests__/iam-permissions-boundary.test.ts b/packages/amplify-e2e-tests/src/__tests__/iam-permissions-boundary.test.ts new file mode 100644 index 00000000000..59ee3be9e65 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/iam-permissions-boundary.test.ts @@ -0,0 +1,59 @@ +import { + addFunction, + amplifyPushAuth, + createNewProjectDir, + deleteProject, + deleteProjectDir, + getPermissionsBoundary, + getProjectMeta, + getTeamProviderInfo, + initJSProjectWithProfile, +} from 'amplify-e2e-core'; +import _ from 'lodash'; +import { updateEnvironment } from '../environment/env'; + +// Using a random AWS managed policy as a permissions boundary +const permissionsBoundaryArn = 'arn:aws:iam::aws:policy/AlexaForBusinessFullAccess'; + +describe('iam permissions boundary', () => { + let projRoot: string; + beforeEach(async () => { + projRoot = await createNewProjectDir('perm-bound'); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + test('permissions boundary is applied to roles created by the CLI', async () => { + await initJSProjectWithProfile(projRoot, {}); + await updateEnvironment(projRoot, { permissionsBoundaryArn }); + // adding a function isn't strictly part of the test, it just causes the project to have changes to push + await addFunction(projRoot, { functionTemplate: 'Hello World' }, 'nodejs'); + await amplifyPushAuth(projRoot); + const meta = getProjectMeta(projRoot); + const authRoleName = meta?.providers?.awscloudformation?.AuthRoleName; + const region = meta?.providers?.awscloudformation?.Region; + + const actualPermBoundary = await getPermissionsBoundary(authRoleName, region); + expect(actualPermBoundary).toEqual(permissionsBoundaryArn); + + const tpi = getTeamProviderInfo(projRoot); + const storedArn = _.get(tpi, ['integtest', 'awscloudformation', 'PermissionsBoundaryPolicyArn']); + expect(storedArn).toEqual(permissionsBoundaryArn); + }); + + test('permissions boundary is applied during headless init', async () => { + await initJSProjectWithProfile(projRoot, { permissionsBoundaryArn }); + const meta = getProjectMeta(projRoot); + const authRoleName = meta?.providers?.awscloudformation?.AuthRoleName; + const region = meta?.providers?.awscloudformation?.Region; + + const actualPermBoundary = await getPermissionsBoundary(authRoleName, region); + expect(actualPermBoundary).toEqual(permissionsBoundaryArn); + + const tpi = getTeamProviderInfo(projRoot); + const storedArn = _.get(tpi, ['integtest', 'awscloudformation', 'PermissionsBoundaryPolicyArn']); + expect(storedArn).toEqual(permissionsBoundaryArn); + }); +}); diff --git a/packages/amplify-e2e-tests/src/environment/env.ts b/packages/amplify-e2e-tests/src/environment/env.ts index ed3f4ad7cce..e874d6e53d0 100644 --- a/packages/amplify-e2e-tests/src/environment/env.ts +++ b/packages/amplify-e2e-tests/src/environment/env.ts @@ -26,6 +26,27 @@ export function addEnvironment(cwd: string, settings: { envName: string; numLaye }); } +export function updateEnvironment(cwd: string, settings: { permissionsBoundaryArn: string }) { + return new Promise((resolve, reject) => { + spawn(getCLIPath(), ['env', 'update'], { cwd, stripColors: true }) + .wait('Specify an IAM Policy ARN to use as a permissions boundary for all Amplify-generated IAM Roles') + .sendLine(settings.permissionsBoundaryArn) + .run((err: Error) => (!!err ? reject(err) : resolve())); + }); +} + +export function addEnvironmentYes(cwd: string, settings: { envName: string }): Promise { + return new Promise((resolve, reject) => { + spawn(getCLIPath(), ['env', 'add', '--yes', '--envName', settings.envName], { + cwd, + stripColors: true, + env: { + CLI_DEV_INTERNAL_DISABLE_AMPLIFY_APP_CREATION: '1', + }, + }).run((err: Error) => (err ? reject(err) : resolve())); + }); +} + export function addEnvironmentWithImportedAuth(cwd: string, settings: { envName: string; currentEnvName: string }): Promise { return new Promise((resolve, reject) => { spawn(getCLIPath(), ['env', 'add'], { cwd, stripColors: true }) diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/initializer.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/initializer.test.ts index 606349b6408..3d69f1af4ee 100644 --- a/packages/amplify-provider-awscloudformation/src/__tests__/initializer.test.ts +++ b/packages/amplify-provider-awscloudformation/src/__tests__/initializer.test.ts @@ -10,6 +10,7 @@ jest.mock('../aws-utils/aws-cfn'); jest.mock('fs-extra'); jest.mock('../amplify-service-manager'); jest.mock('amplify-cli-core'); +jest.mock('../permissions-boundary/permissions-boundary'); const CloudFormation_mock = CloudFormation as jest.MockedClass; const amplifyServiceManager_mock = amplifyServiceManager as jest.Mocked; diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/permission-boundary/permissions-boundary.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/permission-boundary/permissions-boundary.test.ts new file mode 100644 index 00000000000..df908ea8cd5 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/permission-boundary/permissions-boundary.test.ts @@ -0,0 +1,147 @@ +import { $TSContext } from 'amplify-cli-core'; +import { configurePermissionsBoundaryForInit } from '../../permissions-boundary/permissions-boundary'; +import { setPermissionsBoundaryArn, getPermissionsBoundaryArn, stateManager } from 'amplify-cli-core'; +import { prompt } from 'inquirer'; +import { IAMClient } from '../../aws-utils/aws-iam'; +import { IAM } from 'aws-sdk'; + +const permissionsBoundaryArn = 'arn:aws:iam::123456789012:policy/some-policy-name'; +const argName = 'permissions-boundary'; +const envName = 'newEnvName'; + +jest.mock('amplify-cli-core'); +jest.mock('inquirer'); +jest.mock('../../aws-utils/aws-iam'); + +const setPermissionsBoundaryArn_mock = setPermissionsBoundaryArn as jest.MockedFunction; +const getPermissionsBoundaryArn_mock = getPermissionsBoundaryArn as jest.MockedFunction; +const prompt_mock = prompt as jest.MockedFunction; +const IAMClient_mock = IAMClient as jest.Mocked; +const stateManager_mock = stateManager as jest.Mocked; + +stateManager_mock.getLocalEnvInfo.mockReturnValue({ envName: 'testenv' }); + +describe('configure permissions boundary on init', () => { + let context_stub: $TSContext; + + beforeEach(() => { + context_stub = ({ + amplify: { + inputValidation: () => () => true, + }, + exeInfo: { + isNewProject: true, + isNewEnv: true, + localEnvInfo: { + envName, + }, + }, + input: { + options: {}, + }, + print: { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + }, + } as unknown) as $TSContext; + jest.clearAllMocks(); + }); + it('applies policy specifed in cmd arg when present', async () => { + context_stub.input.options[argName] = permissionsBoundaryArn; + await configurePermissionsBoundaryForInit(context_stub); + expect(setPermissionsBoundaryArn_mock.mock.calls[0][0]).toEqual(permissionsBoundaryArn); + }); + + it('does not prompt for policy', async () => { + await configurePermissionsBoundaryForInit(context_stub); + expect(setPermissionsBoundaryArn_mock.mock.calls[0][0]).toBeUndefined(); + expect(prompt_mock).not.toHaveBeenCalled(); + }); +}); + +describe('configure permissions boundary on env add', () => { + let context_stub: $TSContext; + + beforeEach(() => { + context_stub = ({ + amplify: { + inputValidation: () => () => true, + }, + exeInfo: { + isNewProject: false, + isNewEnv: true, + localEnvInfo: { + envName, + }, + }, + input: { + options: {}, + }, + print: { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + }, + } as unknown) as $TSContext; + jest.clearAllMocks(); + }); + it('applies policy specified in cmd arg when present', async () => { + context_stub.input.options[argName] = permissionsBoundaryArn; + await configurePermissionsBoundaryForInit(context_stub); + expect(setPermissionsBoundaryArn_mock.mock.calls[0][0]).toEqual(permissionsBoundaryArn); + }); + + it('does nothing when no cmd arg specified and no policy in current env', async () => { + await configurePermissionsBoundaryForInit(context_stub); + expect(setPermissionsBoundaryArn_mock).not.toHaveBeenCalled(); + expect(prompt_mock).not.toHaveBeenCalled(); + }); + + it('applies existing policy to new env when existing policy is accessible', async () => { + getPermissionsBoundaryArn_mock.mockReturnValueOnce(permissionsBoundaryArn); + IAMClient_mock.getInstance.mockResolvedValueOnce({ + client: ({ + getPolicy: jest.fn().mockReturnValueOnce({ + promise: jest.fn(), + }), + } as unknown) as IAM, + }); + await configurePermissionsBoundaryForInit(context_stub); + expect(setPermissionsBoundaryArn_mock.mock.calls[0][0]).toEqual(permissionsBoundaryArn); + expect(prompt_mock).not.toHaveBeenCalled(); + }); + + it('prompts for new policy when existing one is not accessible', async () => { + getPermissionsBoundaryArn_mock.mockReturnValueOnce(permissionsBoundaryArn); + IAMClient_mock.getInstance.mockResolvedValueOnce({ + client: ({ + getPolicy: jest.fn().mockReturnValueOnce({ + promise: jest.fn().mockRejectedValueOnce({ statusCode: 404, message: 'test error' }), + }), + } as unknown) as IAM, + }); + const newPermissionsBoundaryArn = 'thisIsANewArn'; + prompt_mock.mockResolvedValueOnce({ + permissionsBoundaryArn: newPermissionsBoundaryArn, + }); + await configurePermissionsBoundaryForInit(context_stub); + expect(setPermissionsBoundaryArn_mock.mock.calls[0][0]).toEqual(newPermissionsBoundaryArn); + }); + + it('fails when existing policy not accessible and --yes specified with no cmd arg', async () => { + context_stub.input.options.yes = true; + getPermissionsBoundaryArn_mock.mockReturnValueOnce(permissionsBoundaryArn); + IAMClient_mock.getInstance.mockResolvedValueOnce({ + client: ({ + getPolicy: jest.fn().mockReturnValueOnce({ + promise: jest.fn().mockRejectedValueOnce({ statusCode: 404, message: 'test error' }), + }), + } as unknown) as IAM, + }); + await expect(configurePermissionsBoundaryForInit(context_stub)).rejects.toMatchInlineSnapshot( + `[Error: A permissions boundary ARN must be specified using --permissions-boundary]`, + ); + expect(prompt_mock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier.test.ts new file mode 100644 index 00000000000..7e5422e3fe8 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier.test.ts @@ -0,0 +1,41 @@ +import { getPermissionsBoundaryArn } from 'amplify-cli-core'; +import Role from 'cloudform-types/types/iam/role'; +import _ from 'lodash'; +import { iamRolePermissionsBoundaryModifier } from '../../../pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier'; + +jest.mock('amplify-cli-core'); + +const getPermissionsBoundaryArn_mock = getPermissionsBoundaryArn as jest.MockedFunction; + +describe('iamRolePermissionsBoundaryModifier', () => { + it('does not overwrite existing permissions boundary', async () => { + const origResource = { + Properties: { + PermissionsBoundary: 'something', + }, + } as Role; + const newResource = await iamRolePermissionsBoundaryModifier(_.cloneDeep(origResource)); + expect(newResource).toEqual(origResource); + }); + + it('does not modify the resource if no policy arn is specified', async () => { + const origResource = { + Type: 'something', + Properties: {}, + } as Role; + getPermissionsBoundaryArn_mock.mockReturnValue(undefined); + const newResource = await iamRolePermissionsBoundaryModifier(_.cloneDeep(origResource)); + expect(newResource).toEqual(origResource); + }); + + it('applies the specified policy arn', async () => { + const origResource = { + Type: 'something', + Properties: {}, + } as Role; + const testPermissionsBoundaryArn = 'testPermissionsBoundaryArn'; + getPermissionsBoundaryArn_mock.mockReturnValue(testPermissionsBoundaryArn); + const newResource = await iamRolePermissionsBoundaryModifier(_.cloneDeep(origResource)); + expect(newResource.Properties.PermissionsBoundary).toEqual(testPermissionsBoundaryArn); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/aws-utils/aws-iam.ts b/packages/amplify-provider-awscloudformation/src/aws-utils/aws-iam.ts new file mode 100644 index 00000000000..17aa50f7166 --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/aws-utils/aws-iam.ts @@ -0,0 +1,29 @@ +import aws from './aws.js'; +import awstype from 'aws-sdk'; +import { IAM } from 'aws-sdk'; +import { AwsSdkConfig } from '../utils/auth-types.js'; +import { getAwsConfig } from '../configuration-manager'; +import { $TSContext } from 'amplify-cli-core'; + +export class IAMClient { + private static instance: IAMClient; + public readonly client: IAM; + + static async getInstance(context: $TSContext, options: IAM.ClientConfiguration = {}): Promise { + if (!IAMClient.instance) { + let cred: AwsSdkConfig; + try { + cred = await getAwsConfig(context); + } catch (e) { + // ignore missing config + } + + IAMClient.instance = new IAMClient(cred, options); + } + return IAMClient.instance; + } + + private constructor(creds: AwsSdkConfig, options: IAM.ClientConfiguration = {}) { + this.client = new (aws as typeof awstype).IAM({ ...creds, ...options }); + } +} diff --git a/packages/amplify-provider-awscloudformation/src/configuration-manager.ts b/packages/amplify-provider-awscloudformation/src/configuration-manager.ts index b204ac534a1..ff356719d5c 100644 --- a/packages/amplify-provider-awscloudformation/src/configuration-manager.ts +++ b/packages/amplify-provider-awscloudformation/src/configuration-manager.ts @@ -741,7 +741,7 @@ function getConfigLevel(context: $TSContext): ProjectType { return configLevel; } -export async function getAwsConfig(context: $TSContext): Promise { +export async function getAwsConfig(context: $TSContext): Promise { const { awsConfigInfo } = context.exeInfo; const httpProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; diff --git a/packages/amplify-provider-awscloudformation/src/index.ts b/packages/amplify-provider-awscloudformation/src/index.ts index 330c66c131a..2e2a26892ac 100644 --- a/packages/amplify-provider-awscloudformation/src/index.ts +++ b/packages/amplify-provider-awscloudformation/src/index.ts @@ -28,6 +28,7 @@ import { loadConfigurationForEnv } from './configuration-manager'; export { resolveAppId } from './utils/resolve-appId'; export { loadConfigurationForEnv } from './configuration-manager'; +import { updateEnv } from './update-env'; function init(context) { return initializer.run(context); @@ -135,4 +136,5 @@ module.exports = { createDynamoDBService, resolveAppId, loadConfigurationForEnv, + updateEnv, }; diff --git a/packages/amplify-provider-awscloudformation/src/initializer.js b/packages/amplify-provider-awscloudformation/src/initializer.ts similarity index 89% rename from packages/amplify-provider-awscloudformation/src/initializer.js rename to packages/amplify-provider-awscloudformation/src/initializer.ts index 44f269c6315..03ddb87225a 100644 --- a/packages/amplify-provider-awscloudformation/src/initializer.js +++ b/packages/amplify-provider-awscloudformation/src/initializer.ts @@ -1,3 +1,6 @@ +import { $TSContext } from 'amplify-cli-core'; +import _ from 'lodash'; + const moment = require('moment'); const path = require('path'); const { pathManager, PathConstants, stateManager, JSONUtilities } = require('amplify-cli-core'); @@ -15,8 +18,9 @@ const amplifyServiceMigrate = require('./amplify-service-migrate'); const { fileLogger } = require('./utils/aws-logger'); const { prePushCfnTemplateModifier } = require('./pre-push-cfn-processor/pre-push-cfn-modifier'); const logger = fileLogger('attach-backend'); +const { configurePermissionsBoundaryForInit } = require('./permissions-boundary/permissions-boundary'); -async function run(context) { +export async function run(context) { await configurationManager.init(context); if (!context.exeInfo || context.exeInfo.isNewEnv) { context.exeInfo = context.exeInfo || {}; @@ -27,6 +31,8 @@ async function run(context) { let stackName = normalizeStackName(`amplify-${projectName}-${envName}-${timeStamp}`); const awsConfig = await configurationManager.getAwsConfig(context); + await configurePermissionsBoundaryForInit(context); + const amplifyServiceParams = { context, awsConfig, @@ -100,6 +106,8 @@ async function run(context) { context.exeInfo.inputParams.amplify.appId ) { await amplifyServiceMigrate.run(context); + } else { + setCloudFormationOutputInContext(context, {}); } } @@ -114,22 +122,25 @@ function processStackCreationData(context, amplifyAppId, stackDescriptiondata) { metadata[constants.AmplifyAppIdLabel] = amplifyAppId; } - context.exeInfo.amplifyMeta = {}; - if (!context.exeInfo.amplifyMeta.providers) { - context.exeInfo.amplifyMeta.providers = {}; - } - context.exeInfo.amplifyMeta.providers[constants.ProviderName] = metadata; - - if (context.exeInfo.isNewEnv) { - const { envName } = context.exeInfo.localEnvInfo; - context.exeInfo.teamProviderInfo[envName] = {}; - context.exeInfo.teamProviderInfo[envName][constants.ProviderName] = metadata; - } + setCloudFormationOutputInContext(context, metadata); } else { throw new Error('No stack data present'); } } +function setCloudFormationOutputInContext(context: $TSContext, cfnOutput: object) { + _.set(context, ['exeInfo', 'amplifyMeta', 'providers', constants.ProviderName], cfnOutput); + const { envName } = context.exeInfo.localEnvInfo; + if (envName) { + const providerInfo = _.get(context, ['exeInfo', 'teamProviderInfo', envName, constants.ProviderName]); + if (providerInfo) { + _.merge(providerInfo, cfnOutput); + } else { + _.set(context, ['exeInfo', 'teamProviderInfo', envName, constants.ProviderName], cfnOutput); + } + } +} + function cloneCLIJSONForNewEnvironment(context) { if (context.exeInfo.isNewEnv && !context.exeInfo.isNewProject) { const { projectPath } = context.exeInfo.localEnvInfo; @@ -150,7 +161,7 @@ function cloneCLIJSONForNewEnvironment(context) { } } -async function onInitSuccessful(context) { +export async function onInitSuccessful(context) { configurationManager.onInitSuccessful(context); if (context.exeInfo.isNewEnv) { context = await storeCurrentCloudBackend(context); @@ -247,8 +258,3 @@ function normalizeStackName(stackName) { } return result; } - -module.exports = { - run, - onInitSuccessful, -}; diff --git a/packages/amplify-provider-awscloudformation/src/permissions-boundary/permissions-boundary.ts b/packages/amplify-provider-awscloudformation/src/permissions-boundary/permissions-boundary.ts new file mode 100644 index 00000000000..267f2620adc --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/permissions-boundary/permissions-boundary.ts @@ -0,0 +1,141 @@ +import { $TSContext, stateManager, getPermissionsBoundaryArn, setPermissionsBoundaryArn } from 'amplify-cli-core'; +import { prompt } from 'inquirer'; +import { IAMClient } from '../aws-utils/aws-iam'; + +export const configurePermissionsBoundaryForExistingEnv = async (context: $TSContext) => { + setPermissionsBoundaryArn(await permissionsBoundarySupplier(context)); + context.print.info( + 'Run `amplify push --force` to update IAM permissions boundary if you have no other resource changes.\nRun `amplify push` to deploy IAM permissions boundary alongside other cloud resource changes.', + ); +}; + +export const configurePermissionsBoundaryForInit = async (context: $TSContext) => { + const envName = context.exeInfo.localEnvInfo.envName; // the new environment name + if (context?.exeInfo?.isNewProject) { + // amplify init + // on init flow, set the permissions boundary if specified in a cmd line arg, but don't prompt for it + setPermissionsBoundaryArn( + await permissionsBoundarySupplier(context, { doPrompt: false, envNameSupplier: () => envName }), + envName, + context.exeInfo.teamProviderInfo, + ); + } else { + // amplify env add + await rolloverPermissionsBoundaryToNewEnvironment(context); + } +}; + +const permissionsBoundarySupplierDefaultOptions = { + required: false, + doPrompt: true, + envNameSupplier: (): string => stateManager.getLocalEnvInfo().envName, +}; + +/** + * Supplies a permissions boundary ARN by first checking headless parameters, then falling back to a CLI prompt + * @param context CLI context object + * @param options Additional options to control the supplier + * @returns string, the permissions boundary ARN or an empty string + */ +const permissionsBoundarySupplier = async ( + context: $TSContext, + options?: Partial, +): Promise => { + const { required, doPrompt, envNameSupplier } = { ...permissionsBoundarySupplierDefaultOptions, ...options }; + const headlessPermissionsBoundary = context?.input?.options?.['permissions-boundary']; + + const validate = context.amplify.inputValidation({ + operator: 'regex', + value: '^(|arn:aws:iam::(\\d{12}|aws):policy/.+)$', + onErrorMsg: 'Specify a valid IAM Policy ARN', + required: true, + }); + + if (typeof headlessPermissionsBoundary === 'string') { + if (validate(headlessPermissionsBoundary)) { + return headlessPermissionsBoundary; + } else { + context.print.error('The permissions boundary ARN specified is not a valid IAM Policy ARN'); + } + } + + const isYes = context?.input?.options?.yes; + if (required && (isYes || !doPrompt)) { + throw new Error('A permissions boundary ARN must be specified using --permissions-boundary'); + } + if (!doPrompt) { + // if we got here, the permissions boundary is not required and we can't prompt so return undefined + return; + } + const envName = envNameSupplier(); + + const defaultValue = getPermissionsBoundaryArn(envName); + const hasDefault = typeof defaultValue === 'string' && defaultValue.length > 0; + const promptSuffix = hasDefault ? ' (leave blank to remove the permissions boundary configuration)' : ''; + + const { permissionsBoundaryArn } = await prompt<{ permissionsBoundaryArn: string }>({ + type: 'input', + name: 'permissionsBoundaryArn', + message: `Specify an IAM Policy ARN to use as a permissions boundary for all Amplify-generated IAM Roles in the ${envName} environment${promptSuffix}:`, + default: defaultValue, + validate, + }); + return permissionsBoundaryArn; +}; + +/** + * This function expects to be called during the env add flow BEFORE the local-env-info file is overwritten with the new env + * (ie when it still contains info on the previous env) + * context.exeInfo.localEnvInfo.envName is expected to have the new env name + */ +const rolloverPermissionsBoundaryToNewEnvironment = async (context: $TSContext) => { + const newEnv = context.exeInfo.localEnvInfo.envName; + const headlessPermissionsBoundary = await permissionsBoundarySupplier(context, { doPrompt: false, envNameSupplier: () => newEnv }); + // if headless policy specified, apply that and return + if (typeof headlessPermissionsBoundary === 'string') { + setPermissionsBoundaryArn(headlessPermissionsBoundary, newEnv, context.exeInfo.teamProviderInfo); + return; + } + + const currBoundary = getPermissionsBoundaryArn(); + // if current env doesn't have a permissions boundary, do nothing + if (!currBoundary) { + return; + } + + const currEnv = stateManager.getLocalEnvInfo()?.envName ?? 'current'; + + // if existing policy is accessible in new env, apply that one + if (await isPolicyAccessible(context, currBoundary)) { + setPermissionsBoundaryArn(currBoundary, newEnv, context.exeInfo.teamProviderInfo); + context.print.info( + `Permissions boundary ${currBoundary} from the ${currEnv} environment has automatically been applied to the ${newEnv} environment.\nTo modify this, run \`amplify env update\`.\n`, + ); + return; + } + // if existing policy policy is not accessible in the new environment, prompt for a new one + context.print.warning( + `Permissions boundary ${currBoundary} from the ${currEnv} environment cannot be applied to resources the ${newEnv} environment.`, + ); + setPermissionsBoundaryArn( + await permissionsBoundarySupplier(context, { required: true, envNameSupplier: () => newEnv }), + newEnv, + context.exeInfo.teamProviderInfo, + ); +}; + +const isPolicyAccessible = async (context: $TSContext, policyArn: string) => { + const iamClient = await IAMClient.getInstance(context); + try { + await iamClient.client.getPolicy({ PolicyArn: policyArn }).promise(); + } catch (err) { + // NoSuchEntity error + if (err?.statusCode === 404) { + return false; + } + // if it's some other error (such as client credentials don't have getPolicy permissions, or network error) + // give customer's the benefit of the doubt that the ARN is correct + return true; + } + return true; +}; diff --git a/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier.ts b/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier.ts new file mode 100644 index 00000000000..ed87ae6d20a --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/modifiers/iam-role-permissions-boundary-modifier.ts @@ -0,0 +1,15 @@ +import { getPermissionsBoundaryArn } from 'amplify-cli-core'; +import Role from 'cloudform-types/types/iam/role'; +import { ResourceModifier } from '../pre-push-cfn-modifier'; + +export const iamRolePermissionsBoundaryModifier: ResourceModifier = async resource => { + if (resource?.Properties?.PermissionsBoundary) { + return resource; // don't modify an existing permissions boundary + } + const policyArn = getPermissionsBoundaryArn(); + if (!policyArn) { + return resource; // exit if no permissions boundary specified + } + resource.Properties.PermissionsBoundary = policyArn; + return resource; +}; diff --git a/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/pre-push-cfn-modifier.ts b/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/pre-push-cfn-modifier.ts index 0ccac723c57..ec5f3f03df9 100644 --- a/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/pre-push-cfn-modifier.ts +++ b/packages/amplify-provider-awscloudformation/src/pre-push-cfn-processor/pre-push-cfn-modifier.ts @@ -2,6 +2,7 @@ import Resource from 'cloudform-types/types/resource'; import _ from 'lodash'; import { applyS3SSEModification } from './modifiers/s3-sse-modifier'; import { Template } from 'cloudform-types'; +import { iamRolePermissionsBoundaryModifier } from './modifiers/iam-role-permissions-boundary-modifier'; // modifies the template in-place export type TemplateModifier = (template: Template) => Promise; @@ -25,6 +26,7 @@ const getResourceModifiers = (type: string): ResourceModifier[] => { const resourceTransformerRegistry: Record[]> = { 'AWS::S3::Bucket': [applyS3SSEModification], + 'AWS::IAM::Role': [iamRolePermissionsBoundaryModifier], }; const identityResourceModifier: ResourceModifier = async resource => resource; diff --git a/packages/amplify-provider-awscloudformation/src/push-resources.ts b/packages/amplify-provider-awscloudformation/src/push-resources.ts index e6cd5eab0f4..15a8e561067 100644 --- a/packages/amplify-provider-awscloudformation/src/push-resources.ts +++ b/packages/amplify-provider-awscloudformation/src/push-resources.ts @@ -196,7 +196,13 @@ export async function run(context: $TSContext, resourceDefinition: $TSObject) { await updateS3Templates(context, resources, projectDetails.amplifyMeta); // We do not need CloudFormation update if only syncable resources are the changes. - if (resourcesToBeCreated.length > 0 || resourcesToBeUpdated.length > 0 || resourcesToBeDeleted.length > 0 || tagsUpdated) { + if ( + resourcesToBeCreated.length > 0 || + resourcesToBeUpdated.length > 0 || + resourcesToBeDeleted.length > 0 || + tagsUpdated || + context.exeInfo.forcePush + ) { // If there is an API change, there will be one deployment step. But when there needs an iterative update the step count is > 1 if (deploymentSteps.length > 1) { // create deployment manager diff --git a/packages/amplify-provider-awscloudformation/src/update-env.ts b/packages/amplify-provider-awscloudformation/src/update-env.ts new file mode 100644 index 00000000000..797f094002a --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/update-env.ts @@ -0,0 +1,6 @@ +import { $TSContext } from 'amplify-cli-core'; +import { configurePermissionsBoundaryForExistingEnv } from './permissions-boundary/permissions-boundary'; + +export const updateEnv = async (context: $TSContext) => { + await configurePermissionsBoundaryForExistingEnv(context); +}; diff --git a/packages/amplify-util-mock/src/func/index.ts b/packages/amplify-util-mock/src/func/index.ts index 567de06936e..408dc20b5fd 100644 --- a/packages/amplify-util-mock/src/func/index.ts +++ b/packages/amplify-util-mock/src/func/index.ts @@ -100,7 +100,7 @@ const resolveEvent = async (context: $TSContext, resourceName: string): Promise< const validatorOutput = eventNameValidator(eventName); const isValid = typeof validatorOutput !== 'string'; if (!isValid) { - context.print.warning(validatorOutput); + context.print.warning(validatorOutput as string); } else { promptForEvent = false; }