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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module plugins/bcl
|
|
3
|
+
*
|
|
4
|
+
* Back-Channel Logout: what a provider tells us is dead, and what we do with
|
|
5
|
+
* it on the next request.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here stores a session. `core/auth/openidconnect` keeps that in its
|
|
8
|
+
* cookie; a logout token only leaves a **tombstone** saying that one `sid`, or
|
|
9
|
+
* every session of one `sub`, is dead, and each request asks whether such a
|
|
10
|
+
* mark exists. Recording only what is dead is what keeps the sessions
|
|
11
|
+
* stateless and the storage small.
|
|
12
|
+
*
|
|
13
|
+
* Where the marks live is `core/storage`'s business, not this plugin's. What
|
|
14
|
+
* stays here is the policy: which keys a token kills, how long a mark is
|
|
15
|
+
* kept, and what a login forgets.
|
|
16
|
+
*
|
|
17
|
+
* @group Plugins
|
|
18
|
+
*/
|
|
19
|
+
import DmPlugin from '../abstract/plugin';
|
|
20
|
+
import type { DM } from '../bin';
|
|
21
|
+
import type { OidcLogoutToken, OidcSessionClaims } from '../hooks';
|
|
22
|
+
export default class Bcl extends DmPlugin {
|
|
23
|
+
name: string;
|
|
24
|
+
dependencies: {
|
|
25
|
+
storage: string;
|
|
26
|
+
};
|
|
27
|
+
private storage?;
|
|
28
|
+
/** How long a tombstone is kept, in milliseconds. */
|
|
29
|
+
private retention;
|
|
30
|
+
constructor(server: DM);
|
|
31
|
+
/**
|
|
32
|
+
* Take hold of the store.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately not the constructor. A plugin is built before
|
|
35
|
+
* `registerPlugin` reads its `dependencies` and loads what they name, so a
|
|
36
|
+
* constructor asking for the store answers for the order of two `--plugin`
|
|
37
|
+
* arguments rather than for the configuration: `core/bcl` listed first
|
|
38
|
+
* would be refused for a storage that is right there, and the declaration
|
|
39
|
+
* above — the thing meant to make order irrelevant — would have loaded it
|
|
40
|
+
* a moment later. `api()` runs after that loop, which is where
|
|
41
|
+
* `requirePlugin`'s own promise starts holding.
|
|
42
|
+
*/
|
|
43
|
+
api(): void;
|
|
44
|
+
/**
|
|
45
|
+
* The store, once `api()` has taken hold of it.
|
|
46
|
+
*
|
|
47
|
+
* `registerPlugin` calls `api()` before it subscribes anything, so a hook
|
|
48
|
+
* running without one means a plugin built by hand and never registered —
|
|
49
|
+
* a test, in practice. Saying so beats reading `undefined` as "nothing is
|
|
50
|
+
* recorded", which is the fail-open this plugin exists to prevent.
|
|
51
|
+
*/
|
|
52
|
+
private get store();
|
|
53
|
+
/**
|
|
54
|
+
* The keys a token kills.
|
|
55
|
+
*
|
|
56
|
+
* A provider sends `sid`, or `sub`, or both: `sid` ends one session, `sub`
|
|
57
|
+
* ends every session of that person.
|
|
58
|
+
*/
|
|
59
|
+
private static keys;
|
|
60
|
+
hooks: {
|
|
61
|
+
oidclogouttoken: (token: OidcLogoutToken) => Promise<void>;
|
|
62
|
+
oidclogin: (claims: OidcSessionClaims) => Promise<void>;
|
|
63
|
+
oidcsessionvalid: ([claims, valid]: [OidcSessionClaims, boolean]) => Promise<[OidcSessionClaims, boolean]>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -87,7 +87,9 @@ export default class LdapGroups extends DmPlugin {
|
|
|
87
87
|
schema?: Schema;
|
|
88
88
|
constructor(server: DM);
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
90
|
+
* Remove deleted entries from groups once the delete has landed: a delete
|
|
91
|
+
* refused, failed or turned into something else by another plugin leaves
|
|
92
|
+
* the memberships alone.
|
|
91
93
|
*/
|
|
92
94
|
hooks: Hooks;
|
|
93
95
|
/**
|
|
@@ -123,14 +125,14 @@ export default class LdapGroups extends DmPlugin {
|
|
|
123
125
|
/**
|
|
124
126
|
* LDAP group methods
|
|
125
127
|
*/
|
|
126
|
-
addGroup(cn: string, members?: string[], additional?: AttributesList): Promise<boolean>;
|
|
128
|
+
addGroup(cn: string, members?: string[], additional?: AttributesList, req?: Request): Promise<boolean>;
|
|
127
129
|
modifyGroup(cn: string, changes: {
|
|
128
130
|
add?: AttributesList;
|
|
129
131
|
replace?: AttributesList;
|
|
130
132
|
delete?: string[] | AttributesList;
|
|
131
|
-
}): Promise<boolean>;
|
|
132
|
-
renameGroup(cn: string, newCn: string): Promise<boolean>;
|
|
133
|
-
deleteGroup(cn: string): Promise<boolean>;
|
|
133
|
+
}, req?: Request): Promise<boolean>;
|
|
134
|
+
renameGroup(cn: string, newCn: string, req?: Request): Promise<boolean>;
|
|
135
|
+
deleteGroup(cn: string, req?: Request): Promise<boolean>;
|
|
134
136
|
addMember(cn: string, member: string | string[]): Promise<boolean>;
|
|
135
137
|
deleteMember(cn: string, member: string): Promise<boolean>;
|
|
136
138
|
deleteMemberFromAll(memberDn: string): Promise<void>;
|
|
@@ -1,33 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module core/onLdapChange
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Publish the state of an entry before and after each write
|
|
4
|
+
* (onLdapEntryChange), and the hooks derived from it: onLdapChange,
|
|
5
|
+
* onLdapMailChange…
|
|
6
6
|
* @author Xavier Guimard <xguimard@linagora.com>
|
|
7
7
|
*/
|
|
8
8
|
import { Entry } from 'ldapts';
|
|
9
9
|
import DmPlugin, { type Role } from '../../abstract/plugin';
|
|
10
10
|
import type { Hooks } from '../../hooks';
|
|
11
11
|
import type { AttributeValue } from '../../lib/ldapActions';
|
|
12
|
+
import type { ChangeContext } from '../../lib/changeContext';
|
|
12
13
|
export type ChangesToNotify = Record<string, [
|
|
13
14
|
AttributeValue | null,
|
|
14
15
|
AttributeValue | null
|
|
15
16
|
]>;
|
|
17
|
+
/**
|
|
18
|
+
* The attributes whose values differ between two states of an entry. Values
|
|
19
|
+
* are compared as sets, so a single value and a one-element array are equal,
|
|
20
|
+
* and so are two orderings of the same values.
|
|
21
|
+
*/
|
|
22
|
+
export declare function diffEntries(before: Entry | null, after: Entry | null): ChangesToNotify;
|
|
16
23
|
declare class OnLdapChange extends DmPlugin {
|
|
17
24
|
name: string;
|
|
18
25
|
roles: Role[];
|
|
19
26
|
stack: Record<number, Entry>;
|
|
20
27
|
pendingDeletions: Map<string, Entry>;
|
|
28
|
+
pendingRenames: Map<string, Entry>;
|
|
21
29
|
hooks: Hooks;
|
|
22
|
-
|
|
30
|
+
read(dn: string): Promise<Entry | undefined>;
|
|
31
|
+
publish(dn: string, before: Entry | null, after: Entry | null, context?: ChangeContext): void;
|
|
32
|
+
notify(dn: string, changes: ChangesToNotify, before: Entry | null, after: Entry | null): void;
|
|
23
33
|
notifyAttributeChange(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, stringOnly?: boolean): void;
|
|
24
|
-
notifyAttributeChangeWithMail(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify):
|
|
34
|
+
notifyAttributeChangeWithMail(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, after: Entry | null): void;
|
|
25
35
|
/**
|
|
26
36
|
* Reconstruct display name from cn, givenName, and sn attributes
|
|
27
|
-
* @param
|
|
28
|
-
* @param index - 0 for old value, 1 for new value
|
|
37
|
+
* @param entry - The entry, before or after the change
|
|
29
38
|
* @returns The reconstructed display name or null
|
|
30
39
|
*/
|
|
31
|
-
reconstructDisplayName(
|
|
40
|
+
reconstructDisplayName(entry: Entry | null): string | null;
|
|
32
41
|
}
|
|
33
42
|
export default OnLdapChange;
|
|
@@ -139,8 +139,82 @@ export default class LdapOrganizations extends DmPlugin {
|
|
|
139
139
|
getOrganisationTop(req?: Request): Promise<AttributesList | AttributesList[]>;
|
|
140
140
|
getOrganisationByDn(dn: string, req?: Request): Promise<AttributesList>;
|
|
141
141
|
getOrganisationSubnodes(dn: string, req?: Request): Promise<AttributesList[]>;
|
|
142
|
+
/** Nodes already warned about, so a crowded tree says it once per node. */
|
|
143
|
+
private sizeLimitWarned;
|
|
144
|
+
/** How many of them to remember. */
|
|
145
|
+
private static readonly SIZE_LIMIT_WARNED_MAX;
|
|
146
|
+
/**
|
|
147
|
+
* Say once per node what only the directory can fix.
|
|
148
|
+
*
|
|
149
|
+
* A console expanding a tree lists the same crowded node on every refresh,
|
|
150
|
+
* and a warning per listing buries the rest of the log while saying
|
|
151
|
+
* nothing new: the answer stays partial until someone raises the size
|
|
152
|
+
* limit. The first listing carries the message, the others go to `debug`.
|
|
153
|
+
*
|
|
154
|
+
* The set is bounded, so a directory with thousands of crowded nodes
|
|
155
|
+
* cannot grow it without end. Past the bound the message is written every
|
|
156
|
+
* time — the loud side of the trade rather than the silent one.
|
|
157
|
+
*
|
|
158
|
+
* @param key node the message is about
|
|
159
|
+
* @param message what to say
|
|
160
|
+
*/
|
|
161
|
+
private warnSizeLimit;
|
|
162
|
+
/**
|
|
163
|
+
* The child organizations of a node, and what to do when the directory
|
|
164
|
+
* will not list them all.
|
|
165
|
+
*
|
|
166
|
+
* Every failure used to be read as "no children" (#179): the node's own
|
|
167
|
+
* children were searched without a limit, and a directory answering
|
|
168
|
+
* `sizeLimitExceeded` rather than a long list left the endpoint returning
|
|
169
|
+
* `200 []` with nothing above `debug` to say so. An empty tree is what a
|
|
170
|
+
* console then draws for a branch holding a thousand organizations.
|
|
171
|
+
*
|
|
172
|
+
* Three outcomes, and only one of them is emptiness:
|
|
173
|
+
*
|
|
174
|
+
* - `noSuchObject` (32): nothing is there. An empty answer is the truth.
|
|
175
|
+
* - `sizeLimitExceeded` (4): the answer was longer than the server's
|
|
176
|
+
* limit. Measured against OpenLDAP 2.5, the paged control does not lift
|
|
177
|
+
* it — `size.prtotal` bounds the whole search, so a paged walk ends on
|
|
178
|
+
* the same refusal. What does change the answer is asking for a bounded
|
|
179
|
+
* number: ldapts raises the refusal only when the request carried no
|
|
180
|
+
* `sizeLimit` of its own, so a second, bounded search comes back with
|
|
181
|
+
* entries where the first came back with an error. The answer then says
|
|
182
|
+
* it is partial, through the `moreIndicator` row the attached entries
|
|
183
|
+
* already use.
|
|
184
|
+
* - anything else is a failure, and answering `[]` to a failure is how a
|
|
185
|
+
* broken directory comes to look exactly like an empty one.
|
|
186
|
+
*
|
|
187
|
+
* The first search is paged for the directories where paging *is* what
|
|
188
|
+
* lifts the limit: Active Directory answers an unpaged search with at most
|
|
189
|
+
* `MaxPageSize` entries and expects the control for the rest.
|
|
190
|
+
*
|
|
191
|
+
* @param dn organization whose children are wanted
|
|
192
|
+
* @param filter what those children have to match
|
|
193
|
+
* @param cap how many to keep when the directory will not list them all
|
|
194
|
+
* @param req incoming request, forwarded to the authorization hooks
|
|
195
|
+
* @returns the children, followed by a `moreIndicator` row when the
|
|
196
|
+
* directory refused to list them all
|
|
197
|
+
*/
|
|
198
|
+
private childOrganizations;
|
|
199
|
+
/**
|
|
200
|
+
* The entries attached to an organization without being one.
|
|
201
|
+
*
|
|
202
|
+
* Capped at `cap`, with a `moreIndicator` row counting what was left out —
|
|
203
|
+
* the behaviour this endpoint already had, plus the refusal the children
|
|
204
|
+
* search just learned to survive: a directory that will not walk the whole
|
|
205
|
+
* link filter is told about rather than turned into an empty list.
|
|
206
|
+
*
|
|
207
|
+
* @param baseDn branch the attached entries live in
|
|
208
|
+
* @param filter what links them to the organization
|
|
209
|
+
* @param cap how many to return
|
|
210
|
+
* @param dn organization they are attached to, for the indicator's DN
|
|
211
|
+
* @param req incoming request, forwarded to the authorization hooks
|
|
212
|
+
* @returns the entries, followed by a `moreIndicator` row when some were
|
|
213
|
+
* left out
|
|
214
|
+
*/
|
|
215
|
+
private linkedEntities;
|
|
142
216
|
addOrganization(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
|
|
143
|
-
modifyOrganization(dn: string, changes: ModifyRequest): Promise<boolean>;
|
|
217
|
+
modifyOrganization(dn: string, changes: ModifyRequest, req?: Request): Promise<boolean>;
|
|
144
218
|
validateNewOrganization(dn: string, entry: AttributesList): Promise<boolean>;
|
|
145
219
|
/**
|
|
146
220
|
* Build the rejection message for a value that failed its schema `test`,
|
|
@@ -167,8 +241,23 @@ export default class LdapOrganizations extends DmPlugin {
|
|
|
167
241
|
moveOrganization(dn: string, targetOrgDn: string, req?: Request): Promise<{
|
|
168
242
|
newDn: string;
|
|
169
243
|
}>;
|
|
170
|
-
deleteOrganization(dn: string): Promise<boolean>;
|
|
171
|
-
|
|
244
|
+
deleteOrganization(dn: string, req?: Request): Promise<boolean>;
|
|
245
|
+
/**
|
|
246
|
+
* The same two searches as `getOrganisationSubnodes`, narrowed by a query.
|
|
247
|
+
*
|
|
248
|
+
* Both go through the helpers that classify a failure rather than reading
|
|
249
|
+
* every one of them as emptiness (#179), and both now carry the request:
|
|
250
|
+
* they ran without one, and an authorization plugin skips its check when
|
|
251
|
+
* there is none — the same gap the flat routes had until 0.8.2. The
|
|
252
|
+
* attached entries are capped like the other endpoint's, since an answer
|
|
253
|
+
* the directory refuses to finish is what this is about.
|
|
254
|
+
*
|
|
255
|
+
* @param dn organization to search under
|
|
256
|
+
* @param query what to look for in a name, a description or an identity
|
|
257
|
+
* @param req incoming request, forwarded to the authorization hooks
|
|
258
|
+
* @returns matching organizations then matching attached entries
|
|
259
|
+
*/
|
|
260
|
+
searchOrganisationSubnodes(dn: string, query: string, req?: Request): Promise<AttributesList[]>;
|
|
172
261
|
/**
|
|
173
262
|
* Provide configuration for config API
|
|
174
263
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Config } from '../../config/args';
|
|
2
|
-
import type
|
|
2
|
+
import { type DmRequest } from '../../lib/auth/base';
|
|
3
3
|
export interface BaseMapEntry {
|
|
4
4
|
userBase?: string;
|
|
5
5
|
groupBase?: string;
|
|
@@ -14,6 +14,8 @@ export declare class BaseResolver {
|
|
|
14
14
|
private readonly userBaseHeader;
|
|
15
15
|
private readonly groupBaseHeader;
|
|
16
16
|
private readonly headerRoot;
|
|
17
|
+
/** Kept so a base is keyed on whatever `--authz-identity` names. */
|
|
18
|
+
private readonly config;
|
|
17
19
|
constructor(config: Config);
|
|
18
20
|
private applyTemplate;
|
|
19
21
|
/** Read a request header by name, tolerating Express req or a plain object. */
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
8
8
|
import { HttpError } from '../../lib/errors';
|
|
9
|
+
import { extractLdapCode } from '../../lib/ldapCodes';
|
|
10
|
+
export { extractLdapCode };
|
|
9
11
|
import { type ScimErrorResponse, type ScimErrorType } from './types';
|
|
10
12
|
export declare const SCIM_CONTENT_TYPE = "application/scim+json";
|
|
11
13
|
export declare class ScimError extends HttpError {
|
|
@@ -22,16 +24,6 @@ export declare const scimUniqueness: (detail?: string) => ScimError;
|
|
|
22
24
|
export declare const scimTooMany: (detail?: string) => ScimError;
|
|
23
25
|
export declare const scimNotFound: (detail?: string) => ScimError;
|
|
24
26
|
export declare function writeScimError(res: Response, status: number, detail: string, scimType?: ScimErrorType): void;
|
|
25
|
-
/**
|
|
26
|
-
* Extract an LDAP numeric error code from a thrown ldapts error.
|
|
27
|
-
*
|
|
28
|
-
* `.code` first: ldapts sets it, and `ldapActions` now carries it across its
|
|
29
|
-
* own wrapping rather than reducing the failure to a sentence. The message
|
|
30
|
-
* patterns below stay as a fallback for the paths that still throw a bare
|
|
31
|
-
* `Error` — they are why a driver that reworded its messages could quietly
|
|
32
|
-
* turn a 400 into a 500, so they are the last resort, not the first.
|
|
33
|
-
*/
|
|
34
|
-
export declare function extractLdapCode(err: unknown): number | undefined;
|
|
35
27
|
/**
|
|
36
28
|
* Turn any thrown thing into the SCIM error body it deserves.
|
|
37
29
|
*
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type winston from 'winston';
|
|
2
|
+
import Store, { type StoredRecord } from '../../lib/storage/store';
|
|
3
|
+
export default class FileStore extends Store {
|
|
4
|
+
name: string;
|
|
5
|
+
private dir;
|
|
6
|
+
/** How long a temporary file must sit before it counts as abandoned. */
|
|
7
|
+
private abandonedAfter;
|
|
8
|
+
constructor(logger: winston.Logger, dir: string, abandonedAfterSeconds: number);
|
|
9
|
+
/** The directory is created on demand, readable by this user alone. */
|
|
10
|
+
ready(): Promise<void>;
|
|
11
|
+
private path;
|
|
12
|
+
/** `<deadline> <key>\n<value>` — the deadline first, so a short read still has it. */
|
|
13
|
+
private static encode;
|
|
14
|
+
private static decode;
|
|
15
|
+
protected load(key: string): Promise<StoredRecord | null>;
|
|
16
|
+
protected save(key: string, record: StoredRecord): Promise<void>;
|
|
17
|
+
protected drop(key: string): Promise<void>;
|
|
18
|
+
sweep(): Promise<number>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Store, { type StoredRecord } from '../../lib/storage/store';
|
|
2
|
+
import type { DM } from '../../bin';
|
|
3
|
+
export default class LdapStore extends Store {
|
|
4
|
+
name: string;
|
|
5
|
+
private server;
|
|
6
|
+
private base;
|
|
7
|
+
private objectClass;
|
|
8
|
+
constructor(server: DM, base: string, objectClass: string);
|
|
9
|
+
private dn;
|
|
10
|
+
/** `<deadline> <key>\n<value>`, the deadline first as in the file store. */
|
|
11
|
+
private static encode;
|
|
12
|
+
private static decode;
|
|
13
|
+
protected load(key: string): Promise<StoredRecord | null>;
|
|
14
|
+
protected save(key: string, record: StoredRecord): Promise<void>;
|
|
15
|
+
protected drop(key: string): Promise<void>;
|
|
16
|
+
sweep(): Promise<number>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module plugins/storage
|
|
3
|
+
*
|
|
4
|
+
* Keyed storage, for whoever needs to keep something with a deadline.
|
|
5
|
+
*
|
|
6
|
+
* One plugin, one backend, chosen by `--storage-backend`. Consumers ask this
|
|
7
|
+
* one for its store and work through a single interface; where the records
|
|
8
|
+
* actually live is a deployment decision, not theirs.
|
|
9
|
+
*
|
|
10
|
+
* Having exactly one is the point. The alternative — each consumer loading
|
|
11
|
+
* the backend it likes — puts the same record in two places, and makes
|
|
12
|
+
* whichever plugin was registered first decide what happens when one of them
|
|
13
|
+
* is unwell. Registration order is invisible in a configuration, which is a
|
|
14
|
+
* poor place to keep a behaviour.
|
|
15
|
+
*
|
|
16
|
+
* A consumer reaches it with `requirePlugin('storage')` and works through
|
|
17
|
+
* `store`; it should not index `loadedPlugins` itself. And one that makes a
|
|
18
|
+
* security decision must not read "no storage" as "nothing to check":
|
|
19
|
+
* `requirePlugin` answers null and warns once, which is right for an optional
|
|
20
|
+
* feature and wrong for, say, a logout check — such a consumer refuses to
|
|
21
|
+
* load instead.
|
|
22
|
+
*
|
|
23
|
+
* What a consumer must not expect of this layer: it does not decide how long
|
|
24
|
+
* anything lives. A deadline comes in with the record, because retention is
|
|
25
|
+
* the policy of whoever wrote it — the moment a backend decides for itself,
|
|
26
|
+
* two of them can disagree about when the same record died.
|
|
27
|
+
*
|
|
28
|
+
* @group Plugins
|
|
29
|
+
*/
|
|
30
|
+
import DmPlugin from '../abstract/plugin';
|
|
31
|
+
import type { DM } from '../bin';
|
|
32
|
+
import type Store from '../lib/storage/store';
|
|
33
|
+
export default class Storage extends DmPlugin {
|
|
34
|
+
name: string;
|
|
35
|
+
store: Store;
|
|
36
|
+
constructor(server: DM);
|
|
37
|
+
/**
|
|
38
|
+
* Release the single-instance hold.
|
|
39
|
+
*
|
|
40
|
+
* For a test building several servers in one process; a running one has no
|
|
41
|
+
* reason to call it.
|
|
42
|
+
*/
|
|
43
|
+
static release(instance: Storage): void;
|
|
44
|
+
}
|
|
@@ -475,7 +475,18 @@ The `this.server.ldap` object provides access to all LDAP operations:
|
|
|
475
475
|
> `forRequest()` returns a `RequestBoundLdap`, whose methods have no request
|
|
476
476
|
> parameter at all — the omission stops being expressible. Use the unbound
|
|
477
477
|
> methods only for work that genuinely belongs to no request, such as startup
|
|
478
|
-
> or scheduled tasks, where passing nothing is the deliberate answer
|
|
478
|
+
> or scheduled tasks, where passing nothing is the deliberate answer — and
|
|
479
|
+
> say so by reaching for them through `ldap.system`:
|
|
480
|
+
>
|
|
481
|
+
> ```typescript
|
|
482
|
+
> await this.server.ldap.system.search({ scope: 'base' }, dn);
|
|
483
|
+
> ```
|
|
484
|
+
>
|
|
485
|
+
> It is the same object, and that is the point: `ldap.system.search(…)` reads
|
|
486
|
+
> as a decision where `ldap.search(…)` reads as a forgotten argument. Some
|
|
487
|
+
> reads are the server's by nature — a uniqueness check has to see a whole
|
|
488
|
+
> branch, and a reference to an entry the caller cannot read is still a valid
|
|
489
|
+
> reference — and those are the ones it names.
|
|
479
490
|
|
|
480
491
|
#### Search
|
|
481
492
|
|
|
@@ -1272,24 +1283,7 @@ describe('NotificationPlugin', function () {
|
|
|
1272
1283
|
|
|
1273
1284
|
### Running Tests
|
|
1274
1285
|
|
|
1275
|
-
|
|
1276
|
-
# Set up test environment
|
|
1277
|
-
cat > ~/.test-env <<EOF
|
|
1278
|
-
export DM_LDAP_URL="ldap://localhost:389"
|
|
1279
|
-
export DM_LDAP_DN="cn=admin,dc=example,dc=com"
|
|
1280
|
-
export DM_LDAP_PWD="admin"
|
|
1281
|
-
export DM_LDAP_BASE="dc=example,dc=com"
|
|
1282
|
-
EOF
|
|
1283
|
-
|
|
1284
|
-
# Run all tests
|
|
1285
|
-
source ~/.test-env && npm test
|
|
1286
|
-
|
|
1287
|
-
# Run specific test file
|
|
1288
|
-
source ~/.test-env && npm run test:one test/plugins/notification/webhook.test.ts
|
|
1289
|
-
|
|
1290
|
-
# Run tests in watch mode
|
|
1291
|
-
source ~/.test-env && npm run test:dev
|
|
1292
|
-
```
|
|
1286
|
+
See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
|
|
1293
1287
|
|
|
1294
1288
|
### Test Best Practices
|
|
1295
1289
|
|
|
@@ -76,6 +76,16 @@ dependencies = {
|
|
|
76
76
|
- **Consumes Hooks**: None
|
|
77
77
|
- **Notes**: LinID 1.x authorization integration
|
|
78
78
|
|
|
79
|
+
#### `bcl` (core/bcl)
|
|
80
|
+
|
|
81
|
+
- **Dependencies**: `core/storage` — **required**: a logout check with
|
|
82
|
+
nowhere to record what died would leave every session outliving the logout
|
|
83
|
+
that closed it, so the plugin refuses to start
|
|
84
|
+
- **Provides Hooks**: None
|
|
85
|
+
- **Consumes Hooks**: `oidclogouttoken`, `oidclogin`, `oidcsessionvalid`
|
|
86
|
+
- **Notes**: Back-Channel Logout. Records only the sessions a provider
|
|
87
|
+
declared dead; needs `core/auth/openidconnect` to raise those hooks
|
|
88
|
+
|
|
79
89
|
### LDAP Core Plugins
|
|
80
90
|
|
|
81
91
|
#### `onLdapChange` (core/ldap/onChange)
|
|
@@ -238,6 +248,15 @@ dependencies = {
|
|
|
238
248
|
- **Consumes Hooks**: None
|
|
239
249
|
- **Notes**: HTTP request logging
|
|
240
250
|
|
|
251
|
+
#### `storage` (core/storage)
|
|
252
|
+
|
|
253
|
+
- **Dependencies**: None (the `ldap` backend uses the server's connection)
|
|
254
|
+
- **Provides Hooks**: None
|
|
255
|
+
- **Consumes Hooks**: None
|
|
256
|
+
- **Notes**: Keyed storage with a deadline. Only one instance may be loaded —
|
|
257
|
+
a second one is refused rather than leaving registration order to decide
|
|
258
|
+
which answers
|
|
259
|
+
|
|
241
260
|
#### `configApi` (core/configApi)
|
|
242
261
|
|
|
243
262
|
- **Dependencies**: None (reads configuration from other loaded plugins)
|
|
@@ -531,24 +550,30 @@ graph TB
|
|
|
531
550
|
- `static` - Static files
|
|
532
551
|
- `weblogs` - Logging
|
|
533
552
|
- `configApi` - Configuration API
|
|
553
|
+
- `storage` - Keyed storage. Written anywhere in the configuration, but
|
|
554
|
+
loaded before the others: its consumers have to find it registered, so it
|
|
555
|
+
is in the priority list
|
|
534
556
|
|
|
535
557
|
## Plugin Priority System
|
|
536
558
|
|
|
537
|
-
LDAP-Rest uses a priority system defined in
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
559
|
+
LDAP-Rest uses a priority system defined in
|
|
560
|
+
[`src/plugins/priority.json`](../../src/plugins/priority.json), which is the
|
|
561
|
+
list itself — copying it here is how this section came to name two plugins
|
|
562
|
+
the file does not and to omit four it does.
|
|
563
|
+
|
|
564
|
+
Plugins in that list are loaded one at a time, in its order, before every
|
|
565
|
+
other plugin is loaded in parallel. Two reasons put a plugin there: a
|
|
566
|
+
security plugin whose middleware must sit ahead of the routes it guards, and
|
|
567
|
+
a plugin others depend on, which has to be registered before the batch its
|
|
568
|
+
consumers land in — `core/storage` is there for the second reason.
|
|
569
|
+
|
|
570
|
+
The list matches on the **module**, so a named instance
|
|
571
|
+
(`core/auth/token:authAdmins:{…}`) keeps its rank, and every instance of a
|
|
572
|
+
priority module is loaded in that first pass, in configuration order. It
|
|
573
|
+
used to be matched on the whole `--plugin` string, which sent exactly the
|
|
574
|
+
configurations that carry per-instance options into the parallel batch —
|
|
575
|
+
`core/auth/trustedProxy` named that way registered after the routes it
|
|
576
|
+
guards.
|
|
552
577
|
|
|
553
578
|
## Hook Execution Order
|
|
554
579
|
|
|
@@ -16,3 +16,9 @@ Typescript definitions into [hooks.ts](./src/hooks.ts)
|
|
|
16
16
|
- **ldapmodifyrequest**: called before any ldapmodify
|
|
17
17
|
- **ldapdeleterequest**: called before any ldapdelete
|
|
18
18
|
- **ldaprenamerequest**: called before any ldap rename/modifyDN operation
|
|
19
|
+
|
|
20
|
+
## [onChange](../usage/plugins/ldap/on-change.md) hooks
|
|
21
|
+
|
|
22
|
+
- **onLdapEntryChange**: called after any write, with the entry before and
|
|
23
|
+
after it, and who made it
|
|
24
|
+
- **onLdapChange**, **onLdapMailChange**…: derived from it
|
|
@@ -76,23 +76,7 @@ Loading initial LDIF from .../base-structure.ldif...
|
|
|
76
76
|
|
|
77
77
|
### Running Tests
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
npm test
|
|
83
|
-
npm run test:one test/plugins/ldap/flatGeneric.test.ts
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**Using external LDAP:**
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
export DM_LDAP_URL=ldap://localhost:389
|
|
90
|
-
export DM_LDAP_DN=cn=admin,dc=example,dc=com
|
|
91
|
-
export DM_LDAP_PWD=secret
|
|
92
|
-
export DM_LDAP_BASE=dc=example,dc=com
|
|
93
|
-
|
|
94
|
-
npm test
|
|
95
|
-
```
|
|
79
|
+
See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
|
|
96
80
|
|
|
97
81
|
### In CI/CD
|
|
98
82
|
|