Salesforce 公開グループの入れ子メンバーを匿名 Apex で一発取得

Salesforce 公開グループの入れ子メンバーを匿名 Apex で一発取得

みなさん、こんにちは!
Salesforceエンジニアの森川です。
今回のテーマは「公開グループ」です。本記事では、匿名 Apex(Anonymous Apex)を用いた一括展開ソリューションを紹介します。コードをコピー&ペーストして実行するだけで、入れ子構造の公開グループに含まれる全メンバーを出力できます。出力されたデータをExcelに貼り付けて集計・分析することも可能です。まずは問題点を整理し、既存の対応策と比較した上で、匿名Apexによるスマートな解決方法をステップごとに解説していきます。

1. 入れ子構造の公開グループの問題とは?

公開グループは組織内のユーザやロール、他の公開グループをまとめて扱うための機能です。例えば、営業部の公開グループAのメンバーに、支店チームの公開グループBを含めることができます。この場合、グループAには直接のメンバーとしてグループBが表示され、グループBの下位にユーザXやYが所属する 入れ子グループ 構造になります。一見便利な構造ですが、管理者にとって以下のような問題を引き起こします。

全メンバーの可視性が低い

グループAの詳細画面では、直接のメンバーとしてグループBやロール名は見えても、グループB配下のユーザX・Yまでは一覧表示されません。実際に誰がグループAに属しているのか(間接メンバーを含めて)を把握するには、グループBの画面を別途開いて確認する必要があります。

多段ネストで混乱

ネストが深くなるほど、「あるユーザがどの上位グループを通じて権限付与されているか」を追跡するのが困難になります。特に5階層以上の入れ子は避ける推奨がありますが、複数階層にまたがると管理負荷が増大します。

人的ミスのリスク

手作業で辿っていくとメンバーの見落としやチェック漏れが発生しやすく、セキュリティ上のリスク(本来含めてはいけないユーザが間接的に含まれている等)を見過ごす可能性もあります。

以上のように、入れ子構造の公開グループは権限管理を柔軟にする一方、その全メンバーを把握するには一工夫必要です。次に、Winter’25の新機能を含む既存の対処方法を見てみましょう。

2. 既存手法との比較(手動確認、Data Loader)

入れ子グループ問題に対して、管理者はいくつかの方法で対処できます。それぞれの特徴を比較してみましょう。

手動確認

公開グループの全メンバーを確認するには手作業が必要でした。具体的には、公開グループAに入れ子で含まれるグループBやロールを見つけたら、その都度Bの詳細画面を開いてユーザ一覧を確認し、さらにその下にグループがあれば…というように逐次掘り下げて確認する方法です。グループ階層が深いと非常に手間がかかり、人的ミスも起きやすくなります。

Data Loader やレポートの利用

よりテクニカルな方法として、Salesforceのデータローダーを使ってGroupMemberオブジェクト(公開グループとメンバーの中間オブジェクト)のデータをエクスポートし、Excel等で分析する方法もあります。例えば特定の公開グループIDを指定してGroupMemberをエクスポートすれば直接のメンバーIDは取得できます。しかし入れ子を考慮する場合、エクスポート結果を元にさらに対象グループのメンバーを追いかけて別途エクスポートする…というように複数回の抽出と突合が必要です。また、標準レポートでは公開グループを主対象としたものがないため、カスタムレポートタイプを作成してGroupMemberを報告する方法も考えられますが、こちらも設定に手間がかかります。

以上のように、手動確認やデータローダーでは効率や正確性に課題が残ります。そこで登場するのが、匿名Apexによる一括展開ソリューションです。

匿名 Apex での一括展開

今回提案する解決策は、匿名Apex(開発者コンソール等から一時的に実行できるApexコード)を使って、公開グループ内の全メンバーを再帰的に取得・展開する方法です。
※匿名Apexとは、クラスやトリガーとして保存せず即時に実行する簡易スクリプトです。管理者はコピー&ペーストでコードを実行できるためコーディングスキルがなくても利用できます。

一回で全メンバー取得

特定の公開グループIDを指定しさえすれば、その直下および入れ子下位に含まれる全てのユーザを一度の実行で洗い出せます。

CSV形式での出力

コードの実行結果はカンマ区切りで表示するため、そのままコピーしてCSVファイルとして保存できます。Excelに貼り付けて並べ替えやフィルタリング、ピボットテーブル集計など二次利用が容易です。

安全かつ迅速

この処理は読み取り専用のクエリで実現しており、組織のデータを変更することはありません。またUI操作に比べて圧倒的に迅速で、煩雑な手順を踏まずに結果が得られます。

次の章では、実際のApexコード例を示し、行ごとにその動作を解説します。管理者の方はコードを丸ごとコピーし、自身の環境に合わせて一部を書き換えるだけで利用可能です。

3. コード解説

実際の匿名Apexコードを基に、コメントアウトの形で説明文を付記しています。

/* =====================================================================
   公開グループ (rootGroupId) 配下の入れ子メンバーを
   完全パス付き CSV + UserId 一覧でデバッグログに出力する匿名 Apex
   ---------------------------------------------------------------------
   ⚙ 実行前に書き換えるのは ①~③ のみ
   ==================================================================== */

/* === ① 対象公開グループの Id を指定 === */
Id rootGroupId = '00G5h000004XyZA';  // 18/15 桁どちらでも OK

/* === ② 無効ユーザを除外するか? === */
Boolean activeOnly = true;           // true = IsActive = false を除外

/* === ③ 追加で取得したい User 項目 === */
List<String> extraFields = new List<String>{ 'Title', 'Department' };

/* ---------------------------------------------------------------------
   0) ルート設定:対象グループを取得し、パスマップを初期化
   ------------------------------------------------------------------- */
Group root = [
    SELECT Id, Name
    FROM   Group
    WHERE  Id = :rootGroupId
    LIMIT  1
];
/* groupPath : グループID → 階層パス文字列
   例)'00G...' => '営業本部'                                     */
Map<Id,String> groupPath = new Map<Id,String>{ root.Id => root.Name };

/* ---------------------------------------------------------------------
   1) コレクション初期化
   ------------------------------------------------------------------- */
// 今処理するグループ集合(幅優先探索のキューとして使用)
Set<Id> currentLevel = new Set<Id>{ root.Id };
// 訪問済みグループ(無限ループ防止)
Set<Id> seenGroups   = new Set<Id>{ root.Id };
// User → 直接所属している公開グループ(複数可) を保持
Map<Id,Set<Id>> userParents = new Map<Id,Set<Id>>();
// ユニークな UserId を保持(CSV末尾の UserId 一覧用)
Set<Id> allUsers     = new Set<Id>();
// 子グループ → 親グループ の逆引き(階層パス更新に使用)
Map<Id,Id> grpParent = new Map<Id,Id>();

/* ---------------------------------------------------------------------
   2) 階層走査 (BFS):SOQL 1 回 / 階層で入れ子を幅優先に展開
   ------------------------------------------------------------------- */
while (!currentLevel.isEmpty()) {

    /* 2-1) 現階層の公開グループに属する GroupMember を一括取得 */
    List<GroupMember> gms = [
        SELECT GroupId, UserOrGroupId
        FROM   GroupMember
        WHERE  GroupId IN :currentLevel
    ];
    // 次階層の子グループをキューイング
    Set<Id> nextLevel = new Set<Id>();

    /* 2-2) 取得したメンバーを1件ずつ判定 */
    for (GroupMember gm : gms) {
        Schema.SObjectType t = gm.UserOrGroupId.getSObjectType();

        if (t == User.SObjectType) {
            /* --- メンバーが User の場合 ----------------------- */
            // userParents に所属グループを追加(重複可)
            if (!userParents.containsKey(gm.UserOrGroupId))
                userParents.put(gm.UserOrGroupId, new Set<Id>());
            userParents.get(gm.UserOrGroupId).add(gm.GroupId);
            // ユニーク ID セットにも追加
            allUsers.add(gm.UserOrGroupId);

        } else if (t == Group.SObjectType && !seenGroups.contains(gm.UserOrGroupId)) {
            /* --- メンバーが “公開グループ” の場合 ------------- */
            nextLevel.add(gm.UserOrGroupId);          // 次階層へ
            seenGroups.add(gm.UserOrGroupId);         // 再訪防止
            grpParent.put(gm.UserOrGroupId, gm.GroupId); // 親を記録
        }
    }

    /* 2-3) 子グループがあれば名前を取得し、階層パスを更新 */
    if (!nextLevel.isEmpty()) {
        Map<Id,Group> childMap = new Map<Id,Group>(
            [SELECT Id, Name FROM Group WHERE Id IN :nextLevel]
        );
        for (Id childId : nextLevel) {
            // 親パス + ' > ' + 子グループ名
            String pPath = groupPath.get(grpParent.get(childId));
            groupPath.put(childId, pPath + ' > ' + childMap.get(childId).Name);
        }
    }
    // 次階層を現在の階層としてループ続行
    currentLevel = nextLevel;
}

/* ---------------------------------------------------------------------
   3) 出力フェーズ
   ------------------------------------------------------------------- */
if (allUsers.isEmpty()) {
    System.debug('★ 該当ユーザなし');
} else {

    /* 3-1) まとめて User 情報を取得(SOQL 1回) ------------- */
    List<String> cols = new List<String>{ 'Id', 'Name', 'Username', 'Email' };
    cols.addAll(extraFields);  // 追加列も選択
    String soql = 'SELECT ' + String.join(cols, ', ')
                + ' FROM User WHERE Id IN :allUsers'
                + (activeOnly ? ' AND IsActive = true' : '')
                + ' ORDER BY Name';
    // Database.query は List<SObject> で返るためキャスト
    List<User> userList = (List<User>)Database.query(soql);
    Map<Id,User> userMap = new Map<Id,User>(userList); // Id→User

    /* 3-2) CSV 生成:ヘッダ + (ユーザ × 所属グループ) --------- */
    cols.add('GroupPath');                    // 最後に GroupPath 列
    List<String> lines = new List<String>{
        String.join(cols, ',')                // ヘッダ行
    };

    for (Id uid : userParents.keySet()) {
        User u = userMap.get(uid);
        for (Id pgId : userParents.get(uid)) { // 所属グループ分ループ
            List<String> row = new List<String>();
            for (String c : cols) {
                if (c == 'GroupPath') {
                    /* 完全パス + ユーザ名 をセット
                       例)営業本部 > 東日本営業 > 佐藤 花子          */
                    String path = groupPath.get(pgId) + ' > ' + u.Name;
                    row.add('"' + path.replace('"','""') + '"');  // CSV安全化
                } else {
                    row.add('' + u.get(c));  // その他列は動的取得
                }
            }
            lines.add(String.join(row, ','));
        }
    }
    // ----- 結果をデバッグログへ出力 -----
    System.debug('\n' + String.join(lines, '\n'));

    /* 3-3) UserId CSV(重複なし)を最後に出力 --------------- */
    System.debug('\n★UserId CSV\n' + String.join(new List<Id>(allUsers), ','));
}

/* ----- ここまで -------------------------------------------------------
   ▼ 出力例 (ヘッダ抜粋)
     GroupPath,Id,Name,Username,Email,Title,Department
     営業本部 > 東日本営業 > 山田 太郎,005...,山田 太郎,...
   ▼ UserId CSV 行
     005...,005...,005...
   ------------------------------------------------------------------- */

以上のコードを実行することで、対象グループに属する全ユーザの一覧がデバッグログにCSV形式で生成されます。次のセクションでは、このコードの実行手順とログから結果を取得する方法を具体的に説明します。

4. 実行ステップ

では、上記の匿名Apexコードを実際にどのように実行し、結果を取得するかをステップバイステップで確認しましょう。Lightning環境の場合、Developer Console(開発者コンソール)を利用すると簡単です。

Developer Consoleを開く

Salesforce画面右上の歯車アイコンをクリックし、「開発者コンソール」を選択します。(※WorkBenchなど他の方法で匿名Apex実行することも可能ですが、ここでは標準のDeveloper Consoleを使用します。

匿名Apex実行ウィンドウを起動

Developer Consoleが開いたら、上部メニューの「デバッグ」から「匿名Apexの実行…」(英語環境では “Execute Anonymous”)を選択します。ショートカットキーはCtrl+E(Windows)/Cmd+E(Mac)です。するとコード入力用のダイアログが表示されます。(図:匿名Apexコード入力ダイアログの例)

コードを貼り付け

上述のコードブロック全体をコピーし、匿名Apex実行ウィンドウにペーストします。1行目のrootGroupId変数の値を自分の確認したい公開グループのIdに変更するのを忘れないでください。

実行前の設定確認

ダイアログ下部に「デバッグログを開く(Open Log)」のチェックボックスがあります。実行結果をすぐ確認するため、このオプションにチェックを入れておくことを推奨します(チェックしなくてもログは保存されますが、自動表示されません)。

匿名Apexの実行

「実行(Execute)」ボタンを押してコードを実行します。一瞬で処理は完了し、問題なければ「実行が成功しました(Success)」旨のメッセージが表示されます。同時に、新しいデバッグログがコンソール下部またはログタブに生成されます。

デバッグログの確認

ステップ4で「デバッグログを開く」にチェックしていた場合、自動的にログウィンドウが開きます。もし開かない場合は、Developer Console下部の「ログ」タブから最新のログをダブルクリックしてください。ログにはシステム情報が大量に含まれますが、USER_DEBUGという行が今回の出力結果です。

結果の抽出

ログの中から今回出力したCSV行を探します。簡単な方法として、ログ表示画面の「Debug Only」やフィルタ機能で「USER_DEBUG」や「GroupPath」といったキーワードを検索すると、コード中のSystem.debug出力箇所だけを絞り込めます(図:デバッグログでUSER_DEBUG行をフィルタしている様子)。ヘッダー行GroupPath,MemberNameと、それに続く各メンバーの行が確認できるはずです。

Raw Log を開く

処理が終わったら生成されたログを右クリックし Open Raw Log…(または Ctrl + Shift + G)を選択して、テキストビューで全内容を表示します。

ログ全文をコピー

Raw Log 画面で Ctrl + A → Ctrl + C を押し、CSV 含むログ全文を一括コピーします。

Excel に貼り付け

Excel を起動し、A1 セルを選択して Ctrl + V で貼り付けると、データがそのまま取り込まれます。

列をカンマ区切りで分割

もし列が自動分割されない場合は、Excel の [データ]タブ →[テキスト/CSV から] を開き、区切り文字に 「カンマ」 を指定して読み込めば、GroupPath と MemberName が別々の列に整形されます。

5. まとめ

いかがでしたでしょうか。

入れ子構造の公開グループにおける全メンバー把握の課題と、その解決策としての匿名Apex活用方法を解説しました。従来、Lightning管理者は入れ子グループのメンバー確認に手間をかけていましたが、本記事のソリューションを用いれば短時間で正確にメンバー一覧を取得できます。

業務上のメリットも多く、例えばアクセス権レビューの際に公開グループごとのユーザリストを即座に提出できる、入れ子構造を可視化して権限設定の妥当性を検証できる、といった使い方ができます。まさに「知っておいて損はない」管理者スキルの一つでしょう。

今後の次ステップとして、読者の皆さんには是非ご自身の環境で今回の匿名Apexコードを試してみることをお勧めします。小規模なグループで動作を確認し、問題なければ実際に運用中の公開グループにも適用してみましょう。

公開グループの入れ子メンバー展開は一見ハードルが高そうですが、匿名Apexという手段を使えば驚くほど簡単に実現できます。ぜひこの方法をマスターして、日々の権限管理業務の効率化と精度向上に役立ててください。

Salesforceカテゴリの最新記事