Skip to content

Commit 98bb8e9

Browse files
authored
feat: add Keycloak cleanup script (#2426)
1 parent bd68eec commit 98bb8e9

File tree

2 files changed

+207
-1
lines changed

2 files changed

+207
-1
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import 'dotenv/config';
2+
import * as process from 'node:process';
3+
import postgres from 'postgres';
4+
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
5+
import { inArray } from 'drizzle-orm';
6+
import { startOfMonth, subDays } from 'date-fns';
7+
import { pino } from 'pino';
8+
import { buildDatabaseConnectionConfig } from '../core/plugins/database.js';
9+
import Keycloak from '../core/services/Keycloak.js';
10+
import * as schema from '../db/schema.js';
11+
import { getConfig } from './get-config.js';
12+
13+
// The way our system works is that when a user logins for the first time (this includes right after signups) or
14+
// is invited to an organization, they are added to the database.
15+
16+
// Number of users to retrieve from Keycloak per page
17+
const NUMBER_OF_USERS_PER_PAGE = 500;
18+
// Any user created after this date will be ignored
19+
const CREATED_NOT_AFTER = startOfMonth(subDays(new Date(), 60));
20+
21+
const {
22+
realm,
23+
loginRealm,
24+
adminUser,
25+
adminPassword,
26+
clientId,
27+
apiUrl,
28+
databaseConnectionUrl,
29+
databaseTlsCa,
30+
databaseTlsCert,
31+
databaseTlsKey,
32+
} = getConfig();
33+
34+
const keycloakClient = new Keycloak({
35+
apiUrl,
36+
realm: loginRealm,
37+
clientId,
38+
adminUser,
39+
adminPassword,
40+
logger: pino(),
41+
});
42+
43+
const findUsersPaged = keycloakClient.client.users.makeRequest<UserQuery, UserRepresentation[]>({
44+
method: 'GET',
45+
queryParamKeys: ['first', 'max'],
46+
});
47+
48+
try {
49+
const start = performance.now();
50+
51+
// Ensure Keycloak is up and running
52+
console.log('Retrieving users from Keycloak...');
53+
await keycloakClient.authenticateClient();
54+
55+
// Retrieve all the users paged
56+
const keycloakUsers = await getKeycloakUsers();
57+
if (keycloakUsers.length === 0) {
58+
console.log();
59+
console.log('All Keycloak users have accepted the terms and conditions!');
60+
61+
// eslint-disable-next-line unicorn/no-process-exit
62+
process.exit(0);
63+
}
64+
65+
// Create the database connection. TLS is optional.
66+
const connectionConfig = await buildDatabaseConnectionConfig({
67+
tls:
68+
databaseTlsCa || databaseTlsCert || databaseTlsKey
69+
? { ca: databaseTlsCa, cert: databaseTlsCert, key: databaseTlsKey }
70+
: undefined,
71+
});
72+
73+
const queryConnection = postgres(databaseConnectionUrl, { ...connectionConfig });
74+
75+
// Retrieve the existing users from the database
76+
console.log('Retrieving users from database...');
77+
78+
const db = drizzle(queryConnection, { schema: { ...schema } });
79+
const dbUsers = await getExistingDatabaseUsers(
80+
db,
81+
keycloakUsers.map((user) => user.email),
82+
);
83+
84+
await queryConnection.end({
85+
timeout: 1,
86+
});
87+
88+
// Remove any user that exists in Keycloak but doesn't exist in the database
89+
console.log();
90+
console.log('Removing Keycloak users not found in the database...');
91+
const numberOfRemovedUsers = await removeRelevantKeycloakUsers(keycloakUsers, dbUsers);
92+
93+
// Cleanup completed
94+
const duration = ((performance.now() - start) / 1000).toFixed(3);
95+
console.log(`Cleanup completed after ${duration} seconds. A total of ${numberOfRemovedUsers} user(s) were removed.`);
96+
} catch (err) {
97+
console.error(err);
98+
// eslint-disable-next-line unicorn/no-process-exit
99+
process.exit(1);
100+
}
101+
102+
async function getKeycloakUsers(): Promise<UserRepresentation[]> {
103+
const start = performance.now();
104+
105+
let startIndex = 0;
106+
const users: UserRepresentation[] = [];
107+
while (true) {
108+
console.log(`\tFetching range ${startIndex + 1}-${startIndex + NUMBER_OF_USERS_PER_PAGE}`);
109+
const chunkOfUsers = await findUsersPaged({
110+
first: startIndex,
111+
max: NUMBER_OF_USERS_PER_PAGE,
112+
realm,
113+
});
114+
115+
// We only want to keep the users that have not verified their email address, not accepted the terms and
116+
// conditions and were created before `CREATED_NOT_AFTER`
117+
users.push(
118+
...chunkOfUsers.filter((user) => {
119+
const createdAt = new Date(user.createdTimestamp);
120+
const needsToAcceptTermsAndConditions =
121+
user.requiredActions?.some((action) => action === RequiredAction.TERMS_AND_CONDITIONS) ?? false;
122+
123+
return createdAt < CREATED_NOT_AFTER && user.emailVerified !== true && needsToAcceptTermsAndConditions;
124+
}),
125+
);
126+
127+
if (chunkOfUsers.length < NUMBER_OF_USERS_PER_PAGE) {
128+
// We reached the end of the users list
129+
break;
130+
}
131+
132+
startIndex += NUMBER_OF_USERS_PER_PAGE;
133+
}
134+
135+
const duration = ((performance.now() - start) / 1000).toFixed(3);
136+
console.log(`\t${users.length} users loaded from Keycloak after ${duration} seconds`);
137+
return users;
138+
}
139+
140+
async function getExistingDatabaseUsers(
141+
db: PostgresJsDatabase<typeof schema>,
142+
emails: string[],
143+
): Promise<DbUserRepresentation[]> {
144+
const start = performance.now();
145+
const users = await db
146+
.select({
147+
id: schema.users.id,
148+
email: schema.users.email,
149+
})
150+
.from(schema.users)
151+
.where(inArray(schema.users.email, emails))
152+
.execute();
153+
154+
const duration = ((performance.now() - start) / 1000).toFixed(3);
155+
console.log(`\t${users.length} users loaded from the database after ${duration} seconds`);
156+
return users;
157+
}
158+
159+
async function removeRelevantKeycloakUsers(keycloakUsers: UserRepresentation[], dbUsers: DbUserRepresentation[]) {
160+
let numberOfRemovedUsers = 0;
161+
for (const user of keycloakUsers) {
162+
const dbUser = dbUsers.find((u) => u.email.toLowerCase() === user.email.toLowerCase());
163+
if (dbUser) {
164+
// The user exists in the database, we don't need to delete the Keycloak user
165+
continue;
166+
}
167+
168+
try {
169+
await keycloakClient.client.users.del({
170+
id: user.id,
171+
realm,
172+
});
173+
174+
numberOfRemovedUsers++;
175+
console.log(`\t- User "${user.email}" removed from Keycloak successfully`);
176+
} catch (err) {
177+
console.warn(`\t- Failed to remove user "${user.email}" from Keycloak: ${err}`);
178+
}
179+
}
180+
181+
return numberOfRemovedUsers;
182+
}
183+
184+
interface UserQuery {
185+
first: number;
186+
max: number;
187+
}
188+
189+
enum RequiredAction {
190+
TERMS_AND_CONDITIONS = 'TERMS_AND_CONDITIONS',
191+
}
192+
193+
interface UserRepresentation {
194+
id: string;
195+
createdTimestamp: number;
196+
email: string;
197+
emailVerified?: boolean;
198+
requiredActions?: RequiredAction[];
199+
attributes?: Record<string, unknown>;
200+
}
201+
202+
interface DbUserRepresentation {
203+
id: string;
204+
email: string;
205+
}

controlplane/src/bin/migrate-groups.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dotenv/config';
12
import * as process from 'node:process';
23
import postgres from 'postgres';
34
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
@@ -51,7 +52,7 @@ try {
5152
// Ensure keycloak is up and running
5253
await keycloakClient.authenticateClient();
5354

54-
// Create database connection. TLS is optionally.
55+
// Create the database connection. TLS is optional.
5556
const connectionConfig = await buildDatabaseConnectionConfig({
5657
tls:
5758
databaseTlsCa || databaseTlsCert || databaseTlsKey

0 commit comments

Comments
 (0)