藤田優貴
2025.6.30
yamoryプロダクト開発グループの藤田です。
先日yamoryではクラウドアセットスキャン機能をMicrosoft Azure向けにリリースしました。この記事では、その裏側で使われている技術を紹介します。
yamoryではメインのインフラとしてAWSを利用しています。AWS向けのクラウドアセットスキャンではクロスアカウントのAssume Roleを利用することで、シークレットを設定しなくてもお客様環境における権限をいただいて、セキュアにスキャンを実行できます。しかし、Azureについては同じ技術を利用することができません。
そこでこの記事では、まず前半としてAzureからAWS環境に向けてスキャン結果を送信する際に、シークレットを使わずにアクセスする方法について説明していきます。なお、後日後半としてAWSからAzureに対してスキャンリクエストを送信する際に、シークレットを利用しない方法について説明予定です。
まず、AWS Marketplaceとは何かについて簡単にご説明します。AWS Marketplaceは、AWSを利用しているユーザーなら誰でも利用できる、サードパーティのソフトウェアやサービスを容易に検索、購入、デプロイできるオンラインストアです。
yamoryにとって、AWS Marketplaceの導入は、商流の拡大という大きなメリットをもたらします。AWSを利用している多くの企業様に対して、yamoryをより直接的に提供できるようになります。また、AWSの請求システムを通じて契約や決済が効率化されることで、ユーザー側の運用負荷の軽減にも繋がります。
Azure Container Appで動いているアプリケーションから、AWS S3にファイルをPUTするシステムを想定します。
通常であれば、AWSから発行したIAM Userのシークレットを用いて認証を行いますが、今回はシークレットを利用せずに、Azure上のContainer Appに紐付けてあるManaged IdentityからAWSのIAM Roleに対してAssume Roleを行って認証情報を得ます。シークレットを使う際には、次のようなデメリットが存在するためです。
ここでAssume Roleを利用すると、次のようなメリットが得られます。
こうしたメリットを踏まえ、IAM Userのシークレットを使うよりも手順が複雑となりますが、実装方法を説明していきます。
実装には、Azure側とAWS側、双方の準備が必要です。順を追って説明していきます。また、サンプルコードは全てGo言語で書かれていますが、他の言語向けSDKを使っても同じAPIを呼び出せば同様の実装ができるはずです。インフラ定義には、Azure側はBicepを、AWS側はCDK(TypeScript)を利用します。
Azure側にはContainer Appと、それに紐付けるUser Assigned Managed Identityが必要です。また、Assume Roleを受け入れるためEntra ID Applicationの登録も必要です。なお設定に必要な値だけ抜粋して掲載していますので、その他動作に必要なプロパティについては別途指定が必要です。
resource assumeRoleManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: 'assume-role-identity'
}
var uniqueName = 'assume-role-entra-id-app'
resource assumeRoleApp 'Microsoft.Graph/applications@v1.0' = {
displayName: 'assume-role-entraid-app'
uniqueName: uniqueName
// AWS で assume role を受け入れるための identifier url を生成
identifierUris: [
'api://${uniqueName}'
]
appRoles: [
// 権限を受け渡せるようにアプリロールを作成
{
id: guid(uniqueName)
allowedMemberTypes: ['User', 'Application']
description: 'This app role will use AWS STS AssumeRoleWithWebIdentity API to obtain AWS temporary credentials.'
displayName: 'AssumeRoleAppRole'
isEnabled: true
value: 'AssumeRoleWithWebIdentity'
}
]
}
resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = {
appId: assumeRoleApp.appId
}
// サービスプリンシパルとアプリロールの紐付け
resource appRoleAssignment 'Microsoft.Graph/appRoleAssignedTo@v1.0' = {
appRoleId: assumeRoleApp.appRoles[0].id
principalId: assumeRoleManagedIdentity.properties.principalId
resourceId: servicePrincipal.id
}
resource containerApp 'Microsoft.App/jobs@2024-02-02-preview' = {
name: 'containerApp'
// managed identity の紐付け
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${assumeRoleManagedIdentity.id}': {}
}
}
properties: {
template: {
containers: [
{
// Assume Role に必要な情報を環境変数経由で受け渡す
env: [
{
name: 'AZURE_APP_ID_URI'
value: 'api://${uniqueName}'
}
{
name: 'AZURE_MANAGED_ID_CLIENT_ID'
value: assumeRoleManagedIdentity.properties.clientId
}
]
}
]
}
}
}
AWS側には権限を受け渡すIAM Roleと、OpenIdConnectProviderの作成が必要です。ここで作成したロールをAzure側から利用します。
// 接続先 Azure EntraID のテナントID
const azureTenantId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
// Bicep で定義した UniqueName と同一の値を利用
const azureAppUniqueName = 'assume-role-entra-id-app';
const azureAppIdUrl = 'api://${azureAppUniqueName}';
// 上記で作成した assumeRoleManagedIdentity の ObjectId
const azureManagedIdObjectId = 'yyyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy';
const provider = new OpenIdConnectProvider(this, `${projectName}-azure-assume-role-provider`, {
url: `https://sts.windows.net/${azureTenantId}/`,
clientIds: [azureAppIdUrl],
});
const condition: Condition = {};
const audience = `sts.windows.net/${azureTenantId}/:aud`;
const subject = `sts.windows.net/${azureTenantId}/:sub`;
condition[audience] = azureAppIdUrl;
condition[subject] = azureManagedIdObjectId;
const role = new Role(this, `${projectName}-azure-assume-role`, {
roleName: `${projectName}-azure-role-to-assume`,
assumedBy: new OpenIdConnectPrincipal(provider, {
StringEquals: condition,
})
});
role.applyRemovalPolicy(RemovalPolicy.DESTROY);
Azure Container Appにデプロイするプログラムを作っていきます。まずはAzure Container Appに紐付けてあるManaged IdentityからAWS OpenIdConnectProvider(以下OIDC Provider)に接続するためのトークンを取得する部分を作っていきます。
Managed Identityを持ったContainer Appは、環境変数を使ってManaged Identityに関する情報を引き出すREST APIエンドポイントを取得できます(1)。このエンドポイントに対し、OIDC Providerとの接続に必要な認証情報を問い合わせます。audienceにはIAM Roleを作成するときに指定したazureAppIdUrlを、client_idには環境変数に格納したAZURE_MANAGED_ID_CLIENT_IDを渡します(2)。この状態でリクエストを送信すると、AzureAccessTokenを得ることができます(3)。
func FetchAzureAccessToken(_ context.Context, audience string, managedIdClientId string) (*AzureAccessToken, error) {
// (1)
endpoint := os.Getenv("IDENTITY_ENDPOINT")
header := os.Getenv("IDENTITY_HEADER")
url, err := netUrl.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse endpoint: %w", err)
}
// (2)
query := url.Query()
query.Set("api-version", "2019-08-01")
query.Set("resource", audience)
query.Set("client_id", managedIdClientId)
url.RawQuery = query.Encode()
request, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
request.Header.Set("X-IDENTITY-HEADER", header)
client := new(http.Client)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// (3)
var azureAccessToken AzureAccessToken
err = json.Unmarshal(body, &azureAccessToken)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
}
return &azureAccessToken, nil
}
ここで得たAzureAccessTokenを利用して、AWS STSを使ったassume roleを実行します。 WebIdentityTokenContentはGetIdentityToken() ([]byte, error)を実装している必要があるので、Azureから取得したトークンをブリッジできるように定義を先に書いておきます。
type WebIdentityTokenContent string
var _ stscreds.IdentityTokenRetriever = WebIdentityTokenContent("")
func (f WebIdentityTokenContent) GetIdentityToken() ([]byte, error) {
return []byte(f), nil
}
先に通常通りstsClientを生成したら、NewWebIdentityRoleProviderメソッドを使ってAssume Roleを行います。Assume Role先のロール名、先ほど取得したトークン、オプションの設定を書き込んで呼び出すとAssume Roleが行われ、アクセストークンが得られます。
ここでAWS Go SDK限定ではありますが、認証キャッシュを持つことができます。長時間作業を行うような場合に、認証情報を自動的にリフレッシュしてくれます。NewCredentialsCacheを呼び出すことで設定できるので、使っておくと便利です(2)。
func ConfigureAWSWithWebIdentity(_ context.Context, webIdentityToken WebIdentityTokenContent, roleArn string) (aws.Config, error) {
var cfg aws.Config
var err error
cfg, err = config.LoadDefaultConfig(context.Background(), config.WithRegion("ap-northeast-1"))
if err != nil {
return cfg, fmt.Errorf("failed to loading default config: %w", err)
}
stsClient := sts.NewFromConfig(cfg)
// (1)
provider := stscreds.NewWebIdentityRoleProvider(stsClient, roleArn, webIdentityToken, func(o *stscreds.WebIdentityRoleOptions) {
o.RoleSessionName = "azure-assume-role-session"
o.Duration = 1 * time.Hour
})
// (2)
cfg.Credentials = aws.NewCredentialsCache(provider, func(options *aws.CredentialsCacheOptions) {
options.ExpiryWindow = 5 * time.Minute
options.ExpiryWindowJitterFrac = 0.5
})
if _, err := cfg.Credentials.Retrieve(context.Background()); err != nil {
return cfg, fmt.Errorf("error retrieving credentials: %w", err)
}
return cfg, nil
}
あとはここで返したaws.Configの値を各種クライアントに引き渡せば、シークレットなしでAssume Roleを実施した状態で安全にAWS上のリソースがAzureから扱うことができます。
ここまで、具体的なインフラ定義とアプリケーションコードを示しながら、Azureからシークレットを使わずかつセキュアにAWS上のリソースを利用する方法をご紹介しました。シークレットを用いる方法は簡便ではありますが、漏洩時などの手間を考えると、最初から持たないことに越したことはありません。これを機に、皆さんの実装もシークレットレスに切り替えてみてはいかがでしょうか。
© Assured, Inc.