ldap-rest 0.26.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) 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/auth/openidconnect.js +1 -1
  16. package/dist/plugins/bcl.js +1 -1
  17. package/dist/plugins/configApi.js +1 -1
  18. package/dist/plugins/ldap/bulkImport.js +1 -1
  19. package/dist/plugins/ldap/departmentSync.js +1 -1
  20. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  21. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  22. package/dist/plugins/ldap/groups.js +1 -1
  23. package/dist/plugins/ldap/onChange.js +1 -1
  24. package/dist/plugins/ldap/organizations.js +1 -1
  25. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  26. package/dist/plugins/ldap/raw.js +1 -1
  27. package/dist/plugins/ldap/trash.js +1 -1
  28. package/dist/plugins/rabbitmq.js +1 -1
  29. package/dist/plugins/scim/groups.js +1 -1
  30. package/dist/plugins/scim/users.js +1 -1
  31. package/dist/plugins/storage/ldap.js +1 -1
  32. package/dist/plugins/storage/postgres.js +1 -1
  33. package/dist/plugins/storage/valkey.js +1 -1
  34. package/dist/plugins/twake/appAccountsApi.js +1 -1
  35. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  36. package/dist/plugins/twake/clouderyProvision.js +1 -1
  37. package/dist/plugins/twake/groups.js +1 -1
  38. package/dist/plugins/twake/instances.js +1 -1
  39. package/dist/plugins/twake/james.js +1 -1
  40. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  41. package/dist/plugins/twake/spaces.js +1 -1
  42. package/dist/plugins/twake/tombstone.js +1 -1
  43. package/dist/src/abstract/ldapFlat.d.ts +25 -6
  44. package/dist/src/abstract/plugin.d.ts +1 -1
  45. package/dist/src/bin/index.d.ts +12 -0
  46. package/dist/src/hooks.d.ts +12 -3
  47. package/dist/src/lib/authz/base.d.ts +87 -4
  48. package/dist/src/lib/authz/composition.d.ts +21 -0
  49. package/dist/src/lib/ldapActions.d.ts +139 -18
  50. package/dist/src/lib/mailDomains.d.ts +41 -0
  51. package/dist/src/lib/requestContext.d.ts +23 -0
  52. package/dist/src/lib/utils.d.ts +41 -2
  53. package/dist/src/plugins/auth/authzDynamic.d.ts +5 -12
  54. package/dist/src/plugins/auth/authzLinid1.d.ts +3 -1
  55. package/dist/src/plugins/auth/authzPerBranch.d.ts +5 -9
  56. package/dist/src/plugins/auth/openidconnect.d.ts +47 -0
  57. package/dist/src/plugins/bcl.d.ts +2 -1
  58. package/dist/src/plugins/ldap/accountLifecycle.d.ts +2 -2
  59. package/dist/src/plugins/ldap/bulkImport.d.ts +30 -5
  60. package/dist/src/plugins/ldap/enterpriseRules.d.ts +11 -1
  61. package/dist/src/plugins/ldap/externalUsersInGroups.d.ts +20 -3
  62. package/dist/src/plugins/ldap/groups.d.ts +48 -1
  63. package/dist/src/plugins/ldap/organizations.d.ts +4 -4
  64. package/dist/src/plugins/twake/appAccountsApi.d.ts +3 -67
  65. package/dist/src/plugins/twake/groups.d.ts +46 -2
  66. package/dist/src/plugins/twake/james.d.ts +0 -11
  67. package/docs/README.md +25 -0
  68. package/docs/plugin-development/README.md +61 -15
  69. package/docs/plugin-development/dependencies.md +8 -2
  70. package/docs/plugin-development/hooks.md +4 -5
  71. package/docs/usage/configuration.md +11 -8
  72. package/docs/usage/plugins/auth/README.md +22 -3
  73. package/docs/usage/plugins/auth/authz-linid1.md +14 -5
  74. package/docs/usage/plugins/auth/authz-per-branch.md +60 -9
  75. package/docs/usage/plugins/auth/back-channel-logout.md +28 -0
  76. package/docs/usage/plugins/auth/oidc.md +8 -0
  77. package/docs/usage/plugins/integrations/app-accounts.md +5 -0
  78. package/docs/usage/plugins/integrations/groups.md +36 -3
  79. package/docs/usage/plugins/integrations/scim.md +7 -0
  80. package/docs/usage/plugins/integrations/spaces.md +3 -1
  81. package/docs/usage/plugins/ldap/bulk-import.md +20 -0
  82. package/docs/usage/plugins/ldap/enterprise-rules.md +12 -2
  83. package/docs/usage/plugins/ldap/external-users.md +8 -3
  84. package/docs/usage/plugins/ldap/flat-generic.md +3 -0
  85. package/docs/usage/plugins/ldap/groups.md +6 -0
  86. package/docs/usage/plugins/ldap/organizations.md +17 -0
  87. package/docs/usage/plugins/ldap/raw.md +1 -1
  88. package/docs/usage/upgrading.md +228 -0
  89. package/package.json +1 -1
  90. package/static/schemas/example/groups.json +1 -0
  91. package/static/schemas/example/organizations.json +1 -0
  92. package/static/schemas/example/users.json +3 -0
  93. package/static/schemas/twake/groups.json +2 -0
  94. package/static/schemas/twake/organizations.json +1 -0
  95. package/static/schemas/twake/teamMailboxes.json +3 -1
  96. package/static/schemas/twake/users.json +3 -0
@@ -12,6 +12,21 @@ import type { DM } from '../../bin';
12
12
  import type { BranchPermissions } from '../../config/args';
13
13
  import { type DmRequest } from '../auth/base';
14
14
  import type { AttributesList, ModifyRequest, SearchResult } from '../ldapActions';
15
+ /**
16
+ * The rule of the most specific configured branch holding `target`.
17
+ *
18
+ * Branches are compared as DNs, whatever the case and spacing a rule or the
19
+ * directory spells them in. Among one subject's rules, the narrowest branch
20
+ * holding the target applies: a rule on `ou=payroll,ou=acme` takes over from
21
+ * one on `ou=acme` for everything below it, whichever was written first.
22
+ * Rules of different subjects (a user and their groups) are combined by the
23
+ * caller.
24
+ *
25
+ * @param rules the subject's rules, by branch
26
+ * @param target the branch judged
27
+ * @returns the rule that applies, or null when no branch holds the target
28
+ */
29
+ export declare function mostSpecificBranchRule<T>(rules: Iterable<[string, T]>, target: string): T | null;
15
30
  /**
16
31
  * Abstract base class for authorization plugins
17
32
  * Provides common utility methods and interface for LDAP-based authorization
@@ -22,10 +37,21 @@ export default abstract class AuthzBase extends DmPlugin {
22
37
  *
23
38
  * The filter cannot ask `opts` whether the caller wanted the attribute: by
24
39
  * then `opts` is the widened one and would answer yes about an attribute
25
- * we added ourselves. Holding the objects we touched keeps the question
26
- * answerable, and a WeakSet lets them be collected with the request.
40
+ * we added ourselves. A mark on the options keeps the question answerable.
41
+ *
42
+ * A symbol key rather than a set of the objects touched: authorization
43
+ * judges a search before any other plugin, and one rewriting it after —
44
+ * `twake/tombstone` narrows the filter of a SCIM search — hands on a copy.
45
+ * An object spread copies the key; a set would no longer know the copy.
46
+ */
47
+ private static readonly widenedForLink;
48
+ /**
49
+ * The search of the top organization by itself, which every caller may
50
+ * make (`getOrganisationTop`): granted by `ldapsearchrequest` whatever the
51
+ * rules say, it is not filtered by them either. A symbol key, for the
52
+ * reason given at {@link widenedForLink}.
27
53
  */
28
- private static widenedForLink;
54
+ private static readonly topOrganizationRead;
29
55
  roles: Role[];
30
56
  cacheTTL: number;
31
57
  /** What `--authz-unresolved-user` accepts. */
@@ -58,6 +84,11 @@ export default abstract class AuthzBase extends DmPlugin {
58
84
  * `--authz-filter-attached-entries`, off by default.
59
85
  */
60
86
  protected filtersAttachedEntries(): boolean;
87
+ /**
88
+ * Whether this plugin judges entries by their organization link: a plugin
89
+ * writing entries in an organization's branch gives them one then.
90
+ */
91
+ judgesByAttachment(): boolean;
61
92
  /**
62
93
  * Branches the broad read of `filtersAttachedEntries` leaves out: there,
63
94
  * only a caller holding the branch reads. None unless a plugin names some.
@@ -87,6 +118,20 @@ export default abstract class AuthzBase extends DmPlugin {
87
118
  * write.
88
119
  */
89
120
  protected sourcePermission(_branch: string): keyof BranchPermissions;
121
+ /**
122
+ * `dn` as the directory names it, read as the server. OpenLDAP reads a DN
123
+ * through its schema and matching rules — `2.5.4.11=payroll`,
124
+ * `ou=payroll\20` and `ou=payroll` are one entry — further than a text
125
+ * comparison can follow, so a write is judged on the entry it acts on,
126
+ * by the name the rules know it by. An entry that is not there keeps the
127
+ * spelling given: the write fails on it anyway.
128
+ */
129
+ protected storedDn(dn: string): Promise<string>;
130
+ /**
131
+ * A DN about to be written, under its parent as the directory names it:
132
+ * see {@link storedDn}.
133
+ */
134
+ protected storedParent(dn: string): Promise<string>;
90
135
  /**
91
136
  * The branch a write is judged against.
92
137
  *
@@ -178,6 +223,29 @@ export default abstract class AuthzBase extends DmPlugin {
178
223
  * Must be implemented by subclasses
179
224
  */
180
225
  abstract getAuthorizedBranches(user: string): Promise<string[]>;
226
+ /**
227
+ * Every branch one of the caller's rules names, whatever it grants: the
228
+ * branches where what applies differs from the branch above.
229
+ */
230
+ abstract ruleBranches(user: string): Promise<string[]>;
231
+ /**
232
+ * Whether a write changes nothing but the organization link and the path
233
+ * that goes with it: what moving an entry between organizations writes.
234
+ */
235
+ private onlyRelinks;
236
+ /**
237
+ * An entry a rule names is under that rule as well as under what judged
238
+ * the write, and moving it takes along the rules naming what lies below
239
+ * it, which name nothing afterwards (`departmentSync` relinks the
240
+ * accounts attached there to the new DN): the first of these branches
241
+ * where `user` lacks one of `permissions`.
242
+ *
243
+ * Only those: an entry no rule names is under the rule the caller judges.
244
+ *
245
+ * @param subtree whether the rules below `dn` count too: a move, or a
246
+ * delete, which `trash` turns into one
247
+ */
248
+ private refusingRule;
181
249
  /**
182
250
  * Whether this request is none of this plugin's business.
183
251
  *
@@ -256,6 +324,16 @@ export default abstract class AuthzBase extends DmPlugin {
256
324
  * @returns the attribute name, or undefined when no domain is linked
257
325
  */
258
326
  protected domainLinkAttribute(): string | undefined;
327
+ /**
328
+ * The organization a write attaches an entry to, among the link values it
329
+ * gives: one at most. The judgement reads one, so a second would attach
330
+ * the entry to an organization nobody judged; and an empty value, which
331
+ * the directory stores, would make the entry read as attached to none.
332
+ *
333
+ * @throws BadRequestError when a value is empty or the values name
334
+ * several organizations
335
+ */
336
+ private static organizationLinks;
259
337
  /**
260
338
  * The values a write gives an attribute, whatever case its name is
261
339
  * spelled in.
@@ -333,7 +411,12 @@ export default abstract class AuthzBase extends DmPlugin {
333
411
  ldapaddrequest: ([dn, entry, req]: [string, AttributesList, DmRequest?]) => Promise<[string, AttributesList, DmRequest?]>;
334
412
  ldapsearchrequest: ([base, opts, req]: [string, SearchOptions, DmRequest?]) => Promise<[string, SearchOptions, DmRequest?]>;
335
413
  /**
336
- * Drop the accounts attached outside the branches this caller manages.
414
+ * Drop what this caller may not read from a search it was granted.
415
+ *
416
+ * Judged by branch, the search was granted on its base: each entry is
417
+ * kept only where the caller may read it, a rule narrower than the base
418
+ * refusing part of what it holds. Judged by attachment, the accounts
419
+ * attached outside the branches this caller manages are dropped.
337
420
  *
338
421
  * Runs after the cache and carries the request, so what one caller may
339
422
  * not see never reaches another. An entry with no organization link —
@@ -142,4 +142,25 @@ export declare function describePopulation(population: Population, authenticator
142
142
  * @throws Error naming the plugins when the composition is ambiguous
143
143
  */
144
144
  export declare function assertAuthzComposition(server: DM, candidate?: DmPlugin): Promise<void>;
145
+ /**
146
+ * What the loaded plugins can enforce, decided once the configuration is
147
+ * loaded (#385).
148
+ *
149
+ * - No authentication nor authorization plugin: every request is served
150
+ * with every right. An open mode on purpose — local development, a
151
+ * trusted network — said once.
152
+ * - Authorization without authentication: no request carries an identity,
153
+ * so every authorization plugin steps aside and everything passes while
154
+ * the operator believes authorization is on. The server does not start.
155
+ * - Authentication without authorization: every authenticated caller has
156
+ * every right, which a single administration token legitimately wants.
157
+ * Said once.
158
+ *
159
+ * A plugin carrying both roles (`authzDynamic`) authenticates the requests
160
+ * it judges.
161
+ *
162
+ * @param server the server, its configuration loaded
163
+ * @throws Error naming the authorization plugins nothing authenticates for
164
+ */
165
+ export declare function assertEnforceable(server: DM): void;
145
166
  export {};
@@ -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
  *
@@ -11,6 +11,10 @@ export default class OpenIDConnect extends AuthBase {
11
11
  private router?;
12
12
  /** Set once the missing `end_session_endpoint` has been reported. */
13
13
  private warnedNoEndSession;
14
+ /** Set once a `/logout` with nothing to record it has been reported. */
15
+ private warnedNoRecord;
16
+ /** Set once a `/logout` of a session nothing names has been reported. */
17
+ private warnedUnnamed;
14
18
  constructor(server: DM);
15
19
  /**
16
20
  * What is handed to `express-openid-connect`.
@@ -99,6 +103,49 @@ export default class OpenIDConnect extends AuthBase {
99
103
  * one ends the session. Undefined when there is nothing to wait for.
100
104
  */
101
105
  private renewExpired;
106
+ /**
107
+ * Whether the session in front of a request was ended — by a logout token,
108
+ * or by `/logout`.
109
+ *
110
+ * @param req the request, after the library has read its session
111
+ * @returns true when a subscriber of `oidcsessionvalid` refuses it
112
+ */
113
+ private loggedOut;
114
+ /**
115
+ * The id a session gets at login.
116
+ *
117
+ * The same person logging in again over a live session keeps its id: the
118
+ * library merges the new tokens into that session, and a copy of its
119
+ * cookie taken before would otherwise carry an id the next `/logout` no
120
+ * longer names. Anyone else, or a session already ended — the callback
121
+ * runs before the library asks — gets a new one.
122
+ *
123
+ * @param req the callback request, its session as the cookie brought it
124
+ * @param sub the `sub` of the id token just received
125
+ * @returns the id to keep in the session
126
+ */
127
+ private sessionId;
128
+ /**
129
+ * Record that the session in front of `/logout` is dead, as a logout token
130
+ * naming it would.
131
+ *
132
+ * Dropping the cookie ends the browser's copy only: the cookie holds the
133
+ * whole session, and a copy kept elsewhere — a shared computer, a backup
134
+ * of the browser profile, a stolen cookie — stayed valid until its tokens
135
+ * expired. The provider ends the sessions of the other applications
136
+ * through Back-Channel Logout, but does not tell the one that asked it to.
137
+ *
138
+ * The session is named by the id given at login, or by the provider's
139
+ * `sid` for a session from before that id existed — never by the `sub`,
140
+ * which would end every session of that person.
141
+ *
142
+ * A failure to record is logged and the logout goes on: the caller asked
143
+ * to leave, and refusing would keep the browser's copy alive as well.
144
+ *
145
+ * @param req the request, after the library's router
146
+ * @returns once the subscribers have run; never rejects
147
+ */
148
+ private endHere;
102
149
  /**
103
150
  * Whether `/logout` failed because the provider offers no RP-Initiated
104
151
  * Logout, rather than for a reason worth a 500.