🤖

Android Management APIでオレオレMDMを作る

2022/12/05に公開

最近はAndroidアドベントカレンダーすら過疎っているので、もうどうせならニッチなカレンダーを作って思いっきり過疎って記事を書いてやろうと思ってカレンダー作りましたw

Android Management APIとは?

Googleが用意した、EMMソリューションを作るためのAPIセットです。Next.jsが得意なエンジニアであれば、それっぽいフロントの画面を作ってオレオレMDMを簡単に作ることができるでしょう。
Appleだと4万円くらい払ってEnterprise登録しないとMDMを使用するための証明書がもらえず終了ですが、Androidだと素晴らしいことに無料でMDMを作り始めることができるのです。

手っ取り早く始めるぞ

oreore-mdm というなんの変哲もないMDMを作って、自分のデバイスをそのMDMに登録させてみます。

MDMサービスというのは、デバイスを持っている人ではなく、 デバイスを"管理したい人"が直接的なユーザである点に留意して読んでください。

参考資料: https://developers.google.com/android/management/quickstart

oreore-mdm開発前の準備

Google APIコンソールでoreore-mdmプロジェクトを作る

https://console.cloud.google.com/project

oreore-mdmプロジェクトでAndroid Management APIを使えるようにする

https://console.cloud.google.com/apis/library

完了すると、こんな画面に遷移します↓

認証情報(サービスアカウント)を作成する

Googleのクイックスタートガイド(https://developers.google.com/android/management/quickstart )では、お試しでAPIを叩いてみる用でOAuth2を使用していますが、実際のMDMサービスではエンドユーザが直接Android Management APIを叩くことはありません。そのため、サービスアカウントを作成し、認証鍵をダウンロードしておきます。

サービスアカウントの作成

名前などは用途がわかるように適当につける。

ロールは、AndroidManagementユーザロールを追加。

続行。

最後は何も入れず完了。

サービスアカウントが作られたら、それをクリック

「キー」タブを選択して鍵の作成画面へ。

鍵を追加→新しい鍵を作成

鍵のタイプはデフォルトのJSONのまま作成

暫く待つとダウンロードされる。このJSONは大事な認証情報を含んでいるので、大事にとっておきます。(GitHubなどにも絶対にアップロードしてはいけません)

Enterpriseを作る

Enterpriseは2通りの作り方があります。(参考: https://developers.google.com/android/management/create-enterprise
)

  • [Customer-managed] 利用者側でGoogleサインインしてもらって、Googleが用意したフォームを入力し、OKだったらオレオレMDMにリダイレクトで戻ってくる。そのさいにURLパラメータでEntepriseTokenという文字列が渡ってくるので、それを使って POST /enterprises する
  • [EMM-managed] 利用者はオレオレMDM上で利用規約を読み、オレオレMDMが用意したフォームで必要情報を入力。フォームが送信されたらそれを使って POST /enterprises する

後者は「そのうち使えなくなるからね」と注釈が付けられているものの、2022年12月現在はまだ動くので、今回はこちらを使用します。

そして、オレオレMDMは自分で実装して自分が使うMDMということで、規約は読んだことにして必要情報はあとから入力することにして POST /enterprises します

余談ですが、法人向けのサービスの場合には特に、サインアップ系のフォームを用意せずに「興味のある方はこちらからお問い合わせください」など問い合わせベースでサインアップ要求を受け付けて、サービス運営者が代行(手作業)でサインアップをするということはよくあります。サインアップ系のフォームは、多重登録防止制御とかいたずら防止など考慮ポイントが多いため、そんなに重要じゃない割に実装するとなると大変です。オレオレMDMでも当然手を抜きました。

Android Management APIを叩くための認証トークンを取得する

Android Management APIは、他のGoogle APIとおなじく、AuthorizationヘッダーにJWT文字列を指定することで認証します。その認証文字列は先にダウンロードした鍵のJSONファイルをもとに作成します。

ゼロから理解して作りたい人の説明というかメモは↓にあります。
https://zenn.dev/yusukeiwaki/scraps/9882b13700ac82

今回は、手っ取り早くGoogle Auth API Libraryを使っちゃいます。

JavaScriptであれば↓こんなかんじ (packages.jsonに google-auth-library の追加が必要)

require 'google-auth-library'

/**
 *
 * @param {KeyJsonPayload} keyJson
 * @returns {Promise<string | undefined>}
 */
async function fetchAccessToken(keyJson) {
    const auth = new GoogleAuth({
        credentials: {
            client_email: keyJson.client_email,
            private_key: keyJson.private_key,
        },
        scopes: [
            "https://www.googleapis.com/auth/androidmanagement"
        ]
    })
    const accessToken = await auth.getAccessToken()
    if (!accessToken) return
    return accessToken
}

Rubyであれば↓こんなかんじ (Gemfileに googleauth が必要)

    require 'googleauth'

    # @param service_account_string [String] 鍵JSONの文字列
    def fetch_access_token(service_account_string)
      client = Google::Auth::ServiceAccountCredentials.make_creds(
        json_key_io: StringIO.new(service_account_string),
        scope: ['https://www.googleapis.com/auth/androidmanagement'],
      )
      client.fetch_access_token!
      client.access_token
    end

Dartであれば↓こんなかんじ (pubspec.yamlに googleapis_auth が必要)

import 'package:googleapis_auth/auth_io.dart';
import "package:http/http.dart" as http;

Future<String> fetchAccessToken(String serviceAccountJson) async {
  final credentials = ServiceAccountCredentials.fromJson(serviceAccountJson);
  final httpClient = http.Client();
  final accessCredentials = await obtainAccessCredentialsViaServiceAccount(
    credentials,
    ["https://www.googleapis.com/auth/androidmanagement"],
    httpClient,
  );
  httpClient.close();

  return accessCredentials.accessToken.data;
}

トークン取得には、HTTP通信が1回走るので、Android Management APIを使うたびにトークンを発行したりしないよう、取得したトークンはキャッシュするようにします。
API通信の際に「通信で401が返ってきたら、1回だけトークンの再取得・キャッシュ更新を行いリトライする」のような共通処理を(インターセプタ、ミドルウェア、など呼び方は色々ありますがそういうのを利用して)入れておけば効率的にトークン取得ができます。

Android Management API側でEnterpriseを作る

https://github.com/YusukeIwaki/oreore-mdm-2022/blob/main/models/android_management_api.rb

こんな感じでAPIクライアントを適当に書いて、コンソールでAPIをエイっと叩くと

[2] pry(main)> AndroidManagementApi.call 'POST /enterprises project_id={project_id} agreementAccepted=true', payload: { enterpriseDisplayName: 'いわきゆうすけInc' }
=> {"name"=>"enterprises/LC01ycqezo", "enterpriseDisplayName"=>"いわきゆうすけInc"}

enterprises/LC01ycqezo という識別子がもらえました。これがAndroid Management API側に作られたEnterpriseです。

oreore-mdmのWebApp側のDBにも情報は残して、管理者との紐付けなどを手で行います。

[1] pry(main)> enterprise = Enterprise.create!(name: 'LC01ycqezo', display_name: 'いわきゆうすけInc')
D, [2022-12-04T13:37:09.711232 #124] DEBUG -- :   TRANSACTION (0.6ms)  BEGIN
D, [2022-12-04T13:37:09.712609 #124] DEBUG -- :   ↳ (pry):1:in `__pry__'
D, [2022-12-04T13:37:09.714688 #124] DEBUG -- :   Enterprise Create (1.4ms)  INSERT INTO "enterprises" ("name", "created_at", "updated_at", "display_name") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "LC01ycqezo"], ["created_at", "2022-12-04 13:37:09.709823"], ["updated_at", "2022-12-04 13:37:09.709823"], ["display_name", "いわきゆうすけInc"]]
D, [2022-12-04T13:37:09.715215 #124] DEBUG -- :   ↳ (pry):1:in `__pry__'
D, [2022-12-04T13:37:09.718171 #124] DEBUG -- :   TRANSACTION (1.4ms)  COMMIT
D, [2022-12-04T13:37:09.718472 #124] DEBUG -- :   ↳ (pry):1:in `__pry__'
=> #<Enterprise:0x00007f828928cb58
 id: 1,
 name: "LC01ycqezo",
 created_at: 2022-12-04 13:37:09.709823 UTC,
 updated_at: 2022-12-04 13:37:09.709823 UTC,
 display_name: "いわきゆうすけInc">
 
[2] pry(main)> AdminUser.create!(google_account: GoogleAccount.first, enterprise: enterprise)
D, [2022-12-04T13:38:05.513402 #124] DEBUG -- :   GoogleAccount Load (1.1ms)  SELECT "google_accounts".* FROM "google_accounts" ORDER BY "google_accounts"."id" ASC LIMIT $1  [["LIMIT", 1]]
D, [2022-12-04T13:38:05.514839 #124] DEBUG -- :   ↳ (pry):4:in `__pry__'
D, [2022-12-04T13:38:05.539379 #124] DEBUG -- :   TRANSACTION (1.0ms)  BEGIN
D, [2022-12-04T13:38:05.541677 #124] DEBUG -- :   ↳ (pry):4:in `__pry__'
D, [2022-12-04T13:38:05.546026 #124] DEBUG -- :   AdminUser Create (3.8ms)  INSERT INTO "admin_users" ("enterprise_id", "google_account_id") VALUES ($1, $2) RETURNING "id"  [["enterprise_id", 1], ["google_account_id", 1]]
D, [2022-12-04T13:38:05.549196 #124] DEBUG -- :   ↳ (pry):4:in `__pry__'
D, [2022-12-04T13:38:05.553824 #124] DEBUG -- :   TRANSACTION (3.9ms)  COMMIT
D, [2022-12-04T13:38:05.555167 #124] DEBUG -- :   ↳ (pry):4:in `__pry__'
=> #<AdminUser:0x00007f8288e6eb98 id: 1, enterprise_id: 1, google_account_id: 1>

デバイスの「あるべき状態」を定義する

これはサービス利用者側の話です。
デバイスをこのあと実際に登録するわけですが、そのデバイスには「どういう状態になっていてほしいか?」を規定します。このあるべき状態のことをAndroid Management APIの言葉では「ポリシー」といいます。

  • セキュリティポリシー的に、パスコードは8文字以上必須にしたい
  • YouTubeは絶対にインストールさせたくない
  • ADBデバッグは許可する
  • Slackは連絡用で全員が使うので自動で入って欲しい
  • Udemyで勉強してほしいので、Udemyは任意で入れてもいいことにする

ちなみに大前提として、仕事用端末として登録をすると、Google Playからのアプリのインストールは基本的に許可制になります。ポリシーで許可されたアプリしかGoogle Playには出てきません。(全部許可する、ということも一応できますがw)

ちなみに指定可能な項目は山のようにありますw

https://developers.google.com/android/management/reference/rest/v1/enterprises.policies

JSONでポリシーを表現する

上に書いた例を実際にポリシーにするとこんな感じになります。(参考資料: https://developers.google.com/android/management/compliance)

{
  "passwordPolicies": [
    { "passwordMinimumLength": 8 }
  ],
  "advancedSecurityOverrides": {
    "developerSettings": "DEVELOPER_SETTINGS_ALLOWED"
  },
  "applications": [
    { "packageName": "com.google.android.youtube",
      "installType": "BLOCKED" },
    { "packageName": "com.Slack",
      "installType": "FORCE_INSTALLED" },
    { "packageName": "com.udemy.android",
      "installType": "AVAILABLE" }
  ]
}

Android Management API側でポリシーを作る

オレオレMDMは適当なので、利用者がJSONを直接入力し、そのままAndroid Management APIへスルーパスします。

  post '/enterprises/:enterprise_name/policies' do
    login_required
    enterprise_required

    AndroidManagementApi.call "PATCH /enterprises/#{current_enterprise.name}/policies/#{params[:identifier]}",
      payload: JSON.parse(params[:json])

    redirect "/enterprises/#{current_enterprise.name}"
  end

だんだん準備がととのってきました。

オレオレMDMにAndroidデバイスを登録する

いよいよ、実際のデバイスを登録してみます。
まずは初期化不要なWork Profile personally-ownedモードの方法をやってみて、その後初期化を伴うFully Managed deviceモードの方法をやってみます。

どちらにも共通なのは、とりあえずQRコードでデバイス利用者に読み取ってもらうということ。

まずはその登録用QRコードを作成するところからです。

エンロールメントトークンの作成

  • デバイスは、どのMDMサービスのどのEnterpriseに属するべきか?
  • デバイスはどのようなモードでエンロールされるべきか?
  • デバイスはエンロールが完了したら、どのようなポリシーが適用されるべきか?

など、エンロール時に必要な情報は結構あります。そのあたりを1つにまとめたものが「エンロールメントトークン」とよばれるものです。(参考: https://developers.google.com/android/management/provision-device)

とはいえ、そんなに可変なパラメータは多くなく、現実的には、ポリシーとallowPersonalUsageがどちらか、くらいです。

https://developers.google.com/android/management/reference/rest/v1/enterprises.enrollmentTokens

  • duration (有効期限)
    • 無指定の場合には1時間。
  • policyName (ポリシー)
  • allowPersonalUsage
    • 特に、初期化済み端末からエンロールするときに、このフラグにより大きく挙動が変わる。
      • PERSONAL_USAGE_ALLOWEDであればWorkProfile on company-ownedモード
      • PERSONAL_USAGE_DISALLOWEDであればFully Managed deviceモード
    • 初期化を伴わないBYODを許可したい場合には PERSONAL_USAGE_ALLOWED指定が必要

注意点として、POST /enrollmentTokenしたときの返り値にはqrCodeなどがしっかり載っているものの、 GET /enrollmentToken/:id したときの返り値にはnameとexpirationTimestampしか載っていないので、基本的にはPOSTしたその場でQRコードを表示するような作りにしないといけません。

ちなみにlist, get, deleteのAPIも一応用意されていますが、エンロールメントトークンには有効期限があり、暫く経つと勝手に消される(listでも出てこなくなる)ので、list, get, deleteはほぼ出番はありません。

少し話がそれましたが、オレオレMDMでは、各ポリシーを選択して、そこから「QRコードを作成する」というボタンを押すと、PERSONAL_USAGE_ALLOWEDのQRコードとPERSONAL_USAGE_DISALLOWEDの2つのQRコードを表示するようにしてみます。

Google Chart APIを使ってQRコード画像を生成しています。

<div class="card-group">
<% %w[PERSONAL_USAGE_ALLOWED PERSONAL_USAGE_DISALLOWED].each do |allow_personal_usage_value| %>
<div class="card">
  <img src="<%=
  payload = {
    'policyName' => @policy_name,
    'allowPersonalUsage' => allow_personal_usage_value,
  }
  enrollment_token = AndroidManagementApi.call("POST /enterprises/#{current_enterprise.name}/enrollmentTokens", payload: payload)
  chart_uri = URI('https://chart.googleapis.com/chart')
  chart_uri.query = URI.encode_www_form({
    cht: 'qr',
    chs: '500x500',
    chl: enrollment_token['qrCode'],
  })
  chart_uri
  %>" class="card-img-top"/>
  <div class="card-body">
    <h5 class="card-title"><%= allow_personal_usage_value %>のQRコード</h5>
    <p class="card-text">ポリシーは<%= @policy_name.split("/").last %></p>
  </div>
</div>
<% end %>
</div>

個人所有のデバイスを登録する

WorkProfile on personally-ownedモードという、BYOD用のモードでエンロールしてみます。(参考: https://developers.google.com/android/management/provision-device#add_work_profile_from_settings)

設定アプリを開き、「Google設定」→「セットアップと復元」→「仕事用プロファイルの作成」

PERSONAL_USAGE_ALLOWEDの方のQRを読み込み。

セットアップが完了すると、Android Management API側から見えるようになります。

[2] pry(main)> AndroidManagementApi.call 'GET /enterprises/LC01ycqezo/devices'
=> {"devices"=>
  [{"name"=>"enterprises/LC01ycqezo/devices/3de5a6be6fc9faf2",
    "managementMode"=>"PROFILE_OWNER",
    "state"=>"PROVISIONING",
    "enrollmentTime"=>"2022-12-05T05:07:12.771Z",
    "softwareInfo"=>
     {"androidVersion"=>"11",

デバイス側を見てみると、Slackが自動インストールされ、UdemyはPlayストアでダウンロード可能になっています。

これで、無事にEnterprise配下にデバイスを登録できました。

仕事用プロファイルを一旦消す

デバイス側から消すこともできますし、Android Management APIでデバイスリソースを削除すると、デバイスにプッシュが行きデバイスの仕事プロファイルが消えます。

[18] pry(main)> AndroidManagementApi.call 'DELETE /enterprises/LC01ycqezo/devices/366e9f402afb6231'
=> {}

会社所有のデバイスを登録する

今回はシンプルなFully Managed deviceモードでエンロールします。デバイスは予め初期化が必要です。(参考: https://developers.google.com/android/management/provision-device#qr_code_method)

ゲームの裏技みたいなのですが、「ようこそ画面で」開始ボタンの下の空白を連打します。するとなんと!QRコードリーダーが起動しますw

PERSONAL_USAGE_DISALLOWEDの方のQRを読み込み。

同様に、セットアップが完了すると、Android Management API側から見えるようになります。

デバイス側を見てみると、仕事専用のモードでエンロールしているので「個人用」タブなどはありません。アプリはばっちり自動で入っています。

まとめ

雑多にスクショを貼りすぎてわけわからなくなってしまいましたが、oreore-mdmというものを実際に作りながらAndroid Management APIの挙動や概念を説明してみました。

実際に今回つくったoreore-mdmは↓のGitHubに上げてあります。興味のある方は試してみてください。

https://github.com/YusukeIwaki/oreore-mdm-2022

Discussion