はじめに
こんにちは。組織でのAWSアカウントの払い出しを楽にしたいすずきです。
AWSアカウントを「作る人」=「使う人」=「自分」となる個人検証では、払い出しで困ることはあまりありません。ただ組織で考えると、作るのは「情シス」、使うのは「申請した人」などと別々になることが多いと思います。
例えば申請者がSlackでアカウントの払い出し依頼を出したとき、情シス(作成者)は申請内容を確認(Slack)→マネジメントコンソールログイン後、Organizationsでアカウントを作成してOUに移動(AWS)→IAM Identity Centerでユーザーに割り当て(AWS)→払い出し完了後に申請者へ返信(Slack)と、SlackとAWSを行き来することになり作業負荷がかかります。
そこで「承認する人は、わざわざAWSのコンソールに入らなくても、Slackに届いた申請の内容を見て許可を出すだけで、アカウントが自動で作られたら楽になるのでは?」と思い仕組みを考えてみました。
本記事では、アカウント申請からログイン案内までをSlack上で完結できるシステムを紹介します。
本記事の情報は2026年10月時点のものです。
最新情報については公式ドキュメントをご確認ください。
この記事でわかること
- Slack(無料プラン)だけで、申請から承認、アカウント作成、ログイン案内までを完結させる構成
- 公開リポジトリ(CDK)のデプロイ手順
- 作ってみてつまずいたとこ
本記事について
2026年9月26日開催のJAWS-UG栃木「オフライン #10 -秋の登壇祭り-」で「1人アドミンな私は、AWSアカウント申請をSlackで完結したい!」というタイトルで登壇させていただいた内容を含みます。よろしければ以下資料も読んでいただけると幸いです。
- 登壇資料
- イベントの様子
また、AWS Organizationsを利用した一人アドミン検証シリーズの1本でもあります。シリーズの一覧はまとめ記事に載せています。
1. 動作環境
公開リポジトリで固定しているバージョンと、手元の環境です。
| 項目 | 内容 |
|---|---|
| リージョン | ap-northeast-1(東京) |
| AWS CDK | TypeScript。aws-cdk 2.1132.0 / aws-cdk-lib 2.270.0(package-lock.jsonの値) |
| Lambda | Python 3.14 / arm64 |
| Slackのライブラリ | slack_bolt 1.30.0 / slack_sdk 3.44.1 |
| Step Functions | Standard / JSONata |
| Slack | 無料プラン(Slack APIのみ。ワークフロービルダーは使わない) |
| 手元の環境 | macOS / Node.js 22.20.0 / Python 3.13.2 / AWS CLI 2.35.15(要検証: デプロイ時のバージョンと同じか) |
2. 前提条件
AWS側
- デプロイ先はAWS Organizationsの管理アカウント
- IAM Identity Centerが有効で、割り当てに使う許可セットが1つ以上あること
- 組織のアカウント数の上限(初期値10)に空きがあること
- 空きがなくてもドライランなら試せます(9. 後片付け)
管理アカウントに置くのは、CreateAccountが管理アカウントからしか呼べないためです(参考)。これは一人アドミンの検証だからできる置き方で、本番なら別のアカウントから管理アカウントの最小権限のロールをAssumeRoleする形を検討してください。
Identity Centerは、シリーズのIAM Identity Center編で東京リージョンに作ったものです。受付Lambdaは自分と同じリージョンでIdentity Centerを探すので、デプロイ先はIdentity Centerと同じリージョンにしてください。Control Towerは使っていません。
Slack側
ワークスペースと、承認依頼を投稿するチャンネル(本記事では「情シスch」)を用意します。
Slack App(Slack API)は無料プランで利用できますが、追加できるアプリは10個までなので、上限には注意が必要です。(参考)。
手元の環境
Node.js、Python 3(pip3。Lambdaの依存を入れるだけなので3.14でなくてよい)、管理アカウントの認証情報を設定したAWS CLIを使います(1. 動作環境)。
3. 完成イメージ
申請者と情シスから見える画面を、流れの順に載せます。
申請フォーム
Slackで/aws-accountを実行すると、申請フォーム(モーダル)が開きます。
入力するのはアカウント名、ルートユーザーのメールアドレス、OU、許可セット、ログインに使うユーザー(自分のSlackのメールアドレスか、別に入力したメールアドレス)、アカウントの用途です。OUと許可セットの選択肢は、フォームを開くたびにOrganizationsとIdentity Centerから取ってきます。
「ドライラン」にチェックを入れると、アカウントは作らずに承認から通知までの流れだけを確認できます。
承認依頼
「申請する」を押すと、情シスchに承認依頼が届きます。
「承認」を押すとアカウントの作成が始まり、「却下」なら申請者にDMで伝わります。受け付けるのは登録した承認者が押したときだけです。
ログイン案内のDM
申請の受付と承認も申請者のDMに届き、作成が終わるとログイン案内のカードが届きます。カードには申請内容の控えと、新しいアカウントへの入り方を載せています。
画像はドライランのときの表示なので、アカウントIDが「(作成後に決まります)」になっています。
申請者から見ると、Slackから一歩も出ずに「申請して、待って、ボタンからログインする」で終わります!
4. システム構成
すべてOrganizationsの管理アカウント(東京リージョン)に置いています。
Slackからのリクエストは、すべて受付Lambdaの関数URLで受けます。
Slackからの入口はRequest URLの1本だけで、パスやメソッドごとに処理を振り分けるルーティングが要らないので、関数URLで足りると判断しました。リクエストが本当にSlackから来たかは、Boltが署名シークレットで検証しています。WAFでアクセスを絞ったり独自ドメインにしたい場合は、前にAPI Gatewayを置く必要があります。
申請したとき
受付Lambdaが送信を受け取り(①)、DynamoDBに申請をREQUESTEDで1行書いて(②)、情シスchに承認依頼を投稿します。AWSのリソースはまだ作りません。
承認ボタンを押したとき
受付Lambdaは、押した人が承認者かを確かめ(①)、状態をAPPROVEDに更新してから(②)、DynamoDBの申請内容を入力にStep Functionsを起動します(③)。
Step Functionsの流れ
-
CreateAccountでアカウントを作り、完了までポーリングする(④) - 選んだOUへ
MoveAccountで移す - Identity Centerでメールアドレスからユーザーを探し、いなければ作って、許可セットを割り当てる(⑤)
- 途中の状態をDynamoDBに書く(⑥)
- 通知Lambdaが承認依頼のスレッドに完了を返し、申請者にログイン案内をDMする(⑦)
失敗したときは、エラーの詳細を情シスchのスレッドにだけ出し、申請者には「情シスが確認して連絡します」とだけ送ります。
Organizationsはus-east-1のサービスですが、東京のステートマシンから呼べることを実機で確認しました(公式の明記は見つけられませんでした)。
設計で決めたこと
| 決めたこと | 採用 | 採らなかった案 | 理由 |
|---|---|---|---|
| AWSを動かすタイミング | 承認ボタンで起動 | 申請時に起動し、タスクトークンで承認を待つ | 放置された申請の実行が残り続ける |
| ワークフローの書き方 | AWS SDK統合(JSONata) | Lambdaで書く | APIを順番に呼ぶだけ |
| 監査ログの置き場所 | DynamoDB | Slackの履歴、Step Functionsの実行履歴 | どちらも90日で見られなくなる |
最初はタスクトークン案で考えていましたが、申請の記録はDynamoDBに1行書けば足りるので、承認されてから起動する形に変えました。
90日はSlack無料プランの閲覧期限と、Step Functionsの実行履歴の保持期間です(参考)。DynamoDBのテーブルはスタックを消しても残る設定(RETAIN)にしています。
5. デプロイ手順
コードと詳しい手順は公開リポジトリのREADMEにあります。ここではコマンドと要点だけ載せます。
(※準備中です!)
5-1. CDKでデプロイする
管理アカウントの認証情報で実行します(リージョンはap-northeast-1固定)。
$ git clone https://github.com/<TODO>/aws-account-vending-slack.git
$ cd aws-account-vending-slack/cdk
$ npm install
# このアカウント・リージョンでCDKを使うのが初めてなら、先にbootstrapする
$ npx cdk bootstrap
$ npx cdk deploy
依存ライブラリはpip3でバンドルするので、Dockerは要りません。
IAMの差分を見ると、受付Lambdaにlambda:InvokeFunctionがPrincipal *で付いていて一瞬ぎょっとしました。これは2025年10月以降の関数URLに要る2つ目の許可で、CDKが関数URL経由の呼び出しに絞る条件を付けて足しています(参考)。
5-2. マニフェストからSlackアプリを作る
リポジトリのslack-app-manifest.yamlのREQUEST_URLを、出力のSlackRequestUrlに置き換えます。
# リポジトリのルートで実行する
$ URL=$(aws cloudformation describe-stacks --region ap-northeast-1 --stack-name AwsAccountVendingSlack \
--query "Stacks[0].Outputs[?OutputKey=='SlackRequestUrl'].OutputValue" --output text)
$ grep -v '^#' slack-app-manifest.yaml | sed "s#REQUEST_URL#$URL#g" > slack-app-manifest.generated.yaml
Slack APIの「Create New App」→「From a manifest」で中身を貼り付け、「Create and Install」をクリックします。Events APIは使わないので、「No events yet.」と出ても正常です。
情シスchにはボットを招待しておきます(/invite @aws-account-bot)。
5-3. Slackの設定をSSMパラメータストアに入れる
ボットトークン、署名シークレット、情シスchのID、承認者のユーザーIDを、SecureStringにJSONで入れます(値の取り方はREADME)。
$ aws ssm put-parameter --region ap-northeast-1 \
--name /account-vending/slack --type SecureString \
--value '{"botToken":"xoxb-...","signingSecret":"...","adminChannelId":"C...","approverUserIds":["U..."]}'
ここだけCLIなのは、CloudFormationがSecureStringを作れないためです(参考)。Secrets Managerと違い、SSMの標準パラメータは追加料金がかかりません。
最初はプロジェクト名どおり/aws-account-vending/slackで入れようとして、次のエラーになりました。
AccessDeniedException: No access to reserved parameter name: aws-account-vending/slack
パラメータ名はawsかssmで始められません(参考)。cdk deployは通ってしまうので、ここで初めて気づきました(AWS向けのツールだと名前にawsを付けがちです...)。
5-4. Identity CenterのEメール送信OTPを有効にする
APIで作ったユーザーにはパスワードがないので(参考)、初回サインイン時に確認メールが届くようにします。「設定」→「認証」タブ→「標準認証」の「設定」で、「Eメール送信OTP」(英語のUIでは「Send email OTP」)にチェックを入れて保存します。
6. 実装のポイント
要所だけ抜粋します(全体は5. デプロイ手順のリポジトリ)。
6-1. 受付Lambdaとlazy listener
Slackへの応答(ack)も、views.openに渡すtrigger_idも、期限は3秒です(参考)。そこでBolt for Pythonのlazy listenerで、ackと時間のかかる処理を分けました。
app = App(
token=cfg["botToken"],
signing_secret=cfg["signingSecret"],
process_before_response=True, # Lambda では応答を返した時点で処理が止まるため必須
)
app.command("/aws-account")(ack=open_modal, lazy=[fill_modal])
app.view("aws_account_request")(ack=validate_request, lazy=[post_request])
app.action("approve_request")(ack=ack_only, lazy=[approve])
lazy listenerはLambdaが自分を呼び直すので、自分へのlambda:InvokeFunctionが要ります。functionArnを書くと循環参照になるので、ARNを関数名から組み立てました。
receiverFn.addToRolePolicy(
new iam.PolicyStatement({
actions: ['lambda:InvokeFunction'],
resources: [
cdk.Stack.of(this).formatArn({
service: 'lambda',
resource: 'function',
resourceName: RECEIVER_FUNCTION_NAME,
arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
}),
],
}),
);
6-2. 承認ボタンの二重押し対策
DynamoDBの条件付き更新と実行名の2段で、1つの申請から実行が1回しか起動しないようにしています。
# 申請中 → 承認/却下 の遷移を条件付き更新で1回だけにする (二重押し・再送対策)
item = table.update_item(
Key={"requestId": request_id},
UpdateExpression="SET #s = :new, approver = :u, decidedAt = :t, channelId = :c, messageTs = :ts",
ConditionExpression="#s = :requested",
# (中略)
)["Attributes"]
# (中略)
# 実行名を申請IDにしておくと、同じ申請で2回目の実行は起動できない
sfn.start_execution(
stateMachineArn=os.environ["STATE_MACHINE_ARN"],
name=request_id,
input=json.dumps(item, ensure_ascii=False),
)
2回目の押下はConditionalCheckFailedExceptionになり、押した人にだけ「すでに処理済み」と返します。
6-3. Step Functions(JSONata)のSDK統合
サービス名はidentitystoreとssoadminで、CLIのsso-adminとは表記が違います(参考)。
FindUser: {
Type: 'Task',
Resource: 'arn:aws:states:::aws-sdk:identitystore:getUserId',
Arguments: {
IdentityStoreId: '{% $request.identityStoreId %}',
AlternateIdentifier: { UniqueAttribute: { AttributePath: 'emails.value', AttributeValue: '{% $request.loginEmail %}' } },
},
Assign: { userId: '{% $states.result.UserId %}' },
Catch: [
// 例外名の接頭辞は Identitystore (s は小文字)。TestState で確認した
{ ErrorEquals: ['Identitystore.ResourceNotFoundException'], Next: 'CreateUser' },
{ ErrorEquals: ['States.ALL'], Assign: { error: '{% $states.errorOutput %}' }, Next: 'MarkFailed' },
],
Next: 'DescribeUser',
},
Catchの例外名は、TestStateで1ステートだけ実行して確かめました。モックを指定しなければ、本物のロールでAPIまで呼ばれます。
# --definition: FindUser のステートだけを書いたJSON
# --role-arn: ステートマシンのロール
# --variables: JSONata の変数 ($request) を渡す
$ aws stepfunctions test-state \
--definition file://find-user.json \
--role-arn arn:aws:iam::123456789012:role/<ステートマシンのロール名> \
--variables '{"request":{"identityStoreId":"d-xxxxxxxxxx","loginEmail":"not-found@example.com"}}' \
--input '{}'
結果はIdentitystore.ResourceNotFoundExceptionとSsoAdmin.ResourceNotFoundExceptionでした。Identitystoreのsだけ小文字です。
7. つまずいたところ
7-1. 案内どおりにポータルを開いたら「ユーザー名」を聞かれた
リハーサルで割り当てまで成功して、正直ここで完成したつもりでいました。
ところが、カードの案内は「(メールアドレス)でサインイン」なのに、ポータルで聞かれたのは「ユーザー名」でした。
ユーザーをメールで探して割り当てるだけで、ユーザー名(UserName)を取っていなかったのが原因です。私の既存ユーザーは、ユーザー名がメールアドレスと別の文字列でした(新規作成のユーザーはユーザー名 = メールアドレスなので、偶然合っていました)。
identitystore:describeUserでユーザー名を取ってカードに出すように直し、既存ユーザーと未登録のメールの両方で正しく出ることを確認しました。
7-2. Slackの3秒の壁と「準備中」モーダル
コールドスタート直後は、1回目の/aws-accountが必ず失敗し、閉じて打ち直すと開く、という状態でした(デモでこれが出ると地味につらい...)。
計ってみると、初期化0.81秒 + 処理1.7秒で約2.5秒。trigger_idの期限3秒(6-1)まで0.5秒しか残っていませんでした。プロビジョンド同時実行は固定費がかかるので見送りました。
そこで、冷えているときは「フォームを準備しています…」のモーダルを先に開き、OUと許可セットの取得はlazy listenerに回して、views.updateで本物のフォームに差し替えました。views.updateには期限の記述がありません(参考)。lazy側にはview_idを渡せないので、trigger_idから作ったexternal_idでモーダルを指定しています。
def open_modal(ack, body, client):
ack()
if _caches_warm():
client.views_open(trigger_id=body["trigger_id"], view=build_modal())
else:
# 冷えているときは API を呼ばずに「準備中」を開く
client.views_open(trigger_id=body["trigger_id"], view=loading_modal(LOADING_PREFIX + body["trigger_id"]))
def fill_modal(body, client):
# (views.open より先に走ったときの再試行は省略)
client.views_update(external_id=LOADING_PREFIX + body["trigger_id"], view=build_modal())
7-3. URLを開くだけのボタンでも警告マークが出る
完了通知の「AWSアクセスポータルを開く」ボタンを押すと、ボタンの横に ⚠️ が出ました。
URLを開くだけのボタンなら、アプリには何も届かないと思い込んでいました。リファレンスには次のように書かれています(原文まま引用)。
If you're using url, you'll still receive an interaction payload and will need to send an acknowledgement response.
つまりurl付きのボタンでもackが要ります。ack()するだけの処理を足したら、⚠️ は出なくなりました!
app.action("open_portal")(ack_only)
8. 動作確認
実際にアカウントを1つ作った
2026年9月26日のリハーサルで作ったときの実行履歴です。
| 時刻 | ステップ | 内容 |
|---|---|---|
| 05:03:01 | (Slack) 申請 | モーダルから送信 |
| 05:03:10 | (Slack) 承認 | 情シスchで承認ボタンを押す |
| 05:03:11.6 | CreateAccount | 20秒待つ |
| 05:03:32.6 | DescribeCreateAccountStatus | 1回目で SUCCEEDED |
| 05:03:33.2 | MoveAccount | Sandbox OUへ移動 |
| 05:03:33.9 | FindUser → CreateAccountAssignment | 既存ユーザーに許可セットを割り当て |
| 05:03:39.6 | NotifySucceeded | 申請者にDM |
承認ボタンを押してから、ログインできる状態になるまで約30秒でした(※1回だけ計った値のため、あくまで参考値です)。
CreateAccountは公式に「数分待つ必要があるかもしれない」とあるので(参考)、もっと待つつもりでいました(デモとしてはむしろ短すぎるくらいです笑)。なお、7-1の修正前なので、表にDescribeUserはありません。
確認項目と結果
| 確認項目 | 期待する挙動 | 結果 |
|---|---|---|
| ドライラン | アカウントを作らず、案内のカードが届く | ⭕ |
| 既存ユーザー | 既存のユーザー名で案内が届く | ⭕ (ユーザー名の表示はドライランで確認) |
| 新規ユーザー | ユーザー名 = メールで案内が届く | ⭕ (ドライランで表示のみ。要検証: 実際の作成とOTPでの初回サインイン) |
| 却下 | 申請者にDMが届き、実行は起動しない | (要検証: 却下ボタンで確認) |
| 二重押し | 2回目は「すでに処理済み」になる | (要検証: 承認ボタンの連打で確認) |
9. 後片付け
スタックと、スタックの外に残るもの
cdk destroyのあとも、DynamoDBのテーブル(4. システム構成のとおりRETAIN)、ロググループ、SSMパラメータは残るので、続けて消します。
$ cd aws-account-vending-slack/cdk
$ npx cdk destroy
$ aws dynamodb delete-table --region ap-northeast-1 --table-name aws-account-vending-requests
# ロググループ名はCloudFormationが付けるので、スタック名で探してから消す
$ aws logs describe-log-groups --region ap-northeast-1 \
--log-group-name-prefix AwsAccountVendingSlack --query "logGroups[].logGroupName"
$ aws logs delete-log-group --region ap-northeast-1 --log-group-name <上で表示された名前>
$ aws ssm delete-parameter --region ap-northeast-1 --name /account-vending/slack
Slackアプリは「Basic Information」の「Delete App」から、Identity Centerのユーザーと割り当てはコンソールから消します。
作ったAWSアカウント
アカウントだけは、すぐには片付けられません。
- 作成から4日経つまで、組織から外せません(参考:Organizationsのクォータ)
- 閉鎖しても、完全に閉鎖されるまで(最大90日)は組織のアカウント数の上限に数えられます(参考:メンバーアカウントの閉鎖)
自分の組織は閉鎖済みの2つを含めて上限10のうち7つを使っていて、残りは3つでした。試すだけなら、申請フォームの「ドライラン」で承認からログイン案内のDMまで確認できます。
まとめ
Slackの申請と承認ボタンから、ログイン案内のDMまでがつながりました。承認から約30秒で、情シス側はコンソールを開かずに済みます。
いちばんの学びは、「割り当てができた」と「本人が入れる」は同じではない、ということです。
Step Functionsが全部SUCCEEDEDでも、案内どおりにログインしてみるまで「ユーザー名」の問題には気づけませんでした(本番のデモ前に踏めてよかったです...)。
一方で、割り当てはまだユーザーへの直接割り当てで、作ったアカウントの予算の設定まではしていません。次はグループへの割り当てとAWS Budgetsの自動設定、いずれはSlackのDMで会話しながらAmazon Bedrockに申請を組み立ててもらう形も試してみたいです。
最後は登壇のスライドと同じ言葉で締めます。まずは承認ボタン1つから始めませんか?
参考
- CreateAccount(Organizations API)
- Organizationsのクォータ
- メンバーアカウントの閉鎖
- パスワードのないユーザー(IAM Identity Center)
- AWS SDK統合の一覧(Step Functions)
- TestState(Step Functions)
- 関数URLのアクセス制御(Lambda)
- PutParameter(Systems Manager API)
- Lazy listeners(Bolt for Python)
- Handling user interaction(Slack)
- Button element(Slack)
- Usage limits for free workspaces(Slack)



