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.
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/__tests__/constants.test.mjs +207 -0
- package/__tests__/mocks/strapi.mjs +182 -0
- package/__tests__/services/audit-log.test.mjs +283 -0
- package/__tests__/services/keycloak-client.test.mjs +651 -0
- package/__tests__/services/permission.test.mjs +374 -0
- package/__tests__/services/realm.test.mjs +415 -0
- package/__tests__/services/user.test.mjs +487 -0
- package/__tests__/utils/errors.test.mjs +109 -0
- package/admin/src/components/Initializer.jsx +14 -0
- package/admin/src/components/RealmBadge.jsx +17 -0
- package/admin/src/constants.js +14 -0
- package/admin/src/hooks/useAuditLogs.js +142 -0
- package/admin/src/hooks/useKeycloakRoles.js +182 -0
- package/admin/src/hooks/useKeycloakUsers.js +477 -0
- package/admin/src/hooks/useRealmAdmins.js +249 -0
- package/admin/src/hooks/useRealms.js +269 -0
- package/admin/src/index.js +46 -0
- package/admin/src/pages/App.jsx +21 -0
- package/admin/src/pages/AuditPage/index.jsx +213 -0
- package/admin/src/pages/RealmsPage/RealmEditPage.jsx +791 -0
- package/admin/src/pages/RealmsPage/RealmListPage.jsx +231 -0
- package/admin/src/pages/RealmsPage/index.jsx +7 -0
- package/admin/src/pages/UsersPage/UserEditPage.jsx +313 -0
- package/admin/src/pages/UsersPage/UserListPage.jsx +437 -0
- package/admin/src/pages/UsersPage/index.jsx +7 -0
- package/admin/src/pluginId.js +2 -0
- package/admin/src/translations/en.json +77 -0
- package/admin/src/translations/fr.json +77 -0
- package/babel.config.cjs +17 -0
- package/coverage/clover.xml +422 -0
- package/coverage/coverage-final.json +8 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +146 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/bootstrap.js.html +346 -0
- package/coverage/lcov-report/src/config/index.html +116 -0
- package/coverage/lcov-report/src/config/index.js.html +106 -0
- package/coverage/lcov-report/src/constants.js.html +850 -0
- package/coverage/lcov-report/src/content-types/audit-log/index.html +116 -0
- package/coverage/lcov-report/src/content-types/audit-log/index.js.html +94 -0
- package/coverage/lcov-report/src/content-types/index.html +116 -0
- package/coverage/lcov-report/src/content-types/index.js.html +112 -0
- package/coverage/lcov-report/src/content-types/realm-admin/index.html +116 -0
- package/coverage/lcov-report/src/content-types/realm-admin/index.js.html +94 -0
- package/coverage/lcov-report/src/content-types/realm-config/index.html +116 -0
- package/coverage/lcov-report/src/content-types/realm-config/index.js.html +94 -0
- package/coverage/lcov-report/src/controllers/audit.js.html +517 -0
- package/coverage/lcov-report/src/controllers/index.html +161 -0
- package/coverage/lcov-report/src/controllers/index.js.html +112 -0
- package/coverage/lcov-report/src/controllers/realm.js.html +1057 -0
- package/coverage/lcov-report/src/controllers/user.js.html +1324 -0
- package/coverage/lcov-report/src/destroy.js.html +100 -0
- package/coverage/lcov-report/src/index.html +116 -0
- package/coverage/lcov-report/src/policies/can-access-realm.js.html +163 -0
- package/coverage/lcov-report/src/policies/index.html +146 -0
- package/coverage/lcov-report/src/policies/index.js.html +106 -0
- package/coverage/lcov-report/src/policies/is-authenticated.js.html +100 -0
- package/coverage/lcov-report/src/register.js.html +106 -0
- package/coverage/lcov-report/src/routes/admin.js.html +844 -0
- package/coverage/lcov-report/src/routes/index.html +131 -0
- package/coverage/lcov-report/src/routes/index.js.html +109 -0
- package/coverage/lcov-report/src/services/audit-log.js.html +673 -0
- package/coverage/lcov-report/src/services/index.html +176 -0
- package/coverage/lcov-report/src/services/index.js.html +124 -0
- package/coverage/lcov-report/src/services/keycloak-client.js.html +2359 -0
- package/coverage/lcov-report/src/services/permission.js.html +955 -0
- package/coverage/lcov-report/src/services/realm.js.html +1207 -0
- package/coverage/lcov-report/src/services/user.js.html +1924 -0
- package/coverage/lcov-report/src/utils/errors.js.html +274 -0
- package/coverage/lcov-report/src/utils/index.html +116 -0
- package/coverage/lcov-report/src/utils/index.js.html +103 -0
- package/coverage/lcov.info +804 -0
- package/dist/_chunks/App-BaKrvCeS.mjs +1975 -0
- package/dist/_chunks/App-DO6syS77.js +1975 -0
- package/dist/_chunks/en-Li-XBDe9.mjs +72 -0
- package/dist/_chunks/en-aCyfgNfr.js +72 -0
- package/dist/_chunks/fr-Cj33Q8jI.js +72 -0
- package/dist/_chunks/fr-vLrXph-Z.mjs +72 -0
- package/dist/_chunks/index-DwDO4-0C.js +69 -0
- package/dist/_chunks/index-jTVd7LdQ.mjs +70 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/server/index.js +3003 -0
- package/dist/server/index.mjs +3004 -0
- package/jest.config.cjs +50 -0
- package/package.json +55 -0
- package/server/src/bootstrap.js +87 -0
- package/server/src/config/index.js +7 -0
- package/server/src/constants.js +255 -0
- package/server/src/content-types/audit-log/index.js +3 -0
- package/server/src/content-types/audit-log/schema.json +61 -0
- package/server/src/content-types/index.js +9 -0
- package/server/src/content-types/realm-admin/index.js +3 -0
- package/server/src/content-types/realm-admin/schema.json +45 -0
- package/server/src/content-types/realm-config/index.js +3 -0
- package/server/src/content-types/realm-config/schema.json +56 -0
- package/server/src/controllers/audit.js +144 -0
- package/server/src/controllers/index.js +9 -0
- package/server/src/controllers/realm.js +324 -0
- package/server/src/controllers/user.js +413 -0
- package/server/src/destroy.js +5 -0
- package/server/src/index.js +21 -0
- package/server/src/policies/can-access-realm.js +26 -0
- package/server/src/policies/index.js +7 -0
- package/server/src/policies/is-authenticated.js +5 -0
- package/server/src/register.js +7 -0
- package/server/src/routes/admin.js +253 -0
- package/server/src/routes/index.js +8 -0
- package/server/src/services/audit-log.js +196 -0
- package/server/src/services/index.js +13 -0
- package/server/src/services/keycloak-client.js +758 -0
- package/server/src/services/permission.js +290 -0
- package/server/src/services/realm.js +374 -0
- package/server/src/services/user.js +613 -0
- package/server/src/utils/errors.js +63 -0
- 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
|
+
};
|