ldap-rest 0.8.2 → 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 (101) hide show
  1. package/bin/index.mjs +1 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/lib/auth/base.js +1 -1
  6. package/dist/lib/authz/base.js +1 -1
  7. package/dist/lib/authz/composition.js +1 -0
  8. package/dist/lib/changeContext.js +1 -0
  9. package/dist/lib/errors.js +1 -1
  10. package/dist/lib/expressFormatedResponses.js +1 -1
  11. package/dist/lib/ldapActions.js +1 -1
  12. package/dist/lib/ldapCodes.js +1 -0
  13. package/dist/lib/parseConfig.js +1 -1
  14. package/dist/lib/storage/store.js +1 -0
  15. package/dist/lib/utils.js +1 -1
  16. package/dist/plugins/auth/authzDynamic.js +1 -1
  17. package/dist/plugins/auth/authzLinid1.js +1 -1
  18. package/dist/plugins/auth/authzPerBranch.js +1 -1
  19. package/dist/plugins/auth/authzPerRoute.js +1 -1
  20. package/dist/plugins/auth/authzScope.js +1 -1
  21. package/dist/plugins/auth/hmac.js +1 -1
  22. package/dist/plugins/auth/llng.js +1 -1
  23. package/dist/plugins/auth/openidconnect.js +1 -1
  24. package/dist/plugins/auth/token.js +1 -1
  25. package/dist/plugins/auth/totp.js +1 -1
  26. package/dist/plugins/bcl.js +1 -0
  27. package/dist/plugins/ldap/departmentSync.js +1 -1
  28. package/dist/plugins/ldap/groups.js +1 -1
  29. package/dist/plugins/ldap/onChange.js +1 -1
  30. package/dist/plugins/ldap/organizations.js +1 -1
  31. package/dist/plugins/ldap/trash.js +1 -1
  32. package/dist/plugins/priority.json.js +1 -1
  33. package/dist/plugins/scim/baseResolver.js +1 -1
  34. package/dist/plugins/scim/errors.js +1 -1
  35. package/dist/plugins/scim/groups.js +1 -1
  36. package/dist/plugins/scim/list.js +1 -1
  37. package/dist/plugins/scim/scim.js +1 -1
  38. package/dist/plugins/scim/users.js +1 -1
  39. package/dist/plugins/storage/file.js +1 -0
  40. package/dist/plugins/storage/ldap.js +1 -0
  41. package/dist/plugins/storage.js +1 -0
  42. package/dist/plugins/twake/appAccountsApi.js +1 -1
  43. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  44. package/dist/plugins/twake/clouderyProvision.js +1 -1
  45. package/dist/plugins/twake/cozyProvision.js +1 -1
  46. package/dist/src/abstract/ldapFlat.d.ts +7 -0
  47. package/dist/src/abstract/plugin.d.ts +26 -0
  48. package/dist/src/bin/index.d.ts +50 -8
  49. package/dist/src/config/args.d.ts +36 -0
  50. package/dist/src/hooks.d.ts +70 -6
  51. package/dist/src/lib/auth/base.d.ts +175 -9
  52. package/dist/src/lib/authz/base.d.ts +159 -4
  53. package/dist/src/lib/authz/composition.d.ts +145 -0
  54. package/dist/src/lib/changeContext.d.ts +24 -0
  55. package/dist/src/lib/errors.d.ts +15 -0
  56. package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
  57. package/dist/src/lib/ldapActions.d.ts +32 -1
  58. package/dist/src/lib/ldapCodes.d.ts +28 -0
  59. package/dist/src/lib/storage/store.d.ts +84 -0
  60. package/dist/src/lib/utils.d.ts +19 -0
  61. package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
  62. package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
  63. package/dist/src/plugins/auth/authzPerBranch.d.ts +46 -0
  64. package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
  65. package/dist/src/plugins/auth/authzScope.d.ts +44 -3
  66. package/dist/src/plugins/auth/hmac.d.ts +2 -0
  67. package/dist/src/plugins/auth/llng.d.ts +39 -0
  68. package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
  69. package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
  70. package/dist/src/plugins/auth/token.d.ts +2 -0
  71. package/dist/src/plugins/auth/totp.d.ts +2 -0
  72. package/dist/src/plugins/bcl.d.ts +65 -0
  73. package/dist/src/plugins/ldap/groups.d.ts +7 -5
  74. package/dist/src/plugins/ldap/onChange.d.ts +17 -8
  75. package/dist/src/plugins/ldap/organizations.d.ts +92 -3
  76. package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
  77. package/dist/src/plugins/scim/errors.d.ts +2 -10
  78. package/dist/src/plugins/storage/file.d.ts +19 -0
  79. package/dist/src/plugins/storage/ldap.d.ts +17 -0
  80. package/dist/src/plugins/storage.d.ts +44 -0
  81. package/docs/plugin-development/README.md +13 -19
  82. package/docs/plugin-development/dependencies.md +40 -15
  83. package/docs/plugin-development/hooks.md +6 -0
  84. package/docs/plugin-development/testing.md +1 -17
  85. package/docs/usage/configuration.md +90 -14
  86. package/docs/usage/plugins/README.md +17 -12
  87. package/docs/usage/plugins/auth/README.md +129 -0
  88. package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
  89. package/docs/usage/plugins/auth/authz-linid1.md +33 -0
  90. package/docs/usage/plugins/auth/authz-per-branch.md +41 -11
  91. package/docs/usage/plugins/auth/authz-per-route.md +6 -0
  92. package/docs/usage/plugins/auth/authz-scope.md +74 -16
  93. package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
  94. package/docs/usage/plugins/auth/llng.md +29 -0
  95. package/docs/usage/plugins/auth/oidc.md +52 -0
  96. package/docs/usage/plugins/ldap/on-change.md +51 -284
  97. package/docs/usage/plugins/ldap/organizations.md +37 -0
  98. package/docs/usage/plugins/utilities/storage.md +94 -0
  99. package/docs/usage/troubleshooting.md +1 -9
  100. package/docs/usage/upgrading.md +341 -0
  101. package/package.json +17 -1
@@ -15,20 +15,60 @@ whichever authorization plugin is in force.
15
15
  --plugin core/auth/authzScope
16
16
  ```
17
17
 
18
- It has no options. It answers through whichever loaded plugin carries the
19
- `authz` role _and_ can say who may do what where — one implementing
20
- `resolveUser`, `getAuthorizedBranches` and `getUserPermissions`. Both
18
+ It answers through a loaded plugin that carries the `authz` role _and_ can
19
+ say who may do what where — one implementing `resolveUser`,
20
+ `getAuthorizedBranches` and `getUserPermissions`. Both
21
21
  [authz-linid1](authz-linid1.md) and [authz-per-branch](authz-per-branch.md)
22
22
  do, and this endpoint works with either without knowing which.
23
23
 
24
- The role alone is not enough. [authz-per-route](authz-per-route.md) gates URLs
25
- and [authz-dynamic](authz-dynamic.md) reads a token: both carry the `authz`
26
- role and implement none of those three methods, so this endpoint passes them
27
- over — loading one beside a branch-level plugin changes the answer not at all.
28
- A server whose only authorization plugin is one of those has nothing to
29
- describe a scope with, and the answer is the unrestricted one shown below: a
30
- client built on it shows every button, and a refusal, when it comes, comes from
31
- the route or the token rather than from the scope.
24
+ | CLI | Env | Default | Description |
25
+ | ---------------------- | ----------------------- | ------- | ------------------------------------------------------------- |
26
+ | `--authz-scope-source` | `DM_AUTHZ_SCOPE_SOURCE` | | The plugin, by instance name, that describes a caller's scope |
27
+
28
+ ### Which plugin answers
29
+
30
+ The caller is resolved under the name the hooks key on — `req.user`, or
31
+ `req.userName` with `--authz-identity req.userName` — so the scope described
32
+ is the one enforced.
33
+
34
+ Only a plugin that judges the caller answers for them: one scoped with
35
+ `authz_for` to other authentication plugins is not this caller's model, and
36
+ is passed over (see [several authorization
37
+ plugins](README.md#several-authorization-plugins)).
38
+
39
+ When more than one judges the caller — two branch-level plugins combined with
40
+ `--authz-combine` — the hooks apply both and this endpoint can describe only
41
+ one. `--authz-scope-source` names it. Without it, the first loaded answers,
42
+ and for plugins outside the priority list that is import order, which nobody
43
+ chose: the server says so at startup, and the answer lists every plugin
44
+ judging the caller under `sources`, the one it comes from under `source`. A
45
+ name that is not a loaded plugin able to describe a scope is refused at
46
+ startup.
47
+
48
+ ### When nothing can describe the scope
49
+
50
+ [authz-per-route](authz-per-route.md) gates URLs and
51
+ [authz-dynamic](authz-dynamic.md) reads a token: both carry the `authz` role,
52
+ neither resolves a user or a branch. When they judge the caller and no
53
+ branch-level plugin does, no model can say what the caller may do, and the
54
+ answer says that rather than guessing:
55
+
56
+ ```json
57
+ {
58
+ "user": "alice",
59
+ "unrestricted": false,
60
+ "described": false,
61
+ "source": null,
62
+ "sources": ["authzPerRoute"],
63
+ "branches": [],
64
+ "entities": []
65
+ }
66
+ ```
67
+
68
+ This endpoint used to answer `unrestricted: true` with `create: true` on
69
+ every entity there — an authorization judgement handed to a client that acts
70
+ on it, while the route or the token refused. A client that reads an entity
71
+ missing from `entities` as not creatable offers nothing.
32
72
 
33
73
  ## Endpoint
34
74
 
@@ -40,6 +80,9 @@ GET /api/v1/authz/scope
40
80
  {
41
81
  "user": "uid=alice,ou=users,dc=example,dc=com",
42
82
  "unrestricted": false,
83
+ "described": true,
84
+ "source": "authzLinid1",
85
+ "sources": ["authzLinid1"],
43
86
  "branches": [
44
87
  {
45
88
  "dn": "ou=Sales,ou=organization,dc=example,dc=com",
@@ -77,13 +120,16 @@ is where a new organization goes when the client names no parent. A local admini
77
120
  `create: false` for organizations, and creates sub-organizations under their own
78
121
  node by naming it as `parentDn`.
79
122
 
80
- With no authorization plugin loaded the server grants everything, and the
81
- answer says so:
123
+ With no authorization plugin judging the caller the server grants
124
+ everything, and the answer says so:
82
125
 
83
126
  ```json
84
127
  {
85
128
  "user": "alice",
86
129
  "unrestricted": true,
130
+ "described": true,
131
+ "source": null,
132
+ "sources": [],
87
133
  "branches": [],
88
134
  "entities": [
89
135
  { "name": "users", "base": "ou=users,dc=example,dc=com", "create": true }
@@ -93,9 +139,21 @@ answer says so:
93
139
 
94
140
  ## Errors
95
141
 
96
- | Status | When |
97
- | ------ | ------------------------------------------------------------- |
98
- | `401` | No authenticated user, or a user the directory cannot resolve |
142
+ | Status | When |
143
+ | ------ | ------------------------------------------------------------------------------------------- |
144
+ | `401` | No authenticated user while any authorization plugin is loaded, or a user it cannot resolve |
145
+
146
+ An anonymous caller is refused as soon as anything authorizes: every
147
+ authorization plugin skips a request without an identity, so nothing would
148
+ judge them and the answer would be `unrestricted: true` — more than an
149
+ identified caller of the same server is told. A server loading no
150
+ authorization plugin at all answers `unrestricted` to anyone, anonymous
151
+ included, which is what it does.
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.
99
157
 
100
158
  ## See also
101
159
 
@@ -0,0 +1,86 @@
1
+ # Back-Channel Logout
2
+
3
+ Honouring a logout the provider performed elsewhere.
4
+
5
+ ## Overview
6
+
7
+ When someone logs out at the OpenID Provider — or an administrator ends their
8
+ sessions — the provider sends a **logout token** to every application that
9
+ asked for one. Without it, an application keeps honouring its own cookie: the
10
+ session stays alive until it expires on its own, which is exactly what a
11
+ logout is supposed to prevent.
12
+
13
+ `core/bcl` records what such a token killed, and `core/auth/openidconnect`
14
+ asks, on every request, whether the session it holds is still alive.
15
+
16
+ **Nothing here stores a session.** A logout token only leaves a _tombstone_
17
+ saying that one `sid`, or every session of one `sub`, is dead. Sessions stay
18
+ in their cookie, and the storage holds only what died — which is what keeps
19
+ it small.
20
+
21
+ ## Configuration
22
+
23
+ ```bash
24
+ --plugin core/auth/openidconnect \
25
+ --plugin core/storage --storage-backend file --storage-file-directory /var/lib/ldap-rest/storage \
26
+ --plugin core/bcl --bcl-retention 604800
27
+ ```
28
+
29
+ | Option | Environment | Default | Description |
30
+ | ----------------- | ------------------ | -------- | --------------------------- |
31
+ | `--bcl-retention` | `DM_BCL_RETENTION` | `604800` | Seconds a tombstone is kept |
32
+
33
+ Where the marks live, and how often expired ones are reclaimed, belongs to
34
+ [`core/storage`](../utilities/storage.md) — `core/bcl` keeps only the policy.
35
+
36
+ At the provider, register the back-channel logout URI:
37
+
38
+ ```
39
+ https://ldap-rest.example.com/backchannel-logout
40
+ ```
41
+
42
+ ## Retention
43
+
44
+ A tombstone has to outlive the session it kills, or a cookie older than the
45
+ mark would be honoured again. The default matches `express-openid-connect`'s
46
+ own seven-day session; shorten it only alongside the session lifetime.
47
+
48
+ Expiry is enforced when the mark is read, not only when the sweeper passes,
49
+ so a late sweeper never keeps out someone who has since logged in again.
50
+
51
+ ## `core/bcl` Refuses to Start Without Storage
52
+
53
+ `core/storage` must be loaded and `--storage-backend` set, or the plugin
54
+ throws at startup.
55
+
56
+ That is deliberate. A logout check that quietly does nothing would leave
57
+ every session outliving the logout that closed it, and nothing would say so.
58
+ For a feature that can be skipped, a warning is the right answer; for this
59
+ one, a refusal to start is the honest failure.
60
+
61
+ The refusal answers for the configuration, not for the order it is written
62
+ in. `core/bcl` declares `core/storage` as a dependency, so a configuration
63
+ naming only `core/bcl` loads the store anyway; and `core/storage` is in the
64
+ priority list, so one that names both gets a single instance whichever comes
65
+ first.
66
+
67
+ ## What a Login Forgets
68
+
69
+ A mark on the `sub` kills every session of that person. Left in place it
70
+ would also kill the sessions created _after_ it, for the whole retention —
71
+ so a successful login clears the marks matching its own claims.
72
+
73
+ This has a consequence worth knowing, which the SDK's own default shares: a
74
+ login also revives sessions that a "log out everywhere" token killed without
75
+ naming a `sid`.
76
+
77
+ ## Failures Are Not Swallowed
78
+
79
+ If the store cannot record a tombstone, the provider is answered `400` rather
80
+ than `204`. A provider retries a `400`; a `204` tells it a logout was honoured
81
+ that nothing kept.
82
+
83
+ ## See Also
84
+
85
+ - [Storage](../utilities/storage.md) — where the marks live
86
+ - [OpenID Connect](oidc.md)
@@ -15,6 +15,35 @@ Integration with [LemonLDAP::NG](https://lemonldap-ng.org/) (LLNG) Web SSO solut
15
15
  DM_LLNG_INI="/etc/lemonldap-ng/lemonldap-ng.ini"
16
16
  ```
17
17
 
18
+ ## Rules that name nobody
19
+
20
+ The plugin serves a request only when the handler named a user. A LemonLDAP::NG
21
+ location rule set to `skip` makes the handler return at once — no session
22
+ read, no `Lm-Remote-User` written — and such a request is refused with 401
23
+ rather than served with no identity: every authorization plugin skips its
24
+ check when a request carries no identity, so "no authentication on this path"
25
+ would have become "authenticated, and scoped by nobody".
26
+
27
+ Serve such a path from a virtual host this plugin does not guard, or scope
28
+ the plugin with `auth_path_prefix` so the path falls outside it.
29
+
30
+ The identity header is read whatever case the handler wrote it in, and
31
+ whatever the client sent under that name is dropped before the handler runs:
32
+ only the handler may name the caller.
33
+
34
+ `req.user` is what `whatToTrace` traces, which is often a mail or a display
35
+ name. `--llng-username-header` names a second header LemonLDAP::NG exports
36
+ carrying the login, published as `req.userName`; with
37
+ `--authz-identity req.userName`, rules are then written on logins. Export it
38
+ from the LLNG configuration as you would any other header.
39
+
40
+ Whatever the request carried under that name is dropped before the handler
41
+ runs, exactly as the identity header is: a rule can be keyed on
42
+ `req.userName`, so a client supplying it would otherwise take over the
43
+ identity that rule names. The consequence is that the value must come from
44
+ the handler — a header set by an upstream proxy does not survive, and would
45
+ only have been trustworthy if that proxy stripped the client's copy itself.
46
+
18
47
  ## Prerequisites
19
48
 
20
49
  1. **LemonLDAP::NG Handler**: The `lemonldap-ng-handler` npm package (optional dependency)
@@ -35,6 +35,56 @@ Install the optional dependency:
35
35
  npm install express-openid-connect
36
36
  ```
37
37
 
38
+ ## Scope
39
+
40
+ Like every other authentication plugin, this one registers with the server's
41
+ authentication dispatcher, so `auth_path_prefix` says which paths it guards:
42
+
43
+ ```bash
44
+ --plugin 'core/auth/openidconnect:oidc:{"auth_path_prefix":"/api/admin"}'
45
+ ```
46
+
47
+ Until 0.9.0 the option was accepted and ignored — the plugin mounted its own
48
+ middleware and guarded the whole server — and the layers it mounted kept
49
+ their registration order, so a route plugin loading before it was served
50
+ without a session and an authorization plugin loading after it judged rules
51
+ before `req.user` existed. See
52
+ [the note](../../upgrading.md#openid-connect-honours-auth_path_prefix).
53
+
54
+ Its own routes come with it: `/login`, `/logout`, `/callback` and
55
+ `/backchannel-logout` are claimed whatever the scope, so the provider can
56
+ always reach the callback and post a logout token, and a catch-all
57
+ authentication never answers them with a 401.
58
+
59
+ Two instances are supported — `auth()` builds a fresh router per instance —
60
+ but `--base-url` and the callback path are global, so both share one
61
+ redirect URI. Scope by prefix, not by provider, unless you also front them
62
+ with different base URLs.
63
+
64
+ ## The identity it publishes
65
+
66
+ `req.user` is the OIDC `sub` — an opaque provider identifier, `auth0|…` or a
67
+ UUID — which is what authorization rules are keyed on by default, and what
68
+ nobody can write a rule for without looking it up per administrator.
69
+
70
+ `--oidc-username-claim` names the claim published as `req.userName`:
71
+
72
+ ```bash
73
+ --oidc-username-claim preferred_username
74
+ ```
75
+
76
+ Ask the provider for the scope that carries it (`profile`, or `email` for
77
+ `email`), then key the authorization plugins on it with
78
+ `--authz-identity req.userName`. A session missing the configured claim falls
79
+ back to the `sub`, with a warning naming the claim.
80
+
81
+ ## What an unauthenticated request receives
82
+
83
+ The library's own answer: a browser is redirected to the provider, an API
84
+ client (`Accept: application/json`) receives 401. Every other authentication
85
+ plugin in this repository answers JSON 401 throughout — the difference is
86
+ kept because it is what makes a login work at all.
87
+
38
88
  ## How It Works
39
89
 
40
90
  1. Uses `express-openid-connect` for OAuth2/OIDC flow
@@ -163,5 +213,7 @@ npm install express-openid-connect
163
213
 
164
214
  ## See Also
165
215
 
216
+ - [Back-Channel Logout](back-channel-logout.md) — honouring a logout the
217
+ provider performed elsewhere
166
218
  - [OpenID Connect Specification](https://openid.net/connect/)
167
219
  - [express-openid-connect](https://github.com/auth0/express-openid-connect)
@@ -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.