Published on

AWS IAM AccessDenied 스택추적과 정책 최소화

Authors

운영 중 가장 시간을 잡아먹는 오류 중 하나가 AccessDenied 입니다. 특히 애플리케이션 로그에는 AccessDeniedException 한 줄만 남고, 실제로는 어떤 주체가 어떤 API를 어떤 리소스에 호출했는지, 그리고 어떤 정책의 어떤 조건에 걸렸는지가 보이지 않아 원인 파악이 늦어집니다.

이 글은 AccessDenied를 “감”으로 고치는 대신, 스택추적(호출 경로) + CloudTrail 이벤트 기반 역추적 + 정책 최소화로 마무리하는 실무 절차를 설명합니다. EKS, Lambda, EC2, GitHub Actions OIDC 등 어떤 실행 환경이든 동일한 방식으로 적용할 수 있습니다.

1) AccessDenied를 3분류로 쪼개면 빨라진다

AccessDenied는 대체로 아래 셋 중 하나입니다.

  1. 인증 주체(Principal) 착각: 내가 생각한 Role이 아니라 다른 Role, 다른 세션, 다른 계정이 호출함
  2. 권한(Allow) 부족: 필요한 Action 또는 Resource에 대한 Allow가 없음
  3. 명시적 거부(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/... 이면 장기 Role
  • arn: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, CreateLogStream
  • Error code: AccessDenied 또는 서비스별 예: AccessDeniedException, UnauthorizedOperation
  • Username 또는 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:GetObject
  • Resource에 CloudTrail에서 확인한 ARN을 입력

여기서 중요한 점은 리소스 정책과 키 정책까지 고려해야 한다는 것입니다.

  • S3는 버킷 정책 + IAM 정책 조합
  • KMS는 IAM 정책 + KMS 키 정책 조합

EKS에서 IRSA는 되는데 KMS만 막히는 케이스는 KMS 키 정책이 원인인 경우가 많습니다. 아래 글이 같은 결을 다룹니다.

5) “정책 최소화”의 핵심: 먼저 넓게, 로그로 좁게

실무에서 최소 권한은 보통 2단계로 합니다.

  1. 임시로 넓게 허용: 빠른 복구를 위해 필요한 액션을 넓게 열되, 기간을 짧게
  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.arnassumed-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-prodmy-bucket-production처럼 유사한 리소스가 있으면, 개발 환경에서는 되는데 운영에서만 실패합니다. CloudTrail resourcesrequestParameters로 실제 대상이 무엇인지 확정하세요.

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를 “재발 방지”로 끝내기

  1. 실행 환경에서 aws sts get-caller-identity로 Principal 확정
  2. CloudTrail에서 실패 이벤트를 찾아 eventName, resources, requestParameters 확보
  3. IAM Policy Simulator로 Allow 누락인지, Explicit Deny인지 분기
  4. 임시 정책으로 빠르게 복구하되 Resource는 가능한 한 좁게
  5. 1~3일 CloudTrail 집계로 실제 사용 API만 남겨 정책 최소화
  6. KMS 키 정책, SCP, Permission Boundary, Trust Policy 조건까지 함께 점검
  7. 앱 로그에 requestId 포함해 다음 장애에서 탐색 비용을 줄이기

AccessDenied는 권한을 더 주면 언젠가 해결되지만, 그 방식은 보안 부채를 만들고 다음 장애의 원인도 됩니다. CloudTrail과 시뮬레이터를 중심으로 “누가, 무엇을, 왜 못 했는지”를 고정하면, 해결 속도와 정책 품질이 동시에 올라갑니다.