- Published on
AWS IAM AccessDenied 스택추적과 정책 최소화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중 가장 시간을 잡아먹는 오류 중 하나가 AccessDenied 입니다. 특히 애플리케이션 로그에는 AccessDeniedException 한 줄만 남고, 실제로는 어떤 주체가 어떤 API를 어떤 리소스에 호출했는지, 그리고 어떤 정책의 어떤 조건에 걸렸는지가 보이지 않아 원인 파악이 늦어집니다.
이 글은 AccessDenied를 “감”으로 고치는 대신, 스택추적(호출 경로) + CloudTrail 이벤트 기반 역추적 + 정책 최소화로 마무리하는 실무 절차를 설명합니다. EKS, Lambda, EC2, GitHub Actions OIDC 등 어떤 실행 환경이든 동일한 방식으로 적용할 수 있습니다.
1) AccessDenied를 3분류로 쪼개면 빨라진다
AccessDenied는 대체로 아래 셋 중 하나입니다.
- 인증 주체(Principal) 착각: 내가 생각한 Role이 아니라 다른 Role, 다른 세션, 다른 계정이 호출함
- 권한(Allow) 부족: 필요한
Action또는Resource에 대한 Allow가 없음 - 명시적 거부(Explicit Deny) 또는 조건(Condition) 불일치: SCP, Permission Boundary, 세션 정책, 리소스 정책, KMS 키 정책,
aws:RequestedRegion,kms:ViaService같은 조건에 걸림
이 중 1번이 가장 흔합니다. “정책을 추가했는데도 안 된다”는 케이스는 대부분 실제 호출 주체가 다름에서 시작합니다.
2) 먼저 “누가 호출했는지”를 고정: STS Caller Identity
애플리케이션이 실행되는 위치에서 아래를 찍어두면, 이후 CloudTrail에서 찾을 때 탐색 범위가 급격히 줄어듭니다.
aws sts get-caller-identity
출력의 Account, Arn, UserId를 기록하세요. 특히 Arn이 다음 중 무엇인지가 중요합니다.
arn:aws:iam::123456789012:role/...이면 장기 Rolearn:aws:sts::123456789012:assumed-role/ROLE_NAME/SESSION_NAME이면 AssumeRole 세션
EKS IRSA, GitHub Actions OIDC, ECS Task Role은 대부분 assumed-role 형태로 나타납니다. 이때 SESSION_NAME이 Pod, Job, Workflow 런 ID 등으로 들어가므로 추적에 유리합니다.
관련해서 OIDC AssumeRole 실패 추적은 아래 글도 함께 보면 좋습니다.
3) CloudTrail로 “실제 거부 이벤트”를 찾는 가장 빠른 쿼리
3-1) Event history에서 바로 찾기
콘솔 CloudTrail의 Event history에서 필터를 다음처럼 잡습니다.
Event name: 실패한 API 예:Decrypt,GetObject,AssumeRole,CreateLogStreamError code:AccessDenied또는 서비스별 예:AccessDeniedException,UnauthorizedOperationUsername또는Resource name: 위에서 확인한assumed-role/ROLE_NAME/SESSION_NAME조각
하지만 대규모 환경에서는 CloudTrail Lake나 Athena가 더 빠릅니다.
3-2) CloudTrail Lake 예시 쿼리
아래는 CloudTrail Lake에서 최근 1시간 동안 AccessDenied만 찾는 예시입니다.
SELECT
eventTime,
eventSource,
eventName,
userIdentity.type,
userIdentity.arn,
errorCode,
errorMessage,
requestParameters,
resources
FROM
<YOUR_EVENT_DATA_STORE>
WHERE
eventTime > date_add('hour', -1, current_timestamp)
AND errorCode IS NOT NULL
AND (errorCode LIKE '%AccessDenied%' OR errorMessage LIKE '%AccessDenied%')
ORDER BY eventTime DESC
LIMIT 50;
주의: 본문에 < >가 그대로 나오면 MDX에서 JSX로 오인될 수 있으니, 위 쿼리에서 데이터 스토어 이름 같은 플레이스홀더는 인라인 코드 또는 실제 값으로 대체하세요.
3-3) 이벤트에서 꼭 봐야 할 필드
CloudTrail 이벤트 JSON을 열면 다음을 확인합니다.
userIdentity.arn: 실제 호출 주체eventSource+eventName: 어떤 서비스의 어떤 API인지resources: 대상 리소스 ARN이 실리는 경우가 많음requestParameters: 버킷명, 키, 키 ID, 테이블명 등 힌트errorMessage: “explicit deny” 같은 결정적 문구가 포함되기도 함
이 정보가 모이면, “정책을 더 준다”가 아니라 “정확히 무엇이 막혔는지”로 넘어갈 수 있습니다.
4) IAM Policy Simulator로 재현: Allow가 없나, Deny가 있나
CloudTrail에서 확인한 Action, Resource, Principal을 가지고 IAM Policy Simulator에서 테스트합니다.
Principal에 실제role/ROLE_NAME을 선택Action에 예:kms:Decrypt,s3:GetObjectResource에 CloudTrail에서 확인한 ARN을 입력
여기서 중요한 점은 리소스 정책과 키 정책까지 고려해야 한다는 것입니다.
- S3는 버킷 정책 + IAM 정책 조합
- KMS는 IAM 정책 + KMS 키 정책 조합
EKS에서 IRSA는 되는데 KMS만 막히는 케이스는 KMS 키 정책이 원인인 경우가 많습니다. 아래 글이 같은 결을 다룹니다.
5) “정책 최소화”의 핵심: 먼저 넓게, 로그로 좁게
실무에서 최소 권한은 보통 2단계로 합니다.
- 임시로 넓게 허용: 빠른 복구를 위해 필요한 액션을 넓게 열되, 기간을 짧게
- CloudTrail 기반으로 좁히기: 실제 호출된 액션과 리소스만 남기고 나머지 제거
이때 “넓게”도 무제한 Action: *는 피하고, 최소한 서비스 단위로 제한합니다.
5-1) 임시 정책 예시(서비스 단위)
예를 들어 앱이 S3 읽기와 KMS 복호화를 해야 한다면 임시로 아래처럼 시작할 수 있습니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TempS3Read",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
},
{
"Sid": "TempKmsDecrypt",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "arn:aws:kms:ap-northeast-2:123456789012:key/abcd-efgh-..."
}
]
}
여기서도 Resource를 *로 두지 않고, 최소한 버킷과 키 ARN으로 제한합니다.
5-2) CloudTrail로 “실제 사용된 액션”만 추출
운영 중 1~3일 정도 CloudTrail을 보고, 해당 Role이 호출한 이벤트만 모읍니다.
userIdentity.arn에assumed-role/ROLE_NAME/포함eventName을 집계해서 실제 사용 API 목록 만들기
CloudTrail Lake 집계 예시는 다음과 같습니다.
SELECT
eventSource,
eventName,
count(*) AS cnt
FROM
<YOUR_EVENT_DATA_STORE>
WHERE
eventTime > date_add('day', -1, current_timestamp)
AND userIdentity.arn LIKE '%assumed-role/ROLE_NAME/%'
AND errorCode IS NULL
GROUP BY eventSource, eventName
ORDER BY cnt DESC;
이 결과를 기반으로 정책의 Action을 실제 호출된 것만 남깁니다.
6) 최소 권한에서 자주 놓치는 7가지 함정
6-1) List 계열 권한 누락
S3, DynamoDB, ECR 등은 실제 작업 전에 List 또는 Describe를 호출하는 SDK가 많습니다. 예:
s3:ListBucket없이GetObject만 주면, 일부 라이브러리에서 사전 확인 단계에서 실패ecr:GetAuthorizationToken누락으로 이미지 풀 실패
EKS에서 이미지 풀 실패는 IAM과 얽히기도 하니, 증상이 비슷하면 아래 글도 참고하세요.
6-2) 리소스 ARN 스코프가 잘못됨
예를 들어 S3는 ListBucket은 버킷 ARN, GetObject는 오브젝트 ARN이 필요합니다.
- 버킷:
arn:aws:s3:::my-bucket - 오브젝트:
arn:aws:s3:::my-bucket/*
6-3) KMS는 “키 정책”이 최종 관문
IAM 정책에 kms:Decrypt를 줘도, 키 정책에서 Principal을 허용하지 않으면 거부됩니다.
또한 조건으로 kms:ViaService가 걸려 있으면, 예를 들어 S3를 통해서만 KMS를 사용하도록 제한되어 직접 Decrypt 호출이 막힐 수 있습니다.
6-4) SCP(Organizations)와 Permission Boundary
계정 전체에 걸린 SCP가 Deny를 갖고 있으면, 어떤 IAM Allow도 이길 수 없습니다. Permission Boundary도 마찬가지로 상한선입니다.
6-5) 세션 정책(Session Policy) 또는 AssumeRole 조건
AssumeRole 시 세션 정책을 붙이거나, Trust Policy에 Condition이 있으면 특정 액션이 잘립니다.
sts:AssumeRole은 되는데 이후 서비스 호출이 막히면 세션 정책을 의심- OIDC는
sub,aud조건 불일치가 흔함
6-6) 리전 조건(aws:RequestedRegion) 불일치
정책에서 리전을 제한해두고, SDK 기본 리전이 다른 곳으로 잡히면 AccessDenied가 납니다. CloudTrail의 awsRegion을 확인하면 바로 드러납니다.
6-7) “같은 이름 다른 리소스” 문제
my-bucket-prod와 my-bucket-production처럼 유사한 리소스가 있으면, 개발 환경에서는 되는데 운영에서만 실패합니다. CloudTrail resources와 requestParameters로 실제 대상이 무엇인지 확정하세요.
7) 디버깅을 코드로 고정: 요청 ID와 예외 메시지 구조화
애플리케이션 쪽에서도 최소한 아래는 로깅해두면 추적이 빨라집니다.
- AWS SDK 예외의
requestId service,operation(API 이름)- 대상 리소스 식별자(버킷, 키, 테이블 등)
7-1) Node.js AWS SDK v3 예시
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function download(bucket: string, key: string) {
try {
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
return res;
} catch (err: any) {
const meta = err?.$metadata;
console.error("aws_error", {
name: err?.name,
message: err?.message,
httpStatusCode: meta?.httpStatusCode,
requestId: meta?.requestId,
extendedRequestId: meta?.extendedRequestId,
cfId: meta?.cfId,
bucket,
key
});
throw err;
}
}
이렇게 남긴 requestId는 AWS Support나 내부 분석 시에도 유용하고, CloudTrail의 시간대와 맞춰보면 원인 이벤트를 더 빨리 찾을 수 있습니다.
8) 최종 체크리스트: AccessDenied를 “재발 방지”로 끝내기
- 실행 환경에서
aws sts get-caller-identity로 Principal 확정 - CloudTrail에서 실패 이벤트를 찾아
eventName,resources,requestParameters확보 - IAM Policy Simulator로 Allow 누락인지, Explicit Deny인지 분기
- 임시 정책으로 빠르게 복구하되
Resource는 가능한 한 좁게 - 1~3일 CloudTrail 집계로 실제 사용 API만 남겨 정책 최소화
- KMS 키 정책, SCP, Permission Boundary, Trust Policy 조건까지 함께 점검
- 앱 로그에
requestId포함해 다음 장애에서 탐색 비용을 줄이기
AccessDenied는 권한을 더 주면 언젠가 해결되지만, 그 방식은 보안 부채를 만들고 다음 장애의 원인도 됩니다. CloudTrail과 시뮬레이터를 중심으로 “누가, 무엇을, 왜 못 했는지”를 고정하면, 해결 속도와 정책 품질이 동시에 올라갑니다.