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.
- package/bin/index.mjs +1 -1
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/auth/base.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/authz/composition.js +1 -0
- package/dist/lib/changeContext.js +1 -0
- package/dist/lib/errors.js +1 -1
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapCodes.js +1 -0
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/storage/store.js +1 -0
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/authzDynamic.js +1 -1
- package/dist/plugins/auth/authzLinid1.js +1 -1
- package/dist/plugins/auth/authzPerBranch.js +1 -1
- package/dist/plugins/auth/authzPerRoute.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/auth/hmac.js +1 -1
- package/dist/plugins/auth/llng.js +1 -1
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/auth/token.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/bcl.js +1 -0
- package/dist/plugins/ldap/departmentSync.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/priority.json.js +1 -1
- package/dist/plugins/scim/baseResolver.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/scim/groups.js +1 -1
- package/dist/plugins/scim/list.js +1 -1
- package/dist/plugins/scim/scim.js +1 -1
- package/dist/plugins/scim/users.js +1 -1
- package/dist/plugins/storage/file.js +1 -0
- package/dist/plugins/storage/ldap.js +1 -0
- package/dist/plugins/storage.js +1 -0
- package/dist/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/plugins/twake/clouderyProvision.js +1 -1
- package/dist/plugins/twake/cozyProvision.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +7 -0
- package/dist/src/abstract/plugin.d.ts +26 -0
- package/dist/src/bin/index.d.ts +50 -8
- package/dist/src/config/args.d.ts +36 -0
- package/dist/src/hooks.d.ts +70 -6
- package/dist/src/lib/auth/base.d.ts +175 -9
- package/dist/src/lib/authz/base.d.ts +159 -4
- package/dist/src/lib/authz/composition.d.ts +145 -0
- package/dist/src/lib/changeContext.d.ts +24 -0
- package/dist/src/lib/errors.d.ts +15 -0
- package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
- package/dist/src/lib/ldapActions.d.ts +32 -1
- package/dist/src/lib/ldapCodes.d.ts +28 -0
- package/dist/src/lib/storage/store.d.ts +84 -0
- package/dist/src/lib/utils.d.ts +19 -0
- package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
- package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
- package/dist/src/plugins/auth/authzPerBranch.d.ts +46 -0
- package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
- package/dist/src/plugins/auth/authzScope.d.ts +44 -3
- package/dist/src/plugins/auth/hmac.d.ts +2 -0
- package/dist/src/plugins/auth/llng.d.ts +39 -0
- package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
- package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
- package/dist/src/plugins/auth/token.d.ts +2 -0
- package/dist/src/plugins/auth/totp.d.ts +2 -0
- package/dist/src/plugins/bcl.d.ts +65 -0
- package/dist/src/plugins/ldap/groups.d.ts +7 -5
- package/dist/src/plugins/ldap/onChange.d.ts +17 -8
- package/dist/src/plugins/ldap/organizations.d.ts +92 -3
- package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
- package/dist/src/plugins/scim/errors.d.ts +2 -10
- package/dist/src/plugins/storage/file.d.ts +19 -0
- package/dist/src/plugins/storage/ldap.d.ts +17 -0
- package/dist/src/plugins/storage.d.ts +44 -0
- package/docs/plugin-development/README.md +13 -19
- package/docs/plugin-development/dependencies.md +40 -15
- package/docs/plugin-development/hooks.md +6 -0
- package/docs/plugin-development/testing.md +1 -17
- package/docs/usage/configuration.md +90 -14
- package/docs/usage/plugins/README.md +17 -12
- package/docs/usage/plugins/auth/README.md +129 -0
- package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
- package/docs/usage/plugins/auth/authz-linid1.md +33 -0
- package/docs/usage/plugins/auth/authz-per-branch.md +41 -11
- package/docs/usage/plugins/auth/authz-per-route.md +6 -0
- package/docs/usage/plugins/auth/authz-scope.md +74 -16
- package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
- package/docs/usage/plugins/auth/llng.md +29 -0
- package/docs/usage/plugins/auth/oidc.md +52 -0
- package/docs/usage/plugins/ldap/on-change.md +51 -284
- package/docs/usage/plugins/ldap/organizations.md +37 -0
- package/docs/usage/plugins/utilities/storage.md +94 -0
- package/docs/usage/troubleshooting.md +1 -9
- package/docs/usage/upgrading.md +341 -0
- 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
|
|
19
|
-
|
|
20
|
-
`
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
37
|
+
The other hooks are derived from it:
|
|
147
38
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
The entry is then read with `*` and every attribute a loaded plugin declares.
|
|
219
68
|
|
|
220
|
-
|
|
69
|
+
## What counts as a change
|
|
221
70
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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.
|