Google Drive APIでアクセス権限一覧を取得する

Daisuke Miyakawa
JDSC Tech Blog
Published in
20 min readApr 8, 2024

JDSCでソフトウェアエンジニアをしている宮川です。少し昔のインタビュー記事としてはこちらがあります。

今回はGoogle Drive内のファイルのアクセス権限一覧を取得する方法について説明します。ChatGPTに聞いたら(現時点では)間違った回答をしてきたので、その意味でもご参考になれば幸いです。

達成したいこと

JDSC社内でアクセス権限の見直しを行っていたところ、想定と異なる権限が付与されているファイルが一部ありました。結果として情報セキュリティインシデントということではありませんでしたが、あまり気持ちの良い自体ではありません。

そこで今回、Google Drive内全体の権限を見直せるようならこれを機に見直しておこう、と考えました。管理上の見直しもしていくこととセットで行うことですが、この記事では技術的な話題にだけ集中することにします。

JDSC社内にはGoogle Workspaceの「共有ドライブ」が複数あり、そこで保存されているファイルも多数あります。人による目視ですべて確認するのは現実的ではないので、プログラム等を用いて自動化したいところです。

使用するプログラミング言語や環境

プログラミング言語PythonとGoogleクライアントライブラリを使用して、デスクトップアプリケーションとして動作させることを考えます。AWS LambdaやGoogle Cloud Functionsといったクラウド上の実行は一旦想定しません。

Google Drive APIを利用するうえでPython quickstartを参考にクレデンシャルの準備をしておく必要があります。

記事を記載する上で動作確認を行った環境については下記のとおりです (macOS Sonomaで実行しています)

$ python --version
Python 3.11.2
$ pip freeze
cachetools==5.3.2
certifi==2024.2.2
charset-normalizer==3.3.2
google-api-core==2.16.2
google-api-python-client==2.116.0
google-auth==2.27.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.0
googleapis-common-protos==1.62.0
httplib2==0.22.0
idna==3.6
oauthlib==3.2.2
protobuf==4.25.2
pyasn1==0.5.1
pyasn1-modules==0.3.0
pyparsing==3.1.1
requests==2.31.0
requests-oauthlib==1.3.1
rsa==4.9
uritemplate==4.1.1
urllib3==2.2.0

ChatGPTに聞いてみた

最近はLLMが話題です。そこで今回は、まずChatGPT (v3.5)に 「Google Drive APIで特定のファイルの共有状況を取得する方法」というお題で所定のスクリプトが手に入るかを試してみました。

プロンプトでPythonを使うという指定はしなかったのですが、たまたまPythonを用いて下記のように返答してくれました(ChatGPT偉い!)

from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

# ファイルIDを指定
file_id = 'YOUR_FILE_ID'

# OAuth 2.0 クライアント ID のファイルパス
client_secrets_file = 'path/to/client_secrets.json'

# スコープの設定
SCOPES = ['https://www.googleapis.com/auth/drive.metadata.readonly']

# 認証フローの実行
flow = InstalledAppFlow.from_client_secrets_file(client_secrets_file, SCOPES)
creds = flow.run_local_server(port=0)

# API クライアントの構築
service = build('drive', 'v3', credentials=creds)

# ファイルの共有状況を取得
try:
file_info = service.files().get(fileId=file_id, fields='permissions').execute()
permissions = file_info.get('permissions', [])

for permission in permissions:
print(f"Email: {permission['emailAddress']}, Role: {permission['role']}")

except Exception as e:
print(f"エラー: {e}")

全体として良く書かれているとは思います。

ただし結論としてはこの実装にはアクセス権限を取得する周辺で大きな誤りが含まれているようでした。実際、このスクリプトで修正が必要な部分を直しても、期待している結果は出てきません。権限の情報は見えないままです。

確かに、Googleの公式ドキュメントを読むと(この記事を記載した時点では)ChatGPTが提案した方法でやりたいことを達成できるようにも見えます。

Request body

The request body must be empty.

Response body

If successful, the response body contains an instance of File.

「Fileインスタンスが得られる」という趣旨の記述に加えて、FileインスタンスのJSON表現の説明には以下のようにあります。

{
"permissions": [
{
object (Permission)
}
],
}

しかし実際には files.get では permissions フィールドの情報は指定しても有効な情報が返ってきません。

実は、別途存在する permissions REST リソースにアクセスする必要があります。

https://developers.google.com/drive/api/reference/rest/v3/permissions

ハルシネーションの一種と言えそうですが、一方で、公式ドキュメントの側が不親切・不正確という印象も持ちます。一介のソフトウェア開発者としては、どう解釈したら良いんでしょうね……

個人的な感覚で申し上げると、Googleのこの手のライブラリとしてはよくある不親切さのレベルかと思います。ある程度慣れていると、Google Drive API v3で列挙されているRESTリソースの一覧を眺めることで、「なんか嫌な感じ」に気付けるかもしれません。

permissionsが別に列挙されている

公式の説明はなさそうに見える前提で緩い直観が告げています。 permissions を別途インターフェースに加えているところに鍵があるなと……。

執筆時点ではこの直観がおそらく正解で、permissionsインターフェースを使えば望んだ結果が得られそうでした。公式に説明がなさそうに見えて不安ですが、以降はこれを正解と思って話を続けます。

AIに頼らず頑張って一覧を取得する

すくなくとも現時点ではChatGPTで即座にこの問題の正解は得られないようでした。そのことに若干の安堵を抱きつつ、気を取り直して今回の用途に向けた実装を検討していきます。

スコープの設定とクレデンシャルの準備

Google Cloudにて適切なクレデンシャルを準備した上で、OAuthによる認可トークンを取得・記録してそれを使います。

スコープについてはOAuth 2.0 Scopes for Google APIs のGoogle Drive API, v3から適切なものを選びます。

本当は、ファイルの中身を閲覧出来るようなスコープは使いたくはありません。その点で前掲のChatGPTの実装例は最小限のスコープを指定しており望ましいように思います。

SCOPES = ['https://www.googleapis.com/auth/drive.metadata.readonly']

しかし、今回はこのスコープ設定ですと権限が足りません。具体的には共有ドライブ一覧を得る、といった操作で権限が不足しているためにAPIアクセスが失敗します。

代わりに、今回の目標に併せてhttps://www.googleapis.com/auth/drive.metadata.readonlyではなくhttps://www.googleapis.com/auth/drive.readonlyをSCOPESに指定することにします(両方指定することでも動作はします)。

SCOPES = ["https://www.googleapis.com/auth/drive.readonly"]

このような指定をしてしまうと、このアプリケーションがファイル本体をダウンロードすることも理屈上はできるようになります。これは、セキュリティの目線では嬉しくはないようには感じます。実際ユーザ目線でもそうでして、具体的には、最初の実行時にブラウザでOAuthの認可画面を見る際に若干危なそうなアプリケーションとして許可を求められるようになります。

実際にファイルを「ダウンロード」まではしないのですが、指定したスコープでは理屈上はそこまで行えるため、確認の画面でも「怖い」説明になります

今回は自分でソフトウェアを作って自分で使うだけですので、ここは納得することとし、せめてソフトウェア実装時に可能な限り変なことをしないように注意することにしましょう。

ChatGPTの例では一つのファイルに対してだけ権限取得する事例でしたが、今回は複数の共有ドライブ・複数のファイルを横断します。よって前掲のコードと比較しても実装は幾分複雑になります。具体的には下記の3種類のAPIを使用します。

例えば(自分が閲覧可能な)共有ドライブ一覧を得る部分を以下に示します。

# credsは事前に準備しておきます。前掲のChatGPTの事例で動作するはずです
drive_service = build("drive", "v3", credentials=creds)

page_token = None
all_drives = []
while True:
response = drive_service.drives().list(pageToken=page_token).execute()
all_drives.extend([drive for drive in response.get("drives")])
page_token = response.get("nextPageToken", None)
if page_token is None:
break

特に大事(見落とすかも?)と思われるポイントを2つ書いておきます

  • execute() の実行を忘れないように。このライブラリの背後にはREST APIへのアクセスがあり、 drive_service.drives.list の実行結果自体はあくまで「HTTP Request」の抽象化に過ぎません。「HTTP Response」を抽象化したオブジェクトを得るには実際の通信が発生する execute() を実施する必要があります
  • 候補が長大になった際には nextPageToken を受け取って一種のページネーションに対応するような処理をする必要があります。例えば10回のアクセスに分かれるなら、1/10, 2/10, …, 10/10 といったWebアクセスをして、RESTの結果を結合する処理は自分の実装で行います

Python上の工夫: dataclass

RESTリソースと対応してDrive, File, Permissionといったデータ構造の抽象化をしておく方が見通しが良さそうです。Pythonに慣れている人にとっては耳タコかもしれませんが、この手の単純なデータについてもclassを作っておくのが良いと思われました。さらに言えばPython 3.7で導入された dataclasses を活用するのは筋が通っているように思われます。

社内利用においてすべてのフィールドが必要なわけではなかったので、一部を抽出して例えば以下のような実装で筋が通っているように思われました。

@dataclass
class Drive:
id: str
name: str

@dataclass
class File:
drive: Drive
id: str
name: str
mime_type: str

@dataclass
class Permission:
file: File
id: str
display_name: str
type: str
role: str
email_address: str # 実は不適切(後述)

前掲の「共有ドライブの一覧を得るコード」でこの Drive クラスを使用すると以下のようになりそうです。

page_token = None
drives: List[Drive] = []
while True:
response = drive_service.drives().list(pageToken=page_token).execute()
drives.extend([Drive(id=raw["id"], name=raw["name"]) for raw in response.get("drives")])
page_token = response.get("nextPageToken", None)
if page_token is None:
break

私自身がそこまでPythonでの型ヒントにこだわらない古い人間であるため若干省略してしまう悪い癖もあるのですが、 drives のように明確に書いた方が見通しが良いときには型を書くことにしています。 response.get() の返り値は単なる辞書(強いて書くなら Dict[str, Any] でしょうか?) でしかないのと比べると、少し読みやすくなったとは思います。

煩雑なエラーハンドリングを含んだ細かい対応を除くと、File, Permissionに対しても概ね以下のようなコードとなります。

while True:
num_retries = 0
response = drive_service.files().list(
corpora="drive",
includeItemsFromAllDrives=True,
supportsAllDrives=True,
driveId=drive.id,
fields="nextPageToken, files(id, name, mimeType)",
pageToken=page_token).execute()
files.extend([File(drive=drive,
id=f["id"],
name=f["name"],
mime_type=f["mimeType"])
for f in response.get("files", [])])
page_token = response.get("nextPageToken")
if page_token is None:
break
while True:
ret = drive_service.permissions().list(
fileId=f.id,
supportsTeamDrives=True,
fields="nextPageToken, permissions(id, type, emailAddress, displayName, role)").execute()
permissions.extend([Permission(file=f,
id=perm["id"],
display_name=perm["displayName"]
type=perm["type"],
role=perm["role"],
email_address=perm["emailAddress"])
for perm in ret.get("permissions", [])])
page_token = ret.get("nextPageToken")
if page_token is None:
break

RESTで返されるデータの型は分かりづらいことも……

あとは類似のコードを書くだけなので一旦省略しますが、やはり、というか、一部不鮮明な挙動について模索する必要がありました。

一例でいうと、 permissions.listから返されるPermissionに関する挙動です。

emailAddress

string

The email address of the user or group to which this permission refers.

この手のRESTの宿命として、しれっと「値が存在しない」ケースなどがあったりします。そもそも fields 引数で明示的に指定しないフィールドは返ってきません。また、少なくとも今回、emailAddressフィールドについて自社ドメインに関わるPermissionに対しては nullNone となるようでした。一方、前掲の Permission の実装は(当初) email_address: strとしていたため、初期の実装では例外が発生することとなりました。気づけば対処はできますが、ドキュメントを丁寧に読めば気づく問題かと言うと、正直ちょっと怪しいように感じます。

簡単な対策としては、例えば以下のように email_addressNoneが入ることを許すのが良いように思われました。

@dataclass
class Permission:
file: File
id: str
display_name: str
type: str
role: str
email_address: str | None # 例えばroleがdomainの場合には存在しない

こういったことを踏まえると、例えば Drive(id=raw[“id”], name=raw[“name”]) という部分で、辞書オブジェクト raw がキーに必ず idと nameを持つかも心配になる部分はあります。同じく、 email_address=perm[“emailAddress”]という記述には明らかな問題があります。

どのキーがいつ存在していつ存在しないかの事細かな事項は、Googleの公式ドキュメントにはほぼ書いていないように見えます。Google Drive API v3がいつから利用できたかはわからないもののそこまで新しいAPIではありません(2017年頃には同APIを紹介している記事があるようです)ので、ドキュメントという観点でも抜本的に良くなる見込みも少なそうです。個人的には「よく見る現象」です。

心配ならば、その都度 raw.get(“id”)等を使用し、当然 None であってはならないケースで None が来たら警告をログに記録するなり、例外送出してアプリケーションを終了するといった地道なエラーハンドリングが必要になってくるでしょう。

共有状況の取得方法はわかった気がするけれども……

上記のような調査を経て、共有ドライブ内のファイルを巡回してアクセス権限を集計出来る要素技術が明らかになりました。「ChatGPTに今すぐ仕事を奪われたりすることもなさそう」ということも分かって、一エンジニアとして一安心……でしょうか。今回の記事で紹介したかった事項の説明は一旦ここまでで終了となります。

ただ、このアプリケーションを実用する上での課題はAPIアクセスだけではありませんでした。むしろ、ここからがお仕事としては本番かと思います。

今回の調査を元に実際に社内のすべての共有ドライブ・ファイルを調査しようとしてみたところ、当社内にある(私がアクセス可能な)共有ドライブだけでも、どうやらすべてのファイルのアクセス権限チェックには24時間以上かかりそうだ、ということが分かってきました。ファイル本体のダウンロードはせずにメタデータをチェックするだけでも結構な分量があるようです。あくまで私が閲覧できる限定的な中ですらこのような状態ですから、全体としてはもっと膨大になる可能性もあります。

今回は「一回調べられれば良い」ので、ある種の力技で一回実行しきれば良い、という考え方もあります。ただ、この調査のきっかけになった事象(意図せぬ権限付与)が起きたときになるべく早く気づきたい、ということであれば定期的な実行もある程度想定する必要があるかもしれません。立派に「バッチジョブ」の一種です。

この手の「バッチジョブ」的なプログラムは実行時間が長くなればなるほど、追加の考慮事項と、その考慮を怠ったときのトラブル対処の面倒さが飛躍的に増していく印象が私の中にはあります。アクセス中に一時的にエラーが発生することもより容易に目にするようになりますし(クラウド時代、サービスサイドが一時的に500番台のエラーを返してくることもそこまで稀ではありません)、APIの利用制限にヒットする、といった問題も起こりやすくなります。

対策としては、例えば「指数バックオフ」やら「途中過程の保存」といった対応も考えるべきでしょう。そういったことをきちんとやらないと、バッチ処理の最後の段階でたまたまネットワークがちょっと不調になっただけで、24時間分をやり直しになってしまうかもしれません。

なお今回はクラウド上での実行を対象としていないのですが、クラウド上で実行する際にタイムアウトに対する対処も難しくなるとは予想されます。また、何らかの実行キューに詰め込んで処理を分けることも当然検討の俎上に登るでしょう。おそらく、AWS Lambda等であれば「数分」という単位でそのような考慮が必要になりますが、「24時間」もまた、ある種のしきい値を超す感じがあります。例えば社内で指摘があった中で申し上げると、Cloud Run Jobsでも1回の実行では本件は巻き取れません。

……などということを色々想像して楽しみました。

終わりに

今回はGoogle Drive APIを使用した事例を紹介しました。きちんと動作しきるアプリケーションの完成には、もう少し時間がかかりそうです。やっぱりAIに仕事を奪ってもらいたいキモチになってきました。

--

--