Jamf Pro のグループに期限付きでユーザを入れたい

この記事はレコチョク Advent Calendar 2022 の3日目の記事となります。

https://qiita.com/advent-calendar/2022/recochoku

---

**Jamf Pro のグループに期限付きでユーザを入れたい。**[^1]
[^1]: 本稿では User Groups について考察しますが、Computer Groups や Device Groups でも同様の管理が可能と考えています。

## 動機

レコチョクでは Mac や iPhone、iPad といった Apple 製品を Jamf Pro で管理しています。

Jamf Pro には「グループ」という仕組みがあり、グループに含まれるすべてのユーザに対して一様にライセンス等を割り当てることができます。

期限的にライセンスを割り当てる場合は、期限の開始日と終了日にグループを管理する作業が求められます。
具体的には、12月5日（開始日）から12月8日（終了日）まで、あるユーザをグループに入れてライセンスを割り当てたいとすると、12月5日の朝にグループに追加する作業が発生し、12月8日の夜にグループから削除する作業が発生することになります。

これらの操作を自動化することで、管理者の省力化とオペレーションミス削減を図りたいと考えました。

## 方針

グループには「スマートグループ」と「スタティックグループ」の2種類が存在します。
それぞれのグループを利用した方法を検証します。

- **方法1 - スマートグループを用いた方法**
ユーザの拡張属性に開始日と終了日を事前に入力しておき、開始日になると自動的にグループに追加され、終了日を過ぎると自動的にグループから削除されるようにします。

- **方法2 - スタティックグループを用いた方法**
開始日と終了日をリストとして事前に作成しておき、Jamf Pro API と Classic API を活用してグループを自動管理します。

## 方法1
ここではスマートグループを用いた方法を考えます。

**Step 1. 開始日と終了日を入力するための拡張属性を作成する**
クライテリアで日付比較をするため、データタイプは `Date` にします。

![開始日](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142063/72a47a1d-4044-70dd-e48c-4a7e20993be9.png "開始日")
![終了日](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142063/771a0f49-2769-68be-745e-b12beffb8c5b.png "終了日")

**Step 2. スマートグループを作成する**
クライテリアは以下のように設定します。

| AND/OR |　| クライテリア | オペレータ | 値 |　|
|:--:|:--:|:--:|:--:|:--:|:--:|
|     |　| テスト開始日 | more than x days ago | 1 |　|
| and |　| テスト終了日 | less than x days ago | 1 |　|

![グループ設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142063/f6e0ca01-0fa2-250f-fcd8-1a941df9b439.png "グループ設定")

**Step 3. 拡張属性に開始日と終了日を入力する**
ここでのポイントは以下2点です。
- クライテリアを満たすため、開始日と終了日の日付は想定の1日前（24時間前）です。
- JST ではなく UTC なので、開始日と終了日の時刻は想定の9時間前です。

つまり、想定する日時の 33 (=24+9) 時間前を設定しておく必要があります。
例えば「2022年12月3日 00時00分00秒」にグループに入ってほしいのであれば、設定する日時は33時間前の「2022年12月1日 15時00分00秒」です。

![ユーザ属性](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142063/319b1e00-665f-838a-76d7-e67561b81212.png "ユーザ属性")

### 方法1の課題点
- 拡張属性に入力する日時は前日かつ UTC であるため、設定ミスが発生する恐れがある。
- 開始日・終了日には対応する一組しか設定することができない。
- スマートグループが再計算されるタイミングや周期は Jamf Pro 依存であるため、いつグループに追加・削除されるのか分からない。[^2]
  - 対象のスマートグループのみを強制的に再計算するため、Jamf API の [recalculate](https://developer.jamf.com/jamf-pro/reference/post_v1-smart-user-groups-id-recalculate "recalculate") を定期的に実行することを推奨します。

https://developer.jamf.com/jamf-pro/reference/post_v1-smart-user-groups-id-recalculate

[^2]: ずっと待っていますが、なかなか再計算されないです。再計算のタイミングや周期をご存知の方はコメントで教えてください。

## 方法2
ここではスタティックグループを用いた方法を考えます。

**Step 1. スタティックグループを作成する**
特別な設定は必要ありません。

**Step 2. グループの追加・削除リストを作成する**
今回は3列のみのシンプルな CSV ファイル（list.csv）を作成します。
- `USER_ID` （対象ユーザの ID）
- `START_DATE` （開始日）
- `END_DATE` （終了日）

```
USER_ID,START_DATE,END_DATE
1,20221201,20221202
2,20221202,20221205
```

**Step 3. 追加・削除リストを読み込む**
Step 2. で作成した CSV のリストをスクリプトから読み込みます。

```js
const csv = parse(fs.readFileSync("list.csv"), { columns: true });
```

**Step 4. Jamf API からトークンを取得する**

https://developer.jamf.com/jamf-pro/reference/post_v1-auth-token

`JAMF_ID` と `JAMF_PASSWORD` は適切な権限があるユーザの ID とパスワードを使います。
（Jamf Pro のログインに普段使っているユーザではなく、権限を限定したスクリプト用ユーザを推奨します）

```js
const auth = Buffer.from(`${JAMF_ID}:${JAMF_PASSWORD}`).toString("base64");
const headers = { Authorization: `Basic ${auth}` };
const response = await axios.post(
  `https://yourserver.jamfcloud.com/api/v1/auth/token`,
  {},
  { headers }
);
const token = response.data.token;
```

**Step 5. スタティックグループに現在含まれるユーザを取得する**
ユーザの取得には Classic API を使います。
`GROUP_ID` には Step 1. で作成したスタティックグループの ID を使用します。

https://developer.jamf.com/jamf-pro/reference/findusergroupsbyid

```js
const headers = { Authorization: `Bearer ${token}` };
const response = await axios.get(
  `https://yourserver.jamfcloud.com/JSSResource/usergroups/id/${GROUP_ID}`,
  { headers }
);
const users = response.data.user_group.users;
```

**Step 6. スタティックグループに追加するユーザを抽出する**
CSV から以下の条件を満たしているユーザを抽出します。
- 本日（`TODAY`）が CSV の `START_DATE` と `END_DATE` の間に含まれている
- まだスタティックグループに存在していない

```js
const includeArray = csv
  .filter((x) => x.START_DATE <= TODAY && TODAY <= x.END_DATE)
  .map((x) => x.USER_ID);
const deduplicatedIncludeArray = [...new Set(includeArray)];

const addArray = [];
deduplicatedIncludeArray.forEach((x) => {
  let exists = false;
  users.forEach((y) => {
    if (!exists && x === String(y.id)) {
      exists = true;
    }
  });
  if (!exists) {
    addArray.push(x);
  }
});
```

**Step 7. ユーザを追加するための XML を作成する**
```js
let xml = `<user_group>
    <user_additions>`;
addArray.forEach((x) => {
  xml += `
      <user>
          <id>${x}</id>
      </user>`;
});
xml += `
    </user_additions>
</user_group>`;
```

**Step 8. ユーザをスタティックグループに追加する**
Step 7. で作成した XML を Classic API に PUT します。

https://developer.jamf.com/jamf-pro/reference/updateusergroupsbyid

```js
const headers = {
  Authorization: `Bearer ${token}`,
  "Content-Type": "text/xml",
};
const response = await axios.put(
  `https://yourserver.jamfcloud.com/JSSResource/usergroups/id/${GROUP_ID}`,
  xml,
  { headers }
);
```

以上でスタティックグループへのユーザ追加は完了です。
次に、期限が過ぎたユーザをスタティックグループから削除します。

**Step 9. ユーザをスタティックグループから削除する**
処理の流れは追加のときと同様のため、詳細な説明は割愛します。
CSV のすべての行で以下を満たしているユーザを削除します。
- 本日（`TODAY`）が CSV の `START_DATE` と `END_DATE` の間に含まれていない
- すでにスタティックグループに存在している

<details><summary>サンプルコードを示しておきます。</summary><div>

```js
const fs = require("fs");
const axios = require("axios");
const { parse } = require("csv-parse/sync");

const JAMF_URL = "https://yourserver.jamfcloud.com";
const JAMF_ID = "yourid";
const JAMF_PASSWORD = "yourpassword";

const GROUP_ID = "3";

const date = new Date();
const YEAR = String(date.getFullYear()).padStart(2, "0");
const MONTH = String(date.getMonth() + 1).padStart(2, "0");
const DAY = String(date.getDate()).padStart(2, "0");
const TODAY = `${YEAR}${MONTH}${DAY}`;

let token = "";

const getToken = async () => {
  const auth = Buffer.from(`${JAMF_ID}:${JAMF_PASSWORD}`).toString("base64");
  const headers = { Authorization: `Basic ${auth}` };
  const response = await axios.post(
    `${JAMF_URL}/api/v1/auth/token`,
    {},
    { headers }
  );
  return response.data.token;
};

const getUsers = async () => {
  const headers = { Authorization: `Bearer ${token}` };
  const response = await axios.get(
    `${JAMF_URL}/JSSResource/usergroups/id/${GROUP_ID}`,
    { headers }
  );
  return response.data.user_group.users;
};

const createAdditionXML = (array) => {
  let xml = `<user_group>
    <user_additions>`;
  array.forEach((x) => {
    xml += `
        <user>
            <id>${x}</id>
        </user>`;
  });
  xml += `
    </user_additions>
</user_group>`;
  return xml;
};

const createDeletionXML = (array) => {
  let xml = `<user_group>
    <user_deletions>`;
  array.forEach((x) => {
    xml += `
        <user>
            <id>${x}</id>
        </user>`;
  });
  xml += `
    </user_deletions>
</user_group>`;
  return xml;
};

const updateUsers = async (xml) => {
  const headers = {
    Authorization: `Bearer ${token}`,
    "Content-Type": "text/xml",
  };
  const response = await axios.put(
    `${JAMF_URL}/JSSResource/usergroups/id/${GROUP_ID}`,
    xml,
    { headers }
  );
  return response;
};

(async () => {
  token = await getToken();
  const users = await getUsers();

  const csv = parse(fs.readFileSync("list.csv"), { columns: true });

  const includeArray = csv
    .filter((x) => x.START_DATE <= TODAY && TODAY <= x.END_DATE)
    .map((x) => x.USER_ID);
  const deduplicatedIncludeArray = [...new Set(includeArray)];

  const addArray = [];
  deduplicatedIncludeArray.forEach((x) => {
    let exists = false;
    users.forEach((y) => {
      if (!exists && x === String(y.id)) {
        exists = true;
      }
    });
    if (!exists) {
      addArray.push(x);
    }
  });
  if (addArray.length > 0) {
    const xml = createAdditionXML(addArray);
    console.log(xml);
    await updateUsers(xml);
  }

  const removeArray = [];
  users.forEach((y) => {
    if (!deduplicatedIncludeArray.includes(String(y.id))) {
      removeArray.push(String(y.id));
    }
  });
  if (removeArray.length > 0) {
    const xml = createDeletionXML(removeArray);
    console.log(xml);
    await updateUsers(xml);
  }
})();
```

</div></details>

### 方法2の課題点
- スクリプトを作成する必要があり、メンテナンスには API の知識が求められる。
- スクリプトを定期実行するサーバを維持しなければいけない。
  - リストを Google Sheets、スクリプトを Google Apps Script で作成し、トリガーを一日一回の実行にするといいかも。
- 運用を続けていくうちにリストが大きくなれば、パフォーマンスにも気を配る必要がある。
  - 定期的に棚卸しを実施し、不必要な行は削除する。

## まとめ

本稿では Jamf Pro のグループに期限付きでユーザを入れる方法について考察しました。
それぞれの方法に課題点がありますので、状況に応じて適切な方法を選択していきたいと思います。

Azure AD に関しては、拙稿「Azure AD の動的グループに期限付きでユーザーを入れたい」も合わせてご覧ください。

https://qiita.com/sndyhg/items/1f0eebc73b822d92f5dc

明日のレコチョク Advent Calendar 2022 は4日目、 「【Android】初学者の私が1ヶ月で学んだ内容」 です。

---

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/8608