ldap-rest 1.3.0 → 2.0.1

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 (82) hide show
  1. package/README.md +8 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/abstract/plugin.js +1 -1
  4. package/dist/bin/index.js +1 -1
  5. package/dist/config/options.js +1 -1
  6. package/dist/config/schema.js +1 -1
  7. package/dist/lib/authz/base.js +1 -1
  8. package/dist/lib/errors.js +1 -1
  9. package/dist/lib/ldap/callerView.js +1 -0
  10. package/dist/lib/ldap/pool.js +1 -0
  11. package/dist/lib/ldap/schema.js +1 -0
  12. package/dist/lib/ldap/searchCache.js +1 -0
  13. package/dist/lib/ldapActions.js +1 -1
  14. package/dist/lib/parseConfig.js +1 -1
  15. package/dist/lib/services/index.js +1 -0
  16. package/dist/lib/utils.js +1 -1
  17. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  18. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  19. package/dist/plugins/ldap/groups.js +1 -1
  20. package/dist/plugins/ldap/organizations.js +1 -1
  21. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  22. package/dist/plugins/ldap/raw.js +1 -1
  23. package/dist/plugins/ldap/trash.js +1 -1
  24. package/dist/plugins/scim/mapping.js +1 -1
  25. package/dist/plugins/scim/patch.js +1 -1
  26. package/dist/plugins/storage/file.js +1 -1
  27. package/dist/plugins/twake/instances.js +1 -1
  28. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  29. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  30. package/dist/plugins/twake/tombstone.js +1 -1
  31. package/dist/src/abstract/ldapFlat.d.ts +13 -0
  32. package/dist/src/abstract/plugin.d.ts +21 -0
  33. package/dist/src/bin/index.d.ts +38 -4
  34. package/dist/src/config/options.d.ts +16 -1
  35. package/dist/src/lib/errors.d.ts +2 -1
  36. package/dist/src/lib/ldap/callerView.d.ts +45 -0
  37. package/dist/src/lib/ldap/pool.d.ts +108 -0
  38. package/dist/src/lib/ldap/schema.d.ts +157 -0
  39. package/dist/src/lib/ldap/searchCache.d.ts +112 -0
  40. package/dist/src/lib/ldapActions.d.ts +25 -262
  41. package/dist/src/lib/parseConfig.d.ts +26 -2
  42. package/dist/src/lib/services/index.d.ts +56 -0
  43. package/dist/src/lib/utils.d.ts +72 -3
  44. package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
  45. package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
  46. package/dist/src/plugins/scim/mapping.d.ts +16 -3
  47. package/dist/src/plugins/scim/types.d.ts +2 -0
  48. package/dist/src/plugins/twake/instances.d.ts +31 -0
  49. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
  50. package/dist/src/plugins/twake/tombstone.d.ts +9 -2
  51. package/docs/README.md +25 -2
  52. package/docs/architecture/README.md +180 -0
  53. package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
  54. package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
  55. package/docs/architecture/adr/0003-authorization-composition.md +59 -0
  56. package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
  57. package/docs/architecture/adr/0005-hook-contract.md +54 -0
  58. package/docs/architecture/adr/0006-search-cache.md +49 -0
  59. package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
  60. package/docs/architecture/adr/0008-withconfig-views.md +58 -0
  61. package/docs/client-development/api/rest-api.md +4 -0
  62. package/docs/development/contracts.md +119 -0
  63. package/docs/development/modularization-plan.md +197 -0
  64. package/docs/plugin-development/README.md +8 -1
  65. package/docs/plugin-development/dependencies.md +13 -2
  66. package/docs/plugin-development/hooks.md +4 -2
  67. package/docs/plugin-development/options.md +40 -13
  68. package/docs/plugin-development/services.md +95 -0
  69. package/docs/usage/operations.md +12 -0
  70. package/docs/usage/plugins/integrations/instances.md +11 -3
  71. package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
  72. package/docs/usage/plugins/integrations/scim.md +20 -18
  73. package/docs/usage/plugins/integrations/tombstone.md +10 -6
  74. package/docs/usage/plugins/ldap/flat-generic.md +8 -1
  75. package/docs/usage/plugins/ldap/groups.md +4 -1
  76. package/docs/usage/plugins/ldap/password-policy.md +7 -0
  77. package/docs/usage/upgrading.md +59 -0
  78. package/package.json +21 -12
  79. package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
  80. package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
  81. package/static/browser/ldap-tree-viewer.js.map +1 -1
  82. package/static/browser/ldap-tree-viewer.min.js.map +1 -1
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Typed services between plugins: a token names the interface a consumer
3
+ * reads and the instance a provider offers, so the compiler checks both.
4
+ *
5
+ * @see docs/plugin-development/services.md
6
+ */
7
+ declare const serviceType: unique symbol;
8
+ /**
9
+ * What identifies a service: a name for the logs, and the interface it
10
+ * answers. The type is carried by a phantom property and never exists at
11
+ * runtime; the object itself is the registry key.
12
+ */
13
+ export interface ServiceToken<T> {
14
+ /** Names the service in the logs */
15
+ readonly name: string;
16
+ /** Type only, never set: carries the interface a consumer reads */
17
+ readonly [serviceType]?: T;
18
+ /**
19
+ * Bind the instance providing this service, as a plugin's `provides`
20
+ * declares. The argument must answer the interface the token names.
21
+ *
22
+ * @param provider the plugin instance providing the service
23
+ * @returns the token and its provider
24
+ */
25
+ with(provider: T): ServiceProvider<T>;
26
+ }
27
+ /** A token with the instance providing it, as a plugin declares them. */
28
+ export interface ServiceProvider<T> {
29
+ readonly token: ServiceToken<T>;
30
+ readonly service: T;
31
+ }
32
+ /**
33
+ * Declare a service.
34
+ *
35
+ * Declare a token once, in a module both the provider and its consumers
36
+ * import: the object is the registry key, and a second declaration under the
37
+ * same name would be another service. The name only identifies it in the
38
+ * logs.
39
+ *
40
+ * @param name the service name
41
+ * @returns the token, carrying its interface
42
+ */
43
+ export declare const serviceToken: <T>(name: string) => ServiceToken<T>;
44
+ /** What `DM.services` holds: the provided instance for each token. */
45
+ export type ServiceMap = Map<ServiceToken<unknown>, unknown>;
46
+ /**
47
+ * The instance provided for a token, when a plugin provides it.
48
+ *
49
+ * A provider always answers something: `undefined` means nobody provides it.
50
+ *
51
+ * @param services the server's registry
52
+ * @param token the service wanted
53
+ * @returns the provided instance, or undefined
54
+ */
55
+ export declare const providedService: <T>(services: ReadonlyMap<ServiceToken<unknown>, unknown>, token: ServiceToken<T>) => T | undefined;
56
+ export {};
@@ -155,6 +155,30 @@ export declare function parseDn(dn: string): string[];
155
155
  * @returns whether it reads as a DN
156
156
  */
157
157
  export declare function isDnSyntax(dn: string): boolean;
158
+ /**
159
+ * Whether every escape in `dn` is one RFC 4514 §3 defines: an escaped
160
+ * special (`" + , ; < > \ # =` or a space) or a pair of hex digits.
161
+ *
162
+ * {@link parseDn} and {@link unescapeDnValue} read a DN permissively, as a
163
+ * directory may answer one; a DN a caller writes is held to the grammar.
164
+ *
165
+ * @param dn - The DN to check
166
+ * @returns whether its escapes are RFC 4514 escapes
167
+ */
168
+ export declare function hasOnlyRfc4514Escapes(dn: string): boolean;
169
+ /**
170
+ * Refuse a DN a client wrote when it holds an escape RFC 4514 does not
171
+ * define.
172
+ *
173
+ * The readers here are permissive on purpose, because they also read what a
174
+ * directory answers. A DN a caller sends is held to the grammar instead, so
175
+ * the server only ever writes a spelling the directory would produce itself.
176
+ *
177
+ * @param dn - The DN the client wrote
178
+ * @param what - What to name in the refusal
179
+ * @throws BadRequestError when an escape is not one RFC 4514 defines
180
+ */
181
+ export declare function assertRfc4514Escapes(dn: string, what: string): void;
158
182
  /**
159
183
  * Extract the parent DN from a DN
160
184
  * Removes the first RDN component to get the parent branch
@@ -206,9 +230,11 @@ export declare function rdnValue(dn: string): string;
206
230
  * Read the name of an entry written as a DN: the value of its first RDN when
207
231
  * that RDN asserts `attribute` alone, under `base` when one is given.
208
232
  *
209
- * The value is decoded exactly as {@link rdnValue} decodes it: it is the
210
- * naming attribute's value, the one a write puts back in
211
- * `attribute=…,base`.
233
+ * The value is decoded as {@link rdnValue} decodes it, then folded as the
234
+ * directory folds it ({@link foldRdnValue}): it is the naming attribute's
235
+ * value, the one a write puts back in `attribute=…,base`, and a caller
236
+ * spelling it in a way the directory considers the same name gets that name,
237
+ * not a second one.
212
238
  *
213
239
  * @param dn - The DN to read
214
240
  * @param attribute - The attribute the first RDN must assert
@@ -249,6 +275,26 @@ export declare function namesByDn(id: string, attribute: string): boolean;
249
275
  * ```
250
276
  */
251
277
  export declare function isChildOf(dn: string, parentDn: string): boolean;
278
+ /**
279
+ * Whether `dn` names a direct child of `base`: its parent is `base`, compared
280
+ * as the directory compares DNs.
281
+ *
282
+ * `isChildOf` answers ancestry; this answers parent equality, which is what a
283
+ * route that only serves one branch requires of a DN a client writes. The
284
+ * comparison folds case, spacing and the escapes the directory reads, so a
285
+ * DN created under one spelling is addressed under another.
286
+ *
287
+ * @param dn - The DN to check
288
+ * @param base - The branch its parent has to be
289
+ * @returns True when the DN has a parent and that parent is `base`
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * isDirectChildOf('uid=x,OU=Users , dc=example,dc=com', 'ou=users,dc=example,dc=com')
294
+ * // => true
295
+ * ```
296
+ */
297
+ export declare function isDirectChildOf(dn: string, base: string): boolean;
252
298
  /**
253
299
  * Teach {@link normalizeDn} the names and OIDs of attribute types: each is
254
300
  * read as the type's first name.
@@ -299,6 +345,15 @@ export declare function rdnTypes(dn: string): string[];
299
345
  * ```
300
346
  */
301
347
  export declare function normalizeDn(dn: string): string;
348
+ /**
349
+ * A DN's structure normalized as {@link normalizeDn} does, with the case of
350
+ * its values kept.
351
+ *
352
+ * A configured expression compiled to ignore case reads a DN through this
353
+ * form: its values are folded as the directory folds them, but a value it
354
+ * captures keeps the spelling the caller wrote.
355
+ */
356
+ export declare function canonicalDn(dn: string): string;
302
357
  /**
303
358
  * Whether a member DN is the placeholder a `groupOfNames` holds so it stays
304
359
  * valid with no real member (`--group-dummy-user`).
@@ -374,6 +429,20 @@ export declare function isDnInBranch(dn: string, base: string): boolean;
374
429
  * @returns true when the entry entered the trash branch
375
430
  */
376
431
  export declare function movedToTrash(trashBase: string | undefined, oldDn: string, newDn: string): boolean;
432
+ /**
433
+ * Parse a directory date: an LDAP generalized time or anything `Date`
434
+ * understands.
435
+ *
436
+ * A generalized time (RFC 4517 section 3.3.13) is `yyyyMMddHH`, then optional
437
+ * minutes and seconds, an optional fraction of the last unit given, and a zone:
438
+ * `Z` or an offset of hours with optional minutes (`+02`, `-0530`). `Date` reads
439
+ * none of the shorter forms, and not an offset of bare hours, so they are
440
+ * computed here rather than rewritten into an ISO string it might refuse.
441
+ *
442
+ * @param raw value as stored or submitted
443
+ * @returns the date, or null when it cannot be read
444
+ */
445
+ export declare function parseDirectoryDate(raw: AttributeValue): Date | null;
377
446
  /**
378
447
  * Wrapper for async Express route handlers to catch errors and pass them to error middleware
379
448
  * This ensures that errors in async routes are properly handled and don't crash the server
@@ -107,20 +107,6 @@ export declare function parseByteSize(raw: AttributeValue): number;
107
107
  * @returns true when the rules supply a value
108
108
  */
109
109
  export declare function fillsGeneratedAttribute(name: string, attr: SchemaAttribute, schema: Schema): boolean;
110
- /**
111
- * Parse a directory date: an LDAP generalized time or anything `Date`
112
- * understands.
113
- *
114
- * A generalized time (RFC 4517 section 3.3.13) is `yyyyMMddHH`, then optional
115
- * minutes and seconds, an optional fraction of the last unit given, and a zone:
116
- * `Z` or an offset of hours with optional minutes (`+02`, `-0530`). `Date` reads
117
- * none of the shorter forms, and not an offset of bare hours, so they are
118
- * computed here rather than rewritten into an ISO string it might refuse.
119
- *
120
- * @param raw value as stored or submitted
121
- * @returns the date, or null when it cannot be read
122
- */
123
- export declare function parseDirectoryDate(raw: AttributeValue): Date | null;
124
110
  /**
125
111
  * Start of a day, in UTC.
126
112
  *
@@ -353,9 +353,10 @@ export default class PasswordPolicy extends DmPlugin {
353
353
  private deletePolicy;
354
354
  private assignPolicy;
355
355
  /**
356
- * Parse GeneralizedTime (YYYYMMDDHHmmssZ) to Date
356
+ * A time the directory wrote, or null when the entry holds none, read as
357
+ * the rest of LDAP-Rest reads one: see {@link parseDirectoryDate}.
357
358
  */
358
- private parseGeneralizedTime;
359
+ private parsePolicyTime;
359
360
  /**
360
361
  * Validate password complexity (optional local validation)
361
362
  */
@@ -27,9 +27,11 @@ export declare function withExternalId(mapping: ResourceMapping, ldapAttribute:
27
27
  * `xsd:dateTime` and reject anything else, so the raw directory value must not
28
28
  * be forwarded as-is.
29
29
  *
30
- * A value that does not look like a GeneralizedTime is returned untouched: a
31
- * directory may already store an ISO 8601 string, and dropping it would lose
32
- * more than passing it through.
30
+ * The instant is read by {@link parseDirectoryDate}, as every other directory
31
+ * time is; this only writes it in the form SCIM asks for, in UTC. A value that
32
+ * does not look like a GeneralizedTime is returned untouched: a directory may
33
+ * already store an ISO 8601 string, and dropping it would lose more than
34
+ * passing it through.
33
35
  */
34
36
  export declare function ldapTimeToIso(value: string | undefined): string | undefined;
35
37
  export declare function loadMappingFile(path: string): ResourceMapping | undefined;
@@ -163,6 +165,17 @@ export declare function scimGroupToLdap(group: ScimGroup, mapping: ResourceMappi
163
165
  * mapped SCIM attributes can be populated.
164
166
  */
165
167
  export declare function requiredLdapAttributes(mapping: ResourceMapping, extra?: string[]): string[];
168
+ /**
169
+ * Refuse a DN a client wrote in a mapped reference when it holds an escape
170
+ * RFC 4514 does not define.
171
+ *
172
+ * The mapping says which sub-attributes carry a DN (`subDn`): a reference
173
+ * such as `manager.value` is written to the directory as sent, where the
174
+ * entity validator that holds the flat routes to RFC 4514 never sees it.
175
+ */
176
+ export declare function assertReferenceDn(value: string, what: string): void;
177
+ /** Whether the mapping declares this sub-attribute a DN (`subDn`). */
178
+ export declare function isDnSubAttribute(mapping: ResourceMapping, top: string, sub: string): boolean;
166
179
  /**
167
180
  * Given a SCIM attribute path like "emails.value" or "name.familyName",
168
181
  * return the corresponding LDAP attribute name (used by filter parser).
@@ -147,6 +147,8 @@ export interface MappingEntry {
147
147
  ldapPrimary?: string;
148
148
  ldapSecondary?: string;
149
149
  sub?: Record<string, string>;
150
+ /** Sub-attributes whose value is a DN, held to RFC 4514 on write */
151
+ subDn?: string[];
150
152
  multi?: 'single' | 'array';
151
153
  readOnly?: boolean;
152
154
  operational?: boolean;
@@ -12,6 +12,8 @@ export interface WorkplaceCreated {
12
12
  /** An account's instance request, and the id `user.created` names it by. */
13
13
  interface Account extends InstanceRequest {
14
14
  twakeId: string;
15
+ /** The organization id came from the DN, not from the provider settings */
16
+ fromDn?: boolean;
15
17
  }
16
18
  export declare const EnsureInstanceBody: import("@sinclair/typebox").TObject<{
17
19
  dn: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
@@ -128,12 +130,41 @@ export default class TwakeInstances extends DmPlugin {
128
130
  private get organizationFqdn();
129
131
  private organizationMail;
130
132
  private isOrganization;
133
+ /**
134
+ * The id the organization's own entry spells, for an id captured from a
135
+ * client's DN: the caller may write the branch in another case, and the
136
+ * Cloudery branch and the slug should not follow it. The captured value
137
+ * stands when there is no entry or no organization base.
138
+ */
139
+ private storedOrganizationId;
140
+ /** `account()`, with a captured organization id read from its entry. */
141
+ private storedAccount;
131
142
  private findOrganization;
132
143
  private read;
133
144
  protected announce(exchange: string, key: string, message: Record<string, unknown>): Promise<void>;
134
145
  /** What to ask for this account, when it gets an instance at all. */
135
146
  protected account(dn: string, entry: AttributesList): Account | undefined;
147
+ /**
148
+ * The expression that takes this DN for an account's, and where it sits.
149
+ *
150
+ * Each expression reads the canonical DN first: a client may spell the
151
+ * branch differently, and a space or an escape inside a captured value
152
+ * would otherwise be captured as written. The canonical form keeps the
153
+ * caller's case, which an organization id is sent as. An expression written
154
+ * with the directory's own spelling — `\2C`, a doubled space — does not
155
+ * match that form, so the DN as sent is tried next.
156
+ */
136
157
  private match;
158
+ /**
159
+ * The DN with a comma inside a value written `\2C`, as the directory writes
160
+ * it: an expression's `[^,]` cannot then read that comma as an RDN
161
+ * separator and name the branch of an entry beside it.
162
+ */
163
+ private separators;
164
+ /** The uid of an account's DN, under any name or alias of the type. */
165
+ private uid;
166
+ /** One queue per entry, whatever spelling its DN came in. */
167
+ private key;
137
168
  private id;
138
169
  protected value(entry: AttributesList, attribute: string): unknown;
139
170
  protected findByMail(email: string, req?: Request): Promise<AttributesList | undefined>;
@@ -25,5 +25,16 @@ export declare function first(value: AttributeValue | null | undefined): string
25
25
  export declare const isTombstone: (entry: AttributesList, attrs: LifecycleAttributes) => boolean;
26
26
  export declare const isLocked: (entry: AttributesList, attrs: LifecycleAttributes) => boolean;
27
27
  export declare function formatDeletedAt(date: Date, format: DeletedAtFormat): string;
28
- /** A deletion date as the directory holds it, or undefined if unreadable. */
29
- export declare function parseDeletedAt(value: string, format: DeletedAtFormat): Date | undefined;
28
+ /**
29
+ * A deletion date as the directory holds it, or undefined if unreadable.
30
+ *
31
+ * Read by the same reader as every other directory time: the format only says
32
+ * how this deployment writes the date, not how it is read.
33
+ *
34
+ * A value of another shape is refused here rather than handed to that
35
+ * reader, whose fallback to `new Date()` reads `1` or `Jan 1` as a date: the
36
+ * erase guard takes an unreadable date for a refusal. The same fallback rolls
37
+ * `2025-02-31` over to March, where the shared reader refuses that day
38
+ * spelled as a GeneralizedTime.
39
+ */
40
+ export declare function parseDeletedAt(value: string): Date | undefined;
@@ -48,8 +48,8 @@ export default class TwakeTombstone extends DmPlugin {
48
48
  */
49
49
  tombstone(dn: string, reason: string, req?: Request): Promise<void>;
50
50
  /**
51
- * Remove a tombstone and its group memberships. Refused while the deletion
52
- * is younger than the configured age, unless forced.
51
+ * Remove a tombstone and its group memberships. Refused when its deletion
52
+ * date is unreadable or younger than the configured age, unless forced.
53
53
  */
54
54
  erase(dn: string, { force, req }?: {
55
55
  force?: boolean;
@@ -77,6 +77,13 @@ export default class TwakeTombstone extends DmPlugin {
77
77
  * group the erase left holding only the placeholder loses it again.
78
78
  */
79
79
  private restore;
80
+ /**
81
+ * Whether this DN names an entry the expressions keep. A client may spell
82
+ * the DN differently from the expression; the directory stores the entry
83
+ * at its canonical DN, so the expressions read that too. The DN as sent is
84
+ * still tried: an expression written with the directory's own spelling
85
+ * (`\2C`, a doubled space) matches only that form.
86
+ */
80
87
  private matches;
81
88
  private isScim;
82
89
  private checkReason;
package/docs/README.md CHANGED
@@ -20,6 +20,7 @@ Create and extend LDAP-Rest with custom plugins.
20
20
 
21
21
  - **[Development Guide](plugin-development/README.md)** - Architecture and plugin creation
22
22
  - **[Hooks](plugin-development/hooks.md)** - LDAP hooks system
23
+ - **[Services](plugin-development/services.md)** - Typed interfaces between plugins
23
24
  - **[Routes](plugin-development/routes.md)** - Declaring routes and their input schemas
24
25
  - **[Testing](plugin-development/testing.md)** - Testing with embedded LDAP
25
26
  - **[Dependencies](plugin-development/dependencies.md)** - Plugin dependencies matrix
@@ -33,6 +34,22 @@ Integrate LDAP-Rest into your web applications.
33
34
  - **[JSON Schemas](client-development/schemas/README.md)** - Schema structure and validation
34
35
  - **[Examples](client-development/examples/README.md)** - React, Vue.js, Vanilla JS integration
35
36
 
37
+ ### [Architecture](architecture/README.md)
38
+
39
+ How the server is put together.
40
+
41
+ - **[Overview](architecture/README.md)** - Module map, life of a request, life of a write
42
+ - **[Decision records](architecture/adr/)** - Why the code is the way it is
43
+
44
+ ### [Development](development/contracts.md)
45
+
46
+ Work on ldap-rest itself.
47
+
48
+ - **[Published contracts](development/contracts.md)** - What breaks a
49
+ contract, the snapshots that detect it and the deprecation window.
50
+ - **[Modularization plan](development/modularization-plan.md)** - How the
51
+ `@ldap-rest/*` split and the remaining code-quality issues are sequenced.
52
+
36
53
  ## API stability
37
54
 
38
55
  From 1.0.0, ldap-rest follows semantic versioning. These are its public
@@ -44,8 +61,9 @@ API, which a minor or a patch release does not break:
44
61
  [REST API](client-development/api/rest-api.md) and the plugin pages,
45
62
  their statuses and their error `code`s.
46
63
  - **Plugin API**: what `ldap-rest/plugin` exports — `DmPlugin` and its
47
- roles (`auth`, `authz`, …), `asyncHandler`, the DN and filter escaping
48
- helpers and the error classes —, the `DM` type of `ldap-rest` and the
64
+ roles (`auth`, `authz`, …), `serviceToken`, `ServiceToken` and
65
+ `ServiceProvider`, `asyncHandler`, the DN and filter escaping helpers and
66
+ the error classes —, the `DM` type of `ldap-rest` and the
49
67
  `Hooks` of `ldap-rest/hooks`, whose arguments are those typed in
50
68
  `src/hooks.ts` ([hooks](plugin-development/hooks.md)), and the directory
51
69
  operations of [the development guide](plugin-development/README.md):
@@ -58,6 +76,11 @@ API, which a minor or a patch release does not break:
58
76
  Everything else — other exports, internal helpers, log messages — may
59
77
  change in any release.
60
78
 
79
+ The options and the REST API are checked mechanically: committed snapshots
80
+ are compared with the base branch of each pull request, and a non-additive
81
+ change needs the `breaking` label. The plugin types are still reviewed by
82
+ hand. See [Published contracts](development/contracts.md).
83
+
61
84
  ## Presentations
62
85
 
63
86
  Interactive presentations (using [presenterm](https://github.com/mfontanini/presenterm)):
@@ -0,0 +1,180 @@
1
+ # Architecture
2
+
3
+ How the server is put together, and the two paths that carry most of the
4
+ work. Every design decision the code already embodies is recorded once, in
5
+ [`adr/`](adr/); this page is the map and the tour.
6
+
7
+ The plan that will split the repository into packages is
8
+ [Modularization plan](../development/modularization-plan.md). That plan is a
9
+ roadmap; this page describes the code as it is.
10
+
11
+ ## Module map
12
+
13
+ | Path | Role |
14
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `src/bin/index.ts` | `DM`: the Express app, plugin loading, the authentication dispatcher, `ready` and `stop`, `withConfig` |
16
+ | `src/config/` | Command-line and environment parsing (`args.ts`), option specs (`options.ts`), the entity-schema model (`schema.ts`; the directory's own schema is `src/lib/ldap/schema.ts`) |
17
+ | `src/abstract/` | `DmPlugin` base class; `LdapFlat`, the entity CRUD built on it; `TwakePlugin` |
18
+ | `src/hooks.ts` | The hook type map: one entry per hook, with its arguments and its answer |
19
+ | `src/lib/` | Core services: `ldapActions` and `lib/ldap/` (pool, search cache, schema), `lib/auth/`, `lib/authz/`, `lib/services/`, `lib/storage/`, `hookBus`, `requestContext`, errors, routes |
20
+ | `src/plugins/auth/` | Authentication plugins, authorization plugins, and the guards that sit before them |
21
+ | `src/plugins/ldap/` | Directory plugins: groups, organizations, entities, change detection, trash, password policy |
22
+ | `src/plugins/scim/` | The SCIM 2.0 API |
23
+ | `src/plugins/storage/` | Storage backends (`file`, `ldap`, `postgres`, `valkey`), each a `Store` |
24
+ | `src/plugins/twake/` | Twake integrations: mail, calendar, spaces, drives, tombstones, lifecycle events |
25
+ | `src/plugins/*.ts` | Standalone plugins: `storage` (`core/storage`, over the backends above), `static`, `weblogs`, `configApi`, `metrics`, `bcl`, and `rabbitmq` (a shared broker connection, not a store) |
26
+ | `src/plugins/demo/` | Example plugins |
27
+ | `src/browser/` | Browser libraries shipped with the package |
28
+ | `src/logger/` | Log formatting and levels |
29
+ | `src/types/` | Shared TypeScript declarations |
30
+
31
+ The plugin families depend on `src/lib/` and on each other as documented in
32
+ [dependencies](../plugin-development/dependencies.md). The dependency rules
33
+ that the modularization will enforce are in the
34
+ [plan](../development/modularization-plan.md#target-architecture).
35
+
36
+ ## Life of a request
37
+
38
+ ```mermaid
39
+ flowchart TD
40
+ A[HTTP request] --> B[request id and log context]
41
+ B --> C[body parsers]
42
+ C --> D[protect plugins: rate limit, CrowdSec, trusted proxy]
43
+ D --> E[authentication dispatcher]
44
+ E --> F{most specific prefix}
45
+ F -->|one authenticator| G[vouched]
46
+ F -->|several on the same prefix| H[all of them, an AND]
47
+ H --> G
48
+ G --> I[runForRequest]
49
+ I --> J[route: checked params, query, body]
50
+ J --> K[ldap.forRequest or ldap.system]
51
+ K --> L[request hooks, authorization first]
52
+ L --> M[(directory)]
53
+ M --> N[done hooks of a write, then end hooks]
54
+ N --> O[onLdap* notifications]
55
+ J --> P[error middleware]
56
+ ```
57
+
58
+ 1. `DM` mounts, in this order: the request id and log context
59
+ (`runWithLogContext`), the media-type guard, the parsers a plugin
60
+ registered for its own prefix, the global JSON and form parsers (they keep
61
+ the raw bytes: HMAC authentication hashes them as the client signed them),
62
+ the health probes, the protect and logging plugins, the authentication
63
+ dispatcher, the plugin routes, and the error middleware, kept last.
64
+ 2. The dispatcher picks the authentication whose path prefix is the most
65
+ specific. Several plugins sharing that winning prefix all run, in
66
+ registration order, and every one must let the request through: two
67
+ credentials on one branch are an AND. A path nobody claims is guarded by
68
+ the unscoped authenticators (no prefix), if any are loaded; with none, the
69
+ request passes through unauthenticated, and `warnUnauthenticatedRoutes`
70
+ names those routes at startup.
71
+ 3. A plugin's `afterChain` runs after the whole selected chain and before any
72
+ route: it is the only place where a verdict can depend on several
73
+ authenticators without registration order deciding the answer.
74
+ 4. `runForRequest(req, next)` wraps everything downstream of a request an
75
+ authenticator let through — not the pass-through path, which has no
76
+ caller. A directory operation made there, by whatever plugin, is judged as
77
+ that caller even if the request object was not handed down. Hooks are the
78
+ exception: they run as the server (see below), and a hook acting as the
79
+ caller names the request it received. `ldap.system` is the explicit escape
80
+ anywhere else.
81
+ 5. A route declares its input schemas with `defineRoute`; the handler receives
82
+ checked `params`, `query` and `body`, and reaches the directory through
83
+ `ldap.forRequest(req)`. Work the server does for itself goes through
84
+ `ldap.system`.
85
+ 6. `ldapActions` raises the request hooks (authorization plugins first), makes
86
+ the operation, then raises the "done" and "end" hooks of a write. The next
87
+ section follows one.
88
+
89
+ The decisions behind these steps: [authentication
90
+ dispatch](adr/0002-authentication-dispatch.md), [authorization
91
+ composition](adr/0003-authorization-composition.md), [fail-closed request
92
+ context](adr/0004-fail-closed-request-context.md).
93
+
94
+ ## Life of a write
95
+
96
+ ```mermaid
97
+ sequenceDiagram
98
+ participant R as route
99
+ participant L as ldapActions
100
+ participant H as plugins
101
+ participant D as directory
102
+
103
+ R->>L: add / modify / rename / delete
104
+ L->>H: request hook, chained (authorization first)
105
+ H-->>L: value, changed or refused
106
+ L->>L: drop cached reads the write changes
107
+ L->>D: LDAP add / modify / modifyDN / delete
108
+ D-->>L: result
109
+ L->>L: drop them again
110
+ L->>H: done hooks, notified
111
+ L->>H: end hooks, once the done hooks returned
112
+ H->>H: onLdapEntryChange, onLdapChange, onLdapMailChange…
113
+ ```
114
+
115
+ - **Before the write.** A request hook is chained: each subscriber gets what
116
+ the previous one answered, and a refusal thrown by any of them stops the
117
+ operation. The request hooks of authorization plugins run before every
118
+ other subscriber's, so a caller who may not make the operation is refused
119
+ before a plugin judges its content.
120
+ [trash](adr/0007-trash-and-tombstone.md) and
121
+ [tombstone](adr/0007-trash-and-tombstone.md) use this point to replace a
122
+ delete.
123
+ - **The write.** `ldapActions` drops the cached reads of what it is about to
124
+ change before the directory call and again once it returned, so a read
125
+ landing during the round trip cannot serve what the write replaced.
126
+ [Search cache](adr/0006-search-cache.md).
127
+ - **After the write.** `ldapadddone`, `ldapmodifydone`, `ldapdeletedone` and
128
+ `ldaprenamedone` are notified, not awaited. `ldapaddafter` is awaited
129
+ within the request, for what must follow an add, such as provisioning the
130
+ account the entry describes. Once the done subscribers returned, the
131
+ matching `ldapmodifyend`, `ldapdeleteend` or `ldaprenameend` fires, whether
132
+ the write landed, was refused or was taken out of the request.
133
+ - **Derived notifications.** `core/ldap/onChange` reads the entry before and
134
+ after the write and raises `onLdapEntryChange`, then the attribute-specific
135
+ ones (`onLdapMailChange`, `onLdapQuotaChange`, …). A delete fires
136
+ `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange` only.
137
+ - **Contract.** [Hook contract](adr/0005-hook-contract.md), and the reference
138
+ of every hook in [hooks](../plugin-development/hooks.md). Subscribers run as
139
+ the server, outside the request that raised the hook; one that acts as the
140
+ caller names the request in its arguments.
141
+
142
+ ## Startup
143
+
144
+ The configuration is parsed from the command line and `DM_*` variables, then
145
+ `DM` loads the plugins: `src/plugins/priority.json` first, one at a time in
146
+ its order, then the rest in parallel. Once every load settled, the server
147
+ checks the authorization composition, learns the directory's attribute types
148
+ when an authorization plugin is loaded, reports unknown options and runs
149
+ `afterLoad`. `DM.ready` resolves then, and rejects if a plugin failed to load;
150
+ the CLI opens the port after it. An embedding host may register plugins after
151
+ `ready`, and each registration is checked against the composition too.
152
+ `warnUnauthenticatedRoutes` stays silent when no authentication plugin is
153
+ loaded; the enforcement check says once whether that is an open server or a
154
+ configuration to refuse.
155
+
156
+ [Plugin load order](adr/0001-plugin-load-order.md),
157
+ [server views](adr/0008-withconfig-views.md).
158
+
159
+ ## Decision records
160
+
161
+ | Record | Decision |
162
+ | ----------------------------------------------- | --------------------------------------------------------------------------------------- |
163
+ | [0001](adr/0001-plugin-load-order.md) | Plugins load priority-first, then in parallel; dependencies load on demand |
164
+ | [0002](adr/0002-authentication-dispatch.md) | The most specific authentication prefix wins; several on one prefix are an AND |
165
+ | [0003](adr/0003-authorization-composition.md) | Two authorization models judging the same requests are refused unless made deliberate |
166
+ | [0004](adr/0004-fail-closed-request-context.md) | Directory operations inherit the request being served; `ldap.system` is the escape |
167
+ | [0005](adr/0005-hook-contract.md) | Chained hooks transform and refuse; notified hooks observe, outside the request |
168
+ | [0006](adr/0006-search-cache.md) | The search cache is off by default and only the process's own writes invalidate it |
169
+ | [0007](adr/0007-trash-and-tombstone.md) | A delete becomes a move to trash or a tombstone, never a loss |
170
+ | [0008](adr/0008-withconfig-views.md) | A plugin with overrides sees a server view that shares everything but the configuration |
171
+
172
+ ## Checking the comments
173
+
174
+ `npm run check:comments` (`scripts/check-comments.sh`) refuses new comments in
175
+ `src/` that cite an issue number, narrate history ("used to", "since 1.2") or
176
+ otherwise carry rationale. The rationale belongs in a decision record; the
177
+ comment points at it. The accepted set is `scripts/comment-baseline.txt`, and
178
+ it may only shrink: removing a comment is fine, adding one fails the check.
179
+ `sh scripts/check-comments.sh --update` rewrites the baseline from the current
180
+ tree when a change removes one.
@@ -0,0 +1,50 @@
1
+ # 0001 — Plugin load order
2
+
3
+ ## Context
4
+
5
+ Plugins are listed with `--plugin`, in the order the administrator writes
6
+ them, and a plugin may declare `dependencies` naming modules that must be
7
+ registered before it. Some plugins cannot simply be imported in parallel with
8
+ the rest: a guard whose Express middleware must sit ahead of the routes it
9
+ protects, an access log that must see every request, an authenticator or URL
10
+ gate other plugins must find registered when they register. Import order in
11
+ the parallel batch is the order the imports happen to complete, which is not a
12
+ configuration.
13
+
14
+ ## Decision
15
+
16
+ `src/plugins/priority.json` lists the modules loaded first. That pass is
17
+ sequential, in the file's order, and every instance of a priority module is
18
+ loaded there, in configuration order — matching is on the module, not on the
19
+ whole `--plugin` entry, so a named instance (`module:name:{…}`) keeps its
20
+ rank. The remaining entries load in parallel. A declared dependency is loaded
21
+ on demand when the plugin that needs it registers. A module requested twice
22
+ under the same name without overrides is loaded once; a second instance needs
23
+ its own name. Two plugins with the same instance name are refused, and the
24
+ second is dropped with a warning. `configApi` loads last.
25
+
26
+ The list holds four kinds of plugin: the guards whose middleware must run
27
+ before the routes (`trustedProxy`, `crowdsec`, `rateLimit`), the access log
28
+ that must see every request (`weblogs`), the authenticators the dispatcher and
29
+ the authorization plugins naming them have to find registered (`token`,
30
+ `fake`, `llng`, `openidconnect`), and the URL gate whose rules must exist
31
+ before the routes they judge (`authzPerRoute`). `core/storage` is there
32
+ because others must find it registered when they register.
33
+
34
+ Hooks run in load order, except the request hooks of authorization plugins,
35
+ which run before every other subscriber's.
36
+
37
+ ## Consequences
38
+
39
+ - A plugin whose middleware must precede routes has to be in
40
+ `priority.json`; being written first in the configuration is not enough
41
+ once the parallel batch starts.
42
+ - The relative order of two plugins that are neither priority nor linked by a
43
+ dependency is not defined, and no plugin may rely on it.
44
+ - Adding a module to the priority list changes the order for every
45
+ deployment, so the list stays short.
46
+ - An instance named with overrides loads at the rank of its module but keeps
47
+ its own configuration through a [server view](0008-withconfig-views.md).
48
+
49
+ See [dependencies](../../plugin-development/dependencies.md) and
50
+ `src/bin/index.ts`.