GWSの外部アプリ接続を制御してSlack通知+Claude分析まで自動化した話【GAS実装】
この記事でわかること
  • Google Workspaceで外部アプリへの接続を制御する設計の考え方
  • OUごとに異なる制御方針を適用する方法
  • GASでOAuth申請をSlackに自動通知する仕組みの作り方
  • ClaudeのAPIをGASから呼び出して承認判断を自動分析させる実装

ある会社でGWSのAPI制御——つまり、社員が外部アプリにGoogleアカウントを連携させることへの制御——を整備することになった。

目的はシンプルで、野良SaaSへの情報流出・意図しないデータ連携を防ぐことだ。ただ単に「全部禁止」にすれば業務が止まるし、「全部許可」では管理にならない。そこで制御方針を設計し、申請が来たら自動でSlackに通知+ClaudeのAPIで安全性を分析する仕組みまで実装した。この記事はその設計と実装の話だ。

▼背景:GWSのAPI接続が野良状態になっていた

Google AdminのAPI制御画面を開くと、社員が自由に外部サービスへのOAuth連携を行っていた状態が可視化された。ZoomがGmailとDriveにフルアクセスしていたり、用途不明のサードパーティGASがスプレッドシートを読み書きしていたりと、「誰が何を許可したか」が誰にも把握できていない状態だった。

⚠️ 放置すると何が起きるか
・退職者が使っていた外部サービスがGmailやDriveに接続したままになる
・セキュリティ審査で「外部アプリへのアクセス管理状況を説明してください」と問われても答えられない
・野良GAS(個人Gmailで作成されたスクリプト)が社内のスプレッドシートを読み書きし続ける

▼制御方針の設計

① 最低要件を決める:ログイン情報のみOK

外部アプリがGoogleアカウントに要求するスコープは大きく2種類に分けられる。

種別 スコープ例 方針
基本スコープ openid / email / profile / userinfo.email / userinfo.profile ✅ 原則許可(Googleログインのみ)
追加スコープ Gmail / Drive / Calendar / Contacts など ⚠️ 申請制・個別審査

「Googleでログイン」するだけなら基本スコープで完結する。問題になるのは、アプリがGmailを読んだりDriveを書き換えたりできるスコープを要求してくるケースだ。これを申請制にして、1件ずつ審査するという方針にした。

② OUごとに制御を分ける

全社員に同じ制御をかけるのではなく、OUの性質に応じて適用ルールを変えた。

対象OU 制御方針
正社員・マネージャー等 基本スコープは自由に許可。追加スコープは申請制
業務委託・インターン・サービスアカウント 基本スコープを含む全アクセスを申請制(より厳格に管理)

業務委託やサービスアカウントは特に管理が抜けやすいので、こちらは全スコープ申請制にした。

③ 申請が来たらSlackに通知+Claude分析

制御設定を入れるだけでは「申請が来ても誰も気づかない」問題が起きる。なので申請があった瞬間にSlackへ通知し、そのスレッドにClaudeが自動でアプリの概要・リスク・承認判断を投稿する仕組みを作った。

▼仕組みの全体像

① 社員がGWS上で外部アプリの追加スコープ接続を試みる
② Google AdminのAPI制御でリクエストとして記録される
③ GASが5分おきにAdmin Reports APIをポーリングして申請を検知
④ 基本スコープのみの申請はスキップ。追加スコープがある申請だけSlackに通知
⑤ 同じ通知のスレッドにClaudeがアプリ概要・リスク・判断を自動投稿
⑥ 管理者がSlackを見てGoogle Admin Consoleで承認/却下
💡 なぜリアルタイムではなくポーリングか
Google Admin Reports APIはPush通知(Webhook)に対応していない。そのためGASのトリガーで5分おきに申請を取得するポーリング方式を採用している。最大5分のタイムラグが生じるが、実務上は問題ない。

▼実装:GASのコード解説

設定値

const SLACK_BOT_TOKEN = ‘xoxb-XXXX-XXXX-XXXX’; // Slack Bot Token const SLACK_CHANNEL_ID = ‘CXXXXXXXXX’; // 通知先チャンネルID const ANTHROPIC_API_KEY = ‘sk-ant-XXXXXXXXXXXX’; // Anthropic APIキー const SPREADSHEET_ID = ‘XXXXXXXXXXXXXXXXXXXX’; // 処理済み管理用スプレッドシートID // 基本スコープの定義(これだけなら申請不要) const BASIC_SCOPES = new Set([ ‘openid’, ‘email’, ‘profile’, ‘https://www.googleapis.com/auth/userinfo.email’, ‘https://www.googleapis.com/auth/userinfo.profile’ ]);

メイン処理:申請の検知と振り分け

function checkPendingRequests() { const token = ScriptApp.getOAuthToken(); // 直近72時間の申請を取得 const startTime = new Date(Date.now() – 72 * 60 * 60 * 1000).toISOString(); const url = ‘https://admin.googleapis.com/admin/reports/v1/activity’ + ‘/users/all/applications/token’ + ‘?eventName=request&maxResults=50&startTime=’ + encodeURIComponent(startTime); const response = UrlFetchApp.fetch(url, { headers: { Authorization: ‘Bearer ‘ + token }, muteHttpExceptions: true }); const data = JSON.parse(response.getContentText()); if (!data.items || data.items.length === 0) return; const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getActiveSheet(); const processed = getProcessedKeys(sheet); // 処理済みキーのSet data.items.forEach(item => { if (!item.actor?.email || !item.events?.[0]?.parameters) return; const params = item.events[0].parameters; const userEmail = item.actor.email; const appName = params.find(p => p.name === ‘app_name’)?.value || ‘不明なアプリ’; const clientId = params.find(p => p.name === ‘client_id’)?.value || ”; const scope = (params.find(p => p.name === ‘scope’)?.multiValue || []).join(‘ ‘); // 基本スコープのみ → スキップ if (!hasNonBasicScope(scope)) return; // 重複チェック(同じ申請を2回通知しない) const uniqueKey = userEmail + ‘_’ + appName + ‘_’ + item.id.time; if (processed.has(uniqueKey)) return; const jstTime = formatJst(new Date(item.id.time)); // Slack通知 → スレッドtsを取得 const ts = postSlackNotification(userEmail, appName, jstTime, clientId, scope); if (!ts) return; // Claudeによる分析をスレッドに投稿 postClaudeAnalysis(appName, clientId, scope, ts); // 処理済みとして記録 sheet.appendRow([uniqueKey, userEmail, appName, scope, new Date().toISOString()]); }); }

ポイント①:スコープ判定

function hasNonBasicScope(scope) { if (!scope) return false; // スコープ文字列を分割して、1つでも基本スコープ以外があればtrue return scope.split(‘ ‘).filter(Boolean).some(s => !BASIC_SCOPES.has(s)); }

基本スコープだけのアプリ(Googleログインのみ)は通知不要なのでスキップする。追加スコープが1つでも含まれていたら通知対象になる。

ポイント②:重複防止

function getProcessedKeys(sheet) { const lastRow = sheet.getLastRow(); if (lastRow === 0) return new Set(); return new Set( sheet.getRange(1, 1, lastRow, 1).getValues().flat().map(String).filter(Boolean) ); }

5分おきに実行するためポーリングするたびに同じ申請が取れてしまう。スプレッドシートに処理済みキーを記録しておき、重複は通知しない設計にした。

ポイント③:Slack通知(スレッドtsを返す)

function postSlackNotification(userEmail, appName, requestTime, clientId, scope) { const text = [ ‘⚠️ *OAuth追加スコープ申請が届きました*’, ‘*申請者:* ‘ + userEmail, ‘*アプリ:* ‘ + appName, ‘*日時:* ‘ + requestTime, ‘*要求スコープ:* `’ + (scope || ‘不明’) + ‘`’, ”, ‘👉 審査ページ: https://admin.google.com/ac/owl/list?tab=pendingReviewApps’ ].join(‘\n’); const response = UrlFetchApp.fetch(‘https://slack.com/api/chat.postMessage’, { method: ‘post’, contentType: ‘application/json’, headers: { Authorization: ‘Bearer ‘ + SLACK_BOT_TOKEN }, payload: JSON.stringify({ channel: SLACK_CHANNEL_ID, text: text }), muteHttpExceptions: true }); const result = JSON.parse(response.getContentText()); if (!result.ok) { console.error(‘Slack投稿エラー:’, result.error); return null; } return result.ts; // スレッドに返信するためにtsを返す }

ポイント④:ClaudeのAPIをGASから呼び出す

function postClaudeAnalysis(appName, clientId, scope, threadTs) { const prompt = ‘以下のアプリについて、Google Workspaceの管理者が承認判断できるよう日本語で説明してください。\n\n’ + ‘アプリ名: ‘ + appName + ‘\n’ + ‘Client ID: ‘ + clientId + ‘\n’ + ‘要求スコープ: ‘ + scope + ‘\n\n’ + ‘以下の形式でSlack向けテキストを出力してください(Slack形式:*太字*、箇条書きは •):\n\n’ + ‘*📱 アプリ概要*\n(1〜2文)\n\n’ + ‘*🔐 このスコープで可能になること*\n• (具体的に箇条書き)\n\n’ + ‘*⚠️ リスク*\n• (箇条書き)\n\n’ + ‘*💡 判断: [承認推奨 / 要確認 / 拒否推奨]*\n(理由を1文)’; const response = UrlFetchApp.fetch(‘https://api.anthropic.com/v1/messages’, { method: ‘post’, headers: { ‘Content-Type’: ‘application/json’, ‘x-api-key’: ANTHROPIC_API_KEY, ‘anthropic-version’: ‘2023-06-01’ }, payload: JSON.stringify({ model: ‘claude-opus-4-5’, max_tokens: 1024, messages: [{ role: ‘user’, content: prompt }] }), muteHttpExceptions: true }); const data = JSON.parse(response.getContentText()); const analysisText = data.content?.[0]?.text || ‘分析できませんでした’; // 通知メッセージのスレッドに返信 postThreadMessage(threadTs, ‘🤖 *Claude による安全性分析*\n\n’ + analysisText); }

▼実際のSlack通知イメージ

申請が来るとこんな形でSlackに通知される。

⚠️ OAuth追加スコープ申請が届きました 申請者: user@example.com アプリ: Genspark 日時: 2026/04/13 11:23:47 要求スコープ: `https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/drive.file` 👉 審査ページ: https://admin.google.com/ac/owl/list?tab=pendingReviewApps

そのスレッドにClaudeが自動で分析を返してくる。

🤖 Claude による安全性分析 📱 アプリ概要 Gensparkは生成AIを活用したウェブ検索・コンテンツ生成ツールです。 🔐 このスコープで可能になること • gmail.modify:メールの読み取り・送信・移動・削除が可能 • drive.file:アプリが作成したファイルへのアクセスが可能 ⚠️ リスク • Gmailへのフルアクセスにより、機密情報を含むメールが外部に送信される可能性がある • AI系サービスへの入力データが学習に利用される可能性がある 💡 判断: 要確認 業務上の必要性を申請者に確認した上で、機密情報を含むメールを扱わない運用ルールを整備してから承認を検討することを推奨します。

▼実装でハマったこと

  • Admin Reports APIはeventName=authorizeじゃない:「審査待ち」のイベントはeventName=requestで取得する。authorizeは承認済みのログ全件が返ってくるので別物
  • Admin SDKはAdmin Reportsと同じサービスに登録できない:GASのサービスにAdmin SDK APIを追加する際、directory_v1とreports_v1は別々に登録しようとするとエラーになる。Reports APIはURLFetchAppで直接叩く方法で解決した
  • Slackのプライベートチャンネルへの投稿にはgroups:historyスコープが必要:パブリックチャンネルとは必要なスコープが異なる
  • スレッドへの返信にはmessage_tsではなくmessage.ts:Slackのペイロード構造が古いattachments形式だとtsの取得パスが変わる

▼まとめ

  • GWSのAPI制御は「全禁止」ではなく「基本スコープはOK・追加スコープは申請制」が現実的
  • OU別に制御の厳しさを変えることで、業務委託・サービスアカウントを厚く管理できる
  • GAS+Admin Reports API+Slack+Claude APIを組み合わせることで、申請通知〜安全性分析までが全自動になる
  • Claudeに「アプリ概要・リスク・判断」を出力させることで、情シス担当者がスコープの意味を1から調べなくてよくなる

📅 30分無料相談、受け付けています

「GWSのAPI制御をどう設計すればいいかわからない」「こういう自動化を自社でも導入したい」——そういった段階からお気軽にどうぞ。

📅 30分無料相談を予約する →