こんにちは!イーゴリです。ユーザーがJITノードへの接続を試みると、アクセス理由の入力が求められ、承認ポリシーに基づいて自動承認されるか、承認者へ通知が送られます。承認者への通知は、メール、またはSlackやMicrosoft Teamsと連携するAmazon Q Developer in chat applications経由で行うことができます。
一方で、JITノードアクセスは通常AWS Management ConsoleやCLIから利用しますが、実際にはssm:StartAccessRequestやssm:SendAutomationSignalといったAPIが提供されているため、コンソールを使わずに既存の社内ツール(Backlog等)や監視システムなど任意の外部Webhookからアクセスリクエストの作成・承認を行うことも可能です。
なお、本記事ではSlackを承認通知・アクション先の例として使用していますが、これはあくまで一例です。
本構成の本質的な価値は「Slack連携」自体ではなく、AWS Management ConsoleやCLI以外の任意の外部システムから承認フローを完結できる点にあります。ssm:StartAccessRequestとssm:SendAutomationSignalがAPIとして提供されているため、Webhookやボタン操作(あるいは同等のコールバック機構)さえ実装できれば、LINE WORKS、WIH(Web入力ヘルプデスク/社内問い合わせツール)、Backlogのコメント・Webhook機能など、AWSが標準でネイティブサポートしていない社内システム上でも同様に承認・拒否のアクションを実現できます。
つまり本記事の構成はSlack専用の手順ではなく、「JITノードアクセスの承認プロセスを、任意の社内ツールに拡張するための一般的なパターン」として読み替えていただくことが可能です。
これにより、外部からのWebhookをトリガーにLambda経由でアクセスリクエストを起票し、Slackへ通知を送り、さらにそのチャット上のボタン操作から承認・拒否の判断まで完結させる、といった構成が実現できます。
本記事では、この考え方に基づき、外部Webhook → API Gateway → Lambda → StartAccessRequestによるリクエスト作成 → Slack通知 → Slack上のボタン操作によるSendAutomationSignalでの承認/拒否、という一連の流れを実際に構築する手順を紹介します。
イメージ図


記事内の表記について
本記事では以下のプレースホルダーを使用しています。実際の環境に合わせて適宜読み替えてください。
- <workload-account-id>:ワークロード側のAWSアカウントID
- <委任管理アカウント>:委任管理AWSアカウントID
- x.x.x.x/32:許可するIPアドレス
- xxx:環境に応じたリソース名やID等
注意
本記事で掲載しているLambdaコードは動作確認用のサンプルであり、本番環境での動作を保証するものではありません。
委任管理アカウントで作業する
STEP 1: Lambda関数作成
Lambda コンソール → 「関数を作成」をクリックします。
- 名前:jit-approve-handler
- ランタイム:Python 3.14
- 実行ロール:新しいロールを作成

import boto3
import json
import urllib.request
import os
def send_notification(message):
webhook_url = os.environ['SLACK_WEBHOOK_URL']
payload = json.dumps({'text': message}).encode('utf-8')
req = urllib.request.Request(
webhook_url,
data=payload,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req)
def lambda_handler(event, context):
if 'body' in event and event['body']:
body = json.loads(event['body'])
else:
body = event
instance_id = body['instance_id']
user = body['user']
reason = body.get('reason', 'Webhook経由の自動承認')
sts = boto3.client('sts')
assumed_role = sts.assume_role(
RoleArn='arn:aws:iam::<workload-account-id>:role/jit-cross-account-role',
RoleSessionName='jit-webhook-session'
)
credentials = assumed_role['Credentials']
ssm = boto3.client(
'ssm',
region_name='ap-northeast-1',
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken']
)
response = ssm.start_access_request(
Reason=reason,
Targets=[
{
'Key': 'InstanceIds',
'Values': [instance_id]
}
],
Tags=[
{'Key': 'RequestedBy', 'Value': user},
{'Key': 'Source', 'Value': 'WIH-Webhook'}
]
)
access_request_id = response['AccessRequestId']
# 申請者への通知
send_notification(
f"✅ *JITアクセスリクエストを作成しました*\n"
f"申請者: {user}\n"
f"対象EC2: {instance_id}\n"
f"理由: {reason}\n"
f"リクエストID: {access_request_id}\n"
f"ステータス: 承認待ち"
)
# 承認者への通知
send_notification(
f"🔔 *承認依頼が届きました*\n"
f"申請者: {user}\n"
f"対象EC2: {instance_id}\n"
f"理由: {reason}\n"
f"リクエストID: {access_request_id}\n"
f"AWSコンソールで承認してください。"
)
return {
'statusCode': 200,
'body': json.dumps({
'message': 'アクセスリクエスト作成完了。承認待ち。',
'access_request_id': access_request_id
})
}
STEP 2: LambdaのIAMロールに権限追加
インラインポリシー:jit-approve-policyを作成します。

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:StartAccessRequest",
"ssm:SendAutomationSignal",
"ssm:CreateOpsItem",
"ssm:GetOpsItem",
"ssm:UpdateOpsItem",
"sts:AssumeRole"
],
"Resource": "*"
}
]
}

STEP 3: API Gateway作成
- API Gateway コンソール → 「APIを作成」をクリックします。
- 「REST API」を選択します。
- API名:jit-webhook-api


- リソース → 「/approve」を作成します。


- 統合タイプ → 「Lambda関数」を選択します。

- メソッド → 「POST」を追加します。

jit-approve-handlerを選択します。



STEP 4: API Gatewayデプロイ
- 「APIをデプロイ」をクリックします。

- ステージ:prod

- エンドポイントURLを取得します。
例:https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod

API Gatewayへの悪意リクエストを防ぐために必要に応じて会社もしくはWebhook元のIPで制限してください。
Backlogの場合:
もしくは API Gateway に AWS WAF をアタッチして IP Set でホワイトリスト管理 → IPが変わったときの更新が楽です。
IPアドレスの変更に影響されない方法であれば、WebhookのURLにベーシック認証をつけることで、IPアドレスに依存しない認証できます。但し、API Gateway はベーシック認証をネイティブサポートしていないため、実装するにはLambda オーソライザーでベーシック認証を自前実装するかまたは Cognito / API Key で代替する必要があります。
この記事のため、会社IPで制限します。API Gateway → 左ナビ →「リソースポリシー」→ 以下のJSONを貼り付け
Resource及びaws:SourceIpに適切なパラメータを入力してください。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:ap-northeast-1:<委任管理アカウント>:xxx/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "x.x.x.x/32"
}
}
}
]
}

- 「リソース」→「APIをデプロイ」→ ステージ:prod → 「デプロイ」

STEP 5: Lambdaタイマウント変更
タイムアウトは3秒から30秒に変更します。 3秒が足りないため、下記のエラーが発生します。


動作確認
API Gateway、instance_id、user等を適切に入力した上で、実行してください。
コマンド:
curl -X POST \
https://XXX.execute-api.ap-northeast-1.amazonaws.com/prod/approve \
-H 'Content-Type: application/json' \
-d '{
"instance_id": "i-XXX",
"user": "test-user1@テナント.onmicrosoft.com",
"reason": "本番作業テスト"
}'
結果:
{"errorMessage":"2026-05-18T03:41:50.210Z a842711d-ad4a-4698-bb65-f1370bdf7fc0 Task timed out after 3.01 seconds"}
%
ワークロードAWSアカウントで作業する
STEP 6: クロスアカウントロール作成
ワークロードAWSアカウントで作業を行います。
クロスアカウントロールを作成します。
- Devアカウントのロール作成:
- 信頼されたエンティティ:別のAWSアカウント → 管理委任AWSアカウントIDを入力します。
- インラインポリシー:jit-cross-account-policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:StartAccessRequest",
"ssm:SendAutomationSignal",
"ssm:CreateOpsItem",
"ssm:GetOpsItem",
"ssm:UpdateOpsItem",
"ssm:StartAutomationExecution",
"ssm:StopAutomationExecution",
"ssm:AddTagsToResource"
],
"Resource": "*"
}
]
}
- ロール名:jit-cross-account-role
動作確認
curlでテストします。API Gateway、instance_id、user等を適切に入力した上で、実行してください。
curl -X POST \
https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/approve \
-H 'Content-Type: application/json' \
-d '{
"instance_id": "i-XXX",
"user": "test-user1@テナント.onmicrosoft.com",
"reason": "本番作業テスト"
}'
結果:
{"statusCode": 200, "body": "\"Hello from Lambda!\""}%
STEP 7: テストコードをデプロイする。


STEP 8: テスト
コマンド:
% curl -X POST \
https://u2XXX.execute-api.ap-northeast-1.amazonaws.com/prod/approve \
-H 'Content-Type: application/json' \
-d '{
"instance_id": "i-0e129ee2XXXX",
"user": "test-user1@XXX.onmicrosoft.com",
"reason": "本番作業テスト"
}'
結果:
{"statusCode": 200, "body": "{\"access_request_id\": \"oi-c7aXXXX\", \"operational_data_keys\": [\"/aws/accessrequest/approvaldetails\", \"/aws/accessrequest/context\", \"/aws/accessrequest\"], \"operational_data\": {\"/aws/accessrequest/approvaldetails\": {\"Value\": \"\\u672c\\u756a\\u4f5c\\u696d\\u30c6\\u30b9\\u30c8\", \"Type\": \"SearchableString\"}, \"/aws/accessrequest/context\": {\"Value\": \"{\\\"targets\\\":{\\\"instanceId\\\":\\\"arn:aws:ec2:ap-northeast-1:72XXXX:instance/i-0eXXX\\\"},\\\"accessTokenRole\\\":\\\"\\\"}\", \"Type\": \"SearchableString\"}, \"/aws/accessrequest\": {\"Value\": \"{\\\"requester\\\":{\\\"sourceAccountId\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"sourceRegion\\\":\\\"\\\",\\\"arn\\\":\\\"arn:aws:sts::72XXXXX:assumed-role/jit-cross-account-role/jit-webhook-session\\\",\\\"isReplica\\\":\\\"false\\\",\\\"sourceOpsItemId\\\":\\\"\\\"}}\", \"Type\": \"SearchableString\"}}}"}%
~ %
IP制限確認
SWXのIPからのみしかアクセスできません。 別のIPからアクセスすると、エラーが発生します。

STEP 9: Slackとの連携
Slackでの作業
Slackチャンネルを作成します。自分の場合、Slackプライベートチャンネルを作成しました。
- https://api.slack.com/apps を開きます
- 「Create New App」→「From scratch」
- App Name:jit-notifications
- Workspace:自分のSlack workspaceを選択
- 「Create App」

- 左ナビ →「Incoming Webhooks」をクリックします。
- 「Activate Incoming Webhooks」をONにします。

- 下にスクロール →「Add New Webhook to Workspace」をクリックします。
- チャンネル:Slackチャンネルを選択します。
- 「Allow」をクリックします。


Lambdaでの作業
① Lambda → 設定 → 環境変数 → 編集


キー: SLACK_WEBHOOK_URL
値: https://hooks.slack.com/services/XXX/XXX/XXX

② コードを更新(上のコードに差し替え)
もう一回curlを叩くと、Slackプライベートチャンネルにすべての通知が届きます。

STEP 10: Slackから承認等したい場合
理論上の話のため、不具合等がある可能性がありますので、ご了承ください。
- API Gateway コンソール → jit-webhook-api → 「リソースを作成」をクリックします。
- リソース名:slack-action
- 「リソースを作成」


- 次にメソッドを追加します。
- /slack-action を選択
- 「メソッドを作成」
- メソッドタイプ:POST
- 統合タイプ:Lambda関数
- Lambda関数:jit-approve-handler(同じ関数を使う)
- 「メソッドを作成」


- 「APIをデプロイ」→ ステージ:prod → 「デプロイ」
- 完了したらURLを確認: https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/slack-action

- まず Slack の Interactive Messages を有効化します
- https://api.slack.com/apps → jit-notif を開きます。
- 左ナビ →「Interactivity & Shortcuts」
- 「Interactivity」をONにします
- Request URL にAPI GatewayのURLを入れます
https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/slack-action
- 「Save Changes」

Slack actionsからのリクエストを処理できるためLambdaを下記のコードへ修正します。
import boto3
import json
import urllib.request
import urllib.parse
import os
NOTIFICATION_PROVIDER = os.environ.get('NOTIFICATION_PROVIDER', 'slack')
WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
def send_slack_notification_with_button(message, access_request_id, instance_id, user):
payload = {
"text": message,
"attachments": [
{
"fallback": "承認または拒否してください",
"callback_id": f"jit_approval_{access_request_id}",
"actions": [
{
"name": "approve",
"text": "✅ 承認",
"type": "button",
"value": json.dumps({
"action": "approve",
"access_request_id": access_request_id,
"instance_id": instance_id,
"user": user
}),
"style": "primary"
},
{
"name": "reject",
"text": "❌ 拒否",
"type": "button",
"value": json.dumps({
"action": "reject",
"access_request_id": access_request_id,
"instance_id": instance_id,
"user": user
}),
"style": "danger"
}
]
}
]
}
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
WEBHOOK_URL,
data=data,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req)
def send_notification(message):
payload = json.dumps({'text': message}).encode('utf-8')
req = urllib.request.Request(
WEBHOOK_URL,
data=payload,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req)
def handle_slack_action(event):
body = urllib.parse.unquote(event['body'])
body = body.replace('payload=', '')
payload = json.loads(body)
action = payload['actions'][0]
action_value = json.loads(action['value'])
access_request_id = action_value['access_request_id']
action_type = action_value['action']
user = action_value['user']
instance_id = action_value['instance_id']
approver = payload['user']['name']
sts = boto3.client('sts')
assumed_role = sts.assume_role(
RoleArn='arn:aws:iam::<workload-account-id>:role/jit-cross-account-role',
RoleSessionName='jit-webhook-session'
)
credentials = assumed_role['Credentials']
ssm = boto3.client(
'ssm',
region_name='ap-northeast-1',
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken']
)
ops_item = ssm.get_ops_item(OpsItemId=access_request_id)
operational_data = ops_item['OpsItem'].get('OperationalData', {})
automation_execution_id = None
for key, value in operational_data.items():
if 'automation' in key.lower():
try:
val = json.loads(value['Value'])
if isinstance(val, list) and len(val) > 0:
automation_execution_id = val[0]
except:
pass
if automation_execution_id:
signal_type = 'Approve' if action_type == 'approve' else 'Reject'
ssm.send_automation_signal(
AutomationExecutionId=automation_execution_id,
SignalType=signal_type,
Payload={'Comment': [f'Slackから{signal_type}:承認者 {approver}']}
)
if action_type == 'approve':
send_notification(f"✅ *承認されました*\n申請者: {user}\n対象EC2: {instance_id}\n承認者: {approver}")
else:
send_notification(f"❌ *拒否されました*\n申請者: {user}\n対象EC2: {instance_id}\n承認者: {approver}")
return {
'statusCode': 200,
'body': json.dumps({'message': f'{action_type}処理完了'})
}
def lambda_handler(event, context):
# Slackからのアクション(ボタンクリック)
path = event.get('path', '')
if '/slack-action' in path:
return handle_slack_action(event)
# WIHからのWebhook(リクエスト作成)
if 'body' in event and event['body']:
body = json.loads(event['body'])
else:
body = event
instance_id = body['instance_id']
user = body['user']
reason = body.get('reason', 'Webhook経由の自動承認')
sts = boto3.client('sts')
assumed_role = sts.assume_role(
RoleArn='arn:aws:iam::<workload-account-id>:role/jit-cross-account-role',
RoleSessionName='jit-webhook-session'
)
credentials = assumed_role['Credentials']
ssm = boto3.client(
'ssm',
region_name='ap-northeast-1',
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken']
)
response = ssm.start_access_request(
Reason=reason,
Targets=[
{
'Key': 'InstanceIds',
'Values': [instance_id]
}
],
Tags=[
{'Key': 'RequestedBy', 'Value': user},
{'Key': 'Source', 'Value': 'WIH-Webhook'}
]
)
access_request_id = response['AccessRequestId']
# 申請者への通知
send_notification(
f"✅ *JITアクセスリクエストを作成しました*\n"
f"申請者: {user}\n"
f"対象EC2: {instance_id}\n"
f"理由: {reason}\n"
f"リクエストID: {access_request_id}\n"
f"ステータス: 承認待ち"
)
# 承認者への通知(ボタン付き)
send_slack_notification_with_button(
f"🔔 *承認依頼が届きました*\n"
f"申請者: {user}\n"
f"対象EC2: {instance_id}\n"
f"理由: {reason}\n"
f"リクエストID: {access_request_id}",
access_request_id,
instance_id,
user
)
return {
'statusCode': 200,
'body': json.dumps({
'message': 'アクセスリクエスト作成完了。承認待ち。',
'access_request_id': access_request_id
})
}
リクエストを送ると下記の結果になります。

補足:/slack-action エンドポイントのセキュリティについて
現在の構成では /slack-action も会社IPのみに制限されているため、Slackからのリクエストがブロックされます。
対応策として、リソースポリシーをエンドポイント別に分割し、/slack-action へのすべてのリクエストを許可した上で、
Lambda オーソライザー(X-Slack-Signature 署名検証)とAWS WAF のレートベースルール(例:10 req/sec)を導入する方法も一つの選択肢です。
→ Verifying requests from Slack(Slack公式)
→ Configure an API Gateway Lambda authorizer(AWS公式)
→ Amazon API Gateway adds support for AWS WAF(AWS公式)
以上、御一読ありがとうございました。
本田 イーゴリ (記事一覧)
クロスインダストリー第2本部 インフラ技術課
・2026 AWS Ambassadors
・2026 Japan AWS Top Engineers (Services)
・2026 Japan All AWS Certifications Engineers
・2024 Japan AWS Top Engineers (Security)
・ISC2 CISSP
・EC-Council CCSE
・Azure AZ-900
趣味:日本国内旅行(47都道府県制覇)・ドライブ・音楽