0. Contents
1. Overview
① 고객은 스택만 만들고 키를 넘기지 않는다 ② ExternalId 를 붙여 역할을 잠깐 빌린다 ③ 그 역할로 EKS 에 자기 이름표를 붙인다 ④⑤ 매 명령마다 새 토큰
Kukie 의 역할과 기존 연결 방식
- 팀이 고른 쿠버네티스 클러스터에 kubectl 을 대신 실행해 준다
- 지금까지 클러스터를 맡기는 방법은 kubeconfig 파일 하나였다
- 파일 안에 그 클러스터의 자격증명이 들어 있다
- Kukie 는 그것을 암호화해 보관했다
이번 편: AWS 계정 연결
- 고객은 AWS 콘솔에서 스택을 한 번 만든다
- Kukie 는 그 스택이 만든 역할을 필요할 때만 잠깐 빌린다
설계 원칙 세 가지
- 영구 키를 받지 않는다 — Kukie 가 저장하는 것은 계정 번호, ExternalId, 리전, 클러스터 이름뿐이다
- 할 수 있는 일은 고객 역할이 정한다 — 그 역할의 권한은 공개된 템플릿 한 장에 적혀 있다
- 모든 접근은 짧게 산다 — 역할 세션도, kubectl 토큰도 15분이다
장별 내용
- 2장: 고객 계정에 남는 두 조각
- 3장: 남의 계정에 들어가는 신뢰
- 4·5장: 역할의 권한과 리뷰가 찾은 우회
- 6장: 토큰의 수명
- 7장: 등록 전 주소 검사
- 고객이 계정 번호를 적지 않아도 되게 만든 스택 알림은 다음 편(08편)에서 다룬다
2. Two Pieces — 출입증과 이름표
출입증(①)이 없으면 계정에 못 들어오고, 이름표(②)가 없으면 그 클러스터 안에서 아무것도 못 한다
연결 후 고객 계정에 남는 두 가지
둘 다 있어야 Kukie 가 일할 수 있다 — 출입증만 있으면 계정에는 들어오지만 클러스터 안에서 아무것도 못 하고, 이름표만 남으면 쓸 사람이 없다
접근 항목(access entry)
- EKS 가 2023년 말에 내놓은 방식이다
aws-authConfigMap 대신 EKS API 로 “누가 이 클러스터에 들어오나” 를 관리한다- 항목에 접근 정책(access policy)을 붙여 권한을 정한다
- Kukie 는 AWS 관리형
AmazonEKSEditPolicy를 네임스페이스 범위로만 붙인다
- Kukie 는 AWS 관리형
- 클러스터 인증 모드가 ConfigMap 전용이면 항목을 만들 수 없다 → 등록 단계에서 거절한다
출입증과 이름표의 차이
- 한 계정의 클러스터 둘을 연결하면
- 출입증: 하나를 같이 쓴다
- 이름표: 둘이다
- Kukie 에서 클러스터 하나를 지울 때
- 출입증은 건드리지 않는다
- 그 클러스터의 이름표만 뗀다
- 같은 EKS 를 네임스페이스만 달리해 두 번 연결했다면
- 이름표가 한 장이다
- 떼는 대신 지운 네임스페이스만 범위에서 뺀다
3. Trust — ExternalId 와 혼동된 대리인
1111 · extA · extB 는 설명용 값. 서버 역할을 빼앗긴 경우에도 KukieAccess 범위 안의 일은 남는다 (8장)
문제: 혼동된 대리인(confused deputy)
- 여러 고객을 대신해 일하는 서비스는 모든 고객의 역할을 맡을 권한이 있다
- 그래서 팀 B 가 팀 A 의 계정 번호를 대면, 서비스가 A 의 역할을 대신 맡아 버릴 수 있다
- AWS 가 권하는 해법: 서드파티 역할의 신뢰 정책에 서비스 주체 + ExternalId 조건을 함께 건다
Kukie 의 ExternalId
- 팀마다 하나 만든다
- 형식:
kukie-+ 무작위 16자리 hex - 서버가 만들어 보여 줄 뿐, 팀이 고를 수 없다
- 고객 템플릿은 이 값을 역할 이름과 신뢰 조건에 함께 쓴다
RoleName:
Fn::Sub: "KukieAccess-${ExternalId}"
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
AWS: "arn:aws:iam::<Kukie 계정>:role/kukie-dev-member"
Action: "sts:AssumeRole"
Condition:
StringEquals:
"sts:ExternalId":
Ref: ExternalId
차단 원리
- 서버는 역할을 맡을 때 요청한 팀의 ExternalId 로만 역할 이름과
ExternalId값을 만든다 - 팀 B 가 A 의 계정 번호를 넣어도
- 서버가 찾는 것은 그 계정의
KukieAccess-<B 의 값>이다 - A 는 그런 역할을 만든 적이 없다 → 실패
- 서버가 찾는 것은 그 계정의
- ExternalId 가 역할 이름에 그대로 들어가 비밀이 아니어도 괜찮다
- 막는 것은 “누가 맡느냐(신뢰 주체)” + “어느 팀의 값을 붙이느냐(서버)” 의 조합이다
- 외부 공격자는 값을 알아도 주체가 될 수 없다
Kukie 쪽 권한 경계 — 배포 역할들이 공유하는 권한 경계에 문장 하나를 더했다
{
Sid = "AssumeCustomerAccess", Effect = "Allow", Action = ["sts:AssumeRole"]
Resource = ["arn:aws:iam::*:role/KukieAccess-*"]
Condition = {
ArnEquals = { "aws:PrincipalArn" = "arn:aws:iam::<Kukie 계정>:role/kukie-dev-member" }
Null = { "sts:ExternalId" = "false" }
}
}
- 대상은
KukieAccess-*이름의 역할뿐이다 ExternalId를 빼고 부르면 거부된다- 서버 역할을 빼앗겨도 고객 계정의 다른 역할로 건너갈 수 없다(그림의 넷째 줄)
- 한계: 고객 역할의 신뢰 정책은 Kukie 의 권한 경계로 줄일 수 없다
- 남의 계정에 들어가는 실제 문은 고객 역할의 조건이다
- 우리 쪽 경계는 그 문 앞까지 가는 길을 좁힐 뿐이다
4. Template Policy — 역할이 할 수 있는 일
허용 넷(초록)은 모두 '역할 자기 자신'과 '고른 네임스페이스'로 묶여 있다. 문장 이름은 템플릿 kukie-access-v1.yaml 의 Sid
템플릿 보관 위치와 규칙
- 공개 S3 버킷에 둔다 — CloudFormation 빠른 생성 링크가 S3 의 템플릿만 읽기 때문이다
- 템플릿이 고객 계정 안에서 Kukie 가 할 수 있는 일을 정한다 → 한 번 게시한 버전은 고치지 않는다
- 버전마다 내용 md5 를 코드에 고정한다
- 파일이 바뀌면 plan 이 “새 버전으로 추가하라” 며 실패한다
역할의 권한
- EKS 에만 있다
- 쓰기는 전부 “역할 자기 자신” 으로 묶었다
- Sid: AddSelfOnly
Effect: Allow
Action: "eks:CreateAccessEntry"
Resource: "*"
Condition:
StringEquals:
"eks:principalArn":
Fn::Sub: "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/KukieAccess-${ExternalId}"
"eks:accessEntryType": "STANDARD"
"Null":
"eks:kubernetesGroups": "true"
"eks:username": "true"
- Sid: EditAllowedNamespacesOnly
Effect: Allow
Action: "eks:AssociateAccessPolicy"
Resource: "*"
Condition:
StringEquals:
"eks:principalArn": # 위와 같은 자기 자신
"eks:policyArn": "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy"
"eks:accessScope": "namespace"
"ForAllValues:StringLike":
"eks:namespaces":
Ref: AllowedNamespaces
"Null":
"eks:namespaces": "false"
조건별 차단 대상
- 그룹·사용자 이름 금지
- 그룹을 붙일 수 있으면
system:masters처럼 클러스터에 이미 있는 관리자 바인딩에 올라탈 수 있다 - 그룹과 사용자 이름 키가 아예 없어야(
Null = true) 만들 수 있다
- 그룹을 붙일 수 있으면
- 네임스페이스 키 필수
ForAllValues는 키가 비어 있으면 참이 된다- 그래서
Null = false를 함께 건다(06편 5.2 와 같은 함정)
kube-*는 항상 거부AllowedNamespaces를 무엇으로 고르든 시스템 네임스페이스는 별도 Deny 문장으로 막는다
리뷰는 마지막 줄이 “항상” 이 아니라는 것을 찾아냈다 → 5장
5. Namespace Pattern — k* 가 연 kube-system
IAM 조건은 값을 문자열로만 본다. 그 값을 받는 서비스가 패턴으로 해석하면, 정책은 글자 * 자체를 따로 막아야 한다
AllowedNamespaces 형식
AllowedNamespaces형식: “이름, 또는 끝에만*를 붙인 접두사” (^[a-z0-9][-a-z0-9]*\*?$)- 고객이
k*를 고르는 것은 형식상 문제가 없다 - 그 상태에서
eks:namespaces = ["k*"]로 Edit 를 연결하면 이렇게 된다
- 허용 문장: 요청 값
"k*"를 정책 값k*와StringLike로 비교한다. 패턴은 정책 쪽에만 있고, 요청 값"k*"는 그냥 글자 두 개다.k로 시작하니 맞는다 - 거부 문장: 요청 값
"k*"를kube-*와 비교한다. 글자k*는kube-로 시작하지 않으니 맞지 않는다. 거부가 적용되지 않는다 - EKS: 네임스페이스 범위에
dev-*같은 와일드카드를 그대로 받는다.k*를 패턴으로 풀어kube-system까지 범위에 넣는다
원인: 문자열 비교와 패턴 해석의 차이
- IAM 조건은 요청 값을 문자열로만 본다
- 그 값을 받는 서비스(EKS)는 패턴으로 해석한다
- 그래서 정책이 막았다고 믿은 범위와 실제 범위가 갈라진다
수정: 글자 *·? 거부 — 값 안에 글자 그대로의 * 나 ? 가 있으면 무조건 거부한다
- Sid: NoNamespacePatterns
Effect: Deny
Action: "eks:AssociateAccessPolicy"
Resource: "*"
Condition:
"ForAnyValue:StringLike":
"eks:namespaces":
- "*${*}*"
- "*${?}*"
- IAM 정책 값에서
*와?는 와일드카드다- 글자 자체를 찾으려면 정책 변수 문법
${*},${?}를 쓴다
- 글자 자체를 찾으려면 정책 변수 문법
- 결과
- 접두사 패턴은 스택의 허용 범위(
AllowedNamespaces)에만 남는다 - 실제 연결은 정확한 네임스페이스 이름으로만 이뤄진다
- 접두사 패턴은 스택의 허용 범위(
- 게시 전에 IAM 평가기(
simulate-custom-policy)로 확인했다
요청 eks:namespaces | 결과 | 근거 |
|---|---|---|
kukie-demo | 허용 | 허용 범위 안, 정확한 이름 |
kukie-* | 명시적 거부 | NoNamespacePatterns |
k* | 명시적 거부 | NoNamespacePatterns |
kukie-? | 명시적 거부 | NoNamespacePatterns |
kube-system | 명시적 거부 | NoSystemNamespaces |
default | 암묵적 거부 | 허용 범위 밖 |
서버의 이중 확인
- 등록 요청의 네임스페이스가 DNS 라벨 형식이 아니거나
kube-로 시작하면 AWS 를 부르기 전에 거절한다 - 템플릿은 게시하면 고칠 수 없다 → 이 문장은 v1 을 내기 전 리뷰에서 들어갔다
6. Short-lived Access — 15분 세션과 토큰
세션은 서버 메모리에만, 토큰은 에이전트가 명령 하나에 쓰고 버린다
명령마다 새 접속 정보
- 고객 역할을
AssumeRole로 빌린다. 세션 길이는 900초,AssumeRole이 허용하는 최솟값이다 - 빌린 세션은 메모리에 두고, 만료까지 5분 넘게 남았으면 다시 쓴다. 토큰을 받은 직후 세션이 끝나 명령이 끊기지 않게 하는 여유다
- 그 세션으로 EKS 토큰을 서명한다.
aws eks get-token과 같은 방식으로, STSGetCallerIdentity요청을 미리 서명한 URL 에x-k8s-aws-id: <클러스터 이름>헤더를 묶어 base64url 로 감싸고k8s-aws-v1.을 붙인다. EKS 는 이 토큰을 15분 동안 받아 준다 - 에이전트는 토큰을 명령 하나에 쓰고 버린다
저장 범위
- DB 에는 계정 번호, ExternalId, 리전, 클러스터 이름만 암호화해 둔다
- 키도 토큰도 저장하지 않는다
연결 해제: 스택 삭제
- 고객이 스택을 지우면 역할이 사라진다
- 다음
AssumeRole부터 실패하고, Kukie 에서는AWS_ROLE_NOT_ASSUMABLE로 보인다
한계: 15분 넘는 명령
- 오래 걸리는 명령(
logs -f,watch)은 15분을 넘기면 끊길 수 있다 - 지금 Kukie 의 명령은 대부분 몇 초 안에 끝난다
- 길게 지켜보는 기능을 넣을 때는 토큰을 중간에 갈아 끼워야 한다
7. Private Address — 등록 전 주소 검사
같은 EKS 주소도 묻는 곳에 따라 답이 다르다 — 서버와 같은 VPC 의 클러스터만 내부 IP 로 보인다
위험: 사용자 입력 주소(SSRF)
- kubeconfig 로 등록할 때 클러스터 주소는 사용자가 적는다
- 그대로 믿으면, 누군가
10.42.x.x처럼 Kukie 내부망 주소를 클러스터라며 등록할 수 있다 - 그러면 에이전트가 내부망으로 요청을 보내게 된다
등록 전 주소 검사
- 서버가 등록 전에 이름을 직접 풀어 본다
- 아래로 풀리면 거절한다
- 사설 대역(10/8, 172.16/12, 192.168/16)
- 루프백
- 링크로컬(인스턴스 메타데이터가 있는 169.254/16)
- 문서·예약 대역
- 로컬 개발 때만
KUKIE_ALLOW_LOCAL_CLUSTER로 끈다
AWS 연결에도 같은 검사
- 주소가
DescribeCluster응답에서 와도 이름을 풀어 본다 - 실제로 걸린 때: 우리 계정을 고객처럼 시험하며 후보 목록에서
kukie-dev를 골랐을 때- “사설 주소로 풀리는 이름은 등록할 수 없습니다 … → 10.42.20.48”
kukie-dev 만 걸린 이유
- EKS 엔드포인트 이름은 하나지만, 사설 접근이 켜진 클러스터는
- 같은 VPC 안에서 물으면 내부 IP
- 밖에서 물으면 공인 IP 로 답한다
- Kukie 서버는
kukie-devVPC 안에서 돈다 →kukie-dev이름만 내부 IP 로 보였다 - 다른 VPC 의
kukie-hosting, 다른 계정의 고객 클러스터는 공인 IP 로 풀려 통과한다
예외를 두지 않은 이유
- “AWS 가 준 EKS 주소면 내부 IP 라도 믿는다” 는 예외를 둘 수도 있었다
- 하지만 걸리는 것은 Kukie 가 사는 클러스터 하나뿐이고, 그 클러스터는 애초에 고객용 연결 대상이 아니다
8. Discussion
남은 위험
- 서버 역할과 DB 를 함께 잃는 경우
- DB 의 ExternalId + 서버 역할이 있으면, 연결된 모든 고객 클러스터에서 그 고객이 고른 네임스페이스의 Edit 권한 안의 일을 할 수 있다
- 한 고객의 경계 밖으로는 못 나가지만, 고객 수만큼 범위가 넓다
- 서버 역할을 맡을 수 있는 것은
kukie-dev네임스페이스의 ServiceAccount 하나뿐이다(IRSA)
- 같은 계정을 고객으로 쓸 때
- 기본 허용 범위
kukie-*가 Kukie 자신의 네임스페이스kukie-dev와 겹친다 - 시험용 스택은
AllowedNamespaces = kukie-demo로 좁혔다 - 실제 고객 계정에는 Kukie 서비스가 없으므로 해당하지 않는다
- 기본 허용 범위
- 템플릿은 v1 그대로
- 한 번 게시한 템플릿은 고치지 않는다
- 권한을 바꾸려면 새 버전을 내고 고객이 스택을 업데이트해야 한다
핵심 메시지
- 남의 계정에 들어가는 문은 그쪽 역할의 신뢰 조건이다
- 우리 쪽에서는 “어느 팀의 ExternalId 를 붙이느냐” 를 서버가 정하게 해 대리인 혼동을 막는다
- IAM 은 문자열로, 받는 서비스는 패턴으로 — 그 사이에 틈이 생긴다
- 글자
*자체를 막아 정확한 값만 통과시킨다
- 글자
- 접근은 짧고 좁게
- 영구 키 대신 15분 세션과 15분 토큰
- 클러스터마다 붙였다 떼는 이름표
9. References
여러 고객을 대신하는 서비스가 고객 역할을 맡을 때 생기는 혼동된 대리인 문제와, 신뢰 정책의 ExternalId 조건으로 막는 방법. 3장의 근거다.
서드파티에 계정 접근을 줄 때 ExternalId 를 서비스가 만들어 고객별로 다르게 쓰라는 권고.
EKS 접근 항목과 인증 모드(API, API_AND_CONFIG_MAP, CONFIG_MAP). 2장의 이름표가 이것이다.
접근 정책과 범위. 네임스페이스 범위가 와일드카드 패턴을 받는다는 점이 5장 우회의 출발점이다.
정책 값에서 글자 그대로의
* · ? · $ 를 쓰는 특수 문자 ${*} · ${?} · ${$}. 5장의 NoNamespacePatterns 가 이것을 쓴다.ForAllValues · ForAnyValue 와 키가 없을 때의 동작. 4장의 Null 조건을 함께 거는 이유다.
EKS 토큰 형식(미리 서명한 STS GetCallerIdentity, x-k8s-aws-id 헤더, k8s-aws-v1 접두사). 6장의 토큰 서명이 이 방식을 따른다.
AssumeRole 의 DurationSeconds 범위(최소 900초).
