🆔

Azure DevOps と Azure をマネージド IDで連携する (1/2)

2023/11/18に公開
1

はじめに

今回は、マネージド ID を利用して、Azure DevOps と Azure コンポーネントを連携したいと思います。Azure DevOps には REST API が提供されており、下記のようにいくつかの認証方式がありますが、本記事では マネージド ID に焦点を当てていきたいと思います。
前編 Azure Functions 後編 Logic Apps としたいと思います。

方式 説明
Personal Access Tokens (PATs) 最も一般的な認証方法の1つです。Azure DevOps Servicesで独自のトークンを作成し、それを使用してAPIにアクセスします。これらのトークンは特定の権限と有効期限を持ち、安全にAPIを呼び出すために使用されます。
Basic Authentication 資格情報(ユーザー名とパスワード、またはPersonal Access Token)をHTTPヘッダーに埋め込む方法です。ただし、この方法は安全性の観点から推奨されません。使用する場合は、HTTPS経由でのみAPIを呼び出す必要があります。
Entra ID Integrated Authentication Azure DevOps ServicesとEntra IDを連携させることで、Entra IDの認証機能を使用してAPIにアクセスすることができます。
OAuth 2.0 Azure DevOps ServicesはOAuth 2.0をサポートしており、サードパーティのアプリケーションがユーザーの代わりにAzure DevOps Servicesにアクセスすることを許可します。
マネージド ID Azureのマネージド IDは、Azureリソースに自動的にアクセスするための資格情報を提供するサービスです。この方式を使用することで、Azure DevOps と他のAzureリソース間の認証やアクセス管理を自動化し、セキュリティを高めることができます。資格情報のローテーションや管理が不要になり、開発者や運用チームがセキュリティを維持しながら、簡単にリソースにアクセスできるようになります。

PAT や ServicePrincipal を利用した実装イメージは、前回の記事をご参照ください。
https://zenn.dev/yutakaosada/articles/1a61b2741e0644

なお、今回の検証についての Azure 側のコンポーネントは下記を利用します。

# コンポーネント 用途
1 Resource Groups Azure ソリューションの関連するリソースを保持するコンテナ
2 Managed Identity コンテナイメージの保管場所クラウドリソースへの安全なアクセスを自動化し管理するためのAzureのサービス
3 Azure Functions サーバーレスコンピューティング
4 Application Insights アプリケーションパフォーマンス監視
5 Storage Account データストレージソリューション

本記事の Azure Bicep や C#で実装した Azure Functions の ソースコードは、Gitに登録しています。

https://github.com/yutaka-art/AzDORestApiInspections

今回肝となる、マネージド ID の詳細については下記のドキュメントを参照ください。
https://learn.microsoft.com/ja-jp/entra/identity/managed-identities-azure-resources/overview

でははじめていきます。

アーキテクチャ構成

アーキテクチャ構成はこんな感じです。

Azure Functions を使用する場合、Storage Account はセットで必要ですが、既に別の Storage Account があればそれを利用しても問題ありません。また、Application Insights は 監視要件 が無ければ 省略可能です。

1. Azure コンポーネントの作成

各種コンポーネントを Azure CLI と Bicep を利用して作成していきます。

まずはリソースグループを作成します。

cli
az group create --name rg-zenn-39890c09bdcda2 --location japaneast

リソースグループが作成されるとAzure Portalではこんな感じに見えます

次に Bicep を使って、リソースグループの中に各種コンポーネントを作成していきます。

cli
az deployment group create --resource-group rg-zenn-39890c09bdcda2 --template-file deploy/main.bicep --parameters environmentName=zenn-39890c09bdcda2

main.bicep では、Log Analytics、Application Insights、Storage Account、Azure Managed Identity、App Service Plan、Azure Functions のリソースを定義します。
Bicep内で生成した、Application Insights のインストルメンタルキーや、Managed Identity の Clinet Id を、Functions の環境変数へ動的に設定しているところがポイントです。

main.bicep
main.bicep
param environmentName string = 'zenn-39890c09bdcda2'
param storageAccountName string = 'st${replace(environmentName, '-', '')}'
param logAnalyticsWorkspaceName string = 'logs-${environmentName}'
param appInsightsName string = 'appi-${environmentName}'
param hostingPlanName string = 'plan-${environmentName}'
param functionAppName string = 'func-${environmentName}'
param location string = resourceGroup().location
param managedIdName string = 'id-${environmentName}'

// Managed Identity
resource managedId 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = {
  name: managedIdName
  location: location
}

// LogAnalyticsWorkspace
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
      legacy: 0
      enableLogAccessUsingOnlyResourcePermissions: true
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

// Application Insights
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: { 
    Application_Type: 'web'
    WorkspaceResourceId:logAnalyticsWorkspace.id
  }
}

// Storage account
var strageSku = 'Standard_LRS'

resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = {
  name: storageAccountName
  location: location
  kind: 'Storage'
  sku:{
    name: strageSku
  }
}

// Function App Plan
resource hostingPlan  'Microsoft.Web/serverfarms@2021-03-01' = {
  name: hostingPlanName
  location: location
  sku: {
    name: 'EP1'
    tier: 'ElasticPremium'
    size: 'EP1'
  }
  properties: {
    reserved: true // for Linux
  }
}

// Function App
resource functionApp 'Microsoft.Web/sites@2021-03-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp,linux'
  properties: {
    serverFarmId: hostingPlan.id
    siteConfig: {
      appSettings: [
        {
          name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
          value: appInsights.properties.InstrumentationKey
        }
        {
          name: 'AzureWebJobsStorage'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'FUNCTIONS_EXTENSION_VERSION'
          value: '~4'
        }
        {
          name: 'FUNCTIONS_WORKER_RUNTIME'
          value: 'dotnet'
        }
        {
          name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'TenantId'
          value: '*'
        }
        {
          name: 'AzureDevOpsOrg'
          value: '*'
        }
        {
          name: 'ManagedIdentityClientId'
          value: managedId.properties.clientId
        }
      ]
      ftpsState: 'FtpsOnly'
      minTlsVersion: '1.2'
      cors: {
        allowedOrigins: [
          'https://portal.azure.com'
        ]
        supportCredentials: true
      }
    }
    httpsOnly: true
  }
}

作成されたリソースはこんな感じです。

各コンポーネント名称のプリフィックスは命名規約に従いましょう。
https://learn.microsoft.com/ja-jp/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations

2. マネージド ID より、Client IDを取得し、Azure Functions の環境変数へ設定

Azure Portal を開き、作成した マネージド ID のClient ID を取得します。
これをすることで、Azure Functions のソースコードの中で、Azure Functions はマネージド IDを特定することができます。

クライアントIDをメモしておきましょう。

続いて、クライアントID の情報を、Azure Functions リソース の 環境変数に設定します。
Azure Portal を開き、Azure Functions のページへ進みます。
「構成」をクリックし、「アプリケーション設定」タブの、「編集」をクリックし、「ManagedIdentityClientId」へメモした値を設定します。

続いて、「AzureDevOpsOrg」へは、対象の Azure DevOps Organaization の名称を設定します。

「TenantId」へは、Azure DevOps に紐づく、Azure のテナントID を設定します。

3. マネージド ID を Azure Functions へ設定

続いて、Functionsの ID より、マネージド ID をユーザ割り当てとして追加します。
(ID > ユーザ割り当て済み > ユーザー割り当てマネージド ID の追加 > より、IDを検索し、追加)

4. マネージド ID を Azure DevOps へ設定

続いて、マネージド ID を Azure DevOps の Organaization Users へ追加します。
Add users をクリックし、マネージド ID を指定し、追加します。

プログラムから制御したいプロジェクトを選択し、DevOpsのロールに則って設定します。

正常に追加されていることを確認します。

これをすることで、Azure リソース を経由したプログラムの中で、Azure DevOps へのアクセスが可能となります。

5. Azure Functions の実装

C# でManaged Identity を利用するサンプルを書いてみました。
Functions は処理のトリガーとして Timer, Event Grid, HTTP 3種類 の設定できますが、今回は HTTP とします。

このサンプルは、Azure Managed Identity を利用して Azure DevOps に接続する方法を示しています。このコードは、Microsoft.TeamFoundationServer.Client および Microsoft.VisualStudio.Services.WebApi ライブラリを使用して Azure DevOps のWork Itemを操作するための接続を確立しています。Environment.GetEnvironmentVariable を使って、環境変数から TenantId、ManagedIdentityClientId などの設定を取得し、これらを使用して Azure Managed Identity を介してトークンを取得します。このトークンは、Azure DevOps Services の REST API に対する認証に使用しています。

ManagedIdentityInspection
Reporting.cs
using Azure.Core;
using Azure.Identity;
using ManagedIdentityInspection.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.VisualStudio.Services.Client;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using JsonPatchDocument = Microsoft.VisualStudio.Services.WebApi.Patch.Json.JsonPatchDocument;

namespace ManagedIdentityInspection
{
    /// <summary>
    /// ManagedIdentityInspection 検証クラス
    /// </summary>
    public static class Reporting
    {
        /// <summary>OrganizationUrl</summary>
        public static readonly string OrgUrl = $"https://dev.azure.com/{Environment.GetEnvironmentVariable("AzureDevOpsOrg")}";

        /// <summary>UserAgent</summary>
        public static readonly List<ProductInfoHeaderValue> AppUserAgent = new()
        {
            new ProductInfoHeaderValue("Identity.ManagedIdentitySamples", "3.0"),
            new ProductInfoHeaderValue("(AzureDevOpsWithManagedIdentityInspection)")
        };

        /// <summary>Azure DevOps のサービスプリンシパル認証の際に必要なスコープを示す固定の文字列</summary>
        public const string AzureDevOpsAppScope = "499b84ac-1321-427f-aa17-267ca6975798/.default";

        /// <summary>
        /// Sample Functions
        /// </summary>
        /// <param name="req"></param>
        /// <param name="log"></param>
        /// <returns></returns>
        [FunctionName("Reporting")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            // Start Log
            log.LogInformation("C# HTTP trigger function processed a request.");

            // リクエストボディよりパラメータを取得
            var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var receiveModel = JsonConvert.DeserializeObject<ReceiveModel>(requestBody);

            // ManagedIdentityのTenantId,ManagedIdentityClientIdを利用しアクセストークンを取得
            var credentials = new DefaultAzureCredential(
                    new DefaultAzureCredentialOptions
                    {
                        TenantId = Environment.GetEnvironmentVariable("TenantId"),
                        ManagedIdentityClientId = Environment.GetEnvironmentVariable("ManagedIdentityClientId"),
                        ExcludeEnvironmentCredential = true
                    });
            var accessToken = await credentials.GetTokenAsync(new TokenRequestContext(
                new[] { AzureDevOpsAppScope }), CancellationToken.None);

            var vssAadToken = new VssAadToken("Bearer", accessToken.Token);
            // 取得したトークンでクレデンシャルを確立
            var vssAadCredentials = new VssAadCredential(vssAadToken);
            var settings = VssClientHttpRequestSettings.Default.Clone();
            settings.UserAgent = AppUserAgent;

            // Connection ※Https経由なのでAgentを付与
            var vssConnection = new VssConnection(new Uri(OrgUrl), vssAadCredentials, settings);

            // AzureDevOpsRestAPIの準備
            var workItemTrackingHttpClient = vssConnection.GetClient<WorkItemTrackingHttpClient>();
            JsonPatchDocument patchDocument = new JsonPatchDocument
            {
                new JsonPatchOperation()
                {
                    Operation = Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Add,
                    Path = "/fields/System.Title",
                    Value = receiveModel.title
                }
            };

            try
            {
                // AzureDevOpsRestAPIを発行
                var result = await workItemTrackingHttpClient.CreateWorkItemAsync(patchDocument, receiveModel.project, "task");
                var responseMessage = $"Work item '{result.Id}' created.";

                return new OkObjectResult(responseMessage);
            }
            catch (Exception ex)
            {
                log.LogError(ex, ex.ToString());
                throw;
            }
        }
    }
}
Models/ReceiveModel.cs
using Newtonsoft.Json;

namespace ManagedIdentityInspection.Models
{
    /// <summary>
    /// RequestBodyモデル
    /// </summary>
    public class ReceiveModel
    {
        [JsonProperty("title")]
        public string title { get; set; } = string.Empty;
        [JsonProperty("project")]
        public string project { get; set; } = string.Empty;
    }
}

6. Azure Functions を Azure へ発行

Functionsのデプロイは、Visual Studio より、発行機能を利用するのが手軽ですので、今回はその方法で実行していきます。

Visual Studio を開き、プロジェクトファイルを右クリックし、発行をクリックします。
ターゲットで Azure を選択し、次へ をクリックします。

Azure Function App (Linux) を選択し、次へ をクリックします。

対象のサブスクリプションを選択し、冒頭で作成したリソースグループ内の Azure Functions を指定します。

発行 (pubxml ファイルが生成されます) を選択し、完了 をクリックします。

発行プロファイル '..\AzDORestApiInspections\ManagedIdentityInspection\Properties\PublishProfiles\func-zenn-39890c09bdcda2 - Zip Deploy.pubxml' が作成されました。 を確認し、閉じるをクリックします。

再度 発行 ボタンをクリックします。

続いて、Azure Portal を開き、Azure Functions のページへ進みます。
概要 から、関数が表示されることを確認します。

7. Azure Functions を 検証する

デプロイした Azure Functions を起動し、Azure DevOps に対して、WorkItem が生成されるかを確認していきましょう。

概要から、関数をクリックします。

コードとテスト > テストと実行 > ボディ へ 対象の project と 任意の title を設定し、実行をクリックします。

正常に実行されると、下記のように Workitem ID が返却されます。

Azure DevOps も確認してみましょう。

生成されているようです。また、更新者がマネージド ID となっていることから、関数による実行を裏付けることができます。

まとめ

この検証を通じて、Azure Managed Identity を活用することによる恩恵を明らかにできたと思います。
Managed Identity を用いることにより、認証情報の明示的な管理が不要(有効期限の更新が不要)となり、必要な情報の提供のみで安全に認証を確立することが可能となります。これは、PAT や ServicePrincipal の利用と比べるとメリットが大きく、運用の観点では マネージド ID の利用に軍配が上がると感じました。
また蛇足ですがそれに加えて、アクセストークンの取得も不要だと認識していましたが、今回の検証で必要だということが明らかにできて良かったです。
なぜ、マネージド ID でアクセストークンが不要だと誤解していたのか については、次回公開予定の Logic Apps による マネージド ID の利用編で触れたいと思います。

References

https://learn.microsoft.com/ja-jp/azure/devops/integrate/get-started/authentication/service-principal-managed-identity?view=azure-devops

GitHubで編集を提案

Discussion

yutakaosadayutakaosada

20231119 Update Bicepファイルを修正
Azure Functions生成時に下記の処理を追加

  • CORSのAccess-Control-Allow-Credentialsを有効化
  • 環境変数へ、Managed Identity Client ID を動的に設定