Claude Media
Entra IDのMFA未登録ユーザーをGraph APIで洗い出す — 部署別の案内表まで

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の照合時に揃えています。

前と後

未登録の一覧から、案内できる表へ

BEFORE

Graphの応答そのまま

UPNと真偽値の羅列です。誰に連絡するのか、管理者アカウントなのかは別途調べる必要があります。

AFTER

部署別の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には入力ファイルの範囲内で集計と下書きを任せる。この線引きなら、読み取り専用の権限一つで回せます。

この記事を共有:XはてブLinkedIn