名簿同期ツール: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 のロール名を入力します。退職者は名簿から削除するだけで、ツールがそのユーザーを無効化します。
事前に管理画面でロールを作成し、MaiGPT の利用権限を含む権限を設定してください。その後、名簿で同じロール名を使用します。存在しないロールはツールが自動作成しますが、作成されたロールには権限がありません。そのロールに割り当てられたメンバーは、管理画面でロール権限を設定するまで何も利用できません。
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. 実行する
接続情報を設定する
管理画面で「組織設定 → ユーザー識別同期」に移動し、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実行内容をプレビューする
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、手動対応 13. ツール本体
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 を設定するか、ログインせずにメンバーのユーザー識別埋め込みのみを使用します
最終更新
役に立ちましたか?