Entra IDのMFA未登録ユーザーをGraph APIで洗い出す — 部署別の案内表まで
userRegistrationDetailsからMFA未登録と弱い方式だけの利用者を抜き、部署ごとの案内先に振り分けるスクリプトをClaude Codeに書かせる手順です。取得と案内の送付は管理者が行います。
MFAの登録状況は、Entra管理センターの画面でも見られます。ただ「誰に、どの部署の誰経由で案内するか」まで出すには、Graph APIで一覧を取り、手元で加工するほうが速く済みます。この記事は、userRegistrationDetailsから未登録者と弱い方式だけの利用者を抜き、部署別の案内表にするまでの手順です。
分担ははっきり分けます。Graphからのデータ取得と案内メールの送付は管理者が行います。Claude Codeに渡すのは、取得済みのJSONと部署名簿のCSVだけです。判定スクリプトと案内文の下書きを作らせ、資格情報は渡しません。
先に押さえる前提 — 権限・ロール・ライセンス
エンドポイントは1つです。
GET https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetailsアプリケーション権限も委任権限も、最小権限はAuditLog.Read.Allで、これより上位の権限はありません。委任アクセスでは、サインインしたユーザーに対応ロールが要ります。このAPIが挙げるロールは、Reports Reader、Security Reader、Security Administrator、Global Readerの4つです。
ライセンスは別ページに書かれています。認証方法のアクティビティ機能(Usage and insights)には、Microsoft Entra ID P1またはP2が必要です。リソース型のページも、ライセンス要件はこのページを見るよう案内しています。P1/P2が無いテナントでは、このAPIが使えない前提で計画を立てます。
| 項目 | 内容 |
|---|---|
| 権限 | 内容AuditLog.Read.All(委任・アプリケーションとも) |
| ロール(委任時) | 内容Reports Reader / Security Reader / Security Administrator / Global Reader |
| ライセンス | 内容Microsoft Entra ID P1またはP2 |
| 個人のMicrosoftアカウント | 内容非対応 |
取得できる項目と、見落としやすい制約
リソース型の主な項目は次のとおりです。MFAの判定に使うのは上の3つです。
| 項目 | 意味 |
|---|---|
isMfaRegistered | 意味MFA用の強い認証方法を登録済みか。認証方法ポリシーで許可された方法とは限らない |
isMfaCapable | 意味強い認証方法を登録済みで、その方法が認証方法ポリシーで許可されている |
methodsRegistered | 意味登録済みの方法の一覧(mobilePhone、email、passKeyDeviceBoundなど) |
isAdmin | 意味テナントで管理者ロールを持つか |
userType | 意味memberまたはguest |
lastUpdatedDateTime | 意味レポートが最後に更新された日時(UTC) |
isMfaRegisteredとisMfaCapableの差が、洗い出しの要点です。登録はしているのに、その方法がポリシーで許可されておらず、MFAとして使えない利用者がいます。isMfaRegisteredだけで絞ると、この層を見逃します。
落とし穴
無効化されたユーザーは、このレポートに載らない
APIの説明に、無効なユーザーでは動作しないと書かれています。管理センターの説明も、無効化されたユーザーと削除から間もないユーザー(論理削除)は一覧に出ないとしています。「未登録に載っていない」ことは「登録済み」を意味しません。無効化中のアカウントを再有効化する運用があるなら、その一覧は別に取ります。
もう1つは鮮度です。レポートは多くのユーザーで36時間以内に更新され、リアルタイムではありません。登録を済ませた直後の人が、数時間は未登録のまま表示される場合があります。案内を送る前に、取得日時とlastUpdatedDateTimeを表に残しておきます。
Graphから何を取るか
$filterはisMfaCapableとisMfaRegisteredにeqで使えます。未登録だけなら、サーバー側で絞れます。
GET https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails
?$filter=isMfaRegistered eq falseただし、この記事では絞らずに全件を取り、判定を手元で行います。理由は2つです。1つ目は、「登録済みだがポリシー上は使えない」層と「弱い方式だけ」の層がisMfaRegistered eq falseでは出てこないこと。methodsRegisteredの中身で判定する分類も、サーバー側の絞り込みでは作れません。2つ目は、全件のJSONがあれば条件を変えて何度でも再集計できることです。
結果が複数ページに分かれるときは、応答の@odata.nextLinkをそのまま次のリクエストに使います。URLから$skiptokenだけを抜き出して使い回す方法は、Microsoft Graphのページング説明で避けるよう書かれています。取得したページはreg-1.json、reg-2.jsonのように保存します。
絞り込みの使い分けと、使える環境
$filterに使える項目は、isMfaCapable、isMfaRegistered、isSsprEnabled、isSsprRegistered、methodsRegistered、userDisplayName、userPrincipalNameです。演算子は、方式の一覧とUPN・表示名を除いてeqだけです。UPNと表示名はstartswith()も使えます。
| 知りたいこと | 条件の書き方 |
|---|---|
| MFAとして使えない利用者すべて | 条件の書き方isMfaCapable eq false |
| MFAを1つも登録していない利用者 | 条件の書き方isMfaRegistered eq false |
| 特定の方式を登録している利用者 | 条件の書き方methodsRegistered/any(x:x eq 'email') |
| 特定のUPNで始まる利用者 | 条件の書き方startswith(userPrincipalName, '...') |
「MFAとして使えない利用者すべて」は、未登録と、登録済みでもポリシー上使えない層の合計です。内訳を分けたいなら、全件を取ってから手元で分類します。
このAPIは、グローバル、US Government L4、US Government L5(DOD)、21Vianetが運営する中国の各クラウドで提供されています。v1.0のほかにbetaも公開されていますが、この記事のスクリプトはv1.0の項目だけを使います。
部署名を突き合わせる
部署名はこのレポートに含まれません。リソース型の項目表に、部署を表す項目が無いためです。人事名簿などから「UPN、部署、案内先」のCSVを用意し、UPNで突き合わせます。
upn,department,contact
alland@contoso.com,営業,sales-lead@contoso.com「弱い方式だけ」の基準は自分で決める
APIが返すのは、方式の名前と、強い方式を登録済みかどうかの真偽値だけです。「弱い」かどうかのラベルはありません。どの方式を弱いとみなすかは、組織のポリシーで決める事項になります。
下のスクリプトでは、冒頭のWEAK集合に方式名を並べます。例としてemailとmobilePhoneを入れていますが、これは例示の設定で、公式の分類ではありません。methodsRegisteredの値は、実際のJSONで確かめてから集合に入れてください。
登録の一覧とは別に、既定の第2要素も見られます。isSystemPreferredAuthenticationMethodEnabledがtrueなら、システムが登録済みの方式のうち最も安全なものを選びます。falseのときは、利用者が選んだuserPreferredMethodForSecondaryAuthenticationが使われます。強い方式を登録していても、既定がvoiceMobileのままの利用者は、この2項目を併せて見ると拾えます。
判定スクリプトをClaude Codeに書かせる
プロジェクトのCLAUDE.mdに、守らせる条件を書いておきます。
# MFA未登録の洗い出し
- 入力は reg-*.json(Graphの応答。value 配列)と roster.csv だけ
- ネットワークに接続しない。Graph や az を自分で呼ばない
- 出力は CSV。userType 列を含め、guest は部署の束から外して別の行にまとめる
- 通知メールの送信やユーザー変更のコマンドは書かない
- 分類は「未登録 / 登録済みだがポリシー上は使えない / 弱い方式のみ」の3つ
- 弱い方式の集合は WEAK 定数に置き、判定ロジックと分けるClaude Codeの権限設定では、Bash(curl *)のような拒否ルールでネットワーク系コマンドを止められます。Graphの資格情報をClaudeの手が届く場所に置かないための保険です。
{
"permissions": {
"deny": ["Bash(curl *)", "Bash(wget *)", "Bash(pwsh *)"]
}
}ただしBashルールは、同じプログラムのフルパス指定やsh -cの中の呼び出しには一致しません。権限ルールの説明にも、拒否ルールはコマンドの形ごとに働くもので、プログラム全体を囲む境界ではないと書かれています。厳密に止めたいときは、サンドボックスのネットワーク許可リストと併用します。
そのうえで「CLAUDE.mdのルールどおりに書いて」と頼みます。次のような形になれば条件は満たされます。コードは毎回異なる形で返るので、以下は筆者が書き、サンプルJSONで動かした例です。
mfa_gap.py(47行)
import csv, json, sys
from collections import defaultdict
# 弱いとみなす方式は組織の方針で決める(ここは例)
WEAK = {"email", "mobilePhone"}
GUEST_DEPT = "ゲスト(招待元に連絡)"
def classify(u):
methods = set(u.get("methodsRegistered") or [])
if not u.get("isMfaRegistered"):
return "未登録"
if not u.get("isMfaCapable"):
return "登録済みだがポリシー上は使えない"
if methods and methods <= WEAK:
return "弱い方式のみ"
return None
def main(roster_path, paths):
with open(roster_path, encoding="utf-8", newline="") as f:
roster = {r["upn"].lower(): r for r in csv.DictReader(f)}
rows = defaultdict(list)
for path in paths:
with open(path, encoding="utf-8") as f:
users = json.load(f)["value"]
for u in users:
kind = classify(u)
if not kind:
continue
upn = u["userPrincipalName"]
utype = u.get("userType")
r = roster.get(upn.lower(), {})
if utype == "guest":
dept, contact = GUEST_DEPT, ""
else:
dept = r.get("department") or "名簿に無い"
contact = r.get("contact", "")
rows[dept].append((kind, upn, utype, u.get("isAdmin"), contact))
out = csv.writer(sys.stdout, lineterminator="\n")
out.writerow(["department", "kind", "upn", "userType", "isAdmin", "contact"])
for dept in sorted(rows):
for kind, upn, utype, admin, contact in sorted(rows[dept]):
out.writerow([dept, kind, upn, utype, admin, contact])
main(sys.argv[1], sys.argv[2:])実行は次の形です。
python3 mfa_gap.py roster.csv reg-*.json > mfa-gap.csv動作確認 — 公式サンプルで3種類を再現する
Microsoft Learnの応答例には3人のユーザーが載っています。2人目のAllanD@Contoso.comはuserTypeがguestで、isMfaRegisteredもisMfaCapableもfalse、methodsRegisteredが空という未登録の典型です。残りの2人は登録済みです。この3人に「mobilePhoneだけを登録した管理者」ErinM@Contoso.comを1人加えたJSONと、ErinMだけを総務として載せた名簿で動かすと、次の出力になりました。
department,kind,upn,userType,isAdmin,contact
ゲスト(招待元に連絡),未登録,AllanD@Contoso.com,guest,False,
総務,弱い方式のみ,ErinM@Contoso.com,member,True,hr-lead@contoso.comゲストは名簿に載っているかどうかにかかわらず、部署の束から外れて別の行にまとまります。部署の担当者あての案内に混ざらないよう、userTypeを出力に残しています。メンバーで名簿に無いUPNは「名簿に無い」の行に入ります。この行が増えたら、名簿の更新が追いついていないサインです。大文字小文字はUPNの照合時に揃えています。
未登録の一覧から、案内できる表へ
Graphの応答そのまま
UPNと真偽値の羅列です。誰に連絡するのか、管理者アカウントなのかは別途調べる必要があります。
部署別のCSV
部署、分類、UPN、管理者かどうか、案内先が1行に並びます。部署ごとに分けて渡せます。
案内文の下書きと、送る前の線引き
CSVができたら、部署ごとの案内文をClaude Codeに下書きさせます。渡すのはmfa-gap.csvの該当部署の行だけにします。依頼の文面は、次のように範囲を絞ります。
mfa-gap.csv の「営業」の行について、案内先の担当者あてに
MFA登録のお願い文を書いてください。個人名は書かず、人数と
分類ごとの内訳だけを本文に入れ、対象者のUPNは添付CSVに回して。線引きは3つです。管理者アカウントの行(isAdminがtrue)は、部署の案内とは別に、管理者向けの連絡にまとめます。管理者が強い方式を持たない状態は、一般ユーザーより影響が大きいためです。次に、guestの行は、スクリプトが「ゲスト(招待元に連絡)」の行にまとめます。部署の担当ではなく招待元に連絡します。最後に、メールの送信や、ユーザー設定の変更はClaude Codeに実行させません。
再実行のたびに確認すること
定期的に回すなら、毎回同じ点を確かめます。
再実行前の確認
- 取得から36時間以内に登録した人は、まだ未登録に出る場合がある
- 無効化中のアカウントは対象外なので、別の棚卸しで補う
WEAK集合は、認証方法ポリシーを変えたら見直す- 名簿に無いUPNの行数を、前回と比べる
同じ手法で、ゲストの棚卸しはEntraゲストユーザーの棚卸しをClaude Codeで進める、アプリ登録のシークレット期限はClaude CodeでEntraのシークレット期限切れを一覧にする手順に書いています。サインインログ側から不審な認証を探す方法はEntra IDのサインインログをKQLで調べるが扱っています。
まとめ
MFA未登録の洗い出しは、isMfaRegisteredだけでは足りません。isMfaCapableとの差を見て、ポリシー上使えない登録を拾います。弱い方式の基準はAPIが決めてくれないので、組織の側でWEAK集合を決めます。
取得と送付は人が行い、Claude Codeには入力ファイルの範囲内で集計と下書きを任せる。この線引きなら、読み取り専用の権限一つで回せます。