Skip to content

Commit 1622ff0

Browse files
committed
Add user-bot Lambda infrastructure and slack_id user tag
1 parent c4d53e0 commit 1622ff0

9 files changed

Lines changed: 293 additions & 1 deletion

File tree

‎.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ body:
1111
description: Provide github handle of new user or service account name (if requesting for a service)
1212
validations:
1313
required: true
14+
- type: input
15+
id: slack-member-id
16+
attributes:
17+
label: Slack Member ID
18+
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."
19+
placeholder: U0123456789
20+
validations:
21+
required: false
1422
- type: input
1523
id: project-name
1624
attributes:

‎terraform/.terraform.lock.hcl‎

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎terraform/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,27 @@ Resources created by this code repository.
2424
| ---- | ---- |
2525
| [aws_cloudtrail.management_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudtrail) | resource |
2626
| [aws_cloudtrail.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudtrail) | resource |
27+
| [aws_cloudwatch_event_rule.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource |
28+
| [aws_cloudwatch_event_target.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource |
29+
| [aws_cloudwatch_log_group.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource |
2730
| [aws_iam_group.ops_leads_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group) | resource |
2831
| [aws_iam_group.project_leads](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group) | resource |
2932
| [aws_iam_group_policy_attachment.admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group_policy_attachment) | resource |
3033
| [aws_iam_group_policy_attachment.manageAccessKeys](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group_policy_attachment) | resource |
3134
| [aws_iam_policy.manage_access_keys](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
3235
| [aws_iam_role.incubator_tf_apply](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
3336
| [aws_iam_role.incubator_tf_plan](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
37+
| [aws_iam_role.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
38+
| [aws_iam_role.user_bot_deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
39+
| [aws_iam_role_policy.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
40+
| [aws_iam_role_policy.user_bot_deploy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
3441
| [aws_iam_role_policy_attachment.incubator_tf_apply_admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
3542
| [aws_iam_role_policy_attachment.incubator_tf_plan_readonly](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
3643
| [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 |
3744
| [aws_iam_user.fangyiliu](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource |
3845
| [aws_iam_user.jack_pashayan](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource |
46+
| [aws_lambda_function.user_bot](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource |
47+
| [aws_lambda_permission.user_bot_eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
3948
| [aws_s3_bucket.management_events](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
4049
| [aws_s3_bucket.tf_backend_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
4150
| [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.
4554
| [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 |
4655
| [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 |
4756
| [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 |
57+
| [archive_file.user_bot_placeholder](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source |
58+
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
4859
## Inputs
4960

5061
| Name | Description | Type | Default | Required |
@@ -55,12 +66,14 @@ Resources created by this code repository.
5566

5667
| Name | Version |
5768
| ---- | ------- |
69+
| <a name="provider_archive"></a> [archive](#provider\_archive) | 2.8.1 |
5870
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.64.0 |
5971
## Requirements
6072

6173
| Name | Version |
6274
| ---- | ------- |
6375
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | ~> 1.16.0 |
76+
| <a name="requirement_archive"></a> [archive](#requirement\_archive) | ~> 2.8.0 |
6477
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 6.64.0 |
6578

6679

‎terraform/aws-gha-oidc-providers.tf‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,52 @@ resource "aws_iam_role_policy_attachment" "incubator_tf_apply_admin" {
103103
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
104104
}
105105

106+
// Assumed by .github/workflows/user-bot-deploy.yml in THIS repo to ship the user-bot
107+
// Lambda's code (see user-bot.tf). Unlike devops-security-tf-plan and -apply above, it
108+
// can be declared here: nothing assumes it until Terraform has already run, so there
109+
// is no bootstrap problem, and it carries the normal managed-by tag rather than exempt.
110+
//
111+
// The sub is pinned to main, so neither a pull request nor a workflow_dispatch from
112+
// another branch can deploy. It can update this one function's code and nothing else;
113+
// the function's configuration, role and trigger stay with Terraform.
114+
resource "aws_iam_role" "user_bot_deploy" {
115+
name = "devops-security-user-bot-deploy"
116+
117+
assume_role_policy = jsonencode({
118+
Version = "2012-10-17"
119+
Statement = [
120+
{
121+
Effect = "Allow"
122+
Action = "sts:AssumeRoleWithWebIdentity"
123+
Principal = {
124+
Federated = module.iam_oidc_gha_incubator.provider_arn
125+
}
126+
Condition = {
127+
StringEquals = {
128+
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
129+
"token.actions.githubusercontent.com:sub" = "repo:hackforla/devops-security:ref:refs/heads/main"
130+
}
131+
}
132+
}
133+
]
134+
})
135+
}
136+
137+
// GetFunction is what `aws lambda wait function-updated-v2` polls. The older
138+
// function-updated waiter polls GetFunctionConfiguration instead, which is not granted.
139+
resource "aws_iam_role_policy" "user_bot_deploy" {
140+
name = "user-bot-deploy"
141+
role = aws_iam_role.user_bot_deploy.id
142+
143+
policy = jsonencode({
144+
Version = "2012-10-17"
145+
Statement = [
146+
{
147+
Effect = "Allow"
148+
Action = ["lambda:UpdateFunctionCode", "lambda:GetFunction"]
149+
Resource = aws_lambda_function.user_bot.arn
150+
}
151+
]
152+
})
153+
}
154+

‎terraform/backend.tf‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ terraform {
1111
source = "hashicorp/aws"
1212
version = "~> 6.64.0"
1313
}
14+
// Packages the user-bot Lambda's placeholder code; see user-bot.tf.
15+
archive = {
16+
source = "hashicorp/archive"
17+
version = "~> 2.8.0"
18+
}
1419
}
1520
}
1621

‎terraform/modules/aws-users/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This module declares all of the resources necessary to create AWS IAM users.
1616

1717
| Name | Description | Type | Default | Required |
1818
|------|-------------|------|---------|:--------:|
19+
| <a name="input_slack_id"></a> [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 |
1920
| <a name="input_user_groups"></a> [user\_groups](#input\_user\_groups) | List of IAM groups the user should be a member of | `list(string)` | `[]` | no |
2021
| <a name="input_user_name"></a> [user\_name](#input\_user\_name) | The name of the IAM user | `string` | n/a | yes |
2122
| <a name="input_user_path"></a> [user\_path](#input\_user\_path) | Path in which to create the user | `string` | `"/"` | no |

‎terraform/modules/aws-users/main.tf‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ resource "aws_iam_user" "user" {
1010
// attached policy, and the apply fails partway through the batch.
1111
force_destroy = true
1212

13-
tags = var.user_tags
13+
// slack_id has to be on the user when CreateUser runs: the user-bot Lambda is
14+
// triggered by the CreateLoginProfile that follows and reads it from here.
15+
tags = merge(var.user_tags, var.slack_id == null ? {} : { slack_id = var.slack_id })
1416
}
1517

1618
resource "aws_iam_user_login_profile" "user_login" {

‎terraform/modules/aws-users/variables.tf‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,17 @@ variable "user_groups" {
2222
type = list(string)
2323
default = []
2424
}
25+
26+
// The member ID, not the @handle. Handles are not unique and can be changed at any
27+
// time, and Slack's API needs the ID to send a DM, so a handle here would send the
28+
// user-bot's message nowhere. The validation makes that mistake fail at plan time.
29+
variable "slack_id" {
30+
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."
31+
type = string
32+
default = null
33+
34+
validation {
35+
condition = var.slack_id == null ? true : can(regex("^[UW][A-Z0-9]{8,}$", var.slack_id))
36+
error_message = "slack_id must be a Slack member ID such as U0123456789, not an @handle or display name."
37+
}
38+
}

‎terraform/user-bot.tf‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// The user-bot Lambda: when a new IAM user gets a console login profile, it sets a
2+
// temporary password and DMs it to the person on Slack. See
3+
// hackforla/devops-security#209 and lambda/user-bot/README.md.
4+
//
5+
// Terraform owns everything about the function except its code. The real code is
6+
// built and shipped by .github/workflows/user-bot-deploy.yml, which assumes the
7+
// devops-security-user-bot-deploy role declared in aws-gha-oidc-providers.tf. Here the
8+
// function is created from a placeholder, and ignore_changes stops Terraform reverting
9+
// whatever the deploy workflow last pushed.
10+
//
11+
// Everything that is regional lives in us-east-1, not the provider's us-west-2. IAM is
12+
// a global service and its CloudTrail events are delivered to EventBridge only in
13+
// us-east-1, and an EventBridge rule can only target a Lambda in its own region. The
14+
// events come from the management-events trail in cloudtrail.tf, which is multi-region
15+
// and includes global service events.
16+
17+
data "aws_caller_identity" "current" {}
18+
19+
locals {
20+
user_bot_region = "us-east-1"
21+
}
22+
23+
// ---------------------------------------------------------------------------
24+
// Trigger
25+
// ---------------------------------------------------------------------------
26+
27+
// CreateLoginProfile rather than CreateUser: the aws-users module calls CreateUser a
28+
// moment before CreateLoginProfile, so a Lambda triggered by CreateUser could try to
29+
// update a login profile that does not exist yet. By the time this event fires, the
30+
// user, its tags and its login profile all exist.
31+
//
32+
// Failed calls are recorded by CloudTrail too, with an errorCode. Those are excluded:
33+
// a failed CreateLoginProfile did not create anything to act on.
34+
resource "aws_cloudwatch_event_rule" "user_bot" {
35+
region = local.user_bot_region
36+
37+
name = "user-bot-create-login-profile"
38+
description = "Invokes the user-bot Lambda when an IAM user is given a console login profile"
39+
40+
event_pattern = jsonencode({
41+
source = ["aws.iam"]
42+
detail-type = ["AWS API Call via CloudTrail"]
43+
detail = {
44+
eventSource = ["iam.amazonaws.com"]
45+
eventName = ["CreateLoginProfile"]
46+
errorCode = [{ exists = false }]
47+
}
48+
})
49+
}
50+
51+
resource "aws_cloudwatch_event_target" "user_bot" {
52+
region = local.user_bot_region
53+
54+
rule = aws_cloudwatch_event_rule.user_bot.name
55+
arn = aws_lambda_function.user_bot.arn
56+
}
57+
58+
resource "aws_lambda_permission" "user_bot_eventbridge" {
59+
region = local.user_bot_region
60+
61+
statement_id = "AllowInvokeFromCreateLoginProfileRule"
62+
action = "lambda:InvokeFunction"
63+
function_name = aws_lambda_function.user_bot.function_name
64+
principal = "events.amazonaws.com"
65+
source_arn = aws_cloudwatch_event_rule.user_bot.arn
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// Function
70+
// ---------------------------------------------------------------------------
71+
72+
// Declared so it carries the default tags and a retention period. Without it, Lambda
73+
// creates the group on first invocation, untagged and never expiring.
74+
resource "aws_cloudwatch_log_group" "user_bot" {
75+
region = local.user_bot_region
76+
77+
name = "/aws/lambda/user-bot"
78+
retention_in_days = 90
79+
}
80+
81+
// Stands in until the deploy workflow ships the real code. It deliberately does
82+
// nothing: an event arriving before the first deploy must not reset anyone's password.
83+
data "archive_file" "user_bot_placeholder" {
84+
type = "zip"
85+
output_path = "${path.module}/.terraform/user-bot-placeholder.zip"
86+
87+
source {
88+
filename = "index.js"
89+
content = <<-EOT
90+
exports.handler = async () => {
91+
console.log("user-bot placeholder: no code deployed yet, taking no action");
92+
};
93+
EOT
94+
}
95+
}
96+
97+
resource "aws_lambda_function" "user_bot" {
98+
region = local.user_bot_region
99+
100+
function_name = "user-bot"
101+
description = "DMs new IAM users a temporary console password. Code is deployed by user-bot-deploy.yml in hackforla/devops-security."
102+
role = aws_iam_role.user_bot.arn
103+
104+
// Must match lambda/user-bot/.nvmrc, which is what CI builds and tests with.
105+
runtime = "nodejs24.x"
106+
handler = "index.handler"
107+
timeout = 30
108+
memory_size = 128
109+
110+
filename = data.archive_file.user_bot_placeholder.output_path
111+
source_code_hash = data.archive_file.user_bot_placeholder.output_base64sha256
112+
113+
depends_on = [aws_cloudwatch_log_group.user_bot]
114+
115+
lifecycle {
116+
ignore_changes = [filename, source_code_hash]
117+
}
118+
}
119+
120+
// ---------------------------------------------------------------------------
121+
// Execution role
122+
// ---------------------------------------------------------------------------
123+
124+
resource "aws_iam_role" "user_bot" {
125+
name = "user-bot"
126+
127+
assume_role_policy = jsonencode({
128+
Version = "2012-10-17"
129+
Statement = [
130+
{
131+
Effect = "Allow"
132+
Principal = { Service = "lambda.amazonaws.com" }
133+
Action = "sts:AssumeRole"
134+
}
135+
]
136+
})
137+
}
138+
139+
// The tag condition on UpdateLoginProfile is the important part. Without it this role
140+
// could reset the console password of any user in the account, including admins and
141+
// the users tagged managed-by = exempt. The Lambda checks the same tag in code; this
142+
// makes IAM enforce it even if the code is wrong or the function is invoked with a
143+
// crafted event.
144+
resource "aws_iam_role_policy" "user_bot" {
145+
name = "user-bot"
146+
role = aws_iam_role.user_bot.id
147+
148+
policy = jsonencode({
149+
Version = "2012-10-17"
150+
Statement = [
151+
{
152+
Sid = "ReadUserTags"
153+
Effect = "Allow"
154+
Action = "iam:ListUserTags"
155+
Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/*"
156+
},
157+
{
158+
Sid = "ResetPasswordOfDevopsSecurityUsersOnly"
159+
Effect = "Allow"
160+
Action = "iam:UpdateLoginProfile"
161+
Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/*"
162+
Condition = {
163+
StringEquals = {
164+
"iam:ResourceTag/managed-by" = "terraform-devops-security"
165+
}
166+
}
167+
},
168+
{
169+
Sid = "WriteOwnLogs"
170+
Effect = "Allow"
171+
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
172+
Resource = "${aws_cloudwatch_log_group.user_bot.arn}:*"
173+
}
174+
]
175+
})
176+
}

0 commit comments

Comments
 (0)