GASでMicrosoft Graph APIを使う

どうも、みともりです。
今回はGAS(Google Apps Script)でMicrosoft Graph APIを使ってSaaSのアカウントをプロビジョニングしてみたというお話です。
需要がありそうな割にGASでGraph APIを使うサンプルがあまり無かったため、誰かのお役に立てばと思いアウトプットしてみます。

Freshserviceと繋げる

今回の対象のSaaSはFreshserviceです。いわゆるITSMツールに分類されるSaaSで、問い合わせ管理や資産管理ができるサービスです。
Azure ADとのSAML SSOには対応しているのですが、ユーザープロビジョニングには対応していないので、仕方なくJIT(Just In Time)プロビジョニングで運用していました。
ところが、アカウント作成時に初期設定したい項目が出てきたことで、JITプロビジョニング後にWeb UIからポチポチする運用が発生することに。
うっかりものの私は絶対にポチポチを忘れる自信しかないです。
ということで、いっそのこと全部自動化してしまおうと思います。

ちなみにFreshserviceといえば、最近SmartHRさんのブログでSlackの情シス問い合わせをコーポレートエンジニアの技術でシュッとさせた話という記事を見まして、いたく感銘を受けました。弊社でもこういう取り組みを進めていきたいですね。

GASで実装する仕組み

  • APIでAzure ADのグループからメンバー一覧を取得する
  • APIでFreshserviceのユーザー一覧を取得する
  • 差分を抽出して新規作成・再有効化・無効化・情報更新を行う

うん、簡単ですね!では早速実装してみましょう。
(なお、今回作成したGASはV8 Runtimeを有効にしています)

前準備:GASにOAuth2ライブラリ追加

  • GASのプロジェクトを開いて、左側のライブラリの横の+アイコンを選択します。
  • 以下のIDを入力し、[検索]を押します。1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
  • その後、最新バージョンを選択し[追加]を押します。

前準備:AADにアプリを作成

Microsoft Graph APIを使うためには、AzureADにアプリを作成してアクセストークンを発行してもらう必要があります。

① アプリ作成

  • Azure ADの管理画面にアクセスする
  • [アプリの登録] -> [新規作成]
  • 任意の名前を入力し、あとはそのままで[登録]

② プラットフォームの追加

  • [認証] -> [プラットフォームを追加] -> Web
  • リダイレクトURLに後述するURLを入力
  • [アクセストークン]にチェックを入れて[構成]

◆リダイレクトURL
https://script.google.com/macros/d/[スクリプトID]/usercallback
(スクリプトIDはGASのスクリプトエディタの設定からコピーしてください)

③ クライアントシークレットの作成

  • [証明書とシークレット] -> [新しいクライアントシークレット]
  • 有効期限を設定し[追加] (期限が切れると更新が必要)
  • 作成されたシークレットの値とIDをコピーしておく

④ アクセス許可の追加

  • [APIのアクセス許可] -> [アクセス許可の追加]
  • [Microsoft Graph] -> [委任されたアクセス許可] ※
  • Directory.Read.All と User.Read および offilne_access を選択する
  • 「[組織名]に管理者の同意を与えます」を選択 -> [はい]

アプリケーションの許可はGASのOAuth2ライブラリが対応していないので委任されたアクセス許可を選んでください。

GASサンプルコード

Microsoft_Graph.gs
– OAuth2の認証及びAPIの呼び出しを実装

var CLIENT_ID = "[③でコピーしたシークレットID]";
var CLIENT_SECRET = "[③でコピーしたシークレット]";
var SCOPES = "Directory.Read.All User.Read offline_access";
var AAD_DOMAIN = "yourdomain.com";
 
//認証
function doGet() {
  'use strict';
  var service = getService();
  if (service.hasAccess()) {
    return HtmlService.createHtmlOutput('Success');
  }
  // 承認されていない場合、承認リンクをHTMLページに表示
  var authorizationUrl = service.getAuthorizationUrl();
  var template = '<a href="' + authorizationUrl + '" target="_blank">Authorize</a>';
  return HtmlService.createHtmlOutput(template);
}
 
//OAuth2サービス設定
function getService() {
  return OAuth2.createService("Microsoft Graph")
    .setAuthorizationBaseUrl("https://login.microsoftonline.com/" + AAD_DOMAIN + "/oauth2/v2.0/authorize?")
    .setTokenUrl("https://login.microsoftonline.com/" + AAD_DOMAIN + "/oauth2/v2.0/token")
    .setClientId(CLIENT_ID)
    .setClientSecret(CLIENT_SECRET)
    .setScope(SCOPES)
    .setCallbackFunction("authCallback")
    .setPropertyStore(PropertiesService.getUserProperties())
    .setParam("response_type", "code");
}
 
//認証コールバック
function authCallback(request) {
  console.log(request);
  var service = getService();
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput("Sucess");
  } else {
    return HtmlService.createHtmlOutput("Failed");
  }
}
 
//ログアウト
function reset() {
  getService().reset();
}

// 指定したAADグループのメンバー一覧を取得する
function getAadGroupMembers(group_id) {
  var aad_group_members = new Array();
  var aad_group_members_map = new Object();

  var url = "https://graph.microsoft.com/v1.0/groups/" + group_id + "/members?$count=true";
  do {
    var res = callGraphApi("GET",url);
    aad_group_members = aad_group_members.concat(res.value);
    url = res["@odata.nextLink"];
  } while(url);
  
  for (var i=0; i < aad_group_members.length; i++) {
    var aad_group_member = aad_group_members[i];
    aad_group_members_map[aad_group_member.userPrincipalName] = aad_group_member;
  }
  return aad_group_members_map;
}

//Microsoft Graph APIの呼び出し
function callGraphApi(httpMethod, url) {
  var service = getService();
  if (service.hasAccess()) {
    var response = UrlFetchApp.fetch(url, {
      headers: {
        Authorization: "Bearer " + service.getAccessToken()
      },
      muteHttpExceptions : true,
      method: httpMethod,
      contentType: "application/json"
    });
    return JSON.parse(response.getContentText());
  }
}

Freshservice.gs
– Freshserviceとの差分を抽出してFreshservice側のユーザー情報をAPIで更新する

var FRESH_API_KEY = "[FreshserviceのAPI Key]";
var FRESH_API_URL = "https://yourdomain.freshservice.com/api/v2/";
var AAD_FRESH_GROUP_ID = "[FreshserviceのSAMLを設定しているAADグループのID]";

// メイン処理
function syncFreshservice(){
  // AADのFreshserviceグループのメンバー一覧情報を取得
  var aad_group_members_map = getAadGroupMembers(AAD_FRESH_GROUP_ID);
  var aad_group_members_email = Object.keys(aad_group_members_map);

  var all_requesters = getFreshAllRequesters();
  var active_requesters_map = new Object();
  var deactive_requesters_map = new Object();

  // Freshから取得したリクエスター一覧は無効ユーザーも混ざっているので別々のデータにする
  for (var i=0; i < all_requesters.length; i++) {
    var requester = all_requesters[i];
    if(requester.active) {
      active_requesters_map[requester.primary_email] = requester;
    } else {
      deactive_requesters_map[requester.primary_email] = requester;
    }
  }
  // リクエスター新規作成(Create)
  var active_requesters_email = Object.keys(active_requesters_map);
  var create_list = aad_group_members_email.filter(i=>active_requesters_email.indexOf(i) == -1);
  createFreshRequester(create_list, aad_group_members_map);

  // 無効リクエスターの再有効化(Reactivate)
  var deactive_requesters_email = Object.keys(deactive_requesters_map);
  var reactivate_list = deactive_requesters_email.filter(i=>aad_group_members_email.indexOf(i) > 0);
  reactivateFreshRequester(reactivate_list, deactive_requesters_map);

  // 有効リクエスターの無効化(Deactivate)
  var deactivate_list = active_requesters_email.filter(i=>aad_group_members_email.indexOf(i) == -1);
  deactivateFreshRequester(deactivate_list, active_requesters_map);

  // 有効リクエスターの情報更新(Update)
  var update_check_list = aad_group_members_email.filter(i=>active_requesters_email.indexOf(i) > 0);
  var update_list = new Object();
  // AADと情報に差分があるものを抽出
  for(var i = 0; i < update_check_list.length;i++){
    var aad_member = aad_group_members_map[update_check_list[i]];
    var active_requester = active_requesters_map[update_check_list[i]];
    var payload = new Object();
    if (aad_member.givenName != active_requester.first_name){
      payload["first_name"] = aad_member.givenName;
    }
    if (aad_member.surname != active_requester.last_name){
      payload["last_name"] = aad_member.surname;
    }
    if (Object.keys(payload).length > 0){
      // ここに追加で設定したい項目をpayloadに追加する
      update_list[active_requester.id] = payload;
    }
  }
  updateFreshRequester(update_list);
}

function updateFreshRequester(update_list) {
  var url = FRESH_API_URL + "requesters";
  var requester_id_list = Object.keys(update_list);
  for(var i = 0; i < requester_id_list.length; i++){
    var id = requester_id_list[i];
    var payload = update_list[id];
    callFreshAPI("PUT", url + "/" + id, payload);
  }
}

function deactivateFreshRequester(deactivate_list, requesters_map) {
  var url = FRESH_API_URL + "requesters";
  for(var i = 0; i < deactivate_list.length; i++){
    var requester = requesters_map[deactivate_list[i]];
    callFreshAPI("DELETE", url + "/" + requester.id);
  }
}

function reactivateFreshRequester(reactivate_list, requesters_map) {
  var url = FRESH_API_URL + "requesters";
  for(var i = 0; i < reactivate_list.length; i++){
    var requester = requesters_map[reactivate_list[i]];
    callFreshAPI("PUT", url + "/" + requester.id + "/reactivate");
  }
}

function createFreshRequester(create_list, aad_group_members_map) {
  var url = FRESH_API_URL + "requesters";
  for(var i = 0; i < create_list.length; i++){
    var email = create_list[i];
    var payload = {
      first_name : aad_group_members_map[email].givenName,
      last_name : aad_group_members_map[email].surname,
      primary_email : email,
      // ここに初期設定したい項目を追加する
    };
    callFreshAPI("POST", url, payload);
  }
}

function getFreshAllRequesters() {
  var url = FRESH_API_URL + "requesters";
  var requesters = new Array();
  do {
    var response = callFreshAPI("GET", url);
    requesters = requesters.concat(JSON.parse(response.getContentText()).requesters);
    if (response.getHeaders().Link) {
      var next_link_html = response.getHeaders().Link;
      url = next_link_html.substr(1, next_link_html.length - 14);
    }
  }
  while (response.getHeaders().Link);
  return requesters;
}

//Freshservice APIを呼び出す
function callFreshAPI(http_method, url, payload){
  var options = {
    headers: {
        Authorization: "Basic " + Utilities.base64Encode(FRESH_API_KEY + ":X")
      },
    method : http_method,
    contentType: 'application/json',
  };
  if (http_method == "POST" || http_method == "PUT") {
    options["payload"] = JSON.stringify(payload);
  }
  return UrlFetchApp.fetch(url, options);
}

なお、こちらはサンプルコードなので、実運用で使う場合はシークレットの扱いやエラーハンドリングを追加して頂いたほうがよいと思います。

サンプルコードの使い方

  • GASエディタの右上の[デプロイ] -> [新しいデプロイ]を選択
  • そのまま[デプロイ]
  • 「ウェブアプリ」の下に表示されているURLへアクセス
  • [Authorize]をクリックする
  • アプリの許可画面が表示されたら「はい」ボタンをクリックする
  • GASエディタに戻り、syncFreshservice を手動実行する
  • 正常に実行できていることが確認できれば、トリガにsyncFreshserviceのスケジュール実行を設定する

こうすることでAzureADのグループを編集すると、スケジュール実行のたびに同期処理が走り、ユーザープロビジョニングが自動で行われるようになります。

(気力の関係でかなり説明を端折ってしまった部分があるので、うまく動かない場合は直接私のTwitterでご連絡いただければフォローできるかもです)

まとめ

今回はSAML SSOに対応しているSaaSでしたが、AzureADのSSOに対応していないけどGoogle Loginは対応している、というようなSaaSを今回のような形でプロビジョニングの自動化をすると、アカウント管理をAzureADのグループで一元管理できるので、アカウントの追加/削除漏れが減っていいのでは?と思います。

ちなみに、Oktaと違ってAzureADはSSOの設定がわかりづらかったりプロビジョニングに対応していないものが多いのでOktaのほうが魅力的に感じるかもしれませんが、「自分でコード書いたるわ」という気概のある方の場合は細かい制御もできますし逆にAzureADのほうがやりがいがあるかもしれません。

エンジニアを募集しています

ビザスクでは、エンジニアとして働きたい方を募集しています。
ご興味のある方は下記よりお気軽にご連絡ください。