ENGINEER BLOG ENGINEER BLOG
  • 公開日
  • 最終更新日

サーバーレスでIAMユーザー申請ポータルを構築

この記事を共有する

目次

パーソル&サーバーワークスの印鑰です。

AWSを中心としたクラウドインフラの設計・構築・運用を担当しています。 今回は、IAM ユーザーの作成/延長・削除・パスワードリセットを Web フォームから申請し、管理者承認後に自動実行する仕組みを構築します。

このハンズオンで学べること

「IAM ユーザーの払い出しを申請制にしたいけど、手作業だとミスが起きる」「承認フローを挟みたいけど、メール本文の解析は大変」――そんな悩みを解決します。

このハンズオンでは、1つの申請ポータルから IAM ユーザーの作成/延長・削除・パスワードリセットを申請し、管理者が承認したあと自動で IAM 操作を実行する仕組みを AWS CloudFormation で構築します。

どんな人向け?

  • IAM ユーザー管理を申請制にしたい方
  • AWS CloudFormation の知識があるとさらに楽しめますが、コピー&ペーストで進められる

システムアーキテクチャ

全体構成図

構成図.png

処理フロー

処理フロー-001.png

所要時間

  • 約3〜4時間(※)
    • Amazon Route 53 ドメイン取得・DNS 設定:約15〜30分
    • Amazon SES 送信元設定:約10〜15分
    • AWS CloudFormation デプロイ(全5スタック):約30〜40分
    • Web ファイル配置と設定:約15分
    • AWS Lambda 関数コードの設定:約15〜20分
    • 動作確認:約30〜40分
    • 環境削除:約30分

※ 注意: 事前準備に別途時間がかかる場合があります

  • Amazon Route 53 ドメイン登録: 新規ドメインの取得には数分〜最大48時間かかる場合があります。一般的な .link.com ドメインは数分〜数十分で完了しますが、一部の TLD では登録者の確認が入り時間がかかることがあります。DNS レコードの伝播にも最大48時間かかる場合がありますが、通常は数分〜1時間程度です。
  • Amazon SES サンドボックス解除(本番申請): AWS サポートによる審査が必要で、通常 24時間以内 に回答がありますが、追加情報を求められた場合は 数日 かかることもあります。ハンズオンをサンドボックスモードのまま進め、本番運用開始前に解除申請を行う方法もおすすめです。

おおよその必要コスト

小規模利用を前提とした場合、必要なコストは 約20 USD/月(税抜) です。AWS WAF、CloudFront、Route 53 などの利用量や、無料利用枠の適用状況によって金額は変動します。ドメイン登録料などの初期費用・年額費用は含めていません。詳細な内訳と計算前提は、付録B を参照してください。

必要な権限

  • AWS CloudFormation スタックをデプロイできること
  • Amazon S3、Amazon CloudFront、Amazon API Gateway、AWS WAF、AWS Lambda、Amazon SES、Amazon SNS、Amazon DynamoDB、AWS Step Functions、IAM、ACM を操作できること

リージョン

  • ACMと AWS WAF(stack-01-waf)は バージニア北部リージョン(us-east-1) にリソースを作成してください。
  • 上記を除くリソースは 東京リージョン(ap-northeast-1) にリソースを作成してください。

事前に必要なもの

  • ハンズオンでは 1〜3 は共通で問題ありません。
項目 内容
1 管理者通知を受信するメールアドレス(承認依頼・処理結果・エラー通知など)
2 申請者のメールアドレス
3 申請ポータルのログイン情報通知を受信するメールアドレス(月次パスワード通知)

構築手順

さあ、ここからが本番です。一緒に作っていきましょう。

ステップ一覧

  1. Amazon SES 送信元の準備
  2. フェーズ0(共通基盤)のデプロイ
  3. Amazon SNS 購読確認
  4. ACM 証明書の発行とフェーズ1(WAF)のデプロイ
  5. フェーズ2(Web ポータル)のデプロイ
  6. フェーズ3(申請登録)のデプロイ
  7. フェーズ4(承認・IAM 操作)のデプロイ
  8. AWS Lambda 関数コードの設定
  9. Web ファイル配置
  10. 月次パスワードの初回発行

ステップ1: Amazon SES 送信元の準備

メール送信には Amazon SES の送信元設定が必要です。ドメイン全体を送信元として検証し、サンドボックスを解除します。

1-1. Amazon Route 53 でドメインを取得

送信元ドメインを持っていない場合、Amazon Route 53 で新規取得します。

既にドメインをお持ちの場合: Amazon Route 53 または他の DNS サービスでドメインを取得済みで、MX レコードを未設定であれば、そのドメインを流用できます。その場合は「1-2」へ進んでください。
補足: ドメイン登録には数分〜数十分かかります。.link は年間 5 USD 程度です。ハンズオン完了後に不要であれば、自動更新を無効にしてください。

  1. マネジメントコンソールにて、Amazon Route 53 を開く
  2. 左ペインの「登録済みドメイン」を選択し「ドメインの登録」を押下
  3. 「ドメインの検索」にて、希望するドメイン名を入力し「検索」を押下
    • {取得したドメイン名[your-domain.link]} など、任意のドメイン名を指定
  4. 「検索結果」にて、希望するドメイン名を「選択」し「チェックアウトに進む」を押下
  5. 「Secure your brand across other extensions」にて「Skip」を押下
  6. 「ドメインの料金オプション」にて、一時的な検証用ドメインとして使用する場合は「自動更新」のチェックを外し「次へ」を押下
  7. 連絡先情報を入力し「次へ」を押下
  8. 「確認して送信」にて、内容を確認の上、「利用規約」にチェックを入れ「送信」を押下
  9. 連絡先情報を入力しEメールへ、件名:「[Action Required] Verify your email address.」が受信されるので開く
  10. 本文の「Click the following link to verify your email address:」の下にあるリンクを押下し、ブラウザに認証完了画面が表示されるので閉じる
  11. 登録完了後、左ペインの「ホストゾーン」を選択し、希望するドメイン名のホストゾーンが自動作成されたことを確認
  12. 作成されたホストゾーン名の「ホストゾーン ID」を控える

Amazon Route 53 でドメインを取得-002.png

Amazon Route 53 でドメインを取得-001.png

補足: メールが届かない場合は「E メールの再送信」を再度押下してください。迷惑メールフォルダーも確認してください。

1-2. Amazon SES で送信元ドメインの所有を確認

  1. マネジメントコンソールにて、Amazon SES を開く
  2. 左ペインの「ID」を選択し「ID の作成」を押下
  3. 「ID タイプ」で「ドメイン」を選択
  4. {取得したドメイン名[your-domain.link]} を入力
  5. 「DKIM の詳細設定」を展開し、ID タイプで「Easy DKIM」を選択
  6. 「DKIM 署名キーの長さ」で「RSA_2048_BIT」を選択
  7. 「DNS レコードの Route53 への発行」の「有効化」にチェックが入っていることを確認
  8. 「DKIM 署名」の「有効化」にチェックが入っていることを確認
  9. 「ID の作成」を押下

Amazon SES で送信元ドメインの所有を確認-001.png

Amazon SES で送信元ドメインの所有を確認-002.png

1-3. DNS レコードの登録

ID 作成後、マネジメントコンソールにて Amazon SES の画面に「この ID の所有権を確認するには、指定された CNAME レコードを使用してドメインの DNS 設定で DKIM を設定する必要があります。」と表示された場合は、Amazon Route 53 に CNAME レコードを手動追加してください。

補足: 「ID の作成」時に「DNS レコードの Route53 への発行」を有効化しても自動登録されない場合があります。Amazon SES の ID 詳細画面に「検証保留中」と表示されたら、以下の手順で手動追加してください。

  1. マネジメントコンソールにて、Amazon SES を開き、作成したドメインの ID 詳細画面を開く
  2. 「認証」タブの「DomainKeys Identified Mail (DKIM)」セクションまでスクロール
  3. 「DKIM を設定するために DNS プロバイダーに次の CNAME レコードを公開します」に表示された3つの CNAME レコード(名前と値)を控える
  4. マネジメントコンソールにて、Amazon Route 53 を開き、対象ドメインのホストゾーンを選択する
  5. 「レコードを作成」を押下
  6. レコードタイプで「CNAME」を選択し、Amazon SES で控えた名前と値を入力
  7. 3つの CNAME レコードすべてを追加

DNS レコードの登録-001.png

DNS レコードの登録-002.png

1-4. 検証完了の確認

DNS 反映後(数分〜数時間)、マネジメントコンソールにて、Amazon SES で ID のステータスが「検証済み」になったことを確認します。

検証完了の確認-001.png

1-5. Amazon SES サンドボックスの解除

サンドボックスを解除すると、送信先メールアドレスの事前検証が不要になります。

  1. マネジメントコンソールにて、Amazon SES を開き、「アカウントダッシュボード」を開く
  2. 「Amazon SES アカウントは以下のサンドボックスにあります~」にて「設定を始める ページを表示」ボタンを押下
  3. 「本番アクセスをリクエスト」ボタンを押下
  4. 以下を入力:
    • メールタイプ: 「トランザクション」を選択
    • ウェブサイトの URL: https://request.{取得したドメイン名[your-domain.link]} (スタック: dev-001-iam-stack-02-portal-auth でデプロイ予定の申請ポータル URL を入力)
    • その他の連絡先 - オプション: 追加の連絡先メールアドレス(省略可)
    • 送信する形の希望言語: 「Japanese」を選択
  5. 「了解」セクションの「私は AWS サービス条件 と 適正利用規約 (AUP) に同意します」にチェックを入れる
  6. 「リクエストの送信」を押下
  7. 左ペインの「ID」を選択
  8. 登録したIDの「ID ステータス」が 検証済み となるまで待機

Amazon SES サンドボックスの解除-001.png

Amazon SES サンドボックスの解除-002.png

Amazon SES サンドボックスの解除-003.png

Amazon SES サンドボックスの解除-004.png

補足: 審査には最大24時間かかる場合があります。承認後は送信先メールアドレスの事前検証が不要になります。承認前にハンズオンを進める場合は、動作確認で使うすべての送信先メールアドレス(管理者、利用者)を Amazon SES の「Eメール検証」から個別に検証してください。

ステップ1はこれで完了です。ステップ2 へ進んでください。


ステップ2: フェーズ0(共通基盤)のデプロイ

後続スタックが参照する Amazon SNS、Amazon DynamoDB、Amazon S3、IAM グループを作成します。

2-1. テンプレートファイルの作成

ローカル PC で stack-00-foundation.yaml という名前のファイルを作って、以下の内容をコピー&ペーストしてください。

stack-00-foundation.yaml(ここを押すと展開します)

AWSTemplateFormatVersion: '2010-09-09'
Description: 'IAM User Request Portal - Phase 0: Foundation (SNS, DynamoDB, S3, IAM Group)'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  ProjectInfix:
    Type: String
    Default: iam
  AdminEmailAddress:
    Type: String
    Description: Email address to subscribe to the administrator notification SNS topic
  PortalPasswordAdminEmailAddress:
    Type: String
    Description: Email address to subscribe to the portal password notification SNS topic
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
          - ProjectInfix
      - Label:
          default: Notification Configuration
        Parameters:
          - AdminEmailAddress
          - PortalPasswordAdminEmailAddress
Resources:
  AdminNotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-admin-notifications'
  PortalPasswordNotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-portal-password-notifications'
  AdminEmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref AdminNotificationTopic
      Protocol: email
      Endpoint: !Ref AdminEmailAddress
  PortalPasswordEmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref PortalPasswordNotificationTopic
      Protocol: email
      Endpoint: !Ref PortalPasswordAdminEmailAddress
  RequestTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Delete
    Properties:
      TableName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-requests'
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk
          AttributeType: S
        - AttributeName: sk
          AttributeType: S
      KeySchema:
        - AttributeName: pk
          KeyType: HASH
        - AttributeName: sk
          KeyType: RANGE
      TimeToLiveSpecification:
        AttributeName: expiresAt
        Enabled: true
  PasswordBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${AWS::AccountId}-${AWS::Region}-${Environment}-${ProjectId}-${ProjectInfix}-credentials'
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:
        Rules:
          - Id: DeleteTemporaryCredentials
            Status: Enabled
            ExpirationInDays: 3
  IamUserGroup:
    Type: AWS::IAM::Group
    Properties:
      GroupName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-users'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess
Outputs:
  AdminNotificationTopicArn:
    Value: !Ref AdminNotificationTopic
    Export:
      Name: !Sub '${AWS::StackName}-AdminNotificationTopicArn'
  PortalPasswordNotificationTopicArn:
    Value: !Ref PortalPasswordNotificationTopic
    Export:
      Name: !Sub '${AWS::StackName}-PortalPasswordNotificationTopicArn'
  IamUserGroupName:
    Value: !Ref IamUserGroup
    Export:
      Name: !Sub '${AWS::StackName}-IamUserGroupName'
  PasswordBucketName:
    Value: !Ref PasswordBucket
    Export:
      Name: !Sub '${AWS::StackName}-PasswordBucketName'
  RequestTableName:
    Value: !Ref RequestTable
    Export:
      Name: !Sub '${AWS::StackName}-RequestTableName'

ファイルを保存してください。

2-2. マネジメントコンソールにて、AWS CloudFormation を開きデプロイ

  1. マネジメントコンソールにて、AWS CloudFormation を開く
  2. リージョンが 東京(ap-northeast-1) であることを確認
  3. 左ペインにて「スタック」を選択
  4. 「スタックの作成」→「新しいリソースを使用(標準)」を選択
  5. 「既存のテンプレートを選択」→「テンプレートファイルのアップロード」を選択
  6. stack-00-foundation.yaml をアップロードし、「次へ」を選択

2-3. スタック詳細の指定

AdminEmailAddress、 PortalPasswordAdminEmailAddress の指定方法:

ハンズオンでは、AdminEmailAddressPortalPasswordAdminEmailAddress に同じメールアドレスを指定して問題ありません。本番運用を見据える場合は、PortalPasswordAdminEmailAddress にメーリングリストまたは Microsoft Teams でチャネルにメールを送信するアドレスを指定すると管理がしやすいです。

ステップ2と同じ手順でデプロイします。

スタック名: dev-001-iam-stack-00-foundation

パラメーター 説明
Environmentdev環境名
ProjectId001プロジェクト識別子
ProjectInfixiamリソース名のプレフィックス
AdminEmailAddress管理者のメールアドレス承認依頼・結果通知の送信先
PortalPasswordAdminEmailAddressログイン情報の通知先メールアドレスポータルへのログイン情報の通知先
  1. 上記を設定し、「次へ」を選択
  2. 「AWS CloudFormation によって IAM リソースがカスタム名で作成される場合があることを承認します。」にチェックを入れ、「次へ」を選択
  3. 「送信」を選択

2-4. デプロイ完了の確認

スタックのステータスが CREATE_COMPLETE になるまで待ちましょう。

ステップ2_フェーズ0_共通基盤_のデプロイ-001.png

「出力」タブで以下の値を控えてください(後のステップで使います)。

出力キー 用途
AdminNotificationTopicArn管理者通知用 Amazon SNS
PortalPasswordNotificationTopicArnパスワード通知用 Amazon SNS
IamUserGroupNameIAM ユーザー追加先グループ
PasswordBucketName認証情報一時保存用 Amazon S3

ステップ3: Amazon SNS 購読確認

AWS CloudFormation が Amazon SNS サブスクリプションを作成しますが、受信者本人が確認メールのリンクをクリックしないと通知が届きません。

  1. パラメーターに指定したメールアドレスに AWS Notification - Subscription Confirmation メールが届く
  2. 各メールの Confirm subscription リンクをクリック
  3. マネジメントコンソールにて、Amazon SNS を開き、両方のサブスクリプションが Confirmed になっていることを確認

メールが届かない場合: 迷惑メールフォルダーを確認してください。組織のメールゲートウェイが amazonaws.com を拒否している場合もあります。

補足: AdminEmailAddress と PortalPasswordAdminEmailAddress を同じメールアドレスにした場合、 2通メール が届きますので、それぞれ上記の手順を実行してください。

ステップ3_Amazon SNS 購読確認-001.png


ステップ4: ACM 証明書の発行とフェーズ1(WAF)のデプロイ(us-east-1)

このステップでは バージニア北部(us-east-1) リージョンで2つの作業を行います。

  1. Amazon CloudFront 用の ACM 証明書を発行
  2. AWS WAF の WebACL スタックをデプロイ

AWS WAF を CloudFront に関連付け、以下のルールでポータルを保護します。

ルール 内容
レートベースルール 同一 IP から 5 分間に 300 リクエスト超でブロック(ブルートフォース対策)
AWS Managed Rules(Common Rule Set) SQL インジェクション・XSS など一般的な攻撃パターンをブロック
AWS Managed Rules(Known Bad Inputs) 既知の不正入力パターンをブロック
AWS Managed Rules(Bot Control) 自動化ツールやスクレイパーからのアクセスを制限

4-1. ACM で SSL 証明書を発行(us-east-1)

マネジメントコンソールにて、リージョンを バージニア北部(us-east-1) に切り替えてください。このステップ4 の作業はすべて us-east-1 で行います。

Amazon CloudFront にカスタムドメインを設定するには、バージニア北部(us-east-1) で ACM 証明書を発行する必要があります。

  1. マネジメントコンソールにて、AWS Certificate Manager (ACM) を開く
  2. リージョンを バージニア北部(us-east-1) に切り替える
  3. 左ペインにて「証明書をリクエスト」を選択
  4. 「パブリック証明書をリクエスト」を選択し「次へ」を押下
  5. ドメイン名に request.{取得したドメイン名[your-domain.link]} を入力
  6. 検証方法で「DNS 検証」を選択し「リクエスト」を押下
  7. 左ペインにて「証明書を一覧」を選択
  8. 作成した証明書の「証明書ID」を選択
  9. 「ドメイン」の「Route 53にレコードを作成」を押下
  10. 「CNAMEレコード」が表示するため、選択し「レコードを作成」を押下
  11. 左ペインにて「証明書を一覧」を選択
  12. 証明書のステータスが「発行済み」になるまで待機(数分)
  13. ステータス更新後、「証明書ID」を選択
  14. 「証明書のステータス」にてARNを控える
  15. リージョンを 東京(ap-northeast-1) に戻す

ACM で SSL 証明書を発行-001.png

ACM で SSL 証明書を発行-002.png

重要: 証明書は必ず us-east-1 で発行してください。Amazon CloudFront が参照できるのは us-east-1 の ACM 証明書のみです。

4-2. テンプレートファイルの作成

ローカル PC で stack-01-waf.yaml を作成し、以下の内容を保存してください。

stack-01-waf.yaml(ここを押すと展開します)

AWSTemplateFormatVersion: '2010-09-09'
Description: 'IAM User Request Portal - Phase 1: WAF WebACL for CloudFront (us-east-1)'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  ProjectInfix:
    Type: String
    Default: iam
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
          - ProjectInfix
Resources:
  # --- AWS WAF WebACL(CloudFront 用 -- スコープ: CLOUDFRONT) ---
  # レートベースルール + AWS Managed Rules で不正リクエストを遮断
  WebAcl:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-web-acl'
      Scope: CLOUDFRONT
      DefaultAction:
        Allow: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-web-acl'
      Rules:
        # レートベースルール: 同一 IP から 5 分間に 300 リクエスト超でブロック
        - Name: RateLimitRule
          Priority: 1
          Action:
            Block: {}
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: RateLimitRule
          Statement:
            RateBasedStatement:
              Limit: 300
              AggregateKeyType: IP
        # AWS Managed Rules -- 既知の悪意あるリクエストパターン
        - Name: AWSManagedRulesCommonRuleSet
          Priority: 2
          OverrideAction:
            None: {}
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: AWSManagedRulesCommonRuleSet
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesCommonRuleSet
        # AWS Managed Rules -- 既知の不正入力パターン(SQLi など)
        - Name: AWSManagedRulesKnownBadInputsRuleSet
          Priority: 3
          OverrideAction:
            None: {}
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: AWSManagedRulesKnownBadInputsRuleSet
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesKnownBadInputsRuleSet
        # AWS Managed Rules -- Bot 対策
        - Name: AWSManagedRulesBotControlRuleSet
          Priority: 4
          OverrideAction:
            None: {}
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: AWSManagedRulesBotControlRuleSet
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesBotControlRuleSet
Outputs:
  WebAclArn:
    Description: WAF WebACL ARN for CloudFront association
    Value: !GetAtt WebAcl.Arn
    Export:
      Name: !Sub '${AWS::StackName}-WebAclArn'

4-3. マネジメントコンソールにて、AWS CloudFormation を開きデプロイ(us-east-1)

重要: このスタックは バージニア北部(us-east-1) リージョンでデプロイしてください。WAFv2 の Scope: CLOUDFRONT は us-east-1 でのみ作成可能です。

スタック名: dev-001-iam-stack-01-waf

パラメーター 説明
Environmentdev環境名
ProjectId001プロジェクト識別子
ProjectInfixiamリソース名のプレフィックス

CREATE_COMPLETE を確認し、「出力」タブで以下の値を控えてください(後のステップで使います)。

出力キー 用途
WebAclArnステップ5 で WafWebAclArn パラメータに指定

ステップ5: フェーズ2(Web ポータル)のデプロイ

申請ポータルの Web 配信基盤(Amazon CloudFront + カスタムドメイン)、API、月次パスワード更新を作成します。ステップ4で作成した WAF WebACL の ARN をパラメータに渡して CloudFront に関連付けます。

5-1. テンプレートファイルの作成

ローカル PC で stack-02-portal-auth.yaml を作成し、以下の内容を保存してください。

stack-02-portal-auth.yaml(ここを押すと展開します)

AWSTemplateFormatVersion: '2010-09-09'
Description: 'IAM User Request Portal - Phase 2: Web Portal, API, Auth, EventBridge'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  ProjectInfix:
    Type: String
    Default: iam
  LambdaLogRetentionInDays:
    Type: Number
    Default: 7
    AllowedValues: [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653]
    Description: Number of days to retain Lambda function logs
  CustomDomainName:
    Type: String
    Description: Custom domain name (e.g. request.your-domain.link)
  AcmCertificateArn:
    Type: String
    Description: ACM certificate ARN issued in us-east-1
  HostedZoneId:
    Type: String
    Description: Route 53 hosted zone ID
  PortalLoginUsername:
    Type: String
    Default: persol-serverworks
    Description: Fixed login user name for the request portal
  PortalPasswordNotificationTopicArn:
    Type: String
    Description: Output value of phase 0
  AdminNotificationTopicArn:
    Type: String
    Description: Output value of phase 0 (admin notification SNS topic ARN)
  WafWebAclArn:
    Type: String
    Description: WAF WebACL ARN exported from stack-01-waf (us-east-1)
  LambdaArchitecture:
    Type: String
    Default: x86_64
    AllowedValues: [x86_64, arm64]
    Description: Lambda function architecture for Powertools layer
  LambdaPythonVersion:
    Type: String
    Default: python3.13
    AllowedValues: [python3.9, python3.10, python3.11, python3.12, python3.13, python3.14]
    Description: Python runtime version for Powertools layer
  FoundationStackName:
    Type: String
    Default: dev-001-iam-stack-00-foundation
    Description: Stack name of phase 0
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
          - ProjectInfix
      - Label:
          default: Portal Configuration
        Parameters:
          - CustomDomainName
          - AcmCertificateArn
          - HostedZoneId
          - PortalLoginUsername
      - Label:
          default: Lambda Configuration
        Parameters:
          - LambdaLogRetentionInDays
          - LambdaArchitecture
          - LambdaPythonVersion
      - Label:
          default: Cross-Stack Configuration
        Parameters:
          - PortalPasswordNotificationTopicArn
          - AdminNotificationTopicArn
          - WafWebAclArn
          - FoundationStackName
Resources:
  # --- S3 bucket for web contents ---
  WebBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${AWS::AccountId}-${AWS::Region}-${Environment}-${ProjectId}-${ProjectInfix}-web'
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  # --- CloudFront OAC ---
  CloudFrontOAC:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-oac'
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4
  # --- CloudFront ---
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        DefaultRootObject: index.html
        WebACLId: !Ref WafWebAclArn
        Aliases:
          - !Ref CustomDomainName
        Origins:
          - Id: S3Origin
            DomainName: !GetAtt WebBucket.RegionalDomainName
            OriginAccessControlId: !Ref CloudFrontOAC
            S3OriginConfig:
              OriginAccessIdentity: ''
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
          AllowedMethods: [GET, HEAD]
        ViewerCertificate:
          AcmCertificateArn: !Ref AcmCertificateArn
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
  # --- Route 53 record ---
  DnsRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Ref CustomDomainName
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt Distribution.DomainName
  # --- S3 bucket policy ---
  WebBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref WebBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource: !Sub '${WebBucket.Arn}/*'
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}'
  # --- API Gateway (REST API only. Deployment and Stage are created in phase 4) ---
  RequestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-api'
  # Health check endpoint for the API root
  ApiRootMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RequestApi
      ResourceId: !GetAtt RequestApi.RootResourceId
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        RequestTemplates:
          application/json: '{"statusCode": 200}'
        IntegrationResponses:
          - StatusCode: '200'
      MethodResponses:
        - StatusCode: '200'
  # --- /auth ---
  AuthResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RequestApi
      ParentId: !GetAtt RequestApi.RootResourceId
      PathPart: auth
  # --- /auth/login ---
  LoginResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RequestApi
      ParentId: !Ref AuthResource
      PathPart: login
  LoginPostMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RequestApi
      ResourceId: !Ref LoginResource
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthFunction.Arn}/invocations'
  LoginOptionsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RequestApi
      ResourceId: !Ref LoginResource
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        RequestTemplates:
          application/json: '{"statusCode": 200}'
        IntegrationResponses:
          - StatusCode: '200'
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: !Sub "'https://${CustomDomainName}'"
              method.response.header.Access-Control-Allow-Credentials: "'true'"
      MethodResponses:
        - StatusCode: '200'
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: false
            method.response.header.Access-Control-Allow-Methods: false
            method.response.header.Access-Control-Allow-Origin: false
            method.response.header.Access-Control-Allow-Credentials: false
  # --- Auth Lambda ---
  AuthRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-auth-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: AuthPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [dynamodb:GetItem, dynamodb:PutItem]
                Resource: !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${Environment}-${ProjectId}-${ProjectInfix}-requests'
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !GetAtt ErrorLogGroup.Arn
  AuthFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-auth'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt AuthRole.Arn
      Timeout: 30
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
          LOGIN_USERNAME: !Ref PortalLoginUsername
          CORS_ORIGIN: !Sub 'https://${CustomDomainName}'
          ERROR_LOG_GROUP_NAME: !Ref ErrorLogGroup
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-auth'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  AuthFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${AuthFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  AuthFunctionApiPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref AuthFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${RequestApi}/*/POST/auth/login'
  # --- Monthly password update Lambda ---
  PasswordUpdateRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-password-update-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: PasswordUpdatePolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:GetItem]
                Resource: !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${Environment}-${ProjectId}-${ProjectInfix}-requests'
              - Effect: Allow
                Action: [sns:Publish]
                Resource: !Ref PortalPasswordNotificationTopicArn
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !GetAtt ErrorLogGroup.Arn
  PasswordUpdateFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-password-update'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt PasswordUpdateRole.Arn
      Timeout: 30
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
          SNS_TOPIC_ARN: !Ref PortalPasswordNotificationTopicArn
          PORTAL_URL: !Sub 'https://${CustomDomainName}'
          LOGIN_USERNAME: !Ref PortalLoginUsername
          ERROR_LOG_GROUP_NAME: !Ref ErrorLogGroup
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-password-update'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  PasswordUpdateFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${PasswordUpdateFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  # --- EventBridge Scheduler ---
  SchedulerRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-scheduler-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeLambda
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource: !GetAtt PasswordUpdateFunction.Arn
  MonthlySchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-monthly-password'
      ScheduleExpression: 'cron(0 0 1 * ? *)'
      ScheduleExpressionTimezone: 'Asia/Tokyo'
      State: ENABLED
      FlexibleTimeWindow:
        Mode: 'OFF'
      Target:
        Arn: !GetAtt PasswordUpdateFunction.Arn
        RoleArn: !GetAtt SchedulerRole.Arn
  # --- Error notification infrastructure ---
  ErrorLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/${Environment}/${ProjectId}/${ProjectInfix}/lambda-errors'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  ErrorNotificationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-error-notification-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ErrorNotificationPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [sns:Publish]
                Resource: !Ref AdminNotificationTopicArn
  ErrorNotificationFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-error-notification'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt ErrorNotificationRole.Arn
      Timeout: 30
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          SNS_TOPIC_ARN: !Ref AdminNotificationTopicArn
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-error-notification'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  ErrorNotificationFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${ErrorNotificationFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  ErrorLogSubscriptionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref ErrorNotificationFunction
      Action: lambda:InvokeFunction
      Principal: logs.amazonaws.com
      SourceArn: !GetAtt ErrorLogGroup.Arn
  ErrorLogSubscriptionFilter:
    Type: AWS::Logs::SubscriptionFilter
    DependsOn: ErrorLogSubscriptionPermission
    Properties:
      LogGroupName: !Ref ErrorLogGroup
      FilterPattern: '{ $.level = "ERROR" }'
      DestinationArn: !GetAtt ErrorNotificationFunction.Arn
Outputs:
  WebBucketName:
    Value: !Ref WebBucket
    Export:
      Name: !Sub '${AWS::StackName}-WebBucketName'
  CloudFrontDomainName:
    Value: !GetAtt Distribution.DomainName
    Export:
      Name: !Sub '${AWS::StackName}-CloudFrontDomainName'
  CustomDomainName:
    Value: !Ref CustomDomainName
    Export:
      Name: !Sub '${AWS::StackName}-CustomDomainName'
  RestApiId:
    Value: !Ref RequestApi
    Export:
      Name: !Sub '${AWS::StackName}-RestApiId'
  RootResourceId:
    Value: !GetAtt RequestApi.RootResourceId
    Export:
      Name: !Sub '${AWS::StackName}-RootResourceId'
  PortalLoginUsername:
    Value: !Ref PortalLoginUsername
    Export:
      Name: !Sub '${AWS::StackName}-PortalLoginUsername'
  ApiUrl:
    Description: API Gateway invoke URL. Available after phase 4 creates the v1 stage. Set this value to API_BASE in index.html and form.js
    Value: !Sub 'https://${RequestApi}.execute-api.${AWS::Region}.amazonaws.com/v1'
    Export:
      Name: !Sub '${AWS::StackName}-ApiUrl'
  ErrorLogGroupName:
    Value: !Ref ErrorLogGroup
    Export:
      Name: !Sub '${AWS::StackName}-ErrorLogGroupName'

5-2. マネジメントコンソールにて、AWS CloudFormation を開きデプロイ

マネジメントコンソールにて、リージョンを ap-northeast-1(東京) に切り替えてください。ステップ5 以降の作業は東京リージョンで行います。

ステップ2と同じ手順でデプロイします。

スタック名: dev-001-iam-stack-02-portal-auth

パラメーター 説明
Environmentdev環境名
ProjectId001プロジェクト識別子
ProjectInfixiamリソース名のプレフィックス
LambdaLogRetentionInDays7Lambda のロググループ保持期間(日)。CloudWatch Logs の有効な保持期間を指定
LambdaArchitecturex86_64Powertools Layer の CPU アーキテクチャ
LambdaPythonVersionpython3.13Powertools Layer の Python バージョン
CustomDomainNamerequest.{取得したドメイン名[your-domain.link]}申請ポータルのカスタムドメイン
AcmCertificateArn4-1 で発行した証明書の ARNACM 証明書(us-east-1)
HostedZoneId1-1 で作成した Amazon Route 53 ホストゾーン IDドメインのホストゾーン
PortalLoginUsernamepersol-serverworks申請ポータルのログインユーザー名(任意に変更可)
PortalPasswordNotificationTopicArnスタック: dev-001-iam-stack-00-foundation の出力 PortalPasswordNotificationTopicArnパスワード通知用 Amazon SNS
AdminNotificationTopicArnスタック: dev-001-iam-stack-00-foundation の出力 AdminNotificationTopicArn管理者通知用 Amazon SNS(エラー通知にも使用)
WafWebAclArnスタック: dev-001-iam-stack-01-waf(us-east-1)の出力 WebAclArnWAF WebACL の ARN(ステップ4-3 でデプロイ済み)
FoundationStackNamedev-001-iam-stack-00-foundationstack-00-foundation のスタック名(既定値のまま)

Powertools for AWS Lambda Layerの設定: 各Lambdaには、固定ARNではなくSSM Parameter Storeの dynamic reference でPowertools Layerを設定しています。LambdaArchitecture はLayerのCPUアーキテクチャ、LambdaPythonVersion はLayerのPythonバージョンを指定します。既定値は x86_64 / python3.13 です。テンプレートのLambda Runtimeは現在 python3.13 固定のため、通常は既定値を使用してください。

CREATE_COMPLETE を確認し、「出力」タブで以下の値を控えてください(後のステップで使います)。

出力キー 用途
WebBucketNameステップ9 で Web ファイルをアップロードする Amazon S3 バケット名
CustomDomainName申請ポータルのカスタムドメイン(動作確認時に使用)
ApiUrlステップ9 で Web ファイルの API_BASE に設定する URL

ステップ6: フェーズ3(申請登録)のデプロイ

入力チェック、一時フォーム URL の発行、申請登録 API を作成します。

6-1. テンプレートファイルの作成

ローカル PC で stack-03-form-request.yaml を作成し、以下の内容を保存してください。

stack-03-form-request.yaml(ここを押すと展開します)

AWSTemplateFormatVersion: '2010-09-09'
Description: 'IAM User Request Portal - Phase 3: SES, Form Token, Request Registration'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  ProjectInfix:
    Type: String
    Default: iam
  LambdaLogRetentionInDays:
    Type: Number
    Default: 7
    AllowedValues: [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653]
    Description: Number of days to retain Lambda function logs
  OrganizationDomain:
    Type: String
    Description: Organization domain allowed to submit requests (e.g. company.com)
  SesFromAddress:
    Type: String
    Description: SES verified sender e-mail address
  LambdaArchitecture:
    Type: String
    Default: x86_64
    AllowedValues: [x86_64, arm64]
    Description: Lambda function architecture for Powertools layer
  LambdaPythonVersion:
    Type: String
    Default: python3.13
    AllowedValues: [python3.9, python3.10, python3.11, python3.12, python3.13, python3.14]
    Description: Python runtime version for Powertools layer
  FoundationStackName:
    Type: String
    Default: dev-001-iam-stack-00-foundation
  PortalStackName:
    Type: String
    Default: dev-001-iam-stack-02-portal-auth
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
          - ProjectInfix
      - Label:
          default: Lambda Configuration
        Parameters:
          - LambdaLogRetentionInDays
          - LambdaArchitecture
          - LambdaPythonVersion
      - Label:
          default: Form Request Configuration
        Parameters:
          - OrganizationDomain
          - SesFromAddress
      - Label:
          default: Cross-Stack Configuration
        Parameters:
          - FoundationStackName
          - PortalStackName
Resources:
  FormRequestRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-form-request-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: FormRequestPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:GetItem, dynamodb:Query]
                Resource:
                  Fn::Sub:
                    - 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}'
                    - TableName:
                        Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
              - Effect: Allow
                Action: [ses:SendEmail]
                Resource: '*'
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource:
                  Fn::Sub:
                    - 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/${Environment}/${ProjectId}/${ProjectInfix}/lambda-errors:*'
                    - {}
  FormRequestFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-form-request'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt FormRequestRole.Arn
      Timeout: 30
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
          ORGANIZATION_DOMAIN: !Ref OrganizationDomain
          SES_FROM_ADDRESS: !Ref SesFromAddress
          PORTAL_URL:
            Fn::Sub:
              - 'https://${Domain}'
              - Domain:
                  Fn::ImportValue: !Sub '${PortalStackName}-CustomDomainName'
          CORS_ORIGIN:
            Fn::Sub:
              - 'https://${Domain}'
              - Domain:
                  Fn::ImportValue: !Sub '${PortalStackName}-CustomDomainName'
          ERROR_LOG_GROUP_NAME:
            Fn::ImportValue: !Sub '${PortalStackName}-ErrorLogGroupName'
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-form-request'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  FormRequestFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${FormRequestFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  # --- /request (parent resource shared with phase 4) ---
  RequestResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ParentId:
        Fn::ImportValue: !Sub '${PortalStackName}-RootResourceId'
      PathPart: request
  # --- /request/send-form ---
  SendFormResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ParentId: !Ref RequestResource
      PathPart: send-form
  SendFormPostMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ResourceId: !Ref SendFormResource
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${FormRequestFunction.Arn}/invocations'
  SendFormOptionsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ResourceId: !Ref SendFormResource
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        RequestTemplates:
          application/json: '{"statusCode": 200}'
        IntegrationResponses:
          - StatusCode: '200'
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin:
                Fn::Sub:
                  - "'https://${Domain}'"
                  - Domain:
                      Fn::ImportValue: !Sub '${PortalStackName}-CustomDomainName'
              method.response.header.Access-Control-Allow-Credentials: "'true'"
      MethodResponses:
        - StatusCode: '200'
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: false
            method.response.header.Access-Control-Allow-Methods: false
            method.response.header.Access-Control-Allow-Origin: false
            method.response.header.Access-Control-Allow-Credentials: false
  FormRequestApiPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref FormRequestFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn:
        Fn::Sub:
          - 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiId}/*/POST/request/send-form'
          - ApiId:
              Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
Outputs:
  FormRequestFunctionArn:
    Value: !GetAtt FormRequestFunction.Arn
    Export:
      Name: !Sub '${AWS::StackName}-FormRequestFunctionArn'
  RequestResourceId:
    Value: !Ref RequestResource
    Export:
      Name: !Sub '${AWS::StackName}-RequestResourceId'
  SesFromAddress:
    Value: !Ref SesFromAddress
    Export:
      Name: !Sub '${AWS::StackName}-SesFromAddress'

6-2. マネジメントコンソールにて、AWS CloudFormation を開きデプロイ

SesFromAddress の指定方法: Amazon SES で検証済みのドメイン配下の任意のアドレスを指定します。例: noreply@{取得したドメイン名[your-domain.link]}

ステップ2と同じ手順でデプロイします。

スタック名: dev-001-iam-stack-03-form-request

パラメーター 説明
Environmentdev環境名
ProjectId001プロジェクト識別子
ProjectInfixiamリソース名のプレフィックス
LambdaLogRetentionInDays7Lambda のロググループ保持期間(日)。CloudWatch Logs の有効な保持期間を指定
LambdaArchitecturex86_64Powertools Layer の CPU アーキテクチャ
LambdaPythonVersionpython3.13Powertools Layer の Python バージョン
OrganizationDomain例)example.com申請を許可するメールアドレスのドメイン
SesFromAddressnoreply@{取得したドメイン名[your-domain.link]}一時フォーム URL の送信元
FoundationStackNamedev-001-iam-stack-00-foundationstack-00-foundation のスタック名(既定値のまま)
PortalStackNamedev-001-iam-stack-02-portal-authstack-02-portal-auth のスタック名(既定値のまま)

CREATE_COMPLETE を確認してください。


ステップ7: フェーズ4(承認・IAM 操作)のデプロイ

AWS Step Functions による申請種別分岐、管理者承認、IAM ユーザー操作を作成します。

7-1. テンプレートファイルの作成

ローカル PC で stack-04-iam-workflow.yaml を作成し、以下の内容を保存してください。

stack-04-iam-workflow.yaml(ここを押すと展開します)

AWSTemplateFormatVersion: '2010-09-09'
Description: 'IAM User Request Portal - Phase 4: Step Functions, Approval API, IAM Operations'
Parameters:
  Environment:
    Type: String
    Default: dev
  ProjectId:
    Type: String
    Default: '001'
  ProjectInfix:
    Type: String
    Default: iam
  LambdaLogRetentionInDays:
    Type: Number
    Default: 7
    AllowedValues: [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653]
    Description: Number of days to retain Lambda function logs
  AdminNotificationTopicArn:
    Type: String
    Description: Output value of phase 0
  IamUserGroupName:
    Type: String
    Description: Output value of phase 0
  PasswordBucketName:
    Type: String
    Description: Output value of phase 0
  FoundationStackName:
    Type: String
    Default: dev-001-iam-stack-00-foundation
  PortalStackName:
    Type: String
    Default: dev-001-iam-stack-02-portal-auth
  FormRequestStackName:
    Type: String
    Default: dev-001-iam-stack-03-form-request
  EnableConsoleAccess:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Whether to create an IAM login profile (console access)
  LambdaArchitecture:
    Type: String
    Default: x86_64
    AllowedValues: [x86_64, arm64]
    Description: Lambda function architecture for Powertools layer
  LambdaPythonVersion:
    Type: String
    Default: python3.13
    AllowedValues: [python3.9, python3.10, python3.11, python3.12, python3.13, python3.14]
    Description: Python runtime version for Powertools layer
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Stack Configuration
        Parameters:
          - Environment
          - ProjectId
          - ProjectInfix
      - Label:
          default: Lambda Configuration
        Parameters:
          - LambdaLogRetentionInDays
          - LambdaArchitecture
          - LambdaPythonVersion
      - Label:
          default: Workflow and IAM Configuration
        Parameters:
          - AdminNotificationTopicArn
          - IamUserGroupName
          - PasswordBucketName
          - EnableConsoleAccess
      - Label:
          default: Cross-Stack Configuration
        Parameters:
          - FoundationStackName
          - PortalStackName
          - FormRequestStackName
Resources:
  WorkflowRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-workflow-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: [lambda.amazonaws.com, states.amazonaws.com]
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: WorkflowPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [iam:CreateUser, iam:DeleteUser, iam:AddUserToGroup, iam:RemoveUserFromGroup, iam:CreateLoginProfile, iam:UpdateLoginProfile, iam:DeleteLoginProfile, iam:GetUser, iam:GetLoginProfile, iam:ListAccessKeys, iam:DeleteAccessKey, iam:ListUserPolicies, iam:DeleteUserPolicy, iam:ListAttachedUserPolicies, iam:DetachUserPolicy, iam:ListGroupsForUser, iam:ListUserTags, iam:TagUser, iam:PutUserPolicy]
                Resource: '*'
              - Effect: Allow
                Action: [dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:GetItem, dynamodb:Query]
                Resource:
                  Fn::Sub:
                    - 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${TableName}'
                    - TableName:
                        Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
              - Effect: Allow
                Action: [s3:PutObject, s3:GetObject]
                Resource: !Sub 'arn:aws:s3:::${PasswordBucketName}/*'
              - Effect: Allow
                Action: [sns:Publish]
                Resource: !Ref AdminNotificationTopicArn
              - Effect: Allow
                Action: [ses:SendEmail]
                Resource: '*'
              - Effect: Allow
                Action: [lambda:InvokeFunction]
                Resource: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${Environment}-${ProjectId}-${ProjectInfix}-*'
              - Effect: Allow
                Action: [states:StartExecution]
                Resource: !Sub 'arn:aws:states:${AWS::Region}:${AWS::AccountId}:stateMachine:${Environment}-${ProjectId}-${ProjectInfix}-workflow'
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource:
                  Fn::Sub:
                    - 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/${Environment}/${ProjectId}/${ProjectInfix}/lambda-errors:*'
                    - {}
  # --- IAM operation Lambda ---
  IamOperationFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-iam-operation'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt WorkflowRole.Arn
      Timeout: 60
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
          BUCKET_NAME: !Ref PasswordBucketName
          SNS_TOPIC_ARN: !Ref AdminNotificationTopicArn
          IAM_GROUP_NAME: !Ref IamUserGroupName
          SES_FROM_ADDRESS:
            Fn::ImportValue: !Sub '${FormRequestStackName}-SesFromAddress'
          ENABLE_CONSOLE_ACCESS: !Ref EnableConsoleAccess
          ERROR_LOG_GROUP_NAME:
            Fn::ImportValue: !Sub '${PortalStackName}-ErrorLogGroupName'
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-iam-operation'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  IamOperationFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${IamOperationFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  # --- Step Functions state machine ---
  IamWorkflowStateMachine:
    Type: AWS::StepFunctions::StateMachine
    Properties:
      StateMachineName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-workflow'
      RoleArn: !GetAtt WorkflowRole.Arn
      DefinitionString: !Sub |
        {
          "StartAt": "SelectOperation",
          "States": {
            "SelectOperation": {
              "Type": "Choice",
              "Choices": [
                {"Variable": "$.operationType", "StringEquals": "CREATE_EXTEND_USER", "Next": "CreateUser"},
                {"Variable": "$.operationType", "StringEquals": "DELETE_USER", "Next": "DeleteUser"},
                {"Variable": "$.operationType", "StringEquals": "RESET_PASSWORD", "Next": "ResetPassword"}
              ],
              "Default": "Failed"
            },
            "CreateUser": {
              "Type": "Task",
              "Resource": "${IamOperationFunction.Arn}",
              "End": true
            },
            "DeleteUser": {
              "Type": "Task",
              "Resource": "${IamOperationFunction.Arn}",
              "End": true
            },
            "ResetPassword": {
              "Type": "Task",
              "Resource": "${IamOperationFunction.Arn}",
              "End": true
            },
            "Failed": {
              "Type": "Fail",
              "Cause": "Unknown operationType"
            }
          }
        }
  # --- Request submit Lambda ---
  RequestSubmitFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-request-submit'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt WorkflowRole.Arn
      Timeout: 30
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
          SNS_TOPIC_ARN: !Ref AdminNotificationTopicArn
          STATE_MACHINE_ARN: !Ref IamWorkflowStateMachine
          API_BASE:
            Fn::Sub:
              - 'https://${ApiId}.execute-api.${AWS::Region}.amazonaws.com/v1'
              - ApiId:
                  Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
          CORS_ORIGIN:
            Fn::Sub:
              - 'https://${Domain}'
              - Domain:
                  Fn::ImportValue: !Sub '${PortalStackName}-CustomDomainName'
          ERROR_LOG_GROUP_NAME:
            Fn::ImportValue: !Sub '${PortalStackName}-ErrorLogGroupName'
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-request-submit'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  RequestSubmitFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${RequestSubmitFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  # --- Request approve Lambda ---
  RequestApproveFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-request-approve'
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt WorkflowRole.Arn
      Timeout: 30
      Layers:
        - !Sub "{{resolve:ssm:/aws/service/powertools/python/${LambdaArchitecture}/${LambdaPythonVersion}/latest}}"
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub '${FoundationStackName}-RequestTableName'
          SNS_TOPIC_ARN: !Ref AdminNotificationTopicArn
          STATE_MACHINE_ARN: !Ref IamWorkflowStateMachine
          SES_FROM_ADDRESS:
            Fn::ImportValue: !Sub '${FormRequestStackName}-SesFromAddress'
          ERROR_LOG_GROUP_NAME:
            Fn::ImportValue: !Sub '${PortalStackName}-ErrorLogGroupName'
          POWERTOOLS_SERVICE_NAME: !Sub '${Environment}-${ProjectId}-${ProjectInfix}-request-approve'
      Code:
        ZipFile: |
          # Placeholder. The implementation is deployed separately.
          def lambda_handler(event, context):
              return {'statusCode': 200}
  RequestApproveFunctionLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    Properties:
      LogGroupName: !Sub '/aws/lambda/${RequestApproveFunction}'
      RetentionInDays: !Ref LambdaLogRetentionInDays
  # --- /request/submit ---
  SubmitResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ParentId:
        Fn::ImportValue: !Sub '${FormRequestStackName}-RequestResourceId'
      PathPart: submit
  SubmitPostMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ResourceId: !Ref SubmitResource
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RequestSubmitFunction.Arn}/invocations'
  SubmitOptionsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ResourceId: !Ref SubmitResource
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        RequestTemplates:
          application/json: '{"statusCode": 200}'
        IntegrationResponses:
          - StatusCode: '200'
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type'"
              method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin:
                Fn::Sub:
                  - "'https://${Domain}'"
                  - Domain:
                      Fn::ImportValue: !Sub '${PortalStackName}-CustomDomainName'
              method.response.header.Access-Control-Allow-Credentials: "'true'"
      MethodResponses:
        - StatusCode: '200'
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: false
            method.response.header.Access-Control-Allow-Methods: false
            method.response.header.Access-Control-Allow-Origin: false
            method.response.header.Access-Control-Allow-Credentials: false
  SubmitApiPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref RequestSubmitFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn:
        Fn::Sub:
          - 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiId}/*/POST/request/submit'
          - ApiId:
              Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
  # --- /request/approve ---
  ApproveResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ParentId:
        Fn::ImportValue: !Sub '${FormRequestStackName}-RequestResourceId'
      PathPart: approve
  ApproveGetMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      ResourceId: !Ref ApproveResource
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RequestApproveFunction.Arn}/invocations'
  ApproveApiPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref RequestApproveFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn:
        Fn::Sub:
          - 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiId}/*/GET/request/approve'
          - ApiId:
              Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
  # --- API Gateway Deployment and Stage (created after all methods exist) ---
  ApiDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - SubmitPostMethod
      - SubmitOptionsMethod
      - ApproveGetMethod
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
  RequestApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId:
        Fn::ImportValue: !Sub '${PortalStackName}-RestApiId'
      StageName: v1
      DeploymentId: !Ref ApiDeployment
Outputs:
  StateMachineArn:
    Value: !Ref IamWorkflowStateMachine
    Export:
      Name: !Sub '${AWS::StackName}-StateMachineArn'
  ApiStageName:
    Description: Deployed API Gateway stage name
    Value: !Ref RequestApiStage

7-2. マネジメントコンソールにて、AWS CloudFormation を開きデプロイ

ステップ2と同じ手順でデプロイします。

スタック名: dev-001-iam-stack-04-iam-workflow

パラメーター 説明
Environmentdev環境名
ProjectId001プロジェクト識別子
ProjectInfixiamリソース名のプレフィックス
LambdaLogRetentionInDays7Lambda のロググループ保持期間(日)。CloudWatch Logs の有効な保持期間を指定
LambdaArchitecturex86_64Powertools Layer の CPU アーキテクチャ
LambdaPythonVersionpython3.13Powertools Layer の Python バージョン
AdminNotificationTopicArnスタック: dev-001-iam-stack-00-foundation の出力 AdminNotificationTopicArn管理者通知用 Amazon SNS
IamUserGroupNameスタック: dev-001-iam-stack-00-foundation の出力 IamUserGroupNameIAM ユーザー追加先グループ
PasswordBucketNameスタック: dev-001-iam-stack-00-foundation の出力 PasswordBucketName認証情報一時保存用 Amazon S3
FoundationStackNamedev-001-iam-stack-00-foundationstack-00-foundation のスタック名(既定値のまま)
PortalStackNamedev-001-iam-stack-02-portal-authstack-02-portal-auth のスタック名(既定値のまま)
FormRequestStackNamedev-001-iam-stack-03-form-requeststack-03-form-request のスタック名(既定値のまま)
EnableConsoleAccesstrueコンソールアクセス(LoginProfile)を作成するか

CREATE_COMPLETE を確認してください。このスタックで Amazon API Gateway のステージ( v1 )が作成され、API が呼び出せるようになります。

ポイント: Amazon API Gateway の各エンドポイントは3つのスタックに分散して定義されています。/auth/login はフェーズ2、/request/send-form はフェーズ3、/request/submit/request/approve はフェーズ4です。ステージ(デプロイ)はすべてのメソッドが揃うフェーズ4で作成されるため、必ずフェーズ2→3→4の順にデプロイしてください


ステップ8: AWS Lambda 関数コードの設定

AWS CloudFormation はプレースホルダーコードで AWS Lambda を作成するため、マネジメントコンソールにて AWS Lambda を開き、実際の処理コードを設定します。設定対象は次の7つです。

AWS Lambda 関数名 役割 作成スタック
dev-001-iam-password-update月次パスワードの発行と通知フェーズ2
dev-001-iam-authポータルのログイン認証フェーズ2
dev-001-iam-form-request入力チェックと一時フォーム URL の発行フェーズ3
dev-001-iam-iam-operationIAM ユーザーの作成/延長・削除・パスワードリセットフェーズ4
dev-001-iam-request-submit申請内容の登録と承認依頼の発行フェーズ4
dev-001-iam-request-approve承認・拒否の受付と AWS Step Functions 起動フェーズ4
dev-001-iam-error-notificationエラー通知の整形と管理者への SNS 送信フェーズ2

Powertools for AWS Lambda Layerの設定: 各Lambdaには、固定ARNではなくSSM Parameter Storeの dynamic reference でPowertools Layerを設定しています。LambdaArchitecture はLayerのCPUアーキテクチャ、LambdaPythonVersion はLayerのPythonバージョンを指定します。既定値は x86_64 / python3.13 です。テンプレートのLambda Runtimeは現在 python3.13 固定のため、通常は既定値を使用してください。別のPythonバージョンを使用する場合は、LayerのバージョンとLambda Runtimeを一致させる必要があります。

8-1. 月次パスワード更新 AWS Lambda

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-password-update を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

月次パスワード更新 AWS Lambda のコード(ここを押すと展開します)

import json
import os
import hashlib
import secrets
import time
import traceback
import boto3
from aws_lambda_powertools import Logger
logger = Logger()
dynamodb = boto3.resource('dynamodb')
sns = boto3.client('sns')
logs_client = boto3.client('logs')
TABLE_NAME = os.environ['TABLE_NAME']
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
PORTAL_URL = os.environ.get('PORTAL_URL', '')
LOGIN_USERNAME = 'persol-serverworks'
ERROR_LOG_GROUP_NAME = os.environ.get('ERROR_LOG_GROUP_NAME', '')
@logger.inject_lambda_context
def lambda_handler(event, context):
    try:
        table = dynamodb.Table(TABLE_NAME)
        # ランダムパスワード生成(16文字)
        password = secrets.token_urlsafe(16)
        # PBKDF2 でハッシュ化
        salt = secrets.token_hex(16)
        password_hash = hashlib.pbkdf2_hmac(
            'sha256', password.encode(), salt.encode(), 100000
        ).hex()
        # DynamoDB に保存
        version = str(int(time.time()))
        table.put_item(Item={
            'pk': 'PORTAL_CONFIG',
            'sk': 'CURRENT',
            'passwordHash': password_hash,
            'salt': salt,
            'passwordVersion': version,
            'updatedAt': int(time.time())
        })
        # SNS で管理者に通知
        sns.publish(
            TopicArn=SNS_TOPIC_ARN,
            Subject='【通知】月次パスワード 更新通知',
            Message=(
                'IAM ユーザー申請ポータルの月次パスワードが更新されました。\n\n'
                f'ログイン URL: {PORTAL_URL}\n'
                f'ユーザー名: {LOGIN_USERNAME}\n'
                f'パスワード: {password}\n\n'
                'このパスワードは次回更新(毎月1日)まで有効です。'
            )
        )
        return {'statusCode': 200, 'body': 'Password updated'}
    except Exception as e:
        logger.exception('Unhandled exception occurred')
        write_error_to_dedicated_log_group(e, context)
        raise
def write_error_to_dedicated_log_group(error, context, extra=None):
    """例外情報をエラー専用ロググループに構造化ログとして書き込む"""
    if not ERROR_LOG_GROUP_NAME:
        return
    function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
    stream_name = f'{function_name}/{int(time.time() * 1000)}'
    try:
        logs_client.create_log_stream(logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name)
    except logs_client.exceptions.ResourceAlreadyExistsException:
        pass
    log_event = {
        'level': 'ERROR',
        'service': os.environ.get('POWERTOOLS_SERVICE_NAME', function_name),
        'function_name': function_name,
        'request_id': getattr(context, 'aws_request_id', 'N/A'),
        'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'message': str(error),
        'exception_type': type(error).__name__,
        'stacktrace': traceback.format_exc()
    }
    if extra:
        log_event['extra'] = extra
    logs_client.put_log_events(
        logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name,
        logEvents=[{'timestamp': int(time.time() * 1000), 'message': json.dumps(log_event, ensure_ascii=False)}]
    )

8-2. 認証 AWS Lambda(ログイン処理)

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-auth を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

認証 AWS Lambda のコード(ここを押すと展開します)

import json
import os
import hashlib
import secrets
import time
import traceback
import boto3
from aws_lambda_powertools import Logger
logger = Logger()
dynamodb = boto3.resource('dynamodb')
logs_client = boto3.client('logs')
TABLE_NAME = os.environ['TABLE_NAME']
LOGIN_USERNAME = os.environ.get('LOGIN_USERNAME', 'persol-serverworks')
ERROR_LOG_GROUP_NAME = os.environ.get('ERROR_LOG_GROUP_NAME', '')
CORS_ORIGIN = os.environ.get('CORS_ORIGIN', '*')
CORS_HEADERS = {
    'Access-Control-Allow-Origin': CORS_ORIGIN,
    'Access-Control-Allow-Credentials': 'true',
    'Content-Type': 'application/json'
}
@logger.inject_lambda_context
def lambda_handler(event, context):
    try:
        table = dynamodb.Table(TABLE_NAME)
        body = json.loads(event.get('body', '{}'))
        username = body.get('username', '')
        password = body.get('password', '')
        # ユーザー名照合
        if username != LOGIN_USERNAME:
            return response(401, {'message': 'ユーザー名またはパスワードが正しくありません'})
        # 現在のポータル設定を取得
        resp = table.get_item(Key={'pk': 'PORTAL_CONFIG', 'sk': 'CURRENT'})
        config = resp.get('Item')
        if not config:
            return response(401, {'message': 'ポータルが初期化されていません'})
        # パスワード照合
        password_hash = hashlib.pbkdf2_hmac(
            'sha256', password.encode(), config['salt'].encode(), 100000
        ).hex()
        if password_hash != config['passwordHash']:
            return response(401, {'message': 'ユーザー名またはパスワードが正しくありません'})
        # セッション発行
        session_token = secrets.token_urlsafe(32)
        session_hash = hashlib.sha256(session_token.encode()).hexdigest()
        table.put_item(Item={
            'pk': f'SESSION#{session_hash}',
            'sk': 'SESSION',
            'passwordVersion': config['passwordVersion'],
            'createdAt': int(time.time()),
            'expiresAt': int(time.time()) + 3600
        })
        headers = dict(CORS_HEADERS)
        headers['Set-Cookie'] = f'session={session_token}; HttpOnly; Secure; SameSite=Lax; Max-Age=3600'
        return {
            'statusCode': 200,
            'headers': headers,
            'body': json.dumps({'message': 'OK'})
        }
    except Exception as e:
        logger.exception('Unhandled exception occurred')
        write_error_to_dedicated_log_group(e, context)
        raise
def response(status, body):
    return {
        'statusCode': status,
        'headers': CORS_HEADERS,
        'body': json.dumps(body)
    }
def write_error_to_dedicated_log_group(error, context, extra=None):
    """例外情報をエラー専用ロググループに構造化ログとして書き込む"""
    if not ERROR_LOG_GROUP_NAME:
        return
    function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
    stream_name = f'{function_name}/{int(time.time() * 1000)}'
    try:
        logs_client.create_log_stream(logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name)
    except logs_client.exceptions.ResourceAlreadyExistsException:
        pass
    log_event = {
        'level': 'ERROR',
        'service': os.environ.get('POWERTOOLS_SERVICE_NAME', function_name),
        'function_name': function_name,
        'request_id': getattr(context, 'aws_request_id', 'N/A'),
        'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'message': str(error),
        'exception_type': type(error).__name__,
        'stacktrace': traceback.format_exc()
    }
    if extra:
        log_event['extra'] = extra
    logs_client.put_log_events(
        logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name,
        logEvents=[{'timestamp': int(time.time() * 1000), 'message': json.dumps(log_event, ensure_ascii=False)}]
    )

8-3. フォーム発行 AWS Lambda(入力チェック + Amazon SES 送信)

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-form-request を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

フォーム発行 AWS Lambda のコード(ここを押すと展開します)

import json
import os
import hashlib
import secrets
import time
import traceback
import boto3
from aws_lambda_powertools import Logger
logger = Logger()
dynamodb = boto3.resource('dynamodb')
ses = boto3.client('ses')
logs_client = boto3.client('logs')
TABLE_NAME = os.environ['TABLE_NAME']
ORGANIZATION_DOMAIN = os.environ['ORGANIZATION_DOMAIN']
SES_FROM_ADDRESS = os.environ['SES_FROM_ADDRESS']
PORTAL_URL = os.environ['PORTAL_URL']
ERROR_LOG_GROUP_NAME = os.environ.get('ERROR_LOG_GROUP_NAME', '')
CORS_ORIGIN = os.environ.get('CORS_ORIGIN', '*')
CORS_HEADERS = {
    'Access-Control-Allow-Origin': CORS_ORIGIN,
    'Access-Control-Allow-Credentials': 'true',
    'Content-Type': 'application/json'
}
@logger.inject_lambda_context
def lambda_handler(event, context):
    try:
        table = dynamodb.Table(TABLE_NAME)
        body = json.loads(event.get('body', '{}'))
        email = body.get('email', '').strip().lower()
        operation_type = body.get('operationType', '')
        # メールアドレス形式・組織ドメインのチェック
        if not email or '@' not in email:
            return response(400, {'message': 'メールアドレスの形式が正しくありません'})
        domain = email.split('@')[1]
        if not (domain == ORGANIZATION_DOMAIN or domain.endswith(f'.{ORGANIZATION_DOMAIN}')):
            return response(400, {'message': '組織のメールアドレスを入力してください'})
        # 申請種別の検証
        allowed = ['CREATE_EXTEND_USER', 'DELETE_USER', 'RESET_PASSWORD']
        if operation_type not in allowed:
            return response(400, {'message': '申請種別が正しくありません'})
        # トークン生成
        token = secrets.token_urlsafe(32)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        # DynamoDB に保存(TTL 15分)
        table.put_item(Item={
            'pk': f'FORM_TOKEN#{token_hash}',
            'sk': 'FORM_TOKEN',
            'email': email,
            'operationType': operation_type,
            'createdAt': int(time.time()),
            'expiresAt': int(time.time()) + 7200,
            'used': False
        })
        # フォーム URL 決定
        form_paths = {
            'CREATE_EXTEND_USER': '/form/create.html',
            'DELETE_USER': '/form/delete.html',
            'RESET_PASSWORD': '/form/reset-password.html'
        }
        form_url = f'{PORTAL_URL}{form_paths[operation_type]}?token={token}'
        # SES でメール送信
        ses.send_email(
            Source=SES_FROM_ADDRESS,
            Destination={'ToAddresses': [email]},
            Message={
                'Subject': {'Data': '【IAM申請】申請フォーム案内'},
                'Body': {'Text': {'Data': f'以下の URL から申請フォームにアクセスしてください。\n\n{form_url}\n\nこの URL は120分間有効です。'}}
            }
        )
        return response(200, {'message': 'メールを送信しました'})
    except Exception as e:
        logger.exception('Unhandled exception occurred')
        write_error_to_dedicated_log_group(e, context)
        raise
def response(status, body):
    return {
        'statusCode': status,
        'headers': CORS_HEADERS,
        'body': json.dumps(body)
    }
def write_error_to_dedicated_log_group(error, context, extra=None):
    """例外情報をエラー専用ロググループに構造化ログとして書き込む"""
    if not ERROR_LOG_GROUP_NAME:
        return
    function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
    stream_name = f'{function_name}/{int(time.time() * 1000)}'
    try:
        logs_client.create_log_stream(logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name)
    except logs_client.exceptions.ResourceAlreadyExistsException:
        pass
    log_event = {
        'level': 'ERROR',
        'service': os.environ.get('POWERTOOLS_SERVICE_NAME', function_name),
        'function_name': function_name,
        'request_id': getattr(context, 'aws_request_id', 'N/A'),
        'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'message': str(error),
        'exception_type': type(error).__name__,
        'stacktrace': traceback.format_exc()
    }
    if extra:
        log_event['extra'] = extra
    logs_client.put_log_events(
        logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name,
        logEvents=[{'timestamp': int(time.time() * 1000), 'message': json.dumps(log_event, ensure_ascii=False)}]
    )

8-4. IAM 操作 AWS Lambda(作成/延長・削除・リセット)

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-iam-operation を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

IAM 操作 AWS Lambda のコード(ここを押すと展開します)

import json
import os
import hashlib
import secrets
import string
import random
import time
import traceback
import boto3
from botocore.exceptions import ClientError
from aws_lambda_powertools import Logger
logger = Logger()
iam = boto3.client('iam')
dynamodb = boto3.resource('dynamodb')
s3 = boto3.client('s3')
sns = boto3.client('sns')
ses = boto3.client('ses')
logs_client = boto3.client('logs')
TABLE_NAME = os.environ['TABLE_NAME']
BUCKET_NAME = os.environ['BUCKET_NAME']
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
IAM_GROUP_NAME = os.environ['IAM_GROUP_NAME']
SES_FROM_ADDRESS = os.environ.get('SES_FROM_ADDRESS', '')
ENABLE_CONSOLE_ACCESS = os.environ.get('ENABLE_CONSOLE_ACCESS', 'false').lower() == 'true'
ERROR_LOG_GROUP_NAME = os.environ.get('ERROR_LOG_GROUP_NAME', '')
@logger.inject_lambda_context
def lambda_handler(event, context):
    try:
        operation = event.get('operationType')
        if operation == 'CREATE_EXTEND_USER':
            return handle_create_extend(event)
        elif operation == 'DELETE_USER':
            return handle_delete(event)
        elif operation == 'RESET_PASSWORD':
            return handle_reset_password(event)
        else:
            return {'statusCode': 400, 'body': 'Unknown operationType'}
    except Exception as e:
        logger.exception('Unhandled exception occurred')
        write_error_to_dedicated_log_group(e, context, extra={'event': event})
        raise
def handle_create_extend(event):
    people = event.get('people', [])
    requested_by = event.get('requestedBy', '')
    results = []
    for person in people:
        email = person['email'].strip().lower()
        username = generate_username(email)
        try:
            iam.get_user(UserName=username)
            result = extend_user(username, person, email, requested_by)
        except ClientError as e:
            if e.response['Error']['Code'] == 'NoSuchEntity':
                result = create_user(username, person, email, requested_by)
            else:
                raise
        results.append(result)
    return {'statusCode': 200, 'results': results}
def create_user(username, person, email, requested_by):
    # IAM ユーザー作成
    iam.create_user(UserName=username, Tags=[
        {'Key': 'Email', 'Value': email},
        {'Key': 'Name', 'Value': person.get('name', '')},
        {'Key': 'StartDate', 'Value': person.get('startDate', '')},
        {'Key': 'EndDate', 'Value': person.get('endDate', '')}
    ])
    iam.add_user_to_group(GroupName=IAM_GROUP_NAME, UserName=username)
    # カスタムパスワード(12桁乱数)を常に発行
    password = generate_password_12()
    # コンソールアクセスが有効な場合のみ IAM にパスワードを設定
    if ENABLE_CONSOLE_ACCESS:
        iam.create_login_profile(
            UserName=username, Password=password, PasswordResetRequired=True
        )
    # S3 に認証情報を保存
    key = f'iam-credentials/{username}-{int(time.time())}.txt'
    if ENABLE_CONSOLE_ACCESS:
        body_text = (
            f'IAM ユーザー作成完了\n'
            f'========================================\n\n'
            f'IAM ユーザー名: {username}\n'
            f'初期パスワード: {password}\n\n'
            f'初回ログイン時にパスワード変更が必要です。\n'
        )
    else:
        body_text = (
            f'IAM ユーザー作成完了\n'
            f'========================================\n\n'
            f'IAM ユーザー名: {username}\n'
            f'※ マネジメントコンソールへの利用が許可されていないため、パスワードは未設定です。\n'
        )
    s3.put_object(Bucket=BUCKET_NAME, Key=key, Body=body_text.encode('utf-8'), ContentType='text/plain; charset=utf-8')
    # 48時間有効の署名付き URL
    url = s3.generate_presigned_url(
        'get_object', Params={'Bucket': BUCKET_NAME, 'Key': key}, ExpiresIn=172800
    )
    # 利用者に SES で通知
    if SES_FROM_ADDRESS and email:
        try:
            ses.send_email(
                Source=SES_FROM_ADDRESS,
                Destination={'ToAddresses': [email]},
                Message={
                    'Subject': {'Data': '【完了】IAM ユーザー作成 完了通知'},
                    'Body': {'Text': {'Data': (
                        f'ご申請いただいた IAM ユーザーの作成が完了しました。\n\n'
                        f'━━━━━━━━━━━━━━━━━━━━━━━━\n'
                        f'IAM ユーザー名: {username}\n'
                        f'マネジメントコンソール: https://{get_account_id()}.signin.aws.amazon.com/console\n'
                        f'━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
                        f'メール受信から 48時間以内に、以下の URL へアクセスし認証情報を控えてください。\n'
                        f'時間経過後は URL にアクセスできなくなりますので、ご注意ください。\n\n'
                        f'認証情報の取得 URL:\n{url}\n'
                    )}}
                }
            )
        except ClientError as e:
            print(f"SES send error: {e}")
    # 管理者に作成完了を通知
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject='【管理者向け】IAM ユーザー作成 完了通知',
        Message=(
            f'IAM ユーザーを作成しました。\n\n'
            f'IAM ユーザー名: {username}\n'
            f'申請者メール: {requested_by}\n'
            f'氏名: {person.get("name", "")}\n'
            f'利用期間: {person.get("startDate")} 〜 {person.get("endDate")}\n\n'
            f'利用者へ認証情報を直接通知済みです。'
        )
    )
    return {'username': username, 'status': 'CREATED'}
def extend_user(username, person, email, requested_by):
    end_date = person.get('endDate', '')
    tags_resp = iam.list_user_tags(UserName=username)
    current_end = ''
    for tag in tags_resp.get('Tags', []):
        if tag['Key'] == 'EndDate':
            current_end = tag['Value']
    if current_end and end_date <= current_end:
        # 申請した利用期間が現在の利用期限より短い場合、処理を中止する
        # 管理者に SNS で通知
        sns.publish(
            TopicArn=SNS_TOPIC_ARN,
            Subject='【管理者向け】利用期間延長 処理中止通知',
            Message=(
                f'IAM ユーザーの延長申請を受けましたが、申請された利用終了日が'
                f'現在の利用期限以前のため、処理を中止しました。\n\n'
                f'対象 IAM ユーザー: {username}\n'
                f'申請者メール: {requested_by}\n'
                f'現在の利用期限: {current_end}\n'
                f'申請された利用終了日: {end_date}\n\n'
                f'延長が必要な場合は、現在の利用期限より後の日付で再申請してください。'
            )
        )
        # 申請者に SES で通知
        if SES_FROM_ADDRESS and requested_by:
            try:
                ses.send_email(
                    Source=SES_FROM_ADDRESS,
                    Destination={'ToAddresses': [requested_by]},
                    Message={
                        'Subject': {'Data': '【通知】利用期間延長 処理中止通知'},
                        'Body': {'Text': {'Data': (
                            f'IAM ユーザーの延長申請を処理しましたが、申請された利用終了日が'
                            f'現在の利用期限以前のため、処理を中止しました。\n\n'
                            f'対象 IAM ユーザー: {username}\n'
                            f'現在の利用期限: {current_end}\n'
                            f'申請された利用終了日: {end_date}\n\n'
                            f'延長が必要な場合は、現在の利用期限より後の日付で再申請してください。'
                        )}}
                    }
                )
            except ClientError as e:
                print(f"SES send error: {e}")
        return {'username': username, 'status': 'NO_EXTENSION_NEEDED', 'currentEndDate': current_end}
    iam.tag_user(UserName=username, Tags=[{'Key': 'EndDate', 'Value': end_date}])
    if SES_FROM_ADDRESS and email:
        try:
            ses.send_email(
                Source=SES_FROM_ADDRESS,
                Destination={'ToAddresses': [email]},
                Message={
                    'Subject': {'Data': '【完了】利用期間延長 完了通知'},
                    'Body': {'Text': {'Data': (
                        f'利用期間が延長されました。\n\n'
                        f'IAM ユーザー: {username}\n'
                        f'新しい利用期限: {end_date}'
                    )}}
                }
            )
        except ClientError as e:
            print(f"SES send error: {e}")
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject='【管理者向け】利用期間延長 完了通知',
        Message=f'IAM ユーザー: {username}\n申請者メール: {requested_by}\n新しい利用期限: {end_date}'
    )
    return {'username': username, 'status': 'EXTENDED', 'newEndDate': end_date}
def handle_delete(event):
    target_email = event.get('targetEmail', '').strip().lower() or event.get('targetUsername', '').strip().lower()
    requested_by = event.get('requestedBy', '')
    username = generate_username(target_email)
    try:
        iam.get_user(UserName=username)
    except ClientError as e:
        if e.response['Error']['Code'] == 'NoSuchEntity':
            # 管理者に SNS で通知
            sns.publish(
                TopicArn=SNS_TOPIC_ARN,
                Subject='【管理者向け】対象 IAM ユーザーが見つかりません 処理中止通知',
                Message=(
                    f'IAM ユーザーの削除申請を処理しましたが、'
                    f'対象ユーザーが存在しないため処理を中止しました。\n\n'
                    f'対象 IAM ユーザー: {username}\n'
                    f'申請者メール: {requested_by}'
                )
            )
            # 申請者に SES で通知
            if SES_FROM_ADDRESS and requested_by:
                try:
                    ses.send_email(
                        Source=SES_FROM_ADDRESS,
                        Destination={'ToAddresses': [requested_by]},
                        Message={
                            'Subject': {'Data': '【通知】IAM ユーザー削除 処理中止通知'},
                            'Body': {'Text': {'Data': (
                                f'IAM ユーザーの削除申請を処理しましたが、'
                                f'対象ユーザーが存在しないため処理を中止しました。\n\n'
                                f'対象 IAM ユーザー: {username}\n\n'
                                f'IAM ユーザー名に誤りがないかご確認のうえ、'
                                f'必要に応じて再申請してください。'
                            )}}
                        }
                    )
                except ClientError as ses_err:
                    print(f"SES send error: {ses_err}")
            return {'username': username, 'status': 'TARGET_NOT_FOUND'}
        raise
    keys = iam.list_access_keys(UserName=username).get('AccessKeyMetadata', [])
    for k in keys:
        iam.delete_access_key(UserName=username, AccessKeyId=k['AccessKeyId'])
    try:
        iam.delete_login_profile(UserName=username)
    except ClientError:
        pass
    policies = iam.list_user_policies(UserName=username).get('PolicyNames', [])
    for p in policies:
        iam.delete_user_policy(UserName=username, PolicyName=p)
    attached = iam.list_attached_user_policies(UserName=username).get('AttachedPolicies', [])
    for p in attached:
        iam.detach_user_policy(UserName=username, PolicyArn=p['PolicyArn'])
    groups = iam.list_groups_for_user(UserName=username).get('Groups', [])
    for g in groups:
        iam.remove_user_from_group(GroupName=g['GroupName'], UserName=username)
    # 利用者にSESで削除完了通知を送信
    if SES_FROM_ADDRESS and target_email:
        try:
            ses.send_email(
                Source=SES_FROM_ADDRESS,
                Destination={'ToAddresses': [target_email]},
                Message={
                    'Subject': {'Data': '【完了】IAM ユーザー削除 完了通知'},
                    'Body': {'Text': {'Data': (
                        f'IAM ユーザー の削除が完了しました。\n\n'
                        f'━━━━━━━━━━━━━━━━━━━━━━━━\n'
                        f'IAM ユーザー名: {username}\n'
                        f'━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
                        f'ご不明な点がございましたら管理者にお問い合わせください。\n'
                    )}}
                }
            )
        except ClientError as e:
            print(f"SES send error: {e}")
    iam.delete_user(UserName=username)
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject='【管理者向け】IAM ユーザー削除 完了通知',
        Message=f'IAM ユーザー: {username}\n申請者メール: {requested_by}\n\n利用者へ認証情報を直接通知済みです。'
    )
    return {'username': username, 'status': 'DELETE_COMPLETED'}
def handle_reset_password(event):
    target_email = event.get('targetEmail', '').strip().lower()
    requested_by = event.get('requestedBy', '')
    username = generate_username(target_email)
    # コンソールアクセスが無効の場合、パスワードリセットは不要
    if not ENABLE_CONSOLE_ACCESS:
        # 管理者に SNS で通知
        sns.publish(
            TopicArn=SNS_TOPIC_ARN,
            Subject='【管理者向け】パスワードリセット 処理中止通知',
            Message=(
                f'パスワードリセット申請を受けましたが、コンソールアクセスが'
                f'無効のため処理を中止しました。\n\n'
                f'対象 IAM ユーザー: {username}\n'
                f'申請者メール: {requested_by}'
            )
        )
        # 申請者に SES で通知
        if SES_FROM_ADDRESS and requested_by:
            try:
                ses.send_email(
                    Source=SES_FROM_ADDRESS,
                    Destination={'ToAddresses': [requested_by]},
                    Message={
                        'Subject': {'Data': '【通知】パスワードリセット 処理中止通知'},
                        'Body': {'Text': {'Data': (
                            f'パスワードリセット申請を受けましたが、コンソールアクセスが'
                            f'無効のため処理を中止しました。\n\n'
                            f'対象 IAM ユーザー: {username}\n\n'
                            f'管理者にお問い合わせください。'
                        )}}
                    }
                )
            except ClientError as ses_err:
                print(f"SES send error: {ses_err}")
        return {'username': username, 'status': 'CONSOLE_ACCESS_DISABLED'}
    try:
        iam.get_user(UserName=username)
    except ClientError as e:
        if e.response['Error']['Code'] == 'NoSuchEntity':
            # 管理者に SNS で通知
            sns.publish(
                TopicArn=SNS_TOPIC_ARN,
                Subject='【管理者向け】対象 IAM ユーザーが見つかりません 処理中止通知',
                Message=(
                    f'パスワードリセット申請を処理しましたが、'
                    f'対象ユーザーが存在しないため処理を中止しました。\n\n'
                    f'対象 IAM ユーザー: {username}\n'
                    f'申請者メール: {requested_by}'
                )
            )
            # 申請者に SES で通知
            if SES_FROM_ADDRESS and requested_by:
                try:
                    ses.send_email(
                        Source=SES_FROM_ADDRESS,
                        Destination={'ToAddresses': [requested_by]},
                        Message={
                            'Subject': {'Data': '【通知】パスワードリセット 処理中止通知'},
                            'Body': {'Text': {'Data': (
                                f'パスワードリセット申請を処理しましたが、'
                                f'対象ユーザーが存在しないため処理を中止しました。\n\n'
                                f'対象 IAM ユーザー: {username}\n\n'
                                f'メールアドレスに誤りがないかご確認のうえ、'
                                f'必要に応じて再申請してください。'
                            )}}
                        }
                    )
                except ClientError as ses_err:
                    print(f"SES send error: {ses_err}")
            return {'username': username, 'status': 'TARGET_NOT_FOUND'}
        raise
    # カスタムパスワード(12桁乱数)を常に発行
    password = generate_password_12()
    # IAM にパスワードを設定(ENABLE_CONSOLE_ACCESS=false は早期リターン済み)
    try:
        iam.update_login_profile(
            UserName=username, Password=password, PasswordResetRequired=True
        )
    except ClientError as e:
        if e.response['Error']['Code'] == 'NoSuchEntity':
            # LoginProfile が存在しない場合は作成
            iam.create_login_profile(
                UserName=username, Password=password, PasswordResetRequired=True
            )
        else:
            raise
    # S3 に保存
    key = f'iam-password-reset/{username}-{int(time.time())}.txt'
    body_text = (
        f'パスワードリセット完了\n'
        f'========================================\n\n'
        f'IAM ユーザー名: {username}\n'
        f'新しいパスワード: {password}\n\n'
        f'ログイン時にパスワード変更が必要です。\n'
    )
    s3.put_object(Bucket=BUCKET_NAME, Key=key, Body=body_text.encode('utf-8'), ContentType='text/plain; charset=utf-8')
    # 48時間有効の署名付き URL
    url = s3.generate_presigned_url(
        'get_object', Params={'Bucket': BUCKET_NAME, 'Key': key}, ExpiresIn=172800
    )
    # 利用者に通知
    if SES_FROM_ADDRESS and target_email:
        try:
            ses.send_email(
                Source=SES_FROM_ADDRESS,
                Destination={'ToAddresses': [target_email]},
                Message={
                    'Subject': {'Data': '【完了】パスワードリセット 完了通知'},
                    'Body': {'Text': {'Data': (
                        f'IAMユーザー のパスワードリセットが完了しました。\n\n'
                        f'━━━━━━━━━━━━━━━━━━━━━━━━\n'
                        f'IAM ユーザー名: {username}\n'
                        f'━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
                        f'メール受信から 48時間以内に、以下の URL へアクセスし認証情報を控えてください。\n'
                        f'時間経過後は URL にアクセスできなくなりますので、ご注意ください。\n\n'
                        f'認証情報の取得 URL:\n{url}\n'
                    )}}
                }
            )
        except ClientError as e:
            print(f"SES send error: {e}")
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject='【管理者向け】パスワードリセット 完了通知',
        Message=f'IAM ユーザー: {username}\n申請者メール: {requested_by}\n\n利用者へ認証情報を直接通知済みです。'
    )
    return {'username': username, 'status': 'PASSWORD_RESET_COMPLETED'}
def generate_username(email):
    """メールアドレスをそのままIAMユーザー名として使用"""
    return email
def get_account_id():
    """AWSアカウントIDを取得"""
    sts_client = boto3.client('sts')
    return sts_client.get_caller_identity()['Account']
def generate_password_12():
    """12桁のカスタムパスワードを生成(大文字・小文字・数字・記号を含む)"""
    upper = random.choice(string.ascii_uppercase)
    lower = random.choice(string.ascii_lowercase)
    digit = random.choice(string.digits)
    symbol = random.choice('!@#$%^&*')
    # 残り8文字をランダムに
    all_chars = string.ascii_letters + string.digits + '!@#$%^&*'
    rest = ''.join(random.choices(all_chars, k=8))
    password_list = list(upper + lower + digit + symbol + rest)
    random.shuffle(password_list)
    return ''.join(password_list)
def write_error_to_dedicated_log_group(error, context, extra=None):
    """例外情報をエラー専用ロググループに構造化ログとして書き込む"""
    if not ERROR_LOG_GROUP_NAME:
        return
    function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
    stream_name = f'{function_name}/{int(time.time() * 1000)}'
    try:
        logs_client.create_log_stream(logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name)
    except logs_client.exceptions.ResourceAlreadyExistsException:
        pass
    log_event = {
        'level': 'ERROR',
        'service': os.environ.get('POWERTOOLS_SERVICE_NAME', function_name),
        'function_name': function_name,
        'request_id': getattr(context, 'aws_request_id', 'N/A'),
        'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'message': str(error),
        'exception_type': type(error).__name__,
        'stacktrace': traceback.format_exc()
    }
    if extra:
        log_event['extra'] = extra
    logs_client.put_log_events(
        logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name,
        logEvents=[{'timestamp': int(time.time() * 1000), 'message': json.dumps(log_event, ensure_ascii=False)}]
    )

補足: 環境変数はすべて AWS CloudFormation が設定済みです。ENABLE_CONSOLE_ACCESStrue にすると IAM ユーザーのマネジメントコンソールアクセス(ログインプロファイル)を作成します。false の場合は 12 桁のパスワードを発行しますが、IAM への設定は行いません。


8-5. 申請登録 AWS Lambda(承認依頼の発行)

一時フォームから送信された申請内容を検証し、管理者へ承認依頼メールを送信します。ユーザー作成/延長の申請では、申請者1人ごとに個別の承認依頼を発行します。

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-request-submit を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

申請登録 AWS Lambda のコード(ここを押すと展開します)

import json
import os
import hashlib
import time
import uuid
import traceback
import boto3
from botocore.exceptions import ClientError
from aws_lambda_powertools import Logger
logger = Logger()
dynamodb = boto3.resource('dynamodb')
sns = boto3.client('sns')
sfn = boto3.client('stepfunctions')
logs_client = boto3.client('logs')
TABLE_NAME = os.environ['TABLE_NAME']
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
STATE_MACHINE_ARN = os.environ['STATE_MACHINE_ARN']
API_BASE = os.environ.get('API_BASE', '')
ERROR_LOG_GROUP_NAME = os.environ.get('ERROR_LOG_GROUP_NAME', '')
CORS_ORIGIN = os.environ.get('CORS_ORIGIN', '*')
CORS_HEADERS = {
    'Access-Control-Allow-Origin': CORS_ORIGIN,
    'Access-Control-Allow-Credentials': 'true',
    'Content-Type': 'application/json'
}
OPERATION_LABELS = {
    'CREATE_EXTEND_USER': 'ユーザー作成/延長',
    'DELETE_USER': 'ユーザー削除',
    'RESET_PASSWORD': 'パスワードリセット'
}
@logger.inject_lambda_context
def lambda_handler(event, context):
    try:
        table = dynamodb.Table(TABLE_NAME)
        body = json.loads(event.get('body', '{}'))
        token = body.get('token', '')
        if not token:
            return response(400, {'message': 'トークンが必要です'})
        # トークン検証
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        try:
            resp = table.get_item(Key={'pk': f'FORM_TOKEN#{token_hash}', 'sk': 'FORM_TOKEN'})
        except ClientError:
            return response(500, {'message': 'サーバーエラー'})
        item = resp.get('Item')
        if not item:
            return response(400, {'message': 'トークンが無効または期限切れです'})
        # 有効期限チェック
        if item.get('expiresAt', 0) < int(time.time()):
            return response(400, {'message': 'トークンが期限切れです'})
        # 使用済みチェック
        if item.get('used', False):
            return response(400, {'message': 'このトークンは既に使用されています'})
        # トークンを使用済みにする
        table.update_item(
            Key={'pk': f'FORM_TOKEN#{token_hash}', 'sk': 'FORM_TOKEN'},
            UpdateExpression='SET used = :t',
            ExpressionAttributeValues={':t': True}
        )
        operation_type = item['operationType']
        email = item['email']
        if operation_type == 'CREATE_EXTEND_USER':
            # ユーザー作成/延長: 1人ずつ個別に承認メールを送信
            people = body.get('people', [])
            for person in people:
                sfn_input = {
                    'operationType': operation_type,
                    'requestedBy': email,
                    'requestedAt': int(time.time()),
                    'people': [person]  # 1人分のみ
                }
                send_approval_request(table, operation_type, email, sfn_input, person)
        else:
            # 削除/リセット: 1件なのでそのまま
            sfn_input = {
                'operationType': operation_type,
                'requestedBy': email,
                'requestedAt': int(time.time()),
                'targetEmail': body.get('targetEmail', email),
                'reason': body.get('reason', '')
            }
            send_approval_request(table, operation_type, email, sfn_input, None)
        return response(200, {'message': '申請を受け付けました'})
    except Exception as e:
        logger.exception('Unhandled exception occurred')
        write_error_to_dedicated_log_group(e, context)
        raise
def send_approval_request(table, operation_type, email, sfn_input, person):
    """1件分の承認リクエストを作成し、管理者に通知する"""
    # 承認用トークンを生成
    approval_token = str(uuid.uuid4())
    approval_hash = hashlib.sha256(approval_token.encode()).hexdigest()
    # 承認情報を DynamoDB に保存
    table.put_item(Item={
        'pk': f'APPROVAL#{approval_hash}',
        'sk': 'APPROVAL',
        'sfnInput': json.dumps(sfn_input),
        'status': 'PENDING',
        'createdAt': int(time.time()),
        'expiresAt': int(time.time()) + 604800  # 1週間有効
    })
    # 承認/拒否 URL
    approve_url = f'{API_BASE}/request/approve?token={approval_token}&action=approve'
    reject_url = f'{API_BASE}/request/approve?token={approval_token}&action=reject'
    # 読みやすいメール本文を作成
    message_body = build_notification_message(operation_type, email, sfn_input, person, approve_url, reject_url)
    # 管理者に承認依頼を通知
    operation_label = OPERATION_LABELS.get(operation_type, operation_type)
    if person:
        subject = f'【管理者向け】{operation_label} 承認依頼 - {person.get("name", "")}({person.get("email", "")})'
    else:
        subject = f'【管理者向け】{operation_label} 承認依頼'
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=subject,
        Message=message_body
    )
def build_notification_message(operation_type, email, sfn_input, person, approve_url, reject_url):
    operation_label = OPERATION_LABELS.get(operation_type, operation_type)
    lines = []
    lines.append('以下の申請が届きました。内容を確認し、承認または拒否してください。')
    lines.append('')
    lines.append('━━━━━━━━━━━━━━━━━━━━━━━━')
    lines.append(f'申請種別: {operation_label}')
    lines.append(f'申請者メール: {email}')
    lines.append('━━━━━━━━━━━━━━━━━━━━━━━━')
    lines.append('')
    if operation_type == 'CREATE_EXTEND_USER' and person:
        purpose_labels = {
            'development': '開発', 'operation': '運用',
            'verification': '検証', 'other': 'その他'
        }
        purpose = purpose_labels.get(person.get('purposeType', ''), person.get('purposeType', ''))
        lines.append('【対象ユーザー】')
        lines.append(f'  氏名: {person.get("name", "")}')
        lines.append(f'  対象者メール: {person.get("email", "")}')
        lines.append(f'  利用目的: {purpose}')
        lines.append(f'  利用開始日: {person.get("startDate", "")}')
        lines.append(f'  利用終了日: {person.get("endDate", "")}')
        lines.append('')
    elif operation_type == 'DELETE_USER':
        lines.append(f'対象 IAM ユーザー: {sfn_input.get("targetEmail", "")}')
        lines.append(f'削除理由: {sfn_input.get("reason", "")}')
        lines.append('')
    elif operation_type == 'RESET_PASSWORD':
        lines.append(f'対象 IAM ユーザー: {sfn_input.get("targetEmail", "")}')
        lines.append(f'リセット理由: {sfn_input.get("reason", "")}')
        lines.append('')
    lines.append('━━━━━━━━━━━━━━━━━━━━━━━━')
    lines.append('')
    lines.append('▼ 承認する場合は以下のURLをクリック:')
    lines.append(approve_url)
    lines.append('')
    lines.append('▼ 拒否する場合は以下のURLをクリック:')
    lines.append(reject_url)
    lines.append('')
    lines.append('※ このリンクは1週間有効です。')
    return '\n'.join(lines)
def response(status, body):
    return {
        'statusCode': status,
        'headers': CORS_HEADERS,
        'body': json.dumps(body)
    }
def write_error_to_dedicated_log_group(error, context, extra=None):
    """例外情報をエラー専用ロググループに構造化ログとして書き込む"""
    if not ERROR_LOG_GROUP_NAME:
        return
    function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
    stream_name = f'{function_name}/{int(time.time() * 1000)}'
    try:
        logs_client.create_log_stream(logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name)
    except logs_client.exceptions.ResourceAlreadyExistsException:
        pass
    log_event = {
        'level': 'ERROR',
        'service': os.environ.get('POWERTOOLS_SERVICE_NAME', function_name),
        'function_name': function_name,
        'request_id': getattr(context, 'aws_request_id', 'N/A'),
        'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'message': str(error),
        'exception_type': type(error).__name__,
        'stacktrace': traceback.format_exc()
    }
    if extra:
        log_event['extra'] = extra
    logs_client.put_log_events(
        logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name,
        logEvents=[{'timestamp': int(time.time() * 1000), 'message': json.dumps(log_event, ensure_ascii=False)}]
    )


8-6. 承認処理 AWS Lambda(承認・拒否の受付)

承認依頼メール内のリンクがクリックされたときに呼び出され、承認なら AWS Step Functions を起動し、拒否なら申請を却下します。

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-request-approve を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

承認処理 AWS Lambda のコード(ここを押すと展開します)

import json
import os
import hashlib
import time
import traceback
import boto3
from html import escape
from botocore.exceptions import ClientError
from aws_lambda_powertools import Logger
logger = Logger()
dynamodb = boto3.resource('dynamodb')
sfn = boto3.client('stepfunctions')
sns = boto3.client('sns')
ses = boto3.client('ses')
logs_client = boto3.client('logs')
TABLE_NAME = os.environ['TABLE_NAME']
STATE_MACHINE_ARN = os.environ['STATE_MACHINE_ARN']
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
SES_FROM_ADDRESS = os.environ.get('SES_FROM_ADDRESS', '')
ERROR_LOG_GROUP_NAME = os.environ.get('ERROR_LOG_GROUP_NAME', '')
OPERATION_LABELS = {
    'CREATE_EXTEND_USER': 'ユーザー作成/延長',
    'DELETE_USER': 'ユーザー削除',
    'RESET_PASSWORD': 'パスワードリセット'
}
@logger.inject_lambda_context
def lambda_handler(event, context):
    try:
        # API Gateway から GET パラメータを取得
        qs = event.get('queryStringParameters') or {}
        token = qs.get('token', '')
        action = qs.get('action', '')
        if not token or action not in ('approve', 'reject'):
            return html_response(400, '無効なリクエストです。')
        table = dynamodb.Table(TABLE_NAME)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        try:
            resp = table.get_item(Key={'pk': f'APPROVAL#{token_hash}', 'sk': 'APPROVAL'})
        except ClientError:
            return html_response(500, 'サーバーエラーが発生しました。')
        item = resp.get('Item')
        if not item:
            return html_response(400, '承認トークンが無効または期限切れです。')
        if item.get('expiresAt', 0) < int(time.time()):
            return html_response(400, '承認トークンが期限切れです。')
        if item.get('status') != 'PENDING':
            return html_response(400, f'この申請は既に処理済みです(ステータス: {item.get("status")})。')
        sfn_input = json.loads(item['sfnInput'])
        operation_type = sfn_input.get('operationType', '')
        operation_label = OPERATION_LABELS.get(operation_type, operation_type)
        if action == 'approve':
            # ステータスを更新
            table.update_item(
                Key={'pk': f'APPROVAL#{token_hash}', 'sk': 'APPROVAL'},
                UpdateExpression='SET #s = :s, approvedAt = :t',
                ExpressionAttributeNames={'#s': 'status'},
                ExpressionAttributeValues={':s': 'APPROVED', ':t': int(time.time())}
            )
            # Step Functions を起動
            sfn.start_execution(
                stateMachineArn=STATE_MACHINE_ARN,
                input=json.dumps(sfn_input)
            )
            return html_response(200, f'✅ 承認しました。\n\n{operation_label}の処理を開始します。')
        elif action == 'reject':
            # ステータスを更新
            table.update_item(
                Key={'pk': f'APPROVAL#{token_hash}', 'sk': 'APPROVAL'},
                UpdateExpression='SET #s = :s, rejectedAt = :t',
                ExpressionAttributeNames={'#s': 'status'},
                ExpressionAttributeValues={':s': 'REJECTED', ':t': int(time.time())}
            )
            # 管理者SNSに拒否通知
            # 対象 IAM ユーザーを特定
            if operation_type == 'CREATE_EXTEND_USER':
                people = sfn_input.get('people', [])
                target_user = people[0].get('email', '') if people else ''
            else:
                target_user = sfn_input.get('targetEmail', '')
            sns.publish(
                TopicArn=SNS_TOPIC_ARN,
                Subject=f'【管理者向け】{operation_label} 申請拒否通知',
                Message=(
                    f'{operation_label}の申請が拒否されました。\n\n'
                    f'対象 IAM ユーザー: {target_user}\n'
                    f'申請者メール: {sfn_input.get("requestedBy", "")}'
                )
            )
            # 利用者にSESで拒否通知を送信
            applicant_email = sfn_input.get('requestedBy', '') or sfn_input.get('targetEmail', '')
            if SES_FROM_ADDRESS and applicant_email:
                try:
                    ses.send_email(
                        Source=SES_FROM_ADDRESS,
                        Destination={'ToAddresses': [applicant_email]},
                        Message={
                            'Subject': {'Data': '【通知】申請拒否通知'},
                            'Body': {'Text': {'Data': (
                                f'{operation_label}の申請は拒否されました。管理者にお問い合わせください。\n\n'
                                f'対象 IAM ユーザー: {target_user}\n'
                                f'申請種別: {operation_label}\n'
                                f'申請者メール: {applicant_email}'
                            )}}
                        }
                    )
                except ClientError as e:
                    print(f"SES send error: {e}")
            return html_response(200, f'❌ 拒否しました。\n\n{operation_label}の申請を拒否しました。')
    except Exception as e:
        logger.exception('Unhandled exception occurred')
        write_error_to_dedicated_log_group(e, context)
        raise
def html_response(status, message):
    # 承認結果をカード型で表示するブラウザ用HTMLを生成
    color = '#28a745' if status == 200 else '#dc3545'
    safe_message = escape(message).replace('\n', '<br>')
    html = f'''<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8"><title>申請処理結果</title></head>
<body>
<div style="font-family:sans-serif;max-width:480px;margin:60px auto;padding:16px;text-align:center">
  <div style="padding:24px;border-radius:8px;background:#f8f9fa;border:2px solid {color};font-size:18px;line-height:1.6">{safe_message}</div>
</div>
</body>
</html>'''
    return {
        'statusCode': status,
        'headers': {
            'Content-Type': 'text/html; charset=UTF-8',
            'Cache-Control': 'no-store',
            'X-Content-Type-Options': 'nosniff'
        },
        'isBase64Encoded': False,
        'body': html
    }
def write_error_to_dedicated_log_group(error, context, extra=None):
    """例外情報をエラー専用ロググループに構造化ログとして書き込む"""
    if not ERROR_LOG_GROUP_NAME:
        return
    function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
    stream_name = f'{function_name}/{int(time.time() * 1000)}'
    try:
        logs_client.create_log_stream(logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name)
    except logs_client.exceptions.ResourceAlreadyExistsException:
        pass
    log_event = {
        'level': 'ERROR',
        'service': os.environ.get('POWERTOOLS_SERVICE_NAME', function_name),
        'function_name': function_name,
        'request_id': getattr(context, 'aws_request_id', 'N/A'),
        'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'message': str(error),
        'exception_type': type(error).__name__,
        'stacktrace': traceback.format_exc()
    }
    if extra:
        log_event['extra'] = extra
    logs_client.put_log_events(
        logGroupName=ERROR_LOG_GROUP_NAME, logStreamName=stream_name,
        logEvents=[{'timestamp': int(time.time() * 1000), 'message': json.dumps(log_event, ensure_ascii=False)}]
    )

ポイント: 承認リンクは GET /request/approve?token=...&action=approve、拒否リンクは action=reject を呼び出します。処理結果はブラウザに HTML で表示されます。トークンは1週間有効で、一度処理すると再利用できません。


8-7. エラー通知 Lambda

フェーズ2で作成したエラー通知 Lambda( dev-001-iam-error-notification )に実装コードを設定します。

エラーハンドリングについて: 手順 8-1〜8-6 で設定した6つの Lambda には、Powertools for AWS Lambda の Logger と write_error_to_dedicated_log_group ヘルパーが組み込み済みです。例外発生時にエラー専用ロググループへ構造化ログが自動的に書き込まれ、以下のサブスクリプションフィルターを通じてエラー通知 Lambda が起動します。

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-error-notification を選択する
  2. 「コード」タブを選択
  3. 以下のコードを index.py に貼り付け、「Deploy」を押下

エラー通知 Lambda のコード(ここを押すと展開します)

import json
import os
import base64
import gzip
import boto3
from aws_lambda_powertools import Logger
logger = Logger()
sns = boto3.client('sns')
SNS_TOPIC_ARN = os.environ['SNS_TOPIC_ARN']
@logger.inject_lambda_context
def lambda_handler(event, context):
    """
    CloudWatch Logs サブスクリプションフィルターから呼び出される。
    エラー用ロググループに書き込まれた構造化ログをパースし、
    可読性の高い通知メッセージを作成して SNS に publish する。
    """
    # サブスクリプションフィルターからのデータをデコード
    compressed = base64.b64decode(event['awslogs']['data'])
    log_data = json.loads(gzip.decompress(compressed).decode('utf-8'))
    log_group = log_data.get('logGroup', '')
    log_stream = log_data.get('logStream', '')
    log_events = log_data.get('logEvents', [])
    for log_event in log_events:
        message_raw = log_event.get('message', '')
        error_detail = parse_error_message(message_raw)
        # 可読性の高い通知メッセージを構築
        notification = build_notification(error_detail, log_group, log_stream)
        # SNS で管理者に通知
        subject = build_subject(error_detail)
        sns.publish(
            TopicArn=SNS_TOPIC_ARN,
            Subject=subject[:100],  # SNS Subject は100文字制限
            Message=notification
        )
        logger.info('Error notification sent', extra={
            'function_name': error_detail.get('function_name'),
            'exception_type': error_detail.get('exception_type')
        })
    return {'statusCode': 200, 'body': f'Processed {len(log_events)} error event(s)'}
def parse_error_message(message_raw: str) -> dict:
    """構造化ログメッセージを解析する"""
    try:
        return json.loads(message_raw)
    except (json.JSONDecodeError, TypeError):
        return {
            'level': 'ERROR',
            'service': 'unknown',
            'function_name': 'unknown',
            'request_id': 'N/A',
            'timestamp': 'N/A',
            'message': message_raw,
            'exception_type': 'UnknownError',
            'stacktrace': ''
        }
def build_subject(error_detail: dict) -> str:
    """SNS 通知の件名を作成"""
    service = error_detail.get('service', 'unknown')
    exception_type = error_detail.get('exception_type', 'Error')
    return f'【Lambda エラー】{service} - {exception_type}'
def build_notification(error_detail: dict, log_group: str, log_stream: str) -> str:
    """可読性の高い通知メッセージを構築"""
    lines = []
    lines.append('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
    lines.append('  Lambda 関数でエラーが発生しました')
    lines.append('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
    lines.append('')
    lines.append('【基本情報】')
    lines.append(f'  サービス名    : {error_detail.get("service", "N/A")}')
    lines.append(f'  関数名        : {error_detail.get("function_name", "N/A")}')
    lines.append(f'  リクエストID  : {error_detail.get("request_id", "N/A")}')
    lines.append(f'  発生日時      : {error_detail.get("timestamp", "N/A")}')
    lines.append('')
    lines.append('【エラー内容】')
    lines.append(f'  例外タイプ    : {error_detail.get("exception_type", "N/A")}')
    lines.append(f'  メッセージ    : {error_detail.get("message", "N/A")}')
    lines.append('')
    # スタックトレース
    stacktrace = error_detail.get('stacktrace', '')
    if stacktrace:
        lines.append('【スタックトレース】')
        # 長すぎる場合は先頭1000文字に制限
        if len(stacktrace) > 1000:
            stacktrace = stacktrace[:1000] + '\n... (truncated)'
        lines.append(stacktrace)
        lines.append('')
    # 追加コンテキスト
    extra = error_detail.get('extra', {})
    if extra:
        lines.append('【追加コンテキスト】')
        extra_str = json.dumps(extra, ensure_ascii=False, indent=2)
        if len(extra_str) > 500:
            extra_str = extra_str[:500] + '\n... (truncated)'
        lines.append(extra_str)
        lines.append('')
    lines.append('【ログ情報】')
    lines.append(f'  ロググループ  : {log_group}')
    lines.append(f'  ログストリーム: {log_stream}')
    lines.append('')
    lines.append('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
    lines.append('※ マネジメントコンソールにて、CloudWatch Logs を開き、詳細を確認してください。')
    return '\n'.join(lines)

Powertools for AWS Lambda の活用: このエラー通知 Lambda 自体も Powertools の Logger を使い、処理状況を構造化ログとして出力しています。通知対象のエラーログは Powertools の構造化ログ形式(JSON)で書き込まれるため、パースが容易で、通知メッセージに必要な情報(関数名、リクエスト ID、スタックトレースなど)を確実に抽出できます。

サブスクリプションフィルターのパターン: { $.level = "ERROR" } は Powertools Logger が出力する JSON の level フィールドが ERROR のログイベントのみをフィルターします。通常の INFO/WARNING レベルのログでは通知 Lambda は起動しません。


ステップ9: Web ファイル配置

AWS CloudFormation はインフラを作成しますが、HTML・JavaScript は別途 Amazon S3 へアップロードします。

9-1. 配置先の確認

スタック: dev-001-iam-stack-02-portal-auth の「出力」タブで以下を控えます。

  • Amazon API Gateway の URL(スタック: dev-001-iam-stack-02-portal-auth の出力 ApiUrl

9-2. Web ファイルの準備

ローカル PC に次の構成でファイルを保存します。

/
├─ index.html
└─ form/
   ├─ create.html
   ├─ delete.html
   ├─ reset-password.html
   └─ form.js
控えた値 設定先 用途
ApiUrlindex.html および form/form.js 内の API_BASE 変数JavaScript から Amazon API Gateway を呼び出す接続先
各ファイルの内容は以下のとおりです。コピー&ペーストで作成してください。
index.html ※変数:API_BASEの値にApiUrlを入力(ここを押すと展開します)
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>IAM ユーザー申請ポータル</title>
  <style>
    body { font-family: sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; }
    label { display: block; margin-top: 16px; }
    input, select, button { width: 100%; padding: 8px; margin-top: 4px; box-sizing: border-box; }
    input[type="radio"] { width: auto; margin-right: 8px; }
    fieldset label { display: flex; align-items: center; margin-top: 12px; }
    button { margin-top: 24px; cursor: pointer; }
    fieldset { margin-top: 16px; }
    .error { color: red; margin-top: 8px; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <h1>IAM ユーザー申請ポータル</h1>
  <!-- ログインフォーム -->
  <div id="login-section">
    <label>ユーザー名
      <input type="text" id="portal-username" required>
    </label>
    <label>パスワード
      <input type="password" id="portal-password" required>
    </label>
    <button id="login-btn">ログイン</button>
    <p id="login-error" class="error hidden"></p>
  </div>
  <!-- 申請種別選択フォーム -->
  <div id="request-section" class="hidden">
    <label>メールアドレス
      <input type="email" id="email" required placeholder="user@example.com">
    </label>
    <fieldset>
      <legend>申請種別</legend>
      <label><input type="radio" name="operationType" value="CREATE_EXTEND_USER" required> ユーザー作成/延長</label>
      <label><input type="radio" name="operationType" value="DELETE_USER"> ユーザー削除</label>
      <label><input type="radio" name="operationType" value="RESET_PASSWORD"> パスワードリセット</label>
    </fieldset>
    <button id="submit-btn">申請フォームを送信</button>
    <p id="request-error" class="error hidden"></p>
    <p id="request-success" class="hidden" style="color:green"></p>
  </div>
  <script>
    const API_BASE = ''; // 手順 8-1 で控えた Amazon API Gateway の URL を設定
    document.getElementById('login-btn').addEventListener('click', async () => {
      const username = document.getElementById('portal-username').value;
      const password = document.getElementById('portal-password').value;
      try {
        const res = await fetch(API_BASE + '/auth/login', {
          method: 'POST', headers: {'Content-Type':'application/json'},
          body: JSON.stringify({ username, password }), credentials: 'include'
        });
        if (res.ok) {
          document.getElementById('login-section').classList.add('hidden');
          document.getElementById('request-section').classList.remove('hidden');
        } else {
          document.getElementById('login-error').textContent = 'パスワードが正しくありません';
          document.getElementById('login-error').classList.remove('hidden');
        }
      } catch(e) { alert('通信エラー'); }
    });
    document.getElementById('submit-btn').addEventListener('click', async () => {
      const email = document.getElementById('email').value;
      const opType = document.querySelector('input[name="operationType"]:checked');
      if (!email || !opType) { alert('すべて入力してください'); return; }
      try {
        const res = await fetch(API_BASE + '/request/send-form', {
          method: 'POST', headers: {'Content-Type':'application/json'},
          body: JSON.stringify({ email, operationType: opType.value }), credentials: 'include'
        });
        const data = await res.json();
        if (res.ok) {
          document.getElementById('request-success').textContent = 'メールを送信しました。受信トレイを確認してください。';
          document.getElementById('request-success').classList.remove('hidden');
          document.getElementById('request-error').classList.add('hidden');
          alert('メールを送信しました。受信トレイを確認してください。\n\nブラウザにてこのタブを閉じてください。');
        } else {
          document.getElementById('request-error').textContent = data.message || 'エラーが発生しました';
          document.getElementById('request-error').classList.remove('hidden');
        }
      } catch(e) { alert('通信エラー'); }
    });
  </script>
</body>
</html>

form/form.js ※変数:API_BASEの値にApiUrlを入力(ここを押すと展開します)

// Amazon API Gateway の URL を設定してください
const API_BASE = ''; // 手順 8-1 で控えた Amazon API Gateway の URL
// URL からトークンを取得
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
// 対象メールアドレスの自動設定(削除・リセット用)
const targetEmailEl = document.getElementById('targetEmail');
const targetUsernameEl = document.getElementById('targetUsername');
if (targetEmailEl && token) {
  // トークン検証 API から対象メールを取得
  fetch(API_BASE + '/request/verify-token?token=' + token, { mode: 'cors' })
    .then(r => r.json())
    .then(data => { if (data.email) targetEmailEl.value = data.email; });
}
// 送信前バリデーション関数
function validateForm() {
  const path = window.location.pathname;
  const errors = [];
  if (path.includes('create')) {
    const rows = document.querySelectorAll('.person-row');
    rows.forEach((row, i) => {
      const name = row.querySelector('[name="name"]').value.trim();
      const email = row.querySelector('[name="email"]').value.trim();
      const startDate = row.querySelector('[name="startDate"]').value;
      const endDate = row.querySelector('[name="endDate"]').value;
      if (!name) errors.push(`行${i+1}: 氏名を入力してください`);
      if (!email) errors.push(`行${i+1}: メールアドレスを入力してください`);
      if (!startDate) errors.push(`行${i+1}: 利用開始日を入力してください`);
      if (!endDate) errors.push(`行${i+1}: 利用終了日を入力してください`);
      if (startDate && endDate && startDate > endDate) errors.push(`行${i+1}: 利用終了日は開始日以降にしてください`);
    });
  } else if (path.includes('delete')) {
    const target = (targetUsernameEl || targetEmailEl);
    if (target && !target.value.trim()) errors.push('対象 IAM ユーザー名を入力してください');
    const reason = document.getElementById('reason');
    if (reason && !reason.value) errors.push('削除理由を選択してください');
    const confirm = document.getElementById('confirmDelete');
    if (confirm && !confirm.checked) errors.push('削除確認にチェックを入れてください');
  } else if (path.includes('reset-password')) {
    if (targetEmailEl && !targetEmailEl.value.trim()) errors.push('対象メールアドレスを入力してください');
    const reason = document.getElementById('reason');
    if (reason && !reason.value.trim()) errors.push('リセット理由を入力してください');
    const notify = document.getElementById('notifyUser');
    if (notify && !notify.checked) errors.push('確認にチェックを入れてください');
  }
  return errors;
}
// 送信ボタン
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
  submitBtn.addEventListener('click', async () => {
    // バリデーション
    const errors = validateForm();
    if (errors.length > 0) {
      alert('入力内容に不備があります:\n\n' + errors.join('\n'));
      return;
    }
    // 確認ポップアップ
    if (!confirm('この内容で申請を送信してよろしいですか?')) return;
    // ページのパスから申請種別を判定
    const path = window.location.pathname;
    let payload = { token };
    if (path.includes('create')) {
      const rows = document.querySelectorAll('.person-row');
      const people = [];
      rows.forEach(row => {
        people.push({
          name: row.querySelector('[name="name"]').value,
          email: row.querySelector('[name="email"]').value,
          purposeType: row.querySelector('[name="purposeType"]').value,
          startDate: row.querySelector('[name="startDate"]').value,
          endDate: row.querySelector('[name="endDate"]').value
        });
      });
      payload.people = people;
    } else if (path.includes('delete')) {
      payload.targetEmail = targetUsernameEl ? targetUsernameEl.value : (targetEmailEl ? targetEmailEl.value : '');
      const reasonEl = document.getElementById('reason');
      const detailEl = document.getElementById('reasonDetail');
      payload.reason = reasonEl ? reasonEl.value : '';
      if (detailEl && detailEl.value.trim()) payload.reasonDetail = detailEl.value;
    } else if (path.includes('reset-password')) {
      payload.targetEmail = document.getElementById('targetEmail').value;
      payload.reason = document.getElementById('reason').value;
    }
    try {
      const res = await fetch(API_BASE + '/request/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        mode: 'cors',
        body: JSON.stringify(payload)
      });
      if (res.ok) {
        alert('申請を受け付けました。管理者の承認をお待ちください。\n\nブラウザにてこのタブを閉じてください。');
      } else {
        const data = await res.json();
        alert(data.message || 'エラーが発生しました');
      }
    } catch (e) { alert('通信エラー'); }
  });
}
// 申請者追加ボタン(create.html 用)-- 行削除ボタン付き
const addBtn = document.getElementById('add-person');
if (addBtn) {
  let count = 1;
  addBtn.addEventListener('click', () => {
    if (count >= 20) { alert('最大20人までです'); return; }
    count++;
    const tmpl = document.getElementById('person-1').cloneNode(true);
    tmpl.id = 'person-' + count;
    tmpl.querySelectorAll('input, select').forEach(el => el.value = '');
    // 行削除ボタンを追加
    const removeBtn = document.createElement('button');
    removeBtn.textContent = 'この行を削除';
    removeBtn.type = 'button';
    removeBtn.style.marginTop = '8px';
    removeBtn.style.background = '#dc3545';
    removeBtn.style.color = '#fff';
    removeBtn.style.border = 'none';
    removeBtn.style.padding = '6px 12px';
    removeBtn.style.cursor = 'pointer';
    removeBtn.addEventListener('click', () => { tmpl.remove(); count--; });
    tmpl.appendChild(removeBtn);
    // カレンダー制限を適用
    if (typeof applyDateConstraints === 'function') applyDateConstraints(tmpl);
    document.getElementById('form-area').appendChild(tmpl);
  });
}

form/create.html(ここを押すと展開します)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>IAM ユーザー作成/延長申請</title>
  <style>body{font-family:sans-serif;max-width:560px;margin:40px auto;padding:0 16px}label{display:block;margin-top:12px}input,select,textarea,button{width:100%;padding:8px;margin-top:4px;box-sizing:border-box}button{margin-top:24px;cursor:pointer}.person-row{border:1px solid #ccc;padding:12px;margin-top:12px}</style>
</head>
<body>
  <h1>IAM ユーザー作成/延長申請</h1>
  <div id="form-area">
    <div class="person-row" id="person-1">
      <label>氏名 <input name="name" required maxlength="100" placeholder="山田 太郎"></label>
      <label>メールアドレス <input name="email" type="email" required placeholder="user@example.com"></label>
      <label>利用目的
        <select name="purposeType" required>
          <option value="development">開発</option>
          <option value="operation">運用</option>
          <option value="verification">検証</option>
          <option value="other">その他</option>
        </select>
      </label>
      <label>利用開始日 <input name="startDate" type="date" required></label>
      <label>利用終了日 <input name="endDate" type="date" required></label>
    </div>
  </div>
  <button id="add-person">申請者を追加</button>
  <button id="submit-btn">確認して送信</button>
  <script>
    // カレンダー制限: 利用終了日は年度末まで(3月は翌年度末まで選択可能)
    function getFiscalYearEnd() {
      const now = new Date();
      const month = now.getMonth(); // 0=1月, 1=2月, 2=3月, ...
      if (month === 2) {
        // 3月: 年度切り替え時期のため、翌年度末(来年の3月31日)まで選択可能
        return (now.getFullYear() + 1) + '-03-31';
      } else if (month < 2) {
        // 1月・2月: 今年度末(今年の3月31日)
        return now.getFullYear() + '-03-31';
      } else {
        // 4月〜12月: 翌年度末(翌年の3月31日)
        return (now.getFullYear() + 1) + '-03-31';
      }
    }
    function applyDateConstraints(row) {
      const today = new Date().toISOString().split('T')[0];
      const endMax = getFiscalYearEnd();
      row.querySelector('[name="startDate"]').setAttribute('min', today);
      row.querySelector('[name="endDate"]').setAttribute('min', today);
      row.querySelector('[name="endDate"]').setAttribute('max', endMax);
    }
    applyDateConstraints(document.getElementById('person-1'));
  </script>
  <script src="form.js"></script>
</body>
</html>

form/delete.html(ここを押すと展開します)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>IAM ユーザー削除申請</title>
  <style>
    body{font-family:sans-serif;max-width:480px;margin:40px auto;padding:0 16px}
    label{display:block;margin-top:12px}
    input,textarea,select,button{width:100%;padding:8px;margin-top:4px;box-sizing:border-box}
    input[type="checkbox"]{width:auto;margin-right:8px}
    .checkbox-label{display:flex;align-items:flex-start;margin-top:16px}
    button{margin-top:24px;cursor:pointer}
  </style>
</head>
<body>
  <h1>IAM ユーザー削除申請</h1>
  <label>対象 IAM ユーザー名 <input id="targetUsername" type="text" required placeholder="user@example.com"></label>
  <label>削除理由
    <select id="reason" required>
      <option value="">-- 選択してください --</option>
      <option value="project_end">プロジェクト終了</option>
      <option value="transfer">異動・退職</option>
      <option value="consolidation">アカウント統合</option>
      <option value="security">セキュリティ上の理由</option>
      <option value="other">その他</option>
    </select>
  </label>
  <label id="reason-detail-label" style="display:none">詳細理由 <textarea id="reasonDetail" maxlength="500"></textarea></label>
  <label class="checkbox-label"><input type="checkbox" id="confirmDelete" required> 対象 IAM ユーザーを削除することを確認しました</label>
  <button id="submit-btn">削除申請を確認</button>
  <script>
    document.getElementById('reason').addEventListener('change', function() {
      document.getElementById('reason-detail-label').style.display = this.value === 'other' ? 'block' : 'none';
    });
  </script>
  <script src="form.js"></script>
</body>
</html>

form/reset-password.html(ここを押すと展開します)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>パスワードリセット申請</title>
  <style>
    body{font-family:sans-serif;max-width:480px;margin:40px auto;padding:0 16px}
    label{display:block;margin-top:12px}
    input,textarea,button{width:100%;padding:8px;margin-top:4px;box-sizing:border-box}
    input[type="checkbox"]{width:auto;margin-right:8px}
    .checkbox-label{display:flex;align-items:flex-start;margin-top:16px}
    button{margin-top:24px;cursor:pointer}
  </style>
</head>
<body>
  <h1>パスワードリセット申請</h1>
  <label>対象メールアドレス <input id="targetEmail" type="email" required placeholder="user@example.com"></label>
  <label>リセット理由 <textarea id="reason" required maxlength="500"></textarea></label>
  <label class="checkbox-label"><input type="checkbox" id="notifyUser" required> パスワードリセット後、利用者に新しい認証情報の取得 URL が届くことを確認しました</label>
  <button id="submit-btn">パスワードリセット申請を確認</button>
  <script src="form.js"></script>
</body>
</html>

9-3. Amazon S3 へアップロード

  1. マネジメントコンソールにて、Amazon S3 を開く
  2. バケット一覧から、スタック: dev-001-iam-stack-02-portal-auth の出力 WebBucketName に該当するバケット(例: {アカウントID}-ap-northeast-1-dev-001-iam-web)を選択
  3. 「アップロード」を押下
  4. 「ファイルを追加」を押下し、手順 9-2 で作成した index.html を選択
  5. 「フォルダの追加」を押下し、手順 9-2 で作成した form/ フォルダーを選択
  6. ポップアップが表示するため「アップロード」を押下
  7. 画面下部の「アップロード」を押下
  8. アップロード完了後、バケット内に以下の構成でファイルが配置されていることを確認
    • index.html
    • form/create.html
    • form/delete.html
    • form/reset-password.html
    • form/form.js

Amazon S3 へアップロード-001.png

注意: パブリックアクセス設定(ブロックパブリックアクセス)は変更しないでください。Web コンテンツは Amazon CloudFront(OAC)経由でのみアクセスされるため、バケットを公開する必要はありません。

9-4. Amazon CloudFront で表示確認

  1. https://request.{取得したドメイン名[your-domain.link]} をブラウザで開く
  2. ログイン画面が表示されることを確認

Amazon CloudFront で表示確認-001.png

ステップ10: 月次パスワードの初回発行

EventBridge は毎月1日に AWS Lambda を起動しますが、初回は手動で発行します。

  1. マネジメントコンソールにて、AWS Lambda を開き、dev-001-iam-password-update を選択する
  2. 「テスト」タブにて「テスト」を押下
  3. ポータルパスワード通知用 Amazon SNS の購読者にメールが届く
  4. メール本文に記載されたログイン URL・ユーザー名・パスワードを確認

ステップ10_月次パスワードの初回発行-001.png

ポイント: この月次パスワードは申請ポータルへのログインに使います(ユーザー名: persol-serverworks)。IAM ユーザーのパスワードとは別物です。

動作確認

デプロイが完了したら、実際に申請から IAM ユーザー作成までの流れを確認します。

補足: Amazon SES や Amazon SNS からのメールは、Microsoft 365 のセキュリティ機能によって検査されたり、迷惑メールフォルダーに振り分けられたりする場合があります。メールが届かない場合は、迷惑メールフォルダーやメールセキュリティ製品の設定をご確認ください。


申請フロー

申請フロー-001.png


確認1: ポータルへのログイン

ステップ10 で発行した月次パスワードを使い、申請ポータルにログインできることを確認します。

  1. ブラウザで https://request.{取得したドメイン名[your-domain.link]} を開く
  2. ユーザー名欄に persol-serverworks を入力
  3. パスワード欄にステップ10で取得した月次パスワードを入力
  4. 「ログイン」を押下
  5. ログインが成功し、メールアドレス入力画面が表示されることを確認

確認1ポータルへのログイン-001.png


確認2: 組織外メールアドレスの拒否

ステップ6 のパラメータ OrganizationDomain で指定したドメイン以外のメールアドレスを入力したとき、申請が拒否されることを確認します。これにより、組織外のユーザーからの申請を防止できていることを検証します。

  1. 確認1 でログイン済みのメールアドレス入力画面を開く
  2. メールアドレス欄に、OrganizationDomain で指定した値 とは異なる ドメインのメールアドレスを入力
    • 例: test@yahoo.co.jp
  3. 申請種別(「ユーザー作成/延長」など)を選択
  4. 「申請フォームを送信」を押下
  5. 「組織のメールアドレスを入力してください」とエラーが表示されることを確認

組織外メールアドレスの拒否-001.png


確認3: 一時フォーム URL の受信

組織ドメインのメールアドレスを入力したとき、Amazon SES 経由で一時フォーム URL がメールで届くことを確認します。このURLは申請内容の入力フォームへのワンタイムリンクです。

  1. メールアドレス欄に、OrganizationDomain で指定したドメインのメールアドレスを入力
    • 例: taro.yamada@{OrganizationDomainで指定した値}
  2. 申請種別で「ユーザー作成/延長」を選択
  3. 「申請フォームを送信」を押下
  4. ポップアップが表示するため「OK」を押下
  5. ブラウザのタブを閉じる
  6. 申請者のメールアドレスに Amazon SES で申請フォームが届く
    • 送信者は、スタック:dev-001-iam-stack-03-form-request のパラメータ:SesFromAddress の値
    • 例: noreply@{取得したドメイン名})
  7. メール本文に記載された URL をブラウザで開く
  8. ユーザー作成/延長フォームが表示されることを確認

一時フォーム URL の受信-001.png

一時フォーム URL の受信-002.png

ポイント: URL の有効期限は120分です。有効期限を過ぎるとアクセスできなくなります。この URL は1度のみ使用できます。。


確認4: IAM ユーザー作成/延長の申請と承認

一時フォームから申請を送信し、管理者が承認すると IAM ユーザーが自動作成される一連のフローを確認します。

申請フロー-002.png

新規作成の場合

  1. ユーザー作成/延長フォームに氏名・メールアドレス・利用目的・利用期間を入力
    • 利用期間は申請月が属する年度の翌3月末まで選択可能(例: 2026年3月に申請 → 2027年3月31日まで)
    • 例外として、3月申請時のみ、翌年の3月末まで選択可能
  2. 「確認して送信」を押下
  3. ポップアップが表示するため、申請内容を確認し「OK」を押下
    • もし「トークンが期限切れです」と表示した場合は「OK」を押下し、再度申請フォームを取得する
  4. ポップアップが表示するため「OK」を押下
  5. ブラウザのタブを閉じる
  6. Amazon SNS から 管理者通知用 承認依頼が届く(複数ユーザー申請時は1人ずつ個別のメールが届きます)
  7. メール内に承認リンクと拒否リンクが記載されているため、承認リンクを押下(リンクの有効期限は1週間)
  8. Amazon SNS から 管理者通知用 完了通知が届く
  9. Amazon SES から 利用者のメールアドレスに 完了通知が届く(IAM ユーザー名 + 48時間有効な認証情報取得 URL)
  10. 利用者が取得 URL を開き、ユーザー名とパスワードを確認(URL は48時間で失効)
  11. ブラウザで、完了通知のマネジメントコンソールのURLを開き「サインイン」
  12. 初回ログイン時にパスワード変更を求められるため更新
  13. 「サインインを続行」を押下し、マネジメントコンソール へログイン

IAM ユーザー作成延長の申請と承認-001.png

IAM ユーザー作成延長の申請と承認-002.png

IAM ユーザー作成延長の申請と承認-003.png

IAM ユーザー作成延長の申請と承認-004.png

IAM ユーザー作成延長の申請と承認-005.png

IAM ユーザー作成延長の申請と承認-006.png

IAM ユーザー作成延長の申請と承認-007.png

IAM ユーザー作成延長の申請と承認-008.png

IAM ユーザー作成延長の申請と承認-009.png

ポイント: パスワードは Amazon S3 に SSE-S3 で暗号化保存され、署名付き URL の有効期限は48時間です。URL 失効後はアクセスできなくなります。管理者にはパスワードを送信しません。

既存ユーザーの利用期間延長の場合

  1. 確認4で作成済みの IAM ユーザーと同じメールアドレスで、より長い利用期間を指定して申請
  2. Amazon SNS から 管理者通知用 承認依頼が届く
  3. 管理者が承認リンクをクリック
  4. 利用期限が更新される
  5. Amazon SNS から 管理者通知用 完了通知が届く
  6. Amazon SES から 利用者のメールアドレスに 完了通知が届く

既存ユーザーの利用期間延長の場合-002.png

既存ユーザーの利用期間延長の場合-001.png

既存ユーザーの利用期限が申請より長い場合

  1. 確認4で作成済みの IAM ユーザーと同じメールアドレスで、現在の利用期限以下の期間を指定して申請
  2. Amazon SNS から 管理者通知用 承認依頼が届く
  3. 管理者が承認リンクをクリック
  4. 延長不要と判定され、利用期限は更新されない
  5. Amazon SNS から 管理者通知用 処理中止通知が届く
  6. Amazon SES から 申請者のメールアドレスに 処理中止通知(現在の利用期限を案内)が届く

既存ユーザーの利用期限が申請より長い場合-001.png

既存ユーザーの利用期限が申請より長い場合-002.png


確認5: パスワードリセットの申請と承認

既存の IAM ユーザーのパスワードをリセットし、利用者に新しい認証情報を安全に届けるフローを確認します。

申請フロー-003.png

  1. 事前に検証用 IAM ユーザーを作成しておく(確認4の手順を再度実行)
  2. 確認1 と同じ手順で申請ポータルにログイン
  3. メールアドレス欄に、パスワードリセット対象の IAM ユーザーのメールアドレスを入力
  4. 申請種別で「パスワードリセット」を選択
  5. 「申請フォームを送信」を押下
  6. ポップアップが表示するため「OK」を押下
  7. ブラウザのタブを閉じる
  8. 入力したメールアドレスに一時フォーム URL が届くことを確認し、URL をブラウザで開く
  9. フォームが表示される。情報を入力
  10. 「パスワードリセット申請を確認」を押下
  11. ポップアップが2回表示するため「OK」を押下
  12. ブラウザのタブを閉じる
  13. Amazon SNS から 管理者通知用 承認依頼が届く
  14. メール内の承認リンクをクリック
  15. Amazon SNS から 管理者通知用 完了通知が届く
  16. Amazon SES から 利用者のメールアドレスに 完了通知が届く
  17. 利用者が取得 URL をブラウザで開き、新しいパスワードを確認(48時間有効な認証情報取得 URL)
  18. 新しいパスワードで IAM ユーザーとしてマネジメントコンソールにログインできることを確認
  19. 初回ログイン時にパスワード変更を求められるため更新

パスワードリセットの申請と承認-001.png

パスワードリセットの申請と承認-004.png

パスワードリセットの申請と承認-002.png

パスワードリセットの申請と承認-003.png


確認6: ユーザー削除の申請と承認

不要になった IAM ユーザーを削除申請し、管理者承認後に自動削除されるフローを確認します。

申請フロー-004.png

  1. 確認1 と同じ手順で申請ポータルにログイン
  2. メールアドレス欄に、確認4 で作成した IAM ユーザーのメールアドレスを入力
  3. 申請種別で「ユーザー削除」を選択
  4. 「申請フォームを送信」を押下
  5. ポップアップが表示するため「OK」を押下
  6. ブラウザのタブを閉じる
  7. 入力したメールアドレスに一時フォーム URL が届くことを確認し、URL をブラウザで開く
  8. フォームが表示される。情報を入力
  9. 「削除申請を確認」を押下
  10. ポップアップが2回表示するため「OK」を押下
  11. ブラウザのタブを閉じる
  12. Amazon SNS から 管理者通知用 承認依頼が届く
  13. メール内の承認リンクをクリック
  14. Amazon SNS から 管理者通知用 完了通知が届く
  15. Amazon SES から 利用者のメールアドレスに 完了通知が届く

ユーザー削除の申請と承認-001.png

ユーザー削除の申請と承認-002.png

ユーザー削除の申請と承認-003.png

環境削除手順

検証が完了したら、不要な課金を避けるためリソースを削除します。

1. 動作確認で作成した IAM ユーザーの削除

動作確認中に作成した検証用 IAM ユーザーが不要になった場合は、AWSマネジメントコンソールから削除します。

注意: IAM ユーザーを削除すると、そのユーザーでAWSマネジメントコンソールやAWS CLIを利用できなくなります。削除前に、業務で使用しているユーザーや管理者ユーザーではないことを必ず確認してください。

  1. マネジメントコンソールにて、IAM を開く
  2. 左ペインの「IAM ユーザー」を選択
  3. IAM ユーザーの一覧から、動作確認時に作成したユーザー名を選択
  4. ユーザー詳細画面で「削除」を押下
  5. 確認画面で「confirm」を入力し、「ユーザーを削除」
  6. IAM ユーザー一覧に戻り、削除したユーザーが表示されないことを確認

2. Amazon S3 バケットを空にする

AWS CloudFormation が管理するS3バケットは、バケット内にオブジェクトが残っていると削除できません。先に、Webコンテンツ用バケットと認証情報一時保存用バケットの中身をすべて削除します。

  1. マネジメントコンソールにて、Amazon S3 を開く
  2. 左ペインの「汎用バケット」を選択
  3. バケット一覧から、スタック: dev-001-iam-stack-02-portal-auth の出力 WebBucketName に該当するバケット(例: {アカウントID}-ap-northeast-1-dev-001-iam-web)を選択
  4. 「空にする」を押下
  5. 確認画面で「完全に削除」と入力「空にする」を押下
  6. バケット一覧から、スタック: dev-001-iam-stack-00-foundation の出力 PasswordBucketName に該当するバケット(例: {アカウントID}-ap-northeast-1-dev-001-iam-credentials)を選択
  7. 「空にする」を押下
  8. 確認画面で「完全に削除」と入力「空にする」を押下

3. AWS CloudFormation スタックの削除

デプロイと逆順で削除します。スタック間の依存関係(ImportValue による参照)があるため、必ず以下の順番を守ってください。

  1. マネジメントコンソールにて、AWS CloudFormation を開く
  2. リージョンが 東京(ap-northeast-1) であることを確認
  3. 左ペインにて「スタック」を選択
  4. スタック一覧から dev-001-iam-stack-04-iam-workflow を選択し、「スタックを削除」を押下
  5. 確認画面でスタック名を入力し「スタックを削除」を押下
  6. ステータスが DELETE_COMPLETE になるまで待機(一覧から消えます)
  7. 同様に dev-001-iam-stack-03-form-request を削除
  8. 同様に dev-001-iam-stack-02-portal-auth を削除
  9. リージョンを バージニア北部(us-east-1) に切り替える
  10. 同様に dev-001-iam-stack-01-waf を削除
  11. リージョンを 東京(ap-northeast-1) に戻す
  12. 同様に dev-001-iam-stack-00-foundation を削除

4. Amazon SES の整理

ステップ1 で設定した Amazon SES のリソースを削除します。

  1. マネジメントコンソールにて、Amazon SES を表示
  2. 左ペインの「ID」を選択
  3. 検証済みドメインを選択し、「削除」を押下
  4. 確認画面で「削除」を押下

注意: サンドボックス解除(本番アクセス)は、SES の ID を削除しても自動的に取り消されません。本番アクセスを無効にしたい場合は、AWS サポートに別途リクエストする必要があります。通常はそのまま残しておいても課金は発生しません。

5. ACM 証明書の削除

手順4-1 でバージニア北部(us-east-1)に発行した ACM 証明書を削除します。

  1. マネジメントコンソールにて、AWS Certificate Manager (ACM) を表示
  2. リージョンを バージニア北部(us-east-1) に切り替える
  3. 左ペインの「証明書を一覧」を選択
  4. 手順4-1 で発行した証明書(ドメイン名: request.{取得したドメイン名[your-domain.link]} )を選択し、「削除」を押下
  5. 確認画面で「削除」と入力「削除」を押下
  6. リージョンを 東京(ap-northeast-1) に戻す

6. Amazon Route 53 の整理

ハンズオン用に取得したドメインが不要な場合は、以下を実施します。

ホストゾーンの削除

ホストゾーンを削除すると、中のレコード(CloudFormation が作成した A レコード、SES ドメイン検証で追加した CNAME レコードなど)もまとめて削除されます。ただし、NS レコードと SOA レコード以外のレコードが残っていると削除に失敗するため、先にすべて削除してください。

  1. マネジメントコンソールにて、Amazon Route 53 を表示
  2. 左ペインの「ホストゾーン」を選択
  3. 対象ドメインのホストゾーンを選択
  4. NS レコードと SOA レコード以外のレコードをすべて選択し、「レコードを削除」
  5. 確認画面で「削除」を押下
  6. 「ソーンを削除する」を押下
  7. 確認画面で「削除」と入力「削除」を押下

補足: スタック:dev-001-iam-stack-02-portal-auth が削除済みであれば、CloudFront 向けの A レコードは自動削除されています。SES のドメイン検証用 CNAME(3つ)は手動で追加したものなので、手動で削除する必要があります。

まとめ

お疲れさまでした。IAM ユーザー申請ポータルのハンズオンは以上です。

ポイント 内容
インフラのコード化 AWS CloudFormation 5スタックで全リソースを管理。再現性の高いデプロイ
メールによる所有確認 Amazon SES で一時フォーム URL を送信し、メールアドレスの所有を確認
申請種別の自動分岐 AWS Step Functions の Choice で作成・削除・リセットを振り分け
管理者承認の必須化 承認メールのリンク(1週間有効)をクリックすることで AWS Step Functions が起動。承認前に IAM 操作は実行されない
パスワードの安全な受け渡し 利用者に Amazon SES で直接通知。パスワードは Amazon S3 に保存し、48時間有効な署名付き URL で受け渡し

「IAM ユーザーの申請を Web フォーム化し、承認から作成まで自動化したい」という方の参考になれば幸いです。

今回実装しなかった機能

今回のハンズオンでは、IAM ユーザーの申請受付、管理者の承認・拒否、ユーザーの作成・利用期間延長・削除、パスワード再設定までを対象としています。一方、運用をさらに強化する次の機能は実装していないため、余力のある方はぜひ試してみてください。

  • IAM ユーザーの棚卸し: IAM ユーザーの一覧、最終利用日時、アクセスキーの利用状況、利用期限などを定期的に収集し、不要なユーザーや長期間利用されていない認証情報を確認する機能
  • MFA 設定チェック: IAM ユーザーごとのMFA設定状況を定期的に確認し、MFA未設定のユーザーを管理者へ通知する機能

付録A: AWS CloudFormation スタック構成

フェーズ テンプレート 主なリソース
フェーズ0 stack-00-foundation.yaml Amazon SNS、Amazon DynamoDB、Amazon S3、IAM
フェーズ1 stack-01-waf.yaml AWS WAF(CloudFront 用 WebACL、us-east-1)
フェーズ2 stack-02-portal-auth.yaml Amazon CloudFront、Amazon Route 53、Amazon API Gateway、AWS Lambda(認証 / 月次パスワード更新)、EventBridge
フェーズ3 stack-03-form-request.yaml AWS Lambda(フォーム発行)、Amazon API Gateway
フェーズ4 stack-04-iam-workflow.yaml AWS Step Functions、AWS Lambda(承認処理 / 申請登録)、Amazon API Gateway

付録B: リージョン別のおおよその月額料金見積もり

本構成は、すべてのリソースを同じリージョンに作成する構成ではありません。料金表では、次の区分で整理します。

  • バージニア北部(us-east-1): CloudFront 用 AWS WAF WebACL、CloudFront 用 ACM 証明書
  • 東京(ap-northeast-1): CloudFormation の各スタック、S3、API Gateway、Lambda、DynamoDB、SES、SNS、Step Functions、EventBridge Scheduler、CloudWatch Logs
  • グローバル: Amazon CloudFront、Amazon Route 53、IAM

申請数が月100件程度、Webアクセスが月10万リクエスト程度の小規模利用を想定しています。金額は2026年8月20日時点でAWS公式料金ページを確認して算出した、米ドル建て・税抜きの概算です。AWS無料利用枠、新規アカウント向けクレジット、Savings Plans、組織契約による割引、為替変動、消費税は原則として合計金額から差し引いていません。

サービス リージョン リソース 想定利用量 月額(税抜概算)
AWS WAFバージニア北部CloudFront 用 WebACL、4ルール、Common Bot ControlWebACL 1個、月10万リクエスト約 19.06 USD
AWS Certificate Manager(ACM)バージニア北部CloudFront 用パブリック証明書1枚0.00 USD
Amazon S3東京Webコンテンツ用・認証情報保存用の2バケット合計1GB未満、少量のPUT/GET約 0.03 USD
Amazon API Gateway東京Regional REST API月10万リクエスト約 0.35 USD
AWS Lambda東京7関数(認証、フォーム発行、IAM操作、申請登録、承認処理、エラー通知、月次パスワード更新)合計月500回、平均2秒、128MBを仮定約 0.00 USD〜0.01 USD
Amazon DynamoDB東京申請テーブル(オンデマンド)月1,000読み取り・1,000書き込みリクエスト単位、保存量25GB未満約 0.01 USD未満
Amazon SES東京フォームURL、承認結果、認証情報通知月300通、1通あたり1宛先約 0.05 USD
Amazon SNS東京管理者通知用・ポータルパスワード通知用のStandard Topic月200件のメール通知約 0.01 USD未満
AWS Step Functions東京Standard Workflow月100実行、合計約300ステート遷移0.00 USD※
Amazon EventBridge Scheduler東京月次パスワード更新スケジュール月1回起動0.00 USD※
Amazon CloudWatch Logs東京Lambdaロググループ、エラー専用ロググループ月0.1GB取り込み、保持期間7日約 0.00 USD〜0.05 USD
Amazon CloudFrontグローバルディストリビューションデータ転送1GB、HTTPSリクエスト10万件約 0.20 USD
Amazon Route 53グローバルパブリックホストゾーン、DNSクエリホストゾーン1個、標準クエリ1万件約 0.51 USD
AWS IAMグローバルIAMロール、IAMグループ、IAMユーザー本構成で使用する範囲0.00 USD
AWS CloudFormation東京・バージニア北部スタック管理5スタック0.00 USD

※ 0.00 USD 表記について: AWS Step Functions と Amazon EventBridge Scheduler は、今回の想定利用量が無料利用枠の範囲内であるため、料金表では 0.00 USD としています。無料利用枠の対象外となる場合や、利用量が無料利用枠を超えた場合は、別途料金が発生します。AWS CloudFormation はスタック管理自体に追加料金は発生しませんが、スタックによって作成された各AWSリソースの利用料金は別途発生します。

リージョン別小計

リージョン対象月額(税抜概算)
バージニア北部AWS WAF、ACM約 19.06 USD
東京S3、API Gateway、Lambda、DynamoDB、SES、SNS、Step Functions、EventBridge Scheduler、CloudWatch Logs約 0.50 USD
グローバルCloudFront、Route 53、IAM約 0.71 USD
合計上記の合計約 20.27 USD/月

AWS WAFの計算根拠(us-east-1): WebACL 5.00 USD + ルール4件(Rate-based Rule、AWS Managed Rules 2件、Bot Control 1件)× 1.00 USD = 4.00 USD + Webリクエスト10万件 × 0.60 USD/100万件 = 0.06 USD + Common Bot ControlのWebACL料金 10.00 USD = 19.06 USD。Common Bot Controlは月1,000万リクエストまで無料のため、今回の10万リクエストでは追加のBot Controlリクエスト料金を計上していません。Bot Controlを無効にする場合は、同じ前提で約 9.06 USD になります。

Route 53の計算根拠(グローバル): パブリックホストゾーン 0.50 USD + 標準DNSクエリ1万件(0.40 USD/100万件)約 0.004 USD = 約 0.51 USD。Route 53でドメインを新規取得する場合のドメイン登録料は別途、年額で発生します。

ACMの計算根拠(us-east-1): AWS Certificate Managerで発行するCloudFront用のパブリック証明書は、証明書自体の料金を計上していません。Private CA、インポート証明書、ドメイン登録料などは別料金です。

料金に関する注意: CloudFrontはグローバルサービスですが、データ転送量の単価は配信先エッジロケーションなどにより変動します。DynamoDB、Lambda、CloudWatch Logsの料金は、実際のリクエストサイズ、メモリ、ログ量、無料利用枠の適用状況によって変動します。特にLambdaはテンプレート上の7関数を合計して算出しています。

含めていない料金: Route 53のドメイン登録料、Amazon SESの添付ファイル転送、CloudFrontの大容量通信、WAFの大量リクエスト、AWS Support、Savings Plansや組織割引、消費税は含めていません。ドメイン登録料はTLDごとに異なるため、登録時の料金表示を確認してください。

料金参照ページ(AWS公式)


付録C: Amazon CloudFront のキャッシュ削除方法

Amazon S3 に配置した Web ファイル(HTML・JavaScript)を更新した場合、Amazon CloudFront のキャッシュが残っていると古いファイルが配信され続けます。以下の手順でキャッシュを削除(無効化)してください。

  1. マネジメントコンソールにて、Amazon CloudFront を開く
  2. 左ペインの「ディストリビューション」を選択
  3. 対象のディストリビューションを選択
  4. 「キャッシュ削除」タブを選択し、「キャッシュ削除を作成」を押下
  5. 「オブジェクトパス」に無効化対象を入力:
    • 特定ファイル: /index.html/form/create.html
    • 全ファイル: /*
  6. 「キャッシュ削除を作成」を押下
  7. ステータスが「完了」になるまで待機(通常1〜2分)

補足: 月あたり1,000パスまでの無効化リクエストは無料です。/* は1パスとしてカウントされます。無効化の完了には通常1〜2分かかります。

ポイント: Web ファイルを更新したのにブラウザで反映されない場合は、CloudFront のキャッシュ削除に加えて、ブラウザ側のキャッシュも削除(Ctrl + Shift + R、または開発者ツールで「キャッシュの無効化」を有効にして再読み込み)してください。

この記事は私が書きました

印鑰 幸太

記事一覧

全ての AWS 認定を取得。AWSサービスでは、AWS CloudFormationが好きです。ジム通いが趣味です。

印鑰 幸太

この記事を共有する

クラウドのご相談

CONTACT

クラウド導入や運用でお悩みの方は、お気軽にご相談ください。
専門家がサポートします。

サービス資料ダウンロード

DOWNLOAD

ビジネスをクラウドで加速させる準備はできていますか?
今すぐサービス資料をダウンロードして、詳細をご確認ください。