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>
214 lines
8.9 KiB
JavaScript
214 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
||
// Keycloak(nexthdレルム)に同期済みの全ユーザーをXWikiへ事前登録する。
|
||
// これにより、LINEWORKS SSO初回ログイン時にXWiki OIDC拡張が「新規ユーザー」と
|
||
// 判定せず、事前登録したこのユーザーへマッピングされる(自然作成の抑制)。
|
||
//
|
||
// マッピングの仕組み: OIDC拡張は XWiki.OIDC.UserClass オブジェクトの
|
||
// issuer/subject プロパティで既存ユーザーを検索する(issuer=Keycloakのissuer URL、
|
||
// subject=KeycloakユーザーのUUID=IDトークンのsubクレーム)。
|
||
//
|
||
// 使い方: node provision-oidc-users.js [--dry-run] [--limit=N]
|
||
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
|
||
const envPath = path.join(__dirname, "..", ".env");
|
||
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||
const m = line.match(/^([A-Z_]+)=(.*)$/);
|
||
if (m) process.env[m[1]] = m[2].replace(/^"|"$/g, "");
|
||
}
|
||
|
||
const XWIKI_BASE_URL = process.env.XWIKI_BASE_URL;
|
||
const XWIKI_AUTH = "Basic " + Buffer.from(`${process.env.XWIKI_ADMIN_USER}:${process.env.XWIKI_ADMIN_PASSWORD}`).toString("base64");
|
||
const KEYCLOAK_BASE_URL = process.env.KEYCLOAK_BASE_URL;
|
||
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM;
|
||
const ISSUER = `${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}`;
|
||
|
||
const args = process.argv.slice(2);
|
||
const dryRun = args.includes("--dry-run");
|
||
const limitArg = args.find((a) => a.startsWith("--limit="));
|
||
const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : Infinity;
|
||
|
||
async function getKeycloakToken() {
|
||
const res = await fetch(`${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "client_credentials",
|
||
client_id: process.env.KEYCLOAK_ADMIN_CLIENT_ID,
|
||
client_secret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
return json.access_token;
|
||
}
|
||
|
||
async function getAllKeycloakUsers(token) {
|
||
const res = await fetch(`${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users?max=1000`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// XWikiページ名として使えない文字(. / : \ ~)を除去・置換する。
|
||
// 既存のOIDC自動生成ユーザー名は"."を除去する挙動だったため、それに合わせる。
|
||
function toXWikiPageName(username) {
|
||
return username.replace(/\./g, "").replace(/\//g, "/").replace(/:/g, ":").replace(/\\/g, "\").replace(/~/g, "~");
|
||
}
|
||
|
||
async function xwikiRequest(method, urlPath, body, contentType) {
|
||
return fetch(`${XWIKI_BASE_URL}${urlPath}`, {
|
||
method,
|
||
headers: {
|
||
Authorization: XWIKI_AUTH,
|
||
...(contentType ? { "Content-Type": contentType } : {}),
|
||
},
|
||
body,
|
||
});
|
||
}
|
||
|
||
function escapeXml(s) {
|
||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
// REST Query API(/rest/wikis/xwiki/query)はBasic認証だとXWQL実行権限が無く常に空を返す
|
||
// (実機検証済み)。Groovyページ経由でXWQLを実行し、既存のissuer/subject一覧を1回で取得する。
|
||
async function getAllExistingSubjects() {
|
||
const groovyBody = `{{groovy}}
|
||
def xwql = "select oidc.subject, doc.fullName from Document doc, doc.object(XWiki.OIDC.UserClass) as oidc"
|
||
def results = services.query.xwql(xwql).execute()
|
||
results.each { row -> println("SUBJECT_MAP: " + row[0] + " | " + row[1]) }
|
||
{{/groovy}}`;
|
||
const pageXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<page xmlns="http://www.xwiki.org">
|
||
<title>GetAllSubjectsTmp</title>
|
||
<syntax>xwiki/2.1</syntax>
|
||
<content>${escapeXml(groovyBody)}</content>
|
||
</page>`;
|
||
await xwikiRequest("PUT", "/rest/wikis/xwiki/spaces/Sandbox/pages/GetAllSubjectsTmp", pageXml, "application/xml; charset=UTF-8");
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
const res = await xwikiRequest("GET", "/bin/get/Sandbox/GetAllSubjectsTmp");
|
||
const text = await res.text();
|
||
const map = {};
|
||
const re = /SUBJECT_MAP: (\S+) \| (\S+)/g;
|
||
let m;
|
||
while ((m = re.exec(text))) {
|
||
map[m[1]] = m[2];
|
||
}
|
||
await xwikiRequest("DELETE", "/rest/wikis/xwiki/spaces/Sandbox/pages/GetAllSubjectsTmp");
|
||
return map;
|
||
}
|
||
|
||
async function provisionUser(kcUser, existingSubjects) {
|
||
if (!kcUser.username || !kcUser.email) {
|
||
console.warn(` ! skip (username/email欠損): ${kcUser.id}`);
|
||
return "skipped";
|
||
}
|
||
|
||
const existing = existingSubjects[kcUser.id];
|
||
if (existing) {
|
||
console.log(` = 既存: ${kcUser.username} -> ${existing}`);
|
||
return "existing";
|
||
}
|
||
|
||
const pageName = toXWikiPageName(kcUser.username);
|
||
console.log(` + 新規登録: ${kcUser.username} -> XWiki.${pageName}`);
|
||
|
||
if (dryRun) return "would-create";
|
||
|
||
// 1. ページ作成(存在すれば上書きにならないよう、まず存在確認)
|
||
const checkRes = await xwikiRequest("GET", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}`);
|
||
if (checkRes.status === 200) {
|
||
console.warn(` ! ページ名衝突(別ユーザーが既に使用中の可能性): XWiki.${pageName}`);
|
||
return "conflict";
|
||
}
|
||
|
||
const title = `${kcUser.lastName || ""} ${kcUser.firstName || ""}`.trim() || kcUser.username;
|
||
const createXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<page xmlns="http://www.xwiki.org">
|
||
<title>${escapeXml(title)}</title>
|
||
<content></content>
|
||
</page>`;
|
||
const createRes = await xwikiRequest("PUT", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}`, createXml, "application/xml; charset=UTF-8");
|
||
if (createRes.status !== 201 && createRes.status !== 202) {
|
||
console.warn(` ! ページ作成失敗(${createRes.status}): ${pageName}`);
|
||
return "error";
|
||
}
|
||
|
||
// 2. XWiki.XWikiUsersオブジェクト追加
|
||
const addUsersObjRes = await xwikiRequest(
|
||
"POST",
|
||
`/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects`,
|
||
`<?xml version="1.0" encoding="UTF-8"?><object xmlns="http://www.xwiki.org"><className>XWiki.XWikiUsers</className></object>`,
|
||
"application/xml; charset=UTF-8"
|
||
);
|
||
if (addUsersObjRes.status !== 201) {
|
||
console.warn(` ! XWikiUsersオブジェクト追加失敗(${addUsersObjRes.status}): ${pageName}`);
|
||
return "error";
|
||
}
|
||
|
||
// 3. XWikiUsersプロパティ設定
|
||
const usersPropXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<object xmlns="http://www.xwiki.org">
|
||
<className>XWiki.XWikiUsers</className>
|
||
<property name="first_name"><value>${escapeXml(kcUser.firstName || "")}</value></property>
|
||
<property name="last_name"><value>${escapeXml(kcUser.lastName || "")}</value></property>
|
||
<property name="email"><value>${escapeXml(kcUser.email)}</value></property>
|
||
<property name="active"><value>1</value></property>
|
||
</object>`;
|
||
await xwikiRequest("PUT", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects/XWiki.XWikiUsers/0`, usersPropXml, "application/xml; charset=UTF-8");
|
||
|
||
// 4. XWiki.OIDC.UserClassオブジェクト追加
|
||
const addOidcObjRes = await xwikiRequest(
|
||
"POST",
|
||
`/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects`,
|
||
`<?xml version="1.0" encoding="UTF-8"?><object xmlns="http://www.xwiki.org"><className>XWiki.OIDC.UserClass</className></object>`,
|
||
"application/xml; charset=UTF-8"
|
||
);
|
||
if (addOidcObjRes.status !== 201) {
|
||
console.warn(` ! OIDC.UserClassオブジェクト追加失敗(${addOidcObjRes.status}): ${pageName}`);
|
||
return "error";
|
||
}
|
||
|
||
// 5. OIDC.UserClassプロパティ設定(issuer/subject = 既存ログイン済みユーザーと同じキー)
|
||
const oidcPropXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<object xmlns="http://www.xwiki.org">
|
||
<className>XWiki.OIDC.UserClass</className>
|
||
<property name="issuer"><value>${escapeXml(ISSUER)}</value></property>
|
||
<property name="subject"><value>${escapeXml(kcUser.id)}</value></property>
|
||
</object>`;
|
||
const oidcPropRes = await xwikiRequest("PUT", `/rest/wikis/xwiki/spaces/XWiki/pages/${encodeURIComponent(pageName)}/objects/XWiki.OIDC.UserClass/0`, oidcPropXml, "application/xml; charset=UTF-8");
|
||
if (oidcPropRes.status !== 200 && oidcPropRes.status !== 202) {
|
||
console.warn(` ! OIDCプロパティ設定失敗(${oidcPropRes.status}): ${pageName}`);
|
||
return "error";
|
||
}
|
||
|
||
return "created";
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`Issuer: ${ISSUER}`);
|
||
const token = await getKeycloakToken();
|
||
const users = await getAllKeycloakUsers(token);
|
||
console.log(`Keycloakユーザー数: ${users.length}${dryRun ? " [DRY RUN]" : ""}`);
|
||
|
||
const existingSubjects = await getAllExistingSubjects();
|
||
console.log(`XWiki側の既存OIDCユーザー数: ${Object.keys(existingSubjects).length}`);
|
||
|
||
const stats = {};
|
||
let count = 0;
|
||
for (const u of users) {
|
||
if (count >= limit) break;
|
||
count++;
|
||
const result = await provisionUser(u, existingSubjects);
|
||
stats[result] = (stats[result] || 0) + 1;
|
||
}
|
||
|
||
console.log(`\n完了: ${JSON.stringify(stats)}`);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error(e);
|
||
process.exit(1);
|
||
});
|