strapi-plugin-keycloak-realm-users 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +485 -0
  3. package/__tests__/constants.test.mjs +207 -0
  4. package/__tests__/mocks/strapi.mjs +182 -0
  5. package/__tests__/services/audit-log.test.mjs +283 -0
  6. package/__tests__/services/keycloak-client.test.mjs +651 -0
  7. package/__tests__/services/permission.test.mjs +374 -0
  8. package/__tests__/services/realm.test.mjs +415 -0
  9. package/__tests__/services/user.test.mjs +487 -0
  10. package/__tests__/utils/errors.test.mjs +109 -0
  11. package/admin/src/components/Initializer.jsx +14 -0
  12. package/admin/src/components/RealmBadge.jsx +17 -0
  13. package/admin/src/constants.js +14 -0
  14. package/admin/src/hooks/useAuditLogs.js +142 -0
  15. package/admin/src/hooks/useKeycloakRoles.js +182 -0
  16. package/admin/src/hooks/useKeycloakUsers.js +477 -0
  17. package/admin/src/hooks/useRealmAdmins.js +249 -0
  18. package/admin/src/hooks/useRealms.js +269 -0
  19. package/admin/src/index.js +46 -0
  20. package/admin/src/pages/App.jsx +21 -0
  21. package/admin/src/pages/AuditPage/index.jsx +213 -0
  22. package/admin/src/pages/RealmsPage/RealmEditPage.jsx +791 -0
  23. package/admin/src/pages/RealmsPage/RealmListPage.jsx +231 -0
  24. package/admin/src/pages/RealmsPage/index.jsx +7 -0
  25. package/admin/src/pages/UsersPage/UserEditPage.jsx +313 -0
  26. package/admin/src/pages/UsersPage/UserListPage.jsx +437 -0
  27. package/admin/src/pages/UsersPage/index.jsx +7 -0
  28. package/admin/src/pluginId.js +2 -0
  29. package/admin/src/translations/en.json +77 -0
  30. package/admin/src/translations/fr.json +77 -0
  31. package/babel.config.cjs +17 -0
  32. package/coverage/clover.xml +422 -0
  33. package/coverage/coverage-final.json +8 -0
  34. package/coverage/lcov-report/base.css +224 -0
  35. package/coverage/lcov-report/block-navigation.js +87 -0
  36. package/coverage/lcov-report/favicon.png +0 -0
  37. package/coverage/lcov-report/index.html +146 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/src/bootstrap.js.html +346 -0
  43. package/coverage/lcov-report/src/config/index.html +116 -0
  44. package/coverage/lcov-report/src/config/index.js.html +106 -0
  45. package/coverage/lcov-report/src/constants.js.html +850 -0
  46. package/coverage/lcov-report/src/content-types/audit-log/index.html +116 -0
  47. package/coverage/lcov-report/src/content-types/audit-log/index.js.html +94 -0
  48. package/coverage/lcov-report/src/content-types/index.html +116 -0
  49. package/coverage/lcov-report/src/content-types/index.js.html +112 -0
  50. package/coverage/lcov-report/src/content-types/realm-admin/index.html +116 -0
  51. package/coverage/lcov-report/src/content-types/realm-admin/index.js.html +94 -0
  52. package/coverage/lcov-report/src/content-types/realm-config/index.html +116 -0
  53. package/coverage/lcov-report/src/content-types/realm-config/index.js.html +94 -0
  54. package/coverage/lcov-report/src/controllers/audit.js.html +517 -0
  55. package/coverage/lcov-report/src/controllers/index.html +161 -0
  56. package/coverage/lcov-report/src/controllers/index.js.html +112 -0
  57. package/coverage/lcov-report/src/controllers/realm.js.html +1057 -0
  58. package/coverage/lcov-report/src/controllers/user.js.html +1324 -0
  59. package/coverage/lcov-report/src/destroy.js.html +100 -0
  60. package/coverage/lcov-report/src/index.html +116 -0
  61. package/coverage/lcov-report/src/policies/can-access-realm.js.html +163 -0
  62. package/coverage/lcov-report/src/policies/index.html +146 -0
  63. package/coverage/lcov-report/src/policies/index.js.html +106 -0
  64. package/coverage/lcov-report/src/policies/is-authenticated.js.html +100 -0
  65. package/coverage/lcov-report/src/register.js.html +106 -0
  66. package/coverage/lcov-report/src/routes/admin.js.html +844 -0
  67. package/coverage/lcov-report/src/routes/index.html +131 -0
  68. package/coverage/lcov-report/src/routes/index.js.html +109 -0
  69. package/coverage/lcov-report/src/services/audit-log.js.html +673 -0
  70. package/coverage/lcov-report/src/services/index.html +176 -0
  71. package/coverage/lcov-report/src/services/index.js.html +124 -0
  72. package/coverage/lcov-report/src/services/keycloak-client.js.html +2359 -0
  73. package/coverage/lcov-report/src/services/permission.js.html +955 -0
  74. package/coverage/lcov-report/src/services/realm.js.html +1207 -0
  75. package/coverage/lcov-report/src/services/user.js.html +1924 -0
  76. package/coverage/lcov-report/src/utils/errors.js.html +274 -0
  77. package/coverage/lcov-report/src/utils/index.html +116 -0
  78. package/coverage/lcov-report/src/utils/index.js.html +103 -0
  79. package/coverage/lcov.info +804 -0
  80. package/dist/_chunks/App-BaKrvCeS.mjs +1975 -0
  81. package/dist/_chunks/App-DO6syS77.js +1975 -0
  82. package/dist/_chunks/en-Li-XBDe9.mjs +72 -0
  83. package/dist/_chunks/en-aCyfgNfr.js +72 -0
  84. package/dist/_chunks/fr-Cj33Q8jI.js +72 -0
  85. package/dist/_chunks/fr-vLrXph-Z.mjs +72 -0
  86. package/dist/_chunks/index-DwDO4-0C.js +69 -0
  87. package/dist/_chunks/index-jTVd7LdQ.mjs +70 -0
  88. package/dist/admin/index.js +3 -0
  89. package/dist/admin/index.mjs +4 -0
  90. package/dist/server/index.js +3003 -0
  91. package/dist/server/index.mjs +3004 -0
  92. package/jest.config.cjs +50 -0
  93. package/package.json +55 -0
  94. package/server/src/bootstrap.js +87 -0
  95. package/server/src/config/index.js +7 -0
  96. package/server/src/constants.js +255 -0
  97. package/server/src/content-types/audit-log/index.js +3 -0
  98. package/server/src/content-types/audit-log/schema.json +61 -0
  99. package/server/src/content-types/index.js +9 -0
  100. package/server/src/content-types/realm-admin/index.js +3 -0
  101. package/server/src/content-types/realm-admin/schema.json +45 -0
  102. package/server/src/content-types/realm-config/index.js +3 -0
  103. package/server/src/content-types/realm-config/schema.json +56 -0
  104. package/server/src/controllers/audit.js +144 -0
  105. package/server/src/controllers/index.js +9 -0
  106. package/server/src/controllers/realm.js +324 -0
  107. package/server/src/controllers/user.js +413 -0
  108. package/server/src/destroy.js +5 -0
  109. package/server/src/index.js +21 -0
  110. package/server/src/policies/can-access-realm.js +26 -0
  111. package/server/src/policies/index.js +7 -0
  112. package/server/src/policies/is-authenticated.js +5 -0
  113. package/server/src/register.js +7 -0
  114. package/server/src/routes/admin.js +253 -0
  115. package/server/src/routes/index.js +8 -0
  116. package/server/src/services/audit-log.js +196 -0
  117. package/server/src/services/index.js +13 -0
  118. package/server/src/services/keycloak-client.js +758 -0
  119. package/server/src/services/permission.js +290 -0
  120. package/server/src/services/realm.js +374 -0
  121. package/server/src/services/user.js +613 -0
  122. package/server/src/utils/errors.js +63 -0
  123. package/server/src/utils/index.js +6 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * @fileoverview User management service orchestrating Keycloak operations.
3
+ * Provides high-level user management with permission checking and audit logging.
4
+ *
5
+ * @module services/user
6
+ */
7
+
8
+ import {
9
+ PLUGIN_ID,
10
+ SERVICES,
11
+ AUDIT_ACTIONS,
12
+ ERROR_MESSAGES,
13
+ PAGINATION,
14
+ } from '../constants.js';
15
+ import { createSanitizedError } from '../utils/errors.js';
16
+
17
+ /**
18
+ * @typedef {import('./keycloak-client.js').KeycloakUser} KeycloakUser
19
+ * @typedef {import('./keycloak-client.js').KeycloakRole} KeycloakRole
20
+ * @typedef {import('./permission.js').StrapiUser} StrapiUser
21
+ * @typedef {import('./permission.js').RealmPermissions} RealmPermissions
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} PaginatedUsersResult
26
+ * @property {KeycloakUser[]} users - Array of users
27
+ * @property {Object} pagination - Pagination metadata
28
+ * @property {number} pagination.page - Current page number
29
+ * @property {number} pagination.pageSize - Items per page
30
+ * @property {number} pagination.total - Total items
31
+ * @property {number} pagination.pageCount - Total pages
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} BulkImportResult
36
+ * @property {Object[]} success - Successfully created users
37
+ * @property {string} success[].username - Username
38
+ * @property {string} success[].email - Email
39
+ * @property {string} success[].userId - Created user ID
40
+ * @property {Object[]} failed - Failed user creations
41
+ * @property {string} failed[].username - Username
42
+ * @property {string} failed[].email - Email
43
+ * @property {string} failed[].error - Error message
44
+ */
45
+
46
+ /**
47
+ * Creates the user management service.
48
+ *
49
+ * @param {Object} params - Service parameters
50
+ * @param {Object} params.strapi - Strapi instance
51
+ * @returns {Object} User service methods
52
+ */
53
+ const userService = ({ strapi }) => ({
54
+ /**
55
+ * Gets the realm service instance.
56
+ * @returns {Object} Realm service
57
+ * @private
58
+ */
59
+ get realmService() {
60
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
61
+ },
62
+
63
+ /**
64
+ * Gets the Keycloak client service instance.
65
+ * @returns {Object} Keycloak client service
66
+ * @private
67
+ */
68
+ get keycloakClient() {
69
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.KEYCLOAK_CLIENT);
70
+ },
71
+
72
+ /**
73
+ * Gets the permission service instance.
74
+ * @returns {Object} Permission service
75
+ * @private
76
+ */
77
+ get permissionService() {
78
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
79
+ },
80
+
81
+ /**
82
+ * Gets the audit log service instance.
83
+ * @returns {Object} Audit log service
84
+ * @private
85
+ */
86
+ get auditLogService() {
87
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.AUDIT_LOG);
88
+ },
89
+
90
+ /**
91
+ * Validates user permission and returns realm config with credentials.
92
+ * This is the primary permission gate for all user operations.
93
+ *
94
+ * @param {string} realmId - Realm document ID
95
+ * @param {StrapiUser} user - Strapi user requesting access
96
+ * @param {keyof RealmPermissions} permission - Required permission
97
+ * @returns {Promise<Object>} Realm config with clientSecret for API calls
98
+ * @throws {Error} If realm disabled or user lacks permission
99
+ * @private
100
+ *
101
+ * @example
102
+ * const realm = await this.getRealmWithPermission(realmId, user, 'canCreate');
103
+ * // Now safe to call Keycloak APIs
104
+ */
105
+ async getRealmWithPermission(realmId, user, permission) {
106
+ // First check if realm exists and is enabled
107
+ const realmBasic = await this.realmService.findOne(realmId);
108
+
109
+ if (!realmBasic.enabled) {
110
+ throw createSanitizedError('Realm disabled', ERROR_MESSAGES.REALM_DISABLED);
111
+ }
112
+
113
+ // Check user permission
114
+ const hasPermission = await this.permissionService.canAccessRealm(user, realmId, permission);
115
+
116
+ if (!hasPermission) {
117
+ throw createSanitizedError(
118
+ `User ${user.id} lacks ${permission} for realm ${realmId}`,
119
+ ERROR_MESSAGES.REALM_ACCESS_DENIED
120
+ );
121
+ }
122
+
123
+ // Get realm with clientSecret for Keycloak API calls
124
+ return this.realmService.findOneWithSecret(realmId);
125
+ },
126
+
127
+ /**
128
+ * Lists users from a Keycloak realm with pagination.
129
+ *
130
+ * @param {string} realmId - Realm document ID
131
+ * @param {Object} [options={}] - Query options
132
+ * @param {string} [options.search=''] - Search query
133
+ * @param {number} [options.page=1] - Page number (1-indexed)
134
+ * @param {number} [options.pageSize=25] - Items per page
135
+ * @param {StrapiUser} strapiUser - User making the request
136
+ * @returns {Promise<PaginatedUsersResult>} Paginated users
137
+ *
138
+ * @example
139
+ * const { users, pagination } = await userService.listUsers(
140
+ * realmId,
141
+ * { search: 'john', page: 1, pageSize: 10 },
142
+ * ctx.state.user
143
+ * );
144
+ */
145
+ async listUsers(realmId, { search = '', page = 1, pageSize = PAGINATION.PAGE_SIZE } = {}, strapiUser) {
146
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canRead');
147
+
148
+ const first = (page - 1) * pageSize;
149
+ const [users, total] = await Promise.all([
150
+ this.keycloakClient.getUsers(realm, { search, first, max: pageSize }),
151
+ this.keycloakClient.countUsers(realm, { search }),
152
+ ]);
153
+
154
+ return {
155
+ users,
156
+ pagination: {
157
+ page,
158
+ pageSize,
159
+ total,
160
+ pageCount: Math.ceil(total / pageSize),
161
+ },
162
+ };
163
+ },
164
+
165
+ /**
166
+ * Retrieves a single user by Keycloak ID.
167
+ *
168
+ * @param {string} realmId - Realm document ID
169
+ * @param {string} keycloakUserId - Keycloak user ID
170
+ * @param {StrapiUser} strapiUser - User making the request
171
+ * @returns {Promise<KeycloakUser>} User details
172
+ *
173
+ * @example
174
+ * const user = await userService.getUser(realmId, 'kc-user-123', ctx.state.user);
175
+ */
176
+ async getUser(realmId, keycloakUserId, strapiUser) {
177
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canRead');
178
+ return this.keycloakClient.getUserById(realm, keycloakUserId);
179
+ },
180
+
181
+ /**
182
+ * Creates a new user in Keycloak.
183
+ *
184
+ * @param {string} realmId - Realm document ID
185
+ * @param {Object} userData - User data
186
+ * @param {string} userData.username - Required username
187
+ * @param {string} [userData.email] - Email address
188
+ * @param {string} [userData.firstName] - First name
189
+ * @param {string} [userData.lastName] - Last name
190
+ * @param {boolean} [userData.enabled=true] - Account enabled
191
+ * @param {StrapiUser} strapiUser - User making the request
192
+ * @returns {Promise<KeycloakUser>} Created user
193
+ *
194
+ * @example
195
+ * const user = await userService.createUser(
196
+ * realmId,
197
+ * { username: 'newuser', email: 'new@example.com' },
198
+ * ctx.state.user
199
+ * );
200
+ */
201
+ async createUser(realmId, userData, strapiUser) {
202
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canCreate');
203
+
204
+ const result = await this.keycloakClient.createUser(realm, userData);
205
+
206
+ // Log the action
207
+ await this.auditLogService.log({
208
+ realmName: realm.name,
209
+ realmDisplayName: realm.displayName,
210
+ action: AUDIT_ACTIONS.CREATE_USER,
211
+ keycloakUserId: result.userId,
212
+ keycloakUsername: userData.username,
213
+ details: {
214
+ email: userData.email,
215
+ firstName: userData.firstName,
216
+ lastName: userData.lastName
217
+ },
218
+ user: strapiUser,
219
+ });
220
+
221
+ // Fetch and return the created user if we have the ID
222
+ if (result.userId) {
223
+ return this.keycloakClient.getUserById(realm, result.userId);
224
+ }
225
+
226
+ return result;
227
+ },
228
+
229
+ /**
230
+ * Updates an existing user's attributes.
231
+ *
232
+ * @param {string} realmId - Realm document ID
233
+ * @param {string} keycloakUserId - Keycloak user ID
234
+ * @param {Object} userData - Fields to update
235
+ * @param {StrapiUser} strapiUser - User making the request
236
+ * @returns {Promise<KeycloakUser>} Updated user
237
+ *
238
+ * @example
239
+ * const user = await userService.updateUser(
240
+ * realmId,
241
+ * 'kc-user-123',
242
+ * { firstName: 'Updated', lastName: 'Name' },
243
+ * ctx.state.user
244
+ * );
245
+ */
246
+ async updateUser(realmId, keycloakUserId, userData, strapiUser) {
247
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canUpdate');
248
+
249
+ // Get current user data for audit
250
+ const currentUser = await this.keycloakClient.getUserById(realm, keycloakUserId);
251
+
252
+ await this.keycloakClient.updateUser(realm, keycloakUserId, userData);
253
+
254
+ await this.auditLogService.log({
255
+ realmName: realm.name,
256
+ realmDisplayName: realm.displayName,
257
+ action: AUDIT_ACTIONS.UPDATE_USER,
258
+ keycloakUserId,
259
+ keycloakUsername: currentUser.username,
260
+ details: { changes: userData },
261
+ user: strapiUser,
262
+ });
263
+
264
+ return this.keycloakClient.getUserById(realm, keycloakUserId);
265
+ },
266
+
267
+ /**
268
+ * Permanently deletes a user from Keycloak.
269
+ *
270
+ * @param {string} realmId - Realm document ID
271
+ * @param {string} keycloakUserId - Keycloak user ID
272
+ * @param {StrapiUser} strapiUser - User making the request
273
+ * @returns {Promise<{success: boolean}>} Success indicator
274
+ *
275
+ * @example
276
+ * await userService.deleteUser(realmId, 'kc-user-123', ctx.state.user);
277
+ */
278
+ async deleteUser(realmId, keycloakUserId, strapiUser) {
279
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canDelete');
280
+
281
+ // Get user data for audit before deletion
282
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
283
+
284
+ await this.keycloakClient.deleteUser(realm, keycloakUserId);
285
+
286
+ await this.auditLogService.log({
287
+ realmName: realm.name,
288
+ realmDisplayName: realm.displayName,
289
+ action: AUDIT_ACTIONS.DELETE_USER,
290
+ keycloakUserId,
291
+ keycloakUsername: user.username,
292
+ details: { email: user.email },
293
+ user: strapiUser,
294
+ });
295
+
296
+ return { success: true };
297
+ },
298
+
299
+ /**
300
+ * Resets a user's password.
301
+ * Requires canResetPassword permission (separate from canUpdate for compliance).
302
+ *
303
+ * @param {string} realmId - Realm document ID
304
+ * @param {string} keycloakUserId - Keycloak user ID
305
+ * @param {string} password - New password
306
+ * @param {boolean} temporary - Whether password must be changed on next login
307
+ * @param {StrapiUser} strapiUser - User making the request
308
+ * @returns {Promise<{success: boolean}>} Success indicator
309
+ *
310
+ * @example
311
+ * // Set temporary password
312
+ * await userService.resetPassword(realmId, userId, 'TempPass123!', true, user);
313
+ */
314
+ async resetPassword(realmId, keycloakUserId, password, temporary, strapiUser) {
315
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canResetPassword');
316
+
317
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
318
+ await this.keycloakClient.resetPassword(realm, keycloakUserId, password, temporary);
319
+
320
+ await this.auditLogService.log({
321
+ realmName: realm.name,
322
+ realmDisplayName: realm.displayName,
323
+ action: AUDIT_ACTIONS.RESET_PASSWORD,
324
+ keycloakUserId,
325
+ keycloakUsername: user.username,
326
+ details: { temporary },
327
+ user: strapiUser,
328
+ });
329
+
330
+ return { success: true };
331
+ },
332
+
333
+ /**
334
+ * Enables a user account.
335
+ *
336
+ * @param {string} realmId - Realm document ID
337
+ * @param {string} keycloakUserId - Keycloak user ID
338
+ * @param {StrapiUser} strapiUser - User making the request
339
+ * @returns {Promise<{success: boolean}>} Success indicator
340
+ */
341
+ async enableUser(realmId, keycloakUserId, strapiUser) {
342
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canUpdate');
343
+
344
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
345
+ await this.keycloakClient.enableUser(realm, keycloakUserId);
346
+
347
+ await this.auditLogService.log({
348
+ realmName: realm.name,
349
+ realmDisplayName: realm.displayName,
350
+ action: AUDIT_ACTIONS.ENABLE_USER,
351
+ keycloakUserId,
352
+ keycloakUsername: user.username,
353
+ user: strapiUser,
354
+ });
355
+
356
+ return { success: true };
357
+ },
358
+
359
+ /**
360
+ * Disables a user account.
361
+ *
362
+ * @param {string} realmId - Realm document ID
363
+ * @param {string} keycloakUserId - Keycloak user ID
364
+ * @param {StrapiUser} strapiUser - User making the request
365
+ * @returns {Promise<{success: boolean}>} Success indicator
366
+ */
367
+ async disableUser(realmId, keycloakUserId, strapiUser) {
368
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canUpdate');
369
+
370
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
371
+ await this.keycloakClient.disableUser(realm, keycloakUserId);
372
+
373
+ await this.auditLogService.log({
374
+ realmName: realm.name,
375
+ realmDisplayName: realm.displayName,
376
+ action: AUDIT_ACTIONS.DISABLE_USER,
377
+ keycloakUserId,
378
+ keycloakUsername: user.username,
379
+ user: strapiUser,
380
+ });
381
+
382
+ return { success: true };
383
+ },
384
+
385
+ /**
386
+ * Sends an email verification link to the user.
387
+ *
388
+ * @param {string} realmId - Realm document ID
389
+ * @param {string} keycloakUserId - Keycloak user ID
390
+ * @param {StrapiUser} strapiUser - User making the request
391
+ * @returns {Promise<{success: boolean}>} Success indicator
392
+ */
393
+ async sendVerificationEmail(realmId, keycloakUserId, strapiUser) {
394
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canUpdate');
395
+
396
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
397
+ await this.keycloakClient.sendVerificationEmail(realm, keycloakUserId);
398
+
399
+ await this.auditLogService.log({
400
+ realmName: realm.name,
401
+ realmDisplayName: realm.displayName,
402
+ action: AUDIT_ACTIONS.SEND_VERIFY_EMAIL,
403
+ keycloakUserId,
404
+ keycloakUsername: user.username,
405
+ user: strapiUser,
406
+ });
407
+
408
+ return { success: true };
409
+ },
410
+
411
+ /**
412
+ * Sends a password reset email to the user.
413
+ * Requires canResetPassword permission.
414
+ *
415
+ * @param {string} realmId - Realm document ID
416
+ * @param {string} keycloakUserId - Keycloak user ID
417
+ * @param {StrapiUser} strapiUser - User making the request
418
+ * @returns {Promise<{success: boolean}>} Success indicator
419
+ */
420
+ async sendResetPasswordEmail(realmId, keycloakUserId, strapiUser) {
421
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canResetPassword');
422
+
423
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
424
+ await this.keycloakClient.sendResetPasswordEmail(realm, keycloakUserId);
425
+
426
+ await this.auditLogService.log({
427
+ realmName: realm.name,
428
+ realmDisplayName: realm.displayName,
429
+ action: AUDIT_ACTIONS.SEND_RESET_PASSWORD_EMAIL,
430
+ keycloakUserId,
431
+ keycloakUsername: user.username,
432
+ user: strapiUser,
433
+ });
434
+
435
+ return { success: true };
436
+ },
437
+
438
+ /**
439
+ * Retrieves all realm roles.
440
+ *
441
+ * @param {string} realmId - Realm document ID
442
+ * @param {StrapiUser} strapiUser - User making the request
443
+ * @returns {Promise<KeycloakRole[]>} Array of realm roles
444
+ */
445
+ async getRoles(realmId, strapiUser) {
446
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canRead');
447
+ return this.keycloakClient.getRealmRoles(realm);
448
+ },
449
+
450
+ /**
451
+ * Gets roles assigned to a user.
452
+ *
453
+ * @param {string} realmId - Realm document ID
454
+ * @param {string} keycloakUserId - Keycloak user ID
455
+ * @param {StrapiUser} strapiUser - User making the request
456
+ * @returns {Promise<KeycloakRole[]>} User's assigned roles
457
+ */
458
+ async getUserRoles(realmId, keycloakUserId, strapiUser) {
459
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canRead');
460
+ return this.keycloakClient.getUserRoles(realm, keycloakUserId);
461
+ },
462
+
463
+ /**
464
+ * Assigns roles to a user.
465
+ *
466
+ * @param {string} realmId - Realm document ID
467
+ * @param {string} keycloakUserId - Keycloak user ID
468
+ * @param {KeycloakRole[]} roles - Roles to assign
469
+ * @param {StrapiUser} strapiUser - User making the request
470
+ * @returns {Promise<{success: boolean}>} Success indicator
471
+ */
472
+ async assignRoles(realmId, keycloakUserId, roles, strapiUser) {
473
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canManageRoles');
474
+
475
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
476
+ await this.keycloakClient.assignRoles(realm, keycloakUserId, roles);
477
+
478
+ await this.auditLogService.log({
479
+ realmName: realm.name,
480
+ realmDisplayName: realm.displayName,
481
+ action: AUDIT_ACTIONS.ASSIGN_ROLE,
482
+ keycloakUserId,
483
+ keycloakUsername: user.username,
484
+ details: { roles: roles.map((r) => r.name) },
485
+ user: strapiUser,
486
+ });
487
+
488
+ return { success: true };
489
+ },
490
+
491
+ /**
492
+ * Removes roles from a user.
493
+ *
494
+ * @param {string} realmId - Realm document ID
495
+ * @param {string} keycloakUserId - Keycloak user ID
496
+ * @param {KeycloakRole[]} roles - Roles to remove
497
+ * @param {StrapiUser} strapiUser - User making the request
498
+ * @returns {Promise<{success: boolean}>} Success indicator
499
+ */
500
+ async removeRoles(realmId, keycloakUserId, roles, strapiUser) {
501
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canManageRoles');
502
+
503
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
504
+ await this.keycloakClient.removeRoles(realm, keycloakUserId, roles);
505
+
506
+ await this.auditLogService.log({
507
+ realmName: realm.name,
508
+ realmDisplayName: realm.displayName,
509
+ action: AUDIT_ACTIONS.REMOVE_ROLE,
510
+ keycloakUserId,
511
+ keycloakUsername: user.username,
512
+ details: { roles: roles.map((r) => r.name) },
513
+ user: strapiUser,
514
+ });
515
+
516
+ return { success: true };
517
+ },
518
+
519
+ /**
520
+ * Bulk imports users from an array of user data.
521
+ * Processes users sequentially to handle errors gracefully.
522
+ *
523
+ * @param {string} realmId - Realm document ID
524
+ * @param {Object[]} users - Array of user data objects
525
+ * @param {StrapiUser} strapiUser - User making the request
526
+ * @returns {Promise<BulkImportResult>} Import results with success/failed arrays
527
+ *
528
+ * @example
529
+ * const result = await userService.bulkImport(realmId, [
530
+ * { username: 'user1', email: 'user1@example.com' },
531
+ * { username: 'user2', email: 'user2@example.com' }
532
+ * ], ctx.state.user);
533
+ * console.log(`Created ${result.success.length}, Failed ${result.failed.length}`);
534
+ */
535
+ async bulkImport(realmId, users, strapiUser) {
536
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canCreate');
537
+
538
+ const results = {
539
+ success: [],
540
+ failed: [],
541
+ };
542
+
543
+ // Process users sequentially for better error handling
544
+ for (const userData of users) {
545
+ try {
546
+ const result = await this.keycloakClient.createUser(realm, userData);
547
+ results.success.push({
548
+ username: userData.username,
549
+ email: userData.email,
550
+ userId: result.userId,
551
+ });
552
+ } catch (err) {
553
+ results.failed.push({
554
+ username: userData.username,
555
+ email: userData.email,
556
+ error: err.sanitizedMessage || err.message,
557
+ });
558
+ }
559
+ }
560
+
561
+ await this.auditLogService.log({
562
+ realmName: realm.name,
563
+ realmDisplayName: realm.displayName,
564
+ action: AUDIT_ACTIONS.BULK_IMPORT,
565
+ details: {
566
+ totalAttempted: users.length,
567
+ successCount: results.success.length,
568
+ failedCount: results.failed.length,
569
+ },
570
+ user: strapiUser,
571
+ });
572
+
573
+ return results;
574
+ },
575
+
576
+ /**
577
+ * Exports all users from a realm.
578
+ * Fetches users in batches for performance.
579
+ *
580
+ * @param {string} realmId - Realm document ID
581
+ * @param {StrapiUser} strapiUser - User making the request
582
+ * @returns {Promise<KeycloakUser[]>} All users in the realm
583
+ *
584
+ * @example
585
+ * const users = await userService.exportUsers(realmId, ctx.state.user);
586
+ * // Convert to CSV or JSON for download
587
+ */
588
+ async exportUsers(realmId, strapiUser) {
589
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, 'canRead');
590
+
591
+ const allUsers = [];
592
+ let first = 0;
593
+ const max = PAGINATION.EXPORT_BATCH_SIZE;
594
+ let hasMore = true;
595
+
596
+ // Fetch users in batches
597
+ while (hasMore) {
598
+ const users = await this.keycloakClient.getUsers(realm, {
599
+ first,
600
+ max,
601
+ briefRepresentation: false,
602
+ });
603
+
604
+ allUsers.push(...users);
605
+ first += max;
606
+ hasMore = users.length === max;
607
+ }
608
+
609
+ return allUsers;
610
+ },
611
+ });
612
+
613
+ export default userService;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @fileoverview Error utilities for the Keycloak Realm Users plugin.
3
+ * Provides standardized error creation with sanitization support for secure API responses.
4
+ * @module utils/errors
5
+ */
6
+
7
+ /**
8
+ * Creates an error with both internal and sanitized messages.
9
+ * The internal message is used for logging, while the sanitized message is safe to expose to clients.
10
+ *
11
+ * @param {string} internalMessage - Detailed error message for internal logging (may contain sensitive info)
12
+ * @param {string} sanitizedMessage - Safe error message to expose to API consumers
13
+ * @returns {Error} Error object with additional sanitizedMessage property
14
+ *
15
+ * @example
16
+ * // Create an error with sensitive details hidden from client
17
+ * throw createSanitizedError(
18
+ * `Token fetch failed for client ${clientId}: ${response.status}`,
19
+ * ERROR_MESSAGES.KEYCLOAK_AUTH_FAILED
20
+ * );
21
+ */
22
+ export const createSanitizedError = (internalMessage, sanitizedMessage) => {
23
+ const err = new Error(internalMessage);
24
+ err.sanitizedMessage = sanitizedMessage;
25
+ return err;
26
+ };
27
+
28
+ /**
29
+ * Extracts the appropriate error message for API responses.
30
+ * Uses the sanitized message if available, otherwise falls back to the original message.
31
+ *
32
+ * @param {Error} error - The error object to extract message from
33
+ * @param {string} [fallbackMessage] - Optional fallback message if error has no message
34
+ * @returns {string} The safe error message to return to clients
35
+ *
36
+ * @example
37
+ * try {
38
+ * await someOperation();
39
+ * } catch (err) {
40
+ * return ctx.badRequest(getErrorMessage(err, 'Operation failed'));
41
+ * }
42
+ */
43
+ export const getErrorMessage = (error, fallbackMessage = 'An unexpected error occurred') => {
44
+ if (error.sanitizedMessage) {
45
+ return error.sanitizedMessage;
46
+ }
47
+ return error.message || fallbackMessage;
48
+ };
49
+
50
+ /**
51
+ * Checks if an error is a sanitized error (created by createSanitizedError).
52
+ *
53
+ * @param {Error} error - The error to check
54
+ * @returns {boolean} True if the error has a sanitizedMessage property
55
+ *
56
+ * @example
57
+ * if (isSanitizedError(err)) {
58
+ * // Safe to expose error.sanitizedMessage
59
+ * }
60
+ */
61
+ export const isSanitizedError = (error) => {
62
+ return Boolean(error && typeof error.sanitizedMessage === 'string');
63
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @fileoverview Utility modules exports for the Keycloak Realm Users plugin.
3
+ * @module utils
4
+ */
5
+
6
+ export { createSanitizedError, getErrorMessage, isSanitizedError } from './errors.js';