> For the complete documentation index, see [llms.txt](https://docs.maiagent.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.maiagent.ai/tech/ja/identity-and-access/member-provisioning/member-roster-sync.md).

# 名簿同期ツール：CSV／JSON／データベース

IdP がなくてもメンバーを自動プロビジョニングできます。CSV、JSON、データベースからエクスポートした名簿を受け取り、SCIM で組織メンバーを作成・更新・無効化する既製の名簿同期ツールです。スケジュール実行にも対応しています

利用場面：Okta や Entra ID のようにユーザーを自動で送信する IdP がなく、名簿を HR システム、Excel、または自社のデータベースで管理している場合に適しています。このツールは名簿を MaiAgent の組織メンバーに同期します。名簿にいる人は作成または更新し、名簿から消えた人は自動で無効化します。内部では [SCIM プロビジョニング API](/tech/ja/identity-and-access/member-provisioning.md#scim) を使用するため、API の詳細を自分で処理する必要はありません。

1 回の実行で 1 回の同期を行います。毎日実行するようにスケジュールするか、人事異動時に実行してください。

## 一、名簿の準備 <a href="#roster" id="roster"></a>

名簿のフィールドは 4 つだけです。`id` は自社システム内の**変わらない**個人識別番号（社員番号や HR システムの ID）で、ツールはこれを使って人を識別します。`groups` には MaiAgent のロール名を指定します。退職者は**名簿から削除する**だけで、ツールが無効化します。

{% hint style="warning" %}
**先に管理画面でロールを作成し、権限を設定してください**（MaiGPT の利用権限も含みます）。名簿には同じ名前を使用します。存在しないロールはツールが自動で作成しますが、作成されたロールには**権限がありません**。メンバーを作成しても何も利用できないため、管理画面で権限を追加する必要があります。
{% endhint %}

{% tabs %}
{% tab title="CSV" %}
`roster.csv` では、`groups` に複数のロールを指定する場合は `;` で区切ります：

```csv
id,email,name,groups
emp-10023,alice@acme.com,Alice Chen,客服
emp-10024,bob@acme.com,Bob Lin,客服;主管
emp-10031,carol@acme.com,Carol Wu,
```

{% endtab %}

{% tab title="JSON" %}
`roster.json` では、`groups` は配列です：

```json
[
  { "id": "emp-10023", "email": "alice@acme.com", "name": "Alice Chen", "groups": ["客服"] },
  { "id": "emp-10024", "email": "bob@acme.com",   "name": "Bob Lin",    "groups": ["客服", "主管"] },
  { "id": "emp-10031", "email": "carol@acme.com", "name": "Carol Wu",   "groups": [] }
]
```

{% endtab %}

{% tab title="データベース" %}
名簿がデータベースにある場合は、SQL クエリで上記の JSON 形式にエクスポートし、ツールに渡します。データベースの種類は問いません。以下は PostgreSQL の例です（MySQL の場合は `pymysql` に変更し、SQL も同様に調整してください）：

```python
"""export_roster.py：HR データベースから在職者の名簿を roster.json にエクスポートします。"""
import json
import os

import psycopg  # pip install "psycopg[binary]"

SQL = """
SELECT e.employee_no                    AS id,
       e.email                          AS email,
       e.full_name                      AS name,
       COALESCE(array_agg(r.role_name) FILTER (WHERE r.role_name IS NOT NULL), '{}') AS groups
FROM   employees e
LEFT   JOIN employee_roles r ON r.employee_no = e.employee_no
WHERE  e.status = 'active'
GROUP  BY e.employee_no, e.email, e.full_name
"""

with psycopg.connect(os.environ["HR_DATABASE_URL"]) as conn, conn.cursor() as cur:
    cur.execute(SQL)
    rows = [{"id": i, "email": e, "name": n, "groups": list(g)} for i, e, n, g in cur.fetchall()]

with open("roster.json", "w", encoding="utf-8") as f:
    json.dump(rows, f, ensure_ascii=False, indent=2)
print(f"{len(rows)} 人をエクスポートしました")
```

スケジュール実行時は 2 つのステップをつなげます：`python export_roster.py && python maiagent_scim_sync.py roster.json`。在職者（`status = 'active'`）のみをエクスポートすると、退職者はツールによって無効化されます。
{% endtab %}
{% endtabs %}

## 二、実行 <a href="#run" id="run"></a>

{% stepper %}
{% step %}

### 接続情報の設定

管理画面の「組織設定 → ID 同期」で SCIM を選択し、「接続情報」タブから Endpoint URL と Token をコピーして、環境変数に設定します（取得方法と権限については [有効化と Token の取得](/tech/ja/identity-and-access/member-provisioning.md#scim-enable) を参照してください）。Token は組織の人事管理権限に相当するため、このツールを実行するサーバーにのみ保存してください：

```bash
export MAIAGENT_SCIM_URL="https://api.maiagent.ai/api/v1/scim/<組織 ID>"
export MAIAGENT_SCIM_TOKEN="<SCIM Token>"
pip install requests
```

{% endstep %}

{% step %}

### 実行内容の確認

```bash
python maiagent_scim_sync.py roster.csv --dry-run     # または roster.json
```

計画のみを表示し、データは変更しません：

```
[group]      ロール 主管 を作成します（権限はありません。管理画面でロールの権限を設定してください）
[create]     emp-10023 alice@acme.com  groups=客服
[update]     emp-10024 bob@acme.com    groups=客服;主管
[email]      emp-10031 carol.new@acme.com  groups=  ⚠ Email は SCIM で変更できません（MaiAgent では引き続き carol@acme.com です）。管理画面で変更するか、旧アカウントを無効化して新しい Email で作成してください
[deactivate] emp-10009 dave@acme.com   （名簿にいません）
[skip]       owner@acme.com            （このツールで作成されていないため、処理しません）
計画：作成 1、更新 1、無効化 1、変更なし 0、スキップ 1、手動対応が必要 1
```

{% endstep %}

{% step %}

### 同期の実行とスケジュール設定

`--dry-run` を外して実行します。完了後、管理画面の「メンバー管理」に対象者が表示されるはずです。**もう一度実行すると、全員が `[unchanged]` になるはずです**。これが冪等性です。安心して、例えば毎朝実行するようにスケジュールできます：

```
0 7 * * * cd /opt/maiagent-sync && python maiagent_scim_sync.py roster.csv >> sync.log 2>&1
```

{% endstep %}
{% endstepper %}

## 三、ツール本体 <a href="#tool" id="tool"></a>

`maiagent_scim_sync.py` は Python 3.10+ を使用し、依存パッケージは `requests` のみです。名簿の拡張子は `.csv` と `.json` の両方に対応しています。MaiAgent の dev 環境で実行検証済みです（作成、管理画面にある既存ロールと自動作成ロールの割り当て、再実行時の変更なし、名簿からの削除による無効化、Email 変更の警告）。

```python
"""名簿（CSV または JSON）を MaiAgent の組織メンバーに同期します（SCIM）。使用方法：python maiagent_scim_sync.py roster.csv|roster.json [--dry-run]"""
import csv
import json
import os
import sys
import time
import urllib.parse

import requests

SCIM_URL = os.environ["MAIAGENT_SCIM_URL"].rstrip("/")
HEADERS = {
    "Authorization": f"Bearer {os.environ['MAIAGENT_SCIM_TOKEN']}",
    "Content-Type": "application/scim+json",
    "Accept": "application/scim+json",
}
USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"


def scim(method, path, **kwargs):
    """SCIM エンドポイントを呼び出します。1 分あたりのレート制限（429）に達した場合は待機して再試行します。"""
    for attempt in range(5):
        res = requests.request(method, f"{SCIM_URL}{path}", headers=HEADERS, timeout=30, **kwargs)
        if res.status_code != 429:
            return res
        time.sleep(2 ** attempt)
    res.raise_for_status()


def read_roster(path):
    """CSV（groups は ; 区切り）または JSON（groups は配列）を読み、[{id, email, name, groups}] を返します。"""
    if path.lower().endswith(".json"):
        with open(path, encoding="utf-8") as f:
            rows = json.load(f)
    else:
        with open(path, newline="", encoding="utf-8-sig") as f:
            rows = list(csv.DictReader(f))
    roster = []
    for row in rows:
        groups = row.get("groups") or []
        if isinstance(groups, str):
            groups = [g.strip() for g in groups.split(";") if g.strip()]
        roster.append({"id": str(row["id"]).strip(), "email": row["email"].strip(), "name": (row.get("name") or "").strip(), "groups": groups})
    return roster


def list_all_members():
    members, start = [], 1
    while True:
        page = scim("GET", f"/Users?startIndex={start}&count=100").json()
        resources = page.get("Resources", [])
        members.extend(resources)
        if not resources or start + len(resources) > page.get("totalResults", 0):
            return members
        start += len(resources)


def find_by_email(email):
    query = urllib.parse.quote(f'userName eq "{email}"')
    resources = scim("GET", f"/Users?filter={query}").json().get("Resources", [])
    return resources[0] if resources else None


def ensure_groups(names, dry_run):
    """名簿で使用するロールが存在し、SCIM にマッピングされていることを先に確認します：
    - 存在しない → POST /Groups で作成します（権限はありません。管理画面でロールの権限を追加してください）
    - 既存（例えば管理画面で作成し、権限を設定済み）→ 同じ名前で一度 PATCH し、SCIM のマッピングを作成します。その後、User の groups[] を割り当てられます
    """
    for name in sorted(names):
        query = urllib.parse.quote(f'displayName eq "{name}"')
        found = scim("GET", f"/Groups?filter={query}").json().get("Resources")
        if found:
            if not dry_run:
                scim("PATCH", f"/Groups/{found[0]['id']}", json={"schemas": [PATCH_SCHEMA], "Operations": [{"op": "replace", "path": "displayName", "value": name}]}).raise_for_status()
            continue
        print(f"[group]      ロール {name} を作成します（権限はありません。管理画面でロールの権限を設定してください）")
        if not dry_run:
            scim("POST", "/Groups", json={"schemas": [GROUP_SCHEMA], "displayName": name}).raise_for_status()


def to_payload(person):
    return {
        "schemas": [USER_SCHEMA],
        "userName": person["email"],
        "externalId": person["id"],
        "displayName": person["name"],
        "active": True,
        "groups": [{"display": g} for g in person["groups"]],
    }


def same_as(member, person):
    current_groups = sorted(g["display"] for g in member.get("groups", []))
    return (
        member.get("userName", "").lower() == person["email"].lower()
        and member.get("displayName", "") == person["name"]
        and member.get("active") is True
        and current_groups == sorted(person["groups"])
    )


def main(roster_path, dry_run):
    roster = read_roster(roster_path)
    ensure_groups({g for person in roster for g in person["groups"]}, dry_run)
    existing = list_all_members()
    by_external_id = {m["externalId"]: m for m in existing if m.get("externalId")}
    counts = {"create": 0, "update": 0, "deactivate": 0, "unchanged": 0, "skip": 0, "warn": 0}

    for person in roster:
        member = by_external_id.get(person["id"]) or find_by_email(person["email"])
        label = f"{person['id']} {person['email']}  groups={';'.join(person['groups'])}"
        if member is None:
            counts["create"] += 1
            print(f"[create]     {label}")
            if not dry_run:
                scim("POST", "/Users", json=to_payload(person)).raise_for_status()
        elif member.get("userName", "").lower() != person["email"].lower() and member.get("externalId") == person["id"]:
            counts["warn"] += 1
            print(f"[email]      {label}  ⚠ Email は SCIM で変更できません（MaiAgent では引き続き {member['userName']} です）。管理画面で変更するか、旧アカウントを無効化して新しい Email で作成してください")
        elif same_as(member, person) and member.get("externalId") == person["id"]:
            counts["unchanged"] += 1
            print(f"[unchanged]  {label}")
        else:
            counts["update"] += 1
            print(f"[update]     {label}")
            if not dry_run:
                scim("PUT", f"/Users/{member['id']}", json=to_payload(person)).raise_for_status()

    wanted = {person["id"] for person in roster}
    for member in existing:
        if not member.get("externalId"):
            counts["skip"] += 1
            print(f"[skip]       {member.get('userName')}  （このツールで作成されていないため、処理しません）")
        elif member["externalId"] not in wanted and member.get("active"):
            counts["deactivate"] += 1
            print(f"[deactivate] {member['externalId']} {member.get('userName')}  （名簿にいません）")
            if not dry_run:
                scim(
                    "PATCH",
                    f"/Users/{member['id']}",
                    json={"schemas": [PATCH_SCHEMA], "Operations": [{"op": "replace", "path": "active", "value": False}]},
                ).raise_for_status()

    mode = "計画" if dry_run else "完了"
    print(f"{mode}：作成 {counts['create']}、更新 {counts['update']}、無効化 {counts['deactivate']}、変更なし {counts['unchanged']}、スキップ {counts['skip']}、手動対応が必要 {counts['warn']}")


if __name__ == "__main__":
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    if len(args) != 1:
        sys.exit("使用方法：python maiagent_scim_sync.py roster.csv|roster.json [--dry-run]")
    main(args[0], dry_run="--dry-run" in sys.argv)
```

## 四、動作の仕組みと安全性 <a href="#behavior" id="behavior"></a>

* **自分で作成したメンバーのみを処理します**：`externalId`（自社の個人識別番号）をキーにします。管理画面から手動で招待され、`externalId` がないメンバーはすべて `[skip]` になり、誤って無効化されることはありません。同じ Email のメンバーがすでにいる場合（例えば以前に招待で参加した場合）は、`externalId` を追加して管理を引き継ぎ、重複を作成しません。
* **無効化は削除ではありません**：退職者の会話とクレジットの記録は保持されますが、利用できなくなり、API Key は取り消されます。名簿に戻すと再び有効になります。
* **ロールの存在とマッピングを先に確認します**：管理画面で作成済みのロールには、ツールが同じ名前で一度 `PATCH` を行い、SCIM のマッピングを作成してから割り当てます。存在しないロールだけを `POST /Groups` で作成します（権限はありません）。メンバーは名簿で指定されたロールにのみ所属し、デフォルトのロールには所属しません。MaiGPT を利用するには、ロールに [MaiGPT の利用権限](https://docs.maiagent.ai/org/roles/maigpt-role-permissions) が必要です。
* **Email は SCIM で変更できません**：名簿内で Email が変わると、ツールは `[email]` の警告を表示して対象者をスキップします。管理画面で変更するか、旧アカウントを名簿から削除して無効化し、新しい Email の新規メンバーとして追加してください。
* **Email がログインアカウントです**：SCIM で作成したアカウントにはパスワードがありません。従業員が MaiAgent の Web サイトにログインする場合は、別途 SSO を設定する必要があります。[メンバー ID の埋め込み](/tech/ja/work/maigpt-system-integration-guide/maigpt-integration.md#member-embed) のみを使用する場合は、ログイン不要です。
* **Python 以外でも実装できます**：各操作は数件の HTTP 呼び出し（`GET /Users?filter=`、`POST /Users`、`PUT`／`PATCH /Users/{id}`、`GET`／`POST /Groups`）だけです。このツールと同じ順序で、任意の言語で実装できます。API の詳細は [SCIM プロビジョニング API](/tech/ja/identity-and-access/member-provisioning.md#scim) を参照してください。

## 五、よくあるエラー <a href="#errors" id="errors"></a>

| レスポンス                                  | 原因                                                                                  | 対処                                                                                                |
| -------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `401`                                  | Token が誤っている、再生成されている、または組織 ID と Token が一致していません                                    | 管理画面の「接続情報」で、Endpoint URL と Token が同じ組織のものであることを確認してください                                          |
| `403`／ページが見つかりません                      | 組織のプランに ID 同期が含まれていません                                                              | 営業担当または連携窓口にお問い合わせください                                                                            |
| `400` `userName or emails is required` | 名簿に Email がありません                                                                    | `email` フィールドを追加してください                                                                            |
| `409` `already exists`                 | 同じ Email がすでにこの組織のメンバーに使われています                                                      | ツールは `PUT` で管理を引き継ぐ処理に対応済みです。自分で API を呼ぶ場合は、`filter=userName eq "…"` で検索し、`PUT`／`PATCH` を使用してください |
| `404` on `/Users/{id}`                 | `id` が MaiAgent のメンバー ID ではありません（`externalId` の誤用がよくあります）                           | 先に `filter=externalId eq "…"` でメンバー ID を取得してください                                                  |
| `429`                                  | 組織ごとに 1 分あたり 100 回の制限があります                                                          | ツールは自動でバックオフします。自分で呼ぶ場合は、バッチ処理を毎秒 1 回程度に制限してください                                                  |
| `415`                                  | `Content-Type` が `application/scim+json` ではありません（`application/json` も受け付けます）        | ヘッダーを変更してください                                                                                     |
| ロールが割り当てられません                          | ロールが存在しないか、管理画面で作成したロールが SCIM にマッピングされていません（User の `groups[]` はマッピング済みのロールのみを参照します） | 先に `POST /Groups` を行うか、既存ロールを同じ `displayName` で `PATCH` してください。ツールにはこの処理が組み込まれています                |
| メンバーは作成されましたが、何も利用できません                | ロールに所属していないか、ロールに権限がありません                                                           | 管理画面でロールの権限を設定してください（MaiGPT の利用権限も含みます）。同期前にロールを作成することを推奨します                                      |
| Email を変更しても反映されません                    | SCIM は `userName` の変更を許可していません                                                      | 管理画面で変更するか、旧アカウントを無効化して新しい Email で作成してください                                                        |
| メンバーは作成されましたが、ログインできません                | SCIM で作成したアカウントにはパスワードがありません                                                        | SSO を設定するか、ログインせずにメンバー ID の埋め込みのみを使用してください                                                        |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.maiagent.ai/tech/ja/identity-and-access/member-provisioning/member-roster-sync.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
