🎍

GoogleフォームとGASを使って利便性高くセキュアな共有ドライブ運用を作る

2023/01/01に公開

注意事項

かなり高度なGASの使い方なのである程度GWS(Googleフォームやスプレッドシート)やGASをわかってる方前提で書いていますので結構省略しているとこも多いです。
あと作った後に手順を書いているのでなにか抜け漏れあったらごめんなさい。

まだ作ってみただけで実際に運用はしてないのでテスト等も不十分かも。運用してからまた追記します。

コードは直接スクリプトエディタでサクッと作ったサンプルです、実際はローカルでLintかけたりするのでインデントおかしかったりしても許してください。

背景

GWSを使う際にマイドライブで外部共有可能にするとやりたい放題なのでセキュアな環境とは言い難くなる。
また組織のファイルをマイドライブにおいてしまうとファイルオーナーが退職したときの扱いに困る。上長に移管したり、退職者アカウントに移管するのが一般的かと思うが、移管するということはマイドライブのファイルが無限に増えることになり微妙。

そこで組織のファイルや社外に共有するファイルは共有ドライブを使うのがGWSを使う上でのベストプラクティスなのだが、共有ドライブも結構癖がある。その中でも気になる癖を2つほど紹介する。

癖のひとつが権限管理が難しいということ。
自身に開いたファイルの権限がないときに権限リクエストを送ると思うのだが、この送り先は共有ドライブの管理者宛にメールで送られる。なので管理者を社内ユーザーに渡したいのだが管理者をそのまま渡すと好き勝手に共有できるドライブを作れてしまうので管理者でも共有ドライブの設定自体は変更できないように設定する必要がある。

癖のもうひとつが 「共有ドライブはデフォルトでRoot OUと同じDriveの設定が有効になる」というもの。
つまり社外共有用のドライブを作りたい場合は、Root OUのセキュリティに社外共有可能な穴を開けて、一般ユーザーを入れた子OUでオーバーライドして穴を閉じる。という2段階のアクションが必要になる。

そんな癖を抱えつつも多くの組織で共有ドライブを利用していたと思うのだが、22年5月に共有ドライブとOUを紐付ける機能がリリースされた。OUに紐付けられるということは共有権限を共有ドライブごとに設定できるということだ。
Google Workspace Updates JA: 特定の組織部門に共有ドライブを追加する
ブログでは組織のOUでDriveの権限を分けられるよーとのことだが、〇〇部門だから外部共有OKとかNGとかなんかそういう使い方は現実的ではないように思える。
組織単位ではなくドライブごとに権限を分けるほうが現実的だ。

そうなると次のように共有ドライブ用のOUを作っていくのがよいはず。

このOU設計だと一般ユーザーのマイドライブは外に共有できない状態を作りつつも、共有ドライブ経由だけ共有できる環境が作れる。
共有権限用のOUをどこまで細かく切るかは組織によってよしなに変えるでいいと思うが、「リンク共有可能」「外部共有可能」「組織内限定」の3つあればほとんどの組織でカバーできると思う。

このように共有権限ごとにOUを切ってそれぞれの共有ドライブを紐付ければセキュアに運用できるのだが、ドライブごとにOUを適宜変えていく運用はかなり大変になることが予想される。

なので GoogleフォームとGASを使って利便性高くセキュアな共有ドライブ運用を作ってみました

作りたいものの全体図

以下の要件を最低限実現したいことにした。

  • 共有ドライブ用OUは前述した3パターン
  • 申請者がGWSの管理者を通さずにセルフサービスで共有ドライブを作成できる
    • 3つのどれを使うかは申請者自身が選ぶ
  • 申請者を共有ドライブの管理者にする
    • 共有リクエストを有効にするため
    • 共有ドライブのメンバー管理を移譲したいため
  • 共有ドライブの管理者は申請者のみにする
    • 特権管理者であればGWS管理コンソールからメンバーは操作できるため

上記要件とGoogleフォームを組み合わせると以下のようなフローになる。

これを作る手順とコードを書いていく。

OUの設定

Root OU

  • 外部との共有をオフ
  • 管理者権限を持つユーザーが、以下の設定をオーバーライドできるようになります をオフ
    • これ忘れるとあとから管理者ユーザーでドライブの設定変更できちゃう

Shared Drive OU

これは普通にRoot直下に作るだけ、全部継承でOK

External Share OU

Shared Drive OU配下に作る。

  • 外部との共有をオン
  • リンク共有はオフ

Shared Drive OU配下に作る。

  • 外部との共有をオン
  • リンク共有もオン

Internal Share OU

これは普通にExternal Share OU配下に作るだけ、全部継承でOK

GCPプロジェクトを作成する

OU移動がもっとも厄介です。
OU移動にはCloud Identity APIを使うのだけど、これはGASの標準サービスにないし、Advancedサービスでもないです。

じゃあどうするねん、って話なのだけどこれはGCPプロジェクトを自前で用意してGASの認可フローに組み込むことで可能になる。

なにそれ意味わからんと言う人がほとんどだと思う。
実はGASは裏側でGCPプロジェクトを作っている。
最近GASを始めた人はわからないかもしれないが、以前は作ったGASから裏側で作られたGCPプロジェクトの管理画面にアクセスもできたので裏側で作られているのがわかりやすかったのだが最近のGASは隠蔽できているのでまぁわからんと思う。

つまり何が言いたいかと言うと、GASの認可フローはそもそも裏側でGCPプロジェクトが作られているだけなので手動で自分で作ったGCPプロジェクトを使うことももちろん可能なんです。
どっちかというとGoogleの認可画面を作るには手動でGCPプロジェクトを作るのが普通でDriveAppなどの通常サービスやAdvancedサービスを使ったGASを使った場合にそれが不要になっているのが特殊なのです。

ってことでGCPプロジェクトを作っていきます。
やることはAPIの有効化とOAuth同意画面の作成の2つです。
※ GCPプロジェクトの操作自体は本記事の趣旨とはずれるので詳細は割愛します。

APIの有効化

GCPプロジェクトを適当に一つ作ったら使いたいAPIを有効化します。
今回のケースだと以下の3つです。

  • Cloud Identity API
  • Google Drive API
  • Admin SDK API

OAuth同意画面の作成

特に難しい設定は無いですが、メモを残しときます。

  • ユーザーの種類
    • 内部でよいです
  • 承認済みドメイン
    • 不要です
  • Scope
    * 不要です(後述するGASのappsscript.json->oauthScopesで指定するため)

Googleフォーム x 回答スプレッドシートを作成

これも細かいのは割愛しますが、GASとセットで見ればわかると思います。

こだわりポイントが2点ほどあります。
ひとつめがドライブ種類によってドライブ名称の聞き方を変えています。
作りたいドライブ名を聞いても何を入力すれば迷ってしまうので、外部共有用ドライブの場合は「取引先名」にすることで迷わないようにしているみたいな。

ふたつめが申請者向けには直接的にリンク共有とか言わずにWeb公開と記載しています。これはリンク共有をラフに使わせないためです。取り扱いに気をつけてね。という意味を込めています。

GASを作成

スプレッドシートのコンテナバインドで作成してください。

GCPプロジェクトを切り替える

プロジェクト番号の部分を先程つくったGCPプロジェクトを指定してください。
ついでにappscript.jsonを表示にチェックをします。

appsscript.json

後に後述するOptional.gsの部分も含んでいます。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Drive",
        "version": "v2",
        "serviceId": "drive"
      },
      {
        "userSymbol": "AdminDirectory",
        "version": "directory_v1",
        "serviceId": "admin"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/cloud-identity.orgunits"
  ]
}

Code.gs

概ねそのままで使えると思いますが、prefixとか著者の嗜好が入っているので適宜書き換えてもらえると。

const scriptProperties = PropertiesService.getScriptProperties();
const ORGUNIT_ROOT = scriptProperties.getProperty('ORGUNIT_ROOT');
const driveTypeMap = {
  '社内共有用ドライブ': {
    question: 'プロジェクト名や組織名',
    prefix: '[社内共有用]',
    orgUnit: scriptProperties.getProperty('ORGUNIT_INTERNAL_SHARE')
  },
  '外部共有用ドライブ': {
    question: '取引先名',
    prefix: '[外部共有用]howdy39 x ',
    orgUnit: scriptProperties.getProperty('ORGUNIT_EXTERNAL_SHARE')
  },
  'Web公開用ドライブ': {
    question: '公開する資料の種類',
    prefix: '[Web公開用]',
    orgUnit: scriptProperties.getProperty('ORGUNIT_LINK_SHARE')
  }
};

function onFormSubmit(e) {
  const respondentEmail = e.namedValues['メールアドレス'][0];
  const driveType = e.namedValues['共有ドライブの種類'][0];
  const {question, prefix, orgUnit} = driveTypeMap[driveType];
  const driveName = `${prefix}${e.namedValues[question][0]}`;

  console.log({respondentEmail, driveType, prefix, orgUnit, driveName});

  // 共有ドライブを作成する
  const sharedDrive = Drive.Drives.insert({ name: driveName }, Utilities.getUuid());
  console.log('Shared Drive created', sharedDrive.id);

  // 申請者を共有ドライブの管理者にする
  Drive.Permissions.insert(
    {
      role: 'organizer',
      type: 'user',
      value: respondentEmail,
    },
    sharedDrive.id,
    {supportsAllDrives: true}
  );

  // トリガー設定者を管理者から外す
  const permissions = Drive.Permissions.list(sharedDrive.id, {supportsAllDrives: true});
  const triggerUserPermisson = permissions.items.find(p => p.emailAddress === Session.getActiveUser().getUserLoginId());
  console.log({triggerUserPermisson})
  Drive.Permissions.remove(sharedDrive.id, triggerUserPermisson.id, {supportsAllDrives: true});

  // ドライブ作成とOU移動のタイミングが近すぎると500エラーになるようなのでスリープ処理を入れる
  Utilities.sleep(120 * 1000);

  // 共有ドライブを特定のOUに移動
  moveOrgUnitMemberShip({
    orgUnit: ORGUNIT_ROOT,
    membership: `shared_drive;${sharedDrive.id}`,
    destinationOrgUnit: orgUnit
  });
}

function moveOrgUnitMemberShip({orgUnit, membership, destinationOrgUnit}) {
  // https://cloud.google.com/identity/docs/reference/rest/v1beta1/orgUnits.memberships/move
  const url = `https://cloudidentity.googleapis.com/v1beta1/${orgUnit}/memberships/${membership}:move`;

  const payload = {
    customer: 'customers/my_customer',
    destinationOrgUnit,
  };

  const response = UrlFetchApp.fetch(url, {
    method : 'POST',
    headers: {
      Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
    },
    payload
  });
  return JSON.parse(response.getContentText()).orgMemberships;
}

Optional.gs

これはトリガーを使ったフロー上は必須ではないのですが、あったほうが便利なのでコードを記載しておきます。
OrganizationUnit環境変数をセットするのに初回だけ必要なのと、ドライブの管理者アカウントがわかったほうが便利(権限が欲しかったらここに記載されてる人に直接言ってができる)だと思うので。

ボタン等は作ってないので手動でポチったり定期実行したりよしなにご利用ください。

function writeOrganizationUnit() {
  const organizationUnits = AdminDirectory.Orgunits.list('my_customer', {type : "ALL"}).organizationUnits;

  const headers = Object.keys(organizationUnits[0]).sort();

  let ouValues = [];
  organizationUnits.sort((a, b) => {
    if (a.orgUnitPath < b.orgUnitPath) {
      return -1;
    }
    if (a.orgUnitPath > b.orgUnitPath) {
      return 1;
    }
    return 0;
  }).map(ou => {
    const row = [];
    headers.forEach(h => row.push(ou[h]));
    ouValues.push(row);;
  })
  
  const writeValues = [headers, ...ouValues];
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('OrganizationUnits');
  sheet.clearContents();
  sheet.getRange(1, 1, writeValues.length, writeValues[0].length).setValues(writeValues.sort(wv => wv.orgUnitPath));
}

function writeShareDriveOrganizers() {
  const headers = ['ドライブID', 'ドライブ名', '管理者メールアドレス']

  let allShareDrives = [];
  let nextPageToken;
  do {
    const options = {maxResults: 100, useDomainAdminAccess: true};
    if (nextPageToken) {
      options.pageToken = nextPageToken;
    }
    const shareDrives = Drive.Drives.list(options);
    allShareDrives = [...allShareDrives, ...shareDrives.items];
    if (shareDrives) {
      nextPageToken = shareDrives.nextPageToken;
    } else {
      nextPageToken = null;
    }
  } while(nextPageToken)

  const shareDriveValues = [];
  allShareDrives.forEach(d => {
    const permissions = Drive.Permissions.list(d.id, {supportsAllDrives: true, useDomainAdminAccess: true}).items;
    const organizersPermissions = permissions.filter(p => p.permissionDetails[0].role === 'organizer')
    organizersPermissions.forEach(op => {
      shareDriveValues.push([d.id, d.name, op.emailAddress]);
    })
  })
  const writeValues = [headers, ...shareDriveValues];
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('ShareDriveOrganizers');
  sheet.clearContents();
  sheet.getRange(1, 1, writeValues.length, writeValues[0].length).setValues(writeValues);
}

スクリプトプロパティを設定

OUのIDを設定します。
※ OUのIDはOrganizationUnitsシートにでているやつです

トリガーの設定

onFormSubmitをフォーム送信時に設定してください。

おわりに

まだ運用してないからあれだけど結構いい感じな気がする。

リンク共有可能なドライブをセルフサービスで作れていいのかってとこは利便性を取っているので悩ましいが、共有ドライブであればAPI叩くなりなんなりでGWS管理者側で容易に監視できるので、そこは別の監視ツール作るとか、定期的に棚卸しするとかのカバーでもいいのかなと思う。
※ リンク共有可能ドライブはGWS管理者の手動運用とかにしちゃっうのも全然ありだと思う

実際に作るときは申請者へのSlack通知とか入れると思う。
GASからSlackIDではなくメールアドレスを使ってメンションを送る方法

Discussion