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,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Permission management service for realm access control.
|
|
3
|
+
* Handles permission checking and realm admin assignments.
|
|
4
|
+
*
|
|
5
|
+
* @module services/permission
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CONTENT_TYPES,
|
|
10
|
+
DEFAULT_REALM_PERMISSIONS,
|
|
11
|
+
STRAPI_SUPER_ADMIN_ROLE,
|
|
12
|
+
} from '../constants.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} RealmPermissions
|
|
16
|
+
* @property {boolean} canRead - Can view users in the realm
|
|
17
|
+
* @property {boolean} canCreate - Can create new users
|
|
18
|
+
* @property {boolean} canUpdate - Can modify users (includes enable/disable)
|
|
19
|
+
* @property {boolean} canDelete - Can permanently delete users
|
|
20
|
+
* @property {boolean} canManageRoles - Can assign/remove realm roles
|
|
21
|
+
* @property {boolean} canResetPassword - Can reset passwords and send reset emails
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} RealmAdminAssignment
|
|
26
|
+
* @property {string} documentId - Assignment document ID
|
|
27
|
+
* @property {number} strapiUserId - Strapi admin user ID
|
|
28
|
+
* @property {string} strapiUserEmail - Strapi admin email
|
|
29
|
+
* @property {RealmPermissions} permissions - Granted permissions
|
|
30
|
+
* @property {Object} [realmConfig] - Populated realm configuration
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} StrapiUser
|
|
35
|
+
* @property {number} id - User ID
|
|
36
|
+
* @property {string} email - User email
|
|
37
|
+
* @property {Object[]} roles - Array of role objects
|
|
38
|
+
* @property {string} roles[].code - Role code
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Full permissions object for super admins.
|
|
43
|
+
* @constant {RealmPermissions}
|
|
44
|
+
* @private
|
|
45
|
+
*/
|
|
46
|
+
const SUPER_ADMIN_PERMISSIONS = {
|
|
47
|
+
canRead: true,
|
|
48
|
+
canCreate: true,
|
|
49
|
+
canUpdate: true,
|
|
50
|
+
canDelete: true,
|
|
51
|
+
canManageRoles: true,
|
|
52
|
+
canResetPassword: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates the permission service.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} params - Service parameters
|
|
59
|
+
* @param {Object} params.strapi - Strapi instance
|
|
60
|
+
* @returns {Object} Permission service methods
|
|
61
|
+
*/
|
|
62
|
+
const permissionService = ({ strapi }) => ({
|
|
63
|
+
/**
|
|
64
|
+
* Checks if a user has the Strapi super admin role.
|
|
65
|
+
* Super admins have unrestricted access to all realms and operations.
|
|
66
|
+
*
|
|
67
|
+
* @param {StrapiUser} user - Strapi user object with roles
|
|
68
|
+
* @returns {boolean} True if user is a super admin
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* if (permissionService.isSuperAdmin(ctx.state.user)) {
|
|
72
|
+
* // Allow unrestricted access
|
|
73
|
+
* }
|
|
74
|
+
*/
|
|
75
|
+
isSuperAdmin(user) {
|
|
76
|
+
if (!user || !user.roles) return false;
|
|
77
|
+
return user.roles.some((role) => role.code === STRAPI_SUPER_ADMIN_ROLE);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Retrieves the realm admin assignment for a specific user and realm.
|
|
82
|
+
*
|
|
83
|
+
* @param {number} strapiUserId - Strapi user ID
|
|
84
|
+
* @param {string} realmConfigId - Realm document ID
|
|
85
|
+
* @returns {Promise<RealmAdminAssignment|null>} Assignment or null if none exists
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* const assignment = await permissionService.getRealmAdminAssignment(user.id, realmId);
|
|
89
|
+
* if (assignment?.permissions.canCreate) {
|
|
90
|
+
* // User can create
|
|
91
|
+
* }
|
|
92
|
+
*/
|
|
93
|
+
async getRealmAdminAssignment(strapiUserId, realmConfigId) {
|
|
94
|
+
const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
|
|
95
|
+
filters: {
|
|
96
|
+
strapiUserId,
|
|
97
|
+
realmConfig: { documentId: realmConfigId },
|
|
98
|
+
},
|
|
99
|
+
populate: ['realmConfig'],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return assignments[0] || null;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Checks if a user has a specific permission for a realm.
|
|
107
|
+
*
|
|
108
|
+
* @param {StrapiUser} user - Strapi user object
|
|
109
|
+
* @param {string} realmConfigId - Realm document ID
|
|
110
|
+
* @param {keyof RealmPermissions} [permission='canRead'] - Permission to check
|
|
111
|
+
* @returns {Promise<boolean>} True if user has the permission
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* const canDelete = await permissionService.canAccessRealm(user, realmId, 'canDelete');
|
|
115
|
+
* if (!canDelete) {
|
|
116
|
+
* throw new ForbiddenError();
|
|
117
|
+
* }
|
|
118
|
+
*/
|
|
119
|
+
async canAccessRealm(user, realmConfigId, permission = 'canRead') {
|
|
120
|
+
// Super admins have all permissions
|
|
121
|
+
if (this.isSuperAdmin(user)) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const assignment = await this.getRealmAdminAssignment(user.id, realmConfigId);
|
|
126
|
+
|
|
127
|
+
if (!assignment) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const permissions = assignment.permissions || DEFAULT_REALM_PERMISSIONS;
|
|
132
|
+
return permissions[permission] === true;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Retrieves all realms accessible by a user with their permissions.
|
|
137
|
+
* Super admins see all enabled realms with full permissions.
|
|
138
|
+
* Regular users see only realms they're assigned to.
|
|
139
|
+
*
|
|
140
|
+
* @param {StrapiUser} user - Strapi user object
|
|
141
|
+
* @returns {Promise<Array<Object & {permissions: RealmPermissions}>>} Accessible realms with permissions
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* const realms = await permissionService.getAccessibleRealms(ctx.state.user);
|
|
145
|
+
* // Returns realms with permissions attached
|
|
146
|
+
*/
|
|
147
|
+
async getAccessibleRealms(user) {
|
|
148
|
+
// Super admins see all enabled realms with full permissions
|
|
149
|
+
if (this.isSuperAdmin(user)) {
|
|
150
|
+
const allRealms = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
|
|
151
|
+
filters: { enabled: true },
|
|
152
|
+
sort: { displayName: 'asc' },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return allRealms.map((realm) => ({
|
|
156
|
+
...realm,
|
|
157
|
+
permissions: SUPER_ADMIN_PERMISSIONS,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Regular users see only assigned realms
|
|
162
|
+
const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
|
|
163
|
+
filters: {
|
|
164
|
+
strapiUserId: user.id,
|
|
165
|
+
realmConfig: { enabled: true },
|
|
166
|
+
},
|
|
167
|
+
populate: ['realmConfig'],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return assignments
|
|
171
|
+
.filter((assignment) => assignment.realmConfig)
|
|
172
|
+
.map((assignment) => ({
|
|
173
|
+
...assignment.realmConfig,
|
|
174
|
+
permissions: assignment.permissions || DEFAULT_REALM_PERMISSIONS,
|
|
175
|
+
}));
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Gets a user's specific permissions for a realm.
|
|
180
|
+
*
|
|
181
|
+
* @param {StrapiUser} user - Strapi user object
|
|
182
|
+
* @param {string} realmConfigId - Realm document ID
|
|
183
|
+
* @returns {Promise<RealmPermissions|null>} User's permissions or null if no access
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* const permissions = await permissionService.getRealmPermissions(user, realmId);
|
|
187
|
+
* if (permissions?.canUpdate) {
|
|
188
|
+
* // Show edit button
|
|
189
|
+
* }
|
|
190
|
+
*/
|
|
191
|
+
async getRealmPermissions(user, realmConfigId) {
|
|
192
|
+
if (this.isSuperAdmin(user)) {
|
|
193
|
+
return SUPER_ADMIN_PERMISSIONS;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const assignment = await this.getRealmAdminAssignment(user.id, realmConfigId);
|
|
197
|
+
return assignment?.permissions || null;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Assigns a Strapi user to a realm with specified permissions.
|
|
202
|
+
* If an assignment already exists, it updates the permissions.
|
|
203
|
+
*
|
|
204
|
+
* @param {number} strapiUserId - Strapi user ID to assign
|
|
205
|
+
* @param {string} strapiUserEmail - User's email for reference
|
|
206
|
+
* @param {string} realmConfigId - Realm document ID
|
|
207
|
+
* @param {Partial<RealmPermissions>} [permissions={}] - Permissions to grant
|
|
208
|
+
* @returns {Promise<RealmAdminAssignment>} Created or updated assignment
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* await permissionService.assignUserToRealm(
|
|
212
|
+
* userId,
|
|
213
|
+
* 'admin@example.com',
|
|
214
|
+
* realmId,
|
|
215
|
+
* { canRead: true, canCreate: true, canUpdate: true }
|
|
216
|
+
* );
|
|
217
|
+
*/
|
|
218
|
+
async assignUserToRealm(strapiUserId, strapiUserEmail, realmConfigId, permissions = {}) {
|
|
219
|
+
// Check for existing assignment
|
|
220
|
+
const existing = await this.getRealmAdminAssignment(strapiUserId, realmConfigId);
|
|
221
|
+
|
|
222
|
+
// Merge with default permissions
|
|
223
|
+
const mergedPermissions = { ...DEFAULT_REALM_PERMISSIONS, ...permissions };
|
|
224
|
+
|
|
225
|
+
if (existing) {
|
|
226
|
+
// Update existing assignment
|
|
227
|
+
return strapi.documents(CONTENT_TYPES.REALM_ADMIN).update({
|
|
228
|
+
documentId: existing.documentId,
|
|
229
|
+
data: {
|
|
230
|
+
permissions: mergedPermissions,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Create new assignment
|
|
236
|
+
return strapi.documents(CONTENT_TYPES.REALM_ADMIN).create({
|
|
237
|
+
data: {
|
|
238
|
+
strapiUserId,
|
|
239
|
+
strapiUserEmail,
|
|
240
|
+
realmConfig: realmConfigId,
|
|
241
|
+
permissions: mergedPermissions,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Removes a user's access to a realm.
|
|
248
|
+
*
|
|
249
|
+
* @param {number} strapiUserId - Strapi user ID
|
|
250
|
+
* @param {string} realmConfigId - Realm document ID
|
|
251
|
+
* @returns {Promise<boolean>} True if assignment was removed, false if none existed
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* const removed = await permissionService.removeUserFromRealm(userId, realmId);
|
|
255
|
+
*/
|
|
256
|
+
async removeUserFromRealm(strapiUserId, realmConfigId) {
|
|
257
|
+
const assignment = await this.getRealmAdminAssignment(strapiUserId, realmConfigId);
|
|
258
|
+
|
|
259
|
+
if (assignment) {
|
|
260
|
+
await strapi.documents(CONTENT_TYPES.REALM_ADMIN).delete({
|
|
261
|
+
documentId: assignment.documentId,
|
|
262
|
+
});
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return false;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Retrieves all admin assignments for a specific realm.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} realmConfigId - Realm document ID
|
|
273
|
+
* @returns {Promise<RealmAdminAssignment[]>} Array of admin assignments
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* const admins = await permissionService.getRealmAdmins(realmId);
|
|
277
|
+
* // Display list of users who can manage this realm
|
|
278
|
+
*/
|
|
279
|
+
async getRealmAdmins(realmConfigId) {
|
|
280
|
+
const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
|
|
281
|
+
filters: {
|
|
282
|
+
realmConfig: { documentId: realmConfigId },
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return assignments;
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
export default permissionService;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Realm configuration management service.
|
|
3
|
+
* Handles CRUD operations for Keycloak realm configurations stored in Strapi.
|
|
4
|
+
*
|
|
5
|
+
* @module services/realm
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PLUGIN_ID,
|
|
10
|
+
CONTENT_TYPES,
|
|
11
|
+
SERVICES,
|
|
12
|
+
ERROR_MESSAGES,
|
|
13
|
+
DEFAULT_REALM_COLOR,
|
|
14
|
+
REALM_NAME_PATTERN,
|
|
15
|
+
} from '../constants.js';
|
|
16
|
+
import { createSanitizedError } from '../utils/errors.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} RealmConfigData
|
|
20
|
+
* @property {string} name - Unique slug identifier (lowercase, hyphens only)
|
|
21
|
+
* @property {string} displayName - Human-readable name for UI
|
|
22
|
+
* @property {string} serverUrl - Keycloak server base URL
|
|
23
|
+
* @property {string} realmName - Keycloak realm name
|
|
24
|
+
* @property {string} clientId - Service account client ID
|
|
25
|
+
* @property {string} [clientSecret] - Service account client secret
|
|
26
|
+
* @property {boolean} [enabled=true] - Whether realm is active
|
|
27
|
+
* @property {string} [color] - UI accent color (hex)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} RealmConfig
|
|
32
|
+
* @property {string} documentId - Strapi document ID
|
|
33
|
+
* @property {string} name - Unique slug identifier
|
|
34
|
+
* @property {string} displayName - Human-readable name
|
|
35
|
+
* @property {string} serverUrl - Keycloak server URL
|
|
36
|
+
* @property {string} realmName - Keycloak realm name
|
|
37
|
+
* @property {string} clientId - Client ID
|
|
38
|
+
* @property {boolean} enabled - Active status
|
|
39
|
+
* @property {string} color - UI color
|
|
40
|
+
* @property {Date} createdAt - Creation timestamp
|
|
41
|
+
* @property {Date} updatedAt - Last update timestamp
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates the realm configuration service.
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} params - Service parameters
|
|
48
|
+
* @param {Object} params.strapi - Strapi instance
|
|
49
|
+
* @returns {Object} Realm service methods
|
|
50
|
+
*/
|
|
51
|
+
const realmService = ({ strapi }) => ({
|
|
52
|
+
/**
|
|
53
|
+
* Gets the Keycloak client service instance.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Object} Keycloak client service
|
|
56
|
+
* @private
|
|
57
|
+
*/
|
|
58
|
+
get keycloakClient() {
|
|
59
|
+
return strapi.plugin(PLUGIN_ID).service(SERVICES.KEYCLOAK_CLIENT);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Retrieves all realm configurations.
|
|
64
|
+
* Note: clientSecret is not included in results (private field).
|
|
65
|
+
*
|
|
66
|
+
* @returns {Promise<RealmConfig[]>} Array of realm configurations sorted by displayName
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const realms = await realmService.findAll();
|
|
70
|
+
*/
|
|
71
|
+
async findAll() {
|
|
72
|
+
return strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
|
|
73
|
+
sort: { displayName: 'asc' },
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Retrieves a single realm by document ID.
|
|
79
|
+
* Note: clientSecret is not included (private field).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} documentId - Strapi document ID
|
|
82
|
+
* @returns {Promise<RealmConfig>} Realm configuration
|
|
83
|
+
* @throws {Error} If realm not found
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const realm = await realmService.findOne('abc123');
|
|
87
|
+
*/
|
|
88
|
+
async findOne(documentId) {
|
|
89
|
+
const realm = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findOne({
|
|
90
|
+
documentId,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!realm) {
|
|
94
|
+
throw createSanitizedError(
|
|
95
|
+
`Realm ${documentId} not found`,
|
|
96
|
+
ERROR_MESSAGES.REALM_NOT_FOUND
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return realm;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Retrieves a realm with its clientSecret for internal Keycloak API calls.
|
|
105
|
+
* Uses db.query to bypass Strapi's private field sanitization.
|
|
106
|
+
*
|
|
107
|
+
* SECURITY: Only use this method when the clientSecret is actually needed
|
|
108
|
+
* for Keycloak authentication. Never expose the result to API responses.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} documentId - Strapi document ID
|
|
111
|
+
* @returns {Promise<RealmConfig & {clientSecret: string}>} Full realm config with secret
|
|
112
|
+
* @throws {Error} If realm not found
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* // Internal use only - for Keycloak API calls
|
|
116
|
+
* const realm = await realmService.findOneWithSecret(documentId);
|
|
117
|
+
* const token = await keycloakClient.getAccessToken(realm);
|
|
118
|
+
*/
|
|
119
|
+
async findOneWithSecret(documentId) {
|
|
120
|
+
const realm = await strapi.db.query(CONTENT_TYPES.REALM_CONFIG).findOne({
|
|
121
|
+
where: { documentId },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!realm) {
|
|
125
|
+
throw createSanitizedError(
|
|
126
|
+
`Realm ${documentId} not found`,
|
|
127
|
+
ERROR_MESSAGES.REALM_NOT_FOUND
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return realm;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Finds a realm by its unique slug name.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} name - Realm slug name
|
|
138
|
+
* @returns {Promise<RealmConfig|null>} Realm configuration or null if not found
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* const realm = await realmService.findByName('production-users');
|
|
142
|
+
*/
|
|
143
|
+
async findByName(name) {
|
|
144
|
+
const realms = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
|
|
145
|
+
filters: { name },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return realms[0] || null;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validates realm name format.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} name - Name to validate
|
|
155
|
+
* @returns {boolean} True if valid
|
|
156
|
+
* @private
|
|
157
|
+
*/
|
|
158
|
+
isValidName(name) {
|
|
159
|
+
return REALM_NAME_PATTERN.test(name);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Normalizes a server URL by removing trailing slashes.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} url - URL to normalize
|
|
166
|
+
* @returns {string} Normalized URL
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
normalizeServerUrl(url) {
|
|
170
|
+
return url.replace(/\/$/, '');
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Creates a new realm configuration.
|
|
175
|
+
*
|
|
176
|
+
* @param {RealmConfigData} data - Realm configuration data
|
|
177
|
+
* @returns {Promise<RealmConfig>} Created realm configuration
|
|
178
|
+
* @throws {Error} If validation fails or name already exists
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* const realm = await realmService.create({
|
|
182
|
+
* name: 'production-users',
|
|
183
|
+
* displayName: 'Production Users',
|
|
184
|
+
* serverUrl: 'https://keycloak.example.com',
|
|
185
|
+
* realmName: 'production',
|
|
186
|
+
* clientId: 'strapi-admin',
|
|
187
|
+
* clientSecret: 'secret-value'
|
|
188
|
+
* });
|
|
189
|
+
*/
|
|
190
|
+
async create(data) {
|
|
191
|
+
const { name, displayName, serverUrl, realmName, clientId, clientSecret } = data;
|
|
192
|
+
|
|
193
|
+
// Validate required fields
|
|
194
|
+
if (!name || !displayName || !serverUrl || !realmName || !clientId) {
|
|
195
|
+
throw createSanitizedError(
|
|
196
|
+
'Missing required fields',
|
|
197
|
+
ERROR_MESSAGES.MISSING_REQUIRED_FIELDS
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Validate name format
|
|
202
|
+
if (!this.isValidName(name)) {
|
|
203
|
+
throw createSanitizedError(
|
|
204
|
+
`Invalid name format: ${name}`,
|
|
205
|
+
ERROR_MESSAGES.INVALID_NAME_FORMAT
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for duplicate name
|
|
210
|
+
const existing = await this.findByName(name);
|
|
211
|
+
if (existing) {
|
|
212
|
+
throw createSanitizedError(
|
|
213
|
+
`Realm with name ${name} already exists`,
|
|
214
|
+
ERROR_MESSAGES.REALM_NAME_EXISTS
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return strapi.documents(CONTENT_TYPES.REALM_CONFIG).create({
|
|
219
|
+
data: {
|
|
220
|
+
name,
|
|
221
|
+
displayName,
|
|
222
|
+
serverUrl: this.normalizeServerUrl(serverUrl),
|
|
223
|
+
realmName,
|
|
224
|
+
clientId,
|
|
225
|
+
clientSecret: clientSecret || null,
|
|
226
|
+
enabled: data.enabled !== false,
|
|
227
|
+
color: data.color || DEFAULT_REALM_COLOR,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Updates an existing realm configuration.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} documentId - Strapi document ID
|
|
236
|
+
* @param {Partial<RealmConfigData>} data - Fields to update
|
|
237
|
+
* @returns {Promise<RealmConfig>} Updated realm configuration
|
|
238
|
+
* @throws {Error} If validation fails or realm not found
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* const updated = await realmService.update(documentId, {
|
|
242
|
+
* displayName: 'New Display Name',
|
|
243
|
+
* enabled: false
|
|
244
|
+
* });
|
|
245
|
+
*/
|
|
246
|
+
async update(documentId, data) {
|
|
247
|
+
const existing = await this.findOne(documentId);
|
|
248
|
+
|
|
249
|
+
// Validate name change if applicable
|
|
250
|
+
if (data.name && data.name !== existing.name) {
|
|
251
|
+
if (!this.isValidName(data.name)) {
|
|
252
|
+
throw createSanitizedError(
|
|
253
|
+
`Invalid name format: ${data.name}`,
|
|
254
|
+
ERROR_MESSAGES.INVALID_NAME_FORMAT
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const duplicate = await this.findByName(data.name);
|
|
259
|
+
if (duplicate && duplicate.documentId !== documentId) {
|
|
260
|
+
throw createSanitizedError(
|
|
261
|
+
`Realm with name ${data.name} already exists`,
|
|
262
|
+
ERROR_MESSAGES.REALM_NAME_EXISTS
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Prepare update data
|
|
268
|
+
const updateData = { ...data };
|
|
269
|
+
if (updateData.serverUrl) {
|
|
270
|
+
updateData.serverUrl = this.normalizeServerUrl(updateData.serverUrl);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Clear token cache if connection details changed
|
|
274
|
+
const connectionFieldsChanged =
|
|
275
|
+
updateData.serverUrl ||
|
|
276
|
+
updateData.realmName ||
|
|
277
|
+
updateData.clientId ||
|
|
278
|
+
updateData.clientSecret;
|
|
279
|
+
|
|
280
|
+
if (connectionFieldsChanged) {
|
|
281
|
+
this.keycloakClient.clearTokenCache(existing);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return strapi.documents(CONTENT_TYPES.REALM_CONFIG).update({
|
|
285
|
+
documentId,
|
|
286
|
+
data: updateData,
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Deletes a realm configuration and all associated admin assignments.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} documentId - Strapi document ID
|
|
294
|
+
* @returns {Promise<{success: boolean}>} Success indicator
|
|
295
|
+
* @throws {Error} If realm not found
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* await realmService.delete(documentId);
|
|
299
|
+
*/
|
|
300
|
+
async delete(documentId) {
|
|
301
|
+
const realm = await this.findOne(documentId);
|
|
302
|
+
|
|
303
|
+
// Clear token cache
|
|
304
|
+
this.keycloakClient.clearTokenCache(realm);
|
|
305
|
+
|
|
306
|
+
// Delete all realm admin assignments for this realm
|
|
307
|
+
const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
|
|
308
|
+
filters: {
|
|
309
|
+
realmConfig: { documentId },
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Delete assignments sequentially to avoid race conditions
|
|
314
|
+
for (const assignment of assignments) {
|
|
315
|
+
await strapi.documents(CONTENT_TYPES.REALM_ADMIN).delete({
|
|
316
|
+
documentId: assignment.documentId,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Delete the realm config
|
|
321
|
+
await strapi.documents(CONTENT_TYPES.REALM_CONFIG).delete({
|
|
322
|
+
documentId,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return { success: true };
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Tests connection to a saved realm configuration.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} documentId - Strapi document ID
|
|
332
|
+
* @returns {Promise<{success: boolean, message?: string, realmDisplayName?: string}>} Test result
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* const result = await realmService.testConnection(documentId);
|
|
336
|
+
* if (result.success) {
|
|
337
|
+
* console.log(`Connected to ${result.realmDisplayName}`);
|
|
338
|
+
* } else {
|
|
339
|
+
* console.error(result.message);
|
|
340
|
+
* }
|
|
341
|
+
*/
|
|
342
|
+
async testConnection(documentId) {
|
|
343
|
+
const realmBasic = await this.findOne(documentId);
|
|
344
|
+
|
|
345
|
+
if (!realmBasic.enabled) {
|
|
346
|
+
return { success: false, message: ERROR_MESSAGES.REALM_DISABLED };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Get realm with clientSecret for Keycloak connection
|
|
350
|
+
const realm = await this.findOneWithSecret(documentId);
|
|
351
|
+
return this.keycloakClient.testConnection(realm);
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Tests connection with raw configuration data (before saving).
|
|
356
|
+
* Useful for validating credentials during realm creation/editing.
|
|
357
|
+
*
|
|
358
|
+
* @param {RealmConfigData} config - Raw realm configuration
|
|
359
|
+
* @returns {Promise<{success: boolean, message?: string, realmDisplayName?: string}>} Test result
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* const result = await realmService.testConnectionRaw({
|
|
363
|
+
* serverUrl: 'https://keycloak.example.com',
|
|
364
|
+
* realmName: 'my-realm',
|
|
365
|
+
* clientId: 'strapi-admin',
|
|
366
|
+
* clientSecret: 'secret'
|
|
367
|
+
* });
|
|
368
|
+
*/
|
|
369
|
+
async testConnectionRaw(config) {
|
|
370
|
+
return this.keycloakClient.testConnection(config);
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
export default realmService;
|