Entraゲストユーザーの棚卸しをClaude Codeで進める — 90日未サインインの抽出
Graphで取ったsignInActivityから90日以上サインインしていない外部アカウントを抽出し、削除候補表と依頼元への確認文までClaude Codeで作る手順です。
Entra IDのゲストユーザーは、コラボレーションが終わっても自然には消えません。90日以上サインインしていない外部アカウントを洗い出し、依頼元の部署に「まだ要りますか」と聞く。この記事は、その棚卸しのうち集計と文章づくりをClaude Codeに任せる手順です。
分担ははっきり分けます。Graphからのデータ取得と、無効化・削除の実行は管理者が行います。Claude Codeに渡すのは、取得済みのJSONだけです。Claudeは判定スクリプトを書き、削除候補表と確認文の下書きを作ります。資格情報も書き込み権限も渡しません。
先に押さえる前提 — ライセンス・権限・ロール
signInActivityを読むには条件があります。Microsoft Learnの「inactive user accountsの管理」には、lastSuccessfulSignInDateTimeへのアクセスにMicrosoft Entra ID P1またはP2が要ると書かれています。アプリに付ける権限はAuditLog.Read.AllとUser.Read.Allです。アクティビティログを読む最小ロールはReports Readerとされています。
もう1つ、ユーザー一覧APIの説明にある一文が効きます。ゲストはこのAPIを呼べません。棚卸しを実行するのはメンバーの管理者アカウントか、アプリケーション権限を持つアプリです。
| 項目 | 必要なもの |
|---|---|
| ライセンス | 必要なものMicrosoft Entra ID P1またはP2 |
| Graph権限 | 必要なものAuditLog.Read.All と User.Read.All |
| 最小ロール(ログ閲覧) | 必要なものReports Reader |
| 呼び出し元 | 必要なものメンバーまたはアプリ(ゲストは不可) |
ここでP1/P2が無いテナントは、この手順でsignInActivityを取れません。その場合は管理センターの列表示か、別の方法を検討することになります。
Graphで何を取るか — 絞り込みの落とし穴
取得するのはゲストの全件です。リクエストはMicrosoft Learnの例に沿って$selectにsignInActivityを入れます。この項目は既定では返らないためです。棚卸しに使う項目は、表示名・UPN・メール・会社名・作成日時・招待の状態あたりです。
GET https://graph.microsoft.com/v1.0/users
?$filter=userType eq 'Guest'
&$select=id,displayName,userPrincipalName,mail,companyName,
createdDateTime,externalUserState,userType,signInActivity
&$top=500次の3点が、落とし穴になります。
取得時に踏みやすい3点
signInActivityの絞り込みは単独
signInActivityの$filterは、他の絞り込み可能な項目と一緒には使えません。「ゲストかつ90日未サインイン」をサーバー側で1回で絞る形にはできません。1ページは500件まで
signInActivityを選択または絞り込みに使うと、最大ページサイズは500になります。$topにそれ以上を指定しても、返るのは500件までです。次のページは@odata.nextLinkで辿ります。日付フィルタは記録なしを扱わない
日付で絞る例(
le 2024-06-01T00:00:00Z)は載っていますが、サインイン記録そのものが無いユーザーの扱いは書かれていません。
3つ目が実務では厄介です。サーバー側で日付を絞ると、招待したきり一度も使われていないゲストを取りこぼす恐れがあります。そこで日付の判定はサーバーに任せず、全件を取ってから手元のスクリプトで行います。
$filter=userType eq 'Guest'が環境によって通らないときは、フィルタを外して全ユーザーを取り、スクリプト側でゲストだけ残します。後で書くスクリプトはどちらでも動きます。
取得したページごとのvalueを、guests-1.json、guests-2.jsonのように保存します。保存の方法はGraph Explorer、PowerShell、任意のHTTPクライアントのどれでも構いません。公式のPowerShell例はGet-MgUser -Property "displayName,userPrincipalName,signInActivity"の形です。
「90日未サインイン」の基準日を決める
どの日付を基準にするかで、候補の顔ぶれが変わります。signInActivityには似た名前の項目が3つあります。
| 項目 | 記録されるもの |
|---|---|
lastSignInDateTime | 記録されるもの最後の対話型サインインの試行(失敗も含む) |
lastNonInteractiveSignInDateTime | 記録されるもの最後の非対話型サインインの試行 |
lastSuccessfulSignInDateTime | 記録されるもの最後に成功したサインイン(対話型・非対話型を問わない) |
lastSignInDateTimeは失敗した試行でも更新されます。Microsoft Learnにも、実際の利用を反映しない場合があるため、アクセスの追跡にはlastSuccessfulSignInDateTimeを使うよう書かれています。パスワードを推測されて失敗が続いているだけのゲストが、「最近使われている」と判定されてしまう形です。
基準はlastSuccessfulSignInDateTimeにします。ただし、この項目には2023年12月1日の提供開始以前のデータがありません。過去分への補完(バックフィル)はされないと書かれています。そのため、成功の記録が空でも、最後の試行日時が入っているゲストはいます。
そこで判定の順序を次のように決めます。
基準日の決め方
- 1
最後の成功サインイン
lastSuccessfulSignInDateTimeがあれば、それを使います。 - 2
最後の試行
成功の記録が空なら、
lastSignInDateTimeとlastNonInteractiveSignInDateTimeの新しいほうを使います。成功したかどうかは分からないので、表に「試行日」と明記します。 - 3
作成日時
サインインの記録が何も無いゲストは
createdDateTimeを使います。Entra管理センターの非アクティブゲストのレポートも、一度もサインインしていないユーザーは作成日から日数を数えます。 - 4
どれも空なら要確認
作成日時も空のユーザーは、日数を出さずに「要確認」として表に残します。
createdDateTimeは、2018年6月以前に作られた一部のユーザーでnullになります。
閾値の90日は、管理センターの非アクティブゲストレポートの既定値と同じです。Microsoft Learnには、現実的な窓は90〜180日程度だとも書かれています。長期休暇や四半期ごとの業務を考えると、取引先の特性に合わせた調整が要ります。
判定スクリプトをClaude Codeに書かせる
プロジェクトのCLAUDE.mdに、守らせる条件を先に書きます。
# ゲスト棚卸しの作業ルール
- 入力は guests-*.json(Graph の応答。value 配列)だけを読む
- ネットワークに接続しない。Graph や az を自分で呼ばない
- 出力は CSV。ユーザーの無効化・削除に当たるコマンドは書かない
- 基準日は lastSuccessful → 試行日 → createdDateTime の順
- 90日以上を候補とする(90日ちょうどを含む)あわせて、.claude/settings.jsonでネットワーク系のコマンドを止めます。Claude Codeの権限ルールでは、Bash(curl *)のような拒否ルールでBashからのネットワーク呼び出しを止める構成が紹介されています。Graphの資格情報をClaudeの手が届く場所に置かないための保険です。
{
"permissions": {
"deny": ["Bash(curl *)", "Bash(wget *)", "Bash(pwsh *)"]
}
}ただしこのルールは、sh -cの中や別のパスから呼ばれたコマンドには効きません。Claude Codeの権限ルールの説明にも、同じ注意があります。厳密に止めたいなら、サンドボックスのネットワーク許可リストと組み合わせます。
そのうえで「CLAUDE.mdのルールどおりに判定スクリプトを書いて」と頼みます。次のような形のスクリプトが出てくれば、条件は満たされています(実際の出力は毎回異なります。以下は筆者が書いて動かした例です)。
guest_review.py(42行)
import csv, json, sys
from datetime import datetime
THRESHOLD_DAYS = 90
def parse(ts):
return datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else None
def pick_basis(user):
act = user.get("signInActivity") or {}
ok = parse(act.get("lastSuccessfulSignInDateTime"))
if ok:
return "lastSuccessful", ok
tries = [parse(act.get(k)) for k in
("lastSignInDateTime", "lastNonInteractiveSignInDateTime")]
tries = [t for t in tries if t]
if tries:
return "attempt", max(tries)
created = parse(user.get("createdDateTime"))
return ("created", created) if created else ("none", None)
def main(as_of, paths):
now = parse(as_of)
out = csv.writer(sys.stdout, lineterminator="\n")
out.writerow(["name", "company", "state", "basis", "days"])
for path in paths:
with open(path, encoding="utf-8") as f:
users = json.load(f)["value"]
for u in users:
if u.get("userType") != "Guest":
continue
basis, base = pick_basis(u)
days = (now - base).days if base else ""
if base is None or days >= THRESHOLD_DAYS:
out.writerow([u.get("displayName"), u.get("companyName"),
u.get("externalUserState"), basis, days])
main(sys.argv[1], sys.argv[2:])基準日を引数で受ける作りにしておくと、後述のテストで日付を固定できます。
偽データで境界を確かめる
書かせたスクリプトは、本番の応答に当てる前に架空のデータで検証します。日付の境界、記録なしのゲスト、メンバーの混入という、間違えやすい7パターンをJSONに起こします。基準日は2026年10月20日です。
| 名前 | 内容 | 期待 |
|---|---|---|
| A | 内容成功サインインがちょうど90日前 | 期待候補 |
| B | 内容成功サインインが89日前 | 期待対象外 |
| C | 内容成功の記録なし、試行が200日前 | 期待候補(試行日基準) |
| D | 内容記録なし、招待未承諾、作成が120日前 | 期待候補(作成日基準) |
| E | 内容記録なし、作成が10日前 | 期待対象外 |
| F | 内容Member、最終サインインが400日前 | 期待対象外 |
| G | 内容成功が200日前、失敗の試行が2日前 | 期待候補 |
Claude Codeにはこの表を渡し、「架空のJSONを作り、スクリプトの出力が期待どおりか確かめて」と頼みます。上のスクリプトで実行した結果は次のとおりです。
python3 guest_review.py 2026-10-20T00:00:00+00:00 fixture.jsonname,company,state,basis,days
A-90days,Contoso,,lastSuccessful,90
C-nosuccess,,,attempt,200
D-never-old,,PendingAcceptance,created,120
G-failed-recent,,,lastSuccessful,200
H-nodata,,,none,Hは、全項目が空のゲストを追加で入れた行です。日数が空のまま「要確認」として出ています。Bの89日前、Eの新しい招待、Fのメンバーは出力に入りません。Gは失敗の試行が2日前でも、成功が200日前なので候補に残ります。
ここでlastSignInDateTimeを基準にした実装を書かせると、Gは対象外になります。試行日と成功日の違いは、テストに入れて初めて実装に効いてきます。
落とし穴
日数の数え方で境界がずれる
「90日未サインイン」を> 90と書くか>= 90と書くかで、Aの扱いが変わります。CLAUDE.mdに「90日ちょうどを含む」と書いておかないと、Claudeは自分で解釈を選びます。テストの表で境界の行が通るかを見てから、本番の応答に当てます。
削除候補表と確認文を作る
本番の応答を判定にかけたら、CSVをもとにClaude Codeで削除候補表を作らせます。列は、表示名・会社名・招待の状態・基準日の種類・日数に加えて、「依頼元部署」と「回答」の空欄です。
依頼元は、Graphの項目から自動では決まりません。companyNameは、ゲストがどの会社の人かを書く項目です。社内のどの部署が招待したのかは、別の情報になります。Microsoft Learnのuserリソースには、ユーザーに紐づくsponsorsという関係も載っています。招待時にスポンサーを設定している運用なら、そこから辿れます。設定していないなら、プロジェクト名簿や招待時のチケットなど、社内の台帳と突き合わせることになります。
部署ごとの確認文は、候補表を渡して下書きさせます。
候補表(csv)を部署ごとに分け、依頼元宛ての確認文を作って。
- 各部署の宛名は空欄にする
- ゲストごとに表示名・会社名・最終サインインの基準日と日数を載せる
- 試行日基準の行は「成功したかは不明」と書き添える
- 回答期限は空欄。「回答がなければ無効化の対象にする」と明記する
- 送信はしない。文面だけを出す下書きを読むときは、2点を見ます。「試行日基準」と「作成日基準」の行に、注記が付いているか。件数が表と一致しているか。確認文は、依頼元がゲスト本人の氏名を見て判断するものです。メールアドレスなど必要以上の個人情報を載せていないかも確認します。
無効化から削除までの順序
Claude Codeが作るのは候補までです。実行は管理者が、回答の返ってきたものから順に行います。
削除の前に、サインインを止めるステップを挟みます。Microsoft Learnのアクセスレビューの説明にも、拒否されたゲストを無効化し、後で削除するという流れが載っています。Graphでは、accountEnabledの変更にUser.EnableDisableAccount.AllとUser.Read.Allの組み合わせが最小権限です。
削除は取り消せないわけではありません。ユーザーを削除すると、メールボックスやライセンスの割り当てを含むリソースが一時的なコンテナに移り、30日以内なら復元できます。復元すると、所属していたグループにも戻ります。30日を過ぎると完全に削除されます。それでも、回答待ちの候補をまとめて消すことは避け、無効化から様子を見る運用が無理がありません。
標準機能で足りるか — インアクティブゲストレポートとの違い
ここまでの手順は、Entra側の標準機能と重なります。Microsoft Entra ID GovernanceまたはEntra Suiteのライセンスがあれば、ID Governanceのダッシュボードに非アクティブゲストのレポートがあります。既定の閾値は90日で、変更できます。
アクセスレビューを使うと、ゲスト本人に利用継続を申告させ、2段階目の審査者が決める形にもできます。非アクティブなゲストを無効化し、のちに削除する設定もあります。この機能にはID GovernanceまたはEntra Suiteのライセンスが必要です。
| 方法 | 向いている場面 | 必要なもの |
|---|---|---|
| 非アクティブゲストのレポート・アクセスレビュー | 向いている場面レビューを恒常的に回す | 必要なものID GovernanceまたはEntra Suite |
| Graph取得 + Claude Codeで集計 | 向いている場面ライセンスが無い。単発で、依頼元ごとの文面まで作りたい | 必要なものP1またはP2、AuditLog.Read.All、User.Read.All |
Graph経由の手順は、標準機能の代わりというより、ライセンスの届かない範囲を埋める選択肢です。ID Governanceがあるなら、アクセスレビューに任せるほうが、回答の回収と実行まで一続きになります。
隣接する棚卸しとの使い分け
オンプレミスのActive Directoryにある休眠アカウントは、Active Directory休眠アカウントの棚卸しをClaudeで進める手順が扱っています。属性も取得方法も異なるため、ゲストはEntra側のこの手順で見ます。
SailPointでアクセスレビューを回している組織では、ClaudeをSailPoint連携でアクセスレビューに載せる手順が入口になります。
サインインの中身を調べたいときは、Entra IDのサインインログをKQLで調べるの手順が使えます。「候補になったゲストが、直前に不審なサインインを試みていないか」を確かめる用途です。同じ「棚卸し表をClaude Codeで作る」型の例として、Zendeskマクロの棚卸しもあります。
運用の注意点
最後に、実際に回すときの注意点をまとめます。
- 取得データは、個人情報を含むファイルです。作業ディレクトリは棚卸しの期間だけにし、終えたら消します
lastSignInDateTimeの更新には最大24時間かかることがあります。直近に再開した可能性のあるゲストは、管理センターのサインインログで直接見ます- 同じ会社のゲストが複数いる場合、会社単位でまとめて確認文を出したほうが、依頼元の負担が減ります
- 閾値は組織に合わせて動かします。短くしすぎると、四半期ごとに使うゲストまで候補に入ります
まとめ
Claude Codeに任せるのは、取得済みJSONの判定、境界テスト、候補表と確認文の下書きまでです。取得と実行は管理者が持ちます。
判断が分かれるのは基準日です。失敗の試行を含むlastSignInDateTimeで見ると、実際には使われていないゲストが残ります。成功の記録、試行日、作成日の順に落とし、どれを使ったかを表に残す形にすれば、依頼元との会話が成立します。