diff --git a/.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml b/.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml index 2b51ecb..c3800dd 100644 --- a/.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml +++ b/.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml @@ -11,6 +11,14 @@ body: description: Provide github handle of new user or service account name (if requesting for a service) validations: required: true + - type: input + id: slack-member-id + attributes: + label: Slack Member ID + description: "Your Slack member ID, not your @handle: in Slack, open your profile, click the ⋮ menu, and choose Copy member ID. It looks like U0123456789. We use it to send you your AWS console sign-in details on Slack. Leave blank when requesting a service account." + placeholder: U0123456789 + validations: + required: false - type: input id: project-name attributes: diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index 3f23cc0..f9d0f79 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -1,6 +1,30 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.1" + constraints = "~> 2.8.0" + hashes = [ + "h1:KfIRyazppfpvRKIW5URe4sIVRPPdcbtt4ddkYd1Bw3Y=", + "h1:W+SKtC8w0d/RNYlYc0Pz7IHotwlVXXiccFQ3Vs/Ykm8=", + "h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=", + "h1:eehhIUcuegkswQDKArYBAhVV9wQmRVMhyYGaD7kHIj0=", + "zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec", + "zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058", + "zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59", + "zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4", + "zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35", + "zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6", + "zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad", + "zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9", + "zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831", + "zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249", + "zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477", + ] +} + provider "registry.terraform.io/hashicorp/aws" { version = "6.64.0" constraints = "~> 6.64.0" diff --git a/terraform/README.md b/terraform/README.md index 05a7973..05c7c11 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -24,6 +24,9 @@ Resources created by this code repository. | ---- | ---- | | [aws_cloudtrail.management_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudtrail) | resource | | [aws_cloudtrail.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudtrail) | resource | +| [aws_cloudwatch_event_rule.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_iam_group.ops_leads_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group) | resource | | [aws_iam_group.project_leads](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group) | resource | | [aws_iam_group_policy_attachment.admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group_policy_attachment) | resource | @@ -31,11 +34,17 @@ Resources created by this code repository. | [aws_iam_policy.manage_access_keys](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.incubator_tf_apply](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role.incubator_tf_plan](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.user_bot_deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.user_bot_deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_iam_role_policy_attachment.incubator_tf_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.incubator_tf_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_role_policy_attachment.incubator_tf_plan_secrets_read](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [aws_iam_user.fangyiliu](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource | | [aws_iam_user.jack_pashayan](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource | +| [aws_lambda_function.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.user_bot_eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_s3_bucket.management_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource | | [aws_s3_bucket.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource | | [aws_s3_bucket_ownership_controls.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_ownership_controls) | resource | @@ -45,6 +54,8 @@ Resources created by this code repository. | [aws_s3_bucket_public_access_block.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block) | resource | | [aws_s3_bucket_server_side_encryption_configuration.management_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | | [aws_s3_bucket_server_side_encryption_configuration.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_server_side_encryption_configuration) | resource | +| [archive_file.user_bot_placeholder](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | ## Inputs | Name | Description | Type | Default | Required | @@ -55,12 +66,14 @@ Resources created by this code repository. | Name | Version | | ---- | ------- | +| [archive](#provider\_archive) | 2.8.1 | | [aws](#provider\_aws) | 6.64.0 | ## Requirements | Name | Version | | ---- | ------- | | [terraform](#requirement\_terraform) | ~> 1.16.0 | +| [archive](#requirement\_archive) | ~> 2.8.0 | | [aws](#requirement\_aws) | ~> 6.64.0 | diff --git a/terraform/aws-gha-oidc-providers.tf b/terraform/aws-gha-oidc-providers.tf index 4323869..a27bd08 100644 --- a/terraform/aws-gha-oidc-providers.tf +++ b/terraform/aws-gha-oidc-providers.tf @@ -103,3 +103,52 @@ resource "aws_iam_role_policy_attachment" "incubator_tf_apply_admin" { policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" } +// Assumed by .github/workflows/user-bot-deploy.yml in THIS repo to ship the user-bot +// Lambda's code (see user-bot.tf). Unlike devops-security-tf-plan and -apply above, it +// can be declared here: nothing assumes it until Terraform has already run, so there +// is no bootstrap problem, and it carries the normal managed-by tag rather than exempt. +// +// The sub is pinned to main, so neither a pull request nor a workflow_dispatch from +// another branch can deploy. It can update this one function's code and nothing else; +// the function's configuration, role and trigger stay with Terraform. +resource "aws_iam_role" "user_bot_deploy" { + name = "devops-security-user-bot-deploy" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { + Federated = module.iam_oidc_gha_incubator.provider_arn + } + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + "token.actions.githubusercontent.com:sub" = "repo:hackforla/devops-security:ref:refs/heads/main" + } + } + } + ] + }) +} + +// GetFunction is what `aws lambda wait function-updated-v2` polls. The older +// function-updated waiter polls GetFunctionConfiguration instead, which is not granted. +resource "aws_iam_role_policy" "user_bot_deploy" { + name = "user-bot-deploy" + role = aws_iam_role.user_bot_deploy.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["lambda:UpdateFunctionCode", "lambda:GetFunction"] + Resource = aws_lambda_function.user_bot.arn + } + ] + }) +} + diff --git a/terraform/backend.tf b/terraform/backend.tf index d67e3d8..b02f4fb 100644 --- a/terraform/backend.tf +++ b/terraform/backend.tf @@ -11,6 +11,11 @@ terraform { source = "hashicorp/aws" version = "~> 6.64.0" } + // Packages the user-bot Lambda's placeholder code; see user-bot.tf. + archive = { + source = "hashicorp/archive" + version = "~> 2.8.0" + } } } diff --git a/terraform/modules/aws-users/README.md b/terraform/modules/aws-users/README.md index 0a70869..6c903ee 100644 --- a/terraform/modules/aws-users/README.md +++ b/terraform/modules/aws-users/README.md @@ -16,6 +16,7 @@ This module declares all of the resources necessary to create AWS IAM users. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| +| [slack\_id](#input\_slack\_id) | Slack member ID of the person this user belongs to (Slack profile > Copy member ID). Stored as the user's slack\_id tag, which the user-bot Lambda reads to DM them a temporary console password. | `string` | `null` | no | | [user\_groups](#input\_user\_groups) | List of IAM groups the user should be a member of | `list(string)` | `[]` | no | | [user\_name](#input\_user\_name) | The name of the IAM user | `string` | n/a | yes | | [user\_path](#input\_user\_path) | Path in which to create the user | `string` | `"/"` | no | diff --git a/terraform/modules/aws-users/main.tf b/terraform/modules/aws-users/main.tf index 9b05c2f..5b07941 100644 --- a/terraform/modules/aws-users/main.tf +++ b/terraform/modules/aws-users/main.tf @@ -10,7 +10,9 @@ resource "aws_iam_user" "user" { // attached policy, and the apply fails partway through the batch. force_destroy = true - tags = var.user_tags + // slack_id has to be on the user when CreateUser runs: the user-bot Lambda is + // triggered by the CreateLoginProfile that follows and reads it from here. + tags = merge(var.user_tags, var.slack_id == null ? {} : { slack_id = var.slack_id }) } resource "aws_iam_user_login_profile" "user_login" { diff --git a/terraform/modules/aws-users/variables.tf b/terraform/modules/aws-users/variables.tf index 26a02da..93437b4 100644 --- a/terraform/modules/aws-users/variables.tf +++ b/terraform/modules/aws-users/variables.tf @@ -22,3 +22,17 @@ variable "user_groups" { type = list(string) default = [] } + +// The member ID, not the @handle. Handles are not unique and can be changed at any +// time, and Slack's API needs the ID to send a DM, so a handle here would send the +// user-bot's message nowhere. The validation makes that mistake fail at plan time. +variable "slack_id" { + description = "Slack member ID of the person this user belongs to (Slack profile > Copy member ID). Stored as the user's slack_id tag, which the user-bot Lambda reads to DM them a temporary console password." + type = string + default = null + + validation { + condition = var.slack_id == null ? true : can(regex("^[UW][A-Z0-9]{8,}$", var.slack_id)) + error_message = "slack_id must be a Slack member ID such as U0123456789, not an @handle or display name." + } +} diff --git a/terraform/user-bot.tf b/terraform/user-bot.tf new file mode 100644 index 0000000..66e2da8 --- /dev/null +++ b/terraform/user-bot.tf @@ -0,0 +1,176 @@ +// The user-bot Lambda: when a new IAM user gets a console login profile, it sets a +// temporary password and DMs it to the person on Slack. See +// hackforla/devops-security#209 and lambda/user-bot/README.md. +// +// Terraform owns everything about the function except its code. The real code is +// built and shipped by .github/workflows/user-bot-deploy.yml, which assumes the +// devops-security-user-bot-deploy role declared in aws-gha-oidc-providers.tf. Here the +// function is created from a placeholder, and ignore_changes stops Terraform reverting +// whatever the deploy workflow last pushed. +// +// Everything that is regional lives in us-east-1, not the provider's us-west-2. IAM is +// a global service and its CloudTrail events are delivered to EventBridge only in +// us-east-1, and an EventBridge rule can only target a Lambda in its own region. The +// events come from the management-events trail in cloudtrail.tf, which is multi-region +// and includes global service events. + +data "aws_caller_identity" "current" {} + +locals { + user_bot_region = "us-east-1" +} + +// --------------------------------------------------------------------------- +// Trigger +// --------------------------------------------------------------------------- + +// CreateLoginProfile rather than CreateUser: the aws-users module calls CreateUser a +// moment before CreateLoginProfile, so a Lambda triggered by CreateUser could try to +// update a login profile that does not exist yet. By the time this event fires, the +// user, its tags and its login profile all exist. +// +// Failed calls are recorded by CloudTrail too, with an errorCode. Those are excluded: +// a failed CreateLoginProfile did not create anything to act on. +resource "aws_cloudwatch_event_rule" "user_bot" { + region = local.user_bot_region + + name = "user-bot-create-login-profile" + description = "Invokes the user-bot Lambda when an IAM user is given a console login profile" + + event_pattern = jsonencode({ + source = ["aws.iam"] + detail-type = ["AWS API Call via CloudTrail"] + detail = { + eventSource = ["iam.amazonaws.com"] + eventName = ["CreateLoginProfile"] + errorCode = [{ exists = false }] + } + }) +} + +resource "aws_cloudwatch_event_target" "user_bot" { + region = local.user_bot_region + + rule = aws_cloudwatch_event_rule.user_bot.name + arn = aws_lambda_function.user_bot.arn +} + +resource "aws_lambda_permission" "user_bot_eventbridge" { + region = local.user_bot_region + + statement_id = "AllowInvokeFromCreateLoginProfileRule" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.user_bot.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.user_bot.arn +} + +// --------------------------------------------------------------------------- +// Function +// --------------------------------------------------------------------------- + +// Declared so it carries the default tags and a retention period. Without it, Lambda +// creates the group on first invocation, untagged and never expiring. +resource "aws_cloudwatch_log_group" "user_bot" { + region = local.user_bot_region + + name = "/aws/lambda/user-bot" + retention_in_days = 90 +} + +// Stands in until the deploy workflow ships the real code. It deliberately does +// nothing: an event arriving before the first deploy must not reset anyone's password. +data "archive_file" "user_bot_placeholder" { + type = "zip" + output_path = "${path.module}/.terraform/user-bot-placeholder.zip" + + source { + filename = "index.js" + content = <<-EOT + exports.handler = async () => { + console.log("user-bot placeholder: no code deployed yet, taking no action"); + }; + EOT + } +} + +resource "aws_lambda_function" "user_bot" { + region = local.user_bot_region + + function_name = "user-bot" + description = "DMs new IAM users a temporary console password. Code is deployed by user-bot-deploy.yml in hackforla/devops-security." + role = aws_iam_role.user_bot.arn + + // Must match lambda/user-bot/.nvmrc, which is what CI builds and tests with. + runtime = "nodejs24.x" + handler = "index.handler" + timeout = 30 + memory_size = 128 + + filename = data.archive_file.user_bot_placeholder.output_path + source_code_hash = data.archive_file.user_bot_placeholder.output_base64sha256 + + depends_on = [aws_cloudwatch_log_group.user_bot] + + lifecycle { + ignore_changes = [filename, source_code_hash] + } +} + +// --------------------------------------------------------------------------- +// Execution role +// --------------------------------------------------------------------------- + +resource "aws_iam_role" "user_bot" { + name = "user-bot" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + } + ] + }) +} + +// The tag condition on UpdateLoginProfile is the important part. Without it this role +// could reset the console password of any user in the account, including admins and +// the users tagged managed-by = exempt. The Lambda checks the same tag in code; this +// makes IAM enforce it even if the code is wrong or the function is invoked with a +// crafted event. +resource "aws_iam_role_policy" "user_bot" { + name = "user-bot" + role = aws_iam_role.user_bot.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadUserTags" + Effect = "Allow" + Action = "iam:ListUserTags" + Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/*" + }, + { + Sid = "ResetPasswordOfDevopsSecurityUsersOnly" + Effect = "Allow" + Action = "iam:UpdateLoginProfile" + Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/*" + Condition = { + StringEquals = { + "iam:ResourceTag/managed-by" = "terraform-devops-security" + } + } + }, + { + Sid = "WriteOwnLogs" + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "${aws_cloudwatch_log_group.user_bot.arn}:*" + } + ] + }) +}