🔐

IAMユーザーのMFA認証強制がウルトラCだった件 - IAMポリシー Deep Dive

2024/04/14に公開
2

AWSアカウントのIAMユーザー(人間のアクセスのみ)にMFA認証を強制させるのに悪戦苦闘したので、奮闘記と併せIAMポリシーのDeep Diveしていきます。SCPでサクッと実現できるんじゃないの?と思ってるそこのあなた。私もそう思ってましたよ!

CCoEやプラットフォームチーム所属でAWSセキュリティ周りを担当されている方はもちろん、AWSをより深く理解したい方にも有益な情報になればと思っています。

Problem

私が所属する企業のAWS環境はAWS Control Towerをベースにマルチアカウント構成をとっています。IAMでのセキュリティのペストプラクティスに則り、AWS IAM アイデンティティセンター (AWS Single Sign-On の後継)経由で社内のIdP(Microsoft Entra ID)とのSSO連携を実現しています。

SSO連携ユーザーはIdP側でMFA認証されるので問題ないのですが、例えば受託先パートナー企業のユーザーを全てIdPに登録するのも限界があり、一部にはIAMユーザーを直接発行する方針になっています。そのIAMで直接管理するユーザーにMFA認証を強制したいというのが本件の背景になります。

前置きが長くなりましたが、機能要件はAWSアカウントレベルで、IAMユーザーのアクセスにMFA認証を強制するになります。

要件をブレイクダウンし、IAMのプリンシパルを基にアクセスパターン毎のMFA認証強制の有無を定義します。

アクセスパターン MFA認証強制 説明
人間ユーザー 🔐 人間によるマネジメントコンソールへのアクセス。
人間ユーザーのアクセスキー 🔐 人間ユーザーがアクセスキーを発行し、プログラマティカルにアクセス。正確には、LoginProfileが設定されているIAMユーザーのアクセスキー経由のアクセス。
マシンユーザーのアクセスキー 例えばオンプレサーバーのバッチ処理からのアクセスなど人間を介さないアクセス。 LoginProfileが設定されていないIAMユーザーのアクセスキー経由のアクセス。
ワークロード(IAMロール) EC2にアタッチするAWSサービスロールなど、AWSサービスがユーザーに代わってするアクセス。
外部で認証されたユーザー 🔐 SSO連携で認証されたアクセス。これまで通りIdP側でのMFA認証が動作する必要がある。

Solution

マルチアカウント環境限定になりますが、AWS Organizationの機能であるSCP(Service Control Policy)で実現します。最終的なSCPはこちらです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceMFAOnHumanIAMUser",
      "Effect": "Deny",
      "NotAction": [
        "iam:CreateVirtualMFADevice",
         "iam:EnableMFADevice",
         "iam:ChangePassword",
         "iam:GetUser",
         "iam:GetLoginProfile",
         "iam:ListMFADevices",
         "iam:ListVirtualMFADevices",
         "iam:ResyncMFADevice",
         "iam:DeleteVirtualMFADevice",
         "sts:GetSessionToken",
         "ecr:BatchCheckLayerAvailability",
         "ecr:initiateLayerUpload",
         "ecr:UploadLayerPart",
         "ecr:CompleteLayerUpload",
         "ecr:PutImage",
         "ecr:BatchGetImage",
         "ecr:GetDownloadUrlForLayer",
         "codeartifact:PublishPackageVersion",
         "codeartifact:ReadFromRepository"
      ],
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:PrincipalArn": ["arn:aws:iam::*:user/*"]
        },
        "Bool": {
          "aws:MultiFactorAuthPresent": "false",
          "aws:ViaAWSService": "false"
        }
      }
    }
  ]
}

詳細は次章で説明しますが、ポイントだけ箇条書きします。

  • 人間ユーザーはMFA認証するまで、MFAデバイスの設定、パスワード変更以外のアクションは禁止
    • MFAデバイス、パスワードの変更までに辿るマネジメントコンソールの導線に必要なアクションも許可。
    • (MFAデバイス設定直後は再ログインをする必要あり。)
  • Amazon ECR、AWS CodeArtifactの一部アクションは例外ケースになるため、MFA認証の有無に関わらず許可。

ただし、人間ユーザーのーアクセスキーマシンユーザーのアクセスキーをSCP(IAMポリシー)内で区別が不可能だったため、双方MFA認証強制は”無し”となります。結果アクセスパターン毎のMFA認証強制はこの様になります。

アクセスパターン MFA認証強制 備考
人間ユーザー 🔐 一部アクションはMFA認証無しで可能
人間ユーザーのアクセスキー
マシンユーザーのアクセスキー
ワークロード(IAMロール)
外部で認証されたユーザー 🔐 IdP側でのMFA認証

代替案

人間ユーザーにMFA認証の強制はできたものの、漏洩のリスクが高い人間ユーザーのアクセスキーにはMFA認証を強制できないなんとも中途半端な状態になっています。加えて以下の懸念点があります。

  • ECR、CodeArtifactへのアクセスと類似の例外が今後も発生する可能性があり、AWSとのイタチゴッコ。
  • SIEM(Security Information and Event Management)などCloud Trailのログ分析ソリューションでノイズになる。

前者は次章で詳しく説明していきますが、後者のSIEMのノイズについても重要なポイントだと考えています。SIEMの有名どころでいうとSplunk、AWSでいうと、SIEM on Amazon OpenSearch ServiceCloudTrail Lakeがありますが、それぞれOOTBで利用できるCloudTrailの分析ダッシュボードが提供されており、AccessDeniedを検出するクエリとパネルが用意されています。

CloudTrail LakeのTop access denied eventsパネルのクエリを眺めてみます。条件はAccessDeniedUnauthorizedといった幅広なテキストマッチングのため、SCPで想定通りに拒否したアクションも対象になってきます。

SELECT
    concat(
        replace(eventSource, '.amazonaws.com'),
        ':',
        eventName
    ) as event,
    count(*) as eventCount
FROM
    <$TRAIL_ID>
WHERE
    eventTime > '2024-03-31T08:54:44.584Z'
    AND eventTime < '2024-04-01T08:54:44.584Z'
    AND (
        errorCode = 'AccessDenied'
        OR regexp_like(errorCode, 'Unauthorized')
    )
    AND eventCategory = 'Management'
GROUP BY
    eventSource,
    eventName
ORDER BY
    eventCount DESC
LIMIT
    100

初回ログインからMFAを設定するまでに辿るマネジメントコンソールの導線で、複数のAccessDeniedの発生(例えばIAMコンソールのIAM Dashboard経由で実行されるiam:GetAccountSummary)が多発する可能性があります。

諸々鑑みると、SCPによるMFA認証の強制は見送り、代わりにMFAデバイス未設定のIAMユーザーをリアルタイムで検出する仕組みを導入する方が筋がいいのではと思っています。AWS SecurityHubでは[IAM.5] コンソールパスワードを使用するすべての IAM ユーザーに対して MFA を有効にする必要がありますというデフォルトコントロールも用意されているため手軽に実現が可能です。

または、SCPではなく各AWSアカウントのIAMポリシーで対象スコープを絞ってMFA認証を強制するのも一つの選択肢かと思います。IAM チュートリアル: ユーザーに自分の認証情報および MFA 設定を許可するで説明があります。

Discussion

最終的に辿り着いたSCPを解剖しつつ、IAMアクセス、IAMポリシーのDeep Diveをしていきます。

前提としてSCPはAWS Organizationのデフォルトである拒否リスト戦略を取っています。AWS Control Towerでも拒否リスト戦略が採用されているため、カスタムSCPを構築する際はDeny(拒否)アクションを積み上げていくことになります。許可リスト戦略を採用できる環境であれば、違うポリシーでも実現できるかと思います。

IAMポリシーの評価論理

まずIAMポリシーの評価の仕組みを簡単に説明します。ポリシーの評価論理をざっくりまとめると以下の様になります。

  1. プリンシパル(IAMユーザーなど)"認証"する。
  2. "リクエストコンテキストを収集"し、適用するポリシーを決定する。
  3. ポリシーを評価し"認可"を実施する。

2番のリクエストコンテキストは以下の様に説明されています。

リクエストコンテキストの処理

AWS はリクエストを処理して、以下の情報をリクエストコンテキスト内に取り込みます。

  • アクション (またはオペレーション) – プリンシパルが実行するアクションまたはオペレーション。
  • リソース – アクションまたはオペレーションを実行する対象の AWS リソースオブジェクト。
  • プリンシパル – リクエストの送信元のユーザー、ロール、フェデレーティッドユーザー、またはアプリケーション。プリンシパルに関する情報には、そのプリンシパルに関連付けられたポリシーが含まれます。
  • 環境データ – IP アドレス、ユーザーエージェント、SSL 有効化ステータス、または時刻に関する情報。
  • リソースデータ – リクエストされているリソースに関連するデータ。これには、DynamoDB テーブル名、Amazon EC2 インスタンスのタグなどの情報が含まれる場合があります。
    次に、AWS は以上の情報を使用してリクエストコンテキストに適用するポリシーを見つけます。

実際に取集されたリクエストコンテキストの中身はCloudTrailイベントで確認できます。

以下のCloudTrail管理イベントは人間ユーザーがマネジメントコンソール経由でs3:ListBucketsアクションを実施した例です。例えば環境データのIPアドレスは、sourceIPAddressフィールドに格納されていることがわかります。

{
  "eventVersion": "1.09",
  "userIdentity": {
    "type": "IAMUser",
    "principalId": "*******",
    "arn": "arn:aws:iam::111111111111:user/kuritify",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "userName": "kuritify",
    "sessionContext": {
      "attributes": {
        "creationDate": "2024-03-22T08:17:38Z",
        "mfaAuthenticated": "false"
      }
    }
  },
  "eventTime": "2024-03-22T09:08:14Z",
  "eventSource": "s3.amazonaws.com",
  "eventName": "ListBuckets",
  "awsRegion": "ap-northeast-1",
  "sourceIPAddress": "**.**.**.**",
  "userAgent": "[S3Console/0.4, aws-internal/3 aws-sdk-java/1.12.488 Linux/5.10.210-178.855.amzn2int.x86_64 OpenJDK_64-Bit_Server_VM/25.372-b08 java/1.8.0_372 vendor/Oracle_Corporation cfg/retry-mode/standard]",
  "requestParameters": {
    "Host": "s3.ap-northeast-1.amazonaws.com"
  },
  "responseElements": null,
  "additionalEventData": {
    "SignatureVersion": "SigV4",
    "CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
    "bytesTransferredIn": 0,
    "AuthenticationMethod": "AuthHeader",
    "x-amz-id-2": "************",
    "bytesTransferredOut": 373
  },
  "requestID": "*****************",
  "eventID": "*****************",
  "readOnly": true,
  "eventType": "AwsApiCall",
  "managementEvent": true,
  "recipientAccountId": "111111111111",
  "vpcEndpointId": "vpce-****",
  "eventCategory": "Management",
  "tlsDetails": {
    "tlsVersion": "TLSv1.2",
    "cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
    "clientProvidedHostHeader": "s3.ap-northeast-1.amazonaws.com"
  }
}

このリクエストコンテキストにどのような条件(Condition)のSCP(IAMポリシー)を書けば要件を達成できるかを試行錯誤していく作業となります。最終SCPのCondition句を再掲しますが、このCondition句に辿り着くまでの検証内容を順に振り返っていきます。

"Condition": {
  "Bool": {
    "aws:MultiFactorAuthPresent": "false"
    "aws:ViaAWSService": "false",
  },
  "StringLike": {
    "aws:PrincipalArn": [
      "arn:aws:iam::*:user/*"
    ]
  },
}

人間ユーザーのアクセスキーとマシンユーザーのアクセスキーをーを区別する方法

AWSグローバル条件キーを熟読し調査を重ねましたが、アクセスキーが人間ユーザーのものか、マシンユーザーのものか区別することは不可能であることがわかりました。人間ユーザーと、マシンユーザーの差はIAMユーザーの設定値であるLoginProfileの有無でしか判断できず、リクエストコンテキストに含まれるデータではないためです。

この時点でアクセスキー経由のアクセスにはMFAを強制しない(できない)意思決定を行いました。これ以降は人間ユーザーのアクセス(マネジメントコンソールアクセス)へのMFA認証を強制する方法の探索となります。

MFA認証を強制する方法

次にMFA認証の強制方法を調査しました。MFA認証強制を実現する肝となるのがaws:MultiFactorAuthPresent条件キーで、リクエストコンテキストではuserIdentity.sessionContext.mfaAuthenticatedフィールドのBoolean値です。

MFA認証されたリクエストにはtrueが設定されるのが原則ですが、なかなかに複雑な振る舞いをします。

  • 一時的な認証情報を使用してリクエストを行う場合にのみリクエストコンテキストに含まれます。
    • 長期的な認証情報(アクセスキー経由のCLI、SDKのアクセス)でのアクセスはリクエストコンテキストに含まれませんが、人間ユーザーのId/Passwordで認証したマネジメントコンソールアクセスでは含まれます。マネジメントコンソールの裏側ではSTS経由で取得した一時的な認証情報でアクセスするためです。
    • 長期的な認証情報(アクセスキー経由のCLI、SDKのアクセス)であっても、例えばAssumeRoleしてからアクセスする場合は、一時的な認証情報になるためリクエストコンテキストに含まれてきます。
  • MFA認証済みのリクエストであっても、AWSサービスがサービスロール経由で別のAWSサービスを呼び出すケースでは、同サービスロール経由のアクセスはfalseでリクエストコンテキストに含まれます。
    • 例えばCloud Formationが、サービスロール経由でS3バケットを作成する場合、Cloud FormationにリクエストしたIAMユーザーのMFA認証の有無に関わらず、s3:CreateBucketアクションの aws:MultiFactorAuthPresentfalseでリクエストコンテキストに含まれます。
  • (ご想像の通り)例えばLambda経由の別リソースへのアクセスは、lambdaのサービスロール経由(一時的な認証情報)のため、aws:MultiFactorAuthPresentfalseでリクエストコンテキストに含まれます。

表形式でまとめると以下の様になります。

アクセスパターン aws:MultiFactorAuthPresent
MFA認証済みの人間ユーザー(※1) true
MFA認証済みのアクセスキー(※2) true
MFA認証済みの人間ユーザーがAssumeRole true
MFA認証済みのアクセスキーがAssumeRole true
MFA認証していない人間ユーザー(※1) false
MFA認証していないアクセスキー キーが含まれない
MFA認証していない人間ユーザーがAssumeRole false
MFA認証していないアクセスキーがAssumeRole false
AWSサービス経由(Cloud Formationのサービスロールなど) false
AWSサービスロールのアクセス(Lambdaのサービスロールなど) false
IdP側でMFA認証済みの外部認証ユーザー false
  • ※1: cloud shell経由も同じ振る舞い
  • ※2: $ aws sts get-session-token --serial-number <$ARN_OF_THE_MFA_DEVICE> --token-code <$CODE_FROM_TOKEN> ...

1点補足するとIdP側でMFA認証済みの外部認証ユーザーfalseになる点です。MFA認証はAWSの外で(IdP側で)行われるので当然といえば当然かもしれませんが、私個人が混乱したポイントだったため強調しておきます。

aws:MultiFactorAuthPresentにも例が記載されている様に、以下の条件で人間ユーザーにはMFA認証を強制、かつアクセスキーにはMFA認証の強制をしないが実現できます。

{
  "Condition": {
    "Bool": {
      "aws:MultiFactorAuthPresent": "false"
    }
  }
}

Bool条件演算子は、条件で指定したキーがリクエストコンテキストに存在しない場合、条件要素は false と評価されます。という振る舞いをするので、リクエストコンテキスにキーが含まれていないアクセスキーでのアクセス(唯一の長期的な認証情報でのアクセス)は必ずfalse評価になり、リクエストコンテキストにキーが含まれるそれ以外のアクセスは、aws:MultiFactorAuthPresentがtrueなのか、falseなのかが評価されます。

疑似コード風に説明すると。

if (
  // アクセスキーのアクセスではなく、人間ユーザーのアクセスでMFA認証がされていないアクセスである
  (multifactorauthpresentがリクエストコンテキストに含まれる AND multifactorauthpresentがfalse)
) {
  if (リクエストされたアクションがMFA認証に関わらず許可するアクションに含まれない) {
    拒否する
  }
}
許可する

人間ユーザーにMFA認証は強制できたものの、AssumeRole、サービスリンクロール、サービスロールのアクセスまでMFA認証を強制することになってしまうので、IAMロールのアクセスを対象外にする方法を検討していきます。

IAMロールのアクセスを対象外にする方法

IAMロールのアクセスを対象外にしたい。言い換えるなら、対象をIAMユーザーに限定したいので、aws:PrincipalArnグローバル条件キーを利用していきます。

このキーを使用して、リクエストを行ったプリンシパルの Amazon リソースネーム (ARN) をポリシーで指定した ARN と比較します。IAM ロールの場合、リクエストコンテキストは、ロールを引き受けたユーザーの ARN ではなく、ロールの ARN を返します。

IAMユーザーがAssumeRoleした場合、aws:PrincipalArnarn:aws:iam::111111111111:role/kuritify-roleになり、IAM Identity Centerで外部認証されたユーザーであればarn:aws:iam::111111111111:role/aws-reserved/sso.amazonaws.com/ap-northeast-1/AWSReservedSSO_yyyyyyyyyyyyyyyyyyyyyy_xxxxxxxxxxx"になります。

CloudTrail userIdentity 要素で詳しく説明がありますが、リクエストコンテキストではuserIdentity.sessionContext.sessionIssuer.arnが該当し、同フィールドが含まれない場合userIdentity.arnが該当するフィールドです。いくつかCloudTrail管理イベントの例をあげます。

MFA認証していないアクセスキーのリクエスト。

"userIdentity": {
    "type": "IAMUser",
    "principalId": "*******",
    "arn": "arn:aws:iam::111111111111:user/kuritify",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "userName": "kuritify"
}

MFA認証していないアクセスキーがAssume Roleした後のリクエスト。

{
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "*******",
    "arn": "arn:aws:sts::111111111111:assumed-role/kuritify-role/kuritify-assume-role",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "sessionContext": {
      "sessionIssuer": {
        "type": "Role",
        "principalId": "*******",
        "arn": "arn:aws:iam::111111111111:role/kuritify-role",
        "accountId": "111111111111",
        "userName": "kuritify-role"
      },
      "attributes": {
        "creationDate": "2024-03-22T08:54:54Z",
        "mfaAuthenticated": "false"
      }
    }
  }
}

IAM Identity Centerで外部認証されたユーザーのリクエスト。(IdP側でMFA認証済み)

{
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "*******",
    "arn": "arn:aws:sts::111111111111:assumed-role/AWSReservedSSO_AWSAdministratorAccess_xxxxxxxxxxx/kuritify@kuritify.com",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "sessionContext": {
      "sessionIssuer": {
        "type": "Role",
        "principalId": "*******",
        "arn": "arn:aws:iam::111111111111:role/aws-reserved/sso.amazonaws.com/ap-northeast-1/AWSReservedSSO_AWSAdministratorAccess_xxxxxxxxxxx",
        "accountId": "111111111111",
        "userName": "AWSReservedSSO_AWSAdministratorAccess_xxxxxxxxxxx"
      },
      "attributes": {
        "creationDate": "2024-04-04T02:35:12Z",
        "mfaAuthenticated": "false"
      }
    }
  }
}

前章のアクセスパターン表にaws:PrincipalArnのパターンを追加すると以下になります。

アクセスパターン aws:MultiFactorAuthPresent aws:PrincipalArnのパターン
MFA認証済みの人間ユーザー true arn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>
MFA認証済みのアクセスキー true arn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>
MFA認証済みの人間ユーザーがAssumeRole true arn:aws:iam::<$ACCOUNT_ID>:role/<$ROLE_NAME>
MFA認証済みのアクセスキーがAssumeRole true arn:aws:iam::<$ACCOUNT_ID>:role/<$ROLE_NAME>
MFA認証していない人間ユーザー false arn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>
MFA認証していないアクセスキー キーが含まれない arn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>
MFA認証していない人間ユーザーがAssumeRole false arn:aws:iam::<$ACCOUNT_ID>:role/<$ROLE_NAME>
MFA認証していないアクセスキーがAssumeRole false arn:aws:iam::<$ACCOUNT_ID>:role/<$ROLE_NAME>
AWSサービス経由(Cloud Formationのサービスロールなど) false arn:aws:iam::<$ACCOUNT_ID>:role/<$ROLE_NAME>
AWSサービスロールのアクセス(Lambdaのサービスロールなど) false arn:aws:iam::<$ACCOUNT_ID>:role/<$ROLE_NAME>
IdP側でMFA認証済みの外部認証ユーザー false arn:aws:iam::<$ACCOUNT_ID>:role/aws-reserved/sso.amazonaws.com/<$REGION_OF_IAM_IDENTIY_CENTER>/<$PERMISSION_SET_NAME>_<$UNIQ_ID>

ArnLike条件演算子aws:PrincipalArnを条件キーに、IAMユーザーに対象を限定します。

"Condition" : {
  "Bool" : {
    "aws:MultiFactorAuthPresent" : "false"
  },
+ "ArnLike": {
+   "aws:PrincipalArn": [
+     "arn:aws:iam::*:user/*"
+   ]
}

疑似コードでいうと以下の条件が追加になります。

if (
  // アクセスキーのアクセスではなく、人間ユーザーのアクセスでMFA認証がされていないアクセスである
  (multifactorauthpresentがリクエストコンテキストに含まれる && multifactorauthpresentがfalse)
+ // 直接のIAMユーザーアクセスである(assumeRoleを介していない)
+ AND アクセス下のIAMプリンシパルのArnが"arn:aws:iam::*:user/*"の形式
) {
  if (リクエストされたアクションがMFA認証に関わらず許可するアクションに含まれない) {
    拒否する
  }
}
許可する

これで、aws:MultiFactorAuthPresentがリクエストコンテキストに含まれfalseかつ、aws:PrincipalArnarn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>のみを対象になり、結果MFA認証していない人間ユーザーのみを対象にできました。

と思っていたのですがここからが本当の闘いでした。これだと問題になる例外ケースが2つあります。

例外になる2つのケース

MFA認証していないアクセスキーでのアクセス(長期認証情報)は、aws:MultiFactorAuthPresentがリクエストコンテキストに含まれない。が前提にSCPを構築してきましたが、例外的に含まれてくるケースが2つあります。IAMポリシー視点でいうと以下のケースです。

  • aws:MultiFactorAuthPresentfalse(リクエストコンテキストに含まれる)
  • aws:PrincipalArnのパターンがarn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>

例外1: AWS サービスがユーザーに代わって別のサービスにリクエストを実行するケース

AWSサービスがユーザーに代わって別のサービスにリクエストを実行する場合例外が発生する可能性があります。

via-access-keyの名前で、アクセスキー、シークレットでawsのprofileを作成している状態で、以下のAWS CLIを実行するとConditionがtrueに評価されリクエストが拒否されてしまいます。

$ AWS_PROFILE=via-access-key aws cloudformation create-stack --stack-name <$STACK_NAME> \
    --template-url https://kuritify.s3.ap-northeast-1.amazonaws.com/cf.template.yaml

問題になるリクエストコンテキストはCloudTrail管理イベントでなくCloudTrailデータイベントに記録されています。
CloudFormationがprofileに指定されているアクセスキーの認証情報を使い、S3にcf.template.yamlを取りに行く際のリクエストコンテキストにaws:MultiFactorAuthPresentが含まれていることがわかります。

{
  "eventVersion": "1.09",
  "userIdentity": {
    "type": "IAMUser",
    "principalId": "*******",
    "arn": "arn:aws:iam::111111111111:user/kuritify",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "userName": "kuritify",
    "sessionContext": {
      "attributes": {
        "creationDate": "2024-04-04T06:58:05Z",
        "mfaAuthenticated": "false"
      }
    },
    "invokedBy": "cloudformation.amazonaws.com"
  },
  "eventTime": "2024-04-04T06:58:05Z",
  "eventSource": "s3.amazonaws.com",
  "eventName": "GetObject",
  "awsRegion": "ap-northeast-1",
  "sourceIPAddress": "**.**.**.**",
  "userAgent": "cloudformation.amazonaws.com",
  "errorCode": "AccessDenied",
  "errorMessage": "Access Denied",
  "requestParameters": {
    "bucketName": "kuritify",
    "Host": "kuritify.s3.ap-northeast-1.amazonaws.com",
    "key": "cf.template.yaml"
  },
  "responseElements": null,
  "additionalEventData": {
    "SignatureVersion": "SigV4",
    "CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
    "bytesTransferredIn": 0,
    "AuthenticationMethod": "AuthHeader",
    "x-amz-id-2": "************",
    "bytesTransferredOut": 243
  },
  "requestID": "*****************",
  "eventID": "*****************",
  "readOnly": true,
  "resources": [
    {
      "type": "AWS::S3::Object",
      "ARN": "arn:aws:s3:::kuritify/cf.template.yaml"
    },
    {
      "accountId": "111111111111",
      "type": "AWS::S3::Bucket",
      "ARN": "arn:aws:s3:::kuritify"
    }
  ],
  "eventType": "AwsApiCall",
  "managementEvent": false,
  "recipientAccountId": "111111111111",
  "vpcEndpointId": "vpce-****",
  "eventCategory": "Data"
}

この例外パターンに対処するには、aws:ViaAWSServiceが使えそうなので検証します。説明を引用します。

このキーを使用して、AWS サービスがユーザーに代わって別のサービスにリクエストを実行するかどうか確認します。

サービスが IAM プリンシパルの認証情報を使用し、プリンシパルに代わってリクエストを実行すると、リクエストコンテキストキーは true を返します。サービスがサービスロールまたはサービスリンクロールを使用してプリンシパルに代わって呼び出しを行う場合、コンテキストキーは false を返します。リクエストコンテキストキーは、プリンシパルが直接呼び出しを行ったときも false を返します。

  • 可用性 – このキーは常にリクエストコンテキストに含まれます
  • データ型 – ブール値
  • 値タイプ — 単一値

この条件キーを使用して、リクエストがサービスによって行われたかどうかに基づいてアクセスを許可または拒否できます。

aws:ViaAWSServiceはCloudTrailイベントに該当するフィールドが無いようで、プリンシパルがIAMユーザーかつ、userIdentity.invokedByに何かしらのAWSサービスのドメインが含まれているケースだとtrueに評価される条件キーのようです。

そのためaws:ViaAWSServicefalseをBool条件演算子に追加します。

{
  "Condition": {
    "Bool": {
      "aws:MultiFactorAuthPresent": "false",
+     "aws:ViaAWSService": "false"
    },
    "ArnLike": {
      "aws:PrincipalArn": ["arn:aws:iam::*:user/*"]
    }
  }
}

Bool条件演算子内の条件はANDで評価されます。疑似コードは以下の様になります。

if (
+ (
    // アクセスキーのアクセスではなく、人間ユーザーのアクセスでMFA認証がされていないアクセスである
    (multifactorauthpresentがリクエストコンテキストに含まれる AND multifactorauthpresentがfalse)
+   AND AWSサービス経由のアクセスではない
+ )
  // 直接のIAMユーザーアクセスである(assumeRoleを介していない)
  AND アクセス下のIAMプリンシパルのArnが"arn:aws:iam::*:user/*"の形式
) {
  if (リクエストされたアクションがMFA認証に関わらず許可するアクションに含まれない) {
    拒否する
  }
}
許可する

AWS サービスがユーザーに代わって別のサービスにリクエストを実行するアクセスの時点でCondition句はfalseに評価され、それ以外のアクセスはこれまで通り人間ユーザーのMFA認証の有無で条件分岐が行われます。

例外2:"その他"としか表現できない例外ケース

長くなっていますが、これで最後になります。さらに例外ケースがあります。

  • aws:MultiFactorAuthPresentfalse(リクエストコンテキストに含まれる)
  • aws:PrincipalArnのパターンがarn:aws:iam::<$ACCOUNT_ID>:user/$<USER_NAME>
  • aws:ViaAWSServicefalse

現在確認できているだけで以下の2ケースがあります。

  1. docker cli経由でECRにアクセスするパターンです。以下の操作をすると、docker pushがSCPで拒否されエラーになります。
    $ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin  <$ECR_REPOGITORY>
    $ docker push <$ECR_Repogitory>/<$TAG>
    
  2. npmでCodeArtifactにアクセスするパターンです。以下の操作をすると、npm publishがSCPで拒否されエラーになります。
    $ aws codeartifact login --tool npm --domain kuritify-poc --domain-owner $(aws sts get-caller-identity | jq -r .Account) --repository <$REPOSITORY_NAME> --namespace <$NAME_SPACE>
    $ npm publish
    

双方のCloudTrail管理イベントを掲載します。これまでのどれにも当てはまらないリクエストコンテキストになっていることが確認できます。

MFA認証していないアクセスキーでdocker cli経由のECRアクセス。

{
  "eventVersion": "1.08",
  "userIdentity": {
    "type": "IAMUser",
    "principalId": "*******",
    "arn": "arn:aws:iam::111111111111:user/kuritify",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "userName": "kuritify",
    "sessionContext": {
      "sessionIssuer": {},
      "webIdFederationData": {},
      "attributes": {
        "creationDate": "2024-02-07T02:28:10Z",
        "mfaAuthenticated": "false"
      }
    },
    "invokedBy": "AWS Internal"
  },
  "eventTime": "2024-02-07T02:28:39Z",
  "eventSource": "ecr.amazonaws.com",
  "eventName": "InitiateLayerUpload",
  "awsRegion": "ap-northeast-1",
  "sourceIPAddress": "**.**.**.**",
  "userAgent": "AWS Internal",
  "errorCode": "AccessDenied",
  "errorMessage": "User: arn:aws:iam::111111111111:user/kuritify is not authorized to perform: ecr:InitiateLayerUpload on resource: arn:aws:ecr:ap-northeast-1:111111111111:repository/kuritify/tag with an explicit deny in a service control policy",
  "requestParameters": null,
  "responseElements": null,
  "requestID": "*****************",
  "eventID": "*****************",
  "readOnly": false,
  "eventType": "AwsApiCall",
  "managementEvent": true,
  "recipientAccountId": "111111111111",
  "eventCategory": "Management"
}

MFA認証していないアクセスキーでnpm cli経由のCodeArtifactアクセス。

{
  "eventVersion": "1.08",
  "userIdentity": {
    "type": "IAMUser",
    "principalId": "*******",
    "arn": "arn:aws:iam::111111111111:user/kuritify",
    "accountId": "111111111111",
    "accessKeyId": "*******",
    "userName": "kuritify",
    "sessionContext": {
      "sessionIssuer": {},
      "webIdFederationData": {},
      "attributes": {
        "creationDate": "2024-04-04T09:47:41Z",
        "mfaAuthenticated": "false"
      }
    }
  },
  "eventTime": "2024-04-04T09:47:58Z",
  "eventSource": "codeartifact.amazonaws.com",
  "eventName": "PublishPackageVersion",
  "awsRegion": "ap-northeast-1",
  "sourceIPAddress": "**.**.**.**",
  "userAgent": "npm/10.2.4 node/v20.11.1 darwin arm64 workspaces/false",
  "errorCode": "AccessDenied",
  "errorMessage": "Access denied. User: arn:aws:iam::111111111111:user/kuritify is not authorized to perform: codeartifact:PublishPackageVersion on resource: arn:aws:codeartifact:ap-northeast-1:111111111111:package/kuritify-poc/kuritify-poc/npm/kuritify-poc/kuritify-module with an explicit deny in a service control policy",
  "requestParameters": {
    "domainName": "kuritify-poc",
    "domainOwner": "111111111111",
    "repositoryName": "kuritify-poc",
    "packageName": "kuritify-module",
    "packageFormat": "npm",
    "packageNamespace": "kuritify-poc"
  },
  "responseElements": null,
  "additionalEventData": {
    "httpMethod": "PUT",
    "requestUri": "/npm/@kuritify-poc%2fkuritify-module"
  },
  "requestID": "*****************",
  "eventID": "*****************",
  "readOnly": false,
  "resources": [
    {
      "accountId": "111111111111",
      "type": "AWS::CodeArtifact::Domain",
      "ARN": "arn:aws:codeartifact:ap-northeast-1:111111111111:domain/kuritify-poc"
    },
    {
      "accountId": "111111111111",
      "type": "AWS::CodeArtifact::Repository",
      "ARN": "arn:aws:codeartifact:ap-northeast-1:111111111111:repository/kuritify-poc/kuritify-poc"
    },
    {
      "accountId": "111111111111",
      "type": "AWS::CodeArtifact::Package",
      "ARN": "arn:aws:codeartifact:ap-northeast-1:111111111111:package/kuritify-poc/kuritify-poc/npm/kuritify-poc/kuritify-module"
    }
  ],
  "eventType": "AwsApiCall",
  "managementEvent": true,
  "recipientAccountId": "111111111111",
  "eventCategory": "Management",
  "tlsDetails": {
    "tlsVersion": "TLSv1.3",
    "cipherSuite": "TLS_AES_128_GCM_SHA256",
    "clientProvidedHostHeader": "kuritify-poc-111111111111.d.codeartifact.ap-northeast-1.amazonaws.com"
  }
}

AWS re:Invent2023 - Dive deep into Amazon ECRという資料でECRのアーキテクチャーが説明されていますが、docker cli経由でECRにアクセスする際にはProxy Serviceを経由してリクエストが実行されます。

$ ecr loginで返される認証情報は一時的な認証情報なので、aws:MultiFactorAuthPresentがfalseになるのは理解できますが、aws:ViaAWSServiceはtrueじゃないと整合性とれなくないですかAWSさん。。CodeArtifactについてはアーキテクチャの詳細が記載されている資料を発見できていないのですが、同じような動きなんだろうと想像しています。

こちらはCondition句では対応不可能なので、MFA認証の有無に関わらず実行できるよう、該当するアクションを列挙することで対処します。

{
  "Effect": "Deny",
  "NotAction": [
    "...MFA認証の有無に関わらず許可したいアクション",
+   "ecr:BatchCheckLayerAvailability",
+   "ecr:initiateLayerUpload",
+   "ecr:UploadLayerPart",
+   "ecr:CompleteLayerUpload",
+   "ecr:PutImage",
+   "ecr:BatchGetImage",
+   "ecr:GetDownloadUrlForLayer",
+   "codeartifact:PublishPackageVersion",
+   "codeartifact:ReadFromRepository"
  ],
}

まとめ

SCPでIAMユーザーにMFAを強制させるのは非常に難しいという話をさせていただきました。弊社では実環境でこのSCPを適用しており、現状トラブルは無いですが、利用サービスが限定的なので予断は許さない状況であります。このSCPを参考にしていただける場合、十分に動作検証をして採用の判断をしていただければと思います。

Zenn初投稿ということで気合入りすぎました。長時間お付き合いいただきありがとうございます。

Discussion

knziiyknziiy

素晴らしい記事をありがとうございます。ご指摘のとおり、注意しながら参考にさせていただきます。

AWSアカウントのIAMユーザー(人間のアクセスのみ)にMFA認証を強制させる

本当に、シンプルな要件であるこれをしたいだけなのに、大変過ぎますね。

Customer Obsessionに期待しましょうw

くりてぃふぁい 👾くりてぃふぁい 👾

@knziiy

コメントありがとうございます。
お役に立てたなら苦戦した甲斐がありましたw

AWS進化しすぎましたからね。根幹のIAMが複雑になるのも致し方なしですかね。