IAM: User, Role과 Policy 올바르게 이해하기

1. 개요

IAM (Identity and Access Management)는 AWS의 기반 서비스로, “누가 무엇을 어디에서 할 수 있는가”를 결정합니다. 많은 팀이 처음부터 잘못 사용합니다: 모든 것에 admin 권한을 가진 user를 만들거나, 빨리 처리하려고 *:* policy를 붙이거나, user와 role을 헷갈려 합니다. 이 글은 user, role, policy라는 세 가지 개념을 명확히 구분하고, JSON policy를 올바르게 작성하고, 최소 권한 원칙을 적용하고, AWS SDK for Java를 사용해 role assumption을 테스트하는 방법을 알려줍니다.

이 글을 읽고 나면, 간결하고 안전하며 모든 서비스에 “god-mode” 권한을 주지 않는 IAM 설계에 자신감을 가질 수 있을 것입니다.

2. 세 가지 핵심 요소

IAM은 세 가지 개념을 중심으로 돌아갑니다:

  • User: 특정 사람 또는 애플리케이션의 정체성으로, 오래 지속되는 자격증명(password, access key)을 가집니다.
  • Role: 임시 정체성으로 고정 자격증명이 없으며, user, service 또는 다른 계정에 의해 “assume”(가정)됩니다.
  • Policy: user, role, group 또는 리소스에 붙는 JSON 텍스트로, 권한(allow/deny)을 설명합니다.

참고: Role은 “password 없는 user”가 아닙니다. Role은 임시 자격증명(시간 제한이 있고 STS를 통해 발급)을 생성하는 반면, user는 사용자가 회전(rotate)할 때까지 고정된 자격증명을 사용합니다.

3. User: 언제 사용하고 언제 사용하지 않을까요

User는 다음의 경우에 적합합니다:

  • 사람이 Console에 로그인하여 수동으로 작업해야 할 때
  • Role을 사용할 수 없는 불가피한 경우 (드물게, 예: AWS 외부의 레거시 시스템이 고정된 access key가 필요한 경우)

User는 다음의 경우에는 적합하지 않습니다:

  • EC2, Lambda, ECS에서 실행되는 workload — 이런 경우는 role을 사용해야 합니다.
  • 여러 사람이 하나의 user를 공유하는 경우 (CloudTrail에서 감사 추적이 불가능)

CLI로 user를 만드는 방법:

aws iam create-user --user-name order-service-deployer

aws iam create-login-profile \
  --user-name order-service-deployer \
  --password 'TempPass123!' \
  --password-reset-required

첫 번째 줄이 user를 만듭니다. create-login-profile은 임시 password로 Console 로그인 권한을 부여하고 처음 로그인할 때 반드시 변경하도록 강제합니다 — 기본 password가 오래 유지되는 것을 방지합니다.

흔한 실수: user를 만들고 access key를 code에 hard-code하고 git에 commit합니다. access key를 꼭 사용해야 한다면 주기적인 회전을 활성화하고 Secrets Manager를 사용해 저장하세요.

4. Role: 올바른 곳의 임시 정체성

4.1. EC2 instance role

EC2 instance가 S3를 호출해야 할 때, access key를 .env에 embed하지 마세요. role을 만들고 instance profile에 붙이세요:

aws iam create-role \
  --role-name OrderServiceEc2Role \
  --assume-role-policy-document file://trust-policy.json

trust-policy.json 파일은 누가 이 role을 assume할 수 있는지를 정의합니다:

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

Principal.Service는 EC2 service가 instance profile을 통해 이 role을 assume할 수 있음을 지정합니다. 이것을 trust policy라고 부르는데, 다음 섹션에서 설명할 permission policy와는 다릅니다.

4.2. Cross-account role

Role은 또한 다른 AWS 계정이 자격증명을 공유하지 않고도 귀하의 계정에 접근할 수 있게 해줍니다:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": { "sts:ExternalId": "order-partner-2024" }
      }
    }
  ]
}

sts:ExternalId를 포함한 Condition은 confused deputy attack을 방지합니다 — 제3자가 비밀 ExternalId를 알아야만 role을 assume할 수 있으므로 제4자가 공개 ARN을 악용하는 상황을 피할 수 있습니다.

5. Policy: 구조 및 작성 방법

Policy JSON은 Version과 Statement로 구성됩니다 — 각 statement는 Effect, Action, Resource를 가지며, 선택적으로 Condition을 가집니다.

예: PaymentService가 특정 S3 bucket에 읽고 쓸 수 있지만 삭제는 못하도록 허용:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadWritePaymentBucket",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::payment-receipts-bucket/*"
    },
    {
      "Sid": "DenyDeleteObject",
      "Effect": "Deny",
      "Action": "s3:DeleteObject",
      "Resource": "arn:aws:s3:::payment-receipts-bucket/*"
    }
  ]
}

첫 번째 statement는 Allow GetObject와 PutObject 두 action을 payment-receipts-bucket/* 경로에만 허용합니다. 두 번째 statement는 삭제 작업을 명시적으로 Deny 합니다 — 나중에 누군가 s3:* policy를 추가하더라도 명시적 deny는 항상 우선입니다.

이 policy를 role에 붙이세요:

aws iam put-role-policy \
  --role-name PaymentServiceRole \
  --policy-name PaymentBucketAccess \
  --policy-document file://payment-bucket-policy.json

5.1. Identity-based vs resource-based policy

  • Identity-based policy: user/role/group에 붙이는 것으로, “이 정체성은 무엇을 할 수 있는가”를 답합니다.
  • Resource-based policy: S3 bucket policy, SQS queue policy, KMS key policy처럼 리소스에 직접 붙는 것으로, “누가 이 리소스에 접근할 수 있는가”를 답합니다.

예: bucket policy (resource-based)로 특정 role만 접근 허용:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/PaymentServiceRole"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::payment-receipts-bucket/*"
    }
  ]
}

이 두 종류의 policy는 함께 작동할 수 있습니다 — 최종 권한은 identity-based와 resource-based 정책의 교집합입니다 (둘 다 명시적으로 allow해야 하고, 하나라도 deny하면 접근 불가).

6. 최소 권한 원칙

"Action": "s3:*"로 빨리 처리하지 마세요. 실제로 필요한 action을 나열하세요. Condition을 사용해 범위를 더욱 제한하세요.

예: 특정 VPC에서의 요청일 때만 object 읽기 허용:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::order-exports-bucket/*",
      "Condition": {
        "StringEquals": {
          "aws:SourceVpc": "vpc-0abc1234"
        }
      }
    }
  ]
}

aws:SourceVpc는 글로벌 condition key로, 요청이 지정된 VPC의 VPC endpoint에서 왔을 때만 허용합니다 — 데이터가 내부 네트워크를 벗어나지 않도록 하려는 경우 유용합니다.

최소 권한을 달성하기 위한 실무적 접근:

  1. 빈 policy로 시작합니다 (기본 deny).
  2. workload를 실행하고 IAM Access Analyzer 또는 CloudTrail 로그에서 거부된 action을 확인합니다.
  3. 정확한 action만 policy에 추가합니다. 여분을 추가하지 않습니다.
  4. workload가 안정적으로 작동할 때까지 반복합니다.

팁: aws iam generate-service-last-accessed-details를 사용해 user/role이 지난 90일 동안 실제로 사용한 action을 확인하세요 — 이를 통해 현재 policy에서 불필요한 action을 제거할 수 있습니다.

7. Policy 평가 로직 알아두기

AWS는 권한을 다음 순서대로 평가합니다. 권한 문제를 빠르게 디버그하려면 이 순서를 이해해야 합니다:

  1. 기본적으로 모든 것이 deny 됩니다 (implicit deny).
  2. 명시적 Deny statement가 일치하면 → 결과는 deny, 복구 불가능합니다.
  3. 명시적 deny가 없으면, 일치하는 Allow statement를 찾습니다 → allow.
  4. 일치하는 allow가 없으면 → 여전히 deny (기본 implicit deny).
요청 도착 → 일치하는 명시적 Deny 있음?
  ├─ 있음 → DENY (종료)
  └─ 없음 → 일치하는 Allow 있음?
       ├─ 있음 → ALLOW
       └─ 없음 → DENY (implicit)

이 순서를 올바르게 이해하면 일반적인 버그를 피할 수 있습니다: user에게 AdministratorAccess를 붙였는데도 특정 action이 차단되는 경우, 조직 수준의 SCP (Service Control Policy)에서 명시적으로 그 action을 deny하는 경우가 있습니다.

8. AWS SDK for Java를 사용한 role assumption 테스트

SDK v2 dependency를 추가하세요:

<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>sts</artifactId>
    <version>2.28.11</version>
</dependency>

Service가 STS를 호출해 role을 assume하고 임시 자격증명을 받습니다:

public class RoleAssumer {

    private final StsClient stsClient;

    public RoleAssumer(StsClient stsClient) {
        this.stsClient = stsClient;
    }

    public Credentials assumePaymentServiceRole() {
        AssumeRoleRequest request = AssumeRoleRequest.builder()
                .roleArn("arn:aws:iam::123456789012:role/PaymentServiceRole")
                .roleSessionName("payment-service-session")
                .durationSeconds(900)
                .build();

        AssumeRoleResponse response = stsClient.assumeRole(request);
        return response.credentials();
    }
}

roleArn은 assume할 role을 지정하고, roleSessionName은 CloudTrail에서 추적하기 쉽도록 session 이름을 설정하고, durationSeconds는 임시 자격증명의 수명을 제한합니다 — 짧을수록 안전합니다.

JUnit 5와 Mockito를 사용해 테스트하여 AWS를 실제로 호출하지 않고 올바른 role ARN을 요청하는지 확인합니다:

@Test
void assumePaymentServiceRole_shouldRequestCorrectRoleArn() {
    StsClient stsClient = mock(StsClient.class);
    Credentials fakeCredentials = Credentials.builder()
            .accessKeyId("AKIAFAKE")
            .secretAccessKey("secretFake")
            .sessionToken("tokenFake")
            .build();

    when(stsClient.assumeRole(any(AssumeRoleRequest.class)))
            .thenReturn(AssumeRoleResponse.builder()
                    .credentials(fakeCredentials)
                    .build());

    RoleAssumer assumer = new RoleAssumer(stsClient);
    Credentials result = assumer.assumePaymentServiceRole();

    ArgumentCaptor<AssumeRoleRequest> captor = ArgumentCaptor.forClass(AssumeRoleRequest.class);
    verify(stsClient).assumeRole(captor.capture());

    assertThat(captor.getValue().roleArn())
            .isEqualTo("arn:aws:iam::123456789012:role/PaymentServiceRole");
    assertThat(result.accessKeyId()).isEqualTo("AKIAFAKE");
}

이 테스트는 StsClient를 mock하고, 실제로 전송된 요청을 capture한 다음, roleArn이 예상대로인지 assert합니다. 이 방법으로 누군가 실수로 code의 hard-coded role ARN을 변경하는 경우를 조기에 발견할 수 있습니다.

assumePaymentServiceRole_shouldRequestCorrectRoleArn() PASSED

9. 피해야 할 흔한 실수

  • 모든 service account에 AdministratorAccess 붙이기 (나중에 최적화할 것이라고 생각하며). 실제로는 아무도 돌아와서 최적화하지 않으므로 위험이 영구적으로 존재합니다.
  • EC2/Lambda의 workload에 access key 사용 — role 대신. access key는 leak하기 쉽고 자동으로 회전되지 않으며 만료 기간이 없습니다.
  • role의 trust policy를 검토하지 않기: Principal이 너무 광범위합니다 (예: "AWS": "*"). 이렇게 하면 누구나 assume할 수 있습니다.
  • 민감한 작업(bucket 삭제, IAM policy 수정)에 MFA Condition을 사용하지 않기. "Bool": {"aws:MultiFactorAuthPresent": "true"}를 높은 위험 action에 추가하세요.
  • 많은 책임을 하나의 큰 policy에 섞기 (검토가 어려워집니다). resource/service별로 나누고 명확하게 이름을 붙이세요 (PaymentBucketReadWrite, MyPolicy1 같은 이름 피하기).

이런 경우 복잡한 IAM은 불필요: 개인 sandbox를 만들거나 새로운 서비스를 배우려고 할 때 처음부터 role/policy를 자세히 만드는 것은 학습 속도를 늦출 수 있습니다. 그럴 때는 기존 managed policy (AmazonS3ReadOnlyAccess)를 가진 임시 user를 만드는 것으로도 충분합니다. 끝나면 삭제하세요.

10. 결론

User는 수동 로그인이 필요한 사람을 위한 것이고, role은 workload와 임시 접근을 위한 것이고, policy는 allow/deny를 결정하는 JSON 규칙입니다. 평가 순서를 이해하면 (명시적 deny는 항상 이기고 implicit deny가 기본) 권한이 “이상하게” 작동할 때 빠르게 디버그할 수 있습니다. 최소 권한을 적용하되 빈 상태에서 시작하고 실제 로그를 기반으로 차근차근 추가하고, code의 role assumption 로직을 항상 테스트하세요 — 이것이 AWS 시스템을 안전하게 유지하면서도 유지보수 부담을 줄이는 방법입니다.

awsiamsecuritycloudjava