Ruby Google Calendar

Rubyを使って公開設定された社内のGoogleカレンダーを見つけた話

Ruby Google Calendar

こんにちは、技術部基盤チームのじっぱー(@buty4649)です。

さて、今日からGMOペパボエンジニア Advent Calendar 2023が始まりました!🎉 ペパボのエンジニアで行っているアドベントカレンダーは2014年から毎年開催しており、今年で10回目です。 10回目ではありますが、毎年アドベントカレンダーを作成するとすぐに埋まってしまいます。 そこで今年も2つのアドベントカレンダーを用意しています。 毎日ペパボのエンジニアが書いた記事を公開していきますので、ぜひチェックしてみてください。

すでに🎅GMOペパボエンジニア Advent Calendar 2023の1日目の記事が公開されています。気になる方はこちもチェックしてみてください。

この記事は🎄GMOペパボエンジニア Advent Calendar 2023の1日目の記事です。

事の始まり

ある日、社内でセキュリティインシデントが発生しました。 それはとある社員のGoogleカレンダーの「予定のアクセス権限」が「一般公開して誰でも利用できるようにする」設定が有効になっていたという内容でした。 幸いにも情報漏えいにつながる予定は登録されていなかったため、大きな問題にはなりませんでした。

しかしながら、このような事態が発生したことで、全社員のGoogleカレンダーの「予定のアクセス権限」を一括で確認する必要が出てきました。 1人1人に確認してもらうのは大変なので、なにか楽をしたいところです。 ペパボではGoogle Workspaceを利用しており、社員間のスケジュール管理にGoogleカレンダーを活用しています。 Google WorkspaceはGoogleカレンダーやGmailなどを含む、ビジネス向けの統合クラウドベースオフィススイートです。 Google Workspaceには管理コンソールがあり、それを通じて全社員のカレンダーアクセス権限を一括で確認する方法を調査しましたが、このアプローチは技術的に難しいことがわかりました。 そこで、Rubyを使って全社員のGoogleカレンダーを一括で確認するツールを作成しました。 本記事ではそのツールについて紹介します。

機械的に「予定のアクセス権限」を確認する方法

Googleカレンダーにおける「予定のアクセス権限」を機械的に確認するにはどうしたらいいでしょうか? 答えはGoogle Calendar APIを使うことです。 Google Calendar APIのACL: listを使うと、指定したカレンダーのアクセス制御の一覧を取得できます。 具体的にはレスポンスに含まれるitemsリストの中に、アクセス制御に関わる情報がハッシュとして格納されています。 以下はitemsリストの中身の例示です。

{
 "kind": "calendar#aclRule",
 "etag": "\"00001676359663493000\"",
 "id": "default",
 "scope": {
  "type": "default",
  "value": "__public_principal__@public.calendar.google.com"
 },
 "role": "reader"
}

ここで注目するのはscope.typeです。 この値がdefaultの場合は「予定のアクセス権限」が「一般公開して誰でも利用できるようにする」設定が有効になっています。

さて、カレンダーのアクセス制御の一覧を取得する方法がわかりました。 しかし、このAPIを使うにはカレンダーのIDが必要です。 カレンダーIDの一覧はGoogle Calendar APIのCalendarList: listを使うことで取得できる・・・のですが、このAPIは自分自身のカレンダーの一覧しか取得できません。 では、他の社員のカレンダーの一覧を確認するにはどうしたらいいでしょうか? それにはまず、他の社員のアカウント一覧を取得した上で、そのアカウントに成り代わり、カレンダーの一覧を取得する必要があります。 このアカウントに成り代わる処理はGoogle Workspace ドメイン全体の権限の委任を行うことで実現できます。 具体的には、Google Cloudでサービスアカウントを発行し、そのサービスアカウントにGoogle Workspaceのドメインに対して権限を委任します。

サービスアカウントを発行して権限を委任する

それではサービスアカウントを発行して権限を委任できるようにします。 サービスアカウントの作成については、Google Cloudのドキュメントに詳しい説明がありますので、そちらを参照してください。

サービスアカウントを作成したら、後ほど使用するため秘密鍵の作成とクライアントIDを控えておきます。

また、サービスアカウントを作成したProjectでGoogle Calendar APIとアカウント一覧の取得に利用するのでAdmin SDK APIを有効にしておきます。 有効化する手順は、Google Cloudのドキュメントに詳しい説明がありますので、そちらを参照してください。

次に、Google Workspaceの管理コンソールにアクセスします。 この管理コンソールにアクセスするためには、Google Workspaceの管理者権限が必要です。 管理コンソールにログインしたら左側のメニューから、セキュリティ > アクセスとデータ管理 > APIの権限を選択し、ドメイン全体の委任を管理をクリックします。

新しく追加をクリックするとダイアログが表示されます。 ダイアログにはクライアントIDとOAuthスコープを入力します。 OAuthスコープにはGoogle Calendar APIとAdmin SDK APIのスコープを入力します。

  • クライアントID: 先ほど控えたクライアントID
  • OAuthスコープ:
    • https://www.googleapis.com/auth/calendar
    • https://www.googleapis.com/auth/admin.directory.user

これでサービスアカウントにGoogle Workspaceのドメインに対して権限を委任できるようになりました。

Rubyを用いた具体的な実装プロセス

では実際にRubyを使って実装していきます。 Rubyを選んだ理由は、単純に私がRubyを使い慣れているからです。 Pythonや他の言語を使っても同じようなことができると思います。

APIへのアクセスには以下のGemを使います。 これらのGemはGoogleが公式で提供しているもので、Googleが提供しているAPIを使う場合はこれらのGemを使うのがおすすめです。

次に処理の流れを説明します。 まず、Google WorkspaceのAdmin SDKのDirectory API users.listを使って、Google Workspaceのドメインに所属するアカウントのメールアドレス一覧を取得します。 次に、取得したメールアドレスを使って対象のアカウントのカレンダーの一覧を取得します。 取得したカレンダーの一覧のアクセス制御を確認し、scope.typedefaultのものがあれば、そのアカウントのメールアドレスと対象のカレンダーの情報を出力します。 それぞれの処理の実装について説明していきます。

アカウント一覧の取得

まずアカウント一覧の取得部分です。 対象のAPIに相当するクラスをインスタンス化し、そのインスタンスに対してサービスアカウントの認証を割当てます。 これは、他のAPIを利用するときも同様の流れを踏みます。 Google::Auth::ServiceAccountCredentials.from_envは利用するサービスアカウントの情報を環境変数から取得するというメソッドです。 設定する環境は変数については後述します。 auth.update!(sub: ENV.fetch('ADMIN_EMAIL'))の部分では、管理者アカウントのメールアドレスを設定し、そのユーザに成り代わるという処理になっています。 アクセストークンを設定したら、list_usersメソッドを呼び出してアカウント一覧を取得しています。 このとき、queryを指定してサスペンドになっているアカウントを除外しています。 これはサスペンドしたアカウントのカレンダー一覧を取得しようとすると、例外が発生しノイズになってしまうためです。 Google APIは基本的にページネーションされているので、それを考慮したコードになっています(Gemで自動で対応してくれれば便利なのですが・・・)。 各アカウントのメールアドレスは primary_email に格納されているので、それを配列に格納しています。

scope = 'https://www.googleapis.com/auth/admin.directory.user'
ds = Google::Apis::AdminDirectoryV1::DirectoryService.new
ds.authorization = Google::Auth::ServiceAccountCredentials.from_env(scope:).tap do |auth|
  auth.update!(sub: ENV.fetch('ADMIN_EMAIL'))
  auth.fetch_access_token!
end

users = []
page_token = nil
max_results = 500
query = 'isSuspended=false'

loop do
  response = ds.list_users(domain:, page_token:, max_results:, query:)
  users += response.users.map(&:primary_email)
  page_token = response.next_page_token
  break unless page_token
end

users

カレンダー一覧の取得

次にGoogleカレンダー一覧の取得部分です。 アカウント一覧の取得と同様に、対象のAPIに相当するクラスをインスタンス化し、そのインスタンスに対してサービスアカウントの認証を割当てます。 先程と違う部分は、auth.update!(sub: user)の部分です。 今回はカレンダーの一覧を取得したいアカウントのメールアドレスを指定します。 アクセストークンを設定したら、list_calendar_listsメソッドを呼び出してカレンダー一覧を取得しています。 begin~rescue句を設定しているのは、一度もログインしていないアカウントのカレンダー一覧を取得しようとすると例外が発生するためです。 例えば、機械的なアクセスのみを行うアカウントの場合、一度もログインしていないことがあります。 例外が発生するとプログラム自体が異常終了してしまいますが、処理件数が多く途中で止まってしまうと困るので、例外が発生した場合は画面に出力して処理を継続するようにしています。

user = "<ユーザのメールアドレス>"
scope = 'https://www.googleapis.com/auth/calendar'
client = Google::Apis::CalendarV3::CalendarService.new
client.authorization = Google::Auth::ServiceAccountCredentials.from_env(scope:).tap do |auth|
  auth.update!(sub: user)
  auth.fetch_access_token!
end

calendars = []
page_token = nil
show_hidden = true

begin
  loop do
    response = client.list_calendar_lists(page_token:, show_hidden:)
    calendars += response.items
    page_token = response.next_page_token
    break unless page_token
  end
rescue StandardError => e
  warn e
  warn user
end

calendars

アクセス制御の確認

最後に指定されたカレンダーのアクセス制御を確認します。 clientはカレンダー一覧の取得に使ったインスタンスをそのまま使います。 アクセス制御一覧を確認し、scope.typedefaultに設定されていないかをチェックしています。

acls = []
page_token = nil

loop do
  response = client.list_acls(calendar_id, page_token:)
  acls += response.items
  page_token = response.next_page_token
  break unless page_token
end

acls.find { |acl| acl.scope.type == 'default' }

コードの全体

これらの処理を組み合わせると以下のようなコードになります。 実行時間が長くなるので視覚的に分かりやすくするために、画面に稼働していることがわかるような処理をいれています(コメント部分)。

#!/usr/bin/env ruby
# calendar.rb

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'googleauth'
  gem 'google-apis-admin_directory_v1'
  gem 'google-apis-calendar_v3'
end

def all_users(domain = ENV.fetch('DOMAIN'))
  scope = 'https://www.googleapis.com/auth/admin.directory.user'
  ds = Google::Apis::AdminDirectoryV1::DirectoryService.new
  ds.authorization = Google::Auth::ServiceAccountCredentials.from_env(scope:).tap do |auth|
    auth.update!(sub: ENV.fetch('ADMIN_EMAIL'))
    auth.fetch_access_token!
  end

  users = []
  page_token = nil
  max_results = 500
  query = 'isSuspended=false'

  loop do
    response = ds.list_users(domain:, page_token:, max_results:, query:)
    users += response.users.map(&:primary_email)
    page_token = response.next_page_token
    break unless page_token
  end

  users
end

def calendars(client)
  calendars = []
  page_token = nil
  show_hidden = true

  loop do
    response = client.list_calendar_lists(page_token:, show_hidden:)
    calendars += response.items
    page_token = response.next_page_token
    break unless page_token
  end

  calendars
end

def list_acls(client, calendar_id)
  acls = []
  page_token = nil

  loop do
    response = client.list_acls(calendar_id, page_token:)
    acls += response.items
    page_token = response.next_page_token
    break unless page_token
  end

  acls
end

public_calendars = []
count = 0

all_users.each do |user|
  scope = 'https://www.googleapis.com/auth/calendar'
  cs = Google::Apis::CalendarV3::CalendarService.new
  cs.authorization = Google::Auth::ServiceAccountCredentials.from_env(scope:).tap do |auth|
    auth.update!(sub: user)
    auth.fetch_access_token!
  end

  calendars(cs).each do |calendar|
    count += 1
    # 所有者が自分ではないカレンダーはスキップ
    next unless calendar.access_role == 'owner'

    id = calendar.id
    acls = list_acls(cs, id)
    if acls.find { |acl| acl.scope.type == 'default' }
      $stdout.write('F') # 進捗表示

      public_calendars << {
        user:,
        id:,
        summary: calendar.summary
      }
    else
      $stdout.write('.') # 進捗表示
    end
  end
rescue StandardError => e
  warn
  warn e
  warn user
end

puts
puts "count: #{count}"

if public_calendars.empty?
  puts '✅公開設定されているカレンダーはありませんでした'
else
  require 'yaml'
  puts '❌公開設定されているカレンダーがありました!!'
  puts YAML.dump(public_calendars)
end

実行

スクリプトができたので実行・・・!の前にいくつかの環境変数を設定する必要があります。 設定する環境変数と、内容は以下の通りです。

環境変数名 内容
$GOOGLE_APPLICATION_CREDENTIALS サービスアカウントの認証情報が格納されたファイルへのパス
$ADMIN_EMAIL Google Workspaceの管理者アカウントのメールアドレス
$DOMAIN Google Workspaceのドメイン

環境変数の設定が終わったら、スクリプトを実行します。

$ ruby calendar.rb

ペパボの環境だとアカウントの数が400件程度、カレンダーの数が9400件程度あり、実行時間は20分でした。 また実行結果より、インシデントのきっかけになった社員以外にも数名公開設定になっているカレンダーを発見できました。

まとめ

社内で発生したセキュリティインシデントの対応として、全社員のGoogleカレンダーの「予定のアクセス権限」を一括で確認するツールを作成しました。 このツールのおかげでインシデントのきっかけになった社員以外にも公開設定になっているカレンダーを発見できました。 ヒアリングしてみると故意ではなく勘違いや操作ミスで設定してしまったということでした。

本記事では紹介できませんでしたが、このツール以外にも今回のインシデント対応として以下の対応を行っています。

  • Google Workspaceの管理コンソールから「予定のアクセス権限」を一括で変更し空き時間情報のみ公開するように変更
  • カレンダーが一般公開設定に変更されたSlackで通知される仕組みを作成

これらの対応により、今後はこのような事態が発生することを防ぐことができると思います。