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.
- package/README.md +8 -1
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/abstract/plugin.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/options.js +1 -1
- package/dist/config/schema.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/errors.js +1 -1
- package/dist/lib/ldap/callerView.js +1 -0
- package/dist/lib/ldap/pool.js +1 -0
- package/dist/lib/ldap/schema.js +1 -0
- package/dist/lib/ldap/searchCache.js +1 -0
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/services/index.js +1 -0
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/ldap/accountLifecycle.js +1 -1
- package/dist/plugins/ldap/enterpriseRules.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/passwordPolicy.js +1 -1
- package/dist/plugins/ldap/raw.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/scim/mapping.js +1 -1
- package/dist/plugins/scim/patch.js +1 -1
- package/dist/plugins/storage/file.js +1 -1
- package/dist/plugins/twake/instances.js +1 -1
- package/dist/plugins/twake/lifecycleAttributes.js +1 -1
- package/dist/plugins/twake/lifecycleEvents.js +1 -1
- package/dist/plugins/twake/tombstone.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +13 -0
- package/dist/src/abstract/plugin.d.ts +21 -0
- package/dist/src/bin/index.d.ts +38 -4
- package/dist/src/config/options.d.ts +16 -1
- package/dist/src/lib/errors.d.ts +2 -1
- package/dist/src/lib/ldap/callerView.d.ts +45 -0
- package/dist/src/lib/ldap/pool.d.ts +108 -0
- package/dist/src/lib/ldap/schema.d.ts +157 -0
- package/dist/src/lib/ldap/searchCache.d.ts +112 -0
- package/dist/src/lib/ldapActions.d.ts +25 -262
- package/dist/src/lib/parseConfig.d.ts +26 -2
- package/dist/src/lib/services/index.d.ts +56 -0
- package/dist/src/lib/utils.d.ts +72 -3
- package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
- package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
- package/dist/src/plugins/scim/mapping.d.ts +16 -3
- package/dist/src/plugins/scim/types.d.ts +2 -0
- package/dist/src/plugins/twake/instances.d.ts +31 -0
- package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
- package/dist/src/plugins/twake/tombstone.d.ts +9 -2
- package/docs/README.md +25 -2
- package/docs/architecture/README.md +180 -0
- package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
- package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
- package/docs/architecture/adr/0003-authorization-composition.md +59 -0
- package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
- package/docs/architecture/adr/0005-hook-contract.md +54 -0
- package/docs/architecture/adr/0006-search-cache.md +49 -0
- package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
- package/docs/architecture/adr/0008-withconfig-views.md +58 -0
- package/docs/client-development/api/rest-api.md +4 -0
- package/docs/development/contracts.md +119 -0
- package/docs/development/modularization-plan.md +197 -0
- package/docs/plugin-development/README.md +8 -1
- package/docs/plugin-development/dependencies.md +13 -2
- package/docs/plugin-development/hooks.md +4 -2
- package/docs/plugin-development/options.md +40 -13
- package/docs/plugin-development/services.md +95 -0
- package/docs/usage/operations.md +12 -0
- package/docs/usage/plugins/integrations/instances.md +11 -3
- package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
- package/docs/usage/plugins/integrations/scim.md +20 -18
- package/docs/usage/plugins/integrations/tombstone.md +10 -6
- package/docs/usage/plugins/ldap/flat-generic.md +8 -1
- package/docs/usage/plugins/ldap/groups.md +4 -1
- package/docs/usage/plugins/ldap/password-policy.md +7 -0
- package/docs/usage/upgrading.md +59 -0
- package/package.json +21 -12
- package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
- package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
- package/static/browser/ldap-tree-viewer.js.map +1 -1
- 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 {};
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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
|
|
210
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
|
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`, …), `
|
|
48
|
-
|
|
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`.
|