Google Form のウッカリ外部公開を防ぐ

# Google Form を利用する上での注意点

GoogleForm って、くそ便利ですよね。
GoogleWorkspace を利用している企業や、各個人でも広く便利に使われていると思います。

ですが、ちょっとした設定の組み合わせで、意図せずform回答結果を全世界に公開してしまう状態になってしまうので、注意が必要です。

具体的には、下図のように

- 自組織と信頼できる組織のユーザに限定する を OFF にする
- 概要グラフとテキストの回答を表示 を ON にする

の組み合わせで、他の人の回答結果が参照できる状態になってしまいます。

![GoogelFormをうっかり外部公開しちゃう手順](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1411660/265a9cb5-1155-b030-d9e0-06aecd7436a1.png)

もちろん、URLを知られない限り参照はされないのですが、回答者は回答終了後に参照できてしまうので、それなりの人の目に触れてしまいます。

個人情報を収集するような form だとかなりまずい状態になりますよね。

# GAS で公開設定になっている form を検知しよう

一つ一つ form の設定を開いて、公開設定になっていないか確認して回るのは骨が折れますよね。
そんな時は GAS（Google Apps Script）を活用しましょう。

Google Workspace では、 FormApp の API を用意してくれていて、簡単にチェックできます。
参考： https://developers.google.com/apps-script/reference/forms/form-app

実装は以下のような感じです

```javascript
var form = FormApp.openById(itemId);
if (form.requiresLogin() == false && form.isPublishingSummary() == true ) {
  // アウト！
} else {
  // セーフ
}
```

`itenId` のところには、 form の URL 「 `https://docs.google.com/forms/d/e/xxxxxxxx/viewform` 」 の `xxxxxxxx` の値をセットしましょう。

# FormApp API 利用時の注意点

組織内の全ての form に対してチェックを行おうとすると、いくつかの壁に出くわします。

## 編集権限がないと open できない

FormApp API の openById などのメソッドを利用できる form は 編集権限を持つ form だけです。組織内で特権権限を持っているユーザで GAS を実行してもダメです。ガッデム。

ただ、強い権限で編集権限を付与することはできます。その方法は後述します。

## 編集権限を付与するとログが増える

新しく作られる form や、新しく編集された form に対して、随時チェックをする仕組みにしたいので、 ReportAPI を使って編集ログを取得して、そのログに現れる form に対して編集権限を付与する仕組みになると思います。

ただ、その編集権限を付与する処理もログが出力されます。これによって、不要にログが出力されることになり、 GAS の実行時間が長期化してしまいます。 GAS は最大実行時間 30分 という制限があり、より短時間に処理が稼働するようにする必要があります。

ログに出現する form を片っ端から編集権限を付与するというのは、無駄にログを増やして、GASの実行時間を長時間化させるのでやめましょう（やってしまった）。権限がない form だけにしましょう。

## 共有ドライブにある form への編集権限付与が難しい

あまりちゃんと検証できていないんですが、強制的に編集権限を付与しようとしたときに、共有ドライブにある form に権限付与するのがうまくいかないパターンがある気がします。

そんな時は、もう全ての共有ドライブに管理者としてあなたを参加させましょう。

[GoogleWorkspaceの管理画面](https://admin.google.com/) で、 アプリ > GoogleWorkspace > ドライブとドキュメント > 共有ドライブの管理 の画面で、全共有ドライブに対して、管理者を設定することができます。

## GAS の実行時間制限

すでに触れているように、GAS は 30分の実行時間制限があります。これを考慮した実装にする必要があります。
詳しくは、後半にソースコードを記載しているので、そちらを見ていただきたいのですが、ポイントは 「一定の条件下で、同じ処理を単純に稼働させれば大丈夫な実装にする」 ことだと思います。

他にも、「30分以上稼働しそうなら終わったところが分かる情報を何かに記録して、次回はそれを参照して次から稼働させる」という実装アイディアもあると思いますが、処理が複雑になると思います。

後半で掲載したソースコードは、「30分で終わらないことは想定しているけど、丸一日かかっても終わらないことは想定しない」という条件のもと、1時間ごとに稼働させて「終わってないやつを拾って処理する、終わりきらなければ次のタイミングで処理すればいい」という考え方で実装しています。

# 事前準備

この後色々な実装を紹介しますが、必要な事前準備がいくつかあります

## APIクライアントの登録

- GAS プロジェクトが紐つく、GCPプロジェクトでサービスアカウントを作り、そのサービスアカウントに「ドメイン全体の委任を有効にする」をONにする
- クライアントIDを表示できるようになるので、そのクライアントIDを Google Workspace の管理画面の 「セキュリティ > APIの制御 > ドメイン全体の委任 から APIクライアントを登録する

APIクライアントに登録するスコープは以下
- https://www.googleapis.com/auth/admin.directory.user.readonly
- https://www.googleapis.com/auth/drive
- https://www.googleapis.com/auth/admin.reports.audit.readonly

## サービスアカウントの private key を GAS のスクリプトプロパティに登録する

- サービスアカウントの private key を取得（[参考](https://cloud.google.com/iam/docs/creating-managing-service-account-keys?hl=ja)）
- GASのスクリプトプロパティに登録。ここでは名前を `jsonkey` にしてます。スクリプトプロパティは旧エディタから出ないと編集画面にアクセスできないので注意

## GASのサービスやライブラリを追加

- ライブラリに OAuth2 を追加しましょう（ LibraryId: `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF` ）
- ライブラリに SlackApp を追加しましょう（ LibraryId: `1on93YOYfSmV92R5q59NpKmsyWIQD8qnoLYk-gkQBI92C58SPyA2x1-bq` ）
- サービスに `AdminDirectory` を追加しましょう

## CloudSQL の準備

紹介するスクリプトでは、 CloudSQL を活用しています。

Userからのformの取得や、ログからのformの取得によって、同じ form について重複した情報を取得することになります。 `select distinct form` のように重複を排除して form を取得するのに便利なので、CloudSQL を利用できるようにしましょう。

ここでは、 MySQL5.7 の DBを構築し、以下のようなテーブルを作成しています。

```sql
mysql> desc form_edit_log;
+------------+--------------+------+-----+---------+-------+
| Field      | Type         | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+-------+
| time       | varchar(30)  | NO   | PRI | NULL    |       |
| event_idx  | varchar(2)   | NO   | PRI | NULL    |       |
| form_id    | varchar(50)  | NO   | PRI | NULL    |       |
| title      | varchar(100) | YES  |     | NULL    |       |
| owner      | varchar(30)  | YES  |     | NULL    |       |
| status     | varchar(10)  | YES  |     | NULL    |       |
| created_at | datetime     | YES  |     | NULL    |       |
+------------+--------------+------+-----+---------+-------+
7 rows in set (0.04 sec)

```

また、dbのパスワードをスクリプトプロパティに格納しておきましょう。ここでは `dbpw` という名前で登録してます。



# 既存の form に対してチェックをかけよう

さて、ReportAPI の ログを取得して、新たなログに対するチェックを行う仕組みを実装する前に、既存の form に対するチェックを完了させなければなりません

## 全ユーザ取得する

既存の form の全量を取得するのに有効なのは、全ユーザを取得して、そのユーザが保持する form を取得する方法です。

AdminDirectory API を利用しましょう
参考： https://developers.google.com/apps-script/advanced/admin-sdk-directory

実装は以下のようになります。


```javascript
function getUsers() {
  var pageToken;
  var page;
  var ret = [];

 do {
    page = AdminDirectory.Users.list({
      domain: 'hoge.com',
      orderBy: 'givenName',
      maxResults: 100,
      pageToken: pageToken
    });
    if (page.users) {
      page.users.forEach(function(user) {
        ret.push(user.primaryEmail);
      })
    } else {
      console.error("no users found.");
    }
    pageToken = page.nextPageToken;
 } while (pageToken);

  console.log("users.length", ret.length);
  return ret;
}
```


## ユーザ毎にformを取得する

前述で取得したユーザに対して、formを取得していきます。


```javascript

function main() {
  users.forEach(function(user){
    var service = getDriveService(user);
    var forms = getForms(user, service);
  });
}

function getDriveService(user) {
  return OAuth2.createService('drive_' + user)
    .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
    .setTokenUrl('https://accounts.google.com/o/oauth2/token')
    .setPrivateKey(jsonkey.private_key)
    .setIssuer(jsonkey.client_email)
    .setSubject(user)
    .setPropertyStore(PropertiesService.getScriptProperties())
    .setParam('access_type', 'offline')
    .setParam('approval_prompt', 'force')
    .setScope('https://www.googleapis.com/auth/drive');
}


function getForms(user, service) {
  var pageToken;
  var requestBody = {
      method: "GET",
      headers: {Authorization: 'Bearer ' + service.getAccessToken()},
      contentType: 'application/json',
      muteHttpExceptions: false,
  };
  var formFiles = [];

  do {
    var url = "https://www.googleapis.com/drive/v3/files"
    url += "?supportsAllDrives=true&q=mimeType+%3d+%27application/vnd.google-apps.form%27";
    if (pageToken) {
      url += "&pageToken=" + pageToken;
    }

    var response;
    try {
      response = UrlFetchApp.fetch(url, requestBody);
      var ct = JSON.parse(response.getContentText());
      pageToken = ct.nextPageToken

      if(ct.files) {
        ct.files.forEach(function(file){
          formFiles.push({
            formId: file.id,
            userEmail: user
          });
        });
      }
    } catch(e) {
      console.log(user, e);
    }
  } while (pageToken);
  service.reset();
  return formFiles;
}

```

formが取得できたら、 権限設定したり、公開設定したりします。が、それは、新しい form への随時チェックを同様の処理なので、割愛して、次をご参照ください

# 新規のformや、編集が加えられたformをチェックしよう

## common.gs

共通処理をまとめたもの

```javascript
var jsonkey = JSON.parse(PropertiesService.getScriptProperties().getProperty("jsonkey"));

// エラー履歴などを出力するSpreadSheetのid
var outputSsid = "{spread sheet item id}";

function outputErrorToSpreadSheet(func, form, error) {
  var ss = SpreadsheetApp.openById(outputSsid);
  var sheet = ss.getSheetByName("errors");
  var rowNum = sheet.getLastRow() + 1;
  var today = new Date();
  sheet.getRange(rowNum, 1, 1, 6).setValues([[today, func, form.doc_id, form.doc_title, form.owner, error]]);
}

function outputNGToSpreadSheet(form) {
  var ss = SpreadsheetApp.openById(outputSsid);
  var sheet = ss.getSheetByName("NGforms");
  var rowNum = sheet.getLastRow() + 1;
  var today = new Date();
  sheet.getRange(rowNum, 1, 1, 4).setValues([[today, form.doc_id, form.doc_title, form.owner]]);
}

function getDBStatement() {
  var connName = '{connection name}';
  var user = '{db user}';
  var pwd = PropertiesService.getScriptProperties().getProperty("dbpw");
  var db = '{db name}';

  var instanceUrl = 'jdbc:google:mysql://' + connName;
  var dbUrl = instanceUrl + '/' + db;
  var conn = Jdbc.getCloudSqlConnection(dbUrl, user, pwd);

  return conn.createStatement();
}

function postSlackbot(message, isAtHere) {
  var token = PropertiesService.getScriptProperties().getProperty("slack_token");
  var slackApp = SlackApp.create(token);
  var channelId = "#channel_name";
  if (isAtHere) {
    message = "<!here> " + message
  }
  var res = slackApp.postMessage(channelId, message);
  if (!res.ok) {
    console.log("message", message);
    console.log("isAtHere", isAtHere);
    console.error("POST_SLACK_ERROR", res.error);
  }
}
```

## regist_edit_form_log.gs

Report API のログから form を取得して DB に格納する処理

```javascript
/**
 * Report API Log から form の編集履歴を取得して Cloud SQL に溜め込むスクリプト
 * 1日分のログが30分で処理しきれない場合があるので、1時間ごとに稼働させて、
 * 該当日時のログが取得済みであれば、最終取得時間をログ取得開始時間に設定してログ取得を行う。
 * 取得したログは Cloud SQL に status: todo で登録する。
 */
function hourlyMain_RegistEditFormLog() {
  var today = new Date();
  var yesterday = getYesterday(today);
  var yyyymmdd = getYYYYMMDD(yesterday);
  registRecentEditedFormsIntoDb(yyyymmdd);
}

function registRecentEditedFormsIntoDb(yyyymmdd) {
  var auditReportService = getAuditReportService("you@hoge.com");
  var reqBody = getGetRequestBody(auditReportService);
  var pageToken;
  var startAndEndTime = getStartAndEndTime(yyyymmdd);
  var itemCount = 0;

  do {
    var forms = [];
    var url = getFormEditHistoryApiUrl(startAndEndTime, pageToken);
    console.log("FormEditHistoryApiUrl", url);
    try {
      var response = UrlFetchApp.fetch(url, reqBody);
      var ct = JSON.parse(response.getContentText());
      pageToken = ct.nextPageToken;

      if(ct.items && ct.items.length > 0) {
        var items = ct.items;
        console.log("items", items.length);
        itemCount += items.length;
        items.forEach(function(item){
          if (item.events && item.events.length > 0) {
            for(var i=0; i<item.events.length; i++) {
              var form = createForm(i, item, item.events[i].parameters);
              forms.push(form);
            }
          }
        });
      } else {
        console.log("no items");
      }

      insertForms(forms, url);
    } catch (e) {
      console.log("registRecentEditedFormsIntoDb error", url);
      console.log("registRecentEditedFormsIntoDb error", e);
    }

  } while(pageToken)

  postSlackbot("ReportAPI から form の編集履歴を " + itemCount +" 件、DB格納完了", false);
}

function createForm(i, item, params) {
  var form = {
    time: item.id.time,
    event_idx: i
  };
  params.forEach(function(param){
    if (["doc_id", "doc_title", "owner"].includes(param.name)) {
      if(param.name == "owner" && param.value.indexOf("@") == -1) {
        // このパターンは共有ドライブなので、owner ユーザの情報が取れない。
        // 代わりに actor 情報を利用する
        if (canGetActorEmail(item.actor.email)) {
          form[param.name] = item.actor.email;
        } else {
          console.log("なぜか item.actor.email が取れない", item);
        }
      } else {
        form[param.name] = param.value;
      }
    }
  });
  return form;
}

function getAuditReportService(email) {
  return OAuth2.createService('AuditReportService')
    .setTokenUrl('https://accounts.google.com/o/oauth2/token')
    .setPrivateKey(jsonkey.private_key)
    .setIssuer(jsonkey.client_email)
    .setPropertyStore(PropertiesService.getScriptProperties())
    .setSubject(email)
    .setParam('access_type', 'offline')
    .setParam('approval_prompt', 'force')
    .setScope('https://www.googleapis.com/auth/admin.reports.audit.readonly');
}

function getGetRequestBody(service) {
  return {
    method: "GET",
    headers: {Authorization: 'Bearer ' + service.getAccessToken()},
    contentType: 'application/json',
    muteHttpExceptions: false
  };
}

/**
 * 該当日の取得済みのログの直後から再開できるように、取得していない期間の start/end 時刻を返す
 * ただ、APIは新しい時刻のログから返すので、取得した最後の時刻を end とする必要あり。
 * 例えば、12:00:30 - 21:30:30 で取得できている場合は、 00:00:00 - 12:01:00 を返す
 */
function getStartAndEndTime(yyyymmdd) {
  var sql = "select time from form_edit_log where time like '" + yyyymmdd + "%' order by time asc limit 1";
  var stmt = getDBStatement();
  var result = stmt.executeQuery(sql);

  var end;
  if (result.next()) {
    // 該当日のログの一番最初の時間
    var firstTime = result.getString(1);
    end = get1minLaterYYYYMMDDHHMM(firstTime);
  } else {
    //該当日のレコードがまだ１件もなければ、固定で翌日の 00:00 を end とする
    var today = getDate(yyyymmdd);
    var tomorrow = getTomorrow(today);
    var nextYYYYMMDD = getYYYYMMDD(tomorrow);
    end = nextYYYYMMDD + "T00:00"
  }

  stmt.close();
  return {
    start: yyyymmdd + "T00:00",
    end: end
  };
}

function get1minLaterYYYYMMDDHHMM(time) {
  var yyyymmdd = time.substr(0, 10);
  var lastHH = time.substr(11,2);
  var lastMM = time.substr(14,2);
  var oneMinLater = Number(lastMM) + 1;

  if (oneMinLater > 59) {
    var oneHourLater = Number(lastHH) + 1;
    if (oneHourLater > 23) {
      var date = getDate(yyyymmdd);
      var tomorrow = getTomorrow(date);
      var nextYYYYMMDD = getYYYYMMDD(tomorrow);
      return nextYYYYMMDD + "T00:00";
    } else {
      return yyyymmdd + "T" + ("00" + String(oneHourLater)).slice(-2) + ":00"; 
    }
  } else {
    return yyyymmdd + "T" + lastHH + ":" + ("00" + String(oneMinLater)).slice(-2);
  }
}

function getTomorrow(date) {
  return new Date(date.setDate(date.getDate() + 1))
}

function getYesterday(date) {
  return new Date(date.setDate(date.getDate() - 1))
}

function getYYYYMMDD(dt) {
  var YYYY = dt.getYear() + 1900;
  var MM = ("00" + (dt.getMonth() + 1)).slice(-2);
  var DD = ("00" + dt.getDate()).slice(-2);
  return YYYYMMDD = YYYY + "-" + MM + "-" + DD;
}

function getDate(yyyymmdd) {
  var year = yyyymmdd.substr(0, 4);
  var month = Number(yyyymmdd.substr(5, 2)) - 1;
  var date = yyyymmdd.substr(8, 2);
  return new Date(year, month, date);
}

function getFormEditHistoryApiUrl(startAndEndTime, pageToken) {
    // maxResults のデフォルトは 1000 だが、insertSQL の文字列数制限や、error時のlog出力文字数制限を考慮して 50で区切る。
    var baseUrl = "https://admin.googleapis.com/admin/reports/v1/activity/users/all/applications/DRIVE" + 
      "?maxResults=50&eventName=edit&filters=doc_type==form" + 
      "&startTime=" + startAndEndTime.start + ":00.000Z&endTime=" + startAndEndTime.end + ":00.000Z";

    if (pageToken) {
      return baseUrl + "&pageToken=" + pageToken;
    } else {
      return baseUrl;
    }
}

function canGetActorEmail(actorEmail) {
  if (actorEmail == "" || actorEmail == undefined || actorEmail == null) {
    return false;
  } else {
    return true;
  }
}

function insertForms(forms, url) {
  var stmt = getDBStatement();
  var values = "";
  forms.forEach(function(form) {
    var value = createInsertValue(form);
    // console.log('value', value);
    if (values == "") {
      values = value;
    } else {
      values = values + "," + value;
    }
  });
  // console.log('values', values);
  var insertSql = "insert ignore into form_edit_log "
    + "(time, event_idx, form_id, title, owner, status, created_at) " 
    + "values " + values;
  // console.log('insertSql', insertSql);
  try {
    stmt.executeUpdate(insertSql);
  } catch (e) {
    console.log(url);
    console.log(insertSql);
    console.error(e);
  }
}

function createInsertValue(form) {
  var today = new Date();
  return "('[time]', '[event_idx]', '[form_id]', '[title]', '[owner]', '[status]', '[created_at]')"
    .replace("[time]", form.time)
    .replace("[event_idx]", form.event_idx)
    .replace("[form_id]", form.doc_id)
    .replace("[title]", removeBugText(form.doc_title))
    .replace("[owner]", form.owner)
    .replace("[status]", "todo")
    .replace("[created_at]", today);
}

/**
 * SQLを壊してしまう文字は雑に消してしまう。
 * 本当はエスケープすべきだが。
 */
function removeBugText(text) {
  return text.replace("'","").replace("\\","");
}

```

## verify_forms.gs

公開チェックをする処理

```javascript
/**
 * 編集権限を付与したformに対して、外部公開されているかどうかの検証を行う。
 * 1時間ごとに稼働させて、verified 以外の ステータスの form を 100件ずつ取得して処理していく。
 */

function hourlyMain_verifyForms() {
  var forms = getTargetForms();
  console.log("target forms", forms.length);
  if (forms.length > 0) {
    verifyForms(forms);
  }
}

function getTargetForms() {
  // 権限付与しなくても verify できる可能性はあるので、 verified と error 以外をまるっととってくる
  var sql = "select distinct form_id from form_edit_log where status<>'verified' and status<>'error' limit 100;"
  var stmt = getDBStatement();
  var result = stmt.executeQuery(sql);

  var forms = [];
  while (result.next()) {
    forms.push({
      doc_id: result.getString(1)
    });
  }

  stmt.close();
  return forms;
}

function verifyForms(forms) {
  var stmt = getDBStatement();
  // 一度検証した form は skip したいので、ストックして検証済みかどうか判断できるようにする
  var verifiedForms = [];
  var ngCount = 0;
  var errorCount = 0;
  forms.forEach(function(form){
      try {
        if (!verifiedForms.includes(form.doc_id)) {
          if (isPublishedForm(form)) {
            outputNGToSpreadSheet(form);
            ngCount += 1;
          }
          verifiedForms.push(form.doc_id);
          updateStatusToVerified(stmt, form);
        }
      } catch(e) {
        outputErrorToSpreadSheet("verifyForms", form, e);
        errorCount += 1;
      }
  });
  console.log("verifiedForms", verifiedForms.length);
  console.log("ngCount", ngCount);
  console.log("errorCount", errorCount);
  stmt.close();

  var postMsg = "form の公開設定検証を完了。対象件数: " + verifiedForms.length + ", エラー件数 : " + errorCount;
  if (ngCount > 0) {
    postSlackbot("*NG発見* " + postMsg + ", NG件数: " + ngCount, true);
    postSlackbot("`check_published_forms` action を実行して確認してください");
  } else {
    postSlackbot(postMsg, false);
  }

  // エラーしか出なくなったら setPermission を呼ぶ
  // pemission set されなくても verify できる form はあるので、まずはそれを一通り潰す
  // その上で 残った form に対して permission set する
  if (verifiedForms.length == 0 && errorCount > 0) {
    main_setPermissions();
  }

}

function isPublishedForm(form) {
  var formApp = FormApp.openById(form.doc_id);
  if (formApp.requiresLogin() == false && formApp.isPublishingSummary() == true ) {
    return true;
  } else {
    return false;
  }
}

function updateStatusToVerified(stmt, form) {
  var statusUpdateSql = 
    "update form_edit_log set status='verified' where form_id='[form_id]' and status<>'verified'"
    .replace("[form_id]", form.doc_id);
  stmt.executeUpdate(statusUpdateSql);  
}


```

## set_permissions.gs

権限付与する処理

```javascript
/**
 * Report API Logから取得したformに対して、 you@hoge.com に対する編集権限付与を行う。
 * 編集権限を持っていない form に対して、外部公開しているかどうかの判断のための情報取得が行えないため。
 * 1時間ごとに稼働して、Cloud SQLに溜まっている、todoステータスの formを100件ずつ取得して処理を行う。
 */

function main_setPermissions() {
  cleanUpScriptProperties();
  var forms = getTodoForms();
  console.log("todo forms", forms.length);
  if (forms.length > 0) {
    setPermissionsOnAllForms(forms);
  }
}

/**
 * getDriveService 関数で作られる service に対して getAccessToken すると、
 * ScriptProperties に token情報が格納される
 * service.reset() すると削除されるが、そこに到達する前にエラーなどになると
 * ScriptProperties に ゴミが残る。
 * 一応容量上限があるようで、上限に達するとエラーになるかもしれないので、掃除する
 */
function cleanUpScriptProperties() {
  var props = PropertiesService.getScriptProperties().getProperties();
  Object.keys(props).forEach(function(key) {
    if (key.indexOf("oauth2.") == 0) {
      PropertiesService.getScriptProperties().deleteProperty(key);
    }
  });
}

function getTodoForms() {
  var sql = "select form_id, owner from form_edit_log where status='todo' group by form_id, owner limit 100;"
  var stmt = getDBStatement();
  var result = stmt.executeQuery(sql);

  var forms = [];
  while (result.next()) {
    forms.push({
      doc_id: result.getString(1),
      owner: result.getString(2)
    });
  }

  stmt.close();
  return forms;
}

function setPermissionsOnAllForms(forms) {
  var stmt = getDBStatement();
  // 権限設定済みのformはスキップできるように権限設定済みのformをストックできるようにする
  var permittedForms = [];
  var errorCnt = 0;
  forms.forEach(function(form){
    try {
      if (!permittedForms.includes(form.doc_id)) {
        setPermissionOnForm(form);
        permittedForms.push(form.doc_id);
        updateStatusToPermitted(stmt, form);
      }
    } catch(e) {
      // error になるものは何回やってもダメなので無視扱いできるように error status にする
      updateStatusToError(stmt, form);
      outputErrorToSpreadSheet("setPermissionsOnAllForms", form, e);
      errorCnt += 1;
    }
  });
  console.log('permittedForms', permittedForms.length);
  stmt.close();
  postSlackbot("formへの権限設定処理を完了。設定件数: " + permittedForms.length + " , エラー件数: " + errorCnt);
}

function setPermissionOnForm(form) {
  // console.log(form);
  var service = getDriveService(form.owner);
  var reqBody = getPostRequestBody(service, {
    type: "user",
    role: "writer",
    emailAddress: "you@hoge.com"
  });
  var url = getPermissionApiUrl(form.doc_id);
  UrlFetchApp.fetch(url, reqBody);
  service.reset();
}

function getDriveService(email) {
  return OAuth2.createService('drive_' + email)
    .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
    .setTokenUrl('https://accounts.google.com/o/oauth2/token')
    .setPrivateKey(jsonkey.private_key)
    .setIssuer(jsonkey.client_email)
    .setSubject(email)
    .setPropertyStore(PropertiesService.getScriptProperties())
    .setParam('access_type', 'offline')
    .setParam('approval_prompt', 'force')
    .setScope('https://www.googleapis.com/auth/drive');
}

function getPermissionApiUrl(formId) {
  return "https://www.googleapis.com/drive/v3/files/"
    + formId + "/permissions?sendNotificationEmail=false&supportsAllDrives=true";
}

function getPostRequestBody(service, payload) {
  return {
    method: "POST",
    headers: {Authorization: 'Bearer ' + service.getAccessToken()},
    contentType: 'application/json',
    muteHttpExceptions: false,
    payload: JSON.stringify(payload)
  };
}

function updateStatusToPermitted(stmt, form) {
  var statusUpdateSql = 
    "update form_edit_log set status='permitted' where form_id='[form_id]' and owner='[owner]' and status='todo'"
    .replace("[form_id]", form.doc_id).replace("[owner]", form.owner);
  stmt.executeUpdate(statusUpdateSql);  
}

function updateStatusToError(stmt, form) {
  var statusUpdateSql = 
    "update form_edit_log set status='error' where form_id='[form_id]' and owner='[owner]' and status='todo'"
    .replace("[form_id]", form.doc_id).replace("[owner]", form.owner);
  stmt.executeUpdate(statusUpdateSql);  
}
```


# 以上！
クラウドサービスを利用していて設定ミスで情報漏洩なんて、みんなで知見共有して防ごう！！！