01MSAL のデバイスコードフローでスクリプトからサインインする確認済

ブラウザーを開けないリモートのシェルなどで動くスクリプトは、MSAL for Python の initiate_device_flow で表示したコードを、別の端末のブラウザーに入力してサインインする。キャッシュにアカウントがあれば、先に acquire_token_silent で再利用する。

考え方と手順
  1. Microsoft Entra ID にアプリを登録し、クライアント ID とテナント ID を環境変数 CLIENT_ID と TENANT_ID に入れる
  2. PublicClientApplication を作り、get_accounts でアカウントがあれば acquire_token_silent を先に呼ぶ
  3. 無ければ initiate_device_flow を呼ぶ。戻り値に user_code が無ければ、error_description を示して止める
  4. flow["message"] を画面に出し、利用者に別の端末でコードを入力してもらう
  5. acquire_token_by_device_flow で結果を待つ。既定では、この呼び出しが現在のスレッドを止めて問い合わせを続ける
  6. 結果に access_token が無ければ error を表示して終える。トークン自体は画面にもログにも出さない
Python要サインイン(Microsoft Entra)
"""Sign in with the device code flow (MSAL for Python), reusing a cached account first."""
import os
import sys

import msal

SCOPES = ["User.Read"]


def get_token(app, scopes):
    accounts = app.get_accounts()
    if accounts:
        result = app.acquire_token_silent(scopes, account=accounts[0])
        if result and "access_token" in result:
            return result
    flow = app.initiate_device_flow(scopes=scopes)
    if "user_code" not in flow:
        raise RuntimeError("could not start device flow: %s" % flow.get("error_description"))
    print(flow["message"], file=sys.stderr)  # where to go and which code to enter
    return app.acquire_token_by_device_flow(flow)  # blocks until sign-in, expiry or cancel


def main():
    app = msal.PublicClientApplication(
        os.environ["CLIENT_ID"],
        authority="https://login.microsoftonline.com/" + os.environ["TENANT_ID"],
    )
    result = get_token(app, SCOPES)
    if "access_token" not in result:
        print("sign-in failed:", result.get("error"), result.get("error_description"))
        return 1
    print("signed in; token expires in", result.get("expires_in"), "seconds")  # never print the token
    return 0


if __name__ == "__main__":
    sys.exit(main())
実行結果(2026-09-12)
silent first, then device flow: ok
Copilot に書かせる指示の例

MSAL for Python でデバイスコードフローを使う get_token(app, scopes) を書いて。キャッシュにアカウントがあれば acquire_token_silent を先に試すこと。initiate_device_flow の戻り値に user_code が無ければ RuntimeError にすること。CLIENT_ID と TENANT_ID は環境変数から読み、トークンは表示しないこと。

注意

コードの有効期限は、要求を送ってから既定で 15 分(expires_in)。組織のサインインの方針でデバイスコードフローが使えない場合があるので、先に管理者に確認する。この例のキャッシュはメモリ上だけにあり、次の実行ではまたコードの入力が要る(保存は別の項目)。トークンの中身を読んで処理を分けない。

利用条件
msal 1.38.0(Python 3.12 の venv)で確認。Microsoft Entra ID の職場または学校アカウント。
必要なもの
msal 1.38.0
権限
User.Read(委任)
試験
サインインの手前まで実行して確認(Microsoft 365 には接続していない)・集計・判定の部分を見本データで実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11、msal 1.38.0)
出典
Microsoft Learn「Microsoft identity platform and the OAuth 2.0 device authorization grant flow」
MSAL Python「MSAL Python Documentation」
確認日
2026-09-12(第 1 版)
出典の該当箇所
When possible, we recommend you use the supported Microsoft Authentication Libraries (MSAL) instead to acquire tokens and call secured web APIs.
A successful response would contain “user_code” key, among others
By default, this method’s polling effect will block current thread.
From the moment the request is sent, the user has 15 minutes to sign in. This is the default value for expires_in.

02MSAL のトークンキャッシュを暗号化して保存し、リポジトリに入れない確認済

MSAL for Python はキャッシュをディスクに保存しないので、何もしなければ実行のたびにサインインが要る。msal-extensions の PersistedTokenCache を使うと、Windows では DPAPI で暗号化したファイルに保存でき、ファイルのロックと再読み込みも任せられる。

考え方と手順
  1. build_encrypted_persistence(保存先) で、OS に合った暗号化の保存先を作る(Windows は DPAPI、macOS はキーチェーン、Linux は LibSecret)
  2. PersistedTokenCache(persistence) を PublicClientApplication の token_cache に渡す
  3. 保存先はリポジトリの外(利用者のプロファイルの下など)にし、環境変数 TOKEN_CACHE_PATH で指定する
  4. 念のため .gitignore にキャッシュのファイル名を書き、誤ってコミットしないようにする
  5. 暗号化が使えない環境では平文に切り替えずに止める。公式の例も、平文への切り替えは明示したときだけにしている
Python要サインイン(Microsoft Entra)
"""Keep the MSAL token cache in an encrypted file outside the repository."""
import os
import sys

import msal
from msal_extensions import PersistedTokenCache, build_encrypted_persistence

SCOPES = ["User.Read"]
# Point this outside the repo (e.g. your user profile) and list the file name in .gitignore
CACHE_PATH = os.environ.get("TOKEN_CACHE_PATH", "token_cache.bin")


def build_app():
    persistence = build_encrypted_persistence(CACHE_PATH)  # raises if encryption is unavailable
    print("cache:", type(persistence).__name__, "encrypted:", persistence.is_encrypted)
    return msal.PublicClientApplication(
        os.environ["CLIENT_ID"],
        authority="https://login.microsoftonline.com/" + os.environ["TENANT_ID"],
        token_cache=PersistedTokenCache(persistence),  # file lock and reload are handled
    )


def main():
    app = build_app()
    accounts = app.get_accounts()
    result = app.acquire_token_silent(SCOPES, account=accounts[0]) if accounts else None
    if not result:
        flow = app.initiate_device_flow(scopes=SCOPES)
        if "user_code" not in flow:
            print("could not start sign-in:", flow.get("error_description"))
            return 1
        print(flow["message"], file=sys.stderr)
        result = app.acquire_token_by_device_flow(flow)
    print("token ready" if "access_token" in result else "failed: %s" % result.get("error"))
    return 0


if __name__ == "__main__":
    sys.exit(main())
Copilot に書かせる指示の例

MSAL for Python の PublicClientApplication に、msal-extensions の build_encrypted_persistence と PersistedTokenCache でキャッシュを付けて。保存先は環境変数 TOKEN_CACHE_PATH から読み、暗号化できないときは例外で止めること。平文の保存に切り替える処理は書かないこと。

注意

キャッシュにはアクセストークンなどが入るので、共有フォルダーやリポジトリに置かない。msal-extensions はデスクトップのようなパブリッククライアント向けで、Web アプリには勧められていない。1 台のキャッシュを別の端末で使い回さない。退職や端末の入れ替えでは、ファイルごと消す手順を決めておく。

利用条件
msal 1.38.0、msal-extensions 1.3.1 で確認(Windows 11 で FilePersistenceWithDataProtection、is_encrypted は True)。
必要なもの
msal 1.38.0, msal-extensions 1.3.1
権限
User.Read(委任)
試験
サインインの手前まで実行して確認(Microsoft 365 には接続していない)(2026-09-12、Python 3.12.10 (venv) / Windows 11、msal 1.38.0、msal-extensions 1.3.1)
出典
GitHub (AzureAD)「Microsoft Authentication Extensions for Python」
MSAL Python「MSAL Python Documentation」
Microsoft Learn「Custom token cache serialization in MSAL for Python」
確認日
2026-09-12(第 1 版)
出典の該当箇所
The token cache includes a file lock, and auto-reload behavior under the hood.
This class does NOT actually persist the cache on disk/db/etc.
Windows - DPAPI is used for encryption.
It is recommended to use this library for cache persistance support for Public client applications such as Desktop apps only.

03人のいない定時処理は、クライアントシークレットでなく証明書で認証する確認済

サインインする人のいない処理は、ConfidentialClientApplication に PFX 証明書のパスを渡し、acquire_token_for_client でトークンを取る。PFX を使うと MSAL は SHA-256 の拇印を使う。例はライセンスの割り当て数を読み、権限は LicenseAssignment.Read.All だけにする。

考え方と手順
  1. 証明書を用意し、公開鍵の部分をアプリの登録の[Certificates & secrets]>[Certificates]にアップロードする
  2. 秘密鍵を含む PFX は実行するサーバーの権限を絞ったフォルダーに置き、パスを CERT_PATH、パスフレーズを CERT_PASSPHRASE で渡す
  3. client_credential に {"private_key_pfx_path": ..., "passphrase": ...} を渡す(msal 1.29.0 で追加された形)
  4. アプリ専用のトークンは https://graph.microsoft.com/.default を指定して取る。個々の権限名を並べる要求はできない
  5. API の文書の Permissions の表で最小の権限を選ぶ。subscribedSkus の一覧はアプリケーション権限の LicenseAssignment.Read.All
  6. $select で必要な項目だけを取り、requests には timeout を付ける
Python要サインイン(Microsoft Entra)
"""App-only token with a certificate (no client secret), then read license counts."""
import os
import sys

import msal
import requests

GRAPH = "https://graph.microsoft.com/v1.0"
SCOPES = ["https://graph.microsoft.com/.default"]  # app-only tokens always use /.default


def build_app():
    credential = {"private_key_pfx_path": os.environ["CERT_PATH"]}
    if os.environ.get("CERT_PASSPHRASE"):
        credential["passphrase"] = os.environ["CERT_PASSPHRASE"]
    return msal.ConfidentialClientApplication(
        os.environ["CLIENT_ID"],
        authority="https://login.microsoftonline.com/" + os.environ["TENANT_ID"],
        client_credential=credential,
    )


def seat_rows(payload):
    rows = []
    for sku in payload.get("value", []):
        enabled = (sku.get("prepaidUnits") or {}).get("enabled", 0)
        rows.append((sku.get("skuPartNumber"), sku.get("consumedUnits", 0), enabled))
    return rows


def main():
    result = build_app().acquire_token_for_client(scopes=SCOPES)
    if "access_token" not in result:
        print("token error:", result.get("error"), result.get("error_description"))
        return 1
    resp = requests.get(
        GRAPH + "/subscribedSkus",
        params={"$select": "skuPartNumber,consumedUnits,prepaidUnits"},
        headers={"Authorization": "Bearer " + result["access_token"]},
        timeout=30,
    )
    resp.raise_for_status()
    for name, used, enabled in seat_rows(resp.json()):
        print(f"{name}: {used} of {enabled} assigned")
    return 0


if __name__ == "__main__":
    sys.exit(main())
実行結果(2026-09-12)
seat_rows: [('SKU_A', 14, 25), ('SKU_B', 0, 0)]
Copilot に書かせる指示の例

MSAL for Python の ConfidentialClientApplication で、証明書(PFX)によるアプリ専用のトークンを取り、Graph の /subscribedSkus から skuPartNumber・consumedUnits・prepaidUnits だけを $select で読むスクリプトを書いて。クライアントシークレットは使わないこと。ID とパスは環境変数から読み、集計は seat_rows(payload) という関数に分けること。

注意

アプリケーション権限は、サインインした人の権限に縛られず、テナント全体のデータに届く。付与には管理者の同意が要る。PFX とパスフレーズはリポジトリに入れない。証明書の有効期限を台帳で管理し、切れる前に差し替える。PEM と SHA-1 拇印の形は非推奨とされている。

利用条件
msal 1.38.0、requests 2.34.2 で確認。Microsoft Graph v1.0 の subscribedSkus(2026-09-12 に文書を確認)。
必要なもの
msal 1.38.0, requests 2.34.2
権限
LicenseAssignment.Read.All(アプリケーション)
試験
サインインの手前まで実行して確認(Microsoft 365 には接続していない)・集計・判定の部分を見本データで実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11、msal 1.38.0、requests 2.34.2)
実践編
AI エージェントの運用 ── Entra Agent ID と Agent 365 の範囲を分けて考える
出典
MSAL Python「MSAL Python Documentation」
Microsoft Learn「Microsoft identity platform application authentication certificate credentials」
Microsoft Learn「List subscribedSkus」
Microsoft Learn「Scopes and permissions in the Microsoft identity platform」
確認日
2026-09-12(第 1 版)
出典の該当箇所
Client credentials requests in your client service must include scope={resource}/.default.
This usage will automatically use SHA-256 thumbprint of the certificate.
The Microsoft identity platform allows an application to use its own credentials for authentication anywhere a client secret could be used
This method supports only the $select OData query parameter to help customize the response.

04Azure で動くコードはマネージド ID でトークンを取り、資格情報を持たない確認済

Azure の VM やアプリのホスティングの上で動くコードは、ManagedIdentityCredential でトークンを取れば、シークレットも証明書も置かずに済む。ユーザー割り当てのマネージド ID は client_id で指定する。例は msgraph-sdk でライセンスの割り当て数を読む。

考え方と手順
  1. 実行するリソースでシステム割り当てのマネージド ID を有効にするか、ユーザー割り当ての ID を作ってリソースに割り当てる
  2. コードでは ManagedIdentityCredential を使う。ユーザー割り当てなら、環境変数 AZURE_CLIENT_ID の値を client_id に渡す
  3. GraphServiceClient には scopes=["https://graph.microsoft.com/.default"] を渡す
  4. マネージド ID に付ける Graph の権限は、使う API の最小のもの(この例は LicenseAssignment.Read.All)だけにする。付与は管理者に依頼する
  5. 手元の PC にはマネージド ID が無い。手元では開発者のサインインで試し、Azure では ManagedIdentityCredential に固定する
Python要サインイン(Microsoft Entra)
"""Read license counts from code running in Azure, using its managed identity."""
import asyncio
import os

from azure.identity.aio import ManagedIdentityCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.subscribed_skus.subscribed_skus_request_builder import (
    SubscribedSkusRequestBuilder,
)

SCOPES = ["https://graph.microsoft.com/.default"]


def seat_summary(skus):
    rows = []
    for s in skus:
        enabled = s.prepaid_units.enabled if s.prepaid_units else 0
        rows.append((s.sku_part_number, s.consumed_units or 0, enabled or 0))
    return rows


async def main():
    # user-assigned identity: set AZURE_CLIENT_ID; system-assigned: leave it unset
    async with ManagedIdentityCredential(client_id=os.environ.get("AZURE_CLIENT_ID")) as cred:
        client = GraphServiceClient(credentials=cred, scopes=SCOPES)
        query = SubscribedSkusRequestBuilder.SubscribedSkusRequestBuilderGetQueryParameters(
            select=["skuPartNumber", "consumedUnits", "prepaidUnits"])
        page = await client.subscribed_skus.get(
            request_configuration=RequestConfiguration(query_parameters=query))
        for name, used, enabled in seat_summary(page.value or []):
            print(f"{name}: {used} of {enabled} assigned")


if __name__ == "__main__":
    asyncio.run(main())
実行結果(2026-09-12)
seat_summary: [('SKU_A', 14, 25), ('SKU_B', 0, 0)]
Copilot に書かせる指示の例

azure-identity の非同期版 ManagedIdentityCredential と msgraph-sdk で、Azure 上から /subscribedSkus を読むコードを書いて。ユーザー割り当ての ID は環境変数 AZURE_CLIENT_ID で指定し、未設定ならシステム割り当てを使うこと。$select で skuPartNumber・consumedUnits・prepaidUnits だけを取り、集計は seat_summary(skus) に分けること。

注意

マネージド ID は Azure の中でしか使えない。ID が割り当てられていない、または client_id が違うと、トークンは返らない。マネージド ID に付けた権限は、そのリソースで動くすべてのコードが使えるので、リソースを用途ごとに分ける。マネージド ID のキャッシュを複数の端末で共有しない。

利用条件
azure-identity 1.25.3、msgraph-sdk 1.62.0 で確認(手元ではサインインの手前まで。Azure 上の実行は未確認)。
必要なもの
azure-identity 1.25.3, msgraph-sdk 1.62.0
権限
LicenseAssignment.Read.All(アプリケーション)
試験
サインインの手前まで実行して確認(Microsoft 365 には接続していない)・集計・判定の部分を見本データで実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11、azure-identity 1.25.3、msgraph-sdk 1.62.0)
出典
Microsoft Learn「Managed identities for Azure resources」
Microsoft Learn「Azure Identity client library for Python」
Microsoft Learn「Using Managed Identity (MSAL for Python)」
Microsoft Learn「List subscribedSkus」
確認日
2026-09-12(第 1 版)
出典の該当箇所
Managed identities eliminate the need for developers to manage these credentials.
Managed identity authentication is supported either indirectly via DefaultAzureCredential or directly via ManagedIdentityCredential
this SDK provides a better developer experience by allowing the app to run on private developer machines where managed identity doesn't exist.
If an incorrect identifier is used for the user-assigned managed identity, no token will be returned as well.

05外部サービスの API キーは Key Vault から実行時に読み、コードと設定ファイルに置かない確認済

Entra ID で認証できない外部サービスの API キーなどは Key Vault に置き、azure-keyvault-secrets の SecretClient.get_secret で実行時に読む。DefaultAzureCredential は手元では開発者のサインインを、Azure ではマネージド ID を使えるので、同じコードで動く。

考え方と手順
  1. 保管庫は RBAC で管理し、読むだけのスクリプトの ID には Key Vault Secrets User を割り当てる(書き込みもできる Secrets Officer は付けない)
  2. 保管庫の URL を KEY_VAULT_URL、秘密情報の名前を SECRET_NAME の環境変数で渡す
  3. 最初に credential.get_token("https://vault.azure.net/.default") を呼び、サインインの失敗を保管庫への要求の前に分かるようにする
  4. get_secret(name).value を使う。値は print もログにも出さず、記録するのは名前と版(properties.version)だけにする
  5. ClientAuthenticationError と ResourceNotFoundError を分けて扱い、値を含まないメッセージを出す
Python要サインイン(Microsoft Entra)
"""Read an API key from Azure Key Vault at run time; never print or log the value."""
import os
import sys

from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

VAULT_URL = os.environ["KEY_VAULT_URL"]
SECRET_NAME = os.environ.get("SECRET_NAME", "partner-api-key")
KV_SCOPE = "https://vault.azure.net/.default"


def load_secret(credential, name, make_client=SecretClient):
    credential.get_token(KV_SCOPE)  # fail fast on sign-in problems, before calling the vault
    secret = make_client(vault_url=VAULT_URL, credential=credential).get_secret(name)
    return secret.value, secret.properties.version


def main():
    credential = DefaultAzureCredential()  # developer sign-in locally, managed identity in Azure
    try:
        value, version = load_secret(credential, SECRET_NAME)
    except ClientAuthenticationError as e:
        print("sign-in failed:", type(e).__name__)
        return 1
    except ResourceNotFoundError:
        print("secret not found:", SECRET_NAME)
        return 1
    print("loaded", SECRET_NAME, "version", version)  # the value itself is never shown
    # use `value` here, e.g. as a request header for the external service
    return 0


if __name__ == "__main__":
    sys.exit(main())
実行結果(2026-09-12)
load_secret ok; version v1
Copilot に書かせる指示の例

azure-identity の DefaultAzureCredential と azure-keyvault-secrets の SecretClient で、外部サービスの API キーを読む load_secret(credential, name) を書いて。保管庫の URL は環境変数 KEY_VAULT_URL から読むこと。値は表示もログもしないで、名前と版だけを表示すること。試験のため、SecretClient を引数で差し替えられるようにすること。

注意

Key Vault に移しても、値を print したりログやエラーメッセージに入れたりすれば漏れる。公式のクイックスタートは作成から削除までを示すため値を表示しているが、業務のコードでは表示しない。保管庫はアプリと環境(開発・本番)ごとに分けるのが推奨されている。キーの更新の周期は外部サービスの側と決める。

利用条件
azure-identity 1.25.3、azure-keyvault-secrets 4.11.2 で確認(手元ではサインインの手前まで)。保管庫は Azure RBAC の権限モデルを使う前提。
必要なもの
azure-identity 1.25.3, azure-keyvault-secrets 4.11.2
試験
サインインの手前まで実行して確認(Microsoft 365 には接続していない)・集計・判定の部分を見本データで実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11、azure-identity 1.25.3、azure-keyvault-secrets 4.11.2)
出典
Microsoft Learn「Quickstart: Azure Key Vault secret client library for Python」
Microsoft Learn「Grant permission to applications to access an Azure key vault using Azure RBAC」
Microsoft Learn「Scopes and permissions in the Microsoft identity platform」
確認日
2026-09-12(第 1 版)
出典の該当箇所
By using Key Vault to store secrets, you avoid storing secrets in your code, which increases the security of your app.
When the application is deployed to Azure, the same DefaultAzureCredential code can automatically discover and use a managed identity
Read secret contents including secret portion of a certificate with private key.
Our recommendation is to use a vault per application per environment (Development, Pre-Production, and Production) with roles assigned at the key vault scope.
Azure Key Vault: https://vault.azure.net
The scope parameter value is constructed by using the identifier URI for the resource and .default, separated by a forward slash (/).

06スクリプトごとの Graph 権限を台帳にして、過剰な権限を機械的に見つける確認済

スクリプトが要求する権限を台帳に書き、読み取りだけの処理に ReadWrite が付いていないか、Directory 系の広い権限を使っていないか、1 つのアプリで委任とアプリケーションの権限を混ぜていないかを Python で点検する。

考え方と手順
  1. スクリプトごとに、操作(read か write)、権限の種類(delegated か application)、要求する権限を台帳に書く
  2. 権限は、使う API の文書の Permissions の表で「Least privileged permissions」の列から選ぶ
  3. 読み取りの処理に ReadWrite・Send などの書き込み権限が付いていたら指摘する
  4. Directory.AccessAsUser.All・Directory.ReadWrite.All・Directory.Read.All は範囲が広いので、狭い権限に替えられないかを指摘する
  5. 同じアプリで委任とアプリケーションの権限を混ぜていたら指摘する。人が操作する処理は委任の権限にする
  6. 指摘の一覧を、管理者に同意を依頼するときの資料に添える
Python手元で動く
"""Flag over-broad Microsoft Graph permissions in a per-script permission ledger."""
import json
import os
import re

LEDGER = [
    {"script": "mail_tally.py", "app": "reports", "operation": "read", "type": "delegated",
     "scopes": ["Mail.Read"]},
    {"script": "license_report.py", "app": "reports", "operation": "read", "type": "application",
     "scopes": ["LicenseAssignment.Read.All"]},
    {"script": "list_audit.py", "app": "audit", "operation": "read", "type": "delegated",
     "scopes": ["Sites.ReadWrite.All"]},
    {"script": "dir_dump.py", "app": "audit", "operation": "read", "type": "delegated",
     "scopes": ["Directory.Read.All"]},
]
WRITE = re.compile(r"ReadWrite|\.Send$|\.Write|Manage|FullControl")
BROAD = {"Directory.AccessAsUser.All", "Directory.ReadWrite.All", "Directory.Read.All"}


def review(rows):
    findings = []
    for r in rows:
        for s in r["scopes"]:
            if r["operation"] == "read" and WRITE.search(s):
                findings.append((r["script"], s, "write permission on a read-only script"))
            if s in BROAD:
                findings.append((r["script"], s, "broad directory permission; look for a narrower one"))
    kinds = {}
    for r in rows:
        kinds.setdefault(r["app"], set()).add(r["type"])
    for app, k in sorted(kinds.items()):
        if len(k) > 1:
            findings.append((app, "+".join(sorted(k)), "one app mixes delegated and application permissions"))
    return findings


def main():
    path = os.environ.get("PERM_LEDGER")  # optional JSON file with the same shape as LEDGER
    rows = LEDGER
    if path:
        with open(path, encoding="utf-8") as f:
            rows = json.load(f)
    found = review(rows)
    for f in found:
        print(" | ".join(f))
    print("findings:", len(found))


if __name__ == "__main__":
    main()
実行結果(2026-09-12)
list_audit.py | Sites.ReadWrite.All | write permission on a read-only script
dir_dump.py | Directory.Read.All | broad directory permission; look for a narrower one
reports | application+delegated | one app mixes delegated and application permissions
findings: 3
Copilot に書かせる指示の例

スクリプトの権限台帳(script・app・operation・type・scopes の辞書のリスト)を受け取り、指摘を (対象, 権限, 理由) のタプルで返す review(rows) を書いて。規則は 3 つ: 読み取りの処理に書き込み権限、Directory 系の広い権限、同じ app で delegated と application の混在。

注意

この点検は名前の規則で見るだけで、権限が本当に足りるかは、API の文書と試験用のテナントでの実行で確かめる。アプリケーション権限は、サインインした人の権限に縛られない。委任の権限でも、利用者が見られるデータにはすべて届くので、共有の整理は実践編の項目も見る。

利用条件
Python 3.12 の標準ライブラリだけで動く。権限の名前と最小の権限は 2026-09-12 に Microsoft Learn で確認。
試験
手元で実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11)
実践編
保管した情報で Copilot を動かす ── Copilot は閲覧権限のあるデータだけを表示する
出典
Microsoft Learn「Overview of Microsoft Graph permissions」
Microsoft Learn「Best practices for working with Microsoft Graph」
確認日
2026-09-12(第 1 版)
出典の該当箇所
From a least-privilege perspective, the delegated permission model is the recommended approach whenever it meets the application's requirements.
Avoid using both application and delegated permissions in the same app.
Directory.Read.All is the highest privileged read-only permission for Microsoft Entra ID resources.
Choose the permission or permissions marked as least privileged for this API.

07HTTP 429 を受けたら Retry-After の秒数だけ待って再試行する確認済

Graph は制限を超えた要求に 429 Too Many Requests を返し、Retry-After ヘッダーで待つ秒数を示す。requests で直接呼ぶスクリプトは、その秒数だけ待って再試行し、ヘッダーが無ければ待ち時間を倍にしていく。msgraph-sdk には再試行の仕組みが入っている。

考え方と手順
  1. 状態コード 429 で制限を判定する。すぐに再試行しない(失敗した要求も使用量に数えられる)
  2. Retry-After の秒数だけ待ってから同じ要求を送る。再び 429 なら、また Retry-After に従う
  3. ヘッダーが無いときは待ち時間を倍にしていく。回数の上限を決め、超えたら止めて記録する
  4. 503 も同じように間隔を空けて再試行する
  5. JSON バッチの中の要求は 1 件ずつ 429 になり、バッチ自体は 200 で返る。SDK もバッチ内の要求は自動で再試行しない
  6. 待ち時間を渡す関数(sleep)を引数にしておくと、偽の応答で待たずに試験できる
Python手元で動く
"""Retry on 429/503, honoring Retry-After; checked here with fake responses (no network)."""
import time

RETRY_STATUS = {429, 503}


def call_with_retry(send, max_attempts=5, base_delay=1.0, sleep=time.sleep):
    """send() returns an object with .status_code and .headers, e.g. a requests.Response."""
    for attempt in range(1, max_attempts + 1):
        resp = send()
        if resp.status_code not in RETRY_STATUS or attempt == max_attempts:
            return resp
        header = resp.headers.get("Retry-After", "")
        wait = float(header) if header.isdigit() else base_delay * 2 ** (attempt - 1)
        print(f"attempt {attempt}: HTTP {resp.status_code}, waiting {wait:g}s")
        sleep(wait)
    return resp


class FakeResponse:
    def __init__(self, status_code, headers=None):
        self.status_code, self.headers = status_code, headers or {}


def demo():
    queue = [FakeResponse(429, {"Retry-After": "10"}), FakeResponse(503), FakeResponse(200)]
    waits = []
    resp = call_with_retry(lambda: queue.pop(0), sleep=waits.append)
    assert resp.status_code == 200 and waits == [10.0, 2.0], waits

    stuck = [FakeResponse(429) for _ in range(3)]
    waits2 = []
    resp2 = call_with_retry(lambda: stuck.pop(0), max_attempts=3, sleep=waits2.append)
    assert resp2.status_code == 429 and waits2 == [1.0, 2.0], waits2
    print("retried:", len(waits), "then 200;", len(waits2), "then gave up")
    # real use: call_with_retry(lambda: session.get(url, headers=headers, timeout=30))


if __name__ == "__main__":
    demo()
実行結果(2026-09-12)
attempt 1: HTTP 429, waiting 10s
attempt 2: HTTP 503, waiting 2s
attempt 1: HTTP 429, waiting 1s
attempt 2: HTTP 429, waiting 2s
retried: 2 then 200; 2 then gave up
Copilot に書かせる指示の例

send() を呼んで応答を返す関数を受け取り、429 と 503 のときに Retry-After の秒数だけ待って再試行する call_with_retry(send, max_attempts, base_delay, sleep) を書いて。ヘッダーが無ければ base_delay を倍にしていくこと。sleep は引数で差し替えられるようにし、偽の応答の列で試す assert も付けること。

注意

この関数は Retry-After が秒数のときだけ読む。書き込みの要求を再試行すると二重に処理されることがあるので、再試行してよい要求かを決めておく。大量のデータを定期的に抜き出す処理は、Graph の文書が Microsoft Graph Data Connect を勧めている。変更の検出は差分クエリや変更通知を使い、一覧の定期的な全件取得を避ける。

利用条件
Python 3.12 の標準ライブラリだけで動く(手元で偽の応答を使って確認)。実際の Graph への要求では requests.Response をそのまま渡せる。
試験
手元で実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11)
出典
Microsoft Learn「Microsoft Graph throttling guidance」
Microsoft Learn「Best practices for working with Microsoft Graph」
確認日
2026-09-12(第 1 版)
出典の該当箇所
Wait the number of seconds specified in the Retry-After header.
Microsoft Graph SDKs already implement handlers that rely on the Retry-After header or default to an exponential backoff retry policy.
If SDKs retry throttled requests automatically when they aren't batched, throttled requests that were part of a batch aren't retried automatically.
You should employ a back-off strategy similar to 429.

08実行記録には誰が・何を・いつを残し、トークンと個人データは伏せる確認済

実行ごとに実行 ID、実行者の社内 ID、操作、件数、Graph に送った client-request-id を JSON Lines で記録する。logging の Filter でメールアドレスと Bearer トークンを伏せ字にし、Graph からは $select で集計に要る項目だけを取る。

考え方と手順
  1. 1 回の実行に uuid で実行 ID を付ける。Graph への要求ごとにも uuid を作って client-request-id ヘッダーで送り、同じ値を記録する
  2. 実行者は社内の ID(環境変数 RUN_BY)で記録し、氏名やメールアドレスは書かない
  3. 取得は $select で集計に要る項目だけにする。この例は差出人のドメインの数と件数だけを残す
  4. logging.Filter のサブクラスでメッセージのメールアドレスと Bearer トークンを置き換え、ハンドラーに addFilter で付ける
  5. MSAL の enable_pii_log は既定の False のままにする。azure-identity の INFO のログは URL とヘッダーを含むので、出力先を確かめる
  6. 記録の保存期間と置き場所を決め、Microsoft Purview の監査ログと突き合わせられるようにする
Python手元で動く
"""JSON Lines audit log for a script run, masking e-mail addresses and bearer tokens."""
import json
import logging
import os
import re
import uuid
from collections import Counter
from datetime import datetime, timezone

EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
BEARER = re.compile(r"Bearer\s+[\w.~+/=-]+")
SELECT = ["from", "receivedDateTime"]  # ask Graph only for what the tally needs


class RedactFilter(logging.Filter):
    def filter(self, record):
        msg = EMAIL.sub("[email]", record.getMessage())
        record.msg, record.args = BEARER.sub("Bearer [redacted]", msg), None
        return True


def audit_logger(path):
    handler = logging.FileHandler(path, encoding="utf-8")
    handler.addFilter(RedactFilter())
    log = logging.getLogger("audit")
    log.setLevel(logging.INFO)
    log.addHandler(handler)
    return log


def event(log, run_id, action, **fields):
    rec = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "run_id": run_id,
           "run_by": os.environ.get("RUN_BY", "unknown"), "action": action, **fields}
    log.info(json.dumps(rec, ensure_ascii=False))


def main():
    run_id = str(uuid.uuid4())
    log = audit_logger("out_audit.jsonl")
    with open("samples/messages.json", encoding="utf-8") as f:
        rows = json.load(f)["value"]  # stands in for GET /me/messages?$select=from,receivedDateTime
    event(log, run_id, "graph.get", resource="/me/messages", select=",".join(SELECT),
          client_request_id=str(uuid.uuid4()), count=len(rows))
    domains = Counter(r["from"]["emailAddress"]["address"].split("@")[-1] for r in rows)
    event(log, run_id, "tally", sender_domains=len(domains), messages=sum(domains.values()))
    # demo of a careless line: the filter masks the address and the token before they reach the file
    sender = rows[0]["from"]["emailAddress"]["address"]
    event(log, run_id, "debug", note="sender %s auth Bearer abc.def.ghi" % sender)
    logging.shutdown()
    with open("out_audit.jsonl", encoding="utf-8") as f:
        text = f.read()
    assert "@" not in text and "abc.def" not in text
    print("audit lines:", len(text.splitlines()), "| redacted: ok")
    print(text.splitlines()[-1])


if __name__ == "__main__":
    main()
実行結果(2026-09-12)
audit lines: 3 | redacted: ok
{"ts": "2026-09-12T03:49:23+00:00", "run_id": "448cf962-193d-4a16-a2ed-440adffb5bfe", "run_by": "unknown", "action": "debug", "note": "sender [email] auth Bearer [redacted]"}
Copilot に書かせる指示の例

スクリプトの実行記録を JSON Lines で書く audit_logger(path) と event(log, run_id, action, **fields) を書いて。記録には時刻(UTC)、実行 ID、環境変数 RUN_BY の実行者 ID、操作を入れること。logging.Filter でメールアドレスと Bearer で始まるトークンを伏せ字にすること。記録に @ が残らないことを assert で確かめること。

注意

伏せ字の正規表現は、すべての書き方の個人データを捕まえるわけではない。そもそも個人データを記録に渡さない書き方を先に考える。Graph の文書は問い合わせ用に要求と応答の全体の記録を勧めているが、残す場合も Authorization ヘッダーと本文の個人データは外す。実行者の ID が個人データに当たるかと保存期間は、社内の規程に合わせる。

利用条件
Python 3.12 の標準ライブラリだけで動く(見本の messages.json で確認)。Graph の応答の形は Microsoft Graph v1.0 の messages に合わせている。
試験
手元で実行して確認(2026-09-12、Python 3.12.10 (venv) / Windows 11)
実践編
AI エージェントの運用 ── エージェントの利用記録は Purview の監査ログで追う
出典
Microsoft Learn「Best practices for working with Microsoft Graph」
Python documentation「Logging HOWTO」
Microsoft Learn「Azure Identity client library for Python」
MSAL Python「MSAL Python Documentation」
確認日
2026-09-12(第 1 版)
出典の該当箇所
On every request to Microsoft Graph, generate a unique GUID, send it in the client-request-id HTTP request header, and also log it in your application's logs.
Use the $select query parameter to limit the properties returned by a query to those needed by your application.
instances of Filter can be added to both Logger and Handler instances (through their addFilter() method).
Credentials log basic information, including HTTP sessions (URLs, headers, etc.) at INFO level. These log entries don't contain authentication secrets.
When enabled, logs may include PII (Personal Identifiable Information).