Repository navigation
Wire the user-bot Lambda to Slack: bot token in SSM, real DMs, end-to-end test #212
Description
Overview
We need the user-bot Lambda to actually DM new IAM users their temporary console password, so that new members can sign in without a lead handing the password over by hand. #209 built and deployed the bot and a tested SlackMessageSender, but the deployed function is wired to a stub that sends nothing, because no Slack app or bot token existed yet.
Action Items
How it fits together.
- A Slack app's bot token is stored in an SSM Parameter Store
SecureString,/user-bot/slack-bot-token, in us-east-1 next to the function. - Terraform creates the parameter with a placeholder, and the real token is set by hand, so it never appears in git or in Terraform state.
- At cold start the Lambda reads the token and builds
SlackMessageSender. - If the token is missing, unreadable or still the placeholder, the bot refuses before touching the password: it logs an error and throws, and the user's password is unchanged. There is no silent fallback to the stub.
Delivered as two PRs. The order is less critical than in #209: if PR 2 deploys before the token is set, the bot simply refuses.
Before starting: the Slack app (needs a Slack workspace admin)
- At api.slack.com/apps, choose Create New App → From scratch in the Hack for LA workspace. Name it e.g. "AWS User Bot".
- OAuth & Permissions → Bot Token Scopes: add
chat:writeand nothing else. Posting to a member ID makes Slack open the DM itself, so no other scope is needed. - App Home: turn on the Messages tab. Without it, users who try to reply see "sending messages to this app has been turned off".
- Install to Workspace, then keep the Bot User OAuth Token (
xoxb-…) for the step after PR 1. Do not paste it into this issue, a PR, or Slack.
PR 1: Terraform
-
In
terraform/user-bot.tf, add anaws_ssm_parameterwith:region = local.user_bot_region,name = "/user-bot/slack-bot-token",type = "SecureString"(defaultaws/ssmkey)value_wo = "placeholder-set-by-hand"andvalue_wo_version = 1lifecycle { prevent_destroy = true }
Why
value_woand notvalue: withvalue, the provider reads the decrypted parameter back into Terraform state on every refresh, so the token would sit in the state bucket. With the write-onlyvalue_wo, the provider's read setsvalueto null (resourceParameterReadin provider v6.64.0), and the value is only written whenvalue_wo_versionchanges.Put both of these traps in a comment above the resource:
- Never change
value_wo_version. That rewrites the placeholder over the real token. - Do not change other arguments such as
descriptionin place. The provider then sends an empty value and the apply fails.
-
Execution role (
aws_iam_role_policy.user_bot): addssm:GetParameteron this one parameter's ARN.- No
kms:Decryptstatement should be needed: the AWS-managedaws/ssmkey's key policy allows decryption through SSM for principals in the account. - Confirm that in the end-to-end test below rather than assuming it.
- No
-
Function (
aws_lambda_function.user_bot): addenvironment { variables = { SLACK_TOKEN_PARAMETER = <the parameter's name> } }. The function'signore_changescovers only its code, so Terraform applies this normally. -
Regenerate
terraform/README.mdwith terraform-docs v0.20.0 (terraform-docs -c .terraform.docs.yml .interraform/). Keep the existing| ---- |separator style so the diff shows only the new rows.
After PR 1 merges: set the token
- After PR 1 merges and
Terraform Applysucceeds, store the real token. Keep it out of shell history by reading it from a prompt:read -rs SLACK_TOKEN && MSYS_NO_PATHCONV=1 aws ssm put-parameter --region us-east-1 \ --name /user-bot/slack-bot-token --type SecureString --overwrite --value "$SLACK_TOKEN"; unset SLACK_TOKEN
MSYS_NO_PATHCONV=1is only needed in Git Bash on Windows, which otherwise rewrites/user-bot/...into a Windows path. - Confirm it took without decrypting:
aws ssm get-parameter --region us-east-1 --name /user-bot/slack-bot-token --query Parameter.Versionshould be2. - Confirm Terraform did not notice: a fresh
terraform planshows no change to the parameter.
PR 2: the Lambda
- Add
@aws-sdk/client-ssm. In a newsrc/slack-token.ts, read the parameter named bySLACK_TOKEN_PARAMETERwithWithDecryption: true.- Cache it in module scope for the life of the execution environment, but clear the cache on failure so the next invocation retries instead of reusing a rejected promise.
- Treat these as failures:
- env var unset
- parameter missing
- access denied
- a value that does not start with
xoxb-, which catches the placeholder
- In
src/handler.ts, replacesender: MessageSenderinDependencieswithgetSender: () => Promise<MessageSender>.- Call it after the tag checks and before
generatePassword/UpdateLoginProfile. - If it throws: log an error with the user name (never the token), throw, and leave the password untouched.
- This ordering is the whole point. Resolving the token at send time would mean a reset password that nobody receives.
- Call it after the tag checks and before
- In
src/index.ts, wiregetSenderto build aSlackMessageSenderfrom the token.StubMessageSenderstays in the codebase for local runs and tests but is no longer deployed. Its comment and the README's "Not sending DMs yet" note should say so.
- Unit tests (SSM mocked with
aws-sdk-client-mock). Cover at least:- token read and cached across two invocations: one
GetParametercall - cache cleared after a failure, so the next call retries
- env var unset
ParameterNotFoundAccessDeniedException- placeholder value
- for each failure: the handler throws,
UpdateLoginProfileis never called, nothing is sent, and the token appears in no log or thrown error
- token read and cached across two invocations: one
- Update
lambda/user-bot/README.md: where the token lives, how to rotate it (the sameput-parametercommand), and the refuse-before-reset behaviour.
After both PRs merge: end-to-end test
This replaces #209's last two action items, which were planned against the stub.
- After PR 2 merges, confirm
Deploy user-bot Lambdaran green. - Open a PR adding two throwaway users to
terraform/aws-users.tf, and merge it:user-bot-testwithslack_idset to your own member IDuser-bot-test-noslackwith noslack_id
- Confirm the DM arrived from the app, containing:
- the sign-in page
user-bot-test- a temporary password, which signs in at the sign-in page and immediately forces a password change
- In
/aws/lambda/user-bot(us-east-1), confirm:user-bot-testwas handed to the Slack sender with no errorsuser-bot-test-noslackwas skipped with reasonno-slack-id- the password appears nowhere in the log
- Remove both throwaway users in a follow-up PR, and confirm
Terraform Applydeleted them.
Resources/Instructions
- The bot and its design: Add a user-bot Lambda that DMs new IAM users a temporary console password #209,
lambda/user-bot/README.md,terraform/user-bot.tf - Where the sender plugs in:
lambda/user-bot/src/index.ts,src/handler.ts,src/senders/slack.ts - Terraform
aws_ssm_parameter,value_woand write-only arguments - Slack: creating an app and bot token scopes,
chat.postMessage - Parameter Store
SecureStringand theaws/ssmkey - Related, not duplicates: Define Automations for Slack operations devops#67 (a Slack app for backend monitoring alerts, using incoming webhooks); Slack app for monitoring backend services devops#1 (closed: requirements for a Slack bot platform)
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
ale210 commentedon Oct 4, 2026
End-to-end test done, with a different route than the action items describe. Instead of two throwaway users, the
alexeaccount was deleted (#215) and recreated under the GitHub handleale210, with aslack_id(#216). ItsCreateLoginProfilefired the bot about 4 seconds later:Temporary password set and message handed to the senderforale210kms:Decryptpermission was needed to read theSecureStringThe no-
slack_idskip wasn't run live; it's covered by unit tests, which assert no password change and no message. "Remove both throwaway users" doesn't apply.