
안녕하세요,
AWS 환경을 운영하시다 보면 MFA를 등록하지 않고 사용하시는 분들이 꽤 있습니다.
하지만 보안 권고사항이므로 MFA를 적용하여 사용하시길 권장드립니다.
이번 글은 MFA 미등록 사용자에 대한 강제 정책을 적용해보겠습니다.
목적 : IAM 사용자들 중 MFA를 등록하지 않는 사용자들은 아무 권한이 없는 그룹으로 강제 이동.

01. AWS Config 규칙으로 MFA 미사용자 탐지
📌 AWS Config 란?
AWS 계정의 리소스 구성 및 관계를 지속적으로 추적, 평가, 감사할 수 있게 해주는 서비스 입니다.
아래와 같이 규칙을 생성하여 Non-compliant한 리소스들을 추적하고, 내부 보안 규정에 맞게 준수하도록
관리할 수 있습니다.
✅ AWS Config 설정
- 활성화 → 규칙 → 규칙 추가 → "iam-user-mfa-enabled" 검색.
- Scope 및 규칙 탐지에 대한 빈도는 여러분의 내부 정책에 맞게 설정하시면 됩니다.

✅ AWS Config 확인
- 방금 생성한 규칙에 대해 3분 내외로 시간 경과 후 확인해보면 규칙에 대해 "미준수"라고 된 리소스를 볼 수 있다.

이제 AWS Config에서 감지된 MFA 미등록 이벤트를 자동으로 IAM 그룹에 이동하도록
EventBridge + Lambda를 사용하여 자동화 할 예정입니다.
02. EventBridge + Lambda 함수로 자동화 설계
📌 AWS EventBridge 란?
AWS의 대표적인 Serverless 서비스로, AWS 서비스에서 발생하는 다양한 이벤트를 연결하고
라우팅하여 확장 가능한 이벤트 기반 아키텍처(EDA)를 구현할 수 있도록 돕는 "이벤트 버스"입니다.
특정 규칙(Rule)과 이벤트 패턴을 기반으로 이벤트 소스와 대상(Target)을 분리하여 느슨한 결합이 장점임.
📌 Lambda 함수 란?
AWS가 제공하는 Serverless FaaS 솔루션으로, 함수의 인스턴스를 실행하여 Event를 처리함.
높은 가용성을 제공하며, 서버를 프로비저닝 할 필요 없이 코드를 서비스로 배포할 수 있는 장점이 있다.
즉, Application 및 서비스의 코드 작성 → Event Trigger 정의 → Lambda 함수 실행 → 배포
✅ AWS EventBridge 설정
- 규칙 생성할때 "구성"탭에서 Rule과 그에 맞는 Descriptions을 작성합니다.

- "구축"탭으로 넘어와서 Event에서 Config를 검색하고 아래와 같이 적용한다.
- Trigger : Config Rules Compliance Change
- Target : Lambda

1) 트리거 이벤트에서 하기와 같이 JSON 코드를 이벤트 패턴에 설정.
{
"source": ["aws.config"],
"detail-type": ["Config Rules Compliance Change"],
"detail": {
"newEvaluationResult": {
"complianceType": ["NON_COMPLIANT"]
},
"resourceType": ["AWS::IAM::User"],
"configRuleName": ["iam-user-mfa-enabled"]
}
}
2) Lambda 함수 생성
- Lambda 함수 생성을 클릭하시고 생성하시면 됩니다 (보안 설정은 Skip)

3) python 코드로 작성
- 혹시나 모를 상황에 대비하여 예외 계정에 대한 Key, Vaule를 Tag값으로 적용하려고 설정하였음.
import os
import json
import boto3
import logging
from botocore.exceptions import ClientError
# --- 로깅 설정 ---
logger = logging.getLogger()
logger.setLevel(logging.INFO)
iam = boto3.client('iam')
# --- 환경변수(필요 시 콘솔에서 수정) ---
ISOLATION_GROUP = os.getenv('ISOLATION_GROUP', 'MFA-Registration-Only') # 격리 그룹
NORMAL_GROUP = os.getenv('NORMAL_GROUP', 'Users-Standard') # 일반 그룹
EXCEPTION_TAG_KEY = os.getenv('EXCEPTION_TAG_KEY', 'BreakGlass') # 예외 태그 키
EXCEPTION_TAG_VALUE = os.getenv('EXCEPTION_TAG_VALUE', 'true').lower() # 예외 태그 값
EXCEPTION_USERS = set(u.strip() for u in os.getenv('EXCEPTION_USERS', '').split(',') if u.strip()) # 예외 사용자 목록(쉼표 구분)
def handler(event, context):
"""
EventBridge → AWS Config 'Config Rules Compliance Change' 이벤트 처리
- NON_COMPLIANT: 격리 그룹으로 이동
- COMPLIANT: 일반 그룹으로 복귀 (MFA가 실제로 있는지 재확인)
"""
logger.info("Received event: %s", json.dumps(event))
# 입력 변환(Input transformer)을 쓰지 않은 경우 detail에서 추출
detail = event.get('detail', {})
compliance_type = (
detail.get('newEvaluationResult', {}).get('complianceType')
or event.get('complianceType') # 입력 변환 사용 시
)
username = parse_username_from_event(event)
if not username:
logger.error("Username parse failed from event.")
return {"status": "error", "reason": "username parse failed"}
logger.info("User: %s, complianceType: %s", username, compliance_type)
# 예외 사용자 스킵
if is_exception_user(username):
logger.info("Skipped user due to exception: %s", username)
return {"status": "skipped_exception", "user": username}
if compliance_type == 'NON_COMPLIANT':
# 격리 그룹으로 이동
ensure_membership(username, target_group=ISOLATION_GROUP, remove_group=NORMAL_GROUP)
return {"status": "isolated", "user": username}
elif compliance_type == 'COMPLIANT':
# 실제 MFA 보유 여부 재확인(방어적)
if user_has_mfa(username):
ensure_membership(username, target_group=NORMAL_GROUP, remove_group=ISOLATION_GROUP)
return {"status": "restored", "user": username}
else:
# 규칙은 COMPLIANT로 보였지만 디바이스가 없다면(드문 케이스) 격리 유지
ensure_membership(username, target_group=ISOLATION_GROUP, remove_group=NORMAL_GROUP)
return {"status": "isolated_defense", "user": username}
else:
logger.warning("Unknown complianceType: %s", compliance_type)
return {"status": "ignored", "user": username, "complianceType": compliance_type}
# ----------------- 유틸 함수 -----------------
def parse_username_from_event(event: dict) -> str | None:
"""
Config 이벤트에서 username을 파싱.
- 일반: detail.resourceId == 'user/<username>'
- 방어적: detail.resourceName, detail.resourceArn도 시도
- 입력 변환을 썼다면 event['resourceId']에서 꺼냄
"""
detail = event.get('detail', {})
candidates = [
detail.get('resourceId', ''),
detail.get('resourceName', ''),
detail.get('resourceArn', ''),
event.get('resourceId', ''),
]
for rid in candidates:
if not rid:
continue
# ARN 형태: arn:aws:iam::<account-id>:user/<path/username>
if rid.startswith('arn:aws:iam'):
try:
return rid.split(':')[-1].split('user/')[-1]
except Exception:
continue
# 일반 형태: user/<username>
if rid.startswith('user/'):
return rid.split('user/')[-1]
return None
def user_has_mfa(username: str) -> bool:
try:
resp = iam.list_mfa_devices(UserName=username)
has = len(resp.get('MFADevices', [])) > 0
logger.info("User %s has MFA: %s", username, has)
return has
except ClientError as e:
logger.error("list_mfa_devices error for %s: %s", username, e)
return False
def is_exception_user(username: str) -> bool:
# 리스트 기반 예외
if username in EXCEPTION_USERS:
return True
# 태그 기반 예외
try:
tags = iam.list_user_tags(UserName=username).get('Tags', [])
for t in tags:
if t['Key'] == EXCEPTION_TAG_KEY and t.get('Value', '').lower() == EXCEPTION_TAG_VALUE:
return True
except ClientError as e:
# 사용자 없거나 권한 부족 시 예외 처리
logger.warning("list_user_tags warning for %s: %s", username, e)
return False
def ensure_membership(username: str, target_group: str, remove_group: str | None = None):
"""
remove_group에서 제거 후 target_group으로 추가.
이미 있을 경우 중복 추가를 피하기 위해 사전 조회.
"""
# 현재 그룹 조회
try:
groups = iam.list_groups_for_user(UserName=username).get('Groups', [])
current = {g['GroupName'] for g in groups}
except ClientError as e:
logger.error("list_groups_for_user error for %s: %s", username, e)
return
# 제거
if remove_group and remove_group in current:
try:
iam.remove_user_from_group(GroupName=remove_group, UserName=username)
logger.info("Removed %s from %s", username, remove_group)
current.remove(remove_group)
except ClientError as e:
logger.warning("remove_user_from_group warning for %s: %s", username, e)
# 추가
if target_group not in current:
try:
iam.add_user_to_group(GroupName=target_group, UserName=username)
logger.info("Added %s to %s", username, target_group)
except ClientError as e:
logger.error("add_user_to_group error for %s: %s", username, e)
else:
logger.info("User %s already in %s", username, target_group)
3-1) 파이썬 코드에 대한 환경변수 설정 표는 아래와 같으며 IAM 사용자를 Tag로 관리 가능하다.
- 즉, IAM 사용자에 BreakGlass=true 태그를 달면 Skip 처리 된다.
| Key | 기본 값 | 설명 |
| ISOLATION_GROUP | MFA-Registration-Only | 미등록 사용자 격리 그룹명 |
| NORMAL_GROUP | Users-Standard | 등록 사용자 일반 그룹명 |
| EXCEPTION_TAG_KEY | BreakGlass | 예외 태그 키 |
| EXCEPTION_TAG_VALUE | true | 예외 태그 값 (소문자로 작성 필수) |
| EXCEPTION_USERS | 쉼표로 사용자명 나열 |
4) 코드를 집어넣고 왼쪽에 "Deploy"를 눌러서 새 코드가 런타임에 반영.

2-1. Lambda Functions 에서 사용할 권한 부여
- IAM → 정책 → 정책 생성
- MFA 미적용 계정에 대해 권한이 없는 그룹으로 이동시키기 위한 Policy 설정

- Lambda 함수에서 사용하는 정책에 연결(추가)

- 다시 Lambda 함수에서 확인해보면 "IAM"에 대한 정책이 연결되어 있다.

2-2. 권한이 없는 사용자 그룹 + 역할 생성
- 위 방식과 똑같이 정책을 생성한 후에, 그룹을 생성하고 정책을 연결할 것입니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:GetUser",
"iam:ListMFADevices",
"iam:GetAccountSummary"
],
"Resource": "arn:aws:iam::092318838103:user/${aws:username}"
},
{
"Effect": "Allow",
"Action": [
"iam:ListVirtualMFADevices"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"iam:CreateVirtualMFADevice",
"iam:DeleteVirtualMFADevice"
],
"Resource": "arn:aws:iam::092318838103:mfa/${aws:username}"
},
{
"Effect": "Allow",
"Action": [
"iam:EnableMFADevice",
"iam:DeactivateMFADevice",
"iam:ResyncMFADevice"
],
"Resource": "arn:aws:iam::092318838103:user/${aws:username}"
},
{
"Effect": "Allow",
"Action": [
"iam:ChangePassword"
],
"Resource": "arn:aws:iam::092318838103:user/${aws:username}"
}
]
}
- 정책을 생성하였으니 사용자 그룹을 생성하자.
- Group name : MFA-Registration-Only

2-3. EventBridge 설정 적용 & Flows 확인
✅ 아까 위에서 설정한 내용을 토대로 생성!
- 방금 생성한 'iam-user-mfa-compliance-enabled' 이벤트 버스 규칙이 생성되었다.

📌 Event Flows
- AWS Config (규칙: iam-user-mfa-enabled) : 평가 결과 (준수 / 미준수)
- Amazon EventBridge (규칙: Compliance Change 이벤트 필터) : 대상(Target) 호출
- AWS Lambda (그룹 이동 로직 함수 실행)
- IAM (그룹: MFA-Registration-Only) : MFA 미적용 계정은 해당 그룹으로 이동됨.
Lambda 함수에서 사용된 python 코드는 내부적으로 환경에 맞게 변경하여 사용하는 것을 권장합니다.