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}:*"
+ }
+ ]
+ })
+}