ken_nogi/NodeSrv/docs/superpowers/plans/2026-08-09-org-master-sync-phase2-keycloak.md
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
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>
2026-09-04 15:37:06 +09:00

1264 lines
78 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 組織・社員マスタ基盤構築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.jsCommonJS、`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: PASS1 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: PASS12 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.ColumnsColumnName/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: PASS1 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: PASS8 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<string,string[]>`(純粋関数)。`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はCheck062LINEWORKS在籍フラグに従う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: PASS2 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: PASS6 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: PASS7 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: PASS11 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をKeycloaknexthdレルムへ完全同期し、組織・役職・職級・利用権限タイプのグループへ所属させる。マスタの全項目プリザンターの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`はいずれも「削除して良いものだけ削除し、それ以外は保持する」ことを複数のテストケースで検証済み。ハマった点8PUT完全上書き事故の再発を防ぐ設計になっている
- `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 102回目実行の冪等性確認で、追加・削除ロジックが「変化が無ければ何もしない」ことを実データでも確認する手順を追加した
- プレースホルダなし: 全ステップに実コード・実コマンド・期待値を記載済み