Published on

S3 AccessDenied 403 진단 - 버킷 정책·SCP·VPCE

Authors

서버/배치/Pod에서 S3 호출이 AccessDenied (403)로 떨어질 때, 대부분은 IAM 권한 부족으로 단정하고 정책만 붙였다가 시간을 날립니다. S3의 403은 “누가(Principal)가 무엇(Action)을 어떤 리소스(Resource)에 대해 시도했는지”뿐 아니라, **어떤 경로(인터넷 vs VPC 엔드포인트)**로 들어왔는지, **조직 차원의 가드레일(SCP)**이 막고 있는지, **버킷 정책의 조건(Condition)**이 의도치 않게 걸렸는지까지 함께 봐야 합니다.

이 글은 S3 403을 버킷 정책(Bucket Policy)·SCP·VPC 엔드포인트(VPCE) 관점에서 빠르게 분기하는 체크리스트와, CloudTrail/CLI로 증거를 모아 원인을 확정하는 방법을 다룹니다.

> 참고로 “특정 워크로드(예: EKS Pod)만 403”처럼 네트워크 경로/출구 IP가 얽힌 문제는 패턴이 유사합니다. 외부 API 403을 NAT IP·WAF로 풀었던 사례는 EKS Pod만 외부 API 403 - NAT IP·WAF로 해결도 함께 보면 진단 감이 빨라집니다.

1) 먼저 확인할 것: 403의 “정확한 형태”

S3 403은 크게 3가지로 나뉩니다.

  1. S3 API가 AccessDenied: AccessDenied, AccessDeniedException
  2. KMS가 AccessDenied: SSE-KMS 사용 시 kms:Decrypt/kms:GenerateDataKey에서 403/AccessDenied
  3. 네트워크/엔드포인트 계층에서 차단: VPCE 정책, 버킷 정책의 aws:sourceVpce, aws:SourceIp 조건 등

재현 로그를 “최소 단위”로 만들기

문제를 단순화하려면 같은 자격증명으로 다음을 각각 실행해 보세요.

  • ListBucket (버킷 자체 권한)
  • GetObject (오브젝트 권한)
  • PutObject (쓰기 권한)
# 1) 버킷 목록(버킷 권한)
aws s3api list-objects-v2 --bucket my-bucket --max-keys 1

# 2) 오브젝트 읽기(오브젝트 권한)
aws s3api get-object --bucket my-bucket --key path/to/a.txt /tmp/a.txt

# 3) 오브젝트 쓰기(쓰기 + 암호화 조건 여부)
echo test > /tmp/t.txt
aws s3api put-object --bucket my-bucket --key debug/t.txt --body /tmp/t.txt

여기서 List는 되는데 Get이 안 된다s3:GetObject 또는 리소스 ARN 범위가 문제일 확률이 높고, Put만 안 된다s3:PutObject 또는 암호화/태그/ACL 조건이 걸린 경우가 흔합니다.

2) 증거 수집 1순위: CloudTrail에서 “거부 주체”를 확정

S3 403은 CloudTrail 이벤트에 답이 거의 다 있습니다. 특히 다음 필드를 확인하세요.

  • userIdentity.arn (누가)
  • eventName (무엇을)
  • resources / requestParameters.bucketName / key (어디에)
  • errorCode, errorMessage
  • sourceIPAddress (어디서)
  • vpcEndpointId (VPCE 경유 여부)
# 최근 1시간 내 S3 AccessDenied만 필터링(예시)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventSource,AttributeValue=s3.amazonaws.com \
  --max-results 50 | jq -r '
    .Events[]
    | select(.CloudTrailEvent | fromjson | .errorCode? == "AccessDenied")
    | .CloudTrailEvent | fromjson
    | {eventTime, eventName, user: .userIdentity.arn, bucket: .requestParameters.bucketName, key: .requestParameters.key, sourceIP: .sourceIPAddress, vpce: .vpcEndpointId, msg: .errorMessage}
  '

이 결과에서 vpce가 찍히면 VPC 엔드포인트 경유, sourceIP가 NAT/EIP면 인터넷 경유 가능성이 큽니다(단, 환경에 따라 다름).

3) 버킷 정책(Bucket Policy)에서 흔히 놓치는 403 패턴

IAM 정책을 아무리 열어도, 버킷 정책에 명시적 Deny가 있으면 무조건 막힙니다. S3 평가 순서는 간단히 기억하면 됩니다.

  • Explicit Deny (SCP/버킷/아이덴티티 어디든) > Allow > Implicit Deny

3.1 aws:PrincipalArn/aws:PrincipalOrgID 조건으로 특정 주체만 허용

조직 단위로만 접근 허용하려고 aws:PrincipalOrgID를 걸어두면, 교차 계정/외부 주체는 전부 403이 됩니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowOnlyOurOrg",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ],
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalOrgID": "o-xxxxxxxxxx"
        }
      }
    }
  ]
}

이 정책은 “우리 Org가 아니면 무조건 Deny”입니다. 예상치 못한 주체(예: 외부 CI, 다른 Org의 계정, 개인 IAM User)가 접근하면 403이 나옵니다.

3.2 aws:SecureTransport 강제(HTTP 차단)

SDK/프록시/사내망에서 HTTP로 나가면 바로 403이 뜹니다.

{
  "Sid": "DenyInsecureTransport",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
  "Condition": {"Bool": {"aws:SecureTransport": "false"}}
}

CloudTrail에서 sourceIPAddress는 정상인데 계속 AccessDenied라면, 이 조건을 먼저 의심하세요.

3.3 특정 VPCE로만 접근 허용(aws:sourceVpce)했는데 경로가 바뀐 경우

가장 흔한 “환경별로만 터지는” 케이스입니다.

  • 개발 환경: VPCE 통해서 접근 → 정상
  • 운영/배치: NAT 통해서 인터넷으로 접근 → 버킷 정책이 Deny → 403
{
  "Sid": "DenyIfNotFromVPCE",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
  "Condition": {
    "StringNotEquals": {"aws:sourceVpce": "vpce-0123456789abcdef0"}
  }
}

이때 CloudTrail에 vpcEndpointId가 비어 있으면(또는 다른 VPCE면) 403이 나는 게 정상입니다.

3.4 s3:prefix/s3:delimiter 조건으로 List만 막히는 경우

ListBucket은 버킷 ARN에 걸리고, 오브젝트 작업은 bucket/*에 걸립니다. 정책을 섞어 쓰면 “Get은 되는데 List가 안 됨” 같은 현상이 나옵니다.

{
  "Sid": "AllowListOnlyUnderPrefix",
  "Effect": "Allow",
  "Principal": {"AWS": "arn:aws:iam::111122223333:role/app"},
  "Action": "s3:ListBucket",
  "Resource": "arn:aws:s3:::my-bucket",
  "Condition": {"StringLike": {"s3:prefix": ["home/app/*"]}}
}

4) Organizations SCP(Service Control Policy): ‘권한을 줘도’ 막히는 이유

SCP는 계정/OU 단위의 최대 권한 상한선입니다. 아이덴티티 정책에서 Allow를 줘도 SCP가 Deny면 끝입니다.

4.1 SCP에서 S3 전체 또는 특정 액션을 Deny

예: 실수로 s3:*를 Deny하거나, 퍼블릭 버킷 방지 정책을 과하게 걸어 PutObject까지 막는 경우.

진단 포인트:

  • 동일 역할/정책인데 특정 계정에서만 403
  • CloudTrail에 찍히는 주체는 정상인데 계속 Deny

확인은 AWS Organizations(관리 계정)에서 해당 계정이 속한 OU에 연결된 SCP를 확인해야 합니다.

4.2 SCP가 VPCE/리전/암호화 조건을 강제

SCP에서도 조건 키로 통제할 수 있습니다.

  • aws:RequestedRegion으로 특정 리전만 허용
  • aws:SourceVpce로 VPCE 강제
  • S3 Put 시 SSE-KMS 강제 등

SCP는 CloudTrail에서 “SCP 때문에 Deny”라고 친절히 말해주지 않는 경우가 많아, 조직 정책 변경 이력과 함께 봐야 합니다.

5) VPC 엔드포인트(VPCE) 정책: 게이트웨이/인터페이스 둘 다 함정이 있다

S3는 보통 Gateway VPC Endpoint를 씁니다. 이때 “네트워크는 VPCE로 잘 타는데도 403”이면 VPCE 정책이 먼저 의심 대상입니다.

5.1 Gateway Endpoint Policy가 특정 버킷만 허용

VPCE 정책은 “이 VPCE를 통해 접근 가능한 S3 리소스”를 제한합니다.

{
  "Statement": [
    {
      "Sid": "AllowOnlyOneBucket",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ]
    }
  ]
}

이 경우 다른 버킷은 같은 IAM 권한이 있어도 VPCE에서 막혀 403이 날 수 있습니다.

5.2 S3 Access Point / MRAP / Interface Endpoint 사용 시

S3 Access Point를 쓰면 ARN/리소스 평가가 달라지고, 인터페이스 엔드포인트(PrivateLink) 구성이 섞이면 aws:sourceVpce/aws:SourceVpc 조건이 꼬이기도 합니다. “버킷 정책은 맞는데 Access Point로는 안 됨”이면 Access Point 정책까지 포함해 평가해야 합니다.

6) 암호화(SSE-KMS)라면: S3 403처럼 보이는 KMS 403을 분리

SSE-KMS를 쓰면 Put/Get 과정에서 KMS 권한이 필요합니다.

  • 업로드: kms:GenerateDataKey
  • 다운로드: kms:Decrypt

CloudTrail에서 S3 이벤트가 아니라 **KMS 이벤트(AccessDenied)**가 보이면 KMS 키 정책/권한 문제입니다. 이 케이스는 IRSA 등 “역할은 맞는데 KMS만 403” 패턴과 매우 유사합니다. 필요하면 EKS IRSA는 되는데 KMS Decrypt 403 해결법을 같이 참고하세요.

간단 점검:

# 객체 메타데이터로 SSE-KMS 여부 확인
aws s3api head-object --bucket my-bucket --key path/to/object \
  | jq '{SSE: .ServerSideEncryption, KMSKeyId: .SSEKMSKeyId}'

7) 실전 진단 플로우(10분 컷 체크리스트)

아래 순서대로 보면 “감”이 아니라 “증거”로 확정할 수 있습니다.

7.1 (1) 누가 호출했나: STS Caller Identity

aws sts get-caller-identity

예상한 Role/Account인지 먼저 고정합니다. 컨테이너/EC2/람다에서 종종 다른 Role이 붙어 403이 납니다.

7.2 (2) CloudTrail에서 거부 이벤트 찾기

  • eventNameGetObject인지 ListBucket인지
  • vpcEndpointId가 있는지
  • sourceIPAddress가 어디인지

7.3 (3) 버킷 정책의 Deny 조건 확인

  • aws:sourceVpce, aws:SourceIp, aws:SecureTransport, aws:PrincipalOrgID
  • NotAction/NotResource가 섞여 의도치 않게 Deny 되는지

7.4 (4) VPCE 정책 확인(특히 Gateway Endpoint)

  • 해당 버킷 ARN이 Resource에 포함되는지
  • Action이 충분한지(ListBucket 빠짐 등)

7.5 (5) SCP 확인

  • 해당 계정/OU에 연결된 SCP 중 S3 관련 Deny가 있는지

7.6 (6) KMS 사용 여부 분리

  • head-object로 SSE 확인
  • CloudTrail에서 KMS AccessDenied 여부 확인

8) 자주 쓰는 “정상 동작” 최소 권한 예시

마지막으로, 문제를 격리하기 위한 최소 권한 샘플을 제공합니다.

8.1 IAM(Identity Policy) 최소 예시

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucket",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-bucket"
    },
    {
      "Sid": "ObjectRW",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::my-bucket/home/app/*"
    }
  ]
}

8.2 버킷 정책에서 VPCE 강제 + 특정 Role만 허용(예시)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAppRole",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::111122223333:role/app"},
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::my-bucket"
    },
    {
      "Sid": "AllowAppRoleObjects",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::111122223333:role/app"},
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::my-bucket/home/app/*"
    },
    {
      "Sid": "DenyIfNotFromVPCE",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
      "Condition": {"StringNotEquals": {"aws:sourceVpce": "vpce-0123456789abcdef0"}}
    }
  ]
}

이 구성에서 403이 난다면, 거의 확실히 다음 중 하나입니다.

  • 실제 호출이 app Role이 아니다(자격 증명 문제)
  • 호출이 VPCE를 타지 않는다(라우팅/서브넷/엔드포인트 연결 문제)
  • VPCE 정책이 더 좁다(엔드포인트 정책)
  • 조직 SCP가 막는다

9) 마무리: 403은 ‘권한’이 아니라 ‘정책 체인’ 문제다

S3 AccessDenied 403은 IAM만으로 설명되지 않습니다. (1) CloudTrail로 주체/액션/경로를 고정하고, (2) 버킷 정책의 Deny 조건, (3) SCP, (4) VPCE 정책을 같은 선상에서 비교하면 대부분 10~30분 내에 결론이 납니다.

특히 “환경별로만 403”이면 높은 확률로 aws:sourceVpce/VPCE 정책/라우팅이 원인입니다. EKS처럼 네트워크 레이어 변수가 많은 환경에서는 DNS/네트워크 경로 이슈가 함께 나타나기도 하니, 필요하면 EKS에서 Pod DNS 실패 - CoreDNS·VPC CNI 점검처럼 기본 네트워크 점검도 병행하는 것을 권합니다.