こんにちは。X(クロス)イノベーション本部 ソフトウェアデザインセンター セキュリティグループの耿です。
AWSアカウントのセキュリティを向上させるために、IAMユーザー作成のイベントに即時反応して自動削除する仕組みを作りました。それに際し、マネジメントコンソールからIAMユーザーを作成・削除した時に裏側で起こっていることも調べてみました。
- IAMユーザーをなるべく減らしたい理由
- 運用ルールだけではなく仕組みを
- 構成図
- ユーザーを削除するために必要なこと
- コンソールからIAMユーザーを作成するときに起こっていること
- コンソールから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関数
- さいごに
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
で削除する必要がある
- サインイン時に使用するMFAデバイス。
- (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で執筆されました)