ldap-rest 0.26.0 → 1.0.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 (89) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/twakePlugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/lib/authz/base.js +1 -1
  6. package/dist/lib/authz/composition.js +1 -1
  7. package/dist/lib/ldapActions.js +1 -1
  8. package/dist/lib/mailDomains.js +1 -0
  9. package/dist/lib/requestContext.js +1 -0
  10. package/dist/lib/utils.js +1 -1
  11. package/dist/plugins/auth/authzDynamic.js +1 -1
  12. package/dist/plugins/auth/authzLinid1.js +1 -1
  13. package/dist/plugins/auth/authzPerBranch.js +1 -1
  14. package/dist/plugins/auth/authzScope.js +1 -1
  15. package/dist/plugins/configApi.js +1 -1
  16. package/dist/plugins/ldap/bulkImport.js +1 -1
  17. package/dist/plugins/ldap/departmentSync.js +1 -1
  18. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  19. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  20. package/dist/plugins/ldap/groups.js +1 -1
  21. package/dist/plugins/ldap/onChange.js +1 -1
  22. package/dist/plugins/ldap/organizations.js +1 -1
  23. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  24. package/dist/plugins/ldap/raw.js +1 -1
  25. package/dist/plugins/ldap/trash.js +1 -1
  26. package/dist/plugins/rabbitmq.js +1 -1
  27. package/dist/plugins/scim/groups.js +1 -1
  28. package/dist/plugins/scim/users.js +1 -1
  29. package/dist/plugins/storage/ldap.js +1 -1
  30. package/dist/plugins/storage/postgres.js +1 -1
  31. package/dist/plugins/storage/valkey.js +1 -1
  32. package/dist/plugins/twake/appAccountsApi.js +1 -1
  33. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  34. package/dist/plugins/twake/clouderyProvision.js +1 -1
  35. package/dist/plugins/twake/groups.js +1 -1
  36. package/dist/plugins/twake/instances.js +1 -1
  37. package/dist/plugins/twake/james.js +1 -1
  38. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  39. package/dist/plugins/twake/spaces.js +1 -1
  40. package/dist/plugins/twake/tombstone.js +1 -1
  41. package/dist/src/abstract/ldapFlat.d.ts +25 -6
  42. package/dist/src/abstract/plugin.d.ts +1 -1
  43. package/dist/src/bin/index.d.ts +12 -0
  44. package/dist/src/lib/authz/base.d.ts +87 -4
  45. package/dist/src/lib/authz/composition.d.ts +21 -0
  46. package/dist/src/lib/ldapActions.d.ts +139 -18
  47. package/dist/src/lib/mailDomains.d.ts +41 -0
  48. package/dist/src/lib/requestContext.d.ts +23 -0
  49. package/dist/src/lib/utils.d.ts +41 -2
  50. package/dist/src/plugins/auth/authzDynamic.d.ts +5 -12
  51. package/dist/src/plugins/auth/authzLinid1.d.ts +3 -1
  52. package/dist/src/plugins/auth/authzPerBranch.d.ts +5 -9
  53. package/dist/src/plugins/ldap/accountLifecycle.d.ts +2 -2
  54. package/dist/src/plugins/ldap/bulkImport.d.ts +30 -5
  55. package/dist/src/plugins/ldap/enterpriseRules.d.ts +11 -1
  56. package/dist/src/plugins/ldap/externalUsersInGroups.d.ts +20 -3
  57. package/dist/src/plugins/ldap/groups.d.ts +48 -1
  58. package/dist/src/plugins/ldap/organizations.d.ts +4 -4
  59. package/dist/src/plugins/twake/appAccountsApi.d.ts +3 -67
  60. package/dist/src/plugins/twake/groups.d.ts +46 -2
  61. package/dist/src/plugins/twake/james.d.ts +0 -11
  62. package/docs/README.md +25 -0
  63. package/docs/plugin-development/README.md +61 -15
  64. package/docs/plugin-development/dependencies.md +8 -2
  65. package/docs/plugin-development/hooks.md +4 -5
  66. package/docs/usage/configuration.md +11 -8
  67. package/docs/usage/plugins/auth/README.md +22 -3
  68. package/docs/usage/plugins/auth/authz-linid1.md +14 -5
  69. package/docs/usage/plugins/auth/authz-per-branch.md +60 -9
  70. package/docs/usage/plugins/integrations/app-accounts.md +5 -0
  71. package/docs/usage/plugins/integrations/groups.md +36 -3
  72. package/docs/usage/plugins/integrations/scim.md +7 -0
  73. package/docs/usage/plugins/integrations/spaces.md +3 -1
  74. package/docs/usage/plugins/ldap/bulk-import.md +20 -0
  75. package/docs/usage/plugins/ldap/enterprise-rules.md +12 -2
  76. package/docs/usage/plugins/ldap/external-users.md +8 -3
  77. package/docs/usage/plugins/ldap/flat-generic.md +3 -0
  78. package/docs/usage/plugins/ldap/groups.md +6 -0
  79. package/docs/usage/plugins/ldap/organizations.md +17 -0
  80. package/docs/usage/plugins/ldap/raw.md +1 -1
  81. package/docs/usage/upgrading.md +228 -0
  82. package/package.json +1 -1
  83. package/static/schemas/example/groups.json +1 -0
  84. package/static/schemas/example/organizations.json +1 -0
  85. package/static/schemas/example/users.json +3 -0
  86. package/static/schemas/twake/groups.json +2 -0
  87. package/static/schemas/twake/organizations.json +1 -0
  88. package/static/schemas/twake/teamMailboxes.json +3 -1
  89. package/static/schemas/twake/users.json +3 -0
@@ -4,6 +4,7 @@ import type winston from 'winston';
4
4
  import pLimit from 'p-limit';
5
5
  import { type Config } from '../config/args';
6
6
  import { type DM } from '../bin';
7
+ import type { Hooks } from '../hooks';
7
8
  import { type ChangeContext } from './changeContext';
8
9
  import { SchemaIndex } from './ldapSchema';
9
10
  export type AttributeValue = Buffer | Buffer[] | string[] | string;
@@ -14,15 +15,13 @@ export type { SearchOptions, SearchResult };
14
15
  * The directory as seen by one HTTP request.
15
16
  *
16
17
  * `ldapActions`'s own methods take the request as a trailing optional
17
- * argument, and the authorization plugins hook `ldap*request` and skip every
18
- * check when it is missing — so omitting it is an authorization bypass that
19
- * fails silently rather than loudly. Three shipped in the SCIM plugin alone.
18
+ * argument, which the authorization plugins judge by. One naming none is
19
+ * made for the request being served, if any (#385); this says which
20
+ * request is meant rather than leaving it to the context.
20
21
  *
21
22
  * These methods take no request parameter: it is captured once by
22
- * `ldapActions.forRequest()`, and forgetting it stops being expressible.
23
- * Request handlers should bind once and use this; the unbound methods remain
24
- * for work that genuinely belongs to no request, such as startup and cron
25
- * tasks, where passing nothing is the deliberate answer.
23
+ * `ldapActions.forRequest()`. Request handlers should bind once and use
24
+ * this; the server's own work goes through {@link ldapActions.system}.
26
25
  */
27
26
  export interface RequestBoundLdap {
28
27
  search(options: SearchOptions, base?: string): Promise<SearchResult | AsyncGenerator<SearchResult>>;
@@ -65,6 +64,8 @@ export interface ModifyOptions {
65
64
  */
66
65
  declaredClasses?: string[];
67
66
  }
67
+ /** The request hooks an operation is judged in, see `ldapActions.authorize` */
68
+ type AuthorizedHook = 'ldapaddrequest' | 'ldapmodifyrequest' | 'ldapdeleterequest' | 'ldaprenamerequest';
68
69
  declare class ldapActions {
69
70
  config: Config;
70
71
  options: ClientOptions;
@@ -106,6 +107,13 @@ declare class ldapActions {
106
107
  private schemaLoading?;
107
108
  /** Whether the last schema read failed, so a failure is logged once. */
108
109
  private schemaUnavailable;
110
+ /**
111
+ * How this server spells attribute names, by name in lower case: the
112
+ * configuration's, then the loaded schemas'. See {@link canonicalName}.
113
+ */
114
+ private spellings;
115
+ /** The first name given for each attribute type, to warn of another. */
116
+ private spelledTypes;
109
117
  /** Class problems already reported, per entry. See {@link warnOnce}. */
110
118
  private classWarnings;
111
119
  constructor(server: DM);
@@ -156,31 +164,91 @@ declare class ldapActions {
156
164
  * below it and the extra test costs nothing.
157
165
  */
158
166
  invalidateCache(dn: string): void;
167
+ /**
168
+ * Drop every cached read holding a value that names `dn`, or an entry
169
+ * below it.
170
+ *
171
+ * The directory mirrors some writes onto other entries than the one
172
+ * written: the `memberof` overlay keeps each member's `memberOf` in step
173
+ * with the group's `member` / `uniqueMember`, and `refint` rewrites or
174
+ * removes the references to an entry renamed or deleted. Those entries'
175
+ * cached reads are what {@link invalidateCache} cannot find, and they name
176
+ * the entry written: the members of a group name it in their `memberOf`.
177
+ * A member a write adds names it only once the write has landed; the
178
+ * caller drops those by their own DN.
179
+ *
180
+ * Every attribute is looked at, not `memberOf` alone: which attributes an
181
+ * overlay maintains is the deployment's configuration. A read naming `dn`
182
+ * for another reason is dropped too, which costs a miss.
183
+ */
184
+ private invalidateReferences;
159
185
  /**
160
186
  * The directory as the server itself, with no request behind it.
161
187
  *
162
- * Every authorization plugin skips its check when a call carries no
163
- * request, so an unbound call is a bypass — a deliberate one where the
164
- * work belongs to nobody in particular: a startup task, a cron job, a
165
- * uniqueness check, a referential-integrity check. Those must see the
188
+ * Every authorization plugin judges an operation by its request, and
189
+ * judges nothing without one. Some work belongs to nobody in particular:
190
+ * a startup task, a cron job, a uniqueness check, a referential-integrity
191
+ * check. Those must see the
166
192
  * whole directory to be correct: a caller who cannot *read* `uid=jdoe`
167
193
  * must still not be handed it as a free identifier, and a reference to an
168
194
  * organization they cannot read is still a valid reference.
169
195
  *
170
- * The methods are the same objects as this instance's own — writes
171
- * included, since a scheduled task writes: the examples above are reads
172
- * because that is where the ambiguity lives, not because a write cannot
173
- * belong to nobody. What this adds is a name: `ldap.system.search(…)` says
174
- * the omission was meant, where `ldap.search(…)` says nothing and looks
175
- * exactly like the mistake it takes one review to miss. A plugin serving a
176
- * request should reach for {@link forRequest} instead.
196
+ * Writes included, since a scheduled task writes: the examples above are
197
+ * reads because that is where the ambiguity lives, not because a write
198
+ * cannot belong to nobody.
199
+ *
200
+ * While a request is being served, an operation that names none is made
201
+ * for it ({@link currentRequest}), so `ldap.search(…)` is judged as the
202
+ * caller. These operations step out of the request instead: they are the
203
+ * server's, whatever plugin serves the request — `authzDynamic` included.
204
+ * Each use says why the work is the server's.
205
+ *
206
+ * Only those six step out: `system.readableBy` and `system.forRequest`
207
+ * still judge the request they are given or find.
177
208
  */
178
209
  get system(): this;
210
+ /** {@link system}, built once. */
211
+ private systemView?;
179
212
  /**
180
213
  * Bind every directory operation to one request, so the authorization
181
214
  * hooks always see it. See {@link RequestBoundLdap}.
182
215
  */
183
216
  forRequest(req: Request): RequestBoundLdap;
217
+ /**
218
+ * Whether the caller of a request can read an entry.
219
+ *
220
+ * For a route that finds an entry as the server — by an attribute, across
221
+ * branches — and then answers about it: an entry the caller cannot read
222
+ * must get the answer an entry that is not there gets, or the route tells
223
+ * them what the directory holds where they cannot read. Refused or
224
+ * filtered out by an authorization plugin, the entry reads as absent.
225
+ * Without a request, as for any operation, nobody judges.
226
+ *
227
+ * @param dn the entry
228
+ * @param req the request whose caller asks
229
+ * @returns whether a read of the entry, as that caller, finds it
230
+ */
231
+ readableBy(dn: string, req?: Request): Promise<boolean>;
232
+ /**
233
+ * Have the authorization plugins judge an operation, without making it.
234
+ *
235
+ * A route that looks in the directory before it writes — whether an entry
236
+ * is there, which organization holds it, whether a pointer leads
237
+ * somewhere — answers what it found, as the server, to whoever asked. A
238
+ * caller who may not make the write then learns, one request at a time,
239
+ * what the directory holds where they cannot read. Judged first, they get
240
+ * the 403 the write itself would get, whether the entry is there or not.
241
+ *
242
+ * Only the hooks of the plugins with the `authz` role run, and what they
243
+ * return is dropped: the operation, when it is made, runs every hook
244
+ * again. Without a request in the arguments, the one being served is
245
+ * judged, as for the operation itself; outside any, nobody judges.
246
+ *
247
+ * @param hook the request hook the operation launches
248
+ * @param args what the operation hands that hook
249
+ * @throws the refusal of the first authorization plugin refusing
250
+ */
251
+ authorize<H extends AuthorizedHook>(hook: H, args: Parameters<NonNullable<Hooks[H]>>[0]): Promise<void>;
184
252
  /**
185
253
  * Declare the object classes an entity gives the entries of its branch.
186
254
  *
@@ -243,6 +311,55 @@ declare class ldapActions {
243
311
  * @returns indexed schema
244
312
  */
245
313
  schemaIndex(): Promise<SchemaIndex>;
314
+ /**
315
+ * Say how this server spells attribute names: a schema loaded by a plugin
316
+ * names its attributes as the plugins compare them. The first spelling of
317
+ * a name wins.
318
+ *
319
+ * @param names attribute names
320
+ */
321
+ knowAttributeNames(names: Iterable<string>): void;
322
+ /**
323
+ * Name each attribute of a write as this server does, in place.
324
+ *
325
+ * The directory takes `MAIL`, `rfc822Mailbox` and `0.9.2342.19200300.100.1.3`
326
+ * for `mail`, while the authorization plugins and the hooks look for
327
+ * `mail`: a write naming the organization link by its OID was judged as
328
+ * if it left the link alone. Each name is resolved through the directory
329
+ * schema — case, aliases, OIDs — and written as the configuration or a
330
+ * loaded schema spells it, or as the directory declares it first.
331
+ *
332
+ * An option names a value of its own (`member;lang-en` is not `member` to
333
+ * the hooks guarding `member`), and is refused, `;binary` aside. Two names
334
+ * for one attribute in one part of a write are refused too: on a modify
335
+ * the directory applies both, and only one would have been judged.
336
+ *
337
+ * Without the schema, the case is still set, and a name given as an OID,
338
+ * which nothing here could resolve, is refused.
339
+ *
340
+ * @param entry attributes of an add, or one part of a modify
341
+ * @throws BadRequestError on an option, or on one attribute named twice
342
+ */
343
+ canonicalAttributes(entry: AttributesList | undefined): Promise<void>;
344
+ /**
345
+ * {@link canonicalAttributes} for every part of a modify, in place.
346
+ *
347
+ * @param changes the modify
348
+ * @throws BadRequestError as {@link canonicalAttributes}
349
+ */
350
+ canonicalChanges(changes: ModifyRequest | undefined): Promise<void>;
351
+ /**
352
+ * The names of one part of a write, as {@link canonicalAttributes} writes
353
+ * them.
354
+ */
355
+ private canonicalNames;
356
+ /**
357
+ * One attribute description, named as this server does.
358
+ *
359
+ * @param key the name as written, options included
360
+ * @param index the directory schema, when it could be read
361
+ */
362
+ private canonicalName;
246
363
  /**
247
364
  * Hand a search result to `ldapsearchfilter`, which may drop entries the
248
365
  * caller is not allowed to see.
@@ -267,6 +384,10 @@ declare class ldapActions {
267
384
  * Move an entry to a new location (different parent)
268
385
  * Uses LDAP modifyDN with full DN to change both RDN and parent
269
386
  *
387
+ * Not judged, request or not: a move is the server's, made for a write
388
+ * already judged — the trash turning a delete into one. A route moving an
389
+ * entry for its caller uses {@link rename}, which is judged.
390
+ *
270
391
  * Note: ldapts provides a high-level API that accepts a full DN as the second parameter,
271
392
  * unlike the standard LDAP modifyDN which expects (newRDN, deleteOldRDN, newSuperior).
272
393
  * ldapts automatically parses the full DN and extracts the newRDN and newSuperior components
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @module lib/mailDomains
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * The mail domains a directory declares: entries in the domain branches,
6
+ * each naming its domain in an attribute.
7
+ * @group Libraries
8
+ */
9
+ import type { DM } from '../bin';
10
+ /** Where mail domain entries live, and the attribute naming their domain */
11
+ export interface DomainBranch {
12
+ base: string;
13
+ attribute: string;
14
+ }
15
+ /** The mail domains among values, lower-cased as James stores them */
16
+ export declare const mailDomains: (values: string[] | undefined) => string[];
17
+ /**
18
+ * Where the mail domain entries live, and the attribute naming their
19
+ * domain: each loaded flat schema declaring the `domainName` role, the
20
+ * lookup `core/ldap/enterpriseRules` makes. Without one, the branch of the
21
+ * shipped `twake/nomenclature/twakeDomain.json` and the attribute
22
+ * `--enterprise-domain-name-attribute` names.
23
+ *
24
+ * Rebuilt on each call, as the enterprise rules do theirs: a list taken in
25
+ * a constructor could predate the schemas.
26
+ *
27
+ * @param server the server, whose flat entities are read
28
+ * @returns the domain branches
29
+ */
30
+ export declare function domainBranches(server: DM): DomainBranch[];
31
+ /**
32
+ * Every mail domain the domain branches declare, lower-cased.
33
+ *
34
+ * Read as the server: which domains the directory serves is not a matter of
35
+ * who asks. A branch that does not exist declares none; any other failure
36
+ * is thrown, since an answer missing domains reads as "not ours".
37
+ *
38
+ * @param server the server
39
+ * @returns the declared domains, each once
40
+ */
41
+ export declare function declaredMailDomains(server: DM): Promise<string[]>;
@@ -0,0 +1,23 @@
1
+ import type { Request } from 'express';
2
+ /**
3
+ * Serve a request: what runs inside, synchronously or not, is made for it.
4
+ *
5
+ * @param req the authenticated request
6
+ * @param fn what serves it
7
+ * @returns what `fn` returns
8
+ */
9
+ export declare function runForRequest<T>(req: Request, fn: () => T): T;
10
+ /**
11
+ * Run something as the server, outside the request being served: its
12
+ * operations are judged by nobody. What `ldap.system` does.
13
+ *
14
+ * @param fn what to run
15
+ * @returns what it returns
16
+ */
17
+ export declare function outsideRequest<T>(fn: () => T): T;
18
+ /**
19
+ * The request being served.
20
+ *
21
+ * @returns the request, or undefined outside any, or as the server
22
+ */
23
+ export declare function currentRequest(): Request | undefined;
@@ -147,7 +147,9 @@ export declare function unescapeDnValue(value: string): string;
147
147
  export declare function validateDnValue(value: string, fieldName: string): void;
148
148
  /**
149
149
  * Parse a Distinguished Name (DN) into its component parts (RDNs)
150
- * Handles escaped commas and other special characters
150
+ * Handles escaped commas and other special characters, and what OpenLDAP
151
+ * still reads from LDAPv2 (RFC 1779): `;` separating RDNs as `,` does, and
152
+ * a value in double quotes holding either
151
153
  *
152
154
  * @param dn - The DN to parse
153
155
  * @returns Array of RDN components
@@ -164,6 +166,15 @@ export declare function validateDnValue(value: string, fieldName: string): void;
164
166
  * @throws BadRequestError if `dn` is not a string
165
167
  */
166
168
  export declare function parseDn(dn: string): string[];
169
+ /**
170
+ * Whether a string has the shape of a DN: RDNs of `type=value` assertions,
171
+ * a type being a name or an OID. What the directory would refuse as
172
+ * `invalidDNSyntax` is answered without asking it.
173
+ *
174
+ * @param dn - The string
175
+ * @returns whether it reads as a DN
176
+ */
177
+ export declare function isDnSyntax(dn: string): boolean;
167
178
  /**
168
179
  * Extract the parent DN from a DN
169
180
  * Removes the first RDN component to get the parent branch
@@ -228,6 +239,32 @@ export declare function rdnValue(dn: string): string;
228
239
  * ```
229
240
  */
230
241
  export declare function isChildOf(dn: string, parentDn: string): boolean;
242
+ /**
243
+ * Teach {@link normalizeDn} the names and OIDs of attribute types: each is
244
+ * read as the type's first name.
245
+ *
246
+ * @param types attribute types, as `ldapSchema` parses them
247
+ */
248
+ export declare function registerAttributeTypes(types: Iterable<{
249
+ oid: string;
250
+ names: string[];
251
+ }>): void;
252
+ /**
253
+ * The name an attribute type is known by, spelled as declared, from the
254
+ * names and OIDs {@link registerAttributeTypes} was given: `COMMONNAME`,
255
+ * `2.5.4.3` and `OID.2.5.4.3` are `cn`.
256
+ *
257
+ * @param type attribute name or OID
258
+ * @returns the name, or undefined for a type it was not given
259
+ */
260
+ export declare function knownAttributeName(type: string): string | undefined;
261
+ /**
262
+ * The attribute types the first RDN of a DN names, as {@link normalizeDn}
263
+ * reads them: `CN=a+2.5.4.11=b,…` names `cn` and `ou`.
264
+ *
265
+ * @param dn the DN
266
+ */
267
+ export declare function rdnTypes(dn: string): string[];
231
268
  /**
232
269
  * Normalize a DN into a canonical, comparable form.
233
270
  *
@@ -238,7 +275,9 @@ export declare function isChildOf(dn: string, parentDn: string): boolean;
238
275
  * `,`. Empty components (e.g. from a trailing separator) are dropped.
239
276
  *
240
277
  * This makes equality / suffix comparisons robust to case, surrounding
241
- * whitespace and multi-valued RDN ordering — unlike a raw string match.
278
+ * whitespace, multi-valued RDN ordering, the escapes a directory answers
279
+ * with ({@link canonicalValue}) and the names and OIDs of an attribute type
280
+ * ({@link registerAttributeTypes}) — unlike a raw string match.
242
281
  *
243
282
  * @param dn - The DN to normalize
244
283
  * @returns The canonical form
@@ -24,7 +24,6 @@
24
24
  * Sub-branch matching is used (a permission on `ou=acme` covers
25
25
  * `ou=users,ou=acme`).
26
26
  */
27
- import { AsyncLocalStorage } from 'async_hooks';
28
27
  import type { Express, Response } from 'express';
29
28
  import { type Role } from '../../abstract/plugin';
30
29
  import type { DM } from '../../bin';
@@ -51,16 +50,6 @@ export interface TokenEntry {
51
50
  export interface AuthzDynamicRequest extends DmRequest {
52
51
  authzToken?: TokenEntry;
53
52
  }
54
- /**
55
- * Per-request async context holding the authenticated token.
56
- *
57
- * Core plugins (ldapGroups, ldapFlat, etc.) do not always forward the Express
58
- * `req` object down to `ldapActions.search()` / `.modify()`. Relying solely
59
- * on `req` would therefore let un-tracked operations bypass authorization.
60
- * Using AsyncLocalStorage gives us a request-scoped, ambient handle that the
61
- * authz hooks can read regardless of whether the caller threaded `req`.
62
- */
63
- export declare const authzContext: AsyncLocalStorage<TokenEntry>;
64
53
  export default class AuthzDynamic extends AuthBase {
65
54
  name: string;
66
55
  roles: Role[];
@@ -175,7 +164,11 @@ export default class AuthzDynamic extends AuthBase {
175
164
  * request.
176
165
  */
177
166
  hooks: Hooks;
178
- /** Read the active token from the async context, or (as a fallback) from `req`. */
167
+ /**
168
+ * The token of the request an operation is made for: the one it names,
169
+ * or the one being served (`ldapActions` fills it in). An operation the
170
+ * server makes as itself (`ldap.system`) has none, and is not judged.
171
+ */
179
172
  private activeToken;
180
173
  /**
181
174
  * Throw a ForbiddenError (403) if the token cannot perform `permission` on
@@ -82,8 +82,10 @@ export default class AuthzLinid1 extends AuthzBase {
82
82
  * Get list of branches user has access to
83
83
  */
84
84
  getAuthorizedBranches(userDn: string): Promise<string[]>;
85
+ /** The organizations naming the user an administrator */
86
+ ruleBranches(userDn: string): Promise<string[]>;
85
87
  /**
86
- * Find permissions for a branch (supports sub-branch matching)
88
+ * The caller's rights on a branch: see {@link mostSpecificBranchRule}.
87
89
  */
88
90
  private findBranchPermissions;
89
91
  }
@@ -65,22 +65,18 @@ export default class AuthzPerBranch extends AuthzBase {
65
65
  * Get list of branches user has read access to (base class implementation)
66
66
  */
67
67
  getAuthorizedBranches(uid: string): Promise<string[]>;
68
+ /**
69
+ * The branches named by the user's rules and their groups'
70
+ */
71
+ ruleBranches(uid: string): Promise<string[]>;
68
72
  /**
69
73
  * Get list of branches user has access to for a given permission type
70
74
  */
71
75
  getAuthorizedBranchesForPermission(uid: string, permissionType: 'read' | 'write' | 'delete'): Promise<string[]>;
72
76
  /**
73
- * Find permissions for a branch (supports sub-branch matching)
77
+ * The subject's rule on a branch: see {@link mostSpecificBranchRule}.
74
78
  */
75
79
  private findBranchPermissions;
76
- /**
77
- * Merge permissions (OR logic - grant if any source grants)
78
- */
79
- private mergePermissions;
80
- /**
81
- * Build LDAP filter for authorized branches
82
- */
83
- private buildBranchFilter;
84
80
  /**
85
81
  * The rules configured for a group, compared as DNs.
86
82
  *
@@ -51,8 +51,8 @@ export default class LdapAccountLifecycle extends DmPlugin {
51
51
  * @param entity flat entity holding the account
52
52
  * @param id identifier or DN of the entry
53
53
  * @param changes modification to apply
54
- * @param req request this comes from: without it, every authorization
55
- * plugin built on `AuthzBase` skips its branch check
54
+ * @param req request this comes from, which the authorization plugins
55
+ * judge the write by
56
56
  */
57
57
  private modify;
58
58
  }
@@ -25,11 +25,11 @@ import type { DM } from '../../bin';
25
25
  * example: 42
26
26
  * created:
27
27
  * type: integer
28
- * description: Entries successfully created (or counted in dry-run).
28
+ * description: Entries successfully created (or that would be, in a dry run).
29
29
  * example: 38
30
30
  * updated:
31
31
  * type: integer
32
- * description: Existing entries updated.
32
+ * description: Existing entries updated (or that would be, in a dry run).
33
33
  * example: 2
34
34
  * skipped:
35
35
  * type: integer
@@ -58,7 +58,8 @@ import type { DM } from '../../bin';
58
58
  * type: string
59
59
  * description: |
60
60
  * `unique` or `mailDomain`, with `attribute`, `value` and
61
- * `domains`, as in the `Error` answer of a single write.
61
+ * `domains`, as in the `Error` answer of a single write;
62
+ * `forbidden` when the caller may not write the row.
62
63
  * attribute: { type: string }
63
64
  * value: { type: string }
64
65
  * domains:
@@ -97,9 +98,33 @@ export default class LdapBulkImport extends DmPlugin {
97
98
  */
98
99
  private bulkImport;
99
100
  /**
100
- * Process a single CSV record
101
+ * Whether the caller may write a row: create its entry, or change the one
102
+ * it names. Which of the two the row turns into is known only once the
103
+ * directory has been read, so the caller is judged first on both.
104
+ *
105
+ * The creation alone is not enough: judged by attachment, an entry is
106
+ * judged on the organization the row links it to, or on its parent when
107
+ * the row links it to none, while a change is judged on the organization
108
+ * it is attached to. An administrator updating their own accounts from a
109
+ * file naming no organization would be refused.
110
+ *
111
+ * No oracle: the change is judged on the entry as stored, and an entry
112
+ * that is not there is judged on its parent, as a creation without a link
113
+ * is.
114
+ *
115
+ * @throws the refusal of the creation when neither is allowed
101
116
  */
102
- private processRecord;
117
+ private authorizeRow;
118
+ /**
119
+ * Build the entry of a CSV record, reading nothing from the directory: the
120
+ * write it makes is judged on it before anything is looked for.
121
+ */
122
+ private buildRecord;
123
+ /**
124
+ * Complete a record once its write has been judged: the organization path,
125
+ * read from the organization, then the required attributes.
126
+ */
127
+ private completeRecord;
103
128
  /**
104
129
  * Get editable attributes from schema (exclude fixed and calculated)
105
130
  */
@@ -214,11 +214,19 @@ export default class LdapEnterpriseRules extends DmPlugin {
214
214
  * compared by content, and the entry being updated is excluded from the
215
215
  * search — so re-sending an unchanged name no longer conflicts with itself.
216
216
  *
217
+ * Nor with its copies: a value the entry already holds, as stored, is not
218
+ * looked for again, or the copies other plugins keep of the entry — the
219
+ * applicative accounts of `appAccountsConsistency` carry its mail — would
220
+ * hold it against it. Held under another attribute, it is skipped only
221
+ * when that attribute's own constraint looked for it wherever this one
222
+ * looks: a value accepted under a narrower one is checked here.
223
+ *
217
224
  * @param entity entity the entry belongs to
218
225
  * @param name attribute being checked
219
226
  * @param attr its schema definition
220
227
  * @param values values submitted for it
221
- * @param selfDn DN to exclude, on a modification
228
+ * @param dn entry being written
229
+ * @param current entry as stored, on a modification; its DN is excluded
222
230
  * @throws ConflictError naming the entry that already holds the value
223
231
  */
224
232
  private checkUnique;
@@ -341,6 +349,8 @@ export default class LdapEnterpriseRules extends DmPlugin {
341
349
  * @param dn DN to look for
342
350
  * @returns the matching entries, carrying that attribute
343
351
  */
352
+ /** The bases found missing, said once each */
353
+ private readonly missingBases;
344
354
  private searchReferrers;
345
355
  /**
346
356
  * Every reference the directory holds to a DN.
@@ -9,6 +9,12 @@ import DmPlugin, { type Role } from '../../abstract/plugin';
9
9
  import type { DM } from '../../bin';
10
10
  import { Hooks } from '../../hooks';
11
11
  import ldapActions from '../../lib/ldapActions';
12
+ /**
13
+ * The domain of an address, as the managed domains are compared: after its
14
+ * last `@` (the local part may hold one, quoted), lower-cased, without the
15
+ * trailing dot DNS allows. Empty for an address without one.
16
+ */
17
+ export declare const mailDomainOf: (mail: string) => string;
12
18
  export default class ExternalUsersInGroups extends DmPlugin {
13
19
  name: string;
14
20
  roles: Role[];
@@ -23,10 +29,21 @@ export default class ExternalUsersInGroups extends DmPlugin {
23
29
  * right under the branch. Null for any other member.
24
30
  */
25
31
  externalAddress(member: string): string | null;
26
- /** `--mail-domain`, lower-cased: the domains no external member may use */
27
- managedDomains(): string[];
32
+ /**
33
+ * The domains no external member may use, lower-cased: those of
34
+ * `--mail-domain` and those the directory declares in its domain branches,
35
+ * the ones `core/twake/james` follows. A deployment declaring its domains
36
+ * in the directory only would otherwise take an address of its own for an
37
+ * external one, and so would a client reading the list from `/config`.
38
+ *
39
+ * A subdomain of a managed domain is not managed: it is external unless
40
+ * declared itself.
41
+ */
42
+ managedDomains(): Promise<string[]>;
43
+ /** `--mail-domain`, lower-cased */
44
+ private configuredDomains;
28
45
  /**
29
46
  * Where a client sends an external member: `mail=<escaped address>,<branch>`
30
47
  */
31
- getConfigApiData(): Record<string, unknown>;
48
+ getConfigApiData(): Promise<Record<string, unknown>>;
32
49
  }
@@ -164,6 +164,17 @@ export default class LdapGroups extends DmPlugin {
164
164
  * LDAP group methods
165
165
  */
166
166
  addGroup(cn: string, members?: string[], additional?: AttributesList, req?: Request): Promise<boolean>;
167
+ /**
168
+ * The entry `addGroup` writes, as the plugin's own hooks receive it. Built
169
+ * in one place, so that a creation judged before it is made — here, or by
170
+ * a plugin checking something first — is the creation made.
171
+ *
172
+ * @param cn the group's cn
173
+ * @param members member DNs
174
+ * @param additional attributes sent
175
+ * @returns the entry
176
+ */
177
+ candidateGroup(cn: string, members: string[], additional: AttributesList): AttributesList;
167
178
  modifyGroup(cn: string, changes: {
168
179
  add?: AttributesList;
169
180
  replace?: AttributesList;
@@ -239,7 +250,43 @@ export default class LdapGroups extends DmPlugin {
239
250
  * @returns LdapList (means Record<string, AttributesList> where key is the --ldap-user-main-attribute value [default: cn])
240
251
  */
241
252
  searchGroupsByName(cn: string, partial?: boolean, attributes?: string[], req?: Request): Promise<LdapList>;
242
- protected fixDn(dn: string): string | false;
253
+ /**
254
+ * The DN of a group named by its `cn`, or given as a DN: one with a comma.
255
+ * A DN the directory would refuse is answered 400 rather than its error.
256
+ *
257
+ * @param cn the group's name, or its DN
258
+ * @returns its DN
259
+ */
260
+ protected toGroupDn(cn: string): string;
261
+ /**
262
+ * {@link toGroupDn} for a route serving the groups branch: what is not
263
+ * below it, nor named as these groups are (`cn=`), is no group of these
264
+ * routes, whatever entry it is (#395). The branch may be the whole
265
+ * directory, which is the default: the naming is what tells an account
266
+ * from a group there. A multi-valued RDN holding the main attribute
267
+ * passes here; the group class, checked next, is what settles it.
268
+ *
269
+ * @param cn the group's name, or its DN
270
+ * @returns its DN
271
+ */
272
+ protected routeGroupDn(cn: string): string;
273
+ /**
274
+ * A filter matching a group: one of the classes this plugin gives groups
275
+ * (`--group-class`), or a standard group class.
276
+ */
277
+ private groupFilter;
278
+ /**
279
+ * That the entry a route writes is a group, once the write is judged:
280
+ * another entry named alike is not one, and is not written as one (#395).
281
+ *
282
+ * Judged first, a caller who may not make the write gets the refusal
283
+ * whether the entry is there, a group or not; only one who may learns
284
+ * that it is none.
285
+ *
286
+ * @param dn the entry, from {@link routeGroupDn}
287
+ * @param judge the authorization of the write, as `ldap.authorize`
288
+ */
289
+ protected judgedGroup(dn: string, judge: () => Promise<void>): Promise<void>;
243
290
  /**
244
291
  * Verify that each member exists in LDAP
245
292
  *