diff --git a/skeleton/aws/modules/ecs/main.tf b/skeleton/aws/modules/ecs/main.tf index 4d67b6ea..6ae76c8a 100644 --- a/skeleton/aws/modules/ecs/main.tf +++ b/skeleton/aws/modules/ecs/main.tf @@ -34,8 +34,8 @@ locals { Version = "2012-10-17" Statement = [ { - Effect = "Allow" - Action = "sts:AssumeRole" + Effect = "Allow" + Action = "sts:AssumeRole" Principal = { Service = "ecs-tasks.amazonaws.com" } diff --git a/skeleton/aws/modules/ecs/variables.tf b/skeleton/aws/modules/ecs/variables.tf index a170d71e..d7c78a7b 100644 --- a/skeleton/aws/modules/ecs/variables.tf +++ b/skeleton/aws/modules/ecs/variables.tf @@ -117,10 +117,10 @@ variable "autoscaling_target_memory_percentage" { variable "scale_in_cooldown_period" { description = "The minimum time (in seconds) between two scaling-in activities" - default = 300 + default = 300 } variable "scale_out_cooldown_period" { description = "The minimum time (in seconds) between two scaling-out activities" - default = 300 + default = 300 } diff --git a/skeleton/aws/modules/iam_group_membership/main.tf b/skeleton/aws/modules/iam_group_membership/main.tf new file mode 100644 index 00000000..0daab11b --- /dev/null +++ b/skeleton/aws/modules/iam_group_membership/main.tf @@ -0,0 +1,6 @@ +resource "aws_iam_group_membership" "group" { + name = var.name + + group = var.group + users = var.users +} diff --git a/skeleton/aws/modules/iam_group_membership/variables.tf b/skeleton/aws/modules/iam_group_membership/variables.tf new file mode 100644 index 00000000..5d5da290 --- /dev/null +++ b/skeleton/aws/modules/iam_group_membership/variables.tf @@ -0,0 +1,14 @@ +variable "name" { + description = "The name to identify the Group Membership" + type = string +} + +variable "group" { + description = "The IAM Group name to attach the list of users to" + type = string +} + +variable "users" { + description = "A list of IAM User names to associate with the Group" + type = list(string) +} diff --git a/skeleton/aws/modules/iam_groups/data.tf b/skeleton/aws/modules/iam_groups/data.tf new file mode 100644 index 00000000..cb105971 --- /dev/null +++ b/skeleton/aws/modules/iam_groups/data.tf @@ -0,0 +1,144 @@ +locals { + # Comes from https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_my-sec-creds-self-manage.html + # This policy allows users to view and edit their own passwords, access keys, MFA devices, X.509 certificates, SSH keys, and Git credentials. + # In addition, users are required to set up and authenticate using MFA before performing any other operations in AWS. + # It also means this policy does NOT allow users to reset a password while signing in to the AWS Management Console for the first time. + # They must first set up their MFA because allowing users to change their password without MFA can be a security risk. + # + # The following actions are added to the initial policy from AWS + # - iam:GetLoginProfile: allows the IAM user to view their account information on the security page. + # - iam:GetAccessKeyLastUsed: allows the IAM user to view the last time their access key was used. + allow_manage_own_credentials = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowViewAccountInfo" + Effect = "Allow" + Action = [ + "iam:GetAccountPasswordPolicy", + "iam:ListVirtualMFADevices", + "iam:GetLoginProfile" + ] + Resource = "*" + }, + { + Sid = "AllowManageOwnPasswords" + Effect = "Allow" + Action = [ + "iam:ChangePassword", + "iam:GetUser" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnAccessKeys" + Effect = "Allow" + Action = [ + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:ListAccessKeys", + "iam:GetAccessKeyLastUsed", + "iam:UpdateAccessKey" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnSigningCertificates" + Effect = "Allow" + Action = [ + "iam:DeleteSigningCertificate", + "iam:ListSigningCertificates", + "iam:UpdateSigningCertificate", + "iam:UploadSigningCertificate" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnSSHPublicKeys" + Effect = "Allow" + Action = [ + "iam:DeleteSSHPublicKey", + "iam:GetSSHPublicKey", + "iam:ListSSHPublicKeys", + "iam:UpdateSSHPublicKey", + "iam:UploadSSHPublicKey" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnGitCredentials" + Effect = "Allow" + Action = [ + "iam:CreateServiceSpecificCredential", + "iam:DeleteServiceSpecificCredential", + "iam:ListServiceSpecificCredentials", + "iam:ResetServiceSpecificCredential", + "iam:UpdateServiceSpecificCredential" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnVirtualMFADevice" + Effect = "Allow" + Action = [ + "iam:CreateVirtualMFADevice" + ] + Resource = "arn:aws:iam::*:mfa/*" + }, + { + Sid = "AllowManageOwnUserMFA" + Effect = "Allow" + Action = [ + "iam:DeactivateMFADevice", + "iam:EnableMFADevice", + "iam:ListMFADevices", + "iam:ResyncMFADevice" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "DenyAllExceptListedIfNoMFA" + Effect = "Deny" + NotAction = [ + "iam:CreateVirtualMFADevice", + "iam:ChangePassword", + "iam:EnableMFADevice", + "iam:GetAccountPasswordPolicy", + "iam:GetUser", + "iam:ListMFADevices", + "iam:ListVirtualMFADevices", + "iam:ResyncMFADevice", + "sts:GetSessionToken" + ] + Resource = "*" + Condition = { + BoolIfExists = { + "aws:MultiFactorAuthPresent" = "false" + } + } + } + ] + }) + + # For the bot account + # It must be able to manage policies during terraform apply & create/delete users, permissions, etc. during terraform apply + full_iam_access_policy = jsonencode({ + version = "2012-10-17" + statement = [ + { + sid = "AllowManageRoleAndPolicy" + effect = "Allow" + resources = ["arn:aws:iam::*"] + actions = ["iam:*"] + } + ] + }) +} + +data "aws_iam_policy" "admin_access" { + arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + +data "aws_iam_policy" "power_user_access" { + arn = "arn:aws:iam::aws:policy/PowerUserAccess" +} diff --git a/skeleton/aws/modules/iam_groups/main.tf b/skeleton/aws/modules/iam_groups/main.tf new file mode 100644 index 00000000..1196c71b --- /dev/null +++ b/skeleton/aws/modules/iam_groups/main.tf @@ -0,0 +1,39 @@ +#tfsec:ignore:aws-iam-enforce-group-mfa +resource "aws_iam_group" "admin" { + name = "Admin-group" +} + +#tfsec:ignore:aws-iam-enforce-group-mfa +resource "aws_iam_group" "bot" { + name = "Bot-group" +} + +#tfsec:ignore:aws-iam-enforce-group-mfa +resource "aws_iam_group" "developer" { + name = "Developer-group" +} + +resource "aws_iam_group_policy_attachment" "admin_access" { + group = aws_iam_group.admin.name + policy_arn = data.aws_iam_policy.admin_access.arn +} + +resource "aws_iam_group_policy" "developer_allow_manage_own_credentials" { + group = aws_iam_group.developer.name + policy = local.allow_manage_own_credentials +} + +resource "aws_iam_group_policy_attachment" "developer_power_user_access" { + group = aws_iam_group.developer.name + policy_arn = data.aws_iam_policy.power_user_access.arn +} + +resource "aws_iam_group_policy_attachment" "bot_power_user_access" { + group = aws_iam_group.bot.name + policy_arn = data.aws_iam_policy.power_user_access.arn +} + +resource "aws_iam_group_policy" "bot_full_iam_access" { + group = aws_iam_group.bot.name + policy = local.full_iam_access_policy +} diff --git a/skeleton/aws/modules/iam_groups/outputs.tf b/skeleton/aws/modules/iam_groups/outputs.tf new file mode 100644 index 00000000..95166ff5 --- /dev/null +++ b/skeleton/aws/modules/iam_groups/outputs.tf @@ -0,0 +1,14 @@ +output "admin_group" { + description = "IAM Group with admin permissions" + value = aws_iam_group.admin.name +} + +output "developer_group" { + description = "IAM Group with developer permissions" + value = aws_iam_group.developer.name +} + +output "bot_group" { + description = "IAM Group with bot permissions" + value = aws_iam_group.bot.name +} diff --git a/skeleton/aws/modules/iam_users/main.tf b/skeleton/aws/modules/iam_users/main.tf new file mode 100644 index 00000000..2d5c1838 --- /dev/null +++ b/skeleton/aws/modules/iam_users/main.tf @@ -0,0 +1,24 @@ +locals { + user_accounts = var.has_login ? aws_iam_user.user_account : {} +} + +resource "aws_iam_user" "user_account" { + for_each = toset(var.usernames) + + name = each.value + path = var.path + + force_destroy = true +} + +resource "aws_iam_user_login_profile" "user_account" { + for_each = local.user_accounts + + user = each.value.name + + lifecycle { + ignore_changes = [ + password_reset_required, + ] + } +} diff --git a/skeleton/aws/modules/iam_users/outputs.tf b/skeleton/aws/modules/iam_users/outputs.tf new file mode 100644 index 00000000..6a4fbede --- /dev/null +++ b/skeleton/aws/modules/iam_users/outputs.tf @@ -0,0 +1,3 @@ +output "temporary_passwords" { + value = { for username, login_profile in aws_iam_user_login_profile.user_account : username => login_profile.password } +} diff --git a/skeleton/aws/modules/iam_users/variables.tf b/skeleton/aws/modules/iam_users/variables.tf new file mode 100644 index 00000000..54a57514 --- /dev/null +++ b/skeleton/aws/modules/iam_users/variables.tf @@ -0,0 +1,14 @@ +variable "usernames" { + description = "Desired names for the IAM user" + type = list(string) +} + +variable "path" { + description = "Desired path for the IAM user" + default = "/" +} + +variable "has_login" { + description = "Boolean for whether login is enabled" + default = true +} diff --git a/src/templates/aws/addons/core/iamUserAndGroup.test.ts b/src/templates/aws/addons/core/iamUserAndGroup.test.ts new file mode 100644 index 00000000..192c2e5e --- /dev/null +++ b/src/templates/aws/addons/core/iamUserAndGroup.test.ts @@ -0,0 +1,92 @@ +import { AwsOptions } from '../..'; +import { remove } from '../../../../helpers/file'; +import { applyCore } from '../../../core'; +import applyCommon from './common'; +import applyIamUserAndGroup, { + iamVariablesContent, + iamGroupsModuleContent, + iamUsersModuleContent, + iamGroupMembershipModuleContent, + iamOutputsContent, +} from './iamUserAndGroup'; + +describe('IAM add-on', () => { + describe('given valid AWS options', () => { + const projectDir = 'iam-addon-test'; + + beforeAll(() => { + const awsOptions: AwsOptions = { + projectName: projectDir, + provider: 'aws', + infrastructureType: 'advanced', + awsRegion: 'ap-southeast-1', + }; + + applyCore(awsOptions); + applyCommon(awsOptions); + applyIamUserAndGroup(awsOptions); + }); + + afterAll(() => { + jest.clearAllMocks(); + remove('/', projectDir); + }); + + it('creates expected files', () => { + const expectedFiles = [ + 'shared/main.tf', + 'shared/providers.tf', + 'shared/outputs.tf', + 'shared/variables.tf', + + 'modules/iam_groups/data.tf', + 'modules/iam_groups/main.tf', + 'modules/iam_groups/outputs.tf', + + 'modules/iam_users/main.tf', + 'modules/iam_users/variables.tf', + 'modules/iam_users/outputs.tf', + + 'modules/iam_group_membership/main.tf', + 'modules/iam_group_membership/variables.tf', + ]; + + expect(projectDir).toHaveFiles(expectedFiles); + }); + + it('adds IAM groups module to main.tf', () => { + expect(projectDir).toHaveContentInFile( + 'shared/main.tf', + iamGroupsModuleContent + ); + }); + + it('adds IAM users module to main.tf', () => { + expect(projectDir).toHaveContentInFile( + 'shared/main.tf', + iamUsersModuleContent + ); + }); + + it('adds IAM group membership module to main.tf', () => { + expect(projectDir).toHaveContentInFile( + 'shared/main.tf', + iamGroupMembershipModuleContent + ); + }); + + it('adds IAM variables to variables.tf', () => { + expect(projectDir).toHaveContentInFile( + 'shared/variables.tf', + iamVariablesContent + ); + }); + + it('adds IAM outputs to outputs.tf', () => { + expect(projectDir).toHaveContentInFile( + 'shared/outputs.tf', + iamOutputsContent + ); + }); + }); +}); diff --git a/src/templates/aws/addons/core/iamUserAndGroup.ts b/src/templates/aws/addons/core/iamUserAndGroup.ts new file mode 100644 index 00000000..1391a015 --- /dev/null +++ b/src/templates/aws/addons/core/iamUserAndGroup.ts @@ -0,0 +1,122 @@ +import { dedent } from 'ts-dedent'; + +import { AwsOptions } from '../..'; +import { appendToFile, copy } from '../../../../helpers/file'; +import { + INFRA_SHARED_MAIN_PATH, + INFRA_SHARED_VARIABLES_PATH, + INFRA_SHARED_OUTPUTS_PATH, +} from '../../../core/constants'; + +const iamVariablesContent = dedent` + variable "iam_admin_emails" { + description = "List of admin emails to provision IAM user account" + type = list(string) + } + + variable "iam_bot_emails" { + description = "List of bot emails to provision IAM user account" + type = list(string) + } + + variable "iam_developer_emails" { + description = "List of developer emails to provision IAM user account" + type = list(string) + }`; + +const iamGroupsModuleContent = dedent` + module "iam_groups" { + source = "../modules/iam_groups" + }`; + +const iamUsersModuleContent = dedent` + module "iam_admin_users" { + source = "../modules/iam_users" + + usernames = var.iam_admin_emails + } + + module "iam_developer_users" { + source = "../modules/iam_users" + + usernames = var.iam_developer_emails + } + + module "iam_bot_users" { + source = "../modules/iam_users" + + usernames = var.iam_bot_emails + }`; + +const iamGroupMembershipModuleContent = dedent` + module "iam_admin_group_membership" { + source = "../modules/iam_group_membership" + + name = "admin-group-membership" + group = module.iam_groups.admin_group + users = var.iam_admin_emails + } + + module "iam_bot_group_membership" { + source = "../modules/iam_group_membership" + + name = "bot-group-membership" + group = module.iam_groups.bot_group + users = var.iam_bot_emails + } + + module "iam_developer_group_membership" { + source = "../modules/iam_group_membership" + + name = "developer-group-membership" + group = module.iam_groups.developer_group + users = var.iam_developer_emails + }`; + +const iamOutputsContent = dedent` + output "iam_admin_temporary_passwords" { + description = "List of first time passwords for admin accounts. Must be changed at first time login and will no longer be valid." + value = module.iam_admin_users.temporary_passwords + } + + output "iam_developer_temporary_passwords" { + description = "List of first time passwords for developer accounts. Must be changed at first time login and will no longer be valid." + value = module.iam_developer_users.temporary_passwords + } + + output "iam_bot_temporary_passwords" { + description = "List of first time passwords for bot accounts. Must be changed at first time login and will no longer be valid." + value = module.iam_bot_users.temporary_passwords + }`; + +const applyIamUserAndGroup = ({ projectName }: AwsOptions) => { + copy('aws/modules/iam_groups', 'modules/iam_groups', projectName); + copy('aws/modules/iam_users', 'modules/iam_users', projectName); + + copy( + 'aws/modules/iam_group_membership', + 'modules/iam_group_membership', + projectName + ); + + appendToFile(INFRA_SHARED_MAIN_PATH, iamGroupsModuleContent, projectName); + appendToFile(INFRA_SHARED_MAIN_PATH, iamUsersModuleContent, projectName); + + appendToFile( + INFRA_SHARED_MAIN_PATH, + iamGroupMembershipModuleContent, + projectName + ); + + appendToFile(INFRA_SHARED_VARIABLES_PATH, iamVariablesContent, projectName); + appendToFile(INFRA_SHARED_OUTPUTS_PATH, iamOutputsContent, projectName); +}; + +export default applyIamUserAndGroup; +export { + iamVariablesContent, + iamGroupsModuleContent, + iamUsersModuleContent, + iamGroupMembershipModuleContent, + iamOutputsContent, +}; diff --git a/src/templates/aws/addons/index.ts b/src/templates/aws/addons/index.ts index 0d3cdfeb..220dfd1f 100644 --- a/src/templates/aws/addons/index.ts +++ b/src/templates/aws/addons/index.ts @@ -2,6 +2,7 @@ import applyAlb from './alb'; import applyBastion from './bastion'; import applyCloudwatch from './cloudwatch'; import applyCommon from './core/common'; +import applyIamUserAndGroup from './core/iamUserAndGroup'; import applyRegion from './core/region'; import applySecurityGroup from './core/securityGroup'; import applyVpc from './core/vpc'; @@ -15,9 +16,10 @@ export { applyAlb, applyBastion, applyCommon, + applyCloudwatch, applyEcr, applyEcs, - applyCloudwatch, + applyIamUserAndGroup, applyRds, applyRegion, applyS3, diff --git a/src/templates/aws/index.ts b/src/templates/aws/index.ts index 77283ca9..18550afd 100644 --- a/src/templates/aws/index.ts +++ b/src/templates/aws/index.ts @@ -3,6 +3,7 @@ import { prompt } from 'inquirer'; import { GeneralOptions } from '../../commands/generate'; import { applyCommon, + applyIamUserAndGroup, applyRegion, applySecurityGroup, applyVpc, @@ -55,6 +56,7 @@ const generateAwsTemplate = async ( applyCommonModules(awsOptions); applyVpc(awsOptions); applySecurityGroup(awsOptions); + applyIamUserAndGroup(awsOptions); applyAdvancedTemplate(awsOptions); break;