ldap-rest 0.6.2 → 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 (99) 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/bulk.js +1 -1
  20. package/dist/plugins/scim/errors.js +1 -1
  21. package/dist/plugins/scim/filter.js +1 -1
  22. package/dist/plugins/scim/groups.js +1 -1
  23. package/dist/plugins/scim/list.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/scim/projection.js +1 -0
  27. package/dist/plugins/scim/scim.js +1 -1
  28. package/dist/plugins/scim/users.js +1 -1
  29. package/dist/plugins/twake/calendar.js +1 -0
  30. package/dist/plugins/twake/calendarResources.js +1 -1
  31. package/dist/plugins/twake/james.js +1 -1
  32. package/dist/src/abstract/ldapFlat.d.ts +159 -1
  33. package/dist/src/abstract/plugin.d.ts +13 -0
  34. package/dist/src/config/args.d.ts +5 -0
  35. package/dist/src/config/schema.d.ts +382 -12
  36. package/dist/src/lib/expressFormatedResponses.d.ts +14 -0
  37. package/dist/src/lib/ldapActions.d.ts +59 -6
  38. package/dist/src/lib/utils.d.ts +65 -0
  39. package/dist/src/plugins/auth/authzScope.d.ts +53 -0
  40. package/dist/src/plugins/auth/llng.d.ts +30 -1
  41. package/dist/src/plugins/ldap/accountLifecycle.d.ts +56 -0
  42. package/dist/src/plugins/ldap/departmentSync.d.ts +43 -0
  43. package/dist/src/plugins/ldap/enterpriseRules.d.ts +436 -0
  44. package/dist/src/plugins/ldap/flatGeneric.d.ts +29 -0
  45. package/dist/src/plugins/ldap/groups.d.ts +22 -0
  46. package/dist/src/plugins/ldap/organizations.d.ts +25 -4
  47. package/dist/src/plugins/scim/errors.d.ts +17 -4
  48. package/dist/src/plugins/scim/filter.d.ts +11 -1
  49. package/dist/src/plugins/scim/groups.d.ts +2 -0
  50. package/dist/src/plugins/scim/list.d.ts +3 -5
  51. package/dist/src/plugins/scim/mapping.d.ts +90 -9
  52. package/dist/src/plugins/scim/patch.d.ts +16 -1
  53. package/dist/src/plugins/scim/projection.d.ts +15 -0
  54. package/dist/src/plugins/scim/scim.d.ts +5 -0
  55. package/dist/src/plugins/scim/users.d.ts +21 -0
  56. package/dist/src/plugins/twake/calendar.d.ts +113 -0
  57. package/dist/src/plugins/twake/calendarResources.d.ts +9 -58
  58. package/docs/README.md +2 -0
  59. package/docs/client-development/api/openapi.md +1 -1
  60. package/docs/client-development/browser/libraries.md +1 -1
  61. package/docs/plugin-development/README.md +22 -0
  62. package/docs/plugin-development/dependencies.md +10 -7
  63. package/docs/presentations/en.md +1 -1
  64. package/docs/presentations/fr.md +1 -1
  65. package/docs/usage/README.md +2 -0
  66. package/docs/usage/configuration.md +35 -15
  67. package/docs/usage/directory-audit.md +105 -0
  68. package/docs/usage/plugins/README.md +6 -6
  69. package/docs/usage/plugins/auth/README.md +6 -5
  70. package/docs/usage/plugins/auth/authz-scope.md +103 -0
  71. package/docs/usage/plugins/auth/llng.md +17 -0
  72. package/docs/usage/plugins/integrations/README.md +5 -5
  73. package/docs/usage/plugins/integrations/{calendar-resources.md → calendar.md} +70 -13
  74. package/docs/usage/plugins/integrations/scim.md +147 -38
  75. package/docs/usage/plugins/ldap/README.md +13 -11
  76. package/docs/usage/plugins/ldap/account-lifecycle.md +129 -0
  77. package/docs/usage/plugins/ldap/bulk-import.md +8 -1
  78. package/docs/usage/plugins/ldap/enterprise-rules.md +167 -0
  79. package/docs/usage/plugins/ldap/flat-generic.md +172 -0
  80. package/docs/usage/plugins/ldap/groups.md +10 -4
  81. package/docs/usage/plugins/ldap/organizations.md +59 -33
  82. package/docs/usage/upgrading.md +444 -0
  83. package/package.json +54 -5
  84. package/static/schemas/example/README.md +36 -0
  85. package/static/schemas/example/groups.json +94 -0
  86. package/static/schemas/example/nomenclature/domains.json +49 -0
  87. package/static/schemas/example/organizations.json +106 -0
  88. package/static/schemas/example/positions.json +35 -0
  89. package/static/schemas/example/users.json +246 -0
  90. package/static/schemas/twake/groups.json +81 -15
  91. package/static/schemas/twake/nomenclature/twakeAccountStatus.json +26 -0
  92. package/static/schemas/twake/nomenclature/twakeDeliveryMode.json +22 -0
  93. package/static/schemas/twake/nomenclature/twakeDomain.json +60 -0
  94. package/static/schemas/twake/nomenclature/twakeListType.json +22 -0
  95. package/static/schemas/twake/nomenclature/twakeMailboxType.json +22 -0
  96. package/static/schemas/twake/nomenclature/twakeTitle.json +42 -0
  97. package/static/schemas/twake/organizations.json +92 -12
  98. package/static/schemas/twake/positions.json +22 -4
  99. package/static/schemas/twake/users.json +269 -32
@@ -0,0 +1,436 @@
1
+ /**
2
+ * @module plugins/ldap/enterpriseRules
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Business rules an enterprise directory needs on top of declarative schema
6
+ * validation: uniqueness across a shared namespace, mail addresses confined to
7
+ * the domains an organization owns, computed organization paths, normalised
8
+ * quotas, and referential-integrity guards on deletion.
9
+ *
10
+ * Nothing here knows a concrete attribute name, a domain, or a nomenclature
11
+ * value. Every rule is driven by the loaded entity schemas — their semantic
12
+ * `role`s and their per-attribute markers (`unique`, `mailDomainScope`,
13
+ * `normalize`, `referentialIntegrity`, `deleteGuard`) — so the same code
14
+ * serves a directory laid out differently without a line of change.
15
+ *
16
+ * ## Order of evaluation
17
+ *
18
+ * Checks run **before** transformations, and both run inside the same hook.
19
+ * A check therefore always sees the payload as the client sent it: uniqueness
20
+ * and domain rules validate the address that was submitted, never one the
21
+ * server has already rewritten, and an identifier derived from that address
22
+ * cannot influence the check that justified it.
23
+ *
24
+ * ## What "a reference" is, and the one case this cannot see
25
+ *
26
+ * Two rules — the delete guard and the rename cascade — have to find every
27
+ * entry naming another one by its DN. They look in two places, both declared
28
+ * by the schemas: every `pointer` (single, or under `items`) whose `branch`
29
+ * admits the target, and every attribute carrying the `members` or `owners`
30
+ * role. The second is not redundant: a group's member list cannot be a
31
+ * pointer, since pointer validation requires the target to exist and a group
32
+ * legitimately holds the DN of an external member that has none.
33
+ *
34
+ * A deployment whose schema stores a DN in an attribute that is neither of
35
+ * those — a plain `string` carrying no role — is therefore not covered: its
36
+ * value is left naming the old DN after a rename, and does not hold a
37
+ * deletion back. Declare such an attribute as a `pointer`, or give it the
38
+ * role it plays. No marker is invented here to cover the gap: a marker
39
+ * meaning "this happens to contain a DN" would be a third way of saying what
40
+ * `pointer` and the roles already say.
41
+ * @group Plugins
42
+ */
43
+ import type { Request } from 'express';
44
+ import DmPlugin, { type Role } from '../../abstract/plugin';
45
+ import type { DM } from '../../bin';
46
+ import type { Hooks } from '../../hooks';
47
+ import type { AttributeValue } from '../../lib/ldapActions';
48
+ import type { Schema, SchemaAttribute } from '../../config/schema';
49
+ /**
50
+ * One place a DN is stored: which entry, in which attribute, and what that
51
+ * attribute currently holds there.
52
+ */
53
+ export interface DnReference {
54
+ /** Entry holding the reference */
55
+ dn: string;
56
+ /** Attribute it sits in, in the schema's spelling */
57
+ attribute: string;
58
+ /**
59
+ * Every value the attribute holds in that entry, as the directory spells
60
+ * them. Kept whole rather than reduced to the matching one: a rewrite
61
+ * deletes the spelling the directory actually stores, not the one the
62
+ * caller typed, and it has to know whether the new DN is already there.
63
+ */
64
+ values: string[];
65
+ /** True when the schema declares a list rather than a single value */
66
+ multiValued: boolean;
67
+ }
68
+ /** What a reference rewrite did, and what it could not do. */
69
+ export interface ReferenceRewriteReport {
70
+ /** References rewritten */
71
+ updated: number;
72
+ /**
73
+ * References left naming the old DN, counted per attribute. The referring
74
+ * DNs are deliberately absent: the caller owns the entry it renamed, not
75
+ * the entries pointing at it — the same care `checkUnique` and
76
+ * `guardReferences` take. They are in the log.
77
+ */
78
+ failed: {
79
+ attribute: string;
80
+ count: number;
81
+ }[];
82
+ }
83
+ /**
84
+ * Read a human-readable size and return a number of bytes.
85
+ *
86
+ * `5GB`, `500 MB`, `2048` and `2048B` are all accepted; the multipliers are
87
+ * decimal (1 GB = 10⁹ bytes), which is what mail servers report.
88
+ *
89
+ * @param raw value as submitted
90
+ * @returns the size in bytes
91
+ * @throws BadRequestError when the value is not a size
92
+ */
93
+ export declare function parseByteSize(raw: AttributeValue): number;
94
+ /**
95
+ * Tell whether the enterprise rules fill a generated attribute: a default the
96
+ * server owns, applied on creation, or the organization path, derived from the
97
+ * link or from the tree.
98
+ *
99
+ * A function of the schema alone, so that `audit:directory` asks the same
100
+ * question the server asks without loading the plugin.
101
+ *
102
+ * @param name attribute name
103
+ * @param attr its schema definition
104
+ * @param schema schema it belongs to
105
+ * @returns true when the rules supply a value
106
+ */
107
+ export declare function fillsGeneratedAttribute(name: string, attr: SchemaAttribute, schema: Schema): boolean;
108
+ /**
109
+ * Parse a directory date: an LDAP generalized time or anything `Date`
110
+ * understands.
111
+ *
112
+ * A generalized time (RFC 4517 section 3.3.13) is `yyyyMMddHH`, then optional
113
+ * minutes and seconds, an optional fraction of the last unit given, and a zone:
114
+ * `Z` or an offset of hours with optional minutes (`+02`, `-0530`). `Date` reads
115
+ * none of the shorter forms, and not an offset of bare hours, so they are
116
+ * computed here rather than rewritten into an ISO string it might refuse.
117
+ *
118
+ * @param raw value as stored or submitted
119
+ * @returns the date, or null when it cannot be read
120
+ */
121
+ export declare function parseDirectoryDate(raw: AttributeValue): Date | null;
122
+ /**
123
+ * Start of a day, in UTC.
124
+ *
125
+ * A directory stores generalized times in UTC, and a console that lets someone
126
+ * pick a day stores it as `yyyyMMdd000000Z`. Comparing that against the
127
+ * server's *local* midnight refused a date picked today on every server west
128
+ * of UTC, where local midnight comes hours after midnight UTC: the deployment's
129
+ * timezone decided whether the form worked. Both sides are in UTC now.
130
+ *
131
+ * @param now instant to take the day from, the current one by default
132
+ * @returns midnight UTC of that day, in milliseconds
133
+ */
134
+ export declare function startOfUtcDay(now?: Date): number;
135
+ /**
136
+ * Tell whether a mail address belongs to one of a set of domains.
137
+ *
138
+ * The host is taken after the *last* `@`, so the comparison cannot be fooled
139
+ * by a prefix — the address format itself is the schema's business. A single
140
+ * `*` among the domains authorises every domain.
141
+ *
142
+ * @param mail address to test
143
+ * @param domains authorised domains; an empty list authorises everything
144
+ * @param allowSubdomains accept `list@lists.example.org` under `example.org`
145
+ * @returns true when the address is authorised
146
+ */
147
+ export declare function mailInDomains(mail: string, domains: string[], allowSubdomains?: boolean): boolean;
148
+ export default class LdapEnterpriseRules extends DmPlugin {
149
+ name: string;
150
+ roles: Role[];
151
+ /** Placeholder member some directories keep to satisfy `groupOfNames` */
152
+ private dummyMember?;
153
+ /** Root of the organization tree */
154
+ private topOrganization?;
155
+ constructor(server: DM);
156
+ /**
157
+ * Tell whether this plugin fills a given generated attribute.
158
+ *
159
+ * `ldapFlat` exempts a required attribute from its check when the schema
160
+ * marks it generated, which is only sound if something will honour the
161
+ * bargain. It asks every loaded plugin, so the answer has to be about this
162
+ * attribute, not about a role a dozen plugins happen to carry.
163
+ *
164
+ * @param name attribute the entity is about to demand
165
+ * @param attr its schema definition
166
+ * @param schema schema it belongs to
167
+ * @returns true when a hook here will supply a value
168
+ */
169
+ fillsGeneratedAttribute(name: string, attr: SchemaAttribute, schema: Schema): boolean;
170
+ /**
171
+ * Entities the rules apply to, rebuilt on each call.
172
+ *
173
+ * The organization and group plugins read their schema asynchronously, so a
174
+ * snapshot taken in the constructor would miss them; the list is cheap to
175
+ * rebuild since it only collects references.
176
+ *
177
+ * @returns one binding per entity whose schema is loaded
178
+ */
179
+ private bindings;
180
+ /**
181
+ * Find the entity a DN belongs to: the binding with the longest matching
182
+ * base, so a group branch nested inside the user branch still wins over it.
183
+ *
184
+ * @param dn entry being written
185
+ * @returns the binding, or undefined when the DN belongs to no known entity
186
+ */
187
+ private resolveEntity;
188
+ hooks: Hooks;
189
+ /**
190
+ * Validate a payload against the schema markers that need the directory:
191
+ * uniqueness, mail domains and lifecycle dates.
192
+ *
193
+ * @param entity entity the entry belongs to
194
+ * @param dn entry being written; excluded from uniqueness searches
195
+ * @param values attributes as submitted
196
+ * @param current entry as currently stored, on a modification
197
+ */
198
+ private runChecks;
199
+ /**
200
+ * Apply the value rewrites the server owns: normalisation, defaults for
201
+ * attributes it fills itself, and the organization path derived from the
202
+ * organization link.
203
+ *
204
+ * @param entity entity the entry belongs to
205
+ * @param values attributes as submitted; mutated in place
206
+ * @param creating true on a creation, where defaults apply
207
+ */
208
+ private runTransformations;
209
+ /**
210
+ * Refuse a value already used elsewhere.
211
+ *
212
+ * Two things the rule this replaces got wrong are fixed here: values are
213
+ * compared by content, and the entry being updated is excluded from the
214
+ * search — so re-sending an unchanged name no longer conflicts with itself.
215
+ *
216
+ * @param entity entity the entry belongs to
217
+ * @param name attribute being checked
218
+ * @param attr its schema definition
219
+ * @param values values submitted for it
220
+ * @param selfDn DN to exclude, on a modification
221
+ * @throws ConflictError naming the entry that already holds the value
222
+ */
223
+ private checkUnique;
224
+ /**
225
+ * Refuse a mail address outside the domains its organization may use.
226
+ *
227
+ * @param entity entity the entry belongs to
228
+ * @param attr schema definition of the mail attribute
229
+ * @param values addresses submitted
230
+ * @param submitted attributes as submitted, read for the organization link
231
+ * @param current entry as stored, when the link is not being changed
232
+ * @throws BadRequestError when no organization can be resolved
233
+ * @throws ConflictError when the address is outside the authorised domains
234
+ */
235
+ private checkMailDomains;
236
+ /**
237
+ * Domains an entry may use, walking up from its organization to the root of
238
+ * the tree and collecting what each level declares. An organization that
239
+ * declares none inherits whatever its ancestors allow; when nobody declares
240
+ * anything, no restriction applies.
241
+ *
242
+ * @param entity entity the entry belongs to
243
+ * @param submitted attributes as submitted
244
+ * @param current entry as stored, when the link is not being changed
245
+ * @returns the authorised domain names
246
+ */
247
+ private organizationDomains;
248
+ /**
249
+ * Attribute holding the mail domain of a domain entry.
250
+ *
251
+ * Role first, as its sibling `domainLink` is: a deployment naming the
252
+ * attribute otherwise and declaring the role is exactly what the marker is
253
+ * for, and reading only the option made the rule search an attribute its
254
+ * entries do not have — and accept every address, silently.
255
+ *
256
+ * @returns the attribute name
257
+ */
258
+ private domainNameAttribute;
259
+ /** Every mail domain declared anywhere in the directory. */
260
+ private directoryDomains;
261
+ /**
262
+ * Refuse a lifecycle date that has already passed. The comparison is against
263
+ * the start of the current day *in UTC*, the timezone the values themselves
264
+ * are written in, so scheduling something for today is allowed — which is
265
+ * what "not earlier than today" means to the person filling the form,
266
+ * wherever the server happens to stand.
267
+ *
268
+ * @param name attribute being checked
269
+ * @param attr its schema definition
270
+ * @param values dates submitted
271
+ * @throws BadRequestError when a date is unreadable or in the past
272
+ */
273
+ private checkNotInThePast;
274
+ /**
275
+ * Read the human-readable path of an organization.
276
+ *
277
+ * @param orgDn DN of the organization
278
+ * @returns the path, or undefined when the organization carries none
279
+ */
280
+ private organizationPath;
281
+ /**
282
+ * Refuse a deletion that would leave the directory inconsistent: an entry
283
+ * other entries still point at, or a group that still has members.
284
+ *
285
+ * @param dn entry about to be deleted
286
+ * @throws ConflictError naming what still depends on the entry
287
+ */
288
+ private guardDelete;
289
+ /**
290
+ * Refuse to delete an entry that a `referentialIntegrity: restrict` pointer
291
+ * still names. The pointers are read from the schemas themselves, so a new
292
+ * reference is protected by declaring it, not by editing this file.
293
+ *
294
+ * @param dn entry about to be deleted
295
+ * @throws ConflictError naming the first referencing entry
296
+ */
297
+ private guardReferences;
298
+ /**
299
+ * Every attribute of every loaded entity that could hold this DN.
300
+ *
301
+ * This is the traversal the delete guard and the rename cascade share: it
302
+ * knows no attribute name, only what the schemas declare — a `pointer`
303
+ * (single or under `items`) whose `branch` admits the DN's parent, or an
304
+ * attribute carrying the `members` or `owners` role. The module header says
305
+ * why both are needed and what neither covers.
306
+ *
307
+ * The branch narrowing is what keeps the searches few: only the attributes
308
+ * that could legally name an entry of this branch are looked into.
309
+ *
310
+ * @param dn entry being deleted or renamed
311
+ * @returns one candidate per (entity, attribute) pair
312
+ */
313
+ private referringAttributes;
314
+ /**
315
+ * Entries whose attribute holds a given DN.
316
+ *
317
+ * Run without a request, like every other lookup here: these rules answer
318
+ * about the directory as a whole. A rename that only rewrote the references
319
+ * its caller happens to be able to read would leave the rest silently
320
+ * wrong; the writes are authorized one by one instead.
321
+ *
322
+ * @param candidate entity and attribute to look into
323
+ * @param dn DN to look for
324
+ * @returns the matching entries, carrying that attribute
325
+ */
326
+ private searchReferrers;
327
+ /**
328
+ * Every reference the directory holds to a DN.
329
+ *
330
+ * A reference is a stored value, so this answers about a DN whether or not
331
+ * an entry still carries it — which is what lets the rename ask again
332
+ * afterwards, and what lets a repair run find what an interrupted one left
333
+ * behind.
334
+ *
335
+ * @param dn DN to look for
336
+ * @returns one entry per (referring entry, attribute) pair
337
+ */
338
+ findReferences(dn: string): Promise<DnReference[]>;
339
+ /**
340
+ * Identity of a reference, for comparing the two passes below.
341
+ *
342
+ * @param reference reference to name
343
+ * @returns a key equal for the same attribute of the same entry
344
+ */
345
+ private static referenceKey;
346
+ /**
347
+ * Point every reference at the new DN.
348
+ *
349
+ * Each rewrite is attempted on its own and its failure collected rather
350
+ * than thrown: the rename itself has already been committed by the time
351
+ * this runs, so refusing the whole thing would report a directory state
352
+ * that does not exist. The caller answers 207 with this report.
353
+ *
354
+ * The write is a plain modify, never the group plugin's member API: that
355
+ * one fires member validation, which creates missing external members —
356
+ * work that has no business running while an existing DN is being respelled.
357
+ *
358
+ * **The directory may have done part of the work already.** OpenLDAP's
359
+ * `refint` overlay rewrites the attributes it is configured for — `member`,
360
+ * `owner`, `uniqueMember`, `manager`, `memberOf` in the stock configuration
361
+ * — the moment a `modifyDN` lands, and knows nothing of the rest. Which
362
+ * attributes, and whether the overlay is there at all, is a property of the
363
+ * deployment. So the references are looked for a second time, *after* the
364
+ * rename: what is still stale is what this has to write, and what is no
365
+ * longer there was written by the directory itself and counts as done.
366
+ * Re-adding a value the overlay had already added is refused with
367
+ * `attributeOrValueExists`, which would have been reported as a 207 on a
368
+ * directory that was in fact perfectly consistent.
369
+ *
370
+ * @param references what {@link findReferences} found, before the rename
371
+ * @param oldDn DN as it was
372
+ * @param newDn DN as it now is
373
+ * @param req request the rename came from, so each write is authorized
374
+ * @returns how many references now name the new DN, and what was left behind
375
+ */
376
+ rewriteReferences(references: DnReference[], oldDn: string, newDn: string, req?: Request): Promise<ReferenceRewriteReport>;
377
+ /**
378
+ * Respell one reference, converging when the directory is respelling it at
379
+ * the same moment.
380
+ *
381
+ * `refint` does its fixups in a task of its own, *after* the `modifyDN` has
382
+ * answered, so a rewrite of one of its attributes is a race nobody can win
383
+ * by looking first: the overlay may land between the search and the modify.
384
+ * Both outcomes of that race say the same thing —
385
+ * `attributeOrValueExists` that the new DN is already in, `noSuchAttribute`
386
+ * that the old one is already out — so the entry is read again and the
387
+ * remaining half, if any, is applied. Anything else is a real failure.
388
+ *
389
+ * @param ldap directory to write through, bound to the request
390
+ * @param reference reference to rewrite
391
+ * @param stale canonical form of the old DN
392
+ * @param target canonical form of the new DN
393
+ * @param newDn new DN, as it is to be written
394
+ * @throws whatever the directory answered, when it is not a race
395
+ */
396
+ private applyRewrite;
397
+ /**
398
+ * Read one attribute of one entry, past the cache.
399
+ *
400
+ * The cached copy may predate a write nobody here made — `refint` fixing
401
+ * its own attributes — and the cache is only invalidated by the writes
402
+ * this process performs.
403
+ *
404
+ * @param dn entry to read
405
+ * @param name attribute to read
406
+ * @returns its values, as the directory spells them
407
+ */
408
+ private freshValues;
409
+ /**
410
+ * The modification that respells one reference.
411
+ *
412
+ * A list has the stale values deleted and the new one added, so the other
413
+ * members of a group are left alone — and nothing is added when the new DN
414
+ * is already there, which is what a repair run finds. A single-valued
415
+ * attribute is replaced instead: `ldapActions` emits an `add` before a
416
+ * `delete`, which a single-valued attribute refuses, and the replacement is
417
+ * built from the values read so that a directory holding more than the
418
+ * schema says loses none of them.
419
+ *
420
+ * @param reference reference to rewrite
421
+ * @param stale canonical form of the old DN
422
+ * @param target canonical form of the new DN
423
+ * @param newDn new DN, as it is to be written
424
+ * @returns the changes, or undefined when there is nothing to do
425
+ */
426
+ private rewrite;
427
+ /**
428
+ * Read a single entry, without a request so the lookup is not narrowed by
429
+ * the caller's own permissions: these rules answer about the directory as a
430
+ * whole, not about what one manager may see.
431
+ *
432
+ * @param dn entry to read
433
+ * @returns the entry, or null when it does not exist
434
+ */
435
+ private readEntry;
436
+ }
@@ -2,12 +2,41 @@ import type { Express } from 'express';
2
2
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
3
  import LdapFlat from '../../abstract/ldapFlat';
4
4
  import type { DM } from '../../bin';
5
+ import type { LocalizedText } from '../../config/schema';
6
+ /**
7
+ * Collections the other LDAP plugins serve under the same `/v1/ldap/` prefix,
8
+ * with the plugin that serves each: its registry name, and the module paths a
9
+ * configuration loads it by.
10
+ *
11
+ * They are listed rather than read from the plugins because those write their
12
+ * routes literally — `scripts/generate-openapi.ts` reads the route text, and a
13
+ * `${this.pluralName}` there would document `/v1/ldap/{resource}` instead of
14
+ * the real path. `test/plugins/ldap/flatGenericPluginClash.test.ts` checks
15
+ * this list against the routes those plugins really register, so it cannot
16
+ * drift away from them in silence.
17
+ */
18
+ export declare const ldapPluginCollections: {
19
+ plugin: string;
20
+ modules: string[];
21
+ collection: string;
22
+ }[];
5
23
  /**
6
24
  * Concrete implementation of LdapFlat for generic instances
7
25
  */
8
26
  declare class LdapFlatInstance extends LdapFlat {
9
27
  name: string;
10
28
  roles: Role[];
29
+ /** Names a client shows for the collection and for one entry */
30
+ label?: LocalizedText;
31
+ singularLabel?: LocalizedText;
32
+ /**
33
+ * What a client shows for an entry, keyed by its main attribute value. Also
34
+ * reachable nested in `schema.entity.valueLabels` (the whole schema file is
35
+ * exposed as `schema`); mirrored here, like `label`, so a client finds it
36
+ * at the top level of a `flatResources` entry rather than three levels
37
+ * down under a property `Schema` never declared.
38
+ */
39
+ valueLabels?: Record<string, LocalizedText>;
11
40
  dependencies: {
12
41
  departmentSync: string;
13
42
  };
@@ -94,6 +94,28 @@ export default class LdapGroups extends DmPlugin {
94
94
  * API routes
95
95
  */
96
96
  api(app: Express): void;
97
+ /**
98
+ * Whether a member is the placeholder `groupOfNames` needs to stay valid
99
+ * with no real member.
100
+ *
101
+ * Compared as DNs, not as text: the directory answers with its own spelling
102
+ * of a DN, and `uid=fakeUser,ou=users,…` configured must match the
103
+ * `uid=fakeuser, ou=users,…` it may hold.
104
+ *
105
+ * @param member DN of a member
106
+ * @returns true for the placeholder
107
+ */
108
+ isDummyMember(member: string): boolean;
109
+ /**
110
+ * An entry with the placeholder taken out of its members. It is there for
111
+ * the directory's schema, not for anyone reading the group: listed, it
112
+ * reads as a person, and a client editing the list would offer to remove
113
+ * the one member the server refuses to remove.
114
+ *
115
+ * @param entry group as read
116
+ * @returns the same entry, members filtered
117
+ */
118
+ withoutDummyMember<T extends Record<string, unknown>>(entry: T): T;
97
119
  apiGet(req: Request, res: Response): Promise<void>;
98
120
  apiAdd(req: Request, res: Response, ...requiredFields: string[]): Promise<void>;
99
121
  apiDelete(req: Request, res: Response): Promise<void>;
@@ -121,9 +121,14 @@ export default class LdapOrganizations extends DmPlugin {
121
121
  checkDeptLink(entry: AttributesList): Promise<void>;
122
122
  /**
123
123
  * Check if the department path is valid
124
+ *
124
125
  * @param entry LDAP entry to check
126
+ * @param dn DN the entry is written at, when the caller knows it. It is
127
+ * what says whether the entry is an organization and where it hangs
128
+ * from; a modify carries the changed attributes alone, neither `ou` nor
129
+ * `objectClass`, so without it the invariants below cannot be checked.
125
130
  */
126
- checkDeptPath(entry: AttributesList): Promise<void>;
131
+ checkDeptPath(entry: AttributesList, dn?: string): Promise<void>;
127
132
  isEmptyOrganization(dn: string): Promise<void>;
128
133
  /**
129
134
  * Check if entry is an organisation
@@ -136,9 +141,25 @@ export default class LdapOrganizations extends DmPlugin {
136
141
  getOrganisationSubnodes(dn: string, req?: Request): Promise<AttributesList[]>;
137
142
  addOrganization(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
138
143
  modifyOrganization(dn: string, changes: ModifyRequest): Promise<boolean>;
139
- validateNewOrganization(dn: string, entry: AttributesList): boolean;
140
- validateChanges(dn: string, changes: ModifyRequest): boolean;
141
- _validateOneChange(field: string, value: AttributeValue | null): boolean;
144
+ validateNewOrganization(dn: string, entry: AttributesList): Promise<boolean>;
145
+ /**
146
+ * Build the rejection message for a value that failed its schema `test`,
147
+ * quoting the `hint` when the schema carries one so the answer says what a
148
+ * valid value looks like.
149
+ *
150
+ * @param field attribute name
151
+ * @returns message for a `BadRequestError`
152
+ */
153
+ invalidValueMessage(field: string): string;
154
+ validateChanges(dn: string, changes: ModifyRequest): Promise<boolean>;
155
+ _validateOneChange(field: string, value: AttributeValue | null): Promise<boolean>;
156
+ /**
157
+ * Tell whether a DN names an existing entry.
158
+ *
159
+ * @param dn DN to look up
160
+ * @returns true when the directory holds it
161
+ */
162
+ private entryExists;
142
163
  /**
143
164
  * Move an organization to a different parent organization
144
165
  * Uses LDAP modifyDN to change the DN hierarchy
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { Request, Response, NextFunction, RequestHandler } from 'express';
8
8
  import { HttpError } from '../../lib/errors';
9
- import { type ScimErrorType } from './types';
9
+ import { type ScimErrorResponse, type ScimErrorType } from './types';
10
10
  export declare const SCIM_CONTENT_TYPE = "application/scim+json";
11
11
  export declare class ScimError extends HttpError {
12
12
  scimType?: ScimErrorType;
@@ -23,11 +23,24 @@ export declare const scimTooMany: (detail?: string) => ScimError;
23
23
  export declare const scimNotFound: (detail?: string) => ScimError;
24
24
  export declare function writeScimError(res: Response, status: number, detail: string, scimType?: ScimErrorType): void;
25
25
  /**
26
- * Extract an LDAP numeric error code from either a thrown ldapts error
27
- * (has `.code`) or an Error wrapped by ldapActions (message contains the
28
- * original text). Returns undefined if no code is detectable.
26
+ * Extract an LDAP numeric error code from a thrown ldapts error.
27
+ *
28
+ * `.code` first: ldapts sets it, and `ldapActions` now carries it across its
29
+ * own wrapping rather than reducing the failure to a sentence. The message
30
+ * patterns below stay as a fallback for the paths that still throw a bare
31
+ * `Error` — they are why a driver that reworded its messages could quietly
32
+ * turn a 400 into a 500, so they are the last resort, not the first.
29
33
  */
30
34
  export declare function extractLdapCode(err: unknown): number | undefined;
35
+ /**
36
+ * Turn any thrown thing into the SCIM error body it deserves.
37
+ *
38
+ * Split out from `writeScimErrorFromException` so `/Bulk`, which reports a
39
+ * status per operation instead of writing a response, applies exactly the
40
+ * same translations — including the ones that matter: an authorization
41
+ * refusal is a 403 with the internal marker stripped, not a 500 quoting it.
42
+ */
43
+ export declare function scimErrorFromException(err: unknown, fallbackStatus?: number): ScimErrorResponse;
31
44
  export declare function writeScimErrorFromException(res: Response, err: unknown, fallbackStatus?: number): void;
32
45
  /**
33
46
  * Express wrapper that catches errors, serializes them as SCIM
@@ -1,4 +1,14 @@
1
1
  import type { ResourceMapping } from './types';
2
+ export interface FilterOptions {
3
+ /** LDAP attribute whose presence marks a User as locked. */
4
+ lockAttribute?: string;
5
+ /**
6
+ * Whether this resource type carries SCIM `active`. Users do; Groups do
7
+ * not, and must get `invalidFilter` rather than a filter built out of a
8
+ * pseudo-attribute that means nothing to them.
9
+ */
10
+ supportsActive?: boolean;
11
+ }
2
12
  export interface TranslatedFilter {
3
13
  ldapFilter: string;
4
14
  /** True if the filter contains `id eq "..."`, which the caller should translate into a DN lookup. */
@@ -10,4 +20,4 @@ export interface TranslatedFilter {
10
20
  * Translate a SCIM filter string into an LDAP filter.
11
21
  * Returns metadata enabling the caller to short-circuit id-equals queries.
12
22
  */
13
- export declare function scimFilterToLdap(filter: string, mapping: ResourceMapping): TranslatedFilter;
23
+ export declare function scimFilterToLdap(filter: string, mapping: ResourceMapping, opts?: FilterOptions): TranslatedFilter;
@@ -55,6 +55,8 @@ export declare class ScimGroups {
55
55
  create(req: DmRequest, resource: ScimGroup): Promise<ScimGroup>;
56
56
  replace(req: DmRequest, id: string, resource: ScimGroup): Promise<ScimGroup>;
57
57
  patch(req: DmRequest, id: string, patch: PatchRequest): Promise<ScimGroup>;
58
+ /** Fetch the raw LDAP entry backing a SCIM id, or raise 404. */
59
+ private currentEntry;
58
60
  delete(req: DmRequest, id: string): Promise<void>;
59
61
  /** Used internally and by Bulk to resolve a Group SCIM reference to a DN. */
60
62
  resolveRef(req: DmRequest, value: string): Promise<string | undefined>;
@@ -16,11 +16,10 @@
16
16
  * `tooMany` RFC 7644 §3.12 provides for a filter yielding "many more results
17
17
  * than the server is willing to calculate or process".
18
18
  */
19
- import type ldapActions from '../../lib/ldapActions';
20
- import type { AttributesList } from '../../lib/ldapActions';
21
- import type { DmRequest } from '../../lib/auth/base';
19
+ import type { AttributesList, RequestBoundLdap } from '../../lib/ldapActions';
22
20
  export interface PagedSearch {
23
- ldap: ldapActions;
21
+ /** The directory bound to the request, so authorization always applies. */
22
+ directory: RequestBoundLdap;
24
23
  base: string;
25
24
  filter: string;
26
25
  attributes: string[];
@@ -30,7 +29,6 @@ export interface PagedSearch {
30
29
  count: number;
31
30
  /** Upper bound on entries walked before answering `tooMany`. */
32
31
  maxScanned: number;
33
- req?: DmRequest;
34
32
  }
35
33
  export interface PagedResult {
36
34
  entries: AttributesList[];