Cloud Architect/AWS

[AWS IAM] MFA 미등록 사용자 강제 정책

"Everything about infra" 2025. 12. 10. 19:59

 

안녕하세요,

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

  1. AWS Config (규칙: iam-user-mfa-enabled) : 평가 결과 (준수 / 미준수)
  2. Amazon EventBridge (규칙: Compliance Change 이벤트 필터) : 대상(Target) 호출
  3. AWS Lambda (그룹 이동 로직 함수 실행)
  4. IAM (그룹: MFA-Registration-Only) : MFA 미적용 계정은 해당 그룹으로 이동됨.

 

Lambda 함수에서 사용된 python 코드는 내부적으로 환경에 맞게 변경하여 사용하는 것을 권장합니다.