適当おじさんの適当ブログ

技術のことやゲーム開発のことやゲームのことなど自由に雑多に書き連ねます

Microsoft Graph APIで「ライセンスが付与されている非アクティブなユーザー」を抽出する

Microsoft Graph API で「ライセンスが付与されている非アクティブなユーザー」を抽出したく色々調べた時のメモです。Azure ADは明示的に割り当て解除しない限り、ライセンスが割り当てられたままとなってしまいます。この仕様によって不要ライセンスの外し忘れが稀によく起こります。そこで、Graph API経由でアクティブでないユーザーを抽出して、ライセンスの棚卸等に役立てることにしました。

なお、グループを使ってライセンス割り当てをしている場合、アクセスレビュー で簡単に不要ユーザーを抽出し、ライセンス剥奪できます。アクセスレビューを使えばAPIを叩く必要もないので、お手軽に済ませたい場合はアクセスレビューを使うのが良いと思います。アクセスレビューを使うケースは別の記事にまとめてあります。

www.subarunari.com

Microsoft Graph APIのエンドポイントについて

Microsoft Graph APIのエンドポイントには安定板である v1.0 とβ版である beta が存在します。現在サインイン日時は beta でしか取得できないので、今回は beta を利用します。beta の利用は、突然の仕様変更が発生したとしても困らない場合にのみ留めましょう。バージョンの詳細については以下に詳しく書かれています。

docs.microsoft.com

ユーザーのサインイン日時を取得するAPI

ユーザーのサインイン日時は signInActivity です。このリソースはデフォルトでレスポンスに含まれておらず、$select=signInActivity を指定してリクエストを投げることでのみ取得できます。

リクエスト:
https://graph.microsoft.com/beta/users?$select=userPrincipalName,signInActivity

レスポンス:
{
  '@odata.context': 'https://graph.microsoft.com/beta/$metadata#users(userPrincipalName,signInActivity)',
  'value':[{
    'userPrincipalName': 'xxx',
    'id': 'xxx',
    'signInActivity': {
      'lastSignInDateTime': '2022-06-26T14:27:46Z',
      'lastSignInRequestId': 'xxx',
      'lastNonInteractiveSignInDateTime': '2022-06-26T14:27:53Z',
      'lastNonInteractiveSignInRequestId': 'xxx'
    }
  },
  ... ユーザー分繰り返し ...,
  ]
}

以下のいずれかに該当する場合 signInAcvtivity はレスポンスに含まれない仕様のようです。

  • サインイン日時が2020年4月以前
  • そもそも一回もサインインしたことがない

テストユーザーを作成して一度もサインインしていない状態でデータを取得してみると、以下のように signInActivity 自体が存在しないことを確認できます。

{
  '@odata.context': 'https://graph.microsoft.com/beta/$metadata#users(userPrincipalName,signInActivity)',
  'value':[{
    'userPrincipalName': 'xxx',
    'id': 'xxx'
  },
  ... ユーザー分繰り返し ...,
  ]
}

このパラメータは $filter クエリをサポートしているので、「特定の日時より前に最後にサインインした」という条件で絞りこみができます。ただし、残念ながら他のフィルタと併用ができないため今回は利用していません。

このリソースを使う際の注意点は user リソースの種類(beta) | Microsoft Docs の signInActivity に詳しく記載されています。現在はbeta版で将来的に諸々変わる可能性があるので、利用前に改めてチェックすることをオススメします。

ユーザーに付与されているライセンスを取得するAPI

ユーザーに付与されているライセンスは assignedLicenses に含まれています。userリソースにデフォルトで含まれていますが、以下の例はわかりやすくするために select で指定しています。

リクエスト:
https://graph.microsoft.com/beta/users?$select=userPrincipalName,assignedLicenses

レスポンス:
{
  '@odata.context': 'https://graph.microsoft.com/beta/$metadata#users(userPrincipalName,assignedLicenses),
  'value': [{
    'userPrincipalName': 'xxx',
    'assignedLicenses': [{
      'disabledPlans': [],
      'skuId': 'b05e124f-c7cc-45a0-a6aa-8cf78c946968'}
     ]}
  }]
}

割り当てられているライセンスは skuId という識別子で一意に特定されます。「Enterprise Mobility + Security E5」のようにライセンス名が直接埋め込まれるわけではありません。skuId は以下にGUIDとして一覧掲載されています。

docs.microsoft.com

b05e124f-c7cc-45a0-a6aa-8cf78c946968 は「ENTERPRISE MOBILITY + SECURITY E5」のGUIDに一致するので、EMS E5のライセンスが付与されていることがわかります。このように 「特定のライセンスの付与状態」を確認したい場合は、自身が調べたいライセンスの skuId をあらかじめ把握しておく必要があります。

assignedLicenses は $filter クエリをサポートしています。any演算子 と一緒にクエリを投げることで、特定のライセンスが付与されているユーザーを一覧取得できます。$filter=assignedLicenses/any(l:l/skuId eq b05e124f-c7cc-45a0-a6aa-8cf78c946968) のようにフィルター指定することで、EMS E5が割り当てられているすべてのユーザーが取得できます。ただし、この演算子もまた併用できないようです。and などでフィルタを組み合わせると以下のエラーが返ってきてしまいました。

{
  'error': {
    'code': 'Request_UnsupportedQuery',
    'message': 'Complex query on property assignedLicenses is not supported.', 
    'innerError': {
      'date': '2022-06-28T15:15:53', 
      'request-id': 'xxx',
      'client-request-id': 'xxx'
    }
  }
}

まとめると

「サインイン日時と一緒に特定のライセンスが割り当てられたユーザー」は以下URLで取得できます。

https://graph.microsoft.com/beta/users?$select=userPrincipalName,assignedLicenses,signInActivity,accountEnabled&$filter=assignedLicenses/any(l:l/skuId eq <ライセンスのGUID>)

サインイン日時に関わらずアカウントの状態(有効 or 無効)も確認したいので、select に accountEnabled も指定しています。

PythonでGraph APIにリクエストを送信する

AzureAD関連で何かするときはPowerShellのコマンドレットを使うと楽なことが多いですが、MacOSではそれらが使えません。そこで今回はPythonのライブラリ(プレビュー版)を利用することにしました。まだプレビュー版ですが、今回実現したいことは問題なく実装できました。

github.com

クレデンシャルを作成・設定する

特定ユーザーに紐づけずバッチ処理的に実行したかったので、Web API を呼び出すデーモン アプリ - アプリの登録 | Microsoft Docs の方法で作成したクレデンシャルを利用することにしました。ClientSecretCredential を使うことで、ClientID と Client Secret での認証ができます。

import os
from azure.identity import ClientSecretCredential
client_credential = ClientSecretCredential(
    tenant_id=os.getenv('AZURE_TENANT_ID'),
    client_id=os.getenv('AZURE_CLIENT_ID'),
    client_secret=os.getenv('AZURE_CLIENT_SECRET'))

beta版のエンドポイントを指定する

signInActivity は beta でないと取得できないため、api_version=APIVersion.beta でAPIバージョンを指定する必要があります。"beta" のように文字列を直接指定しても同じ結果が得られます。

from msgraph.core import APIVersion, GraphClient
client = GraphClient(credential=client_credential, api_version=APIVersion.beta)

# これも上と同じ
client = GraphClient(credential=client_credential, api_version="beta")

また、 api_version でバージョンを指定せずとも、リクエスト送信する際のURLをフルパスにすればbetaエンドポイントにリクエスト送信できます。バージョン指定するか、URLで直接指定するかは好きな方をどうぞ。

まとめると

最終的に、以下のようなコードで「サインイン日時と一緒に EMS E5ライセンスが割り当てられたユーザー」を取得しました。

import os
from azure.identity import ClientSecretCredential
from msgraph.core import APIVersion, GraphClient

if __name__ == "__main__":
    client_credential = ClientSecretCredential(
        tenant_id=os.getenv('AZURE_TENANT_ID'),
        client_id=os.getenv('AZURE_CLIENT_ID'),
        client_secret=os.getenv('AZURE_CLIENT_SECRET'))
    client = GraphClient(credential=client_credential, api_version=APIVersion.beta)
    
    # URLを作成
    ems_e5_sku_id = "b05e124f-c7cc-45a0-a6aa-8cf78c946968"
    s = "userPrincipalName,accountEnabled,assignedLicenses,signInActivity"
    f = f"assignedLicenses/any(l:l/skuId eq {ems_e5_sku_id})"
    next_link = f"/users?$select={s}&$filter={f}"

    # ユーザーをすべて取得
    users = []
    while next_link is not None:
        result = client.get(next_link).json()
        users = result["value"]
        next_link = result.get("@data.nextLink", None)

    print(users)

取得したデータをCSV書き出ししてExcelやスプレッドシートに取り込めばライセンスの棚卸などに役立てられます。メンバーを削除する で抽出したユーザーをグループから削除すればライセンスの剥奪もできます。今回は人間の判断を挟みたかったため、ライセンス剥奪まではスクリプト化しませんでした。