ldap-rest 0.7.0 → 0.8.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 (79) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/plugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/config/schema.js +1 -0
  6. package/dist/lib/expressFormatedResponses.js +1 -1
  7. package/dist/lib/ldapActions.js +1 -1
  8. package/dist/lib/utils.js +1 -1
  9. package/dist/plugins/auth/authzScope.js +1 -0
  10. package/dist/plugins/auth/llng.js +1 -1
  11. package/dist/plugins/ldap/accountLifecycle.js +1 -0
  12. package/dist/plugins/ldap/bulkImport.js +1 -1
  13. package/dist/plugins/ldap/departmentSync.js +1 -1
  14. package/dist/plugins/ldap/enterpriseRules.js +1 -0
  15. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  16. package/dist/plugins/ldap/flatGeneric.js +1 -1
  17. package/dist/plugins/ldap/groups.js +1 -1
  18. package/dist/plugins/ldap/organizations.js +1 -1
  19. package/dist/plugins/scim/groups.js +1 -1
  20. package/dist/plugins/twake/calendar.js +1 -0
  21. package/dist/plugins/twake/calendarResources.js +1 -1
  22. package/dist/plugins/twake/james.js +1 -1
  23. package/dist/src/abstract/ldapFlat.d.ts +159 -1
  24. package/dist/src/abstract/plugin.d.ts +13 -0
  25. package/dist/src/config/args.d.ts +2 -0
  26. package/dist/src/config/schema.d.ts +382 -12
  27. package/dist/src/lib/expressFormatedResponses.d.ts +14 -0
  28. package/dist/src/lib/ldapActions.d.ts +33 -6
  29. package/dist/src/lib/utils.d.ts +65 -0
  30. package/dist/src/plugins/auth/authzScope.d.ts +53 -0
  31. package/dist/src/plugins/auth/llng.d.ts +30 -1
  32. package/dist/src/plugins/ldap/accountLifecycle.d.ts +56 -0
  33. package/dist/src/plugins/ldap/departmentSync.d.ts +43 -0
  34. package/dist/src/plugins/ldap/enterpriseRules.d.ts +436 -0
  35. package/dist/src/plugins/ldap/flatGeneric.d.ts +29 -0
  36. package/dist/src/plugins/ldap/groups.d.ts +22 -0
  37. package/dist/src/plugins/ldap/organizations.d.ts +25 -4
  38. package/dist/src/plugins/twake/calendar.d.ts +113 -0
  39. package/dist/src/plugins/twake/calendarResources.d.ts +9 -58
  40. package/docs/README.md +1 -0
  41. package/docs/client-development/api/openapi.md +1 -1
  42. package/docs/client-development/browser/libraries.md +1 -1
  43. package/docs/plugin-development/dependencies.md +10 -7
  44. package/docs/presentations/en.md +1 -1
  45. package/docs/presentations/fr.md +1 -1
  46. package/docs/usage/README.md +1 -0
  47. package/docs/usage/configuration.md +35 -15
  48. package/docs/usage/directory-audit.md +105 -0
  49. package/docs/usage/plugins/README.md +6 -6
  50. package/docs/usage/plugins/auth/README.md +6 -5
  51. package/docs/usage/plugins/auth/authz-scope.md +103 -0
  52. package/docs/usage/plugins/auth/llng.md +17 -0
  53. package/docs/usage/plugins/integrations/README.md +5 -5
  54. package/docs/usage/plugins/integrations/{calendar-resources.md → calendar.md} +70 -13
  55. package/docs/usage/plugins/ldap/README.md +13 -11
  56. package/docs/usage/plugins/ldap/account-lifecycle.md +129 -0
  57. package/docs/usage/plugins/ldap/bulk-import.md +8 -1
  58. package/docs/usage/plugins/ldap/enterprise-rules.md +167 -0
  59. package/docs/usage/plugins/ldap/flat-generic.md +172 -0
  60. package/docs/usage/plugins/ldap/groups.md +10 -4
  61. package/docs/usage/plugins/ldap/organizations.md +59 -33
  62. package/docs/usage/upgrading.md +322 -0
  63. package/package.json +50 -5
  64. package/static/schemas/example/README.md +36 -0
  65. package/static/schemas/example/groups.json +94 -0
  66. package/static/schemas/example/nomenclature/domains.json +49 -0
  67. package/static/schemas/example/organizations.json +106 -0
  68. package/static/schemas/example/positions.json +35 -0
  69. package/static/schemas/example/users.json +246 -0
  70. package/static/schemas/twake/groups.json +81 -15
  71. package/static/schemas/twake/nomenclature/twakeAccountStatus.json +26 -0
  72. package/static/schemas/twake/nomenclature/twakeDeliveryMode.json +22 -0
  73. package/static/schemas/twake/nomenclature/twakeDomain.json +60 -0
  74. package/static/schemas/twake/nomenclature/twakeListType.json +22 -0
  75. package/static/schemas/twake/nomenclature/twakeMailboxType.json +22 -0
  76. package/static/schemas/twake/nomenclature/twakeTitle.json +42 -0
  77. package/static/schemas/twake/organizations.json +92 -12
  78. package/static/schemas/twake/positions.json +22 -4
  79. package/static/schemas/twake/users.json +269 -32
@@ -1,18 +1,388 @@
1
1
  import type { AttributeValue } from '../lib/ldapActions';
2
+ /**
3
+ * Scalar and container types an entity schema may declare.
4
+ *
5
+ * `pointer` is a DN reference validated against `branch`; `date` is an LDAP
6
+ * generalized time (`yyyyMMddHHmmss[.SSS]Z`).
7
+ */
8
+ export type SchemaAttributeType = 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'array' | 'pointer';
9
+ /**
10
+ * Semantic role of an attribute: what it *means*, independent of the concrete
11
+ * LDAP attribute holding it. Core code is written against roles so that a
12
+ * deployment with a different directory layout reuses it unchanged — the name
13
+ * of the attribute and the values it takes are configuration.
14
+ *
15
+ * The type is a plain string: the list below is what the core itself looks
16
+ * for, and a deployment is free to add its own for its plugins.
17
+ *
18
+ * | Role | Meaning |
19
+ * | ------------------ | ---------------------------------------------------- |
20
+ * | `identifier` | RDN value of the entry |
21
+ * | `displayName` | Human-readable name |
22
+ * | `primaryEmail` | Main mail address |
23
+ * | `emailAliases` | Further addresses of the same mailbox |
24
+ * | `emailQuota` | Mailbox size limit, in bytes |
25
+ * | `organizationLink` | DN of the organization the entry belongs to |
26
+ * | `organizationPath` | Human-readable path of that organization |
27
+ * | `members` | DNs of the members of a group |
28
+ * | `owners` | DNs allowed to write to a restricted group |
29
+ * | `accountStatus` | Lifecycle state of an account (enabled / disabled…) |
30
+ * | `password` | Credential |
31
+ * | `passwordReset` | Flag forcing a password change at next login |
32
+ * | `accountExpiry` | Date after which the account is to be removed |
33
+ * | `domainLink` | DNs of the mail domains an organization may use |
34
+ * | `domainName` | Domain name carried by a domain entry |
35
+ */
36
+ export type SchemaRole = string;
37
+ /**
38
+ * A piece of text a client shows, in one or several languages.
39
+ *
40
+ * A plain string is the text itself. A map is keyed by language tag, and a
41
+ * client picks the closest one it can:
42
+ *
43
+ * ```json
44
+ * "label": { "en": "Department", "fr": "Département" }
45
+ * ```
46
+ *
47
+ * These words belong to the deployment, not to the product — they name *its*
48
+ * entities and attributes — so their translations belong in its schemas
49
+ * rather than in a catalogue shipped with the code.
50
+ */
51
+ export type LocalizedText = string | Record<string, string>;
52
+ /** Roles the core itself looks for. See {@link SchemaRole}. */
53
+ export declare const CORE_ROLES: readonly ["identifier", "displayName", "primaryEmail", "emailAliases", "emailQuota", "organizationLink", "organizationPath", "members", "owners", "accountStatus", "password", "passwordReset", "accountExpiry", "domainLink", "domainName"];
54
+ /**
55
+ * How a uniqueness constraint is evaluated.
56
+ *
57
+ * By default the value must be unique among the entries of the entity's own
58
+ * branch. `branches` widens the search to a shared namespace — a mail address
59
+ * belongs to accounts *and* to distribution lists — and `attributes` names the
60
+ * other attributes that hold values of that same namespace.
61
+ */
62
+ export interface UniqueConstraint {
63
+ /**
64
+ * Extra branches searched besides the entity's own base. Each entry may use
65
+ * the `__ldap_base__` placeholder, like `branch`.
66
+ */
67
+ branches?: string[];
68
+ /**
69
+ * Other attributes sharing the value namespace, searched alongside the
70
+ * attribute itself (e.g. `mailAlternateAddress` for `mail`).
71
+ */
72
+ attributes?: string[];
73
+ /**
74
+ * Value exempted from the check. A directory may use a placeholder for
75
+ * non-individual entries (a shared payroll number, say); that value is
76
+ * allowed to repeat.
77
+ */
78
+ sentinel?: string;
79
+ /** Extra LDAP filter narrowing the search, e.g. `(objectClass=twakeAccount)` */
80
+ filter?: string;
81
+ }
82
+ /**
83
+ * Derivation of a generated attribute from another attribute of the same
84
+ * entry. Used for identifiers built from the mail address, where the client
85
+ * never supplies the value.
86
+ */
87
+ export interface GeneratedFrom {
88
+ /** Source attribute, e.g. `mail` */
89
+ attribute: string;
90
+ /**
91
+ * Regex whose first capturing group is the generated value. Applied to the
92
+ * source value; when it does not match, the whole source value is used.
93
+ */
94
+ extract?: string;
95
+ /** Lowercase the result */
96
+ lowercase?: boolean;
97
+ /**
98
+ * Characters to drop from the extracted value, as an extended regular
99
+ * expression. A mail local part may legally hold `+`, `'` or `!`, which an
100
+ * identifier charset usually refuses; without this the entry becomes
101
+ * uncreatable, since the client may not send the generated attribute
102
+ * either. `[^a-zA-Z0-9._-]` keeps what a `uid` accepts.
103
+ */
104
+ strip?: string;
105
+ /**
106
+ * What to do when the generated value is already taken. `error` (the
107
+ * default) refuses the creation; `suffix` appends `-2`, `-3`, … until a free
108
+ * value is found.
109
+ */
110
+ onCollision?: 'error' | 'suffix';
111
+ }
112
+ export interface SchemaAttribute {
113
+ type: SchemaAttributeType;
114
+ items?: {
115
+ type: string;
116
+ test?: string | RegExp;
117
+ hint?: string;
118
+ branch?: string[];
119
+ };
120
+ default?: AttributeValue;
121
+ required?: boolean;
122
+ test?: string | RegExp;
123
+ /**
124
+ * Plain-language description of `test`, shown to the user *before* they get
125
+ * a value wrong — "Expected pattern 999 9999". It travels with the
126
+ * schema so the pattern and its explanation cannot drift apart, and so a
127
+ * client never has to hardcode either.
128
+ */
129
+ hint?: string;
130
+ branch?: string[];
131
+ fixed?: boolean;
132
+ /** Semantic role(s) — see {@link SchemaRole} */
133
+ role?: SchemaRole | SchemaRole[];
134
+ /**
135
+ * Computed server-side. Refused when a client supplies it; still writable by
136
+ * the core and by plugins.
137
+ *
138
+ * The refusal is about *attribute names in a payload*, not about the entry
139
+ * being immutable: an endpoint parameter that happens to end up in a
140
+ * generated attribute is not one of them. `POST /ldap/{resource}/{id}/rename`
141
+ * takes a `newId` and `POST /ldap/{resource}/{id}/move` a `targetOrgDn`, and
142
+ * both then write a `generated` attribute themselves — while
143
+ * `PUT /ldap/{resource}/{id}` naming that attribute is still refused.
144
+ */
145
+ generated?: boolean;
146
+ /** How a `generated` value is derived from another attribute */
147
+ generatedFrom?: GeneratedFrom;
148
+ /**
149
+ * Operational or derived attribute: refused in input and never written.
150
+ * `memberOf` is the archetype — membership is driven from the group side.
151
+ */
152
+ readOnly?: boolean;
153
+ /**
154
+ * Never returned to a client, though it may still be written. Covers the
155
+ * write-only attributes of a password reset.
156
+ */
157
+ neverReturn?: boolean;
158
+ /** Value must be unique; `true` means "unique within this entity" */
159
+ unique?: boolean | UniqueConstraint;
160
+ /**
161
+ * Canonicalisation applied to an incoming value before it is stored.
162
+ *
163
+ * `byteSize` reads a human-readable size (`5GB`, `500MB`, `2048`) and stores
164
+ * the number of bytes, so the same value means the same thing whether it
165
+ * arrived on a creation or an update.
166
+ */
167
+ normalize?: 'byteSize';
168
+ /**
169
+ * Whether a client's "search everywhere" should include this attribute.
170
+ *
171
+ * Which attributes can be searched at all is a property of the deployment,
172
+ * not of the code: a substring filter on an attribute the directory has not
173
+ * indexed scans the branch. A schema that marks none leaves the client to
174
+ * guess — every returnable, single-valued, non-pointer attribute — which is
175
+ * right for a small branch and wrong for a large one. Marking even one
176
+ * attribute replaces the guess entirely, so the list is exactly what the
177
+ * deployment indexed.
178
+ */
179
+ searchable?: boolean;
180
+ /**
181
+ * Named states of a lifecycle attribute, mapping a semantic name the API
182
+ * speaks (`enabled`, `disabled`) to the concrete value this directory
183
+ * stores. What "disabled" *is* — a DN in a nomenclature, a string, a
184
+ * boolean — is a property of the deployment, never of the code.
185
+ */
186
+ states?: Record<string, AttributeValue>;
187
+ /**
188
+ * For a `pointer`: refuse to delete the entry it points at while any entry
189
+ * still references it. `ignore` (the default) leaves the reference dangling.
190
+ */
191
+ referentialIntegrity?: 'restrict' | 'ignore';
192
+ /**
193
+ * For a `members`-role attribute: refuse to delete the entry while it still
194
+ * has members. The placeholder member some directories need to keep a
195
+ * `groupOfNames` valid (`--group-dummy-user`) does not count.
196
+ */
197
+ deleteGuard?: 'nonEmpty';
198
+ /**
199
+ * For a mail attribute: which set of domains the address must belong to.
200
+ *
201
+ * `organization` walks up from the entry's organization, collecting the
202
+ * domains declared on it and on each of its ancestors; `directory` accepts
203
+ * any domain declared anywhere in the directory. When no domain is declared
204
+ * at all, any address passes.
205
+ */
206
+ mailDomainScope?: 'organization' | 'directory';
207
+ /**
208
+ * Accept a subdomain of an authorised domain — `list@lists.example.org`
209
+ * under `example.org`. Off by default: the domain must match exactly.
210
+ */
211
+ allowSubdomains?: boolean;
212
+ /** Grouping hint for a form, e.g. `Mailbox Settings` */
213
+ group?: string;
214
+ /** Name a client shows for this attribute, in one or several languages */
215
+ label?: LocalizedText;
216
+ }
2
217
  export interface Schema {
3
218
  strict: boolean;
4
219
  attributes: {
5
- [key: string]: {
6
- type: 'string' | 'array' | 'pointer';
7
- items?: {
8
- type: string;
9
- test?: string | RegExp;
10
- };
11
- default?: AttributeValue;
12
- required?: boolean;
13
- test?: string | RegExp;
14
- branch?: string[];
15
- fixed?: boolean;
16
- };
220
+ [key: string]: SchemaAttribute;
221
+ };
222
+ /**
223
+ * Metadata about the entity itself, present in a schema file loaded by
224
+ * `ldapFlatGeneric` (the whole file is parsed as a `Schema`, so this is what
225
+ * that parse actually carries — narrowing to the declared type must not
226
+ * silently drop it). A schema attached to a plugin without its own file
227
+ * carries none of this.
228
+ */
229
+ entity?: {
230
+ /** Name a client shows for the collection, in one or several languages */
231
+ label?: LocalizedText;
232
+ /** Same, for a single entry */
233
+ singularLabel?: LocalizedText;
234
+ /**
235
+ * What a client shows for an entry, keyed by its main attribute value:
236
+ * the readable name of a nomenclature value a pointer lands on
237
+ */
238
+ valueLabels?: Record<string, LocalizedText>;
17
239
  };
18
240
  }
241
+ /**
242
+ * Tell whether an attribute carries a given semantic role.
243
+ *
244
+ * @param attr attribute definition
245
+ * @param role role to look for
246
+ * @returns true when the attribute declares that role
247
+ */
248
+ export declare function hasRole(attr: SchemaAttribute | undefined, role: SchemaRole): boolean;
249
+ /**
250
+ * Find the attribute of a schema carrying a given semantic role.
251
+ *
252
+ * This is the lookup that keeps core code free of concrete attribute names:
253
+ * `roleAttribute(schema, 'accountStatus')` returns `twakeAccountStatus` for a
254
+ * Twake directory and whatever another deployment chose for its own.
255
+ *
256
+ * @param schema entity schema
257
+ * @param role role to look for
258
+ * @returns attribute name, or undefined when no attribute declares that role
259
+ */
260
+ export declare function roleAttribute(schema: Schema | undefined, role: SchemaRole): string | undefined;
261
+ /**
262
+ * Find every attribute of a schema carrying a given semantic role.
263
+ *
264
+ * @param schema entity schema
265
+ * @param role role to look for
266
+ * @returns attribute names, in declaration order
267
+ */
268
+ export declare function roleAttributes(schema: Schema | undefined, role: SchemaRole): string[];
269
+ /**
270
+ * Find an attribute definition by the name a request or the directory uses.
271
+ *
272
+ * LDAP attribute names are case-insensitive and may carry options
273
+ * (`cn;lang-fr`), while schema keys are written in one spelling. Looking a
274
+ * name up verbatim lets `TWAKEDEPARTMENTPATH` slip past a rule written for
275
+ * `twakeDepartmentPath`, though the directory treats both as the same.
276
+ *
277
+ * @param schema entity schema
278
+ * @param name attribute name, in any case, with or without options
279
+ * @returns the schema's own spelling and the definition, or undefined
280
+ */
281
+ export declare function schemaAttribute(schema: Schema | undefined, name: string): [string, SchemaAttribute] | undefined;
282
+ /**
283
+ * Every attribute name a modify request touches: added, replaced or deleted.
284
+ *
285
+ * A delete counts as much as a replace: removing a computed attribute is
286
+ * setting it, to nothing, and nothing the client does afterwards can put it
287
+ * back. `delete` comes as a list of names or as an object of values to remove.
288
+ *
289
+ * @param body modify request
290
+ * @returns attribute names, as the client spelt them
291
+ */
292
+ export declare function modifiedAttributeNames(body: {
293
+ add?: object;
294
+ replace?: object;
295
+ delete?: string[] | object;
296
+ }): string[];
297
+ /**
298
+ * Lowercased names of the attributes marked `neverReturn` in any of the given
299
+ * schemas.
300
+ *
301
+ * @param schemas schemas to read, undefined entries skipped
302
+ * @returns lowercased attribute names
303
+ */
304
+ export declare function neverReturnAttributes(schemas: Iterable<Schema | undefined>): Set<string>;
305
+ /**
306
+ * Copy an entry without the attributes of a hidden set.
307
+ *
308
+ * @param entry entry about to be serialised
309
+ * @param hidden lowercased attribute names to leave out
310
+ * @returns the entry itself when nothing is hidden, a filtered copy otherwise
311
+ */
312
+ export declare function withoutAttributes<T extends object>(entry: T, hidden: Set<string>): T;
313
+ /**
314
+ * Check the DNs an attribute carries: a `pointer`, or an array whose `items`
315
+ * are pointers or declare a `branch`.
316
+ *
317
+ * Each DN has to sit inside one of the declared branches, compared RDN by RDN
318
+ * (a suffix match reads `uid=x,xou=users,…` as inside `ou=users,…`), and a
319
+ * pointer has to name an entry that exists. The flat entities checked the
320
+ * branch of a single pointer; organizations and groups read neither `items`
321
+ * rule, so an organization accepted a domain link to a DN outside the domains
322
+ * branch, or to none at all — and lost its mail-domain restriction with it.
323
+ *
324
+ * @param field attribute name, for the messages
325
+ * @param attr its schema definition
326
+ * @param value value submitted
327
+ * @param entryExists tells whether a DN names an existing entry
328
+ * @throws BadRequestError naming the first DN that breaks a rule
329
+ */
330
+ export declare function checkDnValues(field: string, attr: SchemaAttribute, value: AttributeValue, entryExists: (dn: string) => Promise<boolean>): Promise<void>;
331
+ /**
332
+ * Tell whether every value of an attribute matches its pattern: `test`, or
333
+ * `items.test` for the elements of an array.
334
+ *
335
+ * @param attr schema definition
336
+ * @param value value submitted
337
+ * @returns false when one value does not match
338
+ */
339
+ export declare function matchesPattern(attr: SchemaAttribute, value: AttributeValue): boolean;
340
+ /**
341
+ * Refuse a payload naming an attribute the server owns.
342
+ *
343
+ * `generated` and `readOnly` say a value is not the client's to set. The flat
344
+ * entities enforce it on their own paths; organizations and groups have their
345
+ * own routes and need the same guard, or an endpoint promising that a computed
346
+ * path "cannot be changed here" quietly accepts one.
347
+ *
348
+ * @param schema entity schema, when one is loaded
349
+ * @param names attribute names carried by the request
350
+ * @param mainAttribute RDN attribute, exempt when nothing derives it
351
+ * @throws BadRequestError naming the first attribute the client may not set
352
+ */
353
+ export declare function assertClientMaySet(schema: Schema | undefined, names: string[], mainAttribute?: string): void;
354
+ /**
355
+ * A plugin able to say whether it fills a given generated attribute.
356
+ *
357
+ * Declaring the method is the whole contract: an entity asks before it
358
+ * exempts a required attribute from its check, so the answer has to be about
359
+ * that attribute, not about a role the plugin happens to carry.
360
+ */
361
+ export interface GeneratedAttributeFiller {
362
+ fillsGeneratedAttribute?: (name: string, attr: SchemaAttribute, schema: Schema) => boolean;
363
+ }
364
+ /**
365
+ * Name the first required attribute a new entry fails to provide.
366
+ *
367
+ * A `generated` attribute is exempt: it is filled by a hook *after*
368
+ * validation, on purpose — checks must see the payload as the client sent it,
369
+ * never a value the server has already rewritten.
370
+ *
371
+ * Exempt only when something will actually fill it. `generatedFrom` is
372
+ * derived by the entity itself; for the rest, ask the loaded plugins about
373
+ * *this* attribute rather than trusting a role a dozen of them carry. With no
374
+ * filler — a server upgraded without adding `core/ldap/enterpriseRules` —
375
+ * exempting would write an entry missing an attribute its own schema calls
376
+ * required, silently, and the client could not supply it either since
377
+ * `generated` is refused as input. The entry would be unwritable by any
378
+ * route, so demand it and say so instead.
379
+ *
380
+ * Every entity runs this check: the flat branches, the organizations and the
381
+ * groups, each wording the refusal in its own terms.
382
+ *
383
+ * @param schema entity schema, when one is loaded
384
+ * @param entry entry as the client sent it
385
+ * @param plugins loaded plugins, asked about each generated attribute
386
+ * @returns the attribute name to complain about, or undefined
387
+ */
388
+ export declare function missingRequiredAttribute(schema: Schema | undefined, entry: Record<string, unknown>, plugins: Record<string, unknown>): string | undefined;
@@ -11,6 +11,20 @@ export declare const tryMethodData: (res: Response, method: Function, ...args: u
11
11
  export declare const ok: (res: Response, data?: object) => void;
12
12
  export declare const created: (res: Response, data?: object) => void;
13
13
  export declare const noContent: (res: Response) => void;
14
+ /**
15
+ * A request whose parts did not all succeed, and whose failure cannot be
16
+ * undone (RFC 4918 section 13).
17
+ *
18
+ * An entry rename rewrites the references other entries hold to it. The
19
+ * rename itself is the only rollback point: once the directory has accepted
20
+ * it, a reference that could not be rewritten leaves the directory partly
21
+ * inconsistent, and answering 200 would hide it while answering 500 would
22
+ * claim nothing happened. The body says what was done and what was not.
23
+ *
24
+ * @param res express response
25
+ * @param data report of what succeeded and what did not
26
+ */
27
+ export declare const multiStatus: (res: Response, data: object) => void;
14
28
  export declare const badRequest: (res: Response, message?: string) => void;
15
29
  export declare const unauthorized: (res: Response, message?: string) => void;
16
30
  export declare const forbidden: (res: Response, message?: string) => void;
@@ -1,7 +1,3 @@
1
- /**
2
- * LDAP low-level library
3
- * @author Xavier Guimard <xguimard@linagora.com>
4
- */
5
1
  import type { Request } from 'express';
6
2
  import type { ClientOptions, SearchResult, SearchOptions } from 'ldapts';
7
3
  import type winston from 'winston';
@@ -47,6 +43,22 @@ declare class ldapActions {
47
43
  parent: DM;
48
44
  logger: winston.Logger;
49
45
  private searchCache;
46
+ private cacheEnabled;
47
+ /**
48
+ * How many writes this instance has made since it was built.
49
+ *
50
+ * A read can be overtaken by a write: the write lands and `invalidateCache`
51
+ * finds nothing to drop, because the read has not stored its answer yet —
52
+ * and the read then stores what it read *before* the write, an entry
53
+ * nothing will drop until its TTL runs out. `search()` remembers the count
54
+ * it started at and stores only if no write happened in between, so a read
55
+ * a write overtook costs a cache entry, never a stale answer.
56
+ *
57
+ * The count is per instance and per write, not per DN: a write to another
58
+ * branch keeps a concurrent read out of the cache too. That is hit rate
59
+ * given up, not freshness.
60
+ */
61
+ private cacheGeneration;
50
62
  queryLimit: ReturnType<typeof pLimit>;
51
63
  private connectionPool;
52
64
  private poolSize;
@@ -83,11 +95,26 @@ declare class ldapActions {
83
95
  private getAttributeSignature;
84
96
  /**
85
97
  * Generate cache key for LDAP search
98
+ *
99
+ * Every option that changes the answer belongs in here, or a search that
100
+ * asked for more is served what a search that asked for less got: the
101
+ * requested attributes above all, but also the ones that change the shape
102
+ * of what comes back. `timeLimit` is deliberately absent — it bounds how
103
+ * long the server may spend, not what it returns.
86
104
  */
87
105
  private getCacheKey;
88
106
  /**
89
- * Invalidate cache entries for a specific DN
90
- * Called after modifications to ensure cache consistency
107
+ * Drop every cached read of `dn`, and of everything below it.
108
+ *
109
+ * Called after each write, so the next read of what just changed goes to
110
+ * the directory. Two things this does that a `key.startsWith(dn)` test did
111
+ * not: it compares DNs the way the directory does (see {@link foldDn}), so
112
+ * a write spelling a DN differently from the read that cached it still
113
+ * drops it; and it drops the subtree, because `rename()` and `move()`
114
+ * re-parent a whole branch in one operation — renaming an organization
115
+ * changes the DN of every entry under it — leaving the cached reads of
116
+ * those children filed under DNs that no longer exist. A leaf has nothing
117
+ * below it and the extra test costs nothing.
91
118
  */
92
119
  invalidateCache(dn: string): void;
93
120
  /**
@@ -41,6 +41,30 @@ export declare const transformSchemas: (schemas: string | Buffer, config: Config
41
41
  * ```
42
42
  */
43
43
  export declare function escapeLdapFilter(value: string): string;
44
+ /**
45
+ * Build the LDAP filter of a list search: a substring match on one attribute,
46
+ * or on any of several.
47
+ *
48
+ * The value is escaped, so nothing a client types can reach the filter as
49
+ * syntax; the attribute names cannot be escaped — they *are* syntax — so they
50
+ * are checked against what an LDAP attribute name may contain and refused
51
+ * otherwise.
52
+ *
53
+ * Every list route builds the same filter, and a client asking for a mail
54
+ * address expects the same answer whichever entity it is asking about.
55
+ *
56
+ * @param match - The substring to look for
57
+ * @param attribute - One attribute name, or several separated by commas
58
+ * @returns The LDAP filter
59
+ * @throws BadRequestError when an attribute name is not one
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * substringSearchFilter('ali', 'cn,mail')
64
+ * // => '(|(cn=*ali*)(mail=*ali*))'
65
+ * ```
66
+ */
67
+ export declare function substringSearchFilter(match: string, attribute: string): string;
44
68
  /**
45
69
  * Escape special characters in LDAP DN attribute values according to RFC 4514
46
70
  * Prevents LDAP injection attacks by escaping characters that have special meaning in DNs
@@ -145,6 +169,22 @@ export declare function getParentDn(dn: string): string;
145
169
  * ```
146
170
  */
147
171
  export declare function getRdn(dn: string): string;
172
+ /**
173
+ * Extract the *value* of the first RDN of a DN, with its escapes removed
174
+ *
175
+ * Where {@link getRdn} returns `ou=Test Org`, this returns `Test Org`: the
176
+ * name the entry is known by, which is what a path or a label is built from.
177
+ *
178
+ * @param dn - The DN to read
179
+ * @returns The value, or an empty string when the DN has no RDN
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * rdnValue('ou=Test\\, Org,ou=organization,dc=example,dc=com')
184
+ * // => 'Test, Org'
185
+ * ```
186
+ */
187
+ export declare function rdnValue(dn: string): string;
148
188
  /**
149
189
  * Check if a DN is a child of another DN
150
190
  *
@@ -184,6 +224,31 @@ export declare function isChildOf(dn: string, parentDn: string): boolean;
184
224
  * ```
185
225
  */
186
226
  export declare function normalizeDn(dn: string): string;
227
+ /**
228
+ * Whether a member DN is the placeholder a `groupOfNames` holds so it stays
229
+ * valid with no real member (`--group-dummy-user`).
230
+ *
231
+ * Compared as DNs rather than as text: the directory answers with its own
232
+ * spelling, so the configured `uid=fakeUser,ou=users,…` has to match the
233
+ * `uid=fakeuser, ou=users,…` a group may actually hold. A textual comparison
234
+ * counted such a member as a real one — enough to make an empty group refuse
235
+ * deletion, and to show the placeholder to SCIM clients.
236
+ *
237
+ * Every reader of the member list asks here, so the four of them cannot drift
238
+ * apart again.
239
+ *
240
+ * @param member - The member DN to test
241
+ * @param dummy - The configured placeholder DN, if any
242
+ * @returns true when `member` is that placeholder; false when none is
243
+ * configured, as there is then no placeholder to hide
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * isDummyMemberDn('uid=fakeuser, ou=users,dc=e,dc=c', 'uid=fakeUser,ou=users,dc=e,dc=c')
248
+ * // => true
249
+ * ```
250
+ */
251
+ export declare function isDummyMemberDn(member: unknown, dummy: string | undefined): boolean;
187
252
  /**
188
253
  * Check whether a DN is a branch base itself or sits anywhere below it.
189
254
  *
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @module plugins/auth/authzScope
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Tells a client what the signed-in administrator may actually do, before it
6
+ * offers them a button that will fail.
7
+ *
8
+ * In a local-administration model the scope *is* the interface: a manager
9
+ * administers a few branches of the tree and nothing else, and an application
10
+ * that cannot name those branches leaves them guessing. This endpoint answers
11
+ * both halves — which branches, and which entities can be created in them —
12
+ * by asking the authorization plugin in force, whichever one that is.
13
+ * @group Plugins
14
+ */
15
+ import type { Express } from 'express';
16
+ import DmPlugin, { type Role } from '../../abstract/plugin';
17
+ export default class AuthzScope extends DmPlugin {
18
+ name: string;
19
+ roles: Role[];
20
+ /**
21
+ * Register `GET {api_prefix}/v1/authz/scope`.
22
+ *
23
+ * @param app Express application
24
+ */
25
+ api(app: Express): void;
26
+ /**
27
+ * The authorization plugin in force, if any.
28
+ *
29
+ * @returns the plugin, or undefined when the server is unrestricted
30
+ */
31
+ private authz;
32
+ /**
33
+ * Entities a client can create, as declared by the loaded plugins.
34
+ *
35
+ * @returns entity name and branch, one per creatable entity
36
+ */
37
+ private entities;
38
+ /**
39
+ * Answer the scope request.
40
+ *
41
+ * @param req Express request, carrying the authenticated user
42
+ * @param res Express response
43
+ */
44
+ private scope;
45
+ /**
46
+ * Read the display name and path of a branch, so a client can show the
47
+ * scope in the words the directory uses rather than as a raw DN.
48
+ *
49
+ * @param dn branch to describe
50
+ * @returns name and path when the entry carries them
51
+ */
52
+ private branchLabel;
53
+ }
@@ -1,8 +1,37 @@
1
- import type { Response } from 'express';
1
+ /**
2
+ * @module plugins/auth/llng
3
+ * @group Plugins
4
+ * @author Xavier Guimard <xguimard@linagora.com>
5
+ *
6
+ * Lemonldap::NG authentication plugin
7
+ * This plugin enables authentication and authorization using Lemonldap::NG.
8
+ */
9
+ import type { Express, Response } from 'express';
2
10
  import AuthBase, { DmRequest } from '../../lib/auth/base';
3
11
  import type { Role } from '../../abstract/plugin';
12
+ type LlngHandler = typeof import('lemonldap-ng-handler');
4
13
  export default class AuthLLNG extends AuthBase {
5
14
  name: string;
6
15
  roles: Role[];
16
+ private handler?;
17
+ /**
18
+ * Load the optional `lemonldap-ng-handler` dependency before the server
19
+ * starts serving requests, rather than importing it statically.
20
+ *
21
+ * A static import would throw the moment this file is loaded — as soon as
22
+ * this plugin is configured — with Node's own opaque "Cannot find
23
+ * package" error. Loading it here instead means a server that never
24
+ * configures this plugin never touches the package at all, and one that
25
+ * does configure it gets a clear failure naming both the plugin and the
26
+ * missing dependency, at startup, instead of on the first request an auth
27
+ * plugin that cannot run must not pretend to succeed.
28
+ */
29
+ api(app: Express): Promise<void>;
30
+ /**
31
+ * Load the handler. Split out so tests can override it to simulate the
32
+ * dependency being absent, without actually uninstalling it.
33
+ */
34
+ protected loadHandler(): Promise<LlngHandler>;
7
35
  authMethod(req: DmRequest, res: Response, next: () => void): void;
8
36
  }
37
+ export {};