AUTOMATION NOTE — 076

共有ドライブの「幽霊メンバー」をGASで自動検出し権限を棚卸しする実践ガイド

Google Workspaceの共有ドライブにおいて、人の異動や退職後にアクセス権が残り続ける「幽霊メンバー」は、セキュリティリスクや管理コスト増大の原因となります。本記事では、Google Apps Script (GAS) とAdmin SDK Reports API、Drive APIを組み合わせ、共有ドライブに長期間アクセスしていないメンバーを自動で検出し、権限を棚卸しする具体的な方法を解説します。

この記事を読んだほうが良い人

  • 100名規模の企業でGoogle Workspaceを運用している情シス担当者
  • 共有ドライブの権限管理が手作業で追いつかず、自動化を検討している方
  • 非アクティブな共有ドライブメンバーを特定し、セキュリティリスクを低減したい方
  • GASを使ったGoogle Workspaceの管理業務自動化に興味がある方

共有ドライブの「幽霊メンバー」問題とは

Google Workspaceを利用する多くの企業で、共有ドライブはチームやプロジェクトのファイル共有に不可欠なツールです。しかし、メンバーの異動や退職があった際に、共有ドライブからそのユーザーのアクセス権が適切に削除されず、そのまま残り続けるケースが頻繁に発生します。

このような「幽霊メンバー」が共有ドライブに残存することには、いくつかの問題があります。

  • セキュリティリスク: 退職者が誤って、あるいは悪意を持って機密情報にアクセスする可能性。
  • コンプライアンス違反: 監査時に不要なアクセス権が指摘されるリスク。
  • 管理の複雑化: 誰がどの共有ドライブにアクセスできるのかが不明瞭になり、管理が煩雑になる。
  • ライセンスコスト: 不要な権限が付与されたままのユーザーが多ければ、無駄なライセンス費用が発生する可能性。

既存の共有ドライブ管理に関する記事では、ドライブそのものの非アクティブ化を主題とすることが多いですが、本記事ではアクティブな共有ドライブ内に残る個別のメンバーに着目し、そのメンバーのアクセス頻度を分析して権限を整理するアプローチを取ります。

GASによる自動検出・棚卸しの全体像

この自動化ワークフローは、以下の3つの主要なステップで構成されます。

  1. Reports APIで共有ドライブのアクセスログを取得: 指定した期間内の共有ドライブに対するユーザーの操作ログ(閲覧、編集、ダウンロードなど)を収集します。
  2. 取得ログから非アクティブメンバーを特定: 収集したログを分析し、特定の期間(例: 過去90日)に共有ドライブへのアクセスがなかったユーザーを「非アクティブメンバー」としてリストアップします。
  3. Drive APIで非アクティブメンバーの権限を棚卸し: 特定された非アクティブメンバーの共有ドライブアクセス権を、Drive APIを使用して削除または変更します。

これらの処理をGoogle Apps Script (GAS) で実装し、スプレッドシートを介して結果の確認と安全な権限操作を可能にします。

ステップ1: Reports APIで共有ドライブのアクセスログを取得する

まず、GASからAdmin SDK Reports APIを呼び出し、共有ドライブのアクセスログを取得します。このAPIを使用することで、Google Workspaceドメイン内のユーザー活動に関する詳細なレポートを取得できます。

Reports APIの有効化と権限

GASプロジェクトでAdmin SDK Reports APIを利用するには、以下の準備が必要です。

  1. GASプロジェクトの作成: Googleドライブ上で「新規」>「その他」>「Google Apps Script」を選択し、新しいプロジェクトを作成します。
  2. Advanced Google servicesの有効化: GASエディタの左側メニュー「サービス」アイコン(+ボタン)をクリックし、「Admin SDK」を選択して有効化します。
  3. APIの有効化: Google Cloud Platform (GCP) プロジェクトでもAdmin SDK APIを有効にする必要があります。GASプロジェクトの設定画面からGCPプロジェクト番号を確認し、GCPコンソールで「APIとサービス」>「ライブラリ」から「Admin SDK API」を検索して有効化してください。
  4. 実行ユーザーの権限: このスクリプトを実行するユーザーは、Google Workspaceドメインの管理者権限を持つ必要があります。

ログ取得の対象と範囲(90日間の根拠)

アクセスログの取得期間は、企業のセキュリティポリシーや管理方針によって異なります。本記事では、一般的な非アクティブ期間として「過去90日間」を閾値とします。これは、多くの企業のセキュリティポリシーや、Google Workspaceの監査ログ保持期間(一部のログは最長1年間)を考慮した上で、実運用上妥当な期間と判断されるためです。90日を過ぎてもアクセスがないユーザーは、実質的にその共有ドライブの利用が不要になっている可能性が高いと考えられます。

取得するイベントタイプとしては、共有ドライブに対するユーザーのviewdownloadeditなどの操作を対象とします。

GASスクリプト例: アクセスログの取得とスプレッドシート出力

以下のGASスクリプトは、指定した期間内の共有ドライブのアクセスログを取得し、スプレッドシートに出力します。

/**
 * Advanced Google Services: Admin SDK (Reports API), Drive API を有効にしてください。
 * スクリプトを実行するユーザーはGoogle Workspaceの管理者権限が必要です。
 */

const SPREADSHEET_ID = 'YOUR_SPREADSHEET_ID'; // 出力先のスプレッドシートID
const SHEET_NAME = 'SharedDriveActivity'; // 出力先のシート名
const DAYS_THRESHOLD = 90; // 非アクティブと判断する日数(例: 90日)

function getSharedDriveActivityAndOutput() {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
  if (!sheet) {
    SpreadsheetApp.openById(SPREADSHEET_ID).insertSheet(SHEET_NAME);
    const newSheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
    newSheet.appendRow(['共有ドライブID', '共有ドライブ名', 'ユーザーメール', '最終アクセス日時', 'イベントタイプ']);
  }

  // 取得対象期間の設定
  const endDate = new Date();
  const startDate = new Date();
  startDate.setDate(endDate.getDate() - DAYS_THRESHOLD);

  const startTime = startDate.toISOString();
  const endTime = endDate.toISOString();

  const activities = [];
  let pageToken;

  Logger.log(`共有ドライブ活動ログを ${startDate.toLocaleDateString()} から ${endDate.toLocaleDateString()} まで取得中...`);

  try {
    do {
      const response = AdminReports.Activities.list(
        'all', // 全ユーザーを対象
        'drive', // ドライブ関連のアクティビティ
        {
          applicationName: 'drive',
          eventName: 'view', // 閲覧イベントを主に取得('access'はReports APIには存在しません)
          startTime: startTime,
          endTime: endTime,
          maxResults: 1000,
          pageToken: pageToken
        }
      );

      if (response.items) {
        response.items.forEach(activity => {
          const event = activity.events.find(e => e.type === 'drive' && e.name === 'view'); // 'access'を'view'に変更
          if (event) {
            const primaryEvent = event.parameters.find(p => p.name === 'primary_event' && p.value === 'true');
            if (primaryEvent) {
              const sharedDriveIdParam = event.parameters.find(p => p.name === 'shared_drive_id');
              const docIdParam = event.parameters.find(p => p.name === 'doc_id'); // ファイルID
              const actorEmail = activity.actor.email;
              const timestamp = new Date(activity.id.time);
              const eventType = event.name;

              let sharedDriveId = sharedDriveIdParam ? sharedDriveIdParam.value : null;

              // ファイルへのアクセスの場合、そのファイルが共有ドライブ内にあるか確認し、共有ドライブIDを取得
              if (!sharedDriveId && docIdParam) {
                try {
                  const file = Drive.Files.get(docIdParam.value, { fields: 'parents,sharedDriveId' });
                  if (file.sharedDriveId) {
                    sharedDriveId = file.sharedDriveId;
                  }
                } catch (e) {
                  Logger.log(`ファイルID ${docIdParam.value} の情報取得中にエラー: ${e.message}`);
                }
              }

              if (sharedDriveId) {
                activities.push({
                  sharedDriveId: sharedDriveId,
                  userEmail: actorEmail,
                  timestamp: timestamp,
                  eventType: eventType
                });
              }
            }
          }
        });
      }
      pageToken = response.nextPageToken;
    } while (pageToken);

    // 共有ドライブ名を取得
    const uniqueSharedDriveIds = [...new Set(activities.map(a => a.sharedDriveId))];
    const sharedDriveNames = {};
    uniqueSharedDriveIds.forEach(id => {
      try {
        const drive = Drive.Drives.get(id, { fields: 'name' });
        sharedDriveNames[id] = drive.name;
      } catch (e) {
        Logger.log(`共有ドライブID ${id} の名前取得中にエラー: ${e.message}`);
        sharedDriveNames[id] = '取得失敗または削除済み';
      }
    });

    const dataToWrite = activities.map(a => [
      a.sharedDriveId,
      sharedDriveNames[a.sharedDriveId] || '名前不明',
      a.userEmail,
      a.timestamp.toLocaleString(),
      a.eventType
    ]);

    // シートをクリアして新しいデータを書き込む
    sheet.clearContents();
    sheet.appendRow(['共有ドライブID', '共有ドライブ名', 'ユーザーメール', '最終アクセス日時', 'イベントタイプ']);
    if (dataToWrite.length > 0) {
      sheet.getRange(sheet.getLastRow() + 1, 1, dataToWrite.length, dataToWrite[0].length).setValues(dataToWrite);
    }
    Logger.log('共有ドライブ活動ログの出力が完了しました。');

  } catch (e) {
    Logger.log(`スクリプト実行中にエラーが発生しました: ${e.message}`);
  }
}

スクリプトの解説

  • SPREADSHEET_IDSHEET_NAMEは、ご自身の環境に合わせて設定してください。
  • AdminReports.Activities.list()メソッドを使用して、過去DAYS_THRESHOLD日間のドライブ活動ログを取得します。'all'はドメイン内の全ユーザーを対象とします。
  • eventName: 'view'は、共有ドライブへの閲覧イベントをフィルタリングするためのものです。必要に応じてeditdownloadなどのイベントも追加できます。
  • 取得したログからshared_drive_idを抽出し、共有ドライブ名をDrive APIで取得してスプレッドシートに出力します。ファイルへのアクセスログの場合、そのファイルがどの共有ドライブに属しているかを特定する処理も含まれています。
  • 最終的に、共有ドライブID共有ドライブ名ユーザーメール最終アクセス日時イベントタイプをスプレッドシートに書き出します。

ステップ2: 取得ログから非アクティブメンバーを特定する

ステップ1で出力されたスプレッドシートのデータから、非アクティブなメンバーを特定します。スプレッドシートのフィルタ機能や簡単なGAS関数で実現できます。

スプレッドシート出力サンプルのカラム定義

カラム名 説明
共有ドライブID 共有ドライブの一意のID
共有ドライブ名 共有ドライブの名称
ユーザーメール アクセスしたユーザーのメールアドレス
最終アクセス日時 ユーザーが共有ドライブに最後にアクセスした日時(UTC)
イベントタイプ 発生したイベントの種類(例: view, edit, download

非アクティブ判定ロジック

スプレッドシートにデータが出力されたら、以下の手順で非アクティブメンバーを特定します。

  1. データの集計: 各共有ドライブIDとユーザーメールの組み合わせごとに、最終アクセス日時の最新値を取得します。
  2. 閾値との比較: 最新のアクセス日時が、現在からDAYS_THRESHOLD(例: 90日)よりも古い場合、そのユーザーはその共有ドライブにおいて「非アクティブ」と判断します。

この集計は、スプレッドシートのピボットテーブル機能で効率的に実行できます。ピボットテーブルを作成し、行に「共有ドライブID」と「ユーザーメール」、値に「最終アクセス日時」を「最大」で設定することで、各ユーザーの共有ドライブごとの最終アクセス日時を簡単に集計できます。集計されたデータに対して、日付フィルタを適用することで非アクティブメンバーを特定できます。

ステップ3: 非アクティブメンバーの権限を棚卸しするGASスクリプト

非アクティブメンバーのリストが特定できたら、Drive APIを使ってそのメンバーの権限を削除または変更します。この操作は元に戻せないため、非常に慎重に進める必要があります。

GAS開発環境の準備

Admin SDK同様にDrive APIもGASプロジェクトで有効化します。 1. Advanced Google servicesの有効化: GASエディタの左側メニュー「サービス」アイコン(+ボタン)をクリックし、「Drive API」を選択して有効化します。

スクリプト例: 非アクティブメンバーの権限を削除(または変更)する

以下のスクリプトは、非アクティブメンバーリストが記載されたスプレッドシートの情報を読み込み、共有ドライブの権限を操作します。

/**
 * Advanced Google Services: Drive API を有効にしてください。
 * スクリプトを実行するユーザーは、対象の共有ドライブに対して「管理者」権限を持つ必要があります。
 */

const SPREADSHEET_ID_INACTIVE_MEMBERS = 'YOUR_SPREADSHEET_ID_FOR_INACTIVE_MEMBERS'; // 非アクティブメンバーがリストされたスプレッドシートID
const SHEET_NAME_INACTIVE_MEMBERS = 'InactiveMembers'; // 非アクティブメンバーがリストされたシート名
const COLUMN_SHARED_DRIVE_ID = 1; // 共有ドライブIDが記載されている列 (1-indexed)
const COLUMN_USER_EMAIL = 3;     // ユーザーメールが記載されている列 (1-indexed)
const COLUMN_ACTION_STATUS = 6;  // 処理結果を書き込む列 (1-indexed)

function removeInactiveSharedDrivePermissions() {
  const sheet = SpreadsheetApp.openById(SPREADSHEET_ID_INACTIVE_MEMBERS).getSheetByName(SHEET_NAME_INACTIVE_MEMBERS);
  if (!sheet) {
    Logger.log(`シート「${SHEET_NAME_INACTIVE_MEMBERS}」が見つかりませんでした。`);
    return;
  }

  const data = sheet.getDataRange().getValues();
  const header = data.shift(); // ヘッダー行を削除

  // 処理結果を書き込むための配列
  const results = Array(data.length).fill(['未処理']);

  Logger.log('非アクティブメンバーの権限削除処理を開始します。');

  for (let i = 0; i < data.length; i++) {
    const row = data[i];
    const sharedDriveId = row[COLUMN_SHARED_DRIVE_ID - 1];
    const userEmail = row[COLUMN_USER_EMAIL - 1];

    if (!sharedDriveId || !userEmail) {
      results[i] = ['共有ドライブIDまたはユーザーメールが不正です。'];
      continue;
    }

    try {
      // 共有ドライブのパーミッションをリストアップ
      const permissions = Drive.Permissions.list(sharedDriveId, {
        supportsAllDrives: true,
        fields: 'permissions(id,emailAddress,role,type)'
      });

      let permissionFound = false;
      if (permissions.permissions) {
        for (const perm of permissions.permissions) {
          if (perm.emailAddress === userEmail && perm.type === 'user') {
            Logger.log(`共有ドライブID: ${sharedDriveId}, ユーザー: ${userEmail}, 権限ID: ${perm.id}, ロール: ${perm.role} の権限を削除します。`);

            // ★★★ 警告: 以下の行をコメントアウトすると実際に権限が削除されます ★★★
            // Drive.Permissions.remove(sharedDriveId, perm.id, { supportsAllDrives: true });
            // Logger.log(`権限を削除しました。共有ドライブID: ${sharedDriveId}, ユーザー: ${userEmail}`);
            // results[i] = ['削除済み'];

            // ★★★ 代替案: 権限を「閲覧者」に変更する場合(削除ではなく権限を弱める) ★★★
            // const newPermission = {
            //   role: 'reader' // 'reader', 'commenter', 'writer', 'organizer'
            // };
            // Drive.Permissions.update(sharedDriveId, perm.id, newPermission, { supportsAllDrives: true, fields: 'id,emailAddress,role' });
            // Logger.log(`権限を「${newPermission.role}」に変更しました。共有ドライブID: ${sharedDriveId}, ユーザー: ${userEmail}`);
            // results[i] = [`権限変更済み (${newPermission.role})`];

            // ★★★ 安全のため、まずはログ出力のみに留める ★★★
            Logger.log(`[シミュレーション] 権限削除対象: 共有ドライブID: ${sharedDriveId}, ユーザー: ${userEmail}, 権限ID: ${perm.id}, ロール: ${perm.role}`);
            results[i] = ['シミュレーション対象'];

            permissionFound = true;
            break; // 該当ユーザーの権限が見つかったら次へ
          }
        }
      }

      if (!permissionFound) {
        results[i] = ['該当ユーザーの権限が見つかりませんでした。'];
      }

    } catch (e) {
      Logger.log(`権限削除中にエラーが発生しました。共有ドライブID: ${sharedDriveId}, ユーザー: ${userEmail}, エラー: ${e.message}`);
      results[i] = [`エラー: ${e.message}`];
    }
  }

  // 処理結果をスプレッドシートに書き戻す
  if (data.length > 0) {
    sheet.getRange(2, COLUMN_ACTION_STATUS, results.length, 1).setValues(results);
  }
  Logger.log('非アクティブメンバーの権限削除処理が完了しました。');
}

コード解説と安全な運用

  • SPREADSHEET_ID_INACTIVE_MEMBERSSHEET_NAME_INACTIVE_MEMBERSには、ステップ2で作成した非アクティブメンバーリストのスプレッドシート情報を設定してください。
  • Drive.Permissions.list()で共有ドライブの全メンバー権限を取得し、Drive.Permissions.remove()で指定したユーザーの権限を削除します。共有ドライブの権限を操作する場合、supportsAllDrives: trueオプションは必須です。
  • 重要: Drive.Permissions.remove()の行は、デフォルトではコメントアウトされています。実際に権限を削除する前に、必ず十分にテストし、手動での確認プロセスを経てからコメントアウトを解除してください。
  • 誤削除を防ぐため、権限を削除する代わりに、Drive.Permissions.update()を使用して権限を「閲覧者 (reader)」などのより制限されたロールに変更することも検討できます。スクリプト内にその代替案もコメントアウトで記載しています。Drive.Permissions.update()supportsAllDrives: trueオプションが必要です。
  • スクリプトは処理結果をスプレッドシートのCOLUMN_ACTION_STATUS列に書き込みます。これにより、どの権限がどのように処理されたかを追跡できます。

安全な運用と誤削除防止の考慮点

権限の自動削除は非常に強力な機能であるため、以下の点に注意して運用することが重要です。

  • 削除前の手動確認プロセス: スクリプトを実行する前に、非アクティブメンバーリストを情シス担当者や該当部署の責任者が必ず確認するステップを設けてください。
  • ソフト削除の検討: いきなり権限を削除するのではなく、まずは「閲覧者」ロールに変更するなど、より制限された権限にダウングレードする「ソフト削除」を検討しましょう。一定期間後に問題がなければ完全に削除する運用も可能です。
  • 通知と記録: 権限を変更・削除した際には、対象ユーザーや共有ドライブの管理者に自動で通知を送る仕組みを導入し、変更履歴を記録として残すことが推奨されます。
  • 監査ログの活用: Google Workspaceの監査ログは、権限変更の履歴を確認するための重要な情報源です。定期的に監査ログを確認し、不正な変更がないか監視しましょう。

月次自動実行トリガーの設定

このスクリプトを定期的に実行することで、共有ドライブの権限棚卸しプロセスを自動化できます。GASのトリガー機能を使用します。

  1. GASエディタの左側メニュー「トリガー」アイコン(時計のアイコン)をクリックします。
  2. 「トリトリガーを追加」ボタンをクリックします。
  3. 以下の設定を行います。
    • 実行する関数を選択: getSharedDriveActivityAndOutput を選択。
    • イベントのソースを選択: 「時間主導型」を選択。
    • 時間ベースのトリ種類を選択: 「月ベースのタイマー」を選択。
    • 日付を選択: 毎月実行したい日付を選択します(例: 毎月1日〜7日)。
    • 時刻を選択: 実行したい時刻を選択します(例: 午前2時〜3時)。
  4. 同様に、removeInactiveSharedDrivePermissions 関数についても、手動確認の後に自動実行したい場合はトリガーを設定します。ただし、この関数は慎重に運用すべきため、最初は手動実行を推奨します。

まとめ

本記事では、Google Workspaceの共有ドライブに残り続ける「幽霊メンバー」を特定し、その権限を自動で棚卸しするためのGoogle Apps Scriptを活用した実践的な方法を解説しました。Admin SDK Reports APIでアクセスログを取得し、Drive APIで権限を操作する一連のプロセスを自動化することで、情シス担当者の負担を大幅に軽減し、セキュリティリスクを低減できます。

この自動化は、共有ドライブの権限管理を継続的に改善し、常に最新でセキュアな状態を保つための強力な手段です。ただし、権限の削除は元に戻せない操作であるため、実装と運用には十分な注意とテストが不可欠です。まずはシミュレーションモードで実行し、その結果を十分に検証してから、実際の権限変更に踏み切ることを推奨します。情シスとして、このような自動化ツールを駆使し、より堅牢で効率的なGoogle Workspace環境を構築していきましょう。

コーポレートITのご相談はお気軽に

この記事で書いたような業務改善・自動化の設計から実装まで、DRASENASではコーポレートITの現場に寄り添った支援を行っています。 「まず相談だけ」でも大歓迎です。DRASENAS 公式サイトからお気軽にどうぞ。

CONTACT

御社の IT 部門、ここにあります。

「ITのことはあまりわからない」── そのような状態からで、まったく問題ございません。まずはお気軽にご相談ください。

一社ずつ、一から。