3편 끝에서 한 가지를 짚어뒀다. 지금까지 우리가 만든 건 STS 하나였지만, 진짜 AWS에서 IAM과 STS는 애초에 다른 서비스라는 것. 엔드포인트부터 다르다. IAM은 iam.amazonaws.com 하나로 글로벌이고, STS는 sts.ap-northeast-2.amazonaws.com처럼 리전마다 주소가 갈린다. 자격증명을 발급하는 일과 자격증명을 검증하는 일이 서로 다른 곳에서 벌어진다는 뜻이다.
이번 편은 그 경계를 mincloud 안에 실제로 긋는다. 지금까지 STS 핸들러 하나가 하던 일을, IAM과 STS 두 개의 독립 서비스로 쪼갠다. 그리고 그 과정에서, 무심코 지나치면 인증이 통째로 뚫리는 함정 하나를 만난다.
왜 나누나
역할이 다르기 때문이다.
IAM은 컨트롤플레인이다. 사용자를 만들고, 정책을 붙이고, 액세스키를 발급한다. “누가 이 계정에서 무엇을 할 수 있는가"를 정의하는 쪽이다. 계정 전체에 하나뿐인 진실이라, 진짜 AWS에서도 리전을 나누지 않고 글로벌 엔드포인트 하나로 굴린다.
STS는 그 자격증명을 검증하고 신원을 돌려주는 쪽이다. GetCallerIdentity가 그 예다. 요청이 실제로 리소스를 때리는 데이터플레인 가까이에 있어야 하니, 리전별로 엔드포인트가 흩어져 있다.
1편에서 봤던 SigV4 Credential 범위를 다시 떠올려 보자. .../ap-northeast-2/sts/aws4_request — 날짜, 리전, 그리고 서비스 이름이 서명 범위에 박혀 있었다. AWS가 서비스를 이렇게 나눠 뒀기 때문에, 서명 자체가 “이 요청은 sts용이다"라고 자기 자신을 못 박는다. 우리가 이 구조를 흉내 내려면, 서버 쪽도 서비스별로 갈라야 앞뒤가 맞는다.
한 프로세스, 두 리스너
경계를 긋는다고 프로세스까지 둘로 쪼갤 필요는 없다. mincloud는 여전히 바이너리 하나다. 대신 서비스마다 독립된 http.Handler를 두고, 서로 다른 포트에서 듣게 했다.
| |
sts.Handler와 iam.Handler는 각자 자기 패키지에 있고, 자기가 아는 Action만 처리한다. STS는 GetCallerIdentity, IAM은 CreateAccessKey. 모르는 액션엔 InvalidAction을 돌려준다.
포인트는 두 핸들러가 같은 store를 공유한다는 것이다. IAM이 발급한 키를 STS가 검증하려면, 둘이 같은 자격증명 저장소를 봐야 한다. 지금은 프로세스가 하나라 그냥 인메모리 맵 하나를 두 goroutine이 나눠 쓰면 된다 — 그래서 credstore.Store는 sync.RWMutex로 동시 접근에 안전하게 만들어 뒀다.
인증 로직은 공통이라 service.Authenticate 한 곳에 모았다. 두 핸들러가 똑같이 이걸 부른다.
| |
마지막 인자 serviceName. iam 패키지는 "iam"을, sts 패키지는 "sts"를 넘긴다. 별것 아닌 문자열 같지만, 이 인자가 이번 편의 핵심이다.
함정: 서명은 스스로를 검증하지 않는다
Authenticate 안을 보면, 서명을 실제로 다시 계산해 맞춰보는 부분은 이렇다.
| |
Verify는 클라이언트가 했을 서명 계산을 그대로 재현해서, 결과가 요청에 실린 서명과 같은지 본다. 그런데 그 계산에 들어가는 서명 키(signing key)가 어떻게 만들어지는지 보자.
| |
auth.Service — 이건 우리가 아는 서비스 이름이 아니다. 요청의 Authorization 헤더에서 파싱해 온, 클라이언트가 적어 넣은 값이다. 서명 키가 이 값으로 파생된다는 건, 클라이언트가 서명할 때 쓴 서비스 이름과 서버가 검증할 때 쓰는 서비스 이름이 항상 같은 출처라는 뜻이다. 둘 다 헤더에서 온다.
그래서 이런 일이 벌어진다. 어떤 클라이언트가 서비스를 iam으로 서명해 요청을 만든다. 이 요청을 STS 핸들러에 던진다. STS가 Verify를 부르면, 헤더에 적힌 iam으로 키를 파생해 서명을 다시 계산한다. 클라이언트도 iam으로 서명했으니 — 당연히 맞는다. 서명은 자기가 어느 서비스로 갔어야 하는지 모른다. 그저 헤더가 시키는 대로 자기 자신과 아귀만 맞을 뿐이다.
즉 Verify 하나만으로는, iam으로 서명한 요청이 STS 문으로 들어와도 통과해 버린다. 서명 검증은 “이 서명이 이 헤더 내용과 일치하는가"만 볼 뿐, “이 요청이 이 서비스에 와도 되는가"는 보지 않는다.
막는 방법은 단순하다. 헤더가 주장하는 서비스가, 지금 이 요청을 받은 서비스와 실제로 같은지 명시적으로 확인하면 된다.
| |
serviceName은 클라이언트가 아니라 핸들러 자신이 아는 값이다. STS 핸들러는 "sts"를 넘겼으니, 헤더의 auth.Service가 iam이면 여기서 걸린다. 이게 진짜 AWS가 서비스를 엔드포인트로 갈라 두는 것의 서버 쪽 대응이다.
실제로 확인해 보자. aws iam 명령은 서비스 iam으로 서명하는데, 이걸 STS 포트(:19900)로 보내면:
$ aws iam create-access-key --user-name friend01 \
--endpoint-url http://localhost:19900
An error occurred (SignatureDoesNotMatch) when calling the
CreateAccessKey operation: Credential should be scoped to correct
service: 'sts'.
반대로 aws sts 명령을 IAM 포트(:19910)로 보내면:
$ aws sts get-caller-identity --endpoint-url http://localhost:19910
An error occurred (SignatureDoesNotMatch) when calling the
GetCallerIdentity operation: Credential should be scoped to correct
service: 'iam'.
서명은 멀쩡한데 서비스 범위가 안 맞아 거부된다. 이 한 줄짜리 체크가 없었다면, 두 서비스를 나눈 게 이름뿐이고 문은 사실상 하나였을 것이다.
키의 일생: CreateAccessKey
이제 IAM이 실제로 하는 일, 액세스키 발급을 보자. CreateAccessKey는 UserName을 받는데, 생략하면 호출자 본인에게 키를 발급한다. 실제 AWS와 같은 동작이다.
| |
발급 규칙은 진짜 AWS의 겉모습을 따랐다. 액세스키 ID는 AKIA 접두사에 대문자·숫자 16글자를 붙여 만들고, 시크릿은 40글자짜리 base64다. 그리고 발급 즉시 store.Put으로 저장한다. 여기가 핵심이다 — 방금 만든 키가 credstore에 들어감으로써, 곧바로 검증 가능한 자격증명이 된다.
그 “곧바로"를 눈으로 보자. 기본 개발 자격증명(jeff)으로 friend01의 키를 발급받는다. IAM 포트로 간다.
$ aws iam create-access-key --user-name friend01 \
--endpoint-url http://localhost:19910
{
"AccessKey": {
"UserName": "friend01",
"AccessKeyId": "AKIASUCX2E4BPLARBYEZ",
"Status": "Active",
"SecretAccessKey": "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",
"CreateDate": "2026-07-05T13:54:19+00:00"
}
}
(여기 나온 키·시크릿은 로컬 테스트로 방금 생성된 가짜 값이다. 어디에도 통하지 않는다.)
이제 방금 받은 그 키를 들고, 이번엔 STS 포트로 가서 “나는 누구냐"고 묻는다.
$ AWS_ACCESS_KEY_ID=AKIASUCX2E4BPLARBYEZ \
AWS_SECRET_ACCESS_KEY=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 \
aws sts get-caller-identity --endpoint-url http://localhost:19900
{
"UserId": "AIDACNDV6C5Z2A561BOG",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/friend01"
}
키의 일생이 두 엔드포인트를 넘나든다. IAM(:19910)에서 발급돼 credstore에 저장되고, STS(:19900)에서 검증돼 friend01이라는 신원으로 되돌아온다. 서버 로그가 그 왕복을 그대로 찍는다.
iam CreateAccessKey by arn:aws:iam::123456789012:user/jeff
sts GetCallerIdentity by arn:aws:iam::123456789012:user/friend01
jeff가 friend01의 키를 발급했고, 그 키가 STS에서 friend01로 인증됐다. 두 서비스가 하나의 저장소를 매개로 대화한 것이다.
지금은 “글로벌"이 공짜다
진짜 AWS에서 IAM이 글로벌이라는 건, 서울에서 만든 사용자가 버지니아에서도 즉시 보인다는 뜻이고, 그 뒤에는 리전 간 복제라는 만만찮은 인프라가 있다. mincloud는 그 문제를 아직 우아하게 회피하고 있을 뿐이다. 프로세스가 하나고 credstore가 인메모리 맵 하나니, IAM이 Put한 키를 STS가 즉시 Lookup하는 게 당연하다. 복제도, 지연도, 불일치도 없다 — 나눌 데이터가 애초에 한 벌뿐이라서.
이 공짜는 인스턴스를 여러 개로 늘리는 순간 끝난다. IAM 인스턴스 A가 발급한 키를, STS 인스턴스 B가 자기 메모리에서 못 찾는 상황이 생긴다. 그때부터 credstore를 프로세스 밖으로 빼고(공유 저장소), 복제와 eventual consistency를 마주해야 한다. “방금 만든 키가 잠깐 인식이 안 되는” 그 지연이, 사실 진짜 AWS에서도 IAM 변경이 전파되는 데 몇 초씩 걸리는 이유다. 다음 편의 주제로 남겨 둔다.
정직하게 덧붙이면, 지금 IAM은 CreateAccessKey 하나뿐이다. CreateUser도, 사용자 디렉토리도 없다. friend01은 키를 발급하는 순간 즉석에서 만들어진 신원이지, 어딘가 등록된 사용자가 아니다. “사용자를 먼저 만들고, 그 사용자에게 키를 발급한다"는 IAM 본연의 순서는 아직 반쪽이다. 컨트롤플레인이라고 부르기엔 이르지만, 적어도 발급과 검증이 서로 다른 문을 통해 이뤄지는 골격은 이제 섰다.