Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/ISSUE_TEMPLATE/request-aws-iam-resources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions terraform/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,27 @@ 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 |
| [aws_iam_group_policy_attachment.manageAccessKeys](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_group_policy_attachment) | resource |
| [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 |
Expand All @@ -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 |
Expand All @@ -55,12 +66,14 @@ Resources created by this code repository.

| Name | Version |
| ---- | ------- |
| <a name="provider_archive"></a> [archive](#provider\_archive) | 2.8.1 |
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.64.0 |
## Requirements

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


Expand Down
49 changes: 49 additions & 0 deletions terraform/aws-gha-oidc-providers.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
})
}

5 changes: 5 additions & 0 deletions terraform/backend.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}

Expand Down
1 change: 1 addition & 0 deletions terraform/modules/aws-users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This module declares all of the resources necessary to create AWS IAM users.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <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 |
| <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 |
| <a name="input_user_name"></a> [user\_name](#input\_user\_name) | The name of the IAM user | `string` | n/a | yes |
| <a name="input_user_path"></a> [user\_path](#input\_user\_path) | Path in which to create the user | `string` | `"/"` | no |
Expand Down
4 changes: 3 additions & 1 deletion terraform/modules/aws-users/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
14 changes: 14 additions & 0 deletions terraform/modules/aws-users/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
176 changes: 176 additions & 0 deletions terraform/user-bot.tf
Original file line number Diff line number Diff line change
@@ -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}:*"
}
]
})
}
Loading