최근 AWS 환경을 운영하면서 장기적인 액세스 키를 사용하지 않고 보다 안전한 방식으로 AWS 리소스에 접근할 수 있는 방법을 고민했어요.

저는 이러한 문제를 AWS SSO를 통해 굉장히 편하고 쉽게 해결한 경험이 있어요. 그래서 회사에 입사하자 마자 추진했던게 AWS SSO 사용이었어요.

그런데 외부 제약 사항으로 인해 AWS SSO를 사용할 수 없었고, 실제로 우리와 같은 환경을 많은 회사가 겪고 있을거라는 생각을 했어요.

이러한 환경에서 어떻게 AWS API 호출을 보다 안전하게 접근할 수 있게 했는지 소개합니다.


해결하고자 한 문제

최초 회사에 입사했을 때는 AWS 환경의 자원에 접근하기 위해 Long Term Access Key를 발급하고 이를 통해 AWS API를 호출하는 형태였고, 다음의 문제를 안고 있었어요.

  • 액세스 키를 발급하고 관리하는 것은 보안상 취약점이 될 수 있다.
  • 키가 유출되면 언제든 AWS 리소스가 위험에 노출될 수 있다.
  • 누군가 키를 지워버리면 그 순간 모든 것이 중단된다.
  • 키의 주기적 교체 및 관리에 대한 부담이 크다.

이러한 문제를 해결하고자 처음에는 애플리케이션 단위로 IAM Role 및 User를 생성하고 Long Term Access key를 발급했는데 이는 최소 권한 원칙은 지킬 수 있었지만 기존의 문제점들은 해결하지 못했어요.

어떻게 할지 고민하다가 “AWS SSO만 사용할 수 있으면 쉽게 해결될텐데…”라는 생각을 했고, 이는 “💡 AWS SSO와 비슷한 사용자 경험을 줄 수 있는 시스템을 만들면 되지 않을까?” 라는 생각을 했고, 시스템을 만들기 시작했어요.


AWS SSO 그리고 AWS STS

AWS Single SIgn On

AWS SSO는 AWS 인증 센터를 통해 관리되는 여러 AWS 계정에 대해 제공하는 중앙 집중식 로그인 솔루션이에요. 단순히 AWS 콘솔에 로그인 할 수 있을 뿐만 아니라 애플리케이션에서 사용하기 위한 인증/인가도 수행할 수 있어요.

이를 통해 사용자는 회사에서 허용 및 구성한 AWS 계정, AWS 콘솔, AWS와 융합된 여러 애플리케이션에 접근할 수 있어요.

AWS 인증 센터를 기준으로 구성 조직은 AWS Identity Center를 통해 그룹 및 사용자 설정, MFA 등 로그인 관련 정책 그리고 권한 정책을 설정 할 수 있고, 회사와 사용자는 별도의 Long Term Access Key를 발급 관리할 필요가 없어요.

AWS Security Token Service

AWS STS는 짧은 수명을 가진 임시 보안 자격증명(AccessKey, SecretKey, SessionToken)을 발급해 주는 서비스에요.

호출하는 주체의 자격 증명과 조건에 따라 미리 작성되어 있는 IAM Role에 대해 사용할 수 있는 임시 토큰을 발급해요.

AWS STS를 통해 발급된 토큰은 기한이 지나면 자동 폐기되므로 장기 키를 저장·관리하는 위험을 크게 줄여 주고, 이는 보안과 관리 측면에서도 유리해요.

AWS SSO와 AWS STS의 관계

AWS SSO 콘솔에서 사용자가 로그인하면 내부적으로 STS의 AssumeRole 등을 호출하여 임시 자격 증명을 발급해요.

사용자는 이 임시 자격 증명을 바탕으로 AWS 콘솔, CLI, SDK 등에서 STS가 발급한 토큰을 로컬에 저장하고 활용할 수 있어요.

이 구조 덕분에 사용자는 긴 키를 직접 다루지 않고도, 필요한 권한을 안전하게 AWS API 호출에 사용할 수 있게 돼요.


고민한 포인트

해당 시스템을 만들때 가장 크게 고민한 포인트는 다음과 같았어요.

어떻게 해야 개발자의 귀찮음을 최소화할까?

  • 실제로 개발자들은 AWS API를 사용하는 애플리케이션을 개발하기 위해 AWS Credential을 어떻게 구성하고 관리하는지는 그들이 궁금해 하지 않는 한 알 필요 없다고 생각했어요. 그리고 이를 설명해줌으로써 개발자들의 보안 환경에 반감이 들 수 있지 않을까? 하는 걱정도 있었어요.
  • 토큰 만료로 인해 개발자의 애플리케이션 개발 싸이클이 중간에 끊기지 않고, 의도치 않은 버그에 빠지지 않도록 하고자 했어요. (코드는 정상인데 토큰이 만료되어서 에러가 발생하는 등)
  • 보안이 엄격해질수록 사람의 귀찮음이 늘어나기 때문에 제가 제공하는 환경으로 인해 개발자의 업무 효율성이 떨어지는 것을 지양하고 이러한 작업을 최소화 하고자 했어요.

AWS의 Role Chaining에 따른 STS 지속 시간 문제

  • 해당 환경을 제공하는 시스템을 ECS 부터 Lambda까지 고민했어요. ECS나 Lambda의 경우 최소의 비용으로 시스템을 구성할 수 있을거라 생각했어요.
  • 하루 8시간 일한다면, 적어도 8번 해당 시스템에 접근하여 STS를 획득해야 하는 귀찮음이 발생해요…ㅠㅠEC2, ECS, Lambda 등에 시스템을 배포한다면 다음의 시퀀스 다이어그램과 같은 환경이 될거라 예상했어요. 이는 AWS 정책상 임시 자격을 가진 주체가 또 다른 역할을 Assume할 때, 그 유효 시간은 최대 “1시간”으로 한다는 문제점이 생겼어요. 이는 앞서 고민한 개발자의 귀찮음을 최소화 하자는 것에 위배될거라 생각했어요.

이를 해결하기 위한 웹 대시보드 서버의 코드는 다음과 같아요. EKS의 IdP Federation을 활용하여 WebIdentity를 Assume 하는 것이 주요 내용이에요.

// roleArn 사용자에 대한 Assume Role 대상이에요. 이 역할에 필요한 정책이 할당되어 있어요
func assumeWithWebIdentity(ctx context.Context, roleArn, sessionName string, duration int32) (*sts.AssumeRoleWithWebIdentityOutput, error) {

	tokenFile := os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
	data, err := os.ReadFile(tokenFile)
	if err != nil {
		return nil, fmt.Errorf("read token file: %w", err)
	}
	
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
	    return nil, err
	}
	client := sts.NewFromConfig(cfg)
	
	return client.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityInput{
	    RoleArn:          aws.String(roleArn),
	    RoleSessionName:  aws.String(sessionName),
	    WebIdentityToken: aws.String(string(data)),
	    DurationSeconds:  aws.Int32(duration),
	})
}

EKS를 사용하고 있기 때문에, 이를 활용하여 시스템을 구성하자.

  • 우리 회사는 어차피 EKS를 사용하고 있기 때문에 남는 자원으로 애플리케이션을 돌릴 수 있을거라 생각했고, IRSA를 활용하여 사용자 별 권한을 쉽게 융합할 수 있을거라 생각했어요.
  • 특히 EKS의 서비스 계정은 WebIdentityFederation을 통해서 “신뢰받는 외부 자격 제공자”로 취급 받게되고, 이는 Role Chaining이 아니라 서비스 계정의 직접적인 Assume Role이 가능하다는 장점이 있어요.

AWS SSO와 같이 사용자에 대한 인증이 가능해야 한다.

  • 저는 AWS SSO를 사용하면서 굉장히 편했던 경험이 있어요. 그래서 회사에 입사하자 마자 추진했던게 AWS SSO 사용이었어요. 그런데 외부 제약 사항으로 인해 AWS SSO를 사용할 수 없었고, 많은 회사가 우리와 같은 환경일거라 생각했어요.
    • 이는 해당 글을 작성하는 계기가 되었답니다.
  • AWS SSO는 사용자의 계정과 MFA 인증을 통해 임시 자격을 할당하는데, 이처럼 해당 개발자만 알고 있는 정보를 바탕으로 사용자 인증이 가능하게끔 했어요.

💡 AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithSAML

AssumeRole

Assume Role은 IAM에 이미 발급된 자격 증명을 다른 IAM Role로 전환하여 사용할 때 이용되는 기능이에요. 이름에서 볼 수 있듯이 다른 Role로 가장하여 해당 Role의 권한을 그대로 사용할 수 있게 됩니다.

AssumeRole을 통해 권한을 위임하기 위해서 호출하는 Role, User가 iam:PassRole 권한을 가져야 대상 Role을 활용할 수 있어요.

보통 다계정 환경에서의 Role 가정, Broker Role이 필요할 때 주로 사용됩니다.

AssumeRole을 중복 호출할 경우 Role Chaining이 발생할 수 있어요

  • AssumeRole → Assume Role은 STS를 통해 발급한 자격 증명을 바탕으로 또 다시 STS를 이용하기 때문에 Role Chaining이 발생해요.
  • 이러한 Role Chaining은 토큰 최대 사용 시간 제한 등 사용에 제약이 있어요.

AssumeRoleWithWebIdentity

AssumeRole은 IAM의 자격 증명을 바탕으로 동작한다면, AssumeRoleWithWebIdentity는 OIDC를 바탕으로 동작해요.

  • AWS STS가 아닌 Open ID Connect를 통해 발급한 JWT를 사용하기 때문에 Role Chaining이 발생하지 않아요.

대표적으로 AWS의 EKS도 OIDC를 이용하여 쿠버네티스의 서비스 계정을 IAM과 상호작용 하도록 구성하여 사용할 수 있어요.

AssumeRoleWithSAML

Security Assertion Markup Language은 XML 기반의 인증·권한 부여 프레임워크로, 주로 기업 SSO(Single Sign-On) 환경에서 사용되는데요,

AWS도 이를 활용하여 제휴 ID 제공자에서 발급된 정보를 바탕으로 사용자를 인증할 수 있도록 지원해요.

AssumeRoleWithWebIdentity가 OIDC에서 발급된 JWT를 이용한다면, AssumeRoleWithSAML은 SAML Assertion을 이용해요.


시스템 구성

최종적으로 고안한 시스템의 시퀀스 다이어그램은 다음과 같아요.

EKS에 배포하는 시스템들은 IRSA 구성을 통해 AWS 자원에 접근할 수 있는데요, 이 경우 일반적으로 default profile을 통해 IRSA에 연결된 IAM Role의 권한을 사용할 수 있어요.

default role을 사용함으로써 개발자가 stage별 코드를 별도로 운영하지 않아도 되도록 구성했어요.

  • Golang 기준 변경전 코드
// 인터페이스 선언
type AwsConfig interface {
	Config(ctx context.Context, roleArn string) (*aws.Config, error)
}

// EKS 환경에서 사용하기 위한 Default Credential Config 획득 코드
type ProductionAwsConfig struct{}

func NewProductionAwsConfig() AwsConfig {
	return &ProductionAwsConfig{}
}

func (l *ProductionAwsConfig) Config(ctx context.Context, roleArn string) (*aws.Config, error) {

	cfg, err := config.LoadDefaultConfig(
		ctx,
		config.WithRegion("ap-northeast-2"),
	)
	if err != nil {

		return nil, err
	}

	return &cfg, nil
}

// 로컬 환경에서 사용하기 위한 특정 Role Credential Config 획득 코드
type LocalAwsConfig struct{}

func NewLocalAwsConfig() AwsConfig {
	return &LocalAwsConfig{}
}

func (l *LocalAwsConfig) Config(ctx context.Context, roleArn string) (*aws.Config, error) {

	roleSessionName := os.Getenv("LOCAL_ROLE_SESSION_NAME")
	if roleSessionName == "" {
		
		return nil, fmt.Errorf("fail to get env [LOCAL_ROLE_SESSION_NAME]")
	}

	baseCfg, err := config.LoadDefaultConfig(
		ctx,
		config.WithRegion("ap-northeast-2"),
		config.WithSharedConfigProfile("assume-role-sa"),
	)
	if err != nil {

		return nil, err
	}

	stsClient := sts.NewFromConfig(baseCfg)
	provider := stscreds.NewAssumeRoleProvider(
		stsClient,
		roleArn,
		func(o *stscreds.AssumeRoleOptions) {
			o.Duration = 900 * time.Second
			o.RoleSessionName = roleSessionName
		},
	)

	cachedCreds := aws.NewCredentialsCache(provider)
	clientCfg := baseCfg
	clientCfg.Credentials = cachedCreds

	return &clientCfg, nil
}

  • 변경후 코드
type AwsConfig interface {
	Config(ctx context.Context, roleArn string) (*aws.Config, error)
}

type ApplicationAwsConfig struct{}

func NewAwsConfig() AwsConfig {
	return &ApplicationAwsConfig{}
}

func (l *ApplicationAwsConfig) Config(ctx context.Context, roleArn string) (*aws.Config, error) {

	cfg, err := config.LoadDefaultConfig(
		ctx,
		config.WithRegion("ap-northeast-2"),
	)
	if err != nil {

		return nil, err
	}

	return &cfg, nil
}

또한 MFA 인증을 통해 자신의 역할만 사용할 수 있게 했는데, 이는 Golang의 TOTP 라이브러리를 활용하여 AWS 2FA 인증과 동일한 솔루션으로 구성하게 했어요.

  • MFA가 많아지면 많아질수록 이 또한 실 사용자에겐 꽤 고통스러운 일이에요..ㅠㅠ

실제로 동작하는 웹 대시보드의 경우 다음과 같이 생겼어요.


이와 같은 Zero Trust Web Dashboard를 구성함으로써 조금 더 안전하고 편한 사내 개발 환경을 만드는데 기여할 수 있었어요.

이 글이 저와 비슷한 고민을 하는 분들께 도움이 되길 바라며, 글을 마칩니다!

'DevOps > Cloud' 카테고리의 다른 글

GCP Multi Project에 대한 IRSA 구성하기  (0) 2024.09.03
GKE와 Cloud Load Balancing 연결하기  (1) 2024.07.15

+ Recent posts