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
44 changes: 35 additions & 9 deletions lambda/user-bot/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# user-bot

A Lambda that gives each new IAM user a temporary AWS console password and sends it to them as a Slack DM, with sign-in instructions. Tracked in [#209](https://github.com/hackforla/devops-security/issues/209).

> **Not sending DMs yet.** The deployed function uses `StubMessageSender`, which sends nothing and logs who would have been messaged. `SlackMessageSender` is written and tested, but switching to it needs a Slack app, its bot token, a secret the function can read, and permission to read it. None of these exist yet. Until then, the bot resets the password and the new user does not receive it, which is no worse than before: the password Terraform generates was never sent to anyone either.
A Lambda that gives each new IAM user a temporary AWS console password and sends it to them as a Slack DM, with sign-in instructions. Built in [#209](https://github.com/hackforla/devops-security/issues/209); connected to Slack in [#212](https://github.com/hackforla/devops-security/issues/212).

## When it runs

Expand All @@ -23,29 +21,57 @@ It reads the user's tags and **does nothing at all** (no password change, no mes

When both hold, it:

1. generates a 20-character password containing all four character classes;
2. sets it with `UpdateLoginProfile` and `PasswordResetRequired: true`, so the user must replace it at first sign-in;
3. sends the user a message with the sign-in page, their IAM user name and the temporary password.
1. reads the Slack bot token (see [The Slack token](#the-slack-token)). **If that fails, it stops here and the user's password is not touched;**
2. generates a 20-character password containing all four character classes;
3. sets it with `UpdateLoginProfile` and `PasswordResetRequired: true`, so the user must replace it at first sign-in;
4. DMs the user, as the Slack app, with the sign-in page, their IAM user name and the temporary password.

Step 1 comes before the reset on purpose. Reading the token at send time would mean a bad token leaves the user with a new password that nobody received.

Every skip is logged with its reason and the user name. **The password is never logged**, on any path; the tests assert this.

The `managed-by` check is also enforced by IAM. The execution role may call `UpdateLoginProfile` only on users carrying that tag, so a bug or a hand-crafted invocation still cannot reset anyone else's password, such as an admin or a user tagged `exempt`. If you change the check in `src/handler.ts`, change the policy in `terraform/user-bot.tf` with it.

If sending fails after the password was changed, the function throws. Lambda's asynchronous retry then runs it again from the start, which sets a fresh password and sends again.

## The Slack token

The bot posts as a Slack app with the `chat:write` bot scope, and the app's **Messages tab** is turned on so that users can see the DM thread. Its bot token (`xoxb-…`) is stored in the SSM Parameter Store `SecureString` **`/user-bot/slack-bot-token`**, in us-east-1. The function finds it through its `SLACK_TOKEN_PARAMETER` environment variable.

Terraform (`terraform/user-bot.tf`) creates the parameter with a placeholder through the write-only `value_wo`. The real token is set by hand, so it never appears in git **or in Terraform state**. Read the comment above that resource before changing it: bumping `value_wo_version` writes the placeholder back over the real token.

To set or rotate the token, run this from your own terminal. It reads the token from a prompt, so it stays out of your shell history:

```bash
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=1` only matters in Git Bash on Windows, which would otherwise rewrite `/user-bot/...` into a Windows path.

The function keeps the token for the life of an execution environment, so a rotated token is picked up as environments are recycled. To pick it up at once, redeploy: run **Deploy user-bot Lambda** from the Actions tab.

If the token is missing, unreadable, or still the placeholder (anything not starting `xoxb-`), every user the bot would act on is **refused**:
- the password is left unchanged;
- an error naming the parameter is logged (never its value);
- the invocation fails, so it shows up in the function's error metrics.

A failed read is not cached, so the next new user is tried again.

## Layout

```
src/
index.ts Lambda entry point; wires the handler to the stub sender
index.ts Lambda entry point; wires the handler to SlackMessageSender
handler.ts the logic above
slack-token.ts reads and caches the token from SSM
password.ts password generation
message.ts the message text and sign-in URL
logger.ts JSON-line logger
senders/
types.ts MessageSender interface
slack.ts SlackMessageSender (chat.postMessage)
stub.ts StubMessageSender (logs only)
stub.ts StubMessageSender (logs only; not deployed, kept for local runs and tests)
test/ Vitest unit tests; AWS is mocked with aws-sdk-client-mock, Slack by mocking fetch
```

Expand All @@ -69,7 +95,7 @@ Two halves, each with its own workflow:

| | Managed by | Runs when |
|---|---|---|
| The function's configuration, execution role, log group, EventBridge rule, and the deploy role | `terraform/user-bot.tf` and `terraform/aws-gha-oidc-providers.tf`, via the existing `terraform-plan.yaml` / `terraform-apply.yaml` | a `.tf` file changes |
| The function's configuration, execution role, log group, EventBridge rule, the token parameter (not its value), and the deploy role | `terraform/user-bot.tf` and `terraform/aws-gha-oidc-providers.tf`, via the existing `terraform-plan.yaml` / `terraform-apply.yaml` | a `.tf` file changes |
| The function's **code** | `.github/workflows/user-bot-deploy.yml` | a change under `lambda/user-bot/` merges to `main` |

Pull requests touching `lambda/user-bot/` run `.github/workflows/user-bot-test.yml`, which typechecks, tests and builds with no AWS credentials.
Expand Down
21 changes: 20 additions & 1 deletion lambda/user-bot/package-lock.json

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

3 changes: 2 additions & 1 deletion lambda/user-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-iam": "^3.1146.0"
"@aws-sdk/client-iam": "^3.1146.0",
"@aws-sdk/client-ssm": "^3.1146.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.164",
Expand Down
25 changes: 23 additions & 2 deletions lambda/user-bot/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export type Result =

export interface Dependencies {
iam: IAMClient;
sender: MessageSender;
// Called after the tag checks and before the password is touched. Anything the
// sender needs that can fail -- reading the Slack token, above all -- belongs in
// here, so that a failure leaves the user's password unchanged instead of resetting
// it to something nobody receives.
getSender: () => Promise<MessageSender>;
logger: Logger;
generatePassword?: () => string;
}
Expand All @@ -74,7 +78,7 @@ function errorName(error: unknown): string {
}

export function createHandler(deps: Dependencies) {
const { iam, sender, logger } = deps;
const { iam, getSender, logger } = deps;
const generatePassword = deps.generatePassword ?? defaultGeneratePassword;

function skip(reason: SkipReason, userName?: string): Result {
Expand Down Expand Up @@ -123,6 +127,23 @@ export function createHandler(deps: Dependencies) {
return skip("invalid-slack-id", userName);
}

// Resolved before the password is reset, not when sending. If the Slack token is
// missing, unreadable or still the placeholder, the user keeps their current
// password. Errors from here are logged with their message, so getSender must never
// put a secret in one (SlackTokenError names the parameter, not its value).
let sender: MessageSender;
try {
sender = await getSender();
} catch (error) {
const detail = error instanceof Error ? error.message : undefined;
logger.error("No message sender available; password left unchanged", {
userName,
error: errorName(error),
detail,
});
throw new Error(`No message sender available for ${userName}: ${errorName(error)}`);
}

const password = generatePassword();

try {
Expand Down
16 changes: 10 additions & 6 deletions lambda/user-bot/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { IAMClient } from "@aws-sdk/client-iam";
import { SSMClient } from "@aws-sdk/client-ssm";

import { createHandler } from "./handler";
import { consoleLogger } from "./logger";
import { StubMessageSender } from "./senders/stub";
import { SlackMessageSender } from "./senders/slack";
import { createSlackTokenLoader } from "./slack-token";

// SLACK_TOKEN_PARAMETER names the SSM SecureString holding the Slack bot token; it is
// set by terraform/user-bot.tf. The token is read on the first invocation that needs to
// send, not at import, so a missing token surfaces as a logged refusal for that user
// rather than as a cold-start crash -- and always before their password is touched.
const loadSlackToken = createSlackTokenLoader(new SSMClient({}), process.env.SLACK_TOKEN_PARAMETER);

// Wired to the stub sender on purpose. Switching to SlackMessageSender needs a Slack
// app, its bot token, a secret the function can read, and permission to read it --
// none of which exist yet. See the "Out of scope" section of
// hackforla/devops-security#209.
export const handler = createHandler({
iam: new IAMClient({}),
sender: new StubMessageSender(consoleLogger),
getSender: async () => new SlackMessageSender(await loadSlackToken()),
logger: consoleLogger,
});
6 changes: 3 additions & 3 deletions lambda/user-bot/src/senders/stub.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Logger } from "../logger";
import type { DirectMessage, MessageSender } from "./types";

// What the deployed Lambda uses until it is switched to SlackMessageSender, which
// needs a Slack app and bot token that do not exist yet. It sends nothing and logs
// who would have been messaged, never the message itself.
// Not deployed: index.ts wires the Lambda to SlackMessageSender. Kept for local runs and
// tests, where sending a real DM is unwanted. It sends nothing and logs who would have
// been messaged, never the message itself.
export class StubMessageSender implements MessageSender {
constructor(private readonly logger: Logger) {}

Expand Down
54 changes: 54 additions & 0 deletions lambda/user-bot/src/slack-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { GetParameterCommand, type SSMClient } from "@aws-sdk/client-ssm";

// Slack bot tokens start with this. Terraform creates the parameter holding a
// placeholder, so this is also how a token that was never set is caught.
export const SLACK_BOT_TOKEN_PREFIX = "xoxb-";

// Messages name the parameter and the failure, never the value.
export class SlackTokenError extends Error {
override name = "SlackTokenError";
}

function errorName(error: unknown): string {
return error instanceof Error ? error.name : "UnknownError";
}

// Returns a function that reads the token from SSM Parameter Store once per execution
// environment and reuses it. A failed read is not cached, so the next invocation tries
// again rather than failing forever on a rejected promise; that matters right after the
// token is first set or rotated.
export function createSlackTokenLoader(
ssm: SSMClient,
parameterName: string | undefined,
): () => Promise<string> {
let cached: Promise<string> | undefined;

async function load(): Promise<string> {
if (!parameterName) {
throw new SlackTokenError("SLACK_TOKEN_PARAMETER is not set");
}

let value: string | undefined;
try {
const output = await ssm.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
value = output.Parameter?.Value;
} catch (error) {
throw new SlackTokenError(`could not read ${parameterName}: ${errorName(error)}`);
}

if (!value?.startsWith(SLACK_BOT_TOKEN_PREFIX)) {
throw new SlackTokenError(
`${parameterName} does not hold a Slack bot token; it may still be the placeholder Terraform created`,
);
}
return value;
}

return () => {
cached ??= load().catch((error: unknown) => {
cached = undefined;
throw error;
});
return cached;
};
}
50 changes: 46 additions & 4 deletions lambda/user-bot/test/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ function setup(tags: { Key: string; Value: string }[] = [MANAGED, SLACK]) {

const sent: DirectMessage[] = [];
const sender: MessageSender = { send: vi.fn(async (message) => void sent.push(message)) };
const getSender = vi.fn(async () => sender);
const logger = captureLogger();
const deps: Dependencies = {
iam: new IAMClient({ region: "us-east-1" }),
sender,
getSender,
logger,
generatePassword: () => TEST_PASSWORD,
};
return { handler: createHandler(deps), sender, sent, logger };
return { handler: createHandler(deps), sender, getSender, sent, logger };
}

beforeEach(() => {
Expand Down Expand Up @@ -91,13 +92,15 @@ describe("skips without touching the password or sending anything", () => {
];

it.each(cases)("when %s", async (_name, tags, reason) => {
const { handler, sender, logger } = setup(tags);
const { handler, sender, getSender, logger } = setup(tags);

const result = await handler(createLoginProfileEvent());

expect(result).toEqual({ outcome: "skipped", reason, userName: "new.member" });
expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(0);
expect(sender.send).not.toHaveBeenCalled();
// A skipped user does not even cause the Slack token to be read.
expect(getSender).not.toHaveBeenCalled();
expect(logger.lines).toContainEqual(
expect.objectContaining({ fields: expect.objectContaining({ reason, userName: "new.member" }) }),
);
Expand Down Expand Up @@ -148,6 +151,45 @@ describe("skips without touching the password or sending anything", () => {
});
});

describe("when no sender is available (e.g. the Slack token cannot be read)", () => {
it("throws before touching the password, and sends nothing", async () => {
const { handler, getSender, sender, logger } = setup();
const failure = new Error("/user-bot/slack-bot-token does not hold a Slack bot token");
failure.name = "SlackTokenError";
getSender.mockRejectedValueOnce(failure);

const error = await handler(createLoginProfileEvent()).catch((e: unknown) => e);

expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("SlackTokenError");
expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(0);
expect(sender.send).not.toHaveBeenCalled();
expect(logger.lines).toContainEqual(
expect.objectContaining({
level: "error",
fields: expect.objectContaining({ userName: "new.member", error: "SlackTokenError" }),
}),
);
});

it("resolves the sender only after the tag checks pass, and before the reset", async () => {
const { handler, getSender } = setup();
const order: string[] = [];
getSender.mockImplementationOnce(async () => {
order.push("getSender");
return { send: async () => void order.push("send") };
});
iamMock.on(UpdateLoginProfileCommand).callsFake(() => {
order.push("UpdateLoginProfile");
return {};
});

await handler(createLoginProfileEvent());

expect(order).toEqual(["getSender", "UpdateLoginProfile", "send"]);
});
});

describe("failures", () => {
it("rethrows when ListUserTags fails for another reason, and changes nothing", async () => {
const { handler, sender } = setup();
Expand Down Expand Up @@ -193,7 +235,7 @@ describe("the password never reaches a log", () => {
const generated: string[] = [];
const handler = createHandler({
iam: new IAMClient({ region: "us-east-1" }),
sender: { send: async () => {} },
getSender: async () => ({ send: async () => {} }),
logger,
generatePassword: () => {
const password = generatePassword();
Expand Down
Loading
Loading