Azure Functions ( Node.js ) から Azure Resource Graphの実行結果を取得したメモ

# 概要
Node.jsを使ってAzure Resource Graphの実行結果を取得した

[この時点のソース](https://github.com/hibohiboo/async-ttrpg/blob/f187e2e6a10bbae1be695ab2efb75004dd845bee/apps/api/src/routes/sdkTest.ts)

# 環境

ローカル

- Windows 11
- Core Tools Version: 4.0.7030 Commit hash: N/A +bb4c949899cd5659d6bfe8b92cc923453a2e8f88 (32-bit)
Function Runtime Version: 4.1037.0.23568

Functions

- オペレーティング システム: Linux
- App Service プラン: JapanEastLinuxDynamicPlan (Y1: 0)
- ランタイム バージョン: 4.1038.400.1
- Node.js バージョン: Node.js 20 LTS

# ポータルでクエリの確認

```
resources
| where type == 'microsoft.storage/storageaccounts'
| where resourceGroup  == 'async-ttrpg'
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/98c4d8c9-20a1-455f-a8fb-017e67a5e95e.png)

2件取得できた。

# 組み込みロールの確認

[Azure 組み込みロール](https://learn.microsoft.com/ja-jp/azure/role-based-access-control/built-in-roles#general)

組み込みのロール|説明|ID
--|--|--
Reader|すべてのリソースを表示しますが、変更を加えることはできません。|acdd72a7-3385-48ef-bd42-f606fba81ae7

日本語のポータルから見ると「Reader」が「閲覧者」と訳されている。
わかりにくい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/402dbfb3-2e13-4956-af86-f76ad5c73fa3.png)

**~~ローカル実行用に組み込みロールの設定~~**

自分で作成したリソースグループだと共同編集者権限を持っているのでこの作業は不要

<details>

基本的なやりかたは[Azure Functionsをローカル実行してマネージドIDを使った認証でBlobStorageにアクセスしたメモ][*1]と同様。選ぶロールが異なる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/079a9bf1-0a66-4f1a-999a-190c580cf5bd.png)

</details>


# ソースコード

パッケージのインストール。@azure/arm-resourcegraphのlatestの4.2.1だとNodeのfetchのエラーが出たのでalpha版v5を選ぶ。

```
npm i @azure/arm-resourcegraph@5.0.0-alpha.20250506.1 @azure/identity
```


```ts
import { ResourceGraphClient } from '@azure/arm-resourcegraph';
import { DefaultAzureCredential } from '@azure/identity';
import { Hono } from 'hono';

const app = new Hono().post('/', async (c) => {
  const creds = new DefaultAzureCredential();

  const client = new ResourceGraphClient(creds);
  const result = await client.resources({
    query: `
resources
| where type == 'microsoft.storage/storageaccounts'
| where resourceGroup  == 'async-ttrpg'
    `,
  });
  return c.json(result);
});
export default app;
```


<details><summary>サンプルコードの記法</summary><div>

[npm のサンプルコード][*2]の書きかたをすると型エラーとなるのでコメントで回避している。
型エラーとなっても動作はした。私の環境ではsubscriptionIdを入れても入れなくとも結果は変わらなかった。

```ts
import { ResourceGraphClient } from '@azure/arm-resourcegraph';
import { DefaultAzureCredential } from '@azure/identity';
import { Hono } from 'hono';

const subscriptionId = '00000000-0000-0000-0000-000000000000';
const app = new Hono().post('/', async (c) => {
  const creds = new DefaultAzureCredential();

  // @ts-expect-error subscriptionId is not a valid parameter
  const client = new ResourceGraphClient(creds, subscriptionId);
  const result = await client.resources({
    query: `
resources
| where type == 'microsoft.storage/storageaccounts'
| where resourceGroup  == 'async-ttrpg'
    `,
  });
  return c.json(result);
});
export default app;
```

</div></details>

# ローカル実行

ローカルで起動して問い合わせてみる。

```
post http://localhost:7071/api/sdk-test
```


# 実行結果

下記のような形で2件取れる。


```json
{
  "totalRecords": 2,
  "count": 2,
  "resultTruncated": "false",
  "data": {
    "0": {
      "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-resource-group/providers/Microsoft.Storage/storageAccounts/examplestorageaccount1",
      "name": "examplestorageaccount1",
      "type": "microsoft.storage/storageaccounts",
      "tenantId": "00000000-0000-0000-0000-000000000000",
      "kind": "StorageV2",
      "location": "eastasia",
      "resourceGroup": "example-resource-group",
      "subscriptionId": "00000000-0000-0000-0000-000000000000",
      "managedBy": "",
      "sku": {
        "name": "Standard_LRS",
        "tier": "Standard"
      },
      "plan": null,
      "properties": {
        "provisioningState": "Succeeded",
        "publicNetworkAccess": "Enabled",
        "encryption": {
          "keySource": "Microsoft.Storage",
          "services": {
            "blob": {
              "enabled": true,
              "lastEnabledTime": "2025-01-01T01:00:00.0000000Z",
              "keyType": "Account"
            },
            "file": {
              "enabled": true,
              "lastEnabledTime": "2025-01-01T01:00:00.0000000Z",
              "keyType": "Account"
            }
          }
        },
        "privateEndpointConnections": [],
        "supportsHttpsTrafficOnly": true,
        "allowBlobPublicAccess": true,
        "allowCrossTenantReplication": false,
        "minimumTlsVersion": "TLS1_2",
        "primaryEndpoints": {
          "blob": "https://examplestorageaccount1.blob.core.windows.net/",
          "file": "https://examplestorageaccount1.file.core.windows.net/",
          "table": "https://examplestorageaccount1.table.core.windows.net/",
          "queue": "https://examplestorageaccount1.queue.core.windows.net/",
          "web": "https://examplestorageaccount1.z11.web.core.windows.net/",
          "dfs": "https://examplestorageaccount1.dfs.core.windows.net/"
        },
        "statusOfPrimary": "available",
        "primaryLocation": "eastasia",
        "keyCreationTime": {
          "key2": "2025-01-01T01:00:00.0000000Z",
          "key1": "2025-01-01T01:00:00.0000000Z"
        },
        "creationTime": "2025-01-01T01:00:00.0000000Z",
        "networkAcls": {
          "virtualNetworkRules": [],
          "defaultAction": "Allow",
          "ipv6Rules": [],
          "ipRules": [
            {
              "value": "0.0.0.1",
              "action": "Allow"
            },
            {
              "value": "0.0.0.2",
              "action": "Allow"
            },
            {
              "value": "0.0.0.3",
              "action": "Allow"
            }
          ],
          "bypass": "AzureServices",
          "resourceAccessRules": []
        },
        "accessTier": "Hot"
      },
      "tags": {},
      "identity": null,
      "zones": null,
      "extendedLocation": null
    },
    "1": {
      "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-resource-group/providers/Microsoft.Storage/storageAccounts/examplestorageaccount2",
      "name": "examplestorageaccount2",
      "type": "microsoft.storage/storageaccounts",
      "tenantId": "00000000-0000-0000-0000-000000000000",
      "kind": "StorageV2",
      "location": "eastasia",
      "resourceGroup": "example-resource-group",
      "subscriptionId": "00000000-0000-0000-0000-000000000000",
      "managedBy": "",
      "sku": {
        "name": "Standard_LRS",
        "tier": "Standard"
      },
      "plan": null,
      "properties": {
        "provisioningState": "Succeeded",
        "encryption": {
          "keySource": "Microsoft.Storage",
          "services": {
            "blob": {
              "enabled": true,
              "lastEnabledTime": "2024-01-01T01:00:00.0000000Z",
              "keyType": "Account"
            },
            "file": {
              "enabled": true,
              "lastEnabledTime": "2024-01-01T01:00:00.0000000Z",
              "keyType": "Account"
            }
          }
        },
        "privateEndpointConnections": [],
        "supportsHttpsTrafficOnly": true,
        "allowBlobPublicAccess": false,
        "allowCrossTenantReplication": false,
        "minimumTlsVersion": "TLS1_0",
        "primaryEndpoints": {
          "blob": "https://examplestorageaccount2.blob.core.windows.net/",
          "file": "https://examplestorageaccount2.file.core.windows.net/",
          "table": "https://examplestorageaccount2.table.core.windows.net/",
          "queue": "https://examplestorageaccount2.queue.core.windows.net/",
          "web": "https://examplestorageaccount2.z11.web.core.windows.net/",
          "dfs": "https://examplestorageaccount2.dfs.core.windows.net/"
        },
        "statusOfPrimary": "available",
        "primaryLocation": "eastasia",
        "keyCreationTime": {
          "key2": "2024-01-01T01:00:00.0000000Z",
          "key1": "2024-01-01T01:00:00.0000000Z"
        },
        "creationTime": "2024-01-01T01:00:00.0000000Z",
        "networkAcls": {
          "virtualNetworkRules": [],
          "defaultAction": "Allow",
          "ipv6Rules": [],
          "ipRules": [],
          "bypass": "AzureServices"
        },
        "accessTier": "Hot"
      },
      "tags": {},
      "identity": null,
      "zones": null,
      "extendedLocation": null
    }
  },
  "facets": []
}
```

# 組み込みロールの設定
Functionsにロールの設定が不足していると下記エラーとなる。

```
Access is denied to the requested resource. The user might not have enough permission.
```

設定してみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/b3815169-19ec-480b-95ac-fe15e40d1eef.png)

設定が完了すると、エラーが発生しなくなった。

5分ほどまつとロール割り当てに下記のように表示されることを確認できる。（作成されたと通知されても、ここに反映されるまでにはラグがあった）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/fa802de0-ad50-407c-a49a-3952930913be.png)

# Bicepで設定

```bicep:infra/biceps/rbacToFunction.bicep
@description('Specifies the role definition ID used in the role assignment.')
param roleDefinitionID string

@description('Specifies the principal ID assigned to the role.')
param functionAppName string

resource functionApp 'Microsoft.Web/sites@2024-04-01' existing = {
  name: functionAppName
}

var roleAssignmentName= guid(functionAppName, roleDefinitionID, resourceGroup().id)
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: roleAssignmentName
  properties: {
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID)
    principalId: functionApp.identity.principalId
  }
}
```

```bash:infra/bin/rbac-to-function.bin
#!/bin/bash

BIN_DIR=$(cd $(dirname $0) && pwd)
BICEP_DIR=$(cd $BIN_DIR/../biceps && pwd)

# 環境変数読み込み
source $BIN_DIR/.env

# Reader ( 閲覧者 ) のロールを割り当てる
cd $BICEP_DIR && az deployment group create \
  --name frontendDeployment \
  --template-file rbacToFunction.bicep \
  --parameters roleDefinitionID=acdd72a7-3385-48ef-bd42-f606fba81ae7 \
  functionAppName=$APP_NAME \
  -g $RESOURCE_GROUP_NAME
```

```bash:infra/bin/.env
APP_NAME=hogehoge
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/88d4a440-0bfc-4365-a1d8-9c95ff44997b.png)

手動で行ったときに比べ、リソース名がリソースグループになったが、問題なく動いた。

# 参考
[npm @azure/arm-resourcegraph](https://www.npmjs.com/package/@azure/arm-resourcegraph) ... ` TypeError: Expected signal to be an instanceof AbortSignal`が発生。おそらく古くてNodeのfetchの仕様変更についていけていない
[npm @azure/arm-resourcegraph 5.0.0-alpha.20250506.1][*2] ... こちらだとAbortSignalは発生せず。
[@azure/arm-resourcegraph package](https://learn.microsoft.com/ja-jp/javascript/api/@azure/arm-resourcegraph/?view=azure-node-latest)
[@azure/arm-resourcegraph package ResourceGraphClient resources](https://learn.microsoft.com/ja-jp/javascript/api/@azure/arm-resourcegraph/resourcegraphclient?view=azure-node-latest#@azure-arm-resourcegraph-resourcegraphclient-resourcechanges-1)
[Azure Resource Graph (ARG) に馴染んでみる](https://qiita.com/task_woof/items/f3b513e745a052bccce0)
[訳あってMicrosoft Graph API調べてみた](https://qiita.com/massie_g/items/fe7540161aa4a5f86bf5)
[Resource Graph API で Azure のリソース情報を取得する](https://blog.nomupro.com/entry/2020/02/21/080000)
[Azure Resource Graph で Azure リソースの変更を検知して通知する](https://qiita.com/aktsmm/items/238aaafc8a489fb633ec)
[Azure Resource GraphでAzureリソースを可視化する！](https://zenn.dev/choeurtap/articles/c1e231cd806882)
[Azure Resource Graphで簡単にリソースを検索する方法](https://tech-lab.sios.jp/archives/42426)
[Azure Functionsをローカル実行してマネージドIDを使った認証でBlobStorageにアクセスしたメモ][*1]
[Azure FunctionsのマネージドIDを有効化してBlobStorageにアクセスしたメモ](https://qiita.com/hibohiboo/items/e3050de912c9e608cba0)
[Azure 組み込みロール](https://learn.microsoft.com/ja-jp/azure/role-based-access-control/built-in-roles)

[*1]:https://qiita.com/hibohiboo/items/b1efc4f8431d742b0e0f
[*2]:https://www.npmjs.com/package/@azure/arm-resourcegraph/v/5.0.0-alpha.20250506.1