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
@@ -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
- * Catch all deletion to remove deleted users from groups
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
- * Check for ldap modify events and generate hooks:
4
- * - onLdapChange
5
- * - onLdapMailChange
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
- notify(dn: string, changes: ChangesToNotify): void;
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): Promise<void>;
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 changes - The changes object
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(changes: ChangesToNotify, index: 0 | 1): string | null;
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
- searchOrganisationSubnodes(dn: string, query: string): Promise<AttributesList[]>;
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 { DmRequest } from '../../lib/auth/base';
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
- ```bash
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 `src/plugins/priority.json`:
538
-
539
- ```json
540
- [
541
- "core/auth/crowdsec",
542
- "core/auth/rateLimit",
543
- "core/auth/token",
544
- "core/auth/llng",
545
- "core/auth/openidconnect",
546
- "core/auth/authzPerBranch",
547
- "core/auth/authzLinid1"
548
- ]
549
- ```
550
-
551
- Plugins in this list load before others, ensuring security plugins run first.
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
- **Using embedded LDAP (no setup required):**
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