Post

IAM 공부 2편

IAM을 맨날 사용하고 보안에서도 많이 나오는데 이게 어떻게 인증이 되는거야?

IAM 공부 2편

신원의 자료구조

처음 업무를 받았을 때 계획은 이랬다.

1
2
3
4
1. 그룹 하나 만든다
2. MFA 강제 정책을 그 그룹에 붙인다
3. 사람들을 그 그룹에 넣는다
4. 끝

그리고 실제로 이렇게 하면 된다. 되긴 되는데 — 되는지는 몰랐다.

  • 그룹에 붙인 정책이 사람들에게 복사되는 건가?
  • 그럼 나중에 정책을 고치면 다시 복사해야 하나?
  • 새 사람을 그룹에 넣으면 언제부터 적용되나?

세 신원은 “무엇을 가지느냐”로 갈린다

1
2
3
4
                   자격증명      정책 부착 가능?   principal이 될 수 있나?   로그인?
IAM User           있음(장기)        O                  O                  O
IAM Group          없음             O (담는 그릇)        X  ← 중요           X
IAM Role           없음(빌림)        O                  O (걸친 세션이)       X (직접은)

여기서 헷갈린게 Group이다.

1
2
3
4
5
6
그룹 ARN을 정책의 Principal 자리나 리소스 정책에 쓸 수 없다.

"Principal": { "AWS": "arn:aws:iam::123:group/dev" }   ← 불가능

왜? 그룹은 "요청을 보내는 주체"가 아니라 "정책을 담아두는 상자"이기 때문이다.
    요청은 항상 user(또는 assumed-role)가 보낸다. 그룹이 요청을 보내는 일은 없다.

이 한 문장이 그룹의 정체 전부다. 그룹은 principal이 아니라 정책의 컨테이너다.

공식문서 · IAM user groups — “A user group is not truly an identity because it cannot be identified as a Principal in a resource-based policy”


핵심: 그룹과의 연결 정보는 요청할 때마다 “조립한다”

1
2
3
4
5
6
7
8
9
10
내가 갖고 있던 직관 (틀림):
   "홍길동에게 그룹 정책이 복사돼서 붙어 있다"
   → 아니다. 복사하지 않는다.

실제로 벌어지는 일 (런타임 resolve):
   홍길동이 요청을 보낸다
     → 인가 엔진이 "홍길동이 지금 속한 그룹이 뭐지?" 를 실시간 조회
     → 그 그룹들에 붙은 정책 + 홍길동에게 직접 붙은 정책을
        요청 처리 순간에 하나의 "적용 정책 집합"으로 조립
     → 그 집합으로 평가

매 요청마다, 그 순간에 소속을 풀어서 즉석에서 합친다.

그래서 이런 것들이 자동으로 따라온다.

1
2
3
· 신입을 그룹에 넣는 순간   → 다음 요청부터 즉시 적용 (재배포·복사 없음)
· 그룹 정책을 한 줄 고치면  → 그룹원 전원의 다음 요청부터 즉시 반영
· 사용자를 그룹에서 빼면    → 즉시 그 정책이 안 풀림

내가 하려던 “그룹에 MFA 정책 하나 붙이기”가 왜 전원에게 걸리는지, 이제 자료구조 수준에서 답할 수 있다. 개인에게는 아무것도 심지 않았는데도, 요청 시점의 resolve가 매번 그룹을 훑기 때문이다.

제약도 같은 이유에서 나온다

1
2
3
4
5
6
7
· 그룹은 중첩 불가 (그룹 안에 그룹 X)
    → resolve를 단순하고 예측 가능하게 유지하려는 설계 선택

· 사용자는 여러 그룹에 동시 소속 가능 (기본 최대 10)
    → 그 모든 그룹의 정책이 하나로 합쳐진다

· 합쳐진 여러 Allow/Deny의 승부는? → 평가 알고리즘 (Deny가 이긴다)

공식문서 · IAM user groups — “User groups can’t be nested” / 사용자는 여러 그룹에 속할 수 있음 · IAM quotas — 사용자당 그룹 수 등 기본 한도


Role은 “자격증명 없이 걸쳐지는 정책 덩어리”

계속 나온 Role을, 이제 자료구조로 정리해보자.

1
2
3
User:  자격증명(장기) + 신원정책들                    → 항상 자기 자신으로 요청
Role:  자격증명 없음  + 신원정책들(권한정책)
                      + 신뢰정책(누가 걸칠 수 있나)   → 걸친 세션(assumed-role)이 요청
  • Role의 권한정책은 User의 신원정책과 완전히 같은 취급이다 (무엇을 할 수 있나).
  • Role의 신뢰정책은 특이하게 Principal을 명시하는 리소스형 정책이다 — “이 역할이라는 리소스에 누가 접근(assume)할 수 있나”.
  • 걸치면 aws:useridAROA...:세션명이 되고, 원래 그룹 소속은 딸려오지 않는다. 역할은 자기 권한정책만 갖는다.

그리고 여기서 내 업무에 구멍이 하나 생긴다

1
2
3
4
"그룹으로 MFA 강제"는 User에게 걸리는 통제다.

  김민정(User) → 그룹 → Force-MFA 정책 → 걸림 ✓
  누군가 → Role을 걸침 → 그 Role은 그룹 소속이 없음 → 안 걸림 ✗

Role을 통해 들어오는 접근은 별도로 막아야 한다.

이 구분을 놓치면 “그룹으로 MFA 다 걸었는데 역할로는 그냥 뚫리는” 상태가 된다. 감사에서 지적받기 딱 좋은 지점이다.

그럼 역할 정책에 MFA 조건을 넣으면 되지 않나?

여기서 자연스럽게 나오는 생각. “요청할 때 MFA 인증됐는지 보는 조건, 그거 역할 권한정책에 그대로 넣으면 되잖아?”

안 된다. 그리고 이게 이 시리즈에서 제일 헷갈리는 지점이다.

1
2
GetSessionToken + MFA  →  봉인에 MFA 정보 있음  →  이후 매 요청마다 조건 검사 가능 ✓
AssumeRole      + MFA  →  봉인에 MFA 정보 없음  →  이후 요청에서 검사 불가          ✗

역할을 걸칠 때 MFA를 넣어도, 그 정보가 발급된 세션의 봉인에 안 실린다. 공식 문서가 못 박는다.

“The temporary credentials returned by AssumeRole do not include MFA information in the context, so you cannot check individual API operations for MFA.”

the MFA authentication is used only to determine whether a user can assume the role. Once the user has assumed the role, no further MFA checks are made.

MFA가 일회용 개찰구처럼 동작한다. 걸칠 자격이 있나 한 번 확인하고, 그 뒤 세션엔 흔적이 안 남는다.

그래서 권한정책에 넣으면 이렇게 된다.

1
2
3
4
5
6
역할 세션은 임시 자격증명 → aws:MultiFactorAuthPresent 키는 존재하는데 값이 false
   (MFA 넣고 걸쳤어도 false다. 봉인에 정보가 없으니까)

"Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } }
   → 조건이 항상 참 → Deny가 항상 발동
   → MFA로 제대로 걸친 사람까지 전부 막힌다

“안 걸리는” 게 아니라 아무도 못 쓰는 역할이 된다.

정답: 신뢰정책 — 입구에서 한 번

1
2
3
4
5
6
7
[AssumeRole 호출]      ← 여기서만 MFA 검사. 신뢰정책의 Condition이 판정.
       │
       ▼
[역할 세션 발급]        ← 통과했으면 임시 자격증명 획득
       │
       ▼
[이후 모든 API 호출]    ← MFA를 다시 안 묻는다. 만료까지 자유롭게.
1
2
3
4
5
6
7
// 역할의 신뢰정책  여기가 맞는 자리
{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::123456789012:root" },
  "Action": "sts:AssumeRole",
  "Condition": { "Bool": { "aws:MultiFactorAuthPresent": "true" } }
}

문지기를 입구에 세우는 것이지 방마다 세우는 게 아니다. 공식 문서가 MFA 조건을 붙일 수 있는 곳을 나열하는데, 역할의 권한정책은 목록에 없다.

Policies with MFA conditions can be attached to the following: · An IAM user or group · A resource such as an Amazon S3 bucket, SQS queue, or SNS topic · The trust policy of an IAM role that can be assumed by a user

그래서 세션 수명이 곧 MFA 유효기간이다

입구에서 한 번만 검사한다는 건, 뒤집으면 이런 뜻이다.

1
세션 수명 = MFA 없이 활동할 수 있는 시간

12시간짜리 세션을 발급하면 한 번의 MFA로 12시간을 산다. 그 자격증명이 유출되면 훔친 쪽은 MFA를 아예 마주치지 않는다 — 개찰구는 이미 지났으니까.

그래서 MFA를 신뢰정책으로 거는 역할은 수명을 짧게 잡는 것이 세트다.

1
2
역할의 MaxSessionDuration      기본 1시간, 최대 12시간   ← 이 상한을 낮게 유지
AssumeRole --duration-seconds  900초부터 위 상한까지

역할 쪽 MFA 강도 = 신뢰정책 조건 × 세션 수명, 두 개로 결정된다.

함정: CloudTrail은 true라고 찍는다

문서가 경고를 따로 단다.

“CloudTrail will also log mfaAuthenticated: true in the sessionContext for actions performed using the assumed role. However, CloudTrail logging is separate from what IAM requires when API calls are made with the assumed role’s credentials.”

로그만 보면 “MFA 잘 걸려 있네”로 보이는데 정책 조건은 그 값을 못 쓴다. “로그엔 true인데 왜 조건이 안 먹지?”의 정체가 이것이다.

한 장 정리

 GetSessionToken (User)AssumeRole (Role)
봉인에 MFA 정보있음없음
매 요청 조건 검사가능 ✓불가 ✗
MFA 조건 두는 곳사용자·그룹 정책신뢰정책 (입구 1회)
리소스 정책의 MFA 조건 통과가능불가
통제 수단조건 키조건 키 + 세션 수명

마지막 줄도 중요하다. S3 버킷 정책에 MFA 조건이 걸려 있으면 AssumeRole로 받은 자격증명으로는 못 뚫는다. 반드시 GetSessionToken 경로여야 한다.

“The temporary credentials that AssumeRole returns won’t work in this case… the temporary credentials returned by AssumeRole don’t include the MFA information.”

같은 조건 키인데 어느 경로로 들어왔느냐에 따라 검사 시점이 다르다. 그룹 Force-MFA가 매 요청 검사로 작동하는 건 User 경로라서고, 역할이 그렇게 안 되는 건 Role 경로라서다.

공식문서 · IAM roles — “A role does not have standard long-term credentials” · Secure API access with MFA — MFA 조건을 붙일 수 있는 위치 목록(신뢰정책 포함, 역할 권한정책은 없음) / “the MFA authentication is used only to determine whether a user can assume the role” / AssumeRole 자격증명이 MFA 정보를 담지 않는다는 명시 / CloudTrail mfaAuthenticated와 IAM 조건 평가가 별개라는 경고 · Choosing between GetSessionToken and AssumeRole — 두 경로의 차이를 AWS가 직접 비교한 절


권한 평가 엔진

실무에서 제일 자주 받는 말이 이거다.

“권한 줬는데 왜 안 돼요?”

이때 감으로 정책을 뒤지기 시작하면 시간이 무한히 든다. 대신 엔진의 알고리즘을 알고 역추적하면, 확인해야 할 지점이 서너 개로 좁혀진다.


입력: 요청은 “컨텍스트 구조체”로 정규화된다

평가가 시작되기 전, 엔진은 요청 하나를 이런 모양으로 만든다. (개념 모델)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RequestContext {
  principal : AIDA... / AROA...        ← 1부에서 인증으로 확정된 신원
  action    : "s3:GetObject"
  resource  : "arn:aws:s3:::mybucket/key"

  conditionKeys : {                    ← 여기가 조건 평가의 원재료
     aws:CurrentTime            : "2026-07-26T09:15:00Z"
     aws:SourceIp               : "203.0.113.9"
     aws:PrincipalArn           : "arn:aws:iam::123:user/hong"
     aws:userid                 : "AIDA..."
     aws:PrincipalTag/team      : "payments"
     aws:SecureTransport        : "true"
     aws:MultiFactorAuthPresent : "true"      ← 있을 수도, 없을 수도!
     aws:RequestedRegion        : "ap-northeast-2"
     ... (서비스별 키: s3:prefix, ec2:ResourceTag/... 등)
  }
}

여기서 결정적인 통찰 하나.

정책의 Condition이 딕셔너리를 조회할 뿐이다. 그냥 키로 값을 찾아 비교하는 것이다.

그리고 이 성질 때문에 세 가지가 따라온다.

1
2
3
4
5
· 어떤 키는 모든 요청에 항상 존재한다     (aws:CurrentTime, aws:SourceIp)
· 어떤 키는 특정 조건에서만 존재한다      (aws:MultiFactorAuthPresent는 임시 자격증명일 때만)
    → 장기 키에는 SessionToken이 없으니 이 키도 없다.
    → 그래서 "키가 아예 없을 때"를 다루는 ...IfExists 연산자가 필요해진다.
· 정책 변수 ${aws:username} 도 이 컨텍스트 값으로 런타임 치환된다

공식문서 · AWS global condition context keys — 전역 조건 키 목록과 “이 키가 언제 존재하는가” · IAM policy elements: variables${aws:username} 런타임 치환


정책을 5개의 “버킷”으로 나눈다

엔진은 관련 정책들을 한 덩어리로 보지 않는다. 성격별로 분류한다.

1
2
3
4
5
① 신원기반 정책    principal(user + group resolve + role)에 붙은 정책
② 리소스기반 정책  대상 리소스(S3 버킷 등)에 붙은 정책 — Principal 명시형
③ 권한 경계        그 principal의 상한선 (있으면)
④ SCP             조직 차원 상한선 (Organizations를 쓸 때)
⑤ 세션 정책        AssumeRole 시 전달된 세션 한정 정책 (4편)

왜 굳이 나누나? 버킷마다 “허용”의 규칙이 다르기 때문이다.

  • ①②는 “하나만 허용해도 되는” 관계일 수 있다 (같은 계정에서)
  • ③④⑤는 “반드시 통과해야 하는 정책”이다

세 줄로 줄이면:

1
2
3
1. 기본은 거부.
2. Deny가 하나라도 걸리면 → 즉시 거부. (최강)
3. Deny가 없으면 → 모든 천장을 통과하고 + 허용 근거가 있어야 → 허용.

“최종 권한 = 교집합”이라는 흔한 말의 정체가 바로 이 알고리즘이다. 천장 버킷들이 AND로 걸리니, 어느 하나라도 안 열려 있으면 통과 못 한다.

공식문서 · Policy evaluation logic — “By default, all requests are implicitly denied” / “An explicit deny in any policy overrides any allows”.


두 종류의 “거부”

알고리즘에 거부가 두 번 나온다. 이 둘은 대응이 완전히 다르다.

1
2
3
4
5
6
7
EXPLICIT_DENY  (명시적 거부)   ← 알고리즘 ②
   누군가 Deny를 써놨다.
   → 대응: 그 Deny 조항을 찾아야 한다. Allow를 아무리 추가해도 소용없다.

IMPLICIT_DENY  (묵시적 거부)   ← 알고리즘 ④의 else
   아무도 Allow를 안 했다.
   → 대응: 필요한 Allow를 추가하면 된다.

“AccessDenied 떴으니 정책 더 줘야지” 하고 Allow를 늘렸는데 그대로인 경우 — 거의 항상 앞쪽(EXPLICIT_DENY)이다.


신원정책 vs 리소스정책 — OR인가 AND인가

크로스계정 트러블슈팅

policy_cross-account-eval-simple

1
2
3
4
5
6
7
같은 계정 내부:
  신원정책 Allow  OR  리소스정책 Allow   → 한쪽만 허용해도 통과
  (예: 내 정책이 s3:GetObject를 허용하면, 버킷 정책이 침묵해도 내 계정 버킷은 접근 가능)

다른 계정 간 (cross-account):
  신원정책 Allow  AND  리소스정책 Allow  → 양쪽 다 명시적으로 허용해야 통과
  (내 계정: 내 정책이 그 버킷 접근 허용  +  상대 계정: 버킷 정책이 나를 허용)

AssumeRole의 양방향 신회(신뢰정책 + 신원정책)도 똑같은 원리의 한 사례다. “교차계정이면 양쪽 다”

공식문서 · Cross-account policy evaluation logic — “both the account… and the account that owns the resource must grant permission”


MFA 강제 정책

aws:MultiFactorAuthPresent는 세션의 속성이다

1
2
3
4
aws:MultiFactorAuthPresent 는

  "이 사용자가 MFA 기기를 등록했나?"           ←  아니다
  "이 요청을 보낸 세션이 MFA로 인증되었나?"     ← ✅ 이거다
1
2
3
4
5
6
7
8
9
10
11
12
[MFA로 콘솔 로그인]
  → 내부적으로 MFA 검증된 세션 발급 → SessionToken 봉인에 "mfa:true" 딱지        

[CLI: aws sts get-session-token --serial-number ... --token-code ...]
  → STS가 MFA 코드 검증 후, 발급하는 임시 자격증명 봉인에 "mfa:true"             

          ↓  그 세션으로 요청을 보내면

  → 서버가 x-amz-security-token의 봉인을 복호화 → mfa:true 확인                 
  → RequestContext.conditionKeys 에 aws:MultiFactorAuthPresent = "true" 주입    
  → 정책의 Condition이 그 값을 조회                                             
  → 판결                                                                       

그리고 키가 아예 “없는” 경우

1
2
3
4
장기 액세스 키(AKIA)로 직접 호출
  → SessionToken 자체가 없다               (2편: 장기 = 2조각)
  → "이 세션이 MFA인가"라는 정보의 그릇 자체가 없다
  → aws:MultiFactorAuthPresent 키가 컨텍스트에 아예 존재하지 않는다

즉 이 조건 키는 세 가지 상태를 가진다.

1
2
3
true   → MFA로 인증된 임시 세션
false  → MFA 없이 만들어진 임시 세션 (비번만으로 콘솔 로그인 등)
없음   → 장기 액세스 키 직접 호출

공식문서 · aws:MultiFactorAuthPresent — “This key is present only when the principal uses temporary credentials” / long-term credentials로 호출하면 키가 없다는 점을 명시


Bool vs BoolIfExists

“Condition은 conditionKeys를 조회할 뿐”이라고 했다. 그럼 조회했는데 키가 없으면 어떻게 될까?

두 연산자가 정반대로 행동한다. 정책이 "aws:MultiFactorAuthPresent": "false"Deny 조건으로 쓸 때:

세션 상태키 값Bool("false")BoolIfExists("false")
MFA로 로그인"true"조건 불성립 → Deny 안 함 ✓조건 불성립 → Deny 안 함 ✓
비번만 (MFA 없이)"false"조건 성립 → Deny조건 성립 → Deny
장기 키 직접 호출(없음)매칭 실패 → Deny 안 함 키 없으면 참 → Deny
  구멍! 장기 키가 통과한다└ 안전

결론:

Bool을 쓰면 장기 액세스 키 요청이 Deny를 빠져나간다. MFA 강제에 구멍이 뚫린다. BoolIfExists는 “키가 없으면 참으로 간주”해서 그 경로까지 막는다. 그래서 MFA 강제 정책은 반드시 BoolIfExists.

...IfExists의 정의 자체가 이거다 — “키가 있으면 지정한 대로 평가하고, 없으면 조건을 참으로 친다.”

공식문서 · IAM JSON policy elements: Condition operators — …IfExists — “if the key is not present, the condition returns true” · Configuring MFA-protected API access — 왜 BoolIfExists를 쓰는지에 대한 공식 설명

This post is licensed under CC BY 4.0 by the author.