# 組織・社員マスタ基盤構築(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確定)**: 1. マスタレコードに対応するKeycloakユーザーが**見つからない**場合: そのマスタレコードの`ClassZ`が`"有"`の時のみ新規作成する。`ClassZ`が`"無"`(一度もLINEWORKSに存在したことが無いプリザンター専用ユーザー)は新規作成しない(SSO対象外のため) 2. Keycloakユーザーが**見つかった**場合: 常に更新する。`enabled`はマスタの`Check062`(LINEWORKS在籍フラグ。退職時に確実に更新される。`ClassZ`は退職検知時の更新漏れが過去にあったため、判定には使わない)に従う。マスタが`ClassZ`="無"になっていても、既存のKeycloakユーザーは**削除せず無効化のみ**行う(2026-08-09、ユーザー確定) 3. **マスタから`ResultId`自体が消えた**場合(レコードが物理削除された場合)のみ、対応するKeycloakユーザーを完全削除する(`DELETE /admin/realms/{realm}/users/{id}`。design doc7章の「退職者は無効化のみ、削除しない」方針とは別物=レコード消滅時のみの特別対応。判定は前回同期した`ResultId`一覧との差分で行う) - **完全同期(カラムの追加・削除、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`)** ```js 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`を実装** ```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: 残りの関数のテストを追加** ```js 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: ```bash 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: コミット** ```bash 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`)** ```js 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`を実装** ```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: 残りの関数のテストを追加** ```js 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: コミット** ```bash 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`(純粋関数)。`masterItemToKeycloakUser(item, columns)` → `{username, email, enabled, attributes}`(純粋関数)。`mergeKeycloakUser(existingUser, desired, columns)` → 完全なユーザーオブジェクト(純粋関数、Columnsに存在しない既存属性キーを削除する)。`classificationGroupNamesFromMaster(masterItem, groupIdToName)` → `string[]`(純粋関数)。全てTask 4の`applySync`が消費する - [ ] **Step 1: 失敗するテストを書く(`masterItemToAttributes`)** ```js 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`にコアロジックを実装** ```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: 残りの純粋関数のテストを追加** ```js 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: コミット** ```bash 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`がフィルタなしで全件を含む)** ```js 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`末尾に追記** ```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`の統合テストを追加(新規作成条件・削除検出・属性完全同期・ドライラン)** ```js 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: ```bash 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: コミット** ```bash 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`を書く** ```markdown --- 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`セクションを以下に置き換える: ```json { "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`の該当箇所を以下に置き換える: ```markdown ## 現状ステータス 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: コミット** ```bash 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回目実行の冪等性確認)で、追加・削除ロジックが「変化が無ければ何もしない」ことを実データでも確認する手順を追加した - プレースホルダなし: 全ステップに実コード・実コマンド・期待値を記載済み