はじめに

こんにちは、そしてこんばんは!
サービスプラットフォーム事業部の大嵩です。

今回は、AWS Step Functions だけで IAM ユーザーの作成を自動化してみます!

みなさん、お客様や社内メンバー向けの IAM ユーザーって、どうやって作っていますか・・?
私はこれまでマネジメントコンソールから 1 人ずつポチポチ作っていました。
ユーザー名の命名ルールを守って、グループに入れて、コンソールアクセスの有無を選んで、アクセスキーを発行して、CSV をダウンロードして、安全な経路で受け渡す。。。
1 人なら数分ですが、5 人 10 人となると地味に時間がかかりますし、「ユーザー名のスペルミス」「グループの入れ忘れ」「CSV のダウンロードし忘れ」といったヒューマンエラーも怖いですよね。

そこで、「メールアドレスを渡すだけで、あとは全部 Step Functions がやってくれる」仕組みを作ってみました!
今回も Lambda を 1 行も書かない という縛りで挑戦します。

なお、利用する認証は本来 AWS IAM Identity Centerの利用が推奨されています。
ただ、お客様の要件やツールの制約で「どうしても IAM ユーザーが必要」という場面は、SI の現場ではまだまだ多いのが実情です。
そういった場面で少しでも楽に、安全に IAM ユーザーを払い出すための一案として読んでいただければと思います!

本記事のゴール

下記のような JSON を Step Functions に投げるだけで、

{
  "email": "taro.yamada@example.com",
  "consoleAccess": true,
  "accessKey": true,
  "groups": ["sfn-iam-user-readonly"]
}
  1. メールアドレスの @ より前(taro.yamada)をユーザー名にして IAM ユーザーを作成
  2. MFA 強制グループに必ず追加し、groups で指定されたグループにも追加
  3. consoleAccess が true なら、初期パスワードを生成してコンソールアクセスを有効化
  4. accessKey が true なら、アクセスキーを発行
  5. ユーザー名・パスワード・アクセスキー・サインイン URL をまとめた認証情報ファイルを S3 に保管

までを全自動で行い、最後に 署名付き URL で認証情報ファイルをお客様に受け渡す ところまでをゴールとします!

コンソールアクセスとアクセスキーは、それぞれ「つける / つけない」を入力フラグで選べるようにします。
「コンソールだけ使う人」「CLI だけ使う人」「両方使う人」で払い出す認証情報が違うのは、実務でよくあるパターンですよね。

ちなみに、認証情報ファイルは当初「マネコンでダウンロードできる CSV と同じ形式」にするつもりでした。
それが最終的に JSON になった経緯は、ハマりポイントで詳しく書きます。。。

今回の構成

登場するサービスはこちらです。

  • AWS Step Functions(Standard ワークフロー): 全体のオーケストレーション
  • AWS SDK 統合: iam:createUser / iam:addUserToGroup / iam:createLoginProfile / iam:createAccessKey / secretsmanager:getRandomPassword / s3:putObject を Step Functions から直接呼び出す
  • JSONata: メールアドレスの分割や認証情報ファイルの組み立てに使用
  • IAM グループ: MFA 強制グループ(全員必須)と、権限を持たせるグループ(入力で選択)
  • Amazon S3: 認証情報ファイルの一時保管先
  • AWS CLI: 署名付き URL の発行(ここだけ Step Functions の外に出ます。理由は後述!)

リソース一式は Terraform で作成しています。

Step Functions の AWS SDK 統合は、ほぼすべての AWS サービスの API アクションを Task ステートから直接呼び出せる機能です。
今回使う IAM・Secrets Manager・S3 はいずれも対応しています。

Learning to use AWS service SDK integrations in Step Functions - AWS Step Functions
AWS Step Functions integrates with AWS services, so you can call service's API actions from your workflows.

そして JSONata は 2024 年 11 月に Step Functions で使えるようになったクエリ・変換言語です。
今回のように「文字列を分割したい」「値を組み合わせてファイルの中身を作りたい」といった処理を、Lambda を書かずに ASL の中で完結できます!

設計のポイント

実装に入る前に、今回の設計で悩んだポイントを 6 つ紹介します。
ここが今回の記事で一番お伝えしたい部分です!

ユーザー名はメールアドレスの「@」より前

JSONata の $split 関数を使えば一発です。

$split($states.input.email, '@')[0]

taro.yamada@example.com なら taro.yamada が取り出せます。
IAM ユーザー名に使える文字は英数字と + = , . @ - _ なので、一般的なメールアドレスのローカルパート(@ より前)であればそのまま使えます。

権限はグループ経由でのみ付与し、追加先は実行ロールで絞る

ユーザーへのポリシー直接アタッチはせず、グループへの追加だけ で権限を付与します。
これは IAM のベストプラクティスでもありますが、もう一つ大事な理由があります。

このステートマシンを実行できる人は、入力の groups に好きなグループ名を書けます。
もし Administrators と書かれたら・・?権限昇格の入り口になってしまいますよね。
そこで、ステートマシンの実行ロールに与える iam:AddUserToGroupResourcearn:aws:iam::<アカウント ID>:group/sfn-iam-user-* のようにプレフィックスで絞りました。
こうすると、許可外のグループ名を渡されても IAM 側で AccessDenied になります。
「入力値のバリデーションを ASL で頑張る」のではなく、「IAM ポリシーで物理的にできなくする」方が堅いですね!

さらに、入力に関係なく必ず追加される MFA 強制グループ を用意しました。
MFA を設定するまでは自分の MFA・パスワード・アクセスキーの管理以外をすべて Deny する、IAM ユーザーガイドの定番ポリシーです。

IAM tutorial: Permit users to manage their credentials and MFA settings - AWS Identity and Access Management
Configure the users in your AWS account to self-manage their own passwords, MFA devices, and credentials.

初期パスワードはどう作る?

ここが最初の壁でした。。。
Step Functions の組み込み関数には States.UUID() はあるものの、「大文字・小文字・数字・記号を含むランダムパスワードを生成する」関数はありません。
UUID をそのままパスワードにすると、アカウントのパスワードポリシー(大文字必須・記号必須など)に引っかかる可能性があります。

そこで思いついたのが、AWS Secrets Manager の GetRandomPassword API を SDK 統合で叩く という方法です!
この API はシークレットを作成せずにランダムなパスワード文字列だけを返してくれるので、パスワード生成器として使えます。
RequireEachIncludedType を true にすれば、大文字・小文字・数字・記号を必ず 1 文字以上含んでくれます。

GetRandomPassword - AWS Secrets Manager
Generates a random password. We recommend that you specify the maximum length and include every character type that the system you are generating a password for can support. By default, Secrets Manager uses uppercase and lowercase letters, numbers, and the following characters in passwords:

コンソールアクセス / アクセスキーの有無は Choice で分岐

入力の consoleAccessaccessKey を Choice ステートで判定し、必要なステップだけ通るようにします。
組み合わせは 4 パターンですが、Choice を 2 つ直列に並べるだけで表現できます。

  • コンソールあり + キーあり
  • コンソールあり + キーなし
  • コンソールなし + キーあり
  • コンソールなし + キーなし(ユーザーだけ作る)

認証情報ファイルは JSON オブジェクトを Body に渡す

s3:putObjectBody には、文字列ではなく JSON オブジェクトをそのまま渡します

{
  "userName": "taro.yamada",
  "password": "(初期パスワード)",
  "accessKeyId": "AKIA...",
  "secretAccessKey": "(シークレットアクセスキー)",
  "consoleLoginLink": "https://XXXXXXXXXXXX.signin.aws.amazon.com/console"
}

当初はマネコンでダウンロードできる CSV と同じ列構成の文字列を JSONata の & で連結して渡すつもりでしたが、Step Functions の s3:putObject は Body を JSON としてシリアライズして書き込む ため、文字列を渡すとダブルクォートで囲まれた 1 行の JSON 文字列になってしまいます(ハマりポイント参照)。
逆に言うと、JSON オブジェクトを渡せばそのまま正しい JSON ファイルになるので、素直に JSON を成果物にしました。
サインイン URL に必要なアカウント ID は、Context オブジェクトのステートマシン ARN から $split で切り出しています!

署名付き URL は Step Functions では発行できない

当初は「S3 に置いたら、そのまま Step Functions で署名付き URL も発行して出力に含めよう」と考えていました。
しかし、署名付き URL は AWS の API ではなく、SDK がローカルで署名計算を行って生成するもの なので、SDK 統合の対象になりません。
つまり、Step Functions 単体では発行できないんですね。。。

ということで、署名付き URL の発行は Step Functions の外で aws s3 presign を使うことにしました。
ステートマシンの出力にはファイルの S3 URI を含めておき、作業者がその URI に対して署名付き URL を発行してお客様に渡す、という流れです。
「全部 Step Functions で完結」にはなりませんでしたが、認証情報の受け渡しは人が確認を挟むべきポイントでもあるので、結果的にちょうどいい分界点になったと思っています!

環境準備

それでは、実際に作っていきましょう!
リソースは Terraform で作成しています。ポイントになる部分だけ抜粋します。

認証情報ファイル保管用の S3 バケット

認証情報が入ったファイルを置くバケットなので、パブリックアクセスは完全にブロックし、ライフサイクルルールで短期間のうちに自動削除されるようにします。

resource "aws_s3_bucket" "csv" {
  bucket = "sfn-iam-user-csv-${local.account_id}"
}

resource "aws_s3_bucket_public_access_block" "csv" {
  bucket                  = aws_s3_bucket.csv.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_lifecycle_configuration" "csv" {
  bucket = aws_s3_bucket.csv.id
  rule {
    id     = "expire-credentials-file"
    status = "Enabled"
    filter {}
    expiration {
      days = 1 # 検証用。本番はダウンロード確認後に手動で即削除する運用が望ましい
    }
  }
}

(バケット名に csv が残っているのは、当初 CSV を出す設計だった名残です。。。)

IAM グループ

MFA 強制グループ sfn-iam-user-base と、検証用に ReadOnlyAccess を付けた sfn-iam-user-readonly の 2 つを作ります。
MFA 強制ポリシーの中身は先ほどのブログカードのチュートリアルそのままなので割愛します。

Step Functions の実行ロール

ハマりポイントの先出しになりますが、SDK 統合を使う Task ステートの IAM ポリシーは Workflow Studio が自動生成してくれません。
自分で必要なアクションを洗い出して付与する必要があります。

今回必要なのはこちらです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CreateIamUser",
      "Effect": "Allow",
      "Action": [
        "iam:CreateUser",
        "iam:TagUser",
        "iam:CreateLoginProfile",
        "iam:CreateAccessKey"
      ],
      "Resource": "arn:aws:iam::XXXXXXXXXXXX:user/*"
    },
    {
      "Sid": "AddUserToAllowedGroups",
      "Effect": "Allow",
      "Action": "iam:AddUserToGroup",
      "Resource": "arn:aws:iam::XXXXXXXXXXXX:group/sfn-iam-user-*"
    },
    {
      "Sid": "GenerateRandomPassword",
      "Effect": "Allow",
      "Action": "secretsmanager:GetRandomPassword",
      "Resource": "*"
    },
    {
      "Sid": "PutCredentialsToS3",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::sfn-iam-user-csv-XXXXXXXXXXXX/*"
    }
  ]
}

ポイント

  • iam:CreateUser でタグを付ける場合は iam:TagUser も必要です(これがないと AccessDenied になります)
  • iam:AddUserToGroupResource はグループ ARN で絞れます。ここが権限昇格防止の要です
  • secretsmanager:GetRandomPassword はリソースに紐づかない API なので Resource* になります
  • このロールは「IAM ユーザーとアクセスキーを作れる」強い権限を持ちます。このステートマシンを実行できる人(states:StartExecution)は厳密に絞ってください

ステートマシンを作る

ASL 全文

先に ASL の全文を載せます。QueryLanguageJSONata にしているのがポイントです!
(Terraform の templatefile でバケット名などを差し込んでいますが、ここでは差し込み後の形で載せています)

{
  "Comment": "メールアドレスから IAM ユーザーを作成し、グループ追加・認証情報ファイルの S3 保管まで行う",
  "QueryLanguage": "JSONata",
  "StartAt": "ExtractUserName",
  "States": {
    "ExtractUserName": {
      "Type": "Pass",
      "Assign": {
        "email": "{% $states.input.email %}",
        "userName": "{% $split($states.input.email, '@')[0] %}",
        "consoleAccess": "{% $exists($states.input.consoleAccess) ? $states.input.consoleAccess = true : false %}",
        "accessKey": "{% $exists($states.input.accessKey) ? $states.input.accessKey = true : false %}",
        "groups": "{% $exists($states.input.groups) ? $states.input.groups : [] %}",
        "accountId": "{% $split($states.context.StateMachine.Id, ':')[4] %}",
        "bucket": "otake-test-sfn-iam-user-csv-XXXXXXXXXXXX",
        "baseGroup": "sfn-iam-user-base",
        "password": "",
        "accessKeyId": "",
        "secretAccessKey": ""
      },
      "Next": "CreateUser"
    },
    "CreateUser": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:iam:createUser",
      "Arguments": {
        "UserName": "{% $userName %}",
        "Tags": [
          { "Key": "email", "Value": "{% $email %}" },
          { "Key": "created-by", "Value": "step-functions" }
        ]
      },
      "Catch": [
        {
          "ErrorEquals": ["Iam.EntityAlreadyExistsException"],
          "Next": "UserAlreadyExists"
        }
      ],
      "Next": "AddToBaseGroup"
    },
    "AddToBaseGroup": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:iam:addUserToGroup",
      "Arguments": {
        "GroupName": "{% $baseGroup %}",
        "UserName": "{% $userName %}"
      },
      "Next": "AddToRequestedGroups"
    },
    "AddToRequestedGroups": {
      "Type": "Map",
      "Items": "{% $groups %}",
      "MaxConcurrency": 1,
      "ItemSelector": {
        "groupName": "{% $states.context.Map.Item.Value %}",
        "userName": "{% $userName %}"
      },
      "ItemProcessor": {
        "ProcessorConfig": { "Mode": "INLINE" },
        "StartAt": "AddUserToGroup",
        "States": {
          "AddUserToGroup": {
            "Type": "Task",
            "Resource": "arn:aws:states:::aws-sdk:iam:addUserToGroup",
            "Arguments": {
              "GroupName": "{% $states.input.groupName %}",
              "UserName": "{% $states.input.userName %}"
            },
            "End": true
          }
        }
      },
      "Catch": [
        {
          "ErrorEquals": ["Iam.NoSuchEntityException"],
          "Next": "GroupNotFound"
        }
      ],
      "Next": "NeedConsoleAccess?"
    },
    "NeedConsoleAccess?": {
      "Type": "Choice",
      "Choices": [
        { "Condition": "{% $consoleAccess %}", "Next": "GetRandomPassword" }
      ],
      "Default": "NeedAccessKey?"
    },
    "GetRandomPassword": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:secretsmanager:getRandomPassword",
      "Arguments": {
        "PasswordLength": 20,
        "ExcludeCharacters": ",\"'\\` ",
        "RequireEachIncludedType": true
      },
      "Assign": {
        "password": "{% $states.result.RandomPassword %}"
      },
      "Output": {},
      "Next": "CreateLoginProfile"
    },
    "CreateLoginProfile": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:iam:createLoginProfile",
      "Arguments": {
        "UserName": "{% $userName %}",
        "Password": "{% $password %}",
        "PasswordResetRequired": true
      },
      "Next": "NeedAccessKey?"
    },
    "NeedAccessKey?": {
      "Type": "Choice",
      "Choices": [
        { "Condition": "{% $accessKey %}", "Next": "CreateAccessKey" }
      ],
      "Default": "PutCredentialsToS3"
    },
    "CreateAccessKey": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:iam:createAccessKey",
      "Arguments": {
        "UserName": "{% $userName %}"
      },
      "Assign": {
        "accessKeyId": "{% $states.result.AccessKey.AccessKeyId %}",
        "secretAccessKey": "{% $states.result.AccessKey.SecretAccessKey %}"
      },
      "Output": {},
      "Next": "PutCredentialsToS3"
    },
    "PutCredentialsToS3": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:s3:putObject",
      "Arguments": {
        "Bucket": "{% $bucket %}",
        "Key": "{% $userName & '/' & $userName & '_credentials.json' %}",
        "ContentType": "application/json",
        "ServerSideEncryption": "AES256",
        "Body": {
          "userName": "{% $userName %}",
          "password": "{% $password %}",
          "accessKeyId": "{% $accessKeyId %}",
          "secretAccessKey": "{% $secretAccessKey %}",
          "consoleLoginLink": "{% 'https://' & $accountId & '.signin.aws.amazon.com/console' %}"
        }
      },
      "Output": {
        "userName": "{% $userName %}",
        "groups": "{% $append([$baseGroup], $groups) %}",
        "consoleAccess": "{% $consoleAccess %}",
        "accessKey": "{% $accessKey %}",
        "credentialsS3Uri": "{% 's3://' & $bucket & '/' & $userName & '/' & $userName & '_credentials.json' %}"
      },
      "End": true
    },
    "UserAlreadyExists": {
      "Type": "Fail",
      "Error": "UserAlreadyExists",
      "Cause": "同名の IAM ユーザーが既に存在します。メールアドレスを確認してください。"
    },
    "GroupNotFound": {
      "Type": "Fail",
      "Error": "GroupNotFound",
      "Cause": "指定されたグループが存在しません。ユーザーは作成済みのため、グループ名を確認して手動で追加するかユーザーを削除してください。"
    }
  }
}

Workflow Studio で見るとこんな形になります。

それぞれのステートを順番に見ていきましょう!

ExtractUserName:入力を変数に取り込む

Pass ステートの Assign で、以降のステートで使う値をすべて ワークフロー変数 に入れています。
JSONata を使うと、変数は $userName のように $ を付けて後続のどのステートからでも参照できるので、ステート間でデータを引き回す苦労がなくなります!

consoleAccess accessKey groups$exists() で存在チェックを挟んでいます。
入力にフィールドがないときに $states.input.consoleAccess = true をそのまま評価すると値が未定義になり、States.QueryEvaluationError で落ちてしまうためです。
「指定がなければ false(または空配列)扱い」にすることで、{"email": "..."} だけ渡してもユーザー作成だけが動くようになります。

password accessKeyId secretAccessKey を空文字で初期化しているのは、Choice で分岐をスキップした場合でも認証情報ファイルの組み立て時に変数が未定義にならないようにするためです。

CreateUser:IAM ユーザーを作成

iam:createUser を呼び出します。
タグにメールアドレスを入れておくと、後から「このユーザー誰のだっけ?」となったときに追跡できるので便利ですね!

CatchIam.EntityAlreadyExistsException を捕まえて Fail ステートに飛ばしています。
SDK 統合のエラー名は「サービス名.エラー名」で、必ず Exception サフィックスを付ける 必要があります。IAM の API リファレンスでは EntityAlreadyExists と書かれていますが、Step Functions 上では Iam.EntityAlreadyExistsException です。

AddToBaseGroup / AddToRequestedGroups:グループに追加

まず MFA 強制グループに必ず追加し、続いて入力の groups を Map ステートで 1 つずつ iam:addUserToGroup します。
groups が空配列なら Map は何もせずに通過するので、Choice で分岐させる必要はありません。

ここで一つ工夫があります。
Map の中(ItemProcessor)から親スコープの変数 $userName を直接参照する形にはせず、ItemSelectorgroupNameuserName を各アイテムに詰めてから、子ステートでは $states.input として受け取っています。
Map の各イテレーションに渡す入力を ItemSelector で明示的に組む方が、後から読んだときにデータの流れが分かりやすいと思います。

存在しないグループが指定された場合は Iam.NoSuchEntityException を Catch して GroupNotFound で止めます。

GetRandomPassword / CreateLoginProfile:コンソールアクセスを有効化

consoleAccess が true のときだけ通るルートです。

ExcludeCharacters には , " ' \ ` と半角スペースを指定しています。
シェルで扱いづらい文字やコピペで事故りやすい文字を除外しておくためです。
PasswordLength は 20 文字、RequireEachIncludedType で 4 種類の文字種を必ず含めているので、一般的なパスワードポリシーには引っかからないはずです。

生成したパスワードは変数 $password に入れ、iam:createLoginProfile に渡します。
PasswordResetRequired: true にしているので、お客様は初回ログイン時に必ずパスワードを変更することになります。ここは必須の設定 だと思います!

"Output": {} を付けているのは、API の結果(パスワード)をそのまま次のステートの入力として引き回さないためです。理由はハマりポイントで詳しく書きます。

CreateAccessKey:アクセスキーを発行

accessKey が true のときだけ通るルートです。
戻り値の AccessKey.AccessKeyIdAccessKey.SecretAccessKey を変数に保存し、こちらも "Output": {} で結果の引き回しを止めています。

PutCredentialsToS3:認証情報ファイルを S3 に保管

ここまでで集めた変数を JSON オブジェクトにまとめて s3:putObjectBody に渡します。
Step Functions が Body を JSON にシリアライズして書き込んでくれるので、文字列を組み立てる必要はありません。

オブジェクトキーは <ユーザー名>/<ユーザー名>_credentials.json にしています。
Output にはパスワードやシークレットキーを 含めず、S3 URI・所属グループ・フラグだけを返すようにしました。

実際に実行してみる

それでは、実際に動かしてみましょう!
アカウント ID はマスクしています。

パターン 1:コンソールアクセス + アクセスキー + ReadOnly グループ

❯ aws stepfunctions start-execution \
    --state-machine-arn arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:stateMachine:sfn-iam-user-create-iam-user \
    --input '{"email":"taro.yamada@example.com","consoleAccess":true,"accessKey":true,"groups":["sfn-iam-user-readonly"]}'

実行結果の出力はこちらです。

{
    "userName": "taro.yamada",
    "groups": [
        "sfn-iam-user-base",
        "sfn-iam-user-readonly"
    ],
    "consoleAccess": true,
    "accessKey": true,
    "credentialsS3Uri": "s3://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX/taro.yamada/taro.yamada_credentials.json"
}

無事に成功しました!
ほんとにできたんでしょうか・・・?IAM コンソールも確認してみましょう!

ユーザーが作成され、タグ・グループ・コンソールアクセス・アクセスキーがすべて揃っていますね!

S3 の方も見てみます。

❯ aws s3 cp s3://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX/taro.yamada/taro.yamada_credentials.json -
{"userName":"taro.yamada","password":"********************","accessKeyId":"AKIA****************","secretAccessKey":"****************************************","consoleLoginLink":"https://XXXXXXXXXXXX.signin.aws.amazon.com/console"}

認証情報ファイルも正しい JSON で出力されています!(認証情報はマスクしています)

パターン 2:コンソールアクセスのみ

❯ aws stepfunctions start-execution ... \
    --input '{"email":"hanako.suzuki@example.com","consoleAccess":true,"accessKey":false}'
{
    "userName": "hanako.suzuki",
    "groups": [
        "sfn-iam-user-base"
    ],
    "consoleAccess": true,
    "accessKey": false,
    "credentialsS3Uri": "s3://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX/hanako.suzuki/hanako.suzuki_credentials.json"
}

CreateAccessKey がスキップされ、groups を省略したので所属は MFA 強制グループだけになりました。ファイルの accessKeyIdsecretAccessKey は空文字です。

~ $ aws s3 cp s3://otake-test-sfn-iam-user-csv-XXXX/hanako.suzuki/hanako.suzuki_credentials.json -
{"userName":"hanako.suzuki","password":"XXXX","accessKeyId":"","secretAccessKey":"","consoleLoginLink":"https://XXXX.signin.aws.amazon.com/console"}~ $ 

パターン 3:アクセスキーのみ + ReadOnly グループ

❯ aws stepfunctions start-execution ... \
    --input '{"email":"batch.user@example.com","consoleAccess":false,"accessKey":true,"groups":["sfn-iam-user-readonly"]}'
{
    "userName": "batch.user",
    "groups": [
        "sfn-iam-user-base",
        "sfn-iam-user-readonly"
    ],
    "consoleAccess": false,
    "accessKey": true,
    "credentialsS3Uri": "s3://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX/batch.user/batch.user_credentials.json"
}

今度は GetRandomPasswordCreateLoginProfile がスキップされ、password が空文字のファイルになりました。
Choice の分岐が意図通りに動いていますね!

~ $ aws s3 cp s3://otake-test-sfn-iam-user-csv-XXXX/batch.user/batch.user_credentials.json -
{"userName":"batch.user","password":"","accessKeyId":"AKIAXXXX","secretAccessKey":"XXXX","consoleLoginLink":"https://XXXX.signin.aws.amazon.com/console"}~ $ 

パターン 4:メールアドレスだけ渡す

❯ aws stepfunctions start-execution ... --input '{"email":"minimal.user@example.com"}'
{"userName":"minimal.user","groups":["sfn-iam-user-base"],"consoleAccess":false,"accessKey":false,"credentialsS3Uri":"s3://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX/minimal.user/minimal.user_credentials.json"}

フラグを全部省略しても $exists() の効果で false 扱いになり、「ユーザーを作って MFA 強制グループに入れるだけ」で正常終了しました。

~ $ aws s3 cp s3://otake-test-sfn-iam-user-csv-XXXX/minimal.user/minimal.user_credentials.json -
{"userName":"minimal.user","password":"","accessKeyId":"","secretAccessKey":"","consoleLoginLink":"https://XXXX.signin.aws.amazon.com/console"}~ $ 

おまけ 1:同じメールアドレスでもう一度実行すると?

{
    "error": "UserAlreadyExists",
    "cause": "同名の IAM ユーザーが既に存在します。メールアドレスを確認してください。"
}

Iam.EntityAlreadyExistsException が Catch されて、UserAlreadyExists で止まりました。二重作成を防げていますね!

おまけ 2:存在しないグループを指定すると?

{
    "error": "GroupNotFound",
    "cause": "指定されたグループが存在しません。ユーザーは作成済みのため、グループ名を確認して手動で追加するかユーザーを削除してください。"
}

Map の中で起きた Iam.NoSuchEntityException が Map ステートの Catch で拾えています。
ただし、ユーザー作成は済んでいるので「ユーザーだけ残る」状態になります。Cause にその旨を書いておくと親切ですね。

おまけ 3:許可外のグループ(権限昇格を試す)

groups に、実行ロールで許可していない not-allowed-group を渡してみます。

{
    "error": "Iam.IamException",
    "cause": "User: arn:aws:sts::XXXXXXXXXXXX:assumed-role/sfn-iam-user-sfn-role/... is not authorized to perform: iam:AddUserToGroup on resource: group not-allowed-group because no identity-based policy allows the iam:AddUserToGroup action. ..."
}

設計のポイントで書いたとおり、実行ロールの Resource 絞り込みが効いて IAM 側で拒否されました!
ステートマシンの入力でどんなグループ名を渡されても、プレフィックスに合わないグループには追加できません。

認証情報ファイルをお客様に渡す

ステートマシンの出力にある S3 URI に対して、aws s3 presign で署名付き URL を発行します。

❯ aws s3 presign s3://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX/taro.yamada/taro.yamada_credentials.json --expires-in 3600
https://otake-test-sfn-iam-user-csv-XXXXXXXXXXXX.s3.ap-northeast-1.amazonaws.com/taro.yamada/taro.yamada_credentials.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=****&X-Amz-Date=****&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Security-Token=****&X-Amz-Signature=****

この URL をブラウザで開くと、ファイルがダウンロードできます。

--expires-in は秒指定で、AWS CLI の場合は最大 7 日(604800 秒)まで設定できます。
今回は 1 時間にしています。認証情報が入っている以上、有効期限は必要最小限 にしたいところです。

Sharing objects with presigned URLs - Amazon Simple Storage Service
Describes how to set up your objects so that you can share them with others by creating a presigned URL to download the objects.

「Step Functions 内で DL できるのか?」への答え

設計のポイントでも触れましたが、改めて整理しておきます。

  • 署名付き URL の発行: Step Functions 単体では不可。API ではなく SDK のローカル署名計算のため、SDK 統合の対象外
  • ファイルの中身を実行出力に含める: 技術的には可能だが、実行履歴に認証情報が残るため非推奨(ハマりポイント参照)
  • 現実的な落としどころ: Step Functions は S3 に置くところまで。受け渡しは AWS CLI の presignaws s3 cp で作業者が行う

ハマりポイント

CSV のつもりが JSON 文字列に!(putObject の Body は JSON シリアライズされる)

今回一番時間を使ったところです。。。

最初は「マネコンでダウンロードできる CSV と同じ形式にしよう」と、JSONata の & でヘッダー行と値の行を連結した文字列を Body に渡していました。
ところが S3 に置かれたファイルを見ると 1 行 になっていて、ヘッダーの末尾に \n という文字がそのまま入っています。

「JSON の中で \ をエスケープしないと」と思って ASL に \\n と書いていたのが原因だろう、と TestState API で試してみると、確かに \\n はリテラルの \n になり、\n と 1 つ書けば実際の改行になることが分かりました。
なるほど!と直して再実行したところ・・・まだ 1 行のままでした。。。

ここでファイルのバイト数を数えてみると、ヘッダー 69 文字 + 値の行 87 文字 + \n の 2 文字 = 158 バイトのはずが、160 バイト
2 バイト多い。つまり、前後にダブルクォートが付いている。。。!

ということで、Step Functions の s3:putObjectBody の値を JSON としてシリアライズして書き込む というのが真相でした。
文字列を渡すと "..." で囲まれ、改行は \n に、ダブルクォートは \" にエスケープされた「JSON 文字列」がファイルの中身になります。
逆に JSON オブジェクトを渡せば、そのまま正しい JSON ファイルになります。

文字列 Body で素の CSV やテキストを書きたい場合は、Step Functions 単体ではどうにもならず Lambda が必要になります。
今回は「Lambda を書かない」縛りなので、成果物を JSON に変更しました。
最初からファイルの中身を JSON にしていれば何もハマらなかったのですが、CSV にこだわったおかげで面白い挙動を知ることができました!

ちなみに TestState API は、ステートマシンをデプロイせずに 1 ステートだけ実行できるので、JSONata の式の挙動を確かめるのにかなり便利です!

❯ aws stepfunctions test-state \
    --definition '{"Type":"Pass","QueryLanguage":"JSONata","Output":{"v":"{% '"'"'a\nb'"'"' %}"},"End":true}' \
    --role-arn arn:aws:iam::XXXXXXXXXXXX:role/sfn-iam-user-sfn-role \
    --input '{}'

実行履歴にパスワードとシークレットキーが丸見え

これが今回一番お伝えしたい注意点です。

Step Functions(Standard)の実行履歴には、各 Task の入力と出力がそのまま記録されます。
実際に実行履歴を確認したところ、下記のイベントに認証情報が含まれていました。

  • GetRandomPasswordTaskSucceeded(API の戻り値にパスワード)
  • CreateLoginProfileTaskScheduled(API のパラメータにパスワード)
  • CreateAccessKeyTaskSucceeded(API の戻り値にシークレットアクセスキー)
  • PutCredentialsToS3TaskScheduled(パラメータに 認証情報ファイルの中身がまるごと

例:

さらに、Output を指定していない Task は API の結果がそのまま次のステートの入力になる ので、TaskStateExited / TaskStateEntered イベントにも認証情報が広がっていました。
GetRandomPasswordCreateAccessKey"Output": {} を付けたのはこのためで、これで引き回し分は消せます。
ただし、TaskSucceeded / TaskScheduled 自体には残るので、根本対策にはなりません。

Standard ワークフローの実行履歴は 90 日間保持されるため、実行履歴を閲覧できる人は誰でも認証情報を見られる状態になります。
対策としては、下記のような組み合わせが考えられます。

  • states:DescribeExecution / states:GetExecutionHistory を許可する IAM プリンシパルを厳密に絞る
  • CloudWatch Logs へのログ出力を有効にする場合は「実行データを含める」を オフ にする
  • 秘匿情報を扱う部分(パスワード生成〜S3 保管)だけ Lambda に寄せ、Lambda からは S3 URI だけを返すようにする
  • そもそもファイルを作らず、Secrets Manager にシークレットとして格納し、お客様側にはシークレットの読み取り権限だけを渡す

「Lambda 不要」を掲げて始めた今回の検証ですが、本番で使うなら 秘匿情報を触る部分だけは Lambda に閉じ込めるのが素直 だと感じました。
Step Functions だけで完結させることと、認証情報を履歴に残さないことは、残念ながら両立が難しいです。

AccessDenied は専用のエラー名にならない

許可外グループのテストで分かったのですが、IAM の AccessDenied は Step Functions 上では Iam.AccessDeniedException ではなく Iam.IamException という汎用的なエラー名で返ってきます。
Iam.EntityAlreadyExistsExceptionIam.NoSuchEntityException のように個別に Catch できるものと、Iam.IamException にまとめられるものがあるので、Catch を書くときは実際のエラー名を一度確認してから書くのがおすすめです。

SDK 統合の IAM ポリシーは自動生成されない

Workflow Studio で Lambda や SNS などの「最適化された統合」を使うと実行ロールのポリシーを自動生成してくれますが、SDK 統合(aws-sdk: で始まる Resource)は対象外です。
初回実行で AccessDenied が出たら、まず実行ロールを疑いましょう。
iam:CreateUser でタグを付けるときの iam:TagUser は特に忘れがちです!

JSONata の Assign と Output は「並行」に評価される

公式ドキュメントに明記されているのですが、AssignOutput は同時に評価されるため、同じステートの Assign で代入した変数は、そのステートの Output からは参照できません。
今回の PutCredentialsToS3Output$bucket$userName を使えているのは、それらが前のステートで代入済みだからです。
「Assign で変換した値を Output でも使いたい」場合は、Output 側でも同じ式を書き直す必要があります。

署名付き URL の有効期限は認証情報の寿命に縛られる

--expires-in に 7 日を指定しても、署名に使った認証情報が一時的なもの(AssumeRole や IAM Identity Center のセッション)だと、その認証情報が失効した時点で URL も無効 になります。
URL の中に X-Amz-Security-Token が入っているのが一時認証情報のサインで、失効後にアクセスすると S3 から 400 ExpiredToken が返ります(検証中に何度も踏みました。。。)。
「1 日有効な URL を渡したのにお客様から開けないと言われた」という事態になりがちなので、有効期限を長めにしたい場合は認証情報の種類に注意が必要です。
とはいえ認証情報の入ったファイルなので、短い有効期限で渡してすぐ削除、が基本だと思います!

そのほかの小ネタ

  • CreateAccessKey に安易な Retry を付けない: 「API は成功したがタイムアウトで失敗扱い」のケースで再試行されるとアクセスキーが 2 本できます(上限も 2 本です)。今回は Retry を付けず、失敗したら手で確認する方針にしています
  • IAM ユーザー名は大文字小文字を区別しない: Taro.Yamada@...taro.yamada@... は同じユーザー名扱いです。必要なら $lowercase() で統一を。また上限は 64 文字なので、長いメールアドレスでは長さチェックも入れておくと安心です
  • MFA 強制グループはアクセスキー専用ユーザーにも効く: CLI 利用者は sts get-session-token で MFA 付きの一時認証情報を取る運用になります。機械用ユーザーに MFA 強制が不都合な場合は、基本グループの扱いを見直してください

本番で使うなら

検証としては一通り動きましたが、お客様環境で運用するなら下記も検討したいところです。

  • ステートマシンの実行権限を絞る: このステートマシンを実行できる = IAM ユーザーとアクセスキーを作れる、なので states:StartExecution の許可は最小限に
  • 実行履歴の閲覧権限を絞る: 前述のとおり認証情報が残るため
  • ファイルの即時削除: お客様のダウンロード確認後に aws s3 rm する運用を手順化する。ライフサイクルはあくまで保険
  • CloudTrail での証跡: CreateUser AddUserToGroup CreateAccessKey GetRandomPassword はすべて CloudTrail に記録されるので、払い出しの監査はしやすいです
  • パスワードと URL は別経路で: 署名付き URL をメールで送るなら、パスワードはファイルに含めず別の経路で伝える、といった分離も検討する
  • IAM Identity Center への移行提案: そもそも IAM ユーザーを払い出さなくて済むなら、それが一番安全です

Security best practices in IAM - AWS Identity and Access Management
Follow these best practices for using AWS Identity and Access Management (IAM) to help secure your AWS account and resources.

まとめ

今回は、AWS Step Functions の SDK 統合と JSONata だけで、メールアドレスから IAM ユーザーを作成し、グループ追加・認証情報ファイルの S3 保管までを自動化してみました!

今回の検証から、下記のことがわかりました。

  • SDK 統合と JSONata を組み合わせれば、文字列の分割・条件分岐・配列のループは Lambda なしで十分こなせる
  • ランダムパスワードの生成は Secrets Manager の GetRandomPassword を API として叩けば解決できる
  • 入力値による権限昇格は、ASL でバリデーションするより実行ロールの Resource で物理的に封じるのが堅い
  • s3:putObject の Body は JSON としてシリアライズされる ので、Step Functions 単体で書けるファイルは実質 JSON だけ。CSV やテキストが必要なら Lambda が要る
  • 署名付き URL は API ではないため Step Functions からは発行できず、AWS CLI の presign に任せるのが現実的
  • Step Functions の実行履歴には Task の入出力がそのまま残る ため、認証情報を扱うなら閲覧権限の制御か、秘匿部分の Lambda 化が必要

「Lambda 不要」は達成できましたが、本番投入するなら「認証情報を触る部分だけは Lambda に閉じ込める」のが落としどころになりそうです。
検証のつもりで始めたのに、最後はセキュリティ設計の話になってしまいました(笑)

ここまで読んでいただき、ありがとうございました!
この記事がどなたかの参考になりますと幸いです!