Repository navigation
Add a user-bot Lambda that DMs new IAM users a temporary console password #209
Description
Overview
We need a Lambda, user-bot, that sends each new IAM user a temporary console password and sign-in instructions by Slack DM, so that new members can log in to the incubator AWS account without a lead handing a password over by hand. Today the aws-users module already creates a console login profile for every user, but the password Terraform generates sits only in Terraform state and is never given to anyone.
Action Items
How it works. When Terraform creates a user, the module's aws_iam_user_login_profile produces a CreateLoginProfile call. An EventBridge rule catches that event and invokes the Lambda. The Lambda reads the user's tags and acts only if the user carries both managed-by = terraform-devops-security and a slack_id tag. It then sets a new temporary password with PasswordResetRequired: true and DMs it to that Slack member ID.
Why CreateLoginProfile and not CreateUser: Terraform 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 CreateLoginProfile fires, the user and all its tags exist, because the provider sends tags, including default_tags, with CreateUser.
Message sending goes through an interface with two implementations, both built here:
- a Slack sender that calls Slack's Web API, fully unit-tested;
- a stub sender that only logs that a DM would have been sent.
The deployed Lambda uses the stub. No Slack app or bot token exists yet, so switching the deployed Lambda to the Slack sender is out of scope (see the end of this section).
Deliver this as two PRs, in this order. The deploy workflow updates a function that the first PR's Terraform creates, so if both land in one merge, the deploy runs before the function exists and fails.
PR 1: Terraform and the request form
-
terraform/modules/aws-users/variables.tf: add an optionalslack_idvariable (type = string,default = null).- Add a
validationblock that acceptsnullor a value matching^[UW][A-Z0-9]{8,}$. - The value is a Slack member ID, not a handle. Handles are not unique, people can change them, and Slack's API needs the ID to send a DM. The validation makes a pasted handle fail at
terraform planinstead of silently going nowhere.
- Add a
-
terraform/modules/aws-users/main.tf: inaws_iam_user.user, mergeslack_idinto the tags when it is set:tags = merge(var.user_tags, var.slack_id == null ? {} : { slack_id = var.slack_id }).- The tag key
slack_idis the one already used by hand on earlier accounts (see Create VRMS aws account for Incubator access devops#77 and Create AWS IAM user account for Antonina devops#80), and one live user still carries it. - The
tags =line was line 14 when this was written; line numbers may drift, so find it insideresource "aws_iam_user" "user".
- The tag key
- Regenerate
terraform/modules/aws-users/README.mdwith terraform-docs, using that directory's.terraform.docs.yml, so the new input is documented. - New file
terraform/user-bot.tf. Put every resource inregion = "us-east-1": IAM is a global service, and its CloudTrail events reach EventBridge only in us-east-1. The provider (~> 6.64) supports a per-resourceregionargument, so no provider alias is needed. Declare:-
aws_cloudwatch_event_rulematchingsource = ["aws.iam"],detail.eventSource = ["iam.amazonaws.com"],detail.eventName = ["CreateLoginProfile"].- It depends on the existing
management-eventsCloudTrail trail (terraform/cloudtrail.tf), which is multi-region and includes global service events. Do not change that trail.
- It depends on the existing
-
aws_cloudwatch_event_targetpointing at the Lambda, plus anaws_lambda_permissionletting only this rule's ARN invoke the function. -
aws_cloudwatch_log_groupnamed/aws/lambda/user-bot, with a retention period set. -
An execution role and its policy. Allow only:
iam:ListUserTagson usersiam:UpdateLoginProfileon users, with the conditionStringEquals iam:ResourceTag/managed-by = terraform-devops-securitylogs:CreateLogStreamandlogs:PutLogEventson this function's log group
The tag condition matters. Without it, this role could reset the console password of any user in the account, including admins and the users tagged
managed-by = exempt. It enforces in IAM what the Lambda also checks in code. -
aws_lambda_functionuser-bot, on a Node.js runtime matchinglambda/user-bot/.nvmrcfrom PR 2.- Create it from a placeholder handler packaged with an
archive_filedata source using inlinesource { content = ... }. Terraform never builds or ships the real code; the deploy workflow does. - Add
lifecycle { ignore_changes = [filename, source_code_hash] }, so Terraform does not revert the deployed code andterraform planshows no diff after a deploy. - Runtime, handler, memory, timeout, environment and role all stay managed by Terraform.
- Create it from a placeholder handler packaged with an
-
-
terraform/aws-gha-oidc-providers.tf: add the deploy roledevops-security-user-bot-deploy.- Trust: the existing GitHub OIDC provider (
module.iam_oidc_gha_incubator.provider_arn), withaud = sts.amazonaws.comandsub = repo:hackforla/devops-security:ref:refs/heads/mainonly. - Permissions: only
lambda:UpdateFunctionCodeandlambda:GetFunction, on this one function's ARN. - Unlike
devops-security-tf-plan/-apply, this role can be declared in Terraform, because nothing assumes it until after Terraform has run. So it carries the normalmanaged-bytag, notexempt. Leave the comment at the top of that file about those two roles as it is.
- Trust: the existing GitHub OIDC provider (
-
.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml: add a requiredinputfield "Slack member ID". Its description should explain how to find the ID (Slack profile → ⋮ → Copy member ID) and that it is not the @handle. - Do not modify
.github/workflows/terraform-plan.yamlorterraform-apply.yaml. They trigger on**/*.tf, which already covers everything in this PR, and nothing underlambda/contains a.tffile.
PR 2: The Lambda and its workflows
- Scaffold
lambda/user-bot/: TypeScript,package.jsonwith a committedpackage-lock.json,tsconfig.json,.nvmrcmatching the Lambda runtime in PR 1, and a build step that bundles the handler into a single file for zipping. Use AWS SDK v3 (@aws-sdk/client-iam). - The handler:
- Read
detail.requestParameters.userNamefrom the event. - Call
ListUserTags. - Exit without changing anything unless
managed-byis exactlyterraform-devops-securityandslack_idis present. Log which check failed and for which user name. - Generate a 20-character password with Node's
crypto(notMath.random). The account has no custom password policy, so AWS's default applies; include upper, lower, digit and symbol characters. - Call
UpdateLoginProfilewithPasswordResetRequired: true. - Send the DM through a
MessageSenderinterface. The message contains:- the sign-in URL
https://hfla-incubator.signin.aws.amazon.com/console - the IAM user name
- the temporary password
- a note that they must change the password and set up MFA at first sign-in
- the sign-in URL
- Never log the password, on any path including errors.
- Read
- Write
SlackMessageSender, the real implementation:- It calls
chat.postMessagewithchannelset to the member ID, which posts to the bot's DM with that user (bot scopechat:write). - Use the built-in
fetch, so there is no Slack SDK dependency. The bot token is passed in through the constructor; where it comes from at runtime is part of the out-of-scope wiring. - Check the
okfield of the response, not the HTTP status. Slack returns HTTP 200 with"ok": falseand anerrorstring for failures such asuser_not_found,channel_not_foundorinvalid_auth. A sender that only checks the status reports success when nothing was delivered. - Never include the token or the message body in a thrown error or a log line.
- It calls
- Write
StubMessageSender, which logs the user name and Slack ID but not the message body. It has no Slack code and no token. The deployed handler is wired to this one until the out-of-scope follow-up switches it. - Unit tests with Vitest, mocking AWS with
aws-sdk-client-mockand using a mockMessageSender. Cover at least:- the happy path
managed-bymissingmanaged-byset to another value, e.g.exemptslack_idmissingUpdateLoginProfilefailing, in which case nothing is sent- the send failing
- the generated password meeting the default policy
- the password never appearing in any log output, asserted by capturing the logger
- Unit tests for
SlackMessageSender, mockingfetch(no real Slack calls in tests). Cover at least:- it sends the right URL, auth header,
channeland text "ok": trueresolves- HTTP 200 with
"ok": falserejects - a non-200 status rejects
- a network error rejects
- neither the token nor the password appears in a thrown error
- it sends the right URL, auth header,
- New
.github/workflows/user-bot-test.yml:- Trigger:
pull_requestwithpaths: ['lambda/user-bot/**', '.github/workflows/user-bot-test.yml']. permissions: contents: readand no AWS credentials.- Steps:
actions/checkout@v5,actions/setup-nodewithnode-version-file: lambda/user-bot/.nvmrc,npm ci, typecheck,npm test,npm run build.
- Trigger:
- New
.github/workflows/user-bot-deploy.yml:- Triggers:
pushtomainwithpaths: ['lambda/user-bot/**', '.github/workflows/user-bot-deploy.yml'], plusworkflow_dispatchfor recovery. permissions: id-token: write, contents: read, and aconcurrencygroup so two merges cannot deploy over each other.- Steps:
npm ci, test, build, then zip the bundle. Tests run again on exactly what ships.aws-actions/configure-aws-credentials@v6assumingdevops-security-user-bot-deployinus-east-1.aws lambda update-function-code --zip-file, thenaws lambda wait function-updated.
- Use
checkout@v5andconfigure-aws-credentials@v6, the org's settled targets, even though the existing workflows still use v4.
- Triggers:
-
lambda/user-bot/README.mdcovering:- what the bot does and when it fires
- the two tag conditions
- how to run the tests locally
- how deploys happen (merge to
main, orworkflow_dispatchto redeploy) - that Terraform owns everything about the function except its code
After the PRs merge
- After PR 1 merges and
Terraform Applysucceeds, confirm in us-east-1:- the rule, function, log group and both roles exist
terraform planon a fresh PR shows no diff for them
- After PR 2 merges, confirm
user-bot-deployran green and that the function'sCodeSha256changed from the placeholder:aws lambda get-function --function-name user-bot --region us-east-1. - After both merge, test end to end:
- Open a PR adding a throwaway user, e.g.
user-bot-test, interraform/aws-users.tfwith aslack_id, and merge it. - In
/aws/lambda/user-bot, confirm the run updated the login profile and called the stub sender, and that the password appears nowhere in the log. - Remove the throwaway user in a follow-up PR.
- Open a PR adding a throwaway user, e.g.
- After both merge, confirm a run for a user without
slack_idis logged as skipped and does not change that user's login profile.
Out of scope
- Switching the deployed Lambda to
SlackMessageSender: creating the Slack app, its bot token, a Secrets Manager secret the Lambda reads at runtime, and the execution-role permission to read it. Needs its own ticket; the sender code itself is built here. - Backfilling
slack_idon existing users. The bot only fires for new login profiles. - The unused Terraform-generated password that remains in Terraform state.
Resources/Instructions
- Module to change:
terraform/modules/aws-users/(main.tf,variables.tf,README.md) - Existing CloudTrail trail the rule depends on:
terraform/cloudtrail.tf(management-events) - OIDC roles and the comment explaining why the two Terraform CI roles are not in Terraform:
terraform/aws-gha-oidc-providers.tf - Request form:
.github/ISSUE_TEMPLATE/request-aws-iam-resources.yml - Prior use of the
slack_idtag: Create VRMS aws account for Incubator access devops#77, Create AWS IAM user account for Antonina devops#80 - Related password-policy decision records: Research, Define, Document IAM max password policy Decision Record #59, DR: Password Requirements policy #62, Research, Define, Document IAM max password policy #81
- EventBridge: events from AWS services via CloudTrail (global-service events are delivered in us-east-1)
- IAM:
UpdateLoginProfileand controlling access withiam:ResourceTag - Terraform AWS provider: per-resource
region(v6) - Slack
chat.postMessage(DMing by member ID, theok/errorresponse shape) aws-sdk-client-mock
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
ale210 commentedon Oct 4, 2026
Closing out: both PRs are merged and deployed (#210, #211). Verified after merge:
Terraform Applycreated all 9 resources in us-east-1.Deploy user-bot Lambdashipped the real code; the function'sCodeSha256changed from the placeholder's.terraform planonmainshows no changes.Two action items are deliberately left unticked:
slack_idand logs the reason asno-slack-id.