For the complete documentation index, see llms.txt. This page is also available as Markdown.

名簿同期ツール:CSV/JSON/データベース

IdP がなくてもメンバーを自動プロビジョニングできます。CSV、JSON、またはデータベースからエクスポートした名簿を読み込み、SCIM を使用して組織メンバーを作成、更新、無効化し、スケジュール実行できる既成の名簿同期ツールです

このツールは、Okta や Entra ID のようにユーザー情報を自動送信する IdP がなく、人員名簿を HR システム、Excel、または自社データベースで管理している場合に使用します。名簿を MaiAgent の組織メンバーと同期し、名簿に含まれるメンバーを作成または更新し、名簿から削除されたメンバーを自動的に無効化します。内部では SCIM プロビジョニング API を使用するため、API の詳細を自分で処理する必要はありません。

1 回実行すると 1 回同期されます。毎日のスケジュール実行、または人事異動時の実行を設定してください。

1. 名簿を準備する

名簿には 4 つのフィールドだけがあります。id は、自社システム内で変更されない人員番号(従業員番号、HR システム ID など)であり、ツールはこの値で各ユーザーを識別します。groups には MaiAgent のロール名を入力します。退職者は名簿から削除するだけで、ツールがそのユーザーを無効化します。

roster.csv では、groups の複数のロールを ; で区切ります。

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,

roster.json では、groups は配列です。

[
  { "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": [] }
]

名簿がデータベースにある場合は、1 つの SQL 文で上記の JSON としてエクスポートし、ツールに渡します。データベースの種類は問いません。次の例では PostgreSQL を使用します。MySQL の場合は pymysql に置き換えてください。SQL の考え方は同じです。

"""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)} 人をエクスポートしました")

スケジュール実行では、python export_roster.py && python maiagent_scim_sync.py roster.json のように 2 つのステップを連結します。在籍者(status = 'active')だけをエクスポートすると、退職者はツールによって無効化されます。

2. 実行する

1

接続情報を設定する

管理画面で「組織設定 → ユーザー識別同期」に移動し、SCIM を選択します。「接続情報」タブから Endpoint URL と Token をコピーしてください。取得方法と権限については、SCIM の有効化と Token の取得を参照してください。これらを環境変数に設定します。Token は組織の人事管理権限に相当するため、このツールを実行するサーバーにのみ保存してください。

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

実行内容をプレビューする

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
3

実際に実行し、スケジュールを設定する

--dry-run を外して実行します。実行後、管理画面の「メンバー管理」にこれらのユーザーが表示されます。**もう一度実行すると、すべて [unchanged] になるはずです。**これは冪等性を示しているため、たとえば毎朝のように安全にスケジュール実行できます。

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

3. ツール本体

maiagent_scim_sync.py は Python 3.10 以降で動作し、依存パッケージは requests のみです。名簿の拡張子は .csv と .json のどちらにも対応します。MaiAgent の dev 環境で、作成、管理画面の既存ロールの割り当て、ロールの自動作成、再実行時の変更なし、名簿から削除したユーザーの無効化、Email 変更の警告を実際に検証済みです。

"""名簿(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)

4. 仕組みと安全性

  • ツールが作成したユーザーだけを変更します:externalId(自社の人員番号)をキーとして使用します。管理画面から手動で招待され、externalId がないメンバーは常に [skip] となり、誤って無効化されることはありません。同じ Email のメンバーがすでに存在する場合(以前に招待で追加された場合など)は、重複して作成せず、externalId を追加して管理対象にします。

  • 無効化は削除ではありません:退職者の会話と Credits の記録は保持されますが、システムを利用できなくなり、API Key は失効します。名簿に再追加すると再び有効になります。

  • ロールの存在とマッピングを先に確認します:管理画面で作成済みのロールには、同じ名前で一度 PATCH を送信して SCIM マッピングを作成してから割り当てます。存在しないロールのみ POST /Groups で作成されます(権限はありません)。メンバーは名簿で指定したロールのみに割り当てられ、デフォルトロールには割り当てられません。MaiGPT を使用するには、ロールに MaiGPT の利用権限が必要です。

  • Email は SCIM では変更できません:名簿内の Email が変更されると、ツールは [email] 警告を出力して処理をスキップします。管理画面で変更するか、古いユーザーを名簿から削除して無効化し、新しい Email で新規ユーザーとして追加してください。

  • Email がログインアカウントです:SCIM で作成されたアカウントにはパスワードがありません。従業員が MaiAgent の Web サイトにログインするには、別途 SSO を設定する必要があります。メンバーのユーザー識別埋め込みのみを使用する場合は、ログインは不要です。

  • Python 以外でも利用できます:各操作は、いくつかの HTTP 呼び出し(GET /Users?filter=、POST /Users、PUT/PATCH /Users/{id}、GET/POST /Groups)だけです。この順序に従えば、どの言語でも実装できます。API の詳細は、SCIM プロビジョニング APIを参照してください。

5. よくあるエラー

レスポンス
原因
対処方法

401

Token が間違っている、再生成されている、または組織 ID と Token が一致していません

管理画面の「接続情報」で、Endpoint URL と Token が同じ組織のものであることを確認します

403/ページが見つからない

組織のプランにユーザー識別同期が含まれていません

営業担当者または連携窓口にお問い合わせください

400 userName or emails is required

名簿に Email がありません

email フィールドを追加します

409 already exists

同じ Email のメンバーがすでに組織に存在します

ツールは PUT で管理対象にして処理します。自分で API を呼び出す場合は、filter=userName eq "…" でメンバーを検索してから PUT または PATCH を使用します

/Users/{id} で 404

id が MaiAgent のメンバー ID ではありません(externalId を誤って使用するケースが一般的です)

先に filter=externalId eq "…" を使用してメンバー ID を取得します

429

1 組織あたり 1 分間に 100 回の制限に達しました

ツールは自動的にバックオフします。自分で呼び出す場合は、1 秒あたり約 1 回に制限してください

415

Content-Type が application/scim+json ではありません(application/json も使用できます)

ヘッダーを変更します

ロールが割り当てられない

ロールが存在しないか、管理画面で作成したロールがまだ SCIM にマッピングされていません(User の groups[] はマッピング済みロールのみに対応します)

先に POST /Groups を実行するか、既存ロールに同じ displayName で PATCH を実行します。ツールにはこの処理が組み込まれています

メンバーは作成されたが何も利用できない

ロールに所属していないか、ロールに権限がありません

MaiGPT の利用権限を含むロール権限を管理画面で設定します。同期前にロールを作成することを推奨します

Email を変更しても反映されない

SCIM では userName を変更できません

管理画面で変更するか、古いアカウントを無効化して新しい Email で作成します

メンバーを作成したがログインできない

SCIM で作成されたアカウントにはパスワードがありません

SSO を設定するか、ログインせずにメンバーのユーザー識別埋め込みのみを使用します

最終更新

役に立ちましたか?