diff --git a/scripts/sync-users-to-keycloak.ts b/scripts/sync-users-to-keycloak.ts new file mode 100644 index 00000000..93b4660f --- /dev/null +++ b/scripts/sync-users-to-keycloak.ts @@ -0,0 +1,327 @@ +import "dotenv/config"; +import { AppDataSource } from "../src/database/data-source"; +import { Profile } from "../src/entities/Profile"; +import { RoleKeycloak } from "../src/entities/RoleKeycloak"; +import * as keycloak from "../src/keycloak/index"; + +// Default role for users without roles +const DEFAULT_ROLE = "USER"; + +interface SyncOptions { + dryRun: boolean; + delay: number; +} + +interface SyncResult { + total: number; + deleted: number; + created: number; + failed: number; + skipped: number; + errors: Array<{ + profileId: string; + citizenId: string; + error: string; + }>; +} + +/** + * Delete all Keycloak users (except super_admin) + */ +async function deleteAllKeycloakUsers(dryRun: boolean): Promise { + const users = await keycloak.getUserList("0", "-1"); + let count = 0; + let skipped = 0; + + if (!users || typeof users === "boolean") { + return 0; + } + + for (const user of users) { + // Skip super_admin user + if (user.username === "super_admin") { + console.log(`Skipped super_admin user (protected)`); + skipped++; + continue; + } + + if (!dryRun) { + await keycloak.deleteUser(user.id); + } + count++; + console.log(`[${count}/${users.length}] Deleted user: ${user.username}`); + } + + console.log(`Skipped ${skipped} protected user(s)`); + return count; +} + +/** + * Sync profiles to Keycloak + */ +async function syncProfiles(options: SyncOptions): Promise { + const result: SyncResult = { + total: 0, + deleted: 0, + created: 0, + failed: 0, + skipped: 0, + errors: [], + }; + + // Fetch all Keycloak roles first + const keycloakRoles = await keycloak.getRoles(); + if (!keycloakRoles || typeof keycloakRoles === "boolean") { + throw new Error("Failed to get Keycloak roles"); + } + const roleMap = new Map(keycloakRoles.map((r) => [r.name, r.id])); + + // Log all available Keycloak roles for debugging + console.log("Available Keycloak roles:", Array.from(roleMap.keys()).sort()); + console.log(""); + + // Get repositories + const profileRepo = AppDataSource.getRepository(Profile); + const roleKeycloakRepo = AppDataSource.getRepository(RoleKeycloak); + + // Query all active profiles (not just those with keycloak set) + const profiles = await profileRepo + .createQueryBuilder("profile") + .leftJoinAndSelect("profile.roleKeycloaks", "roleKeycloak") + .andWhere("profile.isActive = :isActive", { isActive: true }) + .getMany(); + + result.total = profiles.length; + + for (const profile of profiles) { + const index = result.created + result.failed + result.skipped + 1; + + try { + // Validate required fields + if (!profile.citizenId) { + console.log(`[${index}/${result.total}] Skipped: Missing citizenId`); + result.skipped++; + continue; + } + + // Check if user already exists + const existingUser = await keycloak.getUserByUsername(profile.citizenId); + if (existingUser && existingUser.length > 0) { + const existingUserId = existingUser[0].id; + console.log( + `[${index}/${result.total}] User ${profile.citizenId} already exists in Keycloak (ID: ${existingUserId})`, + ); + + // Update profile.keycloak with existing Keycloak ID + if (!options.dryRun) { + await profileRepo.update(profile.id, { keycloak: existingUserId }); + console.log(` -> Updated profile.keycloak: ${existingUserId}`); + } + + result.skipped++; + continue; + } + + // Handle roles: assign USER role if no roles exist + let rolesToAssign = profile.roleKeycloaks || []; + let needsDefaultRole = false; + + if (rolesToAssign.length === 0) { + needsDefaultRole = true; + console.log( + `[${index}/${result.total}] No roles found for ${profile.citizenId}, will assign ${DEFAULT_ROLE}`, + ); + + // Check if USER role exists in Keycloak + if (!roleMap.has(DEFAULT_ROLE)) { + console.log( + `[${index}/${result.total}] ERROR: ${DEFAULT_ROLE} role not found in Keycloak`, + ); + result.failed++; + result.errors.push({ + profileId: profile.id, + citizenId: profile.citizenId, + error: `${DEFAULT_ROLE} role not found in Keycloak`, + }); + continue; + } + + // In live mode, create role in database + if (!options.dryRun) { + // Check if USER role record exists in database + const userRoleRecord = await roleKeycloakRepo.findOne({ + where: { name: DEFAULT_ROLE }, + }); + + // Assign role to profile in database + if (userRoleRecord) { + profile.roleKeycloaks = [userRoleRecord]; + await profileRepo.save(profile); + rolesToAssign = [userRoleRecord]; + } + } + } + + if (!options.dryRun) { + // Create user in Keycloak + const userId = await keycloak.createUser(profile.citizenId, "P@ssw0rd", { + firstName: profile.firstName || "", + lastName: profile.lastName || "", + // email: profile.email || undefined, + enabled: true, + }); + + if (typeof userId === "string") { + // Update profile.keycloak with new ID + await profileRepo.update(profile.id, { keycloak: userId }); + + // Assign roles to user in Keycloak + // Track which roles exist in Keycloak and which don't + const validRoles: { id: string; name: string }[] = []; + const missingRoles: string[] = []; + + for (const rk of rolesToAssign) { + const roleId = roleMap.get(rk.name); + if (roleId) { + validRoles.push({ id: roleId, name: rk.name }); + } else { + missingRoles.push(rk.name); + } + } + + // Warn about missing roles + if (missingRoles.length > 0) { + console.log(` [WARNING] Roles not found in Keycloak: ${missingRoles.join(", ")}`); + } + + if (validRoles.length > 0) { + const addRolesResult = await keycloak.addUserRoles(userId, validRoles); + if (addRolesResult === false) { + console.log(` [WARNING] Failed to assign roles to user ${profile.citizenId}`); + } + const roleNames = validRoles.map((r) => r.name).join(", "); + console.log( + `[${index}/${result.total}] Created: ${profile.citizenId} -> ${userId} [Roles: ${roleNames}]`, + ); + } else { + console.log( + `[${index}/${result.total}] Created: ${profile.citizenId} -> ${userId} [No roles assigned]`, + ); + } + + result.created++; + } else { + console.log(`[${index}/${result.total}] Failed: ${profile.citizenId}`, userId); + result.failed++; + result.errors.push({ + profileId: profile.id, + citizenId: profile.citizenId, + error: JSON.stringify(userId), + }); + } + } else { + // Dry-run mode - check which roles are valid + const validRoles: string[] = []; + const missingRoles: string[] = []; + + for (const rk of rolesToAssign) { + if (roleMap.has(rk.name)) { + validRoles.push(rk.name); + } else { + missingRoles.push(rk.name); + } + } + + if (needsDefaultRole && roleMap.has(DEFAULT_ROLE)) { + validRoles.push(DEFAULT_ROLE); + } else if (needsDefaultRole) { + missingRoles.push(DEFAULT_ROLE); + } + + const roleNames = validRoles.length > 0 ? validRoles.join(", ") : "None"; + console.log( + `[DRY-RUN ${index}/${result.total}] Would create: ${profile.citizenId} [Roles: ${roleNames}]`, + ); + + if (missingRoles.length > 0) { + console.log(` [WARNING] Roles not found in Keycloak: ${missingRoles.join(", ")}`); + } + + result.created++; + } + + // Delay to avoid rate limiting + if (options.delay > 0) { + await new Promise((resolve) => setTimeout(resolve, options.delay)); + } + } catch (error) { + console.log(`[${index}/${result.total}] Error: ${profile.citizenId}`, error); + result.failed++; + result.errors.push({ + profileId: profile.id, + citizenId: profile.citizenId, + error: String(error), + }); + } + } + + return result; +} + +/** + * Main function + */ +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + + console.log("=".repeat(60)); + console.log("Keycloak User Sync Script"); + console.log("=".repeat(60)); + console.log(`Mode: ${dryRun ? "DRY-RUN (no changes)" : "LIVE"}`); + console.log(""); + + // Initialize database + await AppDataSource.initialize(); + console.log("Database connected"); + + // Validate Keycloak connection + await keycloak.getToken(); + console.log("Keycloak connected"); + console.log(""); + + // Step 1: Delete existing users + console.log("Step 1: Deleting existing Keycloak users..."); + const deletedCount = await deleteAllKeycloakUsers(dryRun); + console.log(`Deleted ${deletedCount} users\n`); + + // Step 2: Sync profiles + console.log("Step 2: Creating users from profiles..."); + const result = await syncProfiles({ dryRun, delay: 100 }); + console.log(""); + + // Summary + console.log("=".repeat(60)); + console.log("Summary:"); + console.log(` Total profiles: ${result.total}`); + console.log(` Deleted users: ${deletedCount}`); + console.log(` Created users: ${result.created}`); + console.log(` Failed: ${result.failed}`); + console.log(` Skipped: ${result.skipped}`); + console.log("=".repeat(60)); + + if (result.errors.length > 0) { + console.log("\nErrors:"); + result.errors.forEach((e) => { + console.log(` ${e.citizenId}: ${e.error}`); + }); + } + + await AppDataSource.destroy(); +} + +main().catch(console.error); + +// add this line to package.json scripts section: +// "sync-keycloak": "ts-node scripts/sync-users-to-keycloak-null-only.ts", +// "sync-keycloak:dry": "ts-node scripts/sync-users-to-keycloak-null-only.ts --dry-run"