← 모든 글

AWS IAM 제대로 이해하기: 모든 요청은 권한 검사를 거친다

IAM 정책이 요청을 허용하거나 거부하는 원리를 정책 문법, 평가 순서, 역할과 임시 자격 증명으로 살펴봅니다.

AWSIAM

모든 클릭은 API 호출입니다

AWS에서 일어나는 모든 일은 결국 API 호출이고, IAM은 그 호출 하나하나에 대해 "이 요청을 허락할까?"를 판단하는 문지기입니다. 콘솔에서 버튼을 누르든, CLI로 aws s3 ls를 치든, 코드에서 SDK를 부르든, AWS 입장에서는 모두 s3:ListBucket 같은 이름이 붙은 HTTP 요청일 뿐입니다.

이 요청에는 서명이 붙어 있습니다. AWS는 먼저 서명을 확인해 요청한 쪽이 누구인지 알아냅니다. 이것이 인증(Authentication)입니다. 그다음 그 주체가 이 리소스에 이 동작을 해도 되는지 정책을 뒤져 판단합니다. 이것이 인가(Authorization)입니다.

단계 묻는 질문 근거
인증 너는 누구인가? 비밀번호, MFA, 액세스 키 서명, 임시 토큰
인가 그걸 해도 되는가? 주체와 리소스에 붙은 정책

인증에 성공해도 인가에서 막히면 AccessDenied가 돌아옵니다. 개발하면서 가장 자주 보게 되는 이 에러는 거의 항상 "누군지는 알겠는데, 허락한 적이 없다"는 뜻입니다.

정책은 "누가, 무엇을, 어디에, 언제"를 적은 JSON입니다

IAM 정책은 허락 또는 거부 문장을 모아 둔 JSON 문서입니다. 먼저 예를 하나 보겠습니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-app-assets/*",
      "Condition": {
        "Bool": { "aws:SecureTransport": "true" }
      }
    }
  ]
}

"my-app-assets 버킷의 객체는 HTTPS로 요청할 때만 읽을 수 있다"는 뜻입니다. 문장 하나는 다음 요소로 이루어집니다.

요소 의미 예시
Effect 허락할지 거부할지 Allow, Deny
Action 어떤 API 호출인지 s3:GetObject, ec2:StartInstances
Resource 어느 리소스에 대한 것인지 (ARN) arn:aws:s3:::my-app-assets/*
Condition 언제만 적용할지 HTTPS일 때, 특정 IP에서, MFA를 거쳤을 때
Principal 누구에게 적용할지 (리소스 정책에만) arn:aws:iam::111122223333:role/app

여기서 Principal이 없다는 점이 중요합니다. 사용자나 역할에 직접 붙이는 정책은 붙은 대상이 곧 주체이므로 따로 적을 필요가 없습니다. 반대로 S3 버킷처럼 리소스에 붙이는 정책에는 "누구에게"를 적는 Principal이 반드시 들어갑니다.

판단 순서: 기본은 거부, 허용은 명시적으로, 거부가 항상 이긴다

IAM은 요청이 들어올 때마다 관련된 정책을 전부 모아 한꺼번에 평가합니다. 정책이 여러 개여도 순서는 항상 같습니다.

명시적 거부가 하나라도 있으면 어떤 허용도 소용없습니다요청 도착출발점은 언제나 거부 (기본 거부)명시적 Deny가 있나?관련된 모든 정책을 통틀어 하나라도SCP·권한 경계가 허용하나?상한선이 걸려 있을 때만 확인Allow가 있나?자격 증명 정책 또는 리소스 정책에허용요청이 실행됩니다거부어떤 Allow로도 뒤집을 수 없음거부상한선 밖의 요청거부허락한 적이 없음 (기본값)예아니오아니오아니오예예

위에서 아래로 내려가다가 오른쪽으로 빠지는 순간 요청은 거부됩니다. 끝까지 내려온 요청만 실행됩니다.

이 규칙에는 분명한 이유가 있습니다. 먼저 기본 거부는 실수가 있어도 안전한 쪽으로 기울게 합니다. 정책을 깜빡 빠뜨리면 기능이 동작하지 않을 뿐, 몰래 열리지는 않습니다.

다음으로 명시적 거부 우선은 보안 규칙이 뚫리지 않게 합니다. 허용은 여러 곳에서 더해지지만, 거부는 한 곳에만 있어도 끝입니다. 그래서 아래 같은 Deny 문장 하나로 "MFA 없이는 운영 데이터를 지울 수 없다"는 규칙을 세울 수 있습니다. 누군가 나중에 관리자 권한을 받아도 마찬가지입니다.

{
  "Effect": "Deny",
  "Action": "s3:DeleteObject",
  "Resource": "arn:aws:s3:::prod-data/*",
  "Condition": {
    "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" }
  }
}

정책은 주체에도, 리소스에도 붙습니다

같은 권한을 "사람 쪽"에서 줄 수도 있고 "물건 쪽"에서 줄 수도 있습니다. 회사로 치면 사원증에 "3층 출입 가능"을 적는 방식과, 3층 문 앞에 "개발팀은 들어와도 됨"을 붙이는 방식의 차이입니다.

구분 자격 증명 기반 정책 리소스 기반 정책
붙는 곳 사용자, 그룹, 역할 S3 버킷, SQS 큐, KMS 키, Lambda 함수 등
Principal 없음 (붙은 대상이 주체) 반드시 적음
답하는 질문 이 사람은 무엇을 할 수 있나? 이 리소스에는 누가 접근할 수 있나?
다른 계정에 열기 혼자서는 불가 가능

같은 계정 안에서는 둘 중 한 곳에만 허용이 있어도 요청이 통과합니다. 다른 계정에서 오는 요청은 양쪽이 모두 허용해야 합니다. 내 계정의 역할이 "저 버킷을 읽어도 된다"는 정책을 갖고, 상대 계정의 버킷도 "그 역할은 들어와도 된다"고 말해줘야 합니다. 예를 들어 버킷 정책은 이렇게 생겼습니다.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::444455556666:role/report-reader" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::shared-reports/*"
  }]
}

사용자보다 역할, 영구 키보다 임시 자격 증명

IAM의 주체는 세 가지입니다. 요즘 권장되는 설계는 사람이든 프로그램이든 가능하면 역할(Role)을 거치게 하는 것입니다.

주체 무엇인가 자격 증명
사용자(User) 특정 사람이나 프로그램 하나 비밀번호, 장기 액세스 키
그룹(Group) 사용자 묶음, 정책을 한 번에 붙이기 위한 것 없음 (주체가 아님)
역할(Role) 누군가 잠시 맡는 권한 묶음 STS가 발급하는 임시 자격 증명

장기 액세스 키의 문제는 단순합니다. 만료가 없으니 한 번 새면 누군가 발견하고 폐기할 때까지 계속 쓸 수 있습니다. GitHub에 실수로 올린 .env 하나가 수십만 원짜리 청구서가 되는 이유입니다.

역할은 다르게 동작합니다. 역할을 맡을 수 있는 주체가 STS(Security Token Service)에 AssumeRole을 요청하면, 기본 1시간짜리 액세스 키, 비밀 키, 세션 토큰을 받습니다. 시간이 지나면 저절로 무효가 되고, 필요하면 다시 받으면 됩니다. 새더라도 피해가 시간으로 제한됩니다.

이 방식은 이미 AWS 곳곳에 녹아 있습니다.

  • EC2 인스턴스 프로파일: 인스턴스에 역할을 붙이면 SDK가 메타데이터 서버에서 임시 자격 증명을 알아서 가져오고 갱신합니다. 코드에 키가 한 줄도 들어가지 않습니다.
  • Lambda 실행 역할: 함수가 호출될 때마다 그 역할의 임시 자격 증명이 환경에 주입됩니다.
  • 교차 계정 AssumeRole: A 계정의 앱이 B 계정의 역할을 맡아 잠시 B의 일을 합니다. B에 A용 사용자를 만들 필요가 없습니다.
  • IAM Identity Center(SSO): 사람은 회사 계정으로 로그인하고, 각 AWS 계정에서는 역할을 맡아 일합니다. 퇴사자는 회사 계정만 막으면 끝납니다.

역할에는 정책이 두 종류 붙습니다. "누가 이 역할을 맡을 수 있나"를 적는 신뢰 정책과, "맡은 뒤 무엇을 할 수 있나"를 적는 권한 정책입니다. 신뢰 정책은 역할이라는 리소스에 붙는 리소스 기반 정책이라서 Principal이 들어갑니다.

해보기: S3 버킷 하나만 읽는 EC2 앱

EC2에서 돌아가는 웹 앱이 my-app-assets 버킷의 파일을 읽어야 한다고 해보겠습니다. 필요한 것은 역할 하나와 정책 두 개입니다.

1단계. 신뢰 정책: EC2 서비스가 이 역할을 맡을 수 있게 합니다.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ec2.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}

2단계. 권한 정책: 그 버킷을 나열하고 읽는 것만 허용합니다.

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

두 문장의 Resource가 다른 점에 주목해 주세요. ListBucket은 버킷 자체에 대한 동작이고, GetObject는 버킷 안의 객체(/*)에 대한 동작입니다. 이 둘을 섞으면 문법은 맞아도 권한이 적용되지 않습니다.

3단계. 연결: 역할을 인스턴스 프로파일에 담아 EC2에 붙입니다. 코드는 키 없이 이렇게만 씁니다.

import boto3

s3 = boto3.client("s3")  # 자격 증명은 SDK가 알아서 찾습니다
obj = s3.get_object(Bucket="my-app-assets", Key="logo.png")

이 앱이 어느 날 탈취되더라도 공격자가 할 수 있는 일은 이 버킷을 읽는 것뿐입니다. 다른 버킷, 삭제, EC2 조작은 모두 기본 거부에 걸립니다.

설계 원칙: 작게 주고, 상한선을 긋고, 루트는 잠근다

좋은 IAM 설계의 출발점은 최소 권한입니다. 필요한 동작을, 필요한 리소스에, 필요한 조건에서만 허용합니다. "Action": "*"와 "Resource": "*"는 당장은 편하지만, 사고가 났을 때 피해 범위를 계정 전체로 넓힙니다. 처음에는 넓게 주고 나중에 줄이겠다는 계획은 대개 줄이는 날이 오지 않으니, IAM Access Analyzer처럼 실제 사용 기록을 보고 정책을 제안해 주는 도구를 활용하는 편이 낫습니다.

조직이 커지면 "아무리 허용해도 이 선은 넘지 못한다"는 상한선이 필요해집니다. 두 장치는 스스로 권한을 주지 않고, 다른 정책이 준 권한을 잘라내기만 합니다.

  • 권한 경계(Permissions Boundary): 특정 사용자나 역할에 붙이는 상한선입니다. 개발자에게 역할 만들기를 맡기되, 자기보다 강한 역할은 못 만들게 할 때 씁니다.
  • SCP(Service Control Policy): AWS Organizations에서 계정 단위로 거는 상한선입니다. "이 계정에서는 서울 리전 외에는 아무것도 만들 수 없다" 같은 규칙을 계정 안의 모든 주체에 적용합니다.

마지막으로 루트 사용자입니다. 계정을 만들 때 쓴 이메일 계정으로, IAM 정책으로는 막을 수 없는 전권을 가집니다. 그래서 다음 세 가지를 지키는 것이 좋습니다.

  • 루트에 MFA를 켜 둡니다.
  • 루트의 액세스 키는 만들지 않습니다. 있다면 지웁니다.
  • 일상 작업은 Identity Center나 관리자 역할로 하고, 루트는 결제 설정처럼 루트만 할 수 있는 일에만 씁니다.

마무리

IAM을 한 문장으로 줄이면 "모든 API 호출은 기본적으로 거부되고, 누군가 명시적으로 허용한 만큼만 열린다"입니다. 이 원리를 알고 나면 AccessDenied를 만났을 때 볼 곳이 분명해집니다. 허용이 아예 없는지, 어디선가 명시적 거부가 있는지, 상한선에 걸렸는지를 차례로 확인하면 됩니다.

설계는 세 가지만 기억하면 충분합니다. 사람과 프로그램은 역할과 임시 자격 증명으로 일하게 하고, 권한은 필요한 만큼만 주고, 루트는 MFA로 잠가 둡니다.