こんにちは。X(クロス) イノベーション 本部 ソフトウェアデザインセンター セキュリティグループの耿です。 AWS アカウントのセキュリティを向上させるために、IAMユーザー作成のイベントに即時反応して自動削除する仕組みを作りました。それに際し、マネジメントコンソールからIAMユーザーを作成・削除した時に裏側で起こっていることも調べてみました。 IAMユーザーをなるべく減らしたい理由 運用ルールだけではなく仕組みを 構成図 ユーザーを削除するために必要なこと コンソールからIAMユーザーを作成するときに起こっていること CreateUser (A)CreateLoginProfile (B)CreateAccessKey (G)PutUserPolicy (H)AttachUserPolicy (I)AddUserToGroup コンソールからIAMユーザーを削除するときに起こっていること (A)DeleteLoginProfile (B)ListAccessKeys > DeleteAccessKey (C)ListSigningCertificates > DeleteSigningCertificate (D)ListSSHPublicKeys > DeleteSSHPublicKey (E)ListServiceSpecificCredentials > DeleteServiceSpecificCredential (F)ListMFADevices > DeactivateMFADevice > DeleteVirtualMFADevice (G)ListUserPolicies > DeleteUserPolicy (H)ListAttachedUserPolicies > DetachUserPolicy (I)ListGroupsForUser > RemoveUserFromGroup DeleteUser 今回の仕組みでカバーする範囲 CDKテンプレート Lambda関数 パッケージからのインポートとIAMClientの生成 (A)ログインプロファイルの削除 (B)アクセスキーの削除 (G)インラインポリシーの削除 (H)管理ポリシーのデタッチ (I)IAMグループへの所属解除 Lambdaハンドラ さいごに IAMユーザーをなるべく減らしたい理由 IAMユーザーはログ インパス ワードやアクセスキーなどを利用して AWS リソースにアクセスできますが、いずれも長期的な認証情報です。有効期限が存在しないので退職者管理が必要となり、漏えいした際の影響も大きいため、 AWS アカウントに存在するIAMユーザーをなるべく減らすのが良いでしょう。IAMユーザーを使わずに AWS リソースにアクセスする方法には、例えば以下のようなものがあります。 AWS リソースから別の AWS リソースにアクセスする場合、短期的な認証情報を発行するIAMロールを割り当てる マネジメントコンソールにアクセスしたい場合、 SAML 2.0やIDフェデレーションでIAMロールを引き受けてシングルサインオンする 外部サービスから AWS リソースにアクセスする場合、サポートされていればOIDCなどでIAMロールを引き受ける(例: GitHubの場合 ) 開発中に一時的にアクセスキーを使いたいなど、IAMユーザーが欲しいケースもありますが、むやみやたらには増やさない方が良いでしょう。 運用ルールだけではなく仕組みを 決まったIAMユーザー以外は作成しないことを運用ルールとして定めている場合も多いと思いますが、ルールはあくまでもルール。あらゆる状況において徹底を保証するものではありません。新任者にルールを漏れなく周知することも課題になります。また何らかのルートで悪意ある第 三者 にアカウントに侵入されてしまった場合、 バックドア としてIAMユーザーを作られることもあります。運用ルールだけではなく、実際のリソースとしてIAMユーザーが作成されたときに仕組みで対応できると安心でしょう。今回は強行策として、ユーザーの作成を検知してほぼリアルタイムで自動削除を行う仕組みを作りました。 構成図 ユーザーの削除はEventBridgeのイベントをトリガーにLambda関数が行います。IAMイベントは直接EventBridgeイベントを生成しないため、 CloudTrailのAPIコールでイベントルールが発火 するようにします。IAMの API コールは us-east-1 リージョンに記録されるため、EventBridgeルールやLambda関数も us-east-1 リージョンに作ります。ユーザー作成イベントドリブンのため、既存のユーザーには影響がありません。 この記事の後半に、この仕組みのCDKコードとLambda関数の実装を掲載しています。 ユーザーを削除するために必要なこと IAMユーザーを削除するには DeleteUser API を使うだけで良さそうだと思っていましたが、実はそれほど単純ではありませんでした。 API のリンク先の説明にある通り、 DeleteUser の前にそのIAMユーザーに付属する以下のアイテムを全て削除する 必要があります。それぞれの説明と削除するための API 名を箇条書きします。また便宜上、(A)~(I)の記号を付け、この記事を通して同じ記号を使うようにしています。 (A)ログインプロファイル ( DeleteLoginProfile ) マネジメントコンソールにログインするためのパスワード。1ユーザーにつき1つのみ作成できる (B)アクセスキー ( DeleteAccessKey ) アクセスキーIDとシークレットアクセスキーのペア。1ユーザーあたり2つまで発行できる (C)署 名証 明書 ( DeleteSigningCertificate ) SOAP アクセスなどで使用するX.509証明書。1ユーザーあたり複数登録できる (D) SSH 公開鍵 ( DeleteSSHPublicKey ) CodeCommit 用の SSH 公開鍵。1ユーザーあたり複数登録できる (E)各サービス用のクレデンシャル ( DeleteServiceSpecificCredential ) CodeCommitや Amazon Keyspaces用のクレデンシャル。1ユーザーあたり複数発行できる (F)MFAデ バイス ( DeactivateMFADevice DeleteVirtualMFADevice ) サインイン時に使用するMFAデ バイス 。 DeactivateMFADevice で無効化してから DeleteVirtualMFADevice で削除する必要がある (G)インラインポリシー ( DeleteUserPolicy ) ユーザーに追加されているインラインポリシー。1ユーザーあたり複数作成できる (H)アタッチされている管理ポリシー ( DetachUserPolicy ) アタッチされている管理ポリシー。1ユーザーあたり複数アタッチできる (I)IAMグループへの所属 ( RemoveUserFromGroup ) 1ユーザーあたり複数のグループに所属できる なんと9種類もあります。(C)(D)(E)あたりは使われるケースが少ないと思いますが、他はいずれも馴染み深いものですね。先にこれらのアイテムを削除しないと、 DeleteUser の呼び出しは失敗してしまいます。 コンソールからIAMユーザーを作成するときに起こっていること IAMユーザーと9つのアイテムとの関連付けについての理解を深めるために、マネジメントコンソールからIAMユーザーを作成する時に、裏側でどのような API が呼ばれているのかを掘り下げてみます。コンソールでユーザーを作る時にはいろいろなオプションがありますが、us-east-1 リージョンの CloudTrail ログより実際に呼び出された API をそれぞれ調べました。参照系の API も多く呼ばれていますが、更新系 API のみに着目します。 CreateUser 最初に必ず CreateUser API が呼ばれます。コンソールからユーザーを作成するときはパスワードかアクセスキーのどちらかを同時に作らなければなりませんが、実はこの API 単体ではいずれも生成されません。この時点ではコンソールアクセスもプログラムアクセスもできないユーザー本体のみができあがります。 これ以降の API コールはコンソールで選択した内容によって変わります。いずれも独立したリソース作成のため、実行順は重要ではありません。 (A)CreateLoginProfile 認証情報として「パスワード - AWS マネジメントコンソールへのアクセス」を選択しているとこの API が呼ばれ、パスワードが設定されます。 (B)CreateAccessKey 認証情報として「アクセスキー - プログラムによるアクセス」を選択しているとこの API が呼ばれ、アクセスキーが発行されます。 (G)PutUserPolicy アクセス許可の設定で「アクセス権限を既存のユーザーからコピー」でコピー元のユーザーにインラインポリシーがあるとこの API が呼ばれ、同じインラインポリシーが追加されます。複数のポリシーを追加可能です。 (H)AttachUserPolicy アクセス許可の設定で「既存のポリシーを直接アタッチ」する、もしくは「アクセス権限を既存のユーザーからコピー」でコピー元のユーザーに管理ポリシーがアタッチされているとこの API が呼ばれ、ユーザーに管理ポリシーがアタッチされます。複数のポリシーをアタッチ可能です。 (I)AddUserToGroup アクセス許可の設定で「ユーザーをグループに追加」する、もしくは「アクセス権限を既存のユーザーからコピー」でコピー元のユーザーがIAMグループに所属しているとこの API が呼ばれ、ユーザーがグループに所属します。複数のグループに所属可能です。 今のところ、「(C)署 名証 明書」「(D) SSH 公開鍵」「(E)各サービス用のクレデンシャル」「(F)MFAデ バイス 」はコンソールからのユーザー作成時に同時には作成できませんでした。 コンソールからIAMユーザーを削除するときに起こっていること 次に、(A)~(I)を全て追加したもりもりのユーザーを作成しておき、コンソールから削除した時にどのような API が裏側で呼ばれているのかを見てみました。更新系 API と、それに関連する参照系 API について記載します。 (A)DeleteLoginProfile パスワードが削除されます。 (B)ListAccessKeys > DeleteAccessKey まずは ListAccessKeys で削除対象ユーザーのアクセスキー一覧を取得し、見つかればそれぞれについて DeleteAccessKey で削除されます。 (C)ListSigningCertificates > DeleteSigningCertificate ListSigningCertificates で証明書一覧を取得し、見つかればそれぞれについて DeleteSigningCertificate で削除されます。 (D)ListSSHPublicKeys > DeleteSSHPublicKey ListSSHPublicKeys で鍵一覧を取得し、見つかればそれぞれについて DeleteSSHPublicKey で削除されます。 (E)ListServiceSpecificCredentials > DeleteServiceSpecificCredential ListServiceSpecificCredentials でクレデンシャル一覧を取得し、見つかればそれぞれについて DeleteServiceSpecificCredential で削除されます。 (F)ListMFADevices > DeactivateMFADevice > DeleteVirtualMFADevice ListMFADevices でデ バイス 一覧を取得し、見つかればそれぞれについて DeactivateMFADevice で無効化したのち、 DeleteVirtualMFADevice で削除されます。 (G)ListUserPolicies > DeleteUserPolicy ListUserPolicies でインラインポリシー一覧を取得し、見つかればそれぞれについて DeleteUserPolicy で削除されます。 (H)ListAttachedUserPolicies > DetachUserPolicy ListAttachedUserPolicies でアタッチされている管理ポリシー一覧を取得し、見つかればそれぞれについて DetachUserPolicy でデタッチされます。管理ポリシー自体は削除されません。 (I)ListGroupsForUser > RemoveUserFromGroup ListGroupsForUser で所属しているIAMグループ一覧を取得し、見つかればそれぞれについて RemoveUserFromGroup で所属が解除されます。IAMグループ自体は削除されません。 DeleteUser 以上のアイテムが全て削除されると、最後に DeleteUser が呼ばれてユーザー本体が晴れて(?)削除されます。画面では数クリックで終わる操作ですが、実は多くの API が呼ばれていることがわかりますね。 今回の仕組みでカバーする範囲 ユーザー削除時の API コールを参考にして、IAMユーザーを自動削除するための仕組みを作ります。以下ではCDKとLambda関数のコードを解説しますが、今回はIAMユーザー本体の削除に先立って、以下の5つのアイテムの削除のみを行います。 (A)ログインプロファイル (B)アクセスキー (G)インラインポリシー (H)アタッチされている管理ポリシー (I)IAMグループへの所属 (C)(D)(E)(F)についてはコンソールでユーザーを作成する時に同時に作成できないため、今回は省略しています。コンソールでも CLI でも、これらのアイテムを作成するためにはユーザー作成とは別の操作が必要になりますので、その操作が実施される前にEventBridgeイベントが発火し、ユーザーが削除されることが期待できます。もちろん、 CLI などから CreateUser 実行後、EventBridgeイベントが発火する前に(C)(D)(E)(F)を作成する API が素早く実行されるとユーザーの削除に失敗しますので、より確実にユーザーを削除したい場合は(C)(D)(E)(F)の削除も対応した方が良いでしょう。 CDKテンプレート IAMユーザーの作成を検知し、自動削除する仕組みのCDKスタックは次のとおりです。 EventBridgeルールは detailType を AWS API Call via CloudTrail 、 eventName を CreateUser にすることで、ユーザー作成 API の呼び出しがCloudTrailに記録されるとイベントが起動します。 前述の通り、IAMの更新系 API は us-east-1 リージョンのCloudTrailに記録されるので、このスタックも us-east-1 リージョンにデプロイする必要があります。 import { Duration , Stack , StackProps } from "aws-cdk-lib" ; import * as events from "aws-cdk-lib/aws-events" ; import * as eventTargets from "aws-cdk-lib/aws-events-targets" ; import * as iam from "aws-cdk-lib/aws-iam" ; import { Runtime } from "aws-cdk-lib/aws-lambda" ; import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs" ; import { Construct } from "constructs" ; export class DeleteUserStack extends Stack { constructor( scope: Construct , id: string , props?: StackProps ) { super( scope , id , props ); // Lambda関数 const deleteUserFunction = new lambdaNodejs.NodejsFunction ( this , "DeleteUserFunction" , { entry: "functions/delete-user.ts" , runtime: Runtime.NODEJS_16_X , timeout: Duration.minutes ( 15 ), } ); // 必要なActionを追加 deleteUserFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ "*" ] , actions: [ "iam:DeleteUser" , "iam:ListAccessKeys" , "iam:DeleteAccessKey" , "iam:ListGroupsForUser" , "iam:RemoveUserFromGroup" , "iam:GetLoginProfile" , "iam:DeleteLoginProfile" , "iam:ListAttachedUserPolicies" , "iam:DetachUserPolicy" , "iam:ListUserPolicies" , "iam:DeleteUserPolicy" , ] , effect: iam.Effect.ALLOW , } ) ); // EventBrdigeルール new events.Rule ( this , "CreateUserEventRule" , { ruleName: "create-user" , eventPattern: { source: [ "aws.iam" ] , detailType: [ "AWS API Call via CloudTrail" ] , detail: { eventSource: [ "iam.amazonaws.com" ] , eventName: [ "CreateUser" ] , } , } , targets: [ new eventTargets.LambdaFunction ( deleteUserFunction ) ] , } ); } } Lambda関数 functions/delete-user.ts のLambda関数の実装を説明します。 パッケージからのインポートとIAMClientの生成 IAM用の SDK をインポートします。Lambda関数が受け取るのはEventBridgeイベントなので、 aws-lambda パッケージから EventBridgeEvent もインポートします。 IAMClientは us-east-1 リージョン向けに生成します。 import * as iam from "@aws-sdk/client-iam" ; import { Handler , EventBridgeEvent } from "aws-lambda" ; const client = new iam.IAMClient ( { region: "us-east-1" } ); (A)ログインプロファイルの削除 ログインプロファイルを削除する関数です。最初に GetLoginProfileCommand でログインプロファイルが存在するかどうかを確認し、存在すれば while ループで削除を試みます。while ループにしている理由は、プロファイルの作成には時間がかかるようで、 CreateLoginProfile コール直後に DeleteLoginProfile をしても次のエラーになってしまうためです。 Login Profile for User <ユーザー名> cannot be modified while login profile is being created. そこで while ループで間に5秒間ずつ待機し、最大30回削除を試みる実装にしました。実際にやってみると、体感的にログインプロファイル作成後15秒ほどで削除できる状態になっていました。 export const deleteLoginProfile = async ( userName: string ) : Promise < void > => { try { const { LoginProfile } = await client.send (new iam.GetLoginProfileCommand ( { UserName: userName } )); if ( LoginProfile === undefined || LoginProfile.UserName === undefined ) return; } catch ( e: unknown ) { console .log ( ` ${ e instanceof Error ? e.message : "" } ` ); return; } let attempts = 30 ; while ( attempts > 0 ) { try { await client.send (new iam.DeleteLoginProfileCommand ( { UserName: userName } )); break; } catch ( e: unknown ) { console .log ( ` ${ e instanceof Error ? e.message : "" } ` ); } await new Promise (( resolve ) => setTimeout ( resolve , 5000 )); attempts --; } } ; (B)アクセスキーの削除 次の関数でアクセスキーを削除します。まずは ListAccessKeysCommand でアクセスキー一覧を取得します。アクセスキーの件数が多い場合(実際には各ユーザー2つまでなので起こらないと思いますが)、一回の ListAccessKeysCommand 呼び出しで全件取得できず、結果の IsTruncated プロパティが true で返ってきます。この時の Marker プロパティを次の ListAccessKeysCommand 呼び出しのパラメータに含めることで、続きの結果を取得できます。一覧取得後は DeleteAccessKeyCommand で1つずつ削除します。 export const deleteAccessKeys = async ( userName: string ) : Promise < void > => { let accessKeyIds: string [] = [] ; let shouldListNext = true ; let Marker: string | undefined = undefined ; while ( shouldListNext ) { const params: iam.ListAccessKeysCommandInput = { UserName: userName , Marker } ; const output = await client.send (new iam.ListAccessKeysCommand ( params )); if ( output.AccessKeyMetadata ) { accessKeyIds = accessKeyIds.concat ( output.AccessKeyMetadata.filter (( accessKeyMetadata ) => accessKeyMetadata.AccessKeyId ) .map ( ( accessKeyMetadata ) => accessKeyMetadata.AccessKeyId ! ) ); } output.IsTruncated ? ( Marker = output.Marker ) : ( shouldListNext = false ); } if ( accessKeyIds.length === 0 ) return; await Promise . all( accessKeyIds.map (async ( keyId ) => { await client.send (new iam.DeleteAccessKeyCommand ( { UserName: userName , AccessKeyId: keyId } )); } ) ); } ; (G)インラインポリシーの削除 (B)アクセスキーの削除 と同様の処理なので、説明は割愛します。 export const deleteUserPolicies = async ( userName: string ) : Promise < void > => { let policyNames: string [] = [] ; let shouldListNext = true ; let Marker: string | undefined = undefined ; while ( shouldListNext ) { const params: iam.ListUserPoliciesCommandInput = { UserName: userName , Marker } ; const output = await client.send (new iam.ListUserPoliciesCommand ( params )); if ( output.PolicyNames ) { policyNames = policyNames.concat ( output.PolicyNames.filter (( name ) => name )); } output.IsTruncated ? ( Marker = output.Marker ) : ( shouldListNext = false ); } if ( policyNames.length === 0 ) return; await Promise . all( policyNames.map (async ( name ) => { await client.send (new iam.DeleteUserPolicyCommand ( { UserName: userName , PolicyName: name } )); } ) ); } ; (H)管理ポリシーのデタッチ 同様の処理なので、説明は割愛します。 export const detachUserPolicies = async ( userName: string ) : Promise < void > => { let attachedPolicieArns: string [] = [] ; let shouldListNext = true ; let Marker: string | undefined = undefined ; while ( shouldListNext ) { const params: iam.ListAttachedUserPoliciesCommandInput = { UserName: userName , Marker } ; const output = await client.send (new iam.ListAttachedUserPoliciesCommand ( params )); if ( output.AttachedPolicies ) { attachedPolicieArns = attachedPolicieArns.concat ( output.AttachedPolicies.filter (( policy ) => policy.PolicyArn ) .map (( policy ) => policy.PolicyArn ! ) ); } output.IsTruncated ? ( Marker = output.Marker ) : ( shouldListNext = false ); } if ( attachedPolicieArns.length === 0 ) return; await Promise . all( attachedPolicieArns.map (async ( arn ) => { await client.send (new iam.DetachUserPolicyCommand ( { UserName: userName , PolicyArn: arn } )); } ) ); } ; (I)IAMグループへの所属解除 同様の処理なので、説明は割愛します。 export const removeUserFromGroups = async ( userName: string ) : Promise < void > => { let groupNames: string [] = [] ; let shouldListNext = true ; let Marker: string | undefined = undefined ; while ( shouldListNext ) { const params: iam.ListGroupsForUserCommandInput = { UserName: userName , Marker } ; const output = await client.send (new iam.ListGroupsForUserCommand ( params )); if ( output.Groups ) { groupNames = groupNames.concat ( output.Groups.filter (( group ) => group.GroupName ) .map (( group ) => group.GroupName ! )); } output.IsTruncated ? ( Marker = output.Marker ) : ( shouldListNext = false ); } if ( groupNames.length === 0 ) return; await Promise . all( groupNames.map (async ( groupName ) => { await client.send (new iam.RemoveUserFromGroupCommand ( { UserName: userName , GroupName: groupName } )); } ) ); } ; Lambdaハンドラ 作成されたIAMユーザー名をイベントの中身から取得するための型を定義し、上で説明した関数を利用してLambdaハンドラを実装します。各アイテムの削除後に DeleteUserCommand でユーザー本体を削除します。 type Detail = { responseElements: { user: { userName: string } } } ; export const handler: Handler = async ( event: EventBridgeEvent < string , Detail >) => { const userName = event.detail.responseElements.user.userName ; await Promise . all( [ deleteLoginProfile ( userName ), deleteAccessKeys ( userName ), deleteUserPolicies ( userName ), detachUserPolicies ( userName ), removeUserFromGroups ( userName ), ] ); await client.send (new iam.DeleteUserCommand ( { UserName: userName } )); } ; これで、IAMユーザーが作成されるイベントをトリガーに、即座にユーザーを削除する仕組みが完成しました。 さいごに 自前で自動化の仕組みを実装してみると、普段コンソールでしている操作の裏側でどのような API が動いているのかが分かり、とても面白かったです。 最後までお読みただいてありがとうございました。 私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。 - セキュリティエンジニア(セキュリティ設計) 執筆: @kou.kinyo2 、レビュー: @higa ( Shodo で執筆されました )