見出し画像

【kintone&GAS】一時的にアプリの管理者権限の付与と削除をするアプリを作る※修正版

※1月26日 フィールドの権限付与をする際にグループ内に権限が設定されていると正常に動作しなかったので修正しました。

kintoneを運用していて一番大変なのが開発よりも圧倒的にその後の保守と運用です。
今回はその作業を少しだけ楽にするツールをGASを使って作成します。
このツールを使うと以下のような作業が安全にかつ簡単に行えるようになります。

GASの設定

以下のコードをコピーしスクリプトに保存します。
下記の3点をスクリプトプロパティに設定してください。
その後スクリプトをデプロイします。デプロイ後のURLは後の工程で使うのでどこかにメモしておきましょう。

  • CYBOZU_USERNAME

  • CYBOZU_PASSWORD

  • CYBOZU_DOMAIN



class KintoneAppPermissions {
  constructor(appId) {
    this.appId = appId;
    const scriptProperties = PropertiesService.getScriptProperties();
    this.username = scriptProperties.getProperty('CYBOZU_USERNAME');
    this.password = scriptProperties.getProperty('CYBOZU_PASSWORD');
    this.domain = scriptProperties.getProperty('CYBOZU_DOMAIN');

    // アクセス権情報の取得
    this.appPermissions = this.getAppPermissions();
    this.recordPermissions = this.getRecordPermissions();
    this.kintoneFieldsPermissions = new KintoneFieldsPermissions(appId)

  }

  /**
   * Base64エンコードされた認証ヘッダーを生成する。
   * @return {Object} 認証ヘッダー。
   */
  createAuthHeader() {
    const authString = `${this.username}:${this.password}`;
    const base64Auth = Utilities.base64Encode(authString);
    return {
      'X-Cybozu-Authorization': base64Auth
    };
  }
  /**
   * アプリのアクセス権情報を取得する。
   * @return {Object} アクセス権情報。
   */
  getAppPermissions() {
    const appPermissionsEndpoint = `https://${this.domain}/k/v1/app/acl.json?app=${this.appId}`;
    const options = {
      'method': 'GET',
      'headers': this.createAuthHeader(),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(appPermissionsEndpoint, options);

    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to get app permissions: ' + response.getContentText());
    }
    console.log("アプリのアクセス権を取得しました")
    return JSON.parse(response.getContentText());
  }
  /**
   * レコードのアクセス権情報を取得する。
   * @return {Object} レコードアクセス権情報。
   */
  getRecordPermissions() {
    // レコードのアクセス権取得用のエンドポイント
    const recordPermissionsEndpoint = `https://${this.domain}/k/v1/record/acl.json?app=${this.appId}`;

    const options = {
      'method': 'GET',
      'headers': this.createAuthHeader(),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(recordPermissionsEndpoint, options);

    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to get record permissions: ' + response.getContentText());
    }
    console.log("レコードのアクセス権を取得しました")
    return JSON.parse(response.getContentText());
  }

  /**
   * 指定されたエンティティのアプリのアクセス権を削除します。
   * @param {Array<{code: string, type: string}>} deleteEntities - 削除するアクセス権のエンティティ配列。
   */
  appPermissionDelete(deleteEntities) {
    this.appPermissions.rights = this.appPermissions.rights.filter(permission =>
      !deleteEntities.some(entity => entity.code === permission.entity.code && entity.type === permission.entity.type));
  }

  /**
   * 指定されたエンティティのレコードのアクセス権を削除します。
   * @param {Array<{code: string, type: string}>} deleteEntities - 削除するアクセス権のエンティティ配列。
   */
  recordPermissionDelete(deleteEntities) {
    this.recordPermissions.rights.forEach(permission => {
      permission.entities = permission.entities.filter(entity =>
        !deleteEntities.some(delEntity => delEntity.code === entity.entity.code && delEntity.type === entity.entity.type));
    });
  }

  /**
   * 指定されたエンティティのアプリのアクセス権を追加します。
   * @param {Array<{code: string, type: string}>} addEntities - 追加するアクセス権のエンティティ配列。
   */
  appPermissionInsert(addEntities) {
    const newPermissions = addEntities.map(entity => {
      return {
        "entity": {
          "type": entity.type,
          "code": entity.code
        },
        "appEditable": true,
        "recordViewable": true,
        "recordAddable": true,
        "recordEditable": true,
      };
    });
    this.appPermissionDelete(addEntities); // 重複を避けるために先に削除
    this.appPermissions.rights = [...newPermissions, ...this.appPermissions.rights];
  }

  /**
   * 指定されたエンティティのレコードのアクセス権を追加します。
   * @param {Array<{code: string, type: string}>} addEntities - 追加するアクセス権のエンティティ配列。
   */
  recordPermissionInsert(addEntities) {
    // 追加するエンティティのアクセス権を作成
    const newPermissions = addEntities.map(entity => {
      return {
        entity: {
          type: entity.type,
          code: entity.code
        },
        viewable: true,
        editable: true,
        deletable: false, // 必要に応じて設定
      };
    });

    this.recordPermissionDelete(addEntities)
    // 追加するエンティティのアクセス権を追加
    this.recordPermissions.rights.forEach(permission => {
      permission.entities = [...newPermissions, ...permission.entities];
    });
  }


  /**
   * アプリのアクセス権を更新し、最新の情報を再取得する。
   */
  updateAppPermissions() {
    const options = {
      'method': 'PUT',
      'headers': this.createAuthHeader(),
      'contentType': 'application/json',
      'payload': JSON.stringify({
        'app': this.appId,
        'rights': this.appPermissions.rights
      }),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(`https://${this.domain}/k/v1/app/acl.json`, options);
    if (response.getResponseCode() !== 200) {
      console.error(this.appPermissions.rights)
      throw new Error('Failed to update app permissions: ' + response.getContentText());
    }
    console.log("アプリのアクセス権を更新しました");
    this.appPermissions = this.getAppPermissions()

  }

  /**
   * レコードのアクセス権を更新し、最新の情報を再取得する。
   */
  updateRecordPermissions() {
    const options = {
      'method': 'PUT',
      'contentType': 'application/json',
      'headers': this.createAuthHeader(),
      'payload': JSON.stringify({
        'app': this.appId,
        'rights': this.recordPermissions.rights
      }),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(`https://${this.domain}/k/v1/record/acl.json`, options);
    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to update record permissions: ' + response.getContentText());
    }
    console.log("レコードのアクセス権を更新しました");
    this.recordPermissions = this.getRecordPermissions(); // 更新された権限情報を再取得
  }

  // KintoneFieldsPermissions Classを呼び出して処理
  fieldPermissionInsert(addEntities) {
    this.kintoneFieldsPermissions.fieldPermissionInsert(addEntities);
  }
  fieldPermissionDelete(deleteEntities) {
    this.kintoneFieldsPermissions.fieldPermissionDelete(deleteEntities);
  }
  updateFieldPermissions() {
    this.kintoneFieldsPermissions.updateFieldPermissions();
  }
}

class KintoneFieldsPermissions {
  constructor(appId) {
    this.appId = appId;
    const scriptProperties = PropertiesService.getScriptProperties();
    this.username = scriptProperties.getProperty('CYBOZU_USERNAME');
    this.password = scriptProperties.getProperty('CYBOZU_PASSWORD');
    this.domain = scriptProperties.getProperty('CYBOZU_DOMAIN');

    // アクセス権情報の取得
    this.fieldPermissions = this.getFieldPermissions();
    //フィールドのレイアウト情報を取得
    this.formLayout = this.getFormLayout()

  }
  /**
   * Base64エンコードされた認証ヘッダーを生成する。
   * @return {Object} 認証ヘッダー。
   */
  createAuthHeader() {
    const authString = `${this.username}:${this.password}`;
    const base64Auth = Utilities.base64Encode(authString);
    return {
      'X-Cybozu-Authorization': base64Auth
    };
  }
  /**
   * フィールドのアクセス権情報を取得する。
   * @return {Object} フィールドアクセス権情報。
   */
  getFieldPermissions() {
    const fieldPermissionsEndpoint = `https://${this.domain}/k/v1/field/acl.json?app=${this.appId}`;
    const options = {
      'method': 'GET',
      'headers': this.createAuthHeader(),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(fieldPermissionsEndpoint, options);
    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to get field permissions: ' + response.getContentText());
    }
    console.log("フィールドのアクセス権を取得しました")
    return JSON.parse(response.getContentText());
  }

  /**
   * フィールドのアクセス権を更新し、最新の情報を再取得する。
   */
  updateFieldPermissions() {
    const options = {
      'method': 'PUT',
      'contentType': 'application/json',
      'headers': this.createAuthHeader(),
      'payload': JSON.stringify({
        'app': this.appId,
        'rights': this.fieldPermissions.rights
      }),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(`https://${this.domain}/k/v1/field/acl.json`, options);
    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to update field permissions: ' + response.getContentText());
    }
    console.log("フィールドのアクセス権を更新しました");
    this.fieldPermissions = this.getFieldPermissions(); // 更新された権限情報を再取得
  }

  /**
   * アプリのフィールド設定情報を取得する。
   * @return {Object} フィールド設定情報。
   */
  getFieldSettings() {
    const fieldSettingsEndpoint = `https://${this.domain}/k/v1/app/form/fields.json?app=${this.appId}`;
    const options = {
      'method': 'GET',
      'headers': this.createAuthHeader(),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(fieldSettingsEndpoint, options);

    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to get field settings: ' + response.getContentText());
    }
    console.log("アプリのフィールド設定情報を取得しました");
    return JSON.parse(response.getContentText());
  }

  /**
   * Note: https://cybozu.dev/ja/kintone/docs/rest-api/apps/form/get-form-layout/
   */
  getFormLayout() {
    const formLayoutEndpoint = `https://${this.domain}/k/v1/app/form/layout.json?app=${this.appId}`;
    const options = {
      'method': 'GET',
      'headers': this.createAuthHeader(),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(formLayoutEndpoint, options);

    if (response.getResponseCode() !== 200) {
      throw new Error('Failed to get form layout: ' + response.getContentText());
    }
    console.log("アプリのフォームレイアウト情報を取得しました");
    const layoutData = JSON.parse(response.getContentText());
    return layoutData.layout; // layoutプロパティを返す
  }

  /**
   * 指定されたエンティティのフィールドのアクセス権を追加します。
   * @param {Array<{code: string, type: string}>} addEntities - 追加するアクセス権のエンティティ配列。
   */
  fieldPermissionInsert(addEntities) {
    const updatedPermissions = [];
    const groupPermissionsMap = this.mapGroupPermissions();

    this.fieldPermissions.rights.forEach(permission => {
      const fieldCode = permission.code;
      const isGroup = this.isFieldGroup(fieldCode);
      const parentGroupCode = this.findParentGroupCode(fieldCode);

      if (isGroup || !parentGroupCode || (parentGroupCode && !groupPermissionsMap.has(parentGroupCode))) {
        const updatedPermission = this.addEntitiesToFieldPermissions(permission, addEntities);
        updatedPermissions.push(updatedPermission);
      }
      // 既に親グループに権限が追加されている場合は何もしない
    });

    console.log("権限が更新されました");
    this.fieldPermissions.rights = updatedPermissions;
  }


  /**
   * 指定されたエンティティのフィールドのアクセス権を削除します。
   * @param {Array<{code: string, type: string}>} deleteEntities - 削除するアクセス権のエンティティ配列。
   */
  fieldPermissionDelete(deleteEntities) {
    const updatedPermissions = [];
    const groupPermissionsMap = this.mapGroupPermissions();

    this.fieldPermissions.rights.forEach(permission => {
      const fieldCode = permission.code;
      const isGroup = this.isFieldGroup(fieldCode);
      const parentGroupCode = this.findParentGroupCode(fieldCode);
      // [フィールドがグループの場合] または [グループ外のフィールドで、親グループがない場]合 または [グループ内のフィールドで、親グループに権限が設定されていない場合]
      if (isGroup || !parentGroupCode || (parentGroupCode && !groupPermissionsMap.has(parentGroupCode))) {
        const updatedPermission = this.removeEntitiesFromFieldPermissions(permission, deleteEntities);
        updatedPermissions.push(updatedPermission);
      }
      // 既に親グループに権限が追加されている場合は何もしない
    });

    console.log("権限が更新されました");
    this.fieldPermissions.rights = updatedPermissions;
  }

  /**
   * グループの権限状態をマッピングするメソッド。
   * @return {Map<string, boolean>} グループフィールドコードとその権限状態のマップ。
   */
  mapGroupPermissions() {
    const groupPermissionsMap = new Map();

    this.fieldPermissions.rights.forEach(permission => {
      const fieldCode = permission.code;
      if (this.isFieldGroup(fieldCode)) {
        // フィールドがグループの場合、マップに追加
        groupPermissionsMap.set(fieldCode, true);
      }
    });

    return groupPermissionsMap;
  }


  /**
   * 指定されたフィールドコードがグループかどうかを判定します。
   * @param {string} fieldCode - 判定するフィールドのコード。
   * @return {boolean} フィールドがグループであれば true、そうでなければ false。
   */
  isFieldGroup(fieldCode) {
    const formLayout = this.formLayout;

    for (const layoutItem of formLayout) {
      if (layoutItem.type === 'GROUP' && layoutItem.code === fieldCode) {
        //console.log(`フィールドコード: ${fieldCode} はグループです。`);
        return true; // フィールドがグループである
      }
    }
    return false; // フィールドがグループではない
  }

  /**
   * 指定されたフィールドコードの親グループコードを返します。
   * @param {string} fieldCode - 親グループを探すフィールドのコード。
   * @return {string|null} 親グループコードが見つかればそのコード、見つからなければ null。
   */
  findParentGroupCode(fieldCode) {
    const formLayout = this.formLayout;

    for (const layoutItem of formLayout) {
      if (layoutItem.type === 'GROUP') {
        const isFieldInGroup = layoutItem.layout.some(row =>
          row.fields.some(field => field.code === fieldCode)
        );
        if (isFieldInGroup) {
          return layoutItem.code; // 親グループのコードを返す
        }
      }
    }
    return null; // 親グループが見つからなかった場合
  }

  /**
   * 指定されたエンティティにアクセス権を追加するサブ関数
   * @param {Object} permission - 現在のアクセス権情報
   * @param {Array<{code: string, type: string}>} addEntities - アクセス権を追加するエンティティの配列
   * @return {Object} - 更新されたアクセス権情報
   */
  addEntitiesToFieldPermissions(permission, addEntities) {
    const newEntities = addEntities.map(entity => ({
      entity: { type: entity.type, code: entity.code },
      accessibility: 'WRITE'
    }));

    // 既に存在するエンティティを削除する
    const filteredEntities = permission.entities.filter(existingEntity =>
      !addEntities.some(addEntity => addEntity.code === existingEntity.entity.code)
    );

    // 新しいエンティティを統合する
    return {
      ...permission,
      entities: [...newEntities, ...filteredEntities]
    };
  }

  /**
   * 指定されたエンティティのフィールドアクセス権を削除するサブ関数。
   * @param {Object} permission - 現在のアクセス権情報。
   * @param {Array<{code: string, type: string}>} deleteEntities - 削除するアクセス権のエンティティ配列。
   * @return {Object} - 更新されたアクセス権情報。
   */
  removeEntitiesFromFieldPermissions(permission, deleteEntities) {
    // 指定されたエンティティと一致するものをフィルタリングして削除
    const filteredEntities = permission.entities.filter(entity =>
      !deleteEntities.some(delEntity => delEntity.code === entity.entity.code)
    );

    return {
      ...permission,
      entities: filteredEntities
    };
  }
}

function doPost(e) {
  // Webhookから送信されたデータをパース
  const webhookData = JSON.parse(e.postData.getDataAsString());
  const recordData = webhookData.record;

  // KintoneAppPermissionsクラスのインスタンスを作成
  const appId = recordData["アプリIDルックアップ"].value; // アプリIDを取得
  const kintonePermissions = new KintoneAppPermissions(appId);

  // 権限付与処理
  if (recordData["権限の付与_削除"].value.includes("権限付与")) {
    const addPermissions = recordData["権限付与"].value;
    const appPermissionsToAdd = [];
    const recordPermissionsToAdd = [];
    const fieldPermissionsToAdd = [];

    addPermissions.forEach(permissionRow => {
      const entityCode = permissionRow.value["権限付与_entityCode"].value;
      const entityType = permissionRow.value["権限付与_entityType"].value;
      const targets = permissionRow.value["権限付与_対象"].value;

      // 追加する権限のエンティティ
      const addEntity = { code: entityCode, type: entityType };

      // 各種権限タイプに対して配列に追加
      if (targets.includes("app")) {
        appPermissionsToAdd.push(addEntity);
      }
      if (targets.includes("record")) {
        recordPermissionsToAdd.push(addEntity);
      }
      if (targets.includes("field")) {
        fieldPermissionsToAdd.push(addEntity);
      }
    });

    // 一度に権限を追加して更新
    if (appPermissionsToAdd.length > 0) {
      kintonePermissions.appPermissionInsert(appPermissionsToAdd);
      kintonePermissions.updateAppPermissions();
    }
    if (recordPermissionsToAdd.length > 0) {
      kintonePermissions.recordPermissionInsert(recordPermissionsToAdd);
      kintonePermissions.updateRecordPermissions();
    }

    if (fieldPermissionsToAdd.length > 0) {
      kintonePermissions.fieldPermissionInsert(fieldPermissionsToAdd);
      kintonePermissions.updateFieldPermissions();
    }
  }



  // 権限削除処理
  if (recordData["権限の付与_削除"].value.includes("権限削除")) {
    const deletePermissions = recordData["権限削除"].value;
    const appPermissionsToDelete = [];
    const recordPermissionsToDelete = [];
    const fieldPermissionsToDelete = [];

    deletePermissions.forEach(permissionRow => {
      const entityCode = permissionRow.value["権限削除_entityCode"].value;
      const entityType = permissionRow.value["削除権限_entityType"].value;
      const targets = permissionRow.value["削除権限_対象"].value;

      // 削除する権限のエンティティ
      const deleteEntity = { code: entityCode, type: entityType };

      // 各種権限タイプに対して配列に追加
      if (targets.includes("app")) {
        appPermissionsToDelete.push(deleteEntity);
      }
      if (targets.includes("record")) {
        recordPermissionsToDelete.push(deleteEntity);
      }
      if (targets.includes("field")) {
        fieldPermissionsToDelete.push(deleteEntity);
      }
    });

    // 一度に権限を削除して更新
    if (appPermissionsToDelete.length > 0) {
      kintonePermissions.appPermissionDelete(appPermissionsToDelete);
      kintonePermissions.updateAppPermissions();
    }
    if (recordPermissionsToDelete.length > 0) {
      kintonePermissions.recordPermissionDelete(recordPermissionsToDelete);
      kintonePermissions.updateRecordPermissions();
    }
    if (fieldPermissionsToDelete.length > 0) {
      kintonePermissions.fieldPermissionDelete(fieldPermissionsToDelete);
      kintonePermissions.updateFieldPermissions();
    }
  }



  // 応答を返す
  return ContentService.createTextOutput(JSON.stringify({ "result": "success" }))
    .setMimeType(ContentService.MimeType.JSON);
}


kintoneのアプリの設定

kintoneのフィールドの設定は下記の画像のように設定します。
アプリのテンプレートをダウンロードできるようにしています。こちらをご利用ください。


webhook

先ほどGASのスクリプトで設定したURLを設定します。
通知を送信する条件は「レコードの編集時」にしてください。

設定後のkintoneの使い方

設定後は下記のように入力をします。
アプリIDは権限の設定を変更したいアプリのIDを入力してください。
※kintone上にアプリの一覧があればそこからルックアップをするのが良いと思います。

entityCode
アクセス権の設定対象のコード

entityType
「USER」 「GROUP」 「ORGANIZATION」「CREATOR」のいずれかを選びます。

対象
どこに権限の付与削除を行うかを選択します。

権限の付与_削除
付与をするのか削除をするのか選べます。両方を選べば両方保存時に実行されます

注意事項など

このアプリを使用すると誰でも好きなアプリに好きな権限を付与できてしまうので、このアプリの置き場所や権限の設定は適切に管理するようにしてください。
使用したことに不具合などよる責任は負いかねますので、自己責任で使用してください。

ここまで読んでいただきありがとうございました。
質問感想はコメントやTwitterまでおねがいいたします。
お役に立てたら嬉しいです。






この記事が気に入ったらサポートをしてみませんか?