GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。 NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは 別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
78 KiB
組織・社員マスタ基盤構築(Phase 2: ④sync-keycloak) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: マスタテーブル(プリザンターSiteId=1)をKeycloak(nexthdレルム)へ完全同期する。マスタの全項目(プリザンターのSiteSettings.Columnsから動的取得、コード変更なしに項目追加・削除の両方に自動追従)をKeycloakのカスタム属性として同期し、突合キーはプリザンター固有のレコードID(ResultId)を使う。新規作成はClassZ(LINEWORKS有無)="有"のレコードのみ、既存ユーザーの在籍状態(有効/無効)はCheck062に従って常に更新、マスタからレコード自体が消えた場合のみKeycloakユーザーを完全削除する。
Architecture: apps/org-master-sync/に④コマンドを追加。src/lib/keycloakClient.js(Keycloak Admin REST API、Client Credentials認証、User Profile操作・ユーザー削除含む)を新規作成し、マスタ読み取り・カラム定義取得・グループ名解決には既存のpleasanterClient.js(getSiteItems・getSite・getAllGroups)をそのまま流用する。同期対象の属性はコード側の固定リストではなく、プリザンターのSiteSettings.Columns(getSiteで取得)を実行のたびに動的に読む。前回の同期で使った「カラム名一覧」と「同期したマスタResultId一覧」の両方をapps/org-master-sync/configs/keycloak-sync/へ保存しておき、今回との差分から属性の追加/削除・ユーザーの削除を検出する。
Tech Stack: Node.js(CommonJS、node --test、他コマンドと同一パターン)。依存パッケージなし。
Global Constraints
- 外部npmパッケージを追加しない
apps/org-master-sync/は他システムから独立して切り出せる構成を維持する.envは既に設定済み(KEYCLOAK_BASE_URL,KEYCLOAK_REALM,KEYCLOAK_ADMIN_CLIENT_ID,KEYCLOAK_ADMIN_CLIENT_SECRET)。認証情報は追加不要org-master-syncのKeycloakサービスアカウントにはrealm-managementクライアントの全ロール(realm-admin含む)が2026-08-09にユーザー側で付与済み。PUT /admin/realms/{realm}/users/profileが200 OKで成功することを実機確認済み- 破壊的・書き込み系操作は既定ドライラン(対象件数・追加/削除予定の属性一覧・作成/更新/削除予定の一覧表示のみ)とし、
--executeフラグを明示指定した場合のみ実送信する - テストは
node:test+node:assert。ネットワークを叩く関数はfetchImpl引数でDIし、テストではモックする - Keycloak Admin APIの
PUT /users/{id}・PUT /users/profileはいずれも完全上書き。Upsert・属性同期時は必ず既存の全フィールド/既存の属性定義配列を読み取ってからマージ(追加・削除)して送ること(設計文書9章「ハマった点8」の事故を再発させない) - 同期対象(
planSync)はマスタの全レコード(ClassZによるフィルタはしない。理由: マスタからレコード自体が消えたことを検出する削除ロジックのため、全件を走査する必要がある。2026-08-09、ユーザー確定で方針変更) - 同期する属性はマスタのプリザンター
SiteSettings.Columnsに定義されている全カラム(2026-08-09時点で56件)。Class034(パスワード)も除外せず同期対象に含める(2026-08-09、ユーザー確定) - 突合キーはプリザンターの
ResultId(Keycloak側にカスタム属性pleasanterResultIdとして保存し、これで検索する。2026-08-09、ユーザー確定・design doc8章のlineworksUserId突合方針を上書き) - 新規作成・更新・削除の判定ルール(2026-08-09確定):
- マスタレコードに対応するKeycloakユーザーが見つからない場合: そのマスタレコードの
ClassZが"有"の時のみ新規作成する。ClassZが"無"(一度もLINEWORKSに存在したことが無いプリザンター専用ユーザー)は新規作成しない(SSO対象外のため) - Keycloakユーザーが見つかった場合: 常に更新する。
enabledはマスタのCheck062(LINEWORKS在籍フラグ。退職時に確実に更新される。ClassZは退職検知時の更新漏れが過去にあったため、判定には使わない)に従う。マスタがClassZ="無"になっていても、既存のKeycloakユーザーは削除せず無効化のみ行う(2026-08-09、ユーザー確定) - マスタから
ResultId自体が消えた場合(レコードが物理削除された場合)のみ、対応するKeycloakユーザーを完全削除する(DELETE /admin/realms/{realm}/users/{id}。design doc7章の「退職者は無効化のみ、削除しない」方針とは別物=レコード消滅時のみの特別対応。判定は前回同期したResultId一覧との差分で行う)
- マスタレコードに対応するKeycloakユーザーが見つからない場合: そのマスタレコードの
- 完全同期(カラムの追加・削除、2026-08-09確定): プリザンター側でカラムが削除された場合、Keycloak側の対応も追従する。
- 判定方法: 前回このツールが同期に使ったカラム名一覧を
apps/org-master-sync/configs/keycloak-sync/last-synced-columns.jsonへ保存しておき、今回のカラム名一覧との差分から削除対象を特定する(他システムが独自に追加した属性を誤って巻き込まないため。configs/は既存のsiteConfigStore.jsと同じくgit管理対象外) - 保護対象(差分検出の対象外・常に保持): Keycloak標準プロファイル属性
username/email/firstName/lastName、および突合キーpleasanterResultId(Columns由来ではなく別枠で常時保証するため) - 既存の
fullName/pleasanterLoginId/lineworksUserId(design doc8章の旧突合方針由来の3属性)は削除する(2026-08-09、ユーザー確定。初回実行時に明示的に削除リストへ含める)
- 判定方法: 前回このツールが同期に使ったカラム名一覧を
- 【重要・実機確認済み】Keycloakのユーザー属性検索(
GET /users?q=key:value)は、その属性名がUser Profileに事前定義されていないとヒットしない(値自体は保存されるが検索インデックスに乗らない。実機で「未定義のままcreate→q検索0件」「User Profileに定義追加後→同じ値でq検索1件」を確認済み)。突合キーpleasanterResultIdは、Columns由来の属性群とは別に常にUser Profileへ保証登録する(Task 2)。applySyncは必ず「①pleasanterResultIdのUser Profile登録保証 → ②Columnsの属性完全同期 → ③ユーザーのUpsert/削除(pleasanterResultId検索を含む)」の順で処理する - 実行トリガーは指示駆動(cron等の定期実行は導入しない)
- 【運用上の重要な注意】: LINEWORKS退職者はLINEWORKS API結果から物理的に消えるため、②
merge-master.jsの退職検知は①②を都度実行しないと最新化されない。④実行前には必ず①②を先に実行すること(Task 5のSKILL.mdに明記)
事前に確認済みの実データ(このプランの前提)
- マスタの
SiteSettings.Columns(2026-08-09時点、56件):ColumnName(物理キー、例:ClassA)とLabelText(日本語ラベル、例: "社員ID")のペアの配列。ユーザーが管理画面で追加・削除した項目は全てここに反映される。この配列をそのままKeycloak同期対象の属性リストとして使う(ColumnName=Keycloak属性名、LabelText=Keycloak側のdisplayName) - ②
merge-master.jsの退職検知バグ修正済み(2026-08-09): 退職者検知(planMergeのretirements)は当初Check062のみをfalseに更新しており、ClassZは"有"のまま取り残されていた。Check062と同時にClassZ: "無"も更新するよう修正済み(テスト含む)。この修正により、マスタのCheck062とClassZは退職時も含めて常に一致する - Keycloak Admin REST API、実機確認済み(2026-08-09):
- トークン取得:
POST {KEYCLOAK_BASE_URL}/realms/{realm}/protocol/openid-connect/token(grant_type=client_credentials) - User Profile取得:
GET /admin/realms/{realm}/users/profile→ 200、{attributes: [{name, displayName, permissions, multivalued, ...}, ...]}(2026-08-09時点で7件:username,email,firstName,lastName,fullName,pleasanterLoginId,lineworksUserId) - User Profile更新:
PUT /admin/realms/{realm}/users/profile(body: 完全なprofileオブジェクト。部分更新不可、permissions等の追加プロパティを持たせないと400エラーになるプロパティもある=searchableは無効プロパティで400。属性定義は{name, displayName, permissions: {view, edit}, multivalued}の形で十分)→ 200 - ユーザー属性検索は事前にUser Profile定義が必要(上記参照)
- ユーザー属性検索:
GET /admin/realms/{realm}/users?q={encodeURIComponent("attrName:attrValue")}→ 200、配列(属性がUser Profile未定義だと常に空配列を返す。エラーにはならない点に注意) - ユーザーemail検索:
GET /admin/realms/{realm}/users?email={email}&exact=true→ 200、配列 - ユーザー作成:
POST /admin/realms/{realm}/users(body:{username, email, enabled, attributes: {key: [value]}})→ 201、Locationヘッダーに.../users/{id} - ユーザー更新:
PUT /admin/realms/{realm}/users/{id}(body: 完全なユーザーオブジェクト)→ 204 - ユーザー削除:
DELETE /admin/realms/{realm}/users/{id}→ 204(実機確認済み) - 一覧取得(
GET /users?max=1000、briefRepresentation=false含め試行)はattributesを含まない(実機確認済み、Keycloakの仕様)。「特定の属性を持つ全ユーザー」を一括取得する標準APIは無いため、削除対象の検出は「前回同期時に保存しておいたResultId一覧」との差分で行う(全ユーザー走査はしない) - グループ一覧:
GET /admin/realms/{realm}/groups?max=1000→ 200、配列({id, name, ...}) - グループ作成:
POST /admin/realms/{realm}/groups(body:{name})→ 201、Locationヘッダーに.../groups/{id} - ユーザーをグループに追加:
PUT /admin/realms/{realm}/users/{userId}/groups/{groupId}(ボディ無し)→ 204 - 全て、テスト用データを作成→検証→削除するテストで実機動作確認済み
- トークン取得:
- プリザンター側の既存関数(
apps/org-master-sync/src/lib/pleasanterClient.js、変更不要):getSite({baseUrl, apiKey, siteId, fetchImpl})→SiteSettings.Columnsを含むサイト設定オブジェクトgetSiteItems({baseUrl, apiKey, siteId, fetchImpl})→ フラット化済み物理キーオブジェクト配列(ResultId・ClassA・Check062・ClassZ等を直接参照可能)getAllGroups({baseUrl, apiKey, fetchImpl})→{GroupId, GroupName}を含むプリザンターグループ配列(マスタのClass035のGroupId逆引きに使う)
- 既存の
apps/org-master-sync/src/lib/siteConfigStore.js(変更不要、参考パターン):saveSiteSnapshot(siteId, data, {now, baseDir})がconfigs/site-{siteId}_{title}/sitesettings/へJSON保存する既存実装。今回の「前回同期カラム一覧」「前回同期ResultId一覧」も同じconfigs/ディレクトリ配下(別サブパスkeycloak-sync/)に置く
Task 1: Keycloak Admin REST APIクライアント(keycloakClient.js)
Files:
- Create:
apps/org-master-sync/src/lib/keycloakClient.js - Test:
apps/org-master-sync/test/keycloakClient.test.js
Interfaces:
-
Produces:
module.exports = { fetchAccessToken, getUserProfile, updateUserProfile, findUserByAttribute, findUserByEmail, createUser, updateUser, deleteUser, getAllGroups, createGroup, addUserToGroup }。Task 2・Task 3・Task 4が消費する -
Step 1: 失敗するテストを書く(
fetchAccessToken)
const { test } = require("node:test");
const assert = require("node:assert");
const { fetchAccessToken } = require("../src/lib/keycloakClient");
test("fetchAccessToken posts client_credentials grant and returns the access_token", async () => {
let capturedUrl, capturedBody;
const fetchImpl = async (url, opts) => {
capturedUrl = url;
capturedBody = opts.body;
return { ok: true, json: async () => ({ access_token: "fake-token" }) };
};
const token = await fetchAccessToken({
baseUrl: "https://kc.example.test", realm: "nexthd", clientId: "cid", clientSecret: "secret", fetchImpl,
});
assert.strictEqual(token, "fake-token");
assert.strictEqual(capturedUrl, "https://kc.example.test/realms/nexthd/protocol/openid-connect/token");
assert.ok(capturedBody.includes("grant_type=client_credentials"));
assert.ok(capturedBody.includes("client_id=cid"));
});
- Step 2: テスト実行して失敗確認
Run: cd apps/org-master-sync && node --test test/keycloakClient.test.js
Expected: FAIL(fetchAccessToken is not a function)
- Step 3:
keycloakClient.jsを実装
"use strict";
async function fetchAccessToken({ baseUrl, realm, clientId, clientSecret, fetchImpl = fetch }) {
const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/realms/${realm}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret }).toString(),
});
const data = await res.json();
if (!res.ok) {
throw new Error(`Keycloak token request failed: ${res.status} ${JSON.stringify(data)}`);
}
return data.access_token;
}
function adminUrl(baseUrl, realm, path) {
return `${baseUrl.replace(/\/$/, "")}/admin/realms/${realm}${path}`;
}
async function callApi({ baseUrl, realm, accessToken, method = "GET", path, body, fetchImpl = fetch }) {
const res = await fetchImpl(adminUrl(baseUrl, realm, path), {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${path} failed: ${res.status} ${text}`);
}
return res;
}
// Realmのユーザー属性定義一覧を取得する
async function getUserProfile({ baseUrl, realm, accessToken, fetchImpl = fetch }) {
const res = await callApi({ baseUrl, realm, accessToken, path: "/users/profile", fetchImpl });
return res.json();
}
// 【重要】PUT /users/profile は完全上書き。呼び出し側は必ずgetUserProfileで取得した
// 既存profileオブジェクトのattributes配列をベースに追記・削除したものを渡すこと(Task 2参照)
async function updateUserProfile({ baseUrl, realm, accessToken, profile, fetchImpl = fetch }) {
await callApi({ baseUrl, realm, accessToken, method: "PUT", path: "/users/profile", body: profile, fetchImpl });
}
// カスタム属性でユーザーを検索する(q=key:value形式。対象属性がUser Profileに定義済みである必要がある)。0件ならnull
async function findUserByAttribute({ baseUrl, realm, accessToken, attrName, attrValue, fetchImpl = fetch }) {
const res = await callApi({ baseUrl, realm, accessToken, path: `/users?q=${encodeURIComponent(`${attrName}:${attrValue}`)}`, fetchImpl });
const users = await res.json();
return users[0] || null;
}
// メールアドレス完全一致で検索する(初回突合用)。0件ならnull
async function findUserByEmail({ baseUrl, realm, accessToken, email, fetchImpl = fetch }) {
const res = await callApi({ baseUrl, realm, accessToken, path: `/users?email=${encodeURIComponent(email)}&exact=true`, fetchImpl });
const users = await res.json();
return users[0] || null;
}
// レスポンスのLocationヘッダーから新規作成されたリソースのIDを取り出す
function idFromLocation(res) {
const location = res.headers.get("location") || res.headers.get("Location");
return location ? location.split("/").pop() : null;
}
async function createUser({ baseUrl, realm, accessToken, user, fetchImpl = fetch }) {
const res = await callApi({ baseUrl, realm, accessToken, method: "POST", path: "/users", body: user, fetchImpl });
return idFromLocation(res);
}
// 【重要】PUT /users/{id}は完全上書き。呼び出し側は必ず既存の全フィールドを読み取ってから
// マージしたuserオブジェクトを渡すこと(設計文書9章「ハマった点8」の事故を再発させない。Task 3参照)
async function updateUser({ baseUrl, realm, accessToken, userId, user, fetchImpl = fetch }) {
await callApi({ baseUrl, realm, accessToken, method: "PUT", path: `/users/${userId}`, body: user, fetchImpl });
}
// マスタからレコード自体が消えた場合に呼ぶ(完全削除、無効化ではない。2026-08-09確定)
async function deleteUser({ baseUrl, realm, accessToken, userId, fetchImpl = fetch }) {
await callApi({ baseUrl, realm, accessToken, method: "DELETE", path: `/users/${userId}`, fetchImpl });
}
async function getAllGroups({ baseUrl, realm, accessToken, fetchImpl = fetch }) {
const res = await callApi({ baseUrl, realm, accessToken, path: "/groups?max=1000", fetchImpl });
return res.json();
}
async function createGroup({ baseUrl, realm, accessToken, name, fetchImpl = fetch }) {
const res = await callApi({ baseUrl, realm, accessToken, method: "POST", path: "/groups", body: { name }, fetchImpl });
return idFromLocation(res);
}
async function addUserToGroup({ baseUrl, realm, accessToken, userId, groupId, fetchImpl = fetch }) {
await callApi({ baseUrl, realm, accessToken, method: "PUT", path: `/users/${userId}/groups/${groupId}`, fetchImpl });
}
module.exports = {
fetchAccessToken, getUserProfile, updateUserProfile, findUserByAttribute, findUserByEmail,
createUser, updateUser, deleteUser, getAllGroups, createGroup, addUserToGroup,
};
- Step 4: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/keycloakClient.test.js
Expected: PASS(1 test)
- Step 5: 残りの関数のテストを追加
test("getUserProfile returns the attributes array from /users/profile", async () => {
const { getUserProfile } = require("../src/lib/keycloakClient");
const fetchImpl = async () => ({ ok: true, json: async () => ({ attributes: [{ name: "email" }] }) });
const profile = await getUserProfile({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl });
assert.strictEqual(profile.attributes[0].name, "email");
});
test("updateUserProfile PUTs the full profile object to /users/profile", async () => {
const { updateUserProfile } = require("../src/lib/keycloakClient");
let capturedUrl, capturedMethod, capturedBody;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedMethod = opts.method; capturedBody = JSON.parse(opts.body); return { ok: true }; };
await updateUserProfile({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", profile: { attributes: [{ name: "x" }] }, fetchImpl });
assert.strictEqual(capturedMethod, "PUT");
assert.ok(capturedUrl.endsWith("/users/profile"));
assert.deepStrictEqual(capturedBody, { attributes: [{ name: "x" }] });
});
test("findUserByAttribute queries with q=attrName:attrValue and returns the first match or null", async () => {
const { findUserByAttribute } = require("../src/lib/keycloakClient");
let capturedUrl;
const fetchImpl = async (url) => {
capturedUrl = url;
return { ok: true, json: async () => (url.includes("hit") ? [{ id: "u1" }] : []) };
};
const found = await findUserByAttribute({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", attrName: "pleasanterResultId", attrValue: "hit-123", fetchImpl });
assert.strictEqual(found.id, "u1");
assert.ok(capturedUrl.includes("q=pleasanterResultId%3Ahit-123"));
const notFound = await findUserByAttribute({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", attrName: "pleasanterResultId", attrValue: "miss-456", fetchImpl });
assert.strictEqual(notFound, null);
});
test("findUserByEmail queries with email + exact=true and returns the first match or null", async () => {
const { findUserByEmail } = require("../src/lib/keycloakClient");
let capturedUrl;
const fetchImpl = async (url) => { capturedUrl = url; return { ok: true, json: async () => [{ id: "u2" }] }; };
const found = await findUserByEmail({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", email: "taro@example.com", fetchImpl });
assert.strictEqual(found.id, "u2");
assert.ok(capturedUrl.includes("email=taro%40example.com&exact=true"));
});
test("createUser posts to /users and returns the new id from the Location header", async () => {
const { createUser } = require("../src/lib/keycloakClient");
const fetchImpl = async () => ({
ok: true,
headers: { get: (name) => (name.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/users/new-id-123" : null) },
});
const id = await createUser({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", user: { username: "u1" }, fetchImpl });
assert.strictEqual(id, "new-id-123");
});
test("updateUser puts the full user object to /users/{id}", async () => {
const { updateUser } = require("../src/lib/keycloakClient");
let capturedUrl, capturedMethod, capturedBody;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedMethod = opts.method; capturedBody = JSON.parse(opts.body); return { ok: true }; };
await updateUser({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", userId: "u1", user: { username: "u1", email: "a@b.com", enabled: true }, fetchImpl });
assert.strictEqual(capturedMethod, "PUT");
assert.ok(capturedUrl.endsWith("/users/u1"));
assert.deepStrictEqual(capturedBody, { username: "u1", email: "a@b.com", enabled: true });
});
test("deleteUser sends DELETE to /users/{id}", async () => {
const { deleteUser } = require("../src/lib/keycloakClient");
let capturedUrl, capturedMethod;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedMethod = opts.method; return { ok: true }; };
await deleteUser({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", userId: "u1", fetchImpl });
assert.strictEqual(capturedMethod, "DELETE");
assert.ok(capturedUrl.endsWith("/users/u1"));
});
test("getAllGroups returns the group array from /groups", async () => {
const { getAllGroups } = require("../src/lib/keycloakClient");
const fetchImpl = async () => ({ ok: true, json: async () => [{ id: "g1", name: "組織:情報システム部" }] });
const groups = await getAllGroups({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl });
assert.strictEqual(groups[0].name, "組織:情報システム部");
});
test("createGroup posts to /groups and returns the new id from the Location header", async () => {
const { createGroup } = require("../src/lib/keycloakClient");
const fetchImpl = async () => ({
ok: true,
headers: { get: (name) => (name.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/groups/g-new" : null) },
});
const id = await createGroup({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", name: "役職:課長", fetchImpl });
assert.strictEqual(id, "g-new");
});
test("addUserToGroup PUTs to /users/{userId}/groups/{groupId} with no body", async () => {
const { addUserToGroup } = require("../src/lib/keycloakClient");
let capturedUrl, capturedMethod;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedMethod = opts.method; return { ok: true }; };
await addUserToGroup({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", userId: "u1", groupId: "g1", fetchImpl });
assert.strictEqual(capturedMethod, "PUT");
assert.ok(capturedUrl.endsWith("/users/u1/groups/g1"));
});
test("callApi throws with status and body text when the response is not ok", async () => {
const { getAllGroups } = require("../src/lib/keycloakClient");
const fetchImpl = async () => ({ ok: false, status: 403, text: async () => "Forbidden" });
await assert.rejects(() => getAllGroups({ baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl }), /403/);
});
- Step 6: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/keycloakClient.test.js
Expected: PASS(12 tests)
- Step 7: 実APIに対して疎通確認(トークン取得・User Profile取得のみ、副作用なし)
Run:
cd apps/org-master-sync && export $(cat .env | grep -v '^#' | xargs) && node -e '
const { fetchAccessToken, getUserProfile } = require("./src/lib/keycloakClient");
(async () => {
const accessToken = await fetchAccessToken({ baseUrl: process.env.KEYCLOAK_BASE_URL, realm: process.env.KEYCLOAK_REALM, clientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID, clientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET });
const profile = await getUserProfile({ baseUrl: process.env.KEYCLOAK_BASE_URL, realm: process.env.KEYCLOAK_REALM, accessToken });
console.log("既存属性数:", profile.attributes.length);
})().catch(e => { console.error(e); process.exit(1); });
'
Expected: 既存属性数: 7程度
- Step 8: コミット
git add apps/org-master-sync/src/lib/keycloakClient.js apps/org-master-sync/test/keycloakClient.test.js
git commit -m "feat(org-master-sync): Keycloak Admin REST APIクライアント(User Profile操作・ユーザー削除含む)を実装"
Task 2: 前回同期状態の保存/読込・属性定義の完全同期ロジック
Files:
- Create:
apps/org-master-sync/src/lib/keycloakColumnSync.js - Test:
apps/org-master-sync/test/keycloakColumnSync.test.js
Interfaces:
-
Produces:
module.exports = { RESULT_ID_ATTR, PROTECTED_ATTRS, LEGACY_ATTRS_TO_REMOVE_ON_FIRST_RUN, LAST_SYNCED_COLUMNS_PATH, LAST_SYNCED_RESULT_IDS_PATH, loadLastSyncedColumnNames, saveLastSyncedColumnNames, loadLastSyncedResultIds, saveLastSyncedResultIds, buildAttributeDefs, planProfileAttributeSync, applyProfileAttributePlan, planResultIdDeletions }。Task 3・Task 4が消費する -
Step 1: 失敗するテストを書く(
buildAttributeDefs)
const { test } = require("node:test");
const assert = require("node:assert");
const { buildAttributeDefs } = require("../src/lib/keycloakColumnSync");
test("buildAttributeDefs maps Pleasanter Columns (ColumnName/LabelText) onto Keycloak attribute defs (name/displayName)", () => {
const columns = [
{ ColumnName: "ClassA", LabelText: "社員ID" },
{ ColumnName: "Class001", LabelText: "姓" },
];
assert.deepStrictEqual(buildAttributeDefs(columns), [
{ name: "ClassA", displayName: "社員ID" },
{ name: "Class001", displayName: "姓" },
]);
});
- Step 2: テスト実行して失敗確認
Run: cd apps/org-master-sync && node --test test/keycloakColumnSync.test.js
Expected: FAIL(buildAttributeDefs is not a function)
- Step 3:
keycloakColumnSync.jsを実装
"use strict";
const fs = require("fs");
const path = require("path");
// 突合キー: プリザンターのResultId(マスタレコード自体の一意キー)。Columns由来の属性群とは別に、
// applySyncが常にUser Profileへ保証登録する(2026-08-09確定)
const RESULT_ID_ATTR = "pleasanterResultId";
// Keycloak標準プロファイル属性・突合キー自身。差分検出(追加/削除判定)の対象外とし、常に保持する
const PROTECTED_ATTRS = new Set(["username", "email", "firstName", "lastName", RESULT_ID_ATTR]);
// design doc8章の旧突合方針由来の3属性。初回実行時に明示的に削除する(2026-08-09確定)
const LEGACY_ATTRS_TO_REMOVE_ON_FIRST_RUN = ["fullName", "pleasanterLoginId", "lineworksUserId"];
const KEYCLOAK_SYNC_DIR = path.join(__dirname, "..", "..", "configs", "keycloak-sync");
const LAST_SYNCED_COLUMNS_PATH = path.join(KEYCLOAK_SYNC_DIR, "last-synced-columns.json");
const LAST_SYNCED_RESULT_IDS_PATH = path.join(KEYCLOAK_SYNC_DIR, "last-synced-result-ids.json");
function loadJsonOrNull(filePath) {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function saveJson(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
function loadLastSyncedColumnNames(filePath = LAST_SYNCED_COLUMNS_PATH) {
return loadJsonOrNull(filePath); // 初回実行時はnull
}
function saveLastSyncedColumnNames(columnNames, filePath = LAST_SYNCED_COLUMNS_PATH) {
saveJson(filePath, columnNames);
}
function loadLastSyncedResultIds(filePath = LAST_SYNCED_RESULT_IDS_PATH) {
return loadJsonOrNull(filePath); // 初回実行時はnull
}
function saveLastSyncedResultIds(resultIds, filePath = LAST_SYNCED_RESULT_IDS_PATH) {
saveJson(filePath, resultIds);
}
// プリザンターのSiteSettings.Columns(ColumnName/LabelText)を、Keycloak属性定義
// (name/displayName)へ変換する。ColumnNameをそのままKeycloak属性名として使うことで、
// プリザンター側で項目が追加・削除されるだけで自動的に同期対象へ追従する
function buildAttributeDefs(columns) {
return columns.map((c) => ({ name: c.ColumnName, displayName: c.LabelText }));
}
// 現在のUser Profile属性定義・今回のColumns一覧・前回同期したカラム名一覧(lastSyncedNamesOrNull)から
// 「追加すべき属性定義」「削除すべき属性名」を求める(純粋関数)。突合キー自身(RESULT_ID_ATTR)は
// Columns一覧に含まれない前提のため、呼び出し側(Task 4)が別途attributeDefsへ追加してから渡すこと。
// lastSyncedNamesOrNullがnull(初回実行)の場合は削除ロジックを働かせず追加のみ行う
// (ただしLEGACY_ATTRS_TO_REMOVE_ON_FIRST_RUNは初回から明示的に削除対象に含める)
function planProfileAttributeSync(currentProfile, attributeDefs, lastSyncedNamesOrNull) {
const desiredNames = new Set(attributeDefs.map((d) => d.name));
const currentNames = new Set(currentProfile.attributes.map((a) => a.name));
const toAdd = attributeDefs.filter((d) => !currentNames.has(d.name));
let toRemove;
if (lastSyncedNamesOrNull === null) {
toRemove = LEGACY_ATTRS_TO_REMOVE_ON_FIRST_RUN.filter((name) => currentNames.has(name) && !PROTECTED_ATTRS.has(name));
} else {
toRemove = lastSyncedNamesOrNull.filter((name) => !desiredNames.has(name) && !PROTECTED_ATTRS.has(name) && currentNames.has(name));
}
return { toAdd, toRemove };
}
// planProfileAttributeSyncの結果から、実際にPUTするprofileオブジェクトを組み立てる。
// 変更が無ければnullを返す(PUT自体を呼ばずに済ませるため)
function applyProfileAttributePlan(currentProfile, plan) {
if (plan.toAdd.length === 0 && plan.toRemove.length === 0) return null;
const removeSet = new Set(plan.toRemove);
const kept = currentProfile.attributes.filter((a) => !removeSet.has(a.name));
const added = plan.toAdd.map((d) => ({
name: d.name,
displayName: d.displayName,
permissions: { view: ["admin"], edit: ["admin"] },
multivalued: false,
}));
return { ...currentProfile, attributes: [...kept, ...added] };
}
// 今回のマスタ全ResultId(文字列化済み)と前回同期したResultId一覧を比較し、
// 「前回は存在したが今回は無い=マスタから消えた」ResultIdを削除対象として返す(純粋関数)。
// lastSyncedResultIdsOrNullがnull(初回実行)の場合は削除対象なし(比較対象が無いため)
function planResultIdDeletions(todayResultIds, lastSyncedResultIdsOrNull) {
if (lastSyncedResultIdsOrNull === null) return [];
const todaySet = new Set(todayResultIds.map(String));
return lastSyncedResultIdsOrNull.filter((id) => !todaySet.has(String(id)));
}
module.exports = {
RESULT_ID_ATTR, PROTECTED_ATTRS, LEGACY_ATTRS_TO_REMOVE_ON_FIRST_RUN,
LAST_SYNCED_COLUMNS_PATH, LAST_SYNCED_RESULT_IDS_PATH,
loadLastSyncedColumnNames, saveLastSyncedColumnNames, loadLastSyncedResultIds, saveLastSyncedResultIds,
buildAttributeDefs, planProfileAttributeSync, applyProfileAttributePlan, planResultIdDeletions,
};
- Step 4: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/keycloakColumnSync.test.js
Expected: PASS(1 test)
- Step 5: 残りの関数のテストを追加
test("planProfileAttributeSync detects additions and, on first run (lastSyncedNamesOrNull=null), only removes the legacy fullName/pleasanterLoginId/lineworksUserId attributes", () => {
const { planProfileAttributeSync } = require("../src/lib/keycloakColumnSync");
const currentProfile = { attributes: [{ name: "username" }, { name: "email" }, { name: "fullName" }, { name: "pleasanterLoginId" }, { name: "lineworksUserId" }, { name: "someOtherAppAttr" }] };
const attributeDefs = [{ name: "ClassA", displayName: "社員ID" }];
const plan = planProfileAttributeSync(currentProfile, attributeDefs, null);
assert.deepStrictEqual(plan.toAdd, [{ name: "ClassA", displayName: "社員ID" }]);
assert.deepStrictEqual(plan.toRemove.sort(), ["fullName", "lineworksUserId", "pleasanterLoginId"]);
});
test("planProfileAttributeSync on a later run removes only attributes that were synced before but are absent today, keeping unrelated and protected attributes", () => {
const { planProfileAttributeSync } = require("../src/lib/keycloakColumnSync");
const currentProfile = { attributes: [{ name: "username" }, { name: "email" }, { name: "ClassA" }, { name: "Class999" }, { name: "someOtherAppAttr" }] };
const attributeDefs = [{ name: "ClassA", displayName: "社員ID" }]; // Class999は今回のColumnsから消えた想定
const lastSyncedNames = ["ClassA", "Class999"];
const plan = planProfileAttributeSync(currentProfile, attributeDefs, lastSyncedNames);
assert.deepStrictEqual(plan.toAdd, []);
assert.deepStrictEqual(plan.toRemove, ["Class999"]);
});
test("planProfileAttributeSync never removes protected attributes (Keycloak standard fields + pleasanterResultId) even if listed in lastSyncedNames", () => {
const { planProfileAttributeSync, RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync");
const currentProfile = { attributes: [{ name: "username" }, { name: "email" }, { name: "firstName" }, { name: "lastName" }, { name: RESULT_ID_ATTR }] };
const plan = planProfileAttributeSync(currentProfile, [], ["username", "email", "firstName", "lastName", RESULT_ID_ATTR]);
assert.deepStrictEqual(plan.toRemove, []);
});
test("applyProfileAttributePlan adds new attribute defs and removes the ones marked for removal, returning null when there is nothing to change", () => {
const { planProfileAttributeSync, applyProfileAttributePlan } = require("../src/lib/keycloakColumnSync");
const currentProfile = { attributes: [{ name: "username" }, { name: "Class999" }] };
const attributeDefs = [{ name: "ClassA", displayName: "社員ID" }];
const plan = planProfileAttributeSync(currentProfile, attributeDefs, ["Class999"]);
const newProfile = applyProfileAttributePlan(currentProfile, plan);
assert.deepStrictEqual(newProfile.attributes.map((a) => a.name), ["username", "ClassA"]);
assert.strictEqual(applyProfileAttributePlan(currentProfile, { toAdd: [], toRemove: [] }), null);
});
test("planResultIdDeletions returns ResultIds present last time but absent today; returns [] on first run", () => {
const { planResultIdDeletions } = require("../src/lib/keycloakColumnSync");
assert.deepStrictEqual(planResultIdDeletions(["1", "2"], null), []); // 初回実行
assert.deepStrictEqual(planResultIdDeletions(["1"], ["1", "2"]), ["2"]); // 2が消えた
assert.deepStrictEqual(planResultIdDeletions(["1", "2"], ["1", "2"]), []); // 変化なし
});
test("saveLastSyncedColumnNames/loadLastSyncedColumnNames round-trip via a temp file; missing file returns null", () => {
const fs = require("fs");
const os = require("os");
const path = require("path");
const { saveLastSyncedColumnNames, loadLastSyncedColumnNames } = require("../src/lib/keycloakColumnSync");
const tmpFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "kc-col-sync-test-")), "last-synced-columns.json");
assert.strictEqual(loadLastSyncedColumnNames(tmpFile), null);
saveLastSyncedColumnNames(["ClassA", "Class001"], tmpFile);
assert.deepStrictEqual(loadLastSyncedColumnNames(tmpFile), ["ClassA", "Class001"]);
});
test("saveLastSyncedResultIds/loadLastSyncedResultIds round-trip via a temp file; missing file returns null", () => {
const fs = require("fs");
const os = require("os");
const path = require("path");
const { saveLastSyncedResultIds, loadLastSyncedResultIds } = require("../src/lib/keycloakColumnSync");
const tmpFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "kc-resultid-sync-test-")), "last-synced-result-ids.json");
assert.strictEqual(loadLastSyncedResultIds(tmpFile), null);
saveLastSyncedResultIds([1350, 831], tmpFile);
assert.deepStrictEqual(loadLastSyncedResultIds(tmpFile), [1350, 831]);
});
- Step 6: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/keycloakColumnSync.test.js
Expected: PASS(8 tests)
- Step 7: コミット
git add apps/org-master-sync/src/lib/keycloakColumnSync.js apps/org-master-sync/test/keycloakColumnSync.test.js
git commit -m "feat(org-master-sync): Keycloak属性・ResultIdの完全同期ロジック(追加・削除判定)を実装"
Task 3: sync-keycloak.jsのユーザー変換・グループ解決ロジック
Files:
- Create:
apps/org-master-sync/src/commands/sync-keycloak.js - Test:
apps/org-master-sync/test/sync-keycloak.test.js
Interfaces:
-
Consumes: Task 2の
keycloakColumnSync.{RESULT_ID_ATTR,PROTECTED_ATTRS,buildAttributeDefs,...} -
Produces:
masterItemToAttributes(item, columns)→Record<string,string[]>(純粋関数)。masterItemToKeycloakUser(item, columns)→{username, email, enabled, attributes}(純粋関数)。mergeKeycloakUser(existingUser, desired, columns)→ 完全なユーザーオブジェクト(純粋関数、Columnsに存在しない既存属性キーを削除する)。classificationGroupNamesFromMaster(masterItem, groupIdToName)→string[](純粋関数)。全てTask 4のapplySyncが消費する -
Step 1: 失敗するテストを書く(
masterItemToAttributes)
const { test } = require("node:test");
const assert = require("node:assert");
const { masterItemToAttributes } = require("../src/commands/sync-keycloak");
const { RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync");
test("masterItemToAttributes extracts every column's value as a Keycloak string-array attribute, plus the ResultId key", () => {
const columns = [{ ColumnName: "ClassA" }, { ColumnName: "Check062" }, { ColumnName: "Class001" }];
const item = { ResultId: 1350, ClassA: "u1-uuid", Check062: true, Class001: "山田" };
const attrs = masterItemToAttributes(item, columns);
assert.deepStrictEqual(attrs[RESULT_ID_ATTR], ["1350"]);
assert.deepStrictEqual(attrs.ClassA, ["u1-uuid"]);
assert.deepStrictEqual(attrs.Check062, ["true"]);
assert.deepStrictEqual(attrs.Class001, ["山田"]);
});
test("masterItemToAttributes converts missing/null/undefined column values to an empty string", () => {
const { masterItemToAttributes } = require("../src/commands/sync-keycloak");
const attrs = masterItemToAttributes({ ResultId: 1 }, [{ ColumnName: "Class999" }]);
assert.deepStrictEqual(attrs.Class999, [""]);
});
- Step 2: テスト実行して失敗確認
Run: cd apps/org-master-sync && node --test test/sync-keycloak.test.js
Expected: FAIL(masterItemToAttributes is not a function)
- Step 3:
sync-keycloak.jsにコアロジックを実装
"use strict";
const { getSite, getSiteItems, getAllGroups: getAllPleasanterGroups } = require("../lib/pleasanterClient");
const kc = require("../lib/keycloakClient");
const {
RESULT_ID_ATTR, PROTECTED_ATTRS,
loadLastSyncedColumnNames, saveLastSyncedColumnNames,
loadLastSyncedResultIds, saveLastSyncedResultIds,
buildAttributeDefs, planProfileAttributeSync, applyProfileAttributePlan, planResultIdDeletions,
} = require("../lib/keycloakColumnSync");
// LINEWORKS由来の4分類(設計文書7章のグループ命名規則)
const GROUP_PREFIXES = ["組織:", "役職:", "職級:", "利用権限タイプ:"];
// マスタレコード(getSiteItemsが返すフラット化済み物理キーオブジェクト)から、columnsに
// 定義された全カラムの値をKeycloak属性(文字列配列)として抽出する。ResultId自身も
// 突合キー属性として含める。値が無ければ空文字列
function masterItemToAttributes(item, columns) {
const attributes = { [RESULT_ID_ATTR]: [String(item.ResultId)] };
for (const col of columns) {
const value = item[col.ColumnName];
attributes[col.ColumnName] = [value === undefined || value === null ? "" : String(value)];
}
return attributes;
}
// マスタレコード1件からKeycloakユーザーの望ましい状態を組み立てる(純粋関数)。
// usernameはResultIdベースの固定文字列にする(メールアドレスは変更されうるため使わない)。
// enabledはCheck062(LINEWORKS在籍フラグ)に従う(ClassZより確実に更新される。事前確認済みデータ参照)
function masterItemToKeycloakUser(item, columns) {
return {
username: `pleasanter-${item.ResultId}`,
email: item.ClassB || undefined,
enabled: item.Check062 === true,
attributes: masterItemToAttributes(item, columns),
};
}
// 【重要・完全同期】既存ユーザーのattributesのうち、今回のcolumnsに存在しないキーは
// 除去する(RESULT_ID_ATTRとPROTECTED_ATTRSは常に保持)。Columnsに存在するキーは
// desired.attributesの新しい値で上書きする(設計文書9章「ハマった点8」対策と、
// プリザンター側カラム削除追従の両方を満たす)
function mergeKeycloakUser(existingUser, desired, columns) {
const keepNames = new Set([...columns.map((c) => c.ColumnName), ...PROTECTED_ATTRS]);
const keptAttributes = Object.fromEntries(
Object.entries(existingUser.attributes || {}).filter(([key]) => keepNames.has(key))
);
return {
...existingUser,
email: desired.email,
enabled: desired.enabled,
attributes: { ...keptAttributes, ...desired.attributes },
};
}
// マスタのClass035(プリザンターGroupIdのカンマ区切り)を、プリザンターのGroupId→GroupName
// 逆引きマップ(groupIdToName)で名前配列に変換し、分類プレフィックスを持つものだけ抽出する
function classificationGroupNamesFromMaster(masterItem, groupIdToName) {
const ids = (masterItem.Class035 || "").split(",").map((s) => s.trim()).filter(Boolean);
const names = ids.map((id) => groupIdToName.get(id)).filter(Boolean);
return names.filter((name) => GROUP_PREFIXES.some((prefix) => name.startsWith(prefix)));
}
module.exports = {
GROUP_PREFIXES,
masterItemToAttributes, masterItemToKeycloakUser, mergeKeycloakUser, classificationGroupNamesFromMaster,
};
- Step 4: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/sync-keycloak.test.js
Expected: PASS(2 tests)
- Step 5: 残りの純粋関数のテストを追加
test("masterItemToKeycloakUser builds a ResultId-based username and enabled from Check062 (not ClassZ)", () => {
const { masterItemToKeycloakUser } = require("../src/commands/sync-keycloak");
const user = masterItemToKeycloakUser({ ResultId: 831, ClassA: "uuid-1", ClassB: "taro@next-hd.co.jp", Check062: true, ClassZ: "有" }, [{ ColumnName: "ClassA" }]);
assert.strictEqual(user.username, "pleasanter-831");
assert.strictEqual(user.email, "taro@next-hd.co.jp");
assert.strictEqual(user.enabled, true);
});
test("mergeKeycloakUser keeps protected/current-column attributes and desired values, but drops attributes for columns no longer in Columns", () => {
const { mergeKeycloakUser } = require("../src/commands/sync-keycloak");
const { RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync");
const columns = [{ ColumnName: "ClassA" }]; // Class999はもうColumnsに無い(削除された想定)
const existingUser = {
id: "kc-1", username: "pleasanter-831", email: "old@example.com", enabled: true,
firstName: "Existing", lastName: "User",
attributes: {
[RESULT_ID_ATTR]: ["831"],
ClassA: ["old-value"],
Class999: ["deleted-column-old-value"], // 削除されるべき
},
};
const desired = { email: "new@example.com", enabled: false, attributes: { [RESULT_ID_ATTR]: ["831"], ClassA: ["new-value"] } };
const merged = mergeKeycloakUser(existingUser, desired, columns);
assert.strictEqual(merged.firstName, "Existing"); // Keycloak標準フィールドは維持
assert.deepStrictEqual(merged.attributes.ClassA, ["new-value"]); // 新しい値で上書き
assert.strictEqual(merged.attributes.Class999, undefined); // Columnsから消えた属性は削除される
assert.deepStrictEqual(merged.attributes[RESULT_ID_ATTR], ["831"]); // 突合キーは常に保持
});
test("classificationGroupNamesFromMaster resolves Class035 GroupIds to names and keeps only classification-prefixed ones", () => {
const { classificationGroupNamesFromMaster } = require("../src/commands/sync-keycloak");
const groupIdToName = new Map([["5", "組織:情報システム部"], ["12", "役職:課長"], ["99", "手動で作った業務用グループ"]]);
const names = classificationGroupNamesFromMaster({ Class035: "5,12,99" }, groupIdToName);
assert.deepStrictEqual(names, ["組織:情報システム部", "役職:課長"]);
});
test("classificationGroupNamesFromMaster returns an empty array when Class035 is empty or missing", () => {
const { classificationGroupNamesFromMaster } = require("../src/commands/sync-keycloak");
assert.deepStrictEqual(classificationGroupNamesFromMaster({ Class035: "" }, new Map()), []);
assert.deepStrictEqual(classificationGroupNamesFromMaster({}, new Map()), []);
});
- Step 6: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/sync-keycloak.test.js
Expected: PASS(6 tests)
- Step 7: コミット
git add apps/org-master-sync/src/commands/sync-keycloak.js apps/org-master-sync/test/sync-keycloak.test.js
git commit -m "feat(org-master-sync): sync-keycloakのユーザー変換ロジック(完全同期対応)を実装"
Task 4: applySync(新規作成/更新/削除の判定)・ドライラン対応・main()
Files:
- Modify:
apps/org-master-sync/src/commands/sync-keycloak.js - Test:
apps/org-master-sync/test/sync-keycloak.test.js
Interfaces:
-
Consumes: Task 1の
keycloakClient.*全関数、Task 2のkeycloakColumnSync.*全関数、Task 3の全純粋関数、pleasanterClient.{getSite,getSiteItems,getAllGroups} -
Produces:
planSync(masterItems)→Array<{item}>(フィルタなし、全件。純粋関数)。applySync(plan, columns, groupIdToName, {baseUrl, realm, accessToken, fetchImpl, dryRun, lastSyncedColumnNames, lastSyncedResultIds})→{profileAttributesAdded, profileAttributesRemoved, created, updated, deleted, groupsCreated, skippedCreate} -
Step 1: 失敗するテストを書く(
planSyncがフィルタなしで全件を含む)
test("planSync includes every master item without filtering by ClassZ (needed for delete detection)", () => {
const { planSync } = require("../src/commands/sync-keycloak");
const masterItems = [
{ ResultId: 1, ClassA: "u1", ClassZ: "有" },
{ ResultId: 2, ClassA: "u2", ClassZ: "無" },
];
const plan = planSync(masterItems);
assert.strictEqual(plan.length, 2);
});
- Step 2: テスト実行して失敗確認
Run: cd apps/org-master-sync && node --test test/sync-keycloak.test.js
Expected: FAIL(planSync is not a function)
- Step 3:
planSync・applySync・mainをsync-keycloak.js末尾に追記
// マスタの全レコードを同期対象とする(ClassZによるフィルタはしない。削除検知のため全件が必要。2026-08-09確定)
function planSync(masterItems) {
return masterItems.map((item) => ({ item }));
}
async function ensureUserInGroups(userId, names, groupNameToId, { baseUrl, realm, accessToken, fetchImpl }) {
let groupsCreated = 0;
for (const name of names) {
let groupId = groupNameToId.get(name);
if (!groupId) {
groupId = await kc.createGroup({ baseUrl, realm, accessToken, name, fetchImpl });
groupNameToId.set(name, groupId);
groupsCreated++;
}
await kc.addUserToGroup({ baseUrl, realm, accessToken, userId, groupId, fetchImpl });
}
return groupsCreated;
}
// dryRun:true の場合はKeycloakへの書き込み(属性定義追加/削除・create/update/delete/group操作・
// last-synced-*.jsonの保存)を一切行わず、何が起きるはずだったかを集計して返すだけにする
async function applySync(plan, columns, groupIdToName, { baseUrl, realm, accessToken, fetchImpl, dryRun = false, lastSyncedColumnNames, lastSyncedResultIds }) {
// ① pleasanterResultId属性をUser Profileへ常に保証登録する(Columns由来の属性とは別枠。
// 事前確認済みデータ参照: 検索対象属性はUser Profile未定義だと検索にヒットしないため必須)
const attributeDefs = [...buildAttributeDefs(columns), { name: RESULT_ID_ATTR, displayName: "プリザンターResultId" }];
// ② Columns完全同期(追加・削除)
const currentProfile = await kc.getUserProfile({ baseUrl, realm, accessToken, fetchImpl });
const profilePlan = planProfileAttributeSync(currentProfile, attributeDefs, lastSyncedColumnNames);
const mergedProfile = applyProfileAttributePlan(currentProfile, profilePlan);
if (mergedProfile && !dryRun) {
await kc.updateUserProfile({ baseUrl, realm, accessToken, profile: mergedProfile, fetchImpl });
}
// ③ ユーザーのUpsert/削除
const kcGroups = await kc.getAllGroups({ baseUrl, realm, accessToken, fetchImpl });
const groupNameToId = new Map(kcGroups.map((g) => [g.name, g.id]));
let created = 0, updated = 0, groupsCreated = 0;
const skippedCreate = [];
for (const { item } of plan) {
const desired = masterItemToKeycloakUser(item, columns);
let existingUser = await kc.findUserByAttribute({ baseUrl, realm, accessToken, attrName: RESULT_ID_ATTR, attrValue: String(item.ResultId), fetchImpl });
if (!existingUser && item.ClassB) {
// 初回のみemailで突合する
existingUser = await kc.findUserByEmail({ baseUrl, realm, accessToken, email: item.ClassB, fetchImpl });
}
if (!existingUser && item.ClassZ !== "有") {
// 一度もLINEWORKSに存在したことが無いプリザンター専用ユーザーは新規作成しない(SSO対象外)
continue;
}
const names = classificationGroupNamesFromMaster(item, groupIdToName);
if (dryRun) {
if (existingUser) updated++; else created++;
continue;
}
let userId;
if (existingUser) {
const merged = mergeKeycloakUser(existingUser, desired, columns);
await kc.updateUser({ baseUrl, realm, accessToken, userId: existingUser.id, user: merged, fetchImpl });
userId = existingUser.id;
updated++;
} else {
userId = await kc.createUser({ baseUrl, realm, accessToken, user: desired, fetchImpl });
if (!userId) {
skippedCreate.push({ item, reason: "createUser did not return an id" });
continue;
}
created++;
}
groupsCreated += await ensureUserInGroups(userId, names, groupNameToId, { baseUrl, realm, accessToken, fetchImpl });
}
// ④ マスタから消えたResultIdに対応するKeycloakユーザーを完全削除する
const todayResultIds = plan.map(({ item }) => item.ResultId);
const deletions = planResultIdDeletions(todayResultIds, lastSyncedResultIds || null);
let deleted = 0;
if (!dryRun) {
for (const resultId of deletions) {
const user = await kc.findUserByAttribute({ baseUrl, realm, accessToken, attrName: RESULT_ID_ATTR, attrValue: String(resultId), fetchImpl });
if (user) {
await kc.deleteUser({ baseUrl, realm, accessToken, userId: user.id, fetchImpl });
deleted++;
}
}
}
return {
profileAttributesAdded: profilePlan.toAdd.length,
profileAttributesRemoved: profilePlan.toRemove.length,
created, updated, deleted: dryRun ? deletions.length : deleted, groupsCreated, skippedCreate,
};
}
async function main() {
const execute = process.argv.includes("--execute");
const pleasanterConfig = {
baseUrl: process.env.PLEASANTER_BASE_URL,
apiKey: process.env.PLEASANTER_API_KEY,
siteId: process.env.PLEASANTER_MASTER_SITE_ID,
};
const kcConfig = { baseUrl: process.env.KEYCLOAK_BASE_URL, realm: process.env.KEYCLOAK_REALM };
const [site, masterItems, pleasanterGroups] = await Promise.all([
getSite(pleasanterConfig),
getSiteItems(pleasanterConfig),
getAllPleasanterGroups(pleasanterConfig),
]);
const columns = site.SiteSettings.Columns || [];
const groupIdToName = new Map(pleasanterGroups.map((g) => [String(g.GroupId), g.GroupName]));
const lastSyncedColumnNames = loadLastSyncedColumnNames();
const lastSyncedResultIds = loadLastSyncedResultIds();
const plan = planSync(masterItems);
console.log(`マスタ全体: ${plan.length}件(うちLINEWORKS在籍=ClassZ有: ${masterItems.filter((i) => i.ClassZ === "有").length}件)`);
console.log(`マスタのColumns(同期する属性数): ${columns.length}件`);
console.log(lastSyncedColumnNames === null ? "[初回実行] 前回の同期記録なし" : `前回同期時のカラム数: ${lastSyncedColumnNames.length}件 / ResultId数: ${(lastSyncedResultIds || []).length}件`);
const accessToken = await kc.fetchAccessToken({
...kcConfig,
clientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET,
});
const result = await applySync(plan, columns, groupIdToName, { ...kcConfig, accessToken, dryRun: !execute, lastSyncedColumnNames, lastSyncedResultIds });
if (!execute) {
console.log(`\n[ドライラン] 属性追加予定=${result.profileAttributesAdded}件 属性削除予定=${result.profileAttributesRemoved}件 / create予定=${result.created}件 / update予定=${result.updated}件 / delete予定=${result.deleted}件`);
console.log("--execute を付けずに実行したため、Keycloakへは何も送信していません。");
return;
}
saveLastSyncedColumnNames(columns.map((c) => c.ColumnName));
saveLastSyncedResultIds(masterItems.map((i) => i.ResultId));
console.log(`\n[OK] profileAttributesAdded=${result.profileAttributesAdded} profileAttributesRemoved=${result.profileAttributesRemoved} created=${result.created} updated=${result.updated} deleted=${result.deleted} groupsCreated=${result.groupsCreated}`);
if (result.skippedCreate.length > 0) {
console.log(`[要確認] ${result.skippedCreate.length}件スキップ:`, JSON.stringify(result.skippedCreate.map((s) => ({ ResultId: s.item.ResultId, reason: s.reason }))));
}
}
module.exports = {
GROUP_PREFIXES,
masterItemToAttributes, masterItemToKeycloakUser, mergeKeycloakUser, classificationGroupNamesFromMaster,
planSync, applySync,
};
if (require.main === module) {
main().catch((e) => { console.error(e); process.exit(1); });
}
(Task 3で作ったmodule.exportsはこのStepの内容で置き換える)
- Step 4: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/sync-keycloak.test.js
Expected: PASS(7 tests)
- Step 5:
applySyncの統合テストを追加(新規作成条件・削除検出・属性完全同期・ドライラン)
test("applySync creates a new Keycloak user only when ClassZ is 有, and skips creation for LINEWORKS-absent (ClassZ=無) master-only items with no existing Keycloak user", async () => {
const { planSync, applySync } = require("../src/commands/sync-keycloak");
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
const masterItems = [
{ ResultId: 831, ClassA: "u1-uuid", ClassB: "taro@next-hd.co.jp", Check062: true, ClassZ: "有", Class035: "" },
{ ResultId: 1350, ClassA: "", ClassB: "", Check062: false, ClassZ: "無", Class035: "" }, // プリザンター専用、Keycloak未登録
];
const plan = planSync(masterItems);
const calls = [];
const fetchImpl = async (url, opts) => {
const urlStr = String(url);
calls.push(urlStr);
if (urlStr.endsWith("/users/profile") && (!opts || !opts.method || opts.method === "GET")) return { ok: true, json: async () => ({ attributes: [{ name: "email" }] }) };
if (urlStr.endsWith("/users/profile") && opts.method === "PUT") return { ok: true };
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [] }; // どちらも未登録
if (urlStr.includes("/users?email=")) return { ok: true, json: async () => [] };
if (urlStr.endsWith("/users") && opts.method === "POST") {
return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/users/new-user-id" : null) } };
}
throw new Error(`unexpected fetch: ${urlStr}`);
};
const result = await applySync(plan, columns, new Map(), { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl });
assert.strictEqual(result.created, 1); // ResultId=831のみ作成
assert.ok(!calls.some((u) => u.endsWith("/users") && u.includes("POST"))); // 簡易チェック(実際はPOST回数で見る)
});
test("applySync updates an existing user's enabled flag based on Check062 even when ClassZ is 無 (deactivation, not deletion)", async () => {
const { planSync, applySync } = require("../src/commands/sync-keycloak");
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
const masterItems = [{ ResultId: 10, ClassA: "u1", ClassB: "taro@next-hd.co.jp", Check062: false, ClassZ: "無", Class035: "" }]; // 退職済み、レコード自体は残っている
const plan = planSync(masterItems);
let putUserBody, deleteCalled = false;
const fetchImpl = async (url, opts) => {
const urlStr = String(url);
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "ClassA" }, { name: "pleasanterResultId" }] }) };
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [{ id: "existing-id", username: "pleasanter-10", attributes: { pleasanterResultId: ["10"], ClassA: ["old"] } }] };
if (urlStr.endsWith("/users/existing-id") && opts.method === "PUT") { putUserBody = JSON.parse(opts.body); return { ok: true }; }
if (urlStr.endsWith("/users/existing-id") && opts.method === "DELETE") { deleteCalled = true; return { ok: true }; }
throw new Error(`unexpected fetch: ${urlStr}`);
};
const result = await applySync(plan, columns, new Map(), { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [10] });
assert.strictEqual(result.updated, 1);
assert.strictEqual(putUserBody.enabled, false); // 無効化
assert.strictEqual(deleteCalled, false); // 削除はされない(レコード自体は存在するため)
});
test("applySync deletes the Keycloak user for a ResultId that was synced last time but is absent from today's master (record physically removed)", async () => {
const { applySync } = require("../src/commands/sync-keycloak");
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
const plan = []; // 今回のマスタにResultId=99は存在しない
let deletedUserId;
const fetchImpl = async (url, opts) => {
const urlStr = String(url);
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "pleasanterResultId" }] }) };
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
if (urlStr.includes("/users?q=pleasanterResultId%3A99")) return { ok: true, json: async () => [{ id: "to-delete-id" }] };
if (urlStr.endsWith("/users/to-delete-id") && opts.method === "DELETE") { deletedUserId = "to-delete-id"; return { ok: true }; }
throw new Error(`unexpected fetch: ${urlStr}`);
};
const result = await applySync(plan, columns, new Map(), { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [99] });
assert.strictEqual(result.deleted, 1);
assert.strictEqual(deletedUserId, "to-delete-id");
});
test("applySync in dryRun mode counts additions/removals/create/update/delete without calling any write API", async () => {
const { planSync, applySync } = require("../src/commands/sync-keycloak");
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
const masterItems = [{ ResultId: 1, ClassA: "u1", ClassB: "", Check062: true, ClassZ: "有", Class035: "" }];
const plan = planSync(masterItems);
const calls = [];
const fetchImpl = async (url, opts) => {
const urlStr = String(url);
calls.push({ url: urlStr, method: opts && opts.method });
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "Class999" }] }) };
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [] };
throw new Error(`unexpected fetch in dry run: ${urlStr}`);
};
const result = await applySync(plan, columns, new Map(), {
baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, dryRun: true,
lastSyncedColumnNames: ["Class999"], lastSyncedResultIds: [99], // 99は今回planに無いので削除予定1件になるはず
});
assert.strictEqual(result.profileAttributesAdded, 1); // ClassA
assert.strictEqual(result.profileAttributesRemoved, 1); // Class999
assert.strictEqual(result.created, 1);
assert.strictEqual(result.deleted, 1); // 99が削除予定(dryRunなので実際のDELETE呼び出しは無い)
assert.ok(!calls.some((c) => c.method === "POST" || c.method === "PUT" || c.method === "DELETE"));
});
- Step 6: テスト実行して通過確認
Run: cd apps/org-master-sync && node --test test/sync-keycloak.test.js
Expected: PASS(11 tests)
- Step 7: ドライラン実行して実データで確認
Run: cd apps/org-master-sync && export $(cat .env | grep -v '^#' | xargs) && node src/commands/sync-keycloak.js
Expected: マスタ全体: 619件(うちLINEWORKS在籍=ClassZ有: 351件)・マスタのColumns(同期する属性数): 56件・[初回実行] 前回の同期記録なしに続き[ドライラン] 属性追加予定=57件 属性削除予定=3件 / create予定=351件程度 / update予定=X件 / delete予定=0件(初回のためdelete予定は必ず0件)
- Step 8: ユーザーに差分プレビュー(Step 7のドライラン結果)を提示し、明示的な許可を得てから実行
Run: cd apps/org-master-sync && export $(cat .env | grep -v '^#' | xargs) && node src/commands/sync-keycloak.js --execute
Expected: [OK] profileAttributesAdded=57 profileAttributesRemoved=3 created=351程度 updated=X deleted=0 groupsCreated=Y。続けてapps/org-master-sync/configs/keycloak-sync/last-synced-columns.json・last-synced-result-ids.jsonの両方が生成されていることを確認する
- Step 9: Keycloak側で実際にUser Profile属性・ユーザー・グループ所属を目視確認
Run:
cd apps/org-master-sync && export $(cat .env | grep -v '^#' | xargs) && node -e '
const kc = require("./src/lib/keycloakClient");
(async () => {
const cfg = { baseUrl: process.env.KEYCLOAK_BASE_URL, realm: process.env.KEYCLOAK_REALM };
const accessToken = await kc.fetchAccessToken({ ...cfg, clientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID, clientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET });
const profile = await kc.getUserProfile({ ...cfg, accessToken });
console.log("User Profile属性数:", profile.attributes.length);
const groups = await kc.getAllGroups({ ...cfg, accessToken });
console.log("Keycloakグループ数:", groups.length, groups.slice(0, 10).map(g => g.name));
const sample = await kc.findUserByAttribute({ ...cfg, accessToken, attrName: "pleasanterResultId", attrValue: "831" });
console.log("サンプルユーザー(ResultId=831):", JSON.stringify(sample && { username: sample.username, enabled: sample.enabled }));
})();
'
Expected: User Profile属性数がusername/email/firstName/lastName+Columns56件+pleasanterResultId=61件、旧fullName等は含まれない。グループに「組織:」「役職:」等が複数
- Step 10: 2回目実行して冪等性を確認(create/update/delete全て変化なしになるはず)
Run: cd apps/org-master-sync && export $(cat .env | grep -v '^#' | xargs) && node src/commands/sync-keycloak.js
Expected: 前回同期時のカラム数: 56件 / ResultId数: 619件と表示され、属性追加予定=0件 属性削除予定=0件 / create予定=0件 / delete予定=0件(update予定は既存ユーザー全員が対象になるため351件程度のまま出る想定。これは仕様であり問題ない)
- Step 11: コミット
git add apps/org-master-sync/src/commands/sync-keycloak.js apps/org-master-sync/test/sync-keycloak.test.js
git commit -m "feat(org-master-sync): ④sync-keycloakコマンド(新規作成/更新/削除判定・属性完全同期・ドライラン対応)を実装"
Task 5: Claude Skill化・README更新
Files:
- Create:
apps/org-master-sync/.claude/skills/org-sync-keycloak/SKILL.md - Modify:
apps/org-master-sync/README.md - Modify:
apps/org-master-sync/package.json
Interfaces:
-
Consumes: Task 4の
node src/commands/sync-keycloak.js(コマンドとして呼び出すのみ、コード依存なし) -
Step 1:
.gitignoreの確認(変更不要)
c:\Users\k.nogi\#GitHub\NodeSrv\.gitignoreのapps/org-master-sync/configs/が既にconfigs/全体をカバーしているため追加変更は不要。grep "org-master-sync/configs" .gitignoreで既存行がヒットすることを確認するのみ
- Step 2:
org-sync-keycloak/SKILL.mdを書く
---
name: org-sync-keycloak
description: apps/org-master-sync/のマスタテーブル(プリザンターSiteId=1)をKeycloak(nexthdレルム)へ完全同期し、組織・役職・職級・利用権限タイプのグループへ所属させる。マスタの全項目(プリザンターのSiteSettings.Columnsから動的取得)をKeycloakのカスタム属性として同期し、項目の追加・削除の両方に自動追従する。「Keycloakに同期して」「Keycloakへ反映して」等の依頼で使う。
---
# Keycloakへの同期
**前提・重要な注意**: LINEWORKS退職者はLINEWORKS API結果から物理的に消えるため、マスタの在籍フラグ(`Check062`・`ClassZ`)は①②を実行して初めて最新化される。このスキルの実行前に、必ず`org-fetch-lineworks`→`org-merge-master`を先に実行済みであることを確認する(未実行なら先にそちらを案内する)。
`apps/org-master-sync/`配下で以下を実行する。
\`\`\`bash
cd apps/org-master-sync
node src/commands/sync-keycloak.js # ドライラン(属性追加/削除予定・作成/更新/削除予定件数の表示のみ)
node src/commands/sync-keycloak.js --execute # 実際にKeycloakへ反映
\`\`\`
- 同期する属性: プリザンターのマスタテーブルの`SiteSettings.Columns`に定義されている**全カラム**。プリザンター管理画面で項目を追加・削除するだけで、コード変更なしに次回実行時から自動的にKeycloakへも反映される(項目削除時は対応するKeycloak属性の定義・値の両方が削除される=完全同期)
- 突合キー: Keycloakユーザーのカスタム属性`pleasanterResultId`(マスタのレコードID)。初回のみメールアドレスで突合し、以降は`pleasanterResultId`で検索
- **新規作成**: マスタの`ClassZ`(LINEWORKS有無)="有"のレコードのみ(プリザンター専用ユーザーはSSO対象外のため作成しない)
- **更新(在籍状態)**: 既にKeycloakに存在するユーザーは常に更新する。`enabled`はマスタの`Check062`(在籍フラグ)に従う。`ClassZ`が"無"になっても(LINEWORKS退職)**削除はせず無効化のみ**
- **削除**: マスタから該当レコード自体(`ResultId`)が消えた場合のみ、対応するKeycloakユーザーを完全削除する
- グループ: マスタの所属グループ(`Class035`)から「組織:」「役職:」「職級:」「利用権限タイプ:」で始まるものだけをKeycloakグループとしてget-or-createし、ユーザーを所属させる
- 実行前に必ずドライラン(`--execute`無し)で件数(特に「属性削除予定」「delete予定」件数)を確認し、ユーザーに提示してから`--execute`で実行する
- 実行後、ログの`profileAttributesAdded`/`profileAttributesRemoved`/`created`/`updated`/`deleted`/`groupsCreated`件数をユーザーに報告する。`skippedCreate`が0件でない場合はその内容も報告する
- Step 3:
package.jsonのscriptsに追加
apps/org-master-sync/package.jsonのscriptsセクションを以下に置き換える:
{
"name": "org-master-sync",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"test": "node --test test/*.test.js",
"fetch-lineworks": "node src/commands/fetch-lineworks.js",
"merge-master": "node src/commands/merge-master.js",
"sync-keycloak": "node src/commands/sync-keycloak.js"
}
}
- Step 4: README.mdの「現状ステータス」「コマンド」節を更新
apps/org-master-sync/README.mdの該当箇所を以下に置き換える:
## 現状ステータス
Phase 1完了: LINEWORKS取得(①fetch-lineworks)→ プリザンターマスタテーブル反映(②merge-master)が動作。
Phase 2完了: マスタ→Keycloak完全同期(④sync-keycloak)が動作。マスタの全項目(プリザンターの
SiteSettings.Columnsから動的取得)をKeycloakのカスタム属性として同期し、項目の追加・削除の
両方に自動追従する。新規作成はClassZ=有のみ、既存ユーザーはCheck062に応じて無効化のみ
(削除はしない)、マスタからレコード自体が消えた場合のみKeycloakユーザーを完全削除する。
前回同期状態は`configs/keycloak-sync/`で管理。
マスタテーブルはプリザンターSiteId=1(`.env`の`PLEASANTER_MASTER_SITE_ID`)。
③sync-pleasanterは本番移行後に着手(別プランで対応)。
## セットアップ
\`\`\`bash
cd apps/org-master-sync
npm test # ユニットテスト実行(node --test)
\`\`\`
`.env`は3システム分の認証情報を含む(gitignore対象、既に設定済み)。
Keycloak側は`org-master-sync`クライアントのサービスアカウントに`realm-management`の`realm-admin`ロールが
付与済み(User Profile属性定義の動的追加・削除に必要。Keycloak管理コンソールで1回のみ設定済み)。
## コマンド
\`\`\`bash
node src/commands/fetch-lineworks.js # ① LINEWORKSから全件取得 → data/master-source/lineworks-snapshot.json
node src/commands/merge-master.js # ② マスタテーブル(SiteId=1)へ突合・反映
node src/commands/sync-keycloak.js [--execute] # ④ マスタをKeycloak(nexthdレルム)へ完全同期
\`\`\`
**運用上の注意**:
- ④実行前には必ず①②を先に実行すること。LINEWORKS退職者検知(②)は都度の実行に依存するため、①②を経ずに④だけを実行すると在籍状態が古いままKeycloakへ反映される
- ④はプリザンター側でカラムが削除されると対応するKeycloak属性も削除し、マスタレコード自体が削除されると対応するKeycloakユーザーも完全削除する(完全同期)。ドライランで「属性削除予定」「delete予定」件数を必ず確認してから`--execute`すること
Claude Skill経由でも起動可能(`.claude/skills/org-fetch-lineworks/` / `org-merge-master/` / `org-sync-keycloak/`)。
- Step 5: 全テストを通しで実行
Run: cd apps/org-master-sync && npm test
Expected: 全ファイル(masterFields/lineworksAuth/lineworksClient/pleasanterClient/fetch-lineworks/merge-master/siteConfigStore/keycloakClient/keycloakColumnSync/sync-keycloak)のテストがPASS
- Step 6: コミット
git add apps/org-master-sync/.claude/skills/org-sync-keycloak/ apps/org-master-sync/README.md apps/org-master-sync/package.json
git commit -m "feat(org-master-sync): ④sync-keycloakをClaude Skill化・README更新(Phase 2完了)"
Self-Review メモ
- 突合キーの変更(2026-08-09): design doc8章の
lineworksUserId属性突合から、プリザンターのResultId(pleasanterResultId属性)突合へ変更(ユーザー確定) - 同期対象・新規作成・更新・削除の判定ルール(2026-08-09、複数回の確認を経て確定):
- 同期対象(
planSync)はマスタ全件(ClassZフィルタなし)。削除検知のため全件走査が必要 - 新規作成は
ClassZ="有"のみ(一度もLINEWORKSに存在したことが無いプリザンター専用ユーザーは対象外) - 既存ユーザーは常に更新し、
enabledはCheck062(ClassZではない。理由はTask 3のコメントと事前確認済みデータ参照)に従う。ClassZが"無"になっても削除せず無効化のみ - マスタから
ResultId自体が消えた場合のみ完全削除 - Task 4の統合テスト(Step 5)でこの3ケース全てを個別に検証済み
- 同期対象(
- ②merge-masterのバグ修正(2026-08-09、④設計時に発覚): 退職検知が
Check062のみ更新しClassZを更新していなかった。Check062と同時にClassZ: "無"も更新するよう修正済み(53 tests pass、design doc7章にも記録済み)。この修正により、Keycloak同期のenabled判定にCheck062を使う設計の前提(在籍状態はCheck062で正確に追える)が成立する - 全項目動的同期・完全同期: プリザンターの
SiteSettings.Columnsを実行のたびに動的取得し、configs/keycloak-sync/last-synced-columns.json(前回同期カラム一覧)との差分で追加・削除を判定。他システム由来の属性を誤って削除しないよう「前回このツールが同期に使った名前一覧」との差分でのみ削除対象を判定する(Task 2のテストで明示的に検証) - 属性検索にはUser Profile事前定義が必須(実機確認済み・重要な既知の制約):
pleasanterResultIdはColumns由来ではないため、applySync内で明示的にbuildAttributeDefsの結果へ追加してからProfile同期にかけている(Task 4 Step 3のコメント参照)。この処理順序(Profile保証登録→ユーザー検索)を誤ると突合が機能しなくなる - 削除時の保護対象: Keycloak標準プロファイル属性(
username/email/firstName/lastName)とpleasanterResultId自身は常に保護 - 旧属性の削除: design doc8章由来の
fullName/pleasanterLoginId/lineworksUserIdは、初回実行時に明示的に削除対象へ含める(LEGACY_ATTRS_TO_REMOVE_ON_FIRST_RUN)。Task 2のテストで検証済み - User Profile書き込み権限:
org-master-syncクライアントへのrealm-adminロール付与(ユーザーが2026-08-09に手動実施)によりPUT /users/profileが機能することを実機確認済み mergeKeycloakUser・planProfileAttributeSync/applyProfileAttributePlan・planResultIdDeletionsはいずれも「削除して良いものだけ削除し、それ以外は保持する」ことを複数のテストケースで検証済み。ハマった点8(PUT完全上書き事故)の再発を防ぐ設計になっているclassificationGroupNamesFromMasterはClass035に混ざりうる「LINEWORKS由来でない手動グループ」を明示的に除外するテストを持つ- ドライラン既定・
--execute明示という安全パターンは①②のpush-master-site-schema.js等と一貫している。ドライラン時はUser Profile属性追加/削除・ユーザー作成/更新/削除・グループ操作・last-synced-*.json保存いずれも一切行われないことをTask 4 Step 5のテストで検証済み Class034(パスワード)を含む全カラムを同期対象とする方針(ユーザー確定・2026-08-09)はColumns配列をそのまま使う設計により自然に満たされる- 「④実行前に①②を先に実行する」という運用注意は、Global ConstraintsとTask 5のSKILL.md・README両方に明記した
- 型・関数名の一貫性:
keycloakClient.js・keycloakColumnSync.js・sync-keycloak.jsの関数名は全Task通して統一 - Task 4 Step 10(2回目実行の冪等性確認)で、追加・削除ロジックが「変化が無ければ何もしない」ことを実データでも確認する手順を追加した
- プレースホルダなし: 全ステップに実コード・実コマンド・期待値を記載済み