ldap-rest 0.9.0 → 0.10.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 (40) hide show
  1. package/bin/index.mjs +1 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/lib/authz/base.js +1 -1
  4. package/dist/lib/changeContext.js +1 -0
  5. package/dist/lib/errors.js +1 -1
  6. package/dist/lib/ldapActions.js +1 -1
  7. package/dist/plugins/auth/authzLinid1.js +1 -1
  8. package/dist/plugins/auth/authzPerBranch.js +1 -1
  9. package/dist/plugins/ldap/departmentSync.js +1 -1
  10. package/dist/plugins/ldap/groups.js +1 -1
  11. package/dist/plugins/ldap/onChange.js +1 -1
  12. package/dist/plugins/ldap/organizations.js +1 -1
  13. package/dist/plugins/ldap/trash.js +1 -1
  14. package/dist/plugins/scim/scim.js +1 -1
  15. package/dist/plugins/twake/appAccountsApi.js +1 -1
  16. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  17. package/dist/plugins/twake/clouderyProvision.js +1 -1
  18. package/dist/plugins/twake/cozyProvision.js +1 -1
  19. package/dist/src/abstract/plugin.d.ts +6 -0
  20. package/dist/src/hooks.d.ts +15 -6
  21. package/dist/src/lib/authz/base.d.ts +26 -0
  22. package/dist/src/lib/changeContext.d.ts +24 -0
  23. package/dist/src/lib/errors.d.ts +15 -0
  24. package/dist/src/lib/ldapActions.d.ts +2 -1
  25. package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
  26. package/dist/src/plugins/auth/authzPerBranch.d.ts +35 -0
  27. package/dist/src/plugins/ldap/groups.d.ts +7 -5
  28. package/dist/src/plugins/ldap/onChange.d.ts +17 -8
  29. package/dist/src/plugins/ldap/organizations.d.ts +2 -2
  30. package/docs/plugin-development/README.md +1 -18
  31. package/docs/plugin-development/hooks.md +6 -0
  32. package/docs/plugin-development/testing.md +1 -17
  33. package/docs/usage/configuration.md +1 -1
  34. package/docs/usage/plugins/auth/authz-linid1.md +8 -7
  35. package/docs/usage/plugins/auth/authz-per-branch.md +33 -11
  36. package/docs/usage/plugins/auth/authz-scope.md +5 -0
  37. package/docs/usage/plugins/ldap/on-change.md +51 -284
  38. package/docs/usage/troubleshooting.md +1 -9
  39. package/docs/usage/upgrading.md +52 -0
  40. package/package.json +1 -1
@@ -174,10 +174,20 @@ Modifies the organization tree API:
174
174
 
175
175
  Groups are resolved dynamically:
176
176
 
177
- 1. Searches for all groups where user is a member
178
- 2. Uses wildcard DN pattern: `(member=uid={user},*)`
179
- 3. Caches results for configured TTL (default: 60 seconds)
180
- 4. Cache prevents repeated LDAP queries for same user
177
+ 1. Resolves the caller to its DN, searching
178
+ `(<ldap_user_main_attribute>=<uid>)` under the LDAP base (`--ldap-base`)
179
+ 2. Searches for all groups where that DN is a member
180
+ 3. Matches each group found against the `groups` keys **as DNs**: case and
181
+ the spaces around `,` and `=` do not matter, so
182
+ `CN=Editors, OU=Groups,dc=example,dc=com` names the same group as
183
+ `cn=editors,ou=groups,dc=example,dc=com`
184
+ 4. Caches results (see [Group Caching](#group-caching)); concurrent
185
+ requests for the same user share one lookup
186
+
187
+ A uid that names **more than one** entry gets **no group rule**, since its
188
+ groups cannot be told apart from its homonym's; a warning is logged, and the
189
+ uid's own `users` rule still applies. A lookup that fails answers `500` and
190
+ is not cached.
181
191
 
182
192
  ## Sub-branch Inheritance
183
193
 
@@ -379,6 +389,10 @@ Group memberships are cached to reduce LDAP queries:
379
389
  - **Configurable**: `--authz-per-branch-cache-ttl`
380
390
  - **Per-user cache**: Each user's groups cached separately
381
391
  - **Automatic expiry**: Cache entries expire after TTL
392
+ - **Dropped on writes**: any add, delete, rename or move made through
393
+ ldap-rest empties the cache, and so does a modify touching the group member
394
+ attribute or `--ldap-user-main-attribute`. A change made directly in the
395
+ directory waits for the TTL
382
396
 
383
397
  Cache hit/miss logging:
384
398
 
@@ -420,7 +434,6 @@ names, this plugin lets them read and refuses every write. Give it an
420
434
 
421
435
  - Group DNs must be fully qualified
422
436
  - Group resolution uses LDAP search - ensure proper indexing
423
- - Cache can delay permission revocation (up to TTL duration)
424
437
 
425
438
  ### Authentication Required
426
439
 
@@ -531,7 +544,8 @@ Changed permissions don't take effect immediately.
531
544
 
532
545
  **Solutions:**
533
546
 
534
- 1. Wait for group cache TTL to expire
547
+ 1. A membership changed directly in the directory waits for the
548
+ [group cache](#group-caching) TTL
535
549
  2. Restart server to clear all caches
536
550
  3. Reduce `--authz-per-branch-cache-ttl` for testing
537
551
 
@@ -542,17 +556,25 @@ User should inherit group permissions but doesn't.
542
556
 
543
557
  **Solutions:**
544
558
 
545
- 1. Verify user is member of group:
559
+ 1. Verify the user's DN is a member of the group:
546
560
 
547
561
  ```bash
548
- ldapsearch -x -b "ou=groups,dc=example,dc=com" "(member=uid=jdoe,*)"
562
+ ldapsearch -x -b "dc=example,dc=com" \
563
+ "(member=uid=jdoe,ou=users,dc=example,dc=com)"
549
564
  ```
550
565
 
551
- 2. Check group DN in config matches LDAP exactly
566
+ 2. Check the group DN in config names the group: it is compared as a DN
567
+ (case and spaces are ignored), but every RDN must be there
552
568
 
553
- 3. Ensure group membership uses correct attribute (default: `member`)
569
+ 3. Check the uid names a single entry:
554
570
 
555
- 4. Configure custom member attribute if needed:
571
+ ```bash
572
+ ldapsearch -x -b "dc=example,dc=com" "(uid=jdoe)" dn
573
+ ```
574
+
575
+ 4. Ensure group membership uses correct attribute (default: `member`)
576
+
577
+ 5. Configure custom member attribute if needed:
556
578
  ```bash
557
579
  --ldap-group-member-attribute uniqueMember
558
580
  ```
@@ -150,6 +150,11 @@ identified caller of the same server is told. A server loading no
150
150
  authorization plugin at all answers `unrestricted` to anyone, anonymous
151
151
  included, which is what it does.
152
152
 
153
+ An identity that names **several** entries — the same `uid` in two branches of
154
+ a directory without the `unique` overlay, which `authzLinid1` refuses with
155
+ `AmbiguousIdentityError` — is answered `403`, not `401`: the caller cannot be
156
+ told apart from the homonym, so no scope can be described for them.
157
+
153
158
  ## See also
154
159
 
155
160
  - [authz-linid1](authz-linid1.md) — the local-administrator model this serves
@@ -1,10 +1,7 @@
1
1
  # LDAP onChange Plugin
2
2
 
3
- Monitor LDAP attribute changes and trigger hooks for specific attributes.
4
-
5
- ## Overview
6
-
7
- The `onChange` plugin watches for LDAP modifications and generates specialized hooks when configured attributes change. This enables reactive integrations with external systems (mail servers, quota managers, etc.).
3
+ Publish the state of an entry before and after each write made through
4
+ LDAP-Rest, and hooks for the attributes other plugins follow.
8
5
 
9
6
  ## Configuration
10
7
 
@@ -14,305 +11,75 @@ The `onChange` plugin watches for LDAP modifications and generates specialized h
14
11
  --quota-attribute mailQuota
15
12
  ```
16
13
 
17
- **Environment Variables:**
18
-
19
- ```bash
20
- DM_MAIL_ATTRIBUTE="mail"
21
- DM_QUOTA_ATTRIBUTE="mailQuota"
22
- ```
23
-
24
- ## Generated Hooks
25
-
26
- The plugin generates hooks based on configured attributes:
27
-
28
- | Config Parameter | Generated Hook | Parameters |
29
- | ------------------- | ------------------- | -------------------------------- |
30
- | `--mail-attribute` | `onLdapMailChange` | `(dn, oldMail, newMail)` |
31
- | `--quota-attribute` | `onLdapQuotaChange` | `(dn, mail, oldQuota, newQuota)` |
32
-
33
- Generic hook for any change:
34
-
35
- - `onLdapChange` - `(dn, changes)` where `changes` is `Record<attr, [oldValue, newValue]>`
36
-
37
- ## How It Works
38
-
39
- 1. **Hooks into LDAP modify operations** via `ldapUsersmodify` and `ldapGroupsmodify` hooks
40
- 2. **Detects attribute changes** by comparing old and new values
41
- 3. **Triggers specialized hooks** for configured attributes
42
- 4. **Provides old and new values** to downstream hooks
43
-
44
- ## Use Cases
45
-
46
- ### Mail Server Synchronization
47
-
48
- When user email changes, update external mail server:
49
-
50
- ```javascript
51
- // In another plugin
52
- hooks: {
53
- onLdapMailChange: async (dn, oldMail, newMail) => {
54
- await mailServer.renameAccount(oldMail, newMail);
55
- console.log(`Renamed mail account: ${oldMail} → ${newMail}`);
56
- };
57
- }
58
- ```
59
-
60
- ### Quota Management
61
-
62
- When quota changes, update mail server quota:
63
-
64
- ```javascript
65
- hooks: {
66
- onLdapQuotaChange: async (dn, mail, oldQuota, newQuota) => {
67
- await mailServer.setQuota(mail, newQuota);
68
- console.log(`Updated quota for ${mail}: ${oldQuota} → ${newQuota}`);
69
- };
70
- }
71
- ```
72
-
73
- ### Generic Change Tracking
74
-
75
- Track all attribute changes:
76
-
77
- ```javascript
78
- hooks: {
79
- onLdapChange: async (dn, changes) => {
80
- for (const [attr, [oldVal, newVal]] of Object.entries(changes)) {
81
- console.log(`${dn}: ${attr} changed from ${oldVal} to ${newVal}`);
82
- }
83
- };
84
- }
85
- ```
86
-
87
- ## Examples
88
-
89
- ### Example 1: Mail Attribute Monitoring
90
-
91
- ```bash
92
- --plugin core/ldap/onChange \
93
- --plugin core/ldap/flatGeneric \
94
- --ldap-flat-schema ./schemas/users.json \
95
- --mail-attribute mail
96
- ```
97
-
98
- When a user's mail attribute changes via API:
99
-
100
- ```bash
101
- PUT /api/v1/ldap/users/jdoe
102
- {
103
- "replace": {
104
- "mail": "john.doe@newdomain.com"
105
- }
106
- }
107
- ```
108
-
109
- The `onLdapMailChange` hook fires with:
110
-
111
- - `dn`: `"uid=jdoe,ou=users,dc=example,dc=com"`
112
- - `oldMail`: `"jdoe@olddomain.com"`
113
- - `newMail`: `"john.doe@newdomain.com"`
114
-
115
- ### Example 2: Multiple Attributes
116
-
117
- ```bash
118
- --plugin core/ldap/onChange \
119
- --mail-attribute mail \
120
- --quota-attribute mailQuota
121
- ```
122
-
123
- When both attributes change:
14
+ ## Hooks
124
15
 
125
- ```bash
126
- PUT /api/v1/ldap/users/jdoe
127
- {
128
- "replace": {
129
- "mail": "jdoe@company.com",
130
- "mailQuota": "5000000000"
131
- }
132
- }
133
- ```
16
+ `onLdapEntryChange(dn, before, after, context)` fires after every add,
17
+ modify, rename and delete, with the entry as the directory held it on each
18
+ side:
134
19
 
135
- Both hooks fire:
20
+ | Operation | `before` | `after` |
21
+ | --------- | ------------------------ | ------------------------ |
22
+ | add | `null` | the new entry |
23
+ | modify | the entry before | the entry after |
24
+ | rename | the entry, at its old DN | the entry, at its new DN |
25
+ | delete | the entry before | `null` |
136
26
 
137
- 1. `onLdapMailChange(dn, "old@mail.com", "jdoe@company.com")`
138
- 2. `onLdapQuotaChange(dn, "jdoe@company.com", "1000000000", "5000000000")`
27
+ `dn` is the entry's DN after the write, its old one on a delete. `context`
28
+ says who made the write, and through which door:
139
29
 
140
- ### Example 3: Generic Change Listener
30
+ - `actor`: the caller's `userName`, or its `user` when it has none;
31
+ - `requestId`: generated, the same for every write of one request;
32
+ - `source`: `scim` for the SCIM API, `rest` otherwise.
141
33
 
142
- ```bash
143
- --plugin core/ldap/onChange
144
- ```
34
+ A write no request is behind, such as a scheduled task's, gets `{}`. The LDAP
35
+ `ldap*done` hooks receive the same context after their arguments.
145
36
 
146
- All modifications trigger `onLdapChange`:
37
+ The other hooks are derived from it:
147
38
 
148
- ```bash
149
- PUT /api/v1/ldap/users/jdoe
150
- {
151
- "replace": {
152
- "cn": "John A. Doe",
153
- "telephoneNumber": "+1234567890"
154
- },
155
- "delete": ["description"]
156
- }
157
- ```
39
+ | Hook | Fires when | Parameters |
40
+ | ------------------------- | ---------------------------------------------------------- | -------------------------------------- |
41
+ | `onLdapChange` | any attribute changed | `(dn, changes)` |
42
+ | `onLdapMailChange` | `--mail-attribute` changed | `(dn, oldMail, newMail)` |
43
+ | `onLdapQuotaChange` | `--quota-attribute` changed | `(dn, mail, oldQuota, newQuota)` |
44
+ | `onLdapAliasChange` | `--alias-attribute` changed | `(dn, mail, oldAliases, newAliases)` |
45
+ | `onLdapForwardChange` | `--forward-attribute` changed | `(dn, mail, oldForwards, newForwards)` |
46
+ | `onLdapDriveQuotaChange` | `--drive-quota-attribute` changed | `(dn, oldQuota, newQuota)` |
47
+ | `onLdapDisplayNameChange` | the name built from `cn`, or `givenName` and `sn`, changed | `(dn, oldName, newName)` |
158
48
 
159
- Hook receives:
49
+ `changes` maps each attribute that changed to `[oldValues, newValues]`, the
50
+ full values on each side, `null` where the attribute is absent:
160
51
 
161
- ```javascript
162
- {
163
- "cn": ["John Doe", "John A. Doe"],
164
- "telephoneNumber": [null, "+1234567890"],
165
- "description": ["Old description", null]
166
- }
52
+ ```text
53
+ member was [a, b], the request added c:
54
+ { member: [['a', 'b'], ['a', 'b', 'c']] }
167
55
  ```
168
56
 
169
- ## Integration Examples
170
-
171
- ### With Twake James Mail Server
172
-
173
- ```bash
174
- --plugin core/ldap/onChange \
175
- --plugin twake/james \
176
- --plugin core/ldap/flatGeneric \
177
- --ldap-flat-schema ./schemas/twake/users.json \
178
- --mail-attribute mail \
179
- --quota-attribute mailQuota \
180
- --james-webadmin-url http://james:8000
181
- ```
57
+ ## Operational attributes
182
58
 
183
- The James plugin listens to `onLdapMailChange` and `onLdapQuotaChange` hooks and automatically updates the James mail server.
184
-
185
- ### Custom Integration Plugin
186
-
187
- Create a custom plugin that reacts to changes:
59
+ A search returns no operational attribute unless asked by name, so neither
60
+ side holds `pwdAccountLockedTime` or its kind by default, and a write changing
61
+ only such an attribute fires no hook. A plugin that follows some declares them:
188
62
 
189
63
  ```typescript
190
- import DmPlugin from '../abstract/plugin';
191
- import { Hooks } from '../hooks';
192
-
193
- export default class CustomSync extends DmPlugin {
194
- name = 'customSync';
195
-
196
- dependencies = {
197
- onChange: 'core/ldap/onChange',
198
- };
199
-
200
- hooks: Hooks = {
201
- onLdapMailChange: async (dn, oldMail, newMail) => {
202
- this.logger.info(`Mail changed: ${oldMail} → ${newMail}`);
203
-
204
- try {
205
- await fetch('https://api.example.com/update-email', {
206
- method: 'POST',
207
- headers: { 'Content-Type': 'application/json' },
208
- body: JSON.stringify({ dn, oldMail, newMail }),
209
- });
210
- } catch (err) {
211
- this.logger.error(`Failed to sync mail change: ${err}`);
212
- }
213
- },
214
- };
215
- }
64
+ followedOperationalAttributes = ['pwdAccountLockedTime'];
216
65
  ```
217
66
 
218
- ## Change Detection
67
+ The entry is then read with `*` and every attribute a loaded plugin declares.
219
68
 
220
- ### Replace Operations
69
+ ## What counts as a change
221
70
 
222
- ```javascript
223
- // Before: mail = "old@example.com"
224
- {
225
- replace: {
226
- mail: 'new@example.com';
227
- }
228
- }
229
- // Triggers: onLdapMailChange("uid=user,ou=users,dc=example,dc=com", "old@example.com", "new@example.com")
230
- ```
231
-
232
- ### Add Operations
71
+ Values are compared as sets: a single value and a one-element array are equal,
72
+ and so are two orderings of the same values. A write that leaves every value
73
+ as it was, such as a `replace` with the current value, fires no hook.
233
74
 
234
- ```javascript
235
- // Before: mail = undefined
236
- {
237
- add: {
238
- mail: 'new@example.com';
239
- }
240
- }
241
- // Triggers: onLdapMailChange("uid=user,ou=users,dc=example,dc=com", null, "new@example.com")
242
- ```
75
+ ## Timing and errors
243
76
 
244
- ### Delete Operations
245
-
246
- ```javascript
247
- // Before: mail = "old@example.com"
248
- { delete: ["mail"] }
249
- // Triggers: onLdapMailChange("uid=user,ou=users,dc=example,dc=com", "old@example.com", null)
250
- ```
251
-
252
- ## Performance Considerations
253
-
254
- ### Hook Execution
255
-
256
- - Hooks run **synchronously** within the modify operation
257
- - Slow hooks delay the LDAP response
258
- - Consider async operations with fire-and-forget for non-critical updates
259
-
260
- ### Optimization Tips
261
-
262
- 1. **Keep hooks fast**: Defer heavy work to background jobs
263
- 2. **Use error handling**: Don't let hook failures block LDAP operations
264
- 3. **Batch updates**: If possible, batch multiple hook calls
265
- 4. **Cache external state**: Avoid redundant external API calls
266
-
267
- ## Error Handling
268
-
269
- Hooks can throw errors to abort the LDAP modification:
270
-
271
- ```javascript
272
- hooks: {
273
- onLdapMailChange: async (dn, oldMail, newMail) => {
274
- // Validate mail domain
275
- if (!newMail.endsWith('@company.com')) {
276
- throw new Error('Mail must be @company.com domain');
277
- }
278
-
279
- // Update mail server
280
- try {
281
- await mailServer.renameAccount(oldMail, newMail);
282
- } catch (err) {
283
- throw new Error(`Mail server update failed: ${err.message}`);
284
- }
285
- };
286
- }
287
- ```
288
-
289
- If the hook throws, the LDAP modification is rolled back (if supported by LDAP server).
290
-
291
- ## Debugging
292
-
293
- Enable debug logging to see change detection:
294
-
295
- ```bash
296
- --log-level debug
297
- ```
298
-
299
- Output:
300
-
301
- ```
302
- [debug] Detected mail change for uid=jdoe,ou=users,dc=example,dc=com: old@domain.com → new@domain.com
303
- [debug] Triggering onLdapMailChange hook
304
- [info] onLdapMailChange: Mail account renamed successfully
305
- ```
77
+ Hooks run after the directory has accepted the write, and the API answers
78
+ without waiting for them. A hook that throws is logged; the write stays.
306
79
 
307
80
  ## Limitations
308
81
 
309
- 1. **Only monitors modify operations**: Does not detect changes from direct LDAP clients
310
- 2. **Requires old value lookup**: Fetches entry before modification for comparison
311
- 3. **Single-valued attributes**: Best suited for single-valued attributes like mail
312
- 4. **Synchronous execution**: Hooks block the LDAP response
313
-
314
- ## See Also
315
-
316
- - [Twake James Plugin](twakeJames.md) - Mail server integration using onChange
317
- - [ldapFlatGeneric.md](ldapFlatGeneric.md) - Generic LDAP entity management
318
- - [Hooks Documentation](../src/hooks.ts) - All available hooks
82
+ - Only writes made through LDAP-Rest are seen; a direct LDAP client's are not.
83
+ - Entries moved by the [trash](trash.md) plugin are not seen, nor the entries
84
+ under a renamed container, which move with it.
85
+ - Each write reads the entry before and after it: one search on each side.
@@ -89,15 +89,7 @@ DM_LOG_LEVEL=debug ldap-rest
89
89
 
90
90
  ## Testing
91
91
 
92
- Run the test suite:
93
-
94
- ```bash
95
- # All tests
96
- source ~/.test-env && npm run test:dev
97
-
98
- # Single file
99
- source ~/.test-env && npm run test:one test/path/to/file.test.ts
100
- ```
92
+ See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
101
93
 
102
94
  ## Support
103
95
 
@@ -4,6 +4,58 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## To 0.10.0
8
+
9
+ ### LDAP base is now required
10
+
11
+ **Who is affected:** a deployment that starts `ldap-rest` without
12
+ `--ldap-base` (or `DM_LDAP_BASE`).
13
+
14
+ The server refuses to start without it. It used to take the second RDN of
15
+ `--ldap-dn` — `dc=example` for `cn=admin,dc=example,dc=com` — so subtree
16
+ searches failed or found nothing. Set `--ldap-base` to the DN under which
17
+ the entries are searched.
18
+
19
+ ### Group rules now apply
20
+
21
+ **Who is affected:** a deployment whose `--authz-per-branch-config` declares
22
+ a `groups` section.
23
+
24
+ That section granted nothing: permissions came from `users` and `default`
25
+ alone. A member of a configured group now also gets what its rule grants.
26
+ **Check the `groups` rules before upgrading.**
27
+
28
+ ### An identity naming several entries is refused
29
+
30
+ **Who is affected:** a directory where the same `uid` appears twice — one
31
+ without a `unique` overlay, or one where a homonym was left in the trash
32
+ while the uid was reused.
33
+
34
+ `authzLinid1` refuses every request of such a caller with a `403`.
35
+ `authzPerBranch` keeps the uid's `users` rules and withholds the `groups`
36
+ rules. Both log a warning naming the identity and the number of entries it
37
+ matched.
38
+
39
+ **Check your directory before upgrading:**
40
+
41
+ ```bash
42
+ ldapsearch -x -b "<base>" "(uid=<uid>)" dn
43
+ ```
44
+
45
+ must answer one entry. Where it answers two, remove the duplicate — the
46
+ trash included — or restore the uniqueness the `unique` overlay enforces.
47
+
48
+ ### `onLdapChange` gives full values
49
+
50
+ **Who is affected:** plugins listening to `onLdapChange` that read the values
51
+ of an `add`, or of a `delete` naming values, as the values added or removed.
52
+
53
+ Each attribute now maps to all its values before and after the write. Adding
54
+ `c` to `member: [a, b]` gave `[null, [c]]` and gives `[[a, b], [a, b, c]]`.
55
+ Compute what was added or removed as the difference of the two sides, as the
56
+ plugins shipped with LDAP-Rest do. A `replace` with the current value no
57
+ longer fires the hook.
58
+
7
59
  ## To 0.9.0
8
60
 
9
61
  ### Two authorization plugins judging the same requests no longer start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"