Skip to main content

不要 IAM ユーザーの整理・削除を自動化 — Step Functions Task Token

こんにちは~ 浮田です。

使われてないIAMユーザー放置してませんか?

今回は社内のAWSコミュニティが再編されたのでその整理をするために、Step Functions + Cognito 認証付き Web App で

「不要な IAM ユーザー」を自動検出 → 人間承認 → 連鎖削除する」ワークフロー を組みました。

完成した画面はこんな感じです。フロントにはAmplifyのコンポーネントを使用して作成しました。

 
Amplifyのコンポーネント

 

TL;DR(ざっくりまとめ) 

  • 背景: 社内 AWS コミュニティ「GAWS」の再編で残存IAM ユーザー 16 人 (最古 600 日) を整理する必要が出た
  • : Step Functions の waitForTaskToken72 時間まで人間承認を待機。待機中はほぼ無料
  • 設計判断: Bedrock Agents (LLM オーケストレーション) を捨て、ワークフロー に固定。
  • コスト: 高くても $4/月。社内業務システムとしてほぼ無料
  • 学び: 不可逆操作はワークフロー + Task Token で人間承認 が普遍解。承認・本番リリース・課金処理・データ削除などに横展開可能

 

■  なぜ IAM ユーザーを削除すべきか 

退職・異動したメンバーのアカウント放置はセキュリティリスクになります。

  • 不正アクセスの温床 — 使われなくなった認証情報は攻撃者に狙われやすい
  • 最小権限の原則違反 — 不要な権限が残ると、侵害時の被害範囲が広がる
  • コンプライアンス違反 — ISO27001 などの監査で指摘対象になる可能性がある
  • アクセスキーの漏洩リスク — 長期間使われているキーはローテーションされず、漏洩していても気づきにくい
  • 必ず検証用 AWS アカウントで動作確認 してから本番適用してください
  • 削除対象が 他システム(CI/CD・監視・スクリプト・別 AWS サービス等)で使われていないか 依存を洗ってください
  • 本記事の delete_user Lambda は 依存物の事前剥がし までやりますが、業務影響の判断はあくまで人間の責任です

先に大事なことを

IAM ユーザー削除は 不可逆 な操作です。本記事の構成を自分の環境に流用する際は、以下の点に留意してください。

間違って消したら復元できません

 

■  アーキテクチャ全体像  

今回の作成物のアーキテクチャ図こんな感じです!

GetImage (6)

主要コンポーネント:

レイヤ

採用技術

役割

認証

Amazon Cognito (MFA 必須 / TOTP)

承認者の本人確認

API

API Gateway (REST + Cognito Authorizer)

SPA からのリクエスト受口

ワークフロー

Step Functions Standard (JSONata)

人間承認待機・並列削除

計算

AWS Lambda x 7 (Python 3.13, arm64)

IAM API / 承認 API / Cognito トリガー

監査

DynamoDB (オンデマンド + GSI x2 + TTL 2 年)

イベントソーシング風ログ

UI

React + Vite (S3 + CloudFront)

承認・管理画面

 

1. なぜ作ったか  

社内に GAWS という有志の技術コミュニティがあり、メンバーごとに IAM ユーザーが払い出されており、

これがコミュニティ再編で整理対象になった。

iam:GetCredentialReport を叩いてみると 90 日以上アクティビティのないユーザーが 16 人、最古は 600 日前 が最終活動日の人や

アクセスキーが残っている人や MFA を解除している人もいた。

これはよくないですよね。

最初は CLI スクリプトで済ませようとしましたが、要件を整理すると以下が必要になった:

  • ログインプロファイル / アクセスキー / MFA / インラインポリシー / 管理ポリシー / グループメンバーシップを すべて削除 してから iam:DeleteUser を呼ばないDeleteConflict (MFA は Deactivate → Delete の順序のみ必須、それ以外の順序は任意)
  • 削除前に「本当に未使用か」を CloudTrail で個別確認したい
  • 削除は不可逆なので人間承認を必ず挟む
  • 「いつ・誰が・なぜ・何を削除したか」を確認できる形で残す
  • この組織は半年に一度代表者が交代する ので、CLI 完結だと引き継ぎが辛い

=> ワークフロー化して、承認だけ人間がやればよい状態にする。

 

2. 設計判断  

2.1 ピボット 1: Bedrock Agents → Step Functions  

最初は Bedrock Agents で IAM API ツールを定義し、Claude にオーケストレーションさせる 方式を考えた。

「古いユーザー検出して削除して」とチャットすれば動く未来を想像していた。

よく考えたら、LLM に削除のオーケストレーションをさせるのは怖すぎる。

LLM オーケストレーションの懸念

影響

ツール選択が確率的 (温度 0 でも厳密ではない)

削除対象の取り違え

承認待機をどう実装する? Agent をポーリング?

アーキテクチャが複雑化

監査トレイルが「Agent の判断」だけになる

監査担当に説明できない

結論: ワークフローは Step Functions が固定する。


 2.2 ピボット 2: Microsoft Teams → 認証付き Web App    

承認 UI として最初は Teams Bot を考えたが、運用に Azure サブスクリプション + M365 管理者権限が要る。これがなかったので

業務監査要件 (誰がいつ承認したかを厳格に記録) を満たす選択肢として、Cognito MFA + Web App を採用。


  2.3 一番の判断 — 「AI を使わない」を選んだ

Bedrock Agents で作れたら格好いい、と最初は思いました。

でも要件を見ると 不可逆操作 + 監査 + 承認 が中心で、LLM の必然性がゼロでした。「使うと便利か」ではなく「使わないと困る要件があるか」で判断したら、答えは No だった。

不可逆操作は「待つ方が安全」

その代わり Step Functions の waitForTaskToken72 時間の人間承認を挟む決定論的ワークフロー に振り切りました。

このパターンは承認・本番リリース・課金処理・データ削除・外部送金など「待ってる方が安全な操作」全般に効くと思います。

 

 3. システムの核 — Task Token パターン解説  

 3.1 待機を Lambda の外に逃がす  

Lambda は 15 分で切れるので 72 時間待てません。

待機中も課金され、落ちれば承認待ちが消える。

承認の待機自体を Lambda から外に出したい — そこでStep Functionsの waitForTaskToken を使うことにしました。


 3.2 waitForTaskToken の仕組み  

これに完璧に当てはまるのが waitForTaskToken。Step Functions が「待機」を引き受けてくれるコールバックパターンです。

ASL (Amazon States Language) ではリソース ARN の末尾に .waitForTaskToken を付けるだけ。

※ASL は Step Functions のワークフローを JSON で記述する専用フォーマット です。

本プロジェクトはステートマシン全体を JSONata モード ("QueryLanguage": "JSONata") で書いているので、サンプルもその記法で示します:

JSONataJSON データを抽出・変換するための問い合わせ言語

SQL が「テーブルに対する問い合わせ言語」なら、JSONata は「JSON に対する問い合わせ言語」です。

※JSONには本来コメントは書けませんがわかりやすいようにコメントを付けてあります。ご留意ください。


------------------------------

{

"SendDetectionCard": {

"Type": "Task",

// ★ 末尾の .waitForTaskToken がコールバックモードのスイッチ。

  これが付いた Task は「外から token が戻るまで待つ」状態になる

"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",

"Arguments": {

"FunctionName": "${StateWriterFunctionArn}",

"Payload": {

"card_type": "DETECTION_RESULT",

"old_users": "{% $states.input.old_users %}",

// ★ Step Functions が自動生成する一意の token。

// Lambda はこれを DynamoDB に保存して return するだけでよい

"task_token": "{% $states.context.Task.Token %}",

"execution_arn": "{% $states.context.Execution.Id %}"

}

},

"Output": "{% $merge([$states.input, $states.result]) %}",

// 72 時間 (= 259,200 秒) 待機したらタイムアウト

"TimeoutSeconds": 259200,

"Catch": [

{ "ErrorEquals": ["States.Timeout"], "Next": "NotifyTimeout" }

],

"Next": "HasSelectedUsers"

}

}

------------------------------


 3.3 全体のシーケンス  

 
GetImage (5)

少し直感に反するのが、Lambda が return しても Step Functions は次の State に進まない こと。

普通の Task State なら return 値がそのまま次の入力になりますが、.waitForTaskToken モードでは「token が外から SendTaskSuccess で戻ってくるまで動かない」という挙動に変わります。


 3.4 Lambda 側の実装  

Lambda 側は意外とシンプルで、token を DynamoDB に保存してすぐ return するだけ。

GetImage (3)

ここで token を保存し忘れると、誰も Step Functions を再開できないまま 72 時間待機することになるので、

put_item が成功するまで return しないことだけ守れば OK。


 3.5 同時承認のレースコンディション  

実装を進めて承認 UI を作ってる最中に、ふと「承認者 2 人が同時にボタンを押したらどうなる?」と気になって調べました。

両方が status=PENDING を読んで、両方が SendTaskSuccess を呼ぶと、後発の方は token が既に消費済みなので TaskTimedOut で例外。Lambda が落ちて 500 エラーが UI に返る、という気持ち悪い状態になります。

これを防ぐには、Step Functions を再開する前に DynamoDB 側で 1 人だけを通す 仕掛けが要ります。

ConditionExpression を使った楽観ロックです:

GetImage (7)

こうすると、2 人同時押しでも DynamoDB レベルで 1 人しか通らないので、SendTaskSuccess も 1 回しか呼ばれません。通らなかった人は 409 がすぐ返るので、UI で「すでに承認済み」と表示できる。

仮に SendTaskSuccess の途中で Lambda が死んでも、DynamoDB の状態を見れば再投入可能、というのも嬉しい副作用でした。


 3.6 TimeoutSeconds と HeartbeatSeconds の取り違え  

実は最初これを書いたとき、HeartbeatSeconds: 259200 と書いていて、記事化のレビューで気付いて直しました。

Heartbeat は人間承認には使えません

設定

何を計る

比喩

TimeoutSeconds

タスク開始からの総経過時間

全体で 30 分以上かかったら諦める

HeartbeatSeconds

「最後の生存報告から」の経過時間

「5 分に 1 回は『生きてます』と連絡して」

HeartbeatSeconds は「長時間動くジョブが定期的に『生きてます』と通知する」用途のもので、人間承認のように何時間も放置するケースには使えません。誰も heartbeat なんて打たないので。

しかも厄介なのが、HeartbeatSeconds: 259200 でも初回実行から 72時間後にちゃんとタイムアウトするんですよ。

なので動作テストのときは「あれ?ちゃんと動いてるじゃん」と通り過ぎてしまう。Catch で通知を握りつぶしていると、いつまで経っても気づかない地雷になります。ひとつ学びになりました。


 3.7「同じことを別の方法でやれないか?」を一応比べておきました:

方式

待ち時間

コスト

監査性

実装複雑度

Lambda ポーリング

〜15 分

Lambda 実行時間

EventBridge Scheduler + DynamoDB

〜数年(運用次第)

スケジューラ料金

Lambda Durable Functions

〜1 年

Lambda 実行時間 + 状態保存料金

○(自分でログ設計)

中(コード中心)

Step Functions waitForTaskToken

〜1 年

状態遷移ベース
(待機中はほぼ無料)


(実行履歴に残る)

低〜中

SQS + 別 Lambda

〜14 日(最大保持期間)

SQS 料金

人間承認に限って言えば Step Functions が圧倒的に楽だと思います。72 時間放置されても財布が痛まないのが地味に効きます。

ちなみに Lambda Durable Functions も「長時間待機 + コールバック再開」を Lambda のコード上で書ける選択肢で、宣言的な ASL ではなく Python / TypeScript でロジックを組みたい チームには有力な対抗馬です。

今回は監査担当に提出する都合で 承認の証跡が実行履歴 UI に自動で残ること半年ごとの承認者交代で引き継ぐときにフロー図のままレビューできること を優先して Step Functions を選びました。

逆に「複雑な業務ロジックを if/try でコードに直接書きたい」「ワークフローが頻繁に変わる」ケースなら Durable Functions の方が嵌まりそうです。

Durable Functions については別のブログ記事で紹介しています。


 3.8 まとめ — waitForTaskToken がこのシステムの設計を決めた  

要は Step Functions に「門番」をやらせているだけです。token を握って 72 時間立ち続け、人間が OK と言うまで誰も次に通さない。

これを Lambda や自前のコードで再現しようとすると、タイムアウト・課金・落ちたときの状態消失・レースコンディションが全部覆いかぶさってきますが、`waitForTaskToken` は AWS 側がこの門番役を丸ごと引き受けてくれる。

「不可逆な操作には「待つのが得意な道具」を据える」 — IAM 削除に限らず、承認・本番リリース・課金・データ削除・外部送金などにそのまま有効なパターンだと思います。

 

4. 周辺実装

 4.1 連鎖削除と部分失敗対応  

iam:DeleteUser は 依存リソースが残っているとエラー になる API です。

本体を消す前に、ユーザーにぶら下がっている以下を すべて削除/解除しておく必要があります(順序は問わない、並列で消しても OK):

  • Login Profile(コンソールパスワード)
  • Access Keys
  • Inline Policies
  • Attached Managed Policies
  • Group Memberships
  • (環境によっては)SSH 公開鍵 / 署名証明書 / サービス固有認証情報
  • MFA Devices(仮想 MFA は DeactivateMFADevice(ユーザーとの関連付け解除)→ DeleteVirtualMFADevice(デバイス本体削除)の 2 段階。

Deactivate までで DeleteUser 自体は通るが、Delete までやらないと持ち主のいない未関連デバイスが残る

本番テストで DeleteVirtualMFADevice の権限不足を踏んで 「Login Profile と Access Keys は消えたが MFA から先で失敗 → ユーザー本体は残ったまま」 という部分的に削除された状態を作ってしまいました。


 4.2 GetCredentialReport で 1 リクエスト全取得  

最終ログイン取得手段の比較:

方法

範囲

速度

コスト

cloudtrail:LookupEvents

90 日

遅い (2 req/sec)

iam:GetUser.PasswordLastUsed

全期間

早い (ユーザー単位)

iam:GetCredentialReport

全期間, CSV 一括

超早い (1 req)

無料

100 ユーザー超えるアカウントだと CloudTrail 個別 API は絶望的に遅い。Credential Report が圧勝

 

■  5.1ハマりポイント集  

メモ程度に実装中に踏んだ罠と修正の概要だけ置いておきます。

  • CredentialReportNotReadyException は HTTP エラー → 例外として飛ぶ(成功 200 でステータス付き、ではない)→ try/except + sleep でリトライ。
  • DeleteVirtualMFADevice の IAM Policy リソースは mfa/*(user/* ではない)。RemoveUserFromGroupgroup/*
    アクション別に分ける。
  • CDK スタックを細かく分けると CloudFormation の循環依存 で詰まった → Compute 系は 1 スタック統合、データ層(DynamoDB / Secrets)だけ分離して 保持しておく

 5.2 月額コスト  

多く見積もっても月 100 回の検出実行を想定して、

サービス

月額

Step Functions Standard (約 2,500 状態遷移)

$0.06

Lambda (約 2,000 呼び出し)

$0.10

API Gateway (約 500 req)

$0.02

DynamoDB オンデマンド

$0.50

CloudWatch Logs (5 GB)

$2.50

CloudFront + S3

$0.55

Cognito (5 MAU、無料枠内)

$0

合計

約 $4/月

業務システムとしてほぼ無料。

 

■  6. 振り返りと学び  

 よかった選択  

  • Bedrock Agents を捨てて Step Functions に振り切った: 監査要件のあるフローに LLM の確率的判断を混ぜると説明責任が崩れる。決定論的なワークフローに固定したことで、実行履歴がそのまま監査ログになった
  • waitForTaskToken を中心に据えた: Lambda タイムアウト・課金・状態管理・レースコンディションが芋づる式に解けた(3 章)
  • GetCredentialReport: 100 人いても 1 リクエストで全クレデンシャル情報が取れる
  • DynamoDB をイベントソーシング風に: 状態は Step Functions が持つので二重管理が無い

 反省  

  • 最初に Bedrock Agents で行けると思い込んだ: 「AI で解く」を出発点にして要件と合わせるのではなく、要件を冷静に見ればワークフロー駆動が自然だった
  • CDK のスタック分割を細かくしすぎた: 循環依存で時間を溶かした
  • エラーケースのテストが甘かった: race condition / partial failure / IAM 権限不足は本番投入前に意図的に再現すべきだった

 普遍化できる学び  

  1. 不可逆な操作は Step Functions の Task Token で人間承認を挟む — 承認・本番リリース・課金・データ削除など「待つほうが安全な操作」全般に効く
  2. 「AI で解けるか」より「AI で解くべきか」を先に問う — 監査・説明責任が中心の業務には決定論的ワークフローを選ぶ。LLM 全盛の時代でも「使わない」が正解の領域がある
  3. IAM ポリシーのリソース ARN はアクション別に確認するuser/* で全部行けると思うと痛い目を見る

 

まとめ  

GAWS 再編に伴う IAM 整理がスタート地点でしたが、結果的に Step Functions + Cognito + Web App という「不要 IAM ユーザーの自動整理・削除システム」に育ちました。GAWS 専用ではなく、他チームでもそのまま使えます。

設計面の最大の学びは、Bedrock Agents 案を捨てて Step Functions に振り切った こと。「AI でやれそう」と「AI でやるべき」は別物 で、不可逆操作・監査要件のある業務では、決定論的なワークフローに任せた方が安全で、結果的に実装も簡潔になりました。

同じような「コミュニティの再編で IAM 整理が要る」「定期的に不要 IAM ユーザーを棚卸ししたい」「人間承認付きワークフローを AWS で組みたい」というケースの参考になれば。

ありがとうございました。