電通総研 テックブログ

電通総研が運営する技術ブログ

作成されたIAMユーザーを片っ端から自動削除する仕組みを作ってみた

こんにちは。X(クロス)イノベーション本部 ソフトウェアデザインセンター セキュリティグループの耿です。
AWSアカウントのセキュリティを向上させるために、IAMユーザー作成のイベントに即時反応して自動削除する仕組みを作りました。それに際し、マネジメントコンソールからIAMユーザーを作成・削除した時に裏側で起こっていることも調べてみました。

IAMユーザーをなるべく減らしたい理由

IAMユーザーはログインパスワードやアクセスキーなどを利用してAWSリソースにアクセスできますが、いずれも長期的な認証情報です。有効期限が存在しないので退職者管理が必要となり、漏えいした際の影響も大きいため、AWSアカウントに存在するIAMユーザーをなるべく減らすのが良いでしょう。IAMユーザーを使わずにAWSリソースにアクセスする方法には、例えば以下のようなものがあります。

開発中に一時的にアクセスキーを使いたいなど、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ユーザーを作成するときに起こっていること

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ルールは detailTypeAWS API Call via CloudTraileventNameCreateUser にすることで、ユーザー作成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、レビュー:@higaShodoで執筆されました