ldap-rest 0.25.1 → 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 (92) 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/instanceProviders.js +1 -1
  8. package/dist/lib/ldapActions.js +1 -1
  9. package/dist/lib/mailDomains.js +1 -0
  10. package/dist/lib/requestContext.js +1 -0
  11. package/dist/lib/utils.js +1 -1
  12. package/dist/plugins/auth/authzDynamic.js +1 -1
  13. package/dist/plugins/auth/authzLinid1.js +1 -1
  14. package/dist/plugins/auth/authzPerBranch.js +1 -1
  15. package/dist/plugins/auth/authzScope.js +1 -1
  16. package/dist/plugins/configApi.js +1 -1
  17. package/dist/plugins/ldap/bulkImport.js +1 -1
  18. package/dist/plugins/ldap/departmentSync.js +1 -1
  19. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  20. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  21. package/dist/plugins/ldap/groups.js +1 -1
  22. package/dist/plugins/ldap/onChange.js +1 -1
  23. package/dist/plugins/ldap/organizations.js +1 -1
  24. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  25. package/dist/plugins/ldap/raw.js +1 -1
  26. package/dist/plugins/ldap/trash.js +1 -1
  27. package/dist/plugins/rabbitmq.js +1 -1
  28. package/dist/plugins/scim/groups.js +1 -1
  29. package/dist/plugins/scim/users.js +1 -1
  30. package/dist/plugins/storage/ldap.js +1 -1
  31. package/dist/plugins/storage/postgres.js +1 -1
  32. package/dist/plugins/storage/valkey.js +1 -1
  33. package/dist/plugins/twake/appAccountsApi.js +1 -1
  34. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  35. package/dist/plugins/twake/clouderyProvision.js +1 -1
  36. package/dist/plugins/twake/groups.js +1 -1
  37. package/dist/plugins/twake/instances.js +1 -1
  38. package/dist/plugins/twake/james.js +1 -1
  39. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  40. package/dist/plugins/twake/spaces.js +1 -1
  41. package/dist/plugins/twake/tombstone.js +1 -1
  42. package/dist/src/abstract/ldapFlat.d.ts +25 -6
  43. package/dist/src/abstract/plugin.d.ts +1 -1
  44. package/dist/src/bin/index.d.ts +12 -0
  45. package/dist/src/lib/authz/base.d.ts +87 -4
  46. package/dist/src/lib/authz/composition.d.ts +21 -0
  47. package/dist/src/lib/instanceProviders.d.ts +8 -0
  48. package/dist/src/lib/ldapActions.d.ts +139 -18
  49. package/dist/src/lib/mailDomains.d.ts +41 -0
  50. package/dist/src/lib/requestContext.d.ts +23 -0
  51. package/dist/src/lib/utils.d.ts +41 -2
  52. package/dist/src/plugins/auth/authzDynamic.d.ts +5 -12
  53. package/dist/src/plugins/auth/authzLinid1.d.ts +3 -1
  54. package/dist/src/plugins/auth/authzPerBranch.d.ts +5 -9
  55. package/dist/src/plugins/ldap/accountLifecycle.d.ts +2 -2
  56. package/dist/src/plugins/ldap/bulkImport.d.ts +30 -5
  57. package/dist/src/plugins/ldap/enterpriseRules.d.ts +11 -1
  58. package/dist/src/plugins/ldap/externalUsersInGroups.d.ts +20 -3
  59. package/dist/src/plugins/ldap/groups.d.ts +48 -1
  60. package/dist/src/plugins/ldap/organizations.d.ts +4 -4
  61. package/dist/src/plugins/twake/appAccountsApi.d.ts +3 -67
  62. package/dist/src/plugins/twake/groups.d.ts +46 -2
  63. package/dist/src/plugins/twake/james.d.ts +0 -11
  64. package/docs/README.md +25 -0
  65. package/docs/plugin-development/README.md +61 -15
  66. package/docs/plugin-development/dependencies.md +8 -2
  67. package/docs/plugin-development/hooks.md +4 -5
  68. package/docs/usage/configuration.md +11 -8
  69. package/docs/usage/plugins/auth/README.md +22 -3
  70. package/docs/usage/plugins/auth/authz-linid1.md +14 -5
  71. package/docs/usage/plugins/auth/authz-per-branch.md +60 -9
  72. package/docs/usage/plugins/integrations/app-accounts.md +5 -0
  73. package/docs/usage/plugins/integrations/groups.md +36 -3
  74. package/docs/usage/plugins/integrations/instances.md +6 -3
  75. package/docs/usage/plugins/integrations/scim.md +7 -0
  76. package/docs/usage/plugins/integrations/spaces.md +3 -1
  77. package/docs/usage/plugins/ldap/bulk-import.md +20 -0
  78. package/docs/usage/plugins/ldap/enterprise-rules.md +12 -2
  79. package/docs/usage/plugins/ldap/external-users.md +8 -3
  80. package/docs/usage/plugins/ldap/flat-generic.md +3 -0
  81. package/docs/usage/plugins/ldap/groups.md +6 -0
  82. package/docs/usage/plugins/ldap/organizations.md +17 -0
  83. package/docs/usage/plugins/ldap/raw.md +1 -1
  84. package/docs/usage/upgrading.md +228 -0
  85. package/package.json +1 -1
  86. package/static/schemas/example/groups.json +1 -0
  87. package/static/schemas/example/organizations.json +1 -0
  88. package/static/schemas/example/users.json +3 -0
  89. package/static/schemas/twake/groups.json +2 -0
  90. package/static/schemas/twake/organizations.json +1 -0
  91. package/static/schemas/twake/teamMailboxes.json +3 -1
  92. package/static/schemas/twake/users.json +3 -0
@@ -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
  *
@@ -219,7 +219,8 @@ export default class LdapOrganizations extends DmPlugin {
219
219
  * search just learned to survive: a directory that will not walk the whole
220
220
  * link filter is told about rather than turned into an empty list.
221
221
  *
222
- * @param baseDn branch the attached entries live in
222
+ * @param baseDn branch the attached entries live in; the organization's
223
+ * own, for a caller who may not search it
223
224
  * @param filter what links them to the organization
224
225
  * @param cap how many to return
225
226
  * @param dn organization they are attached to, for the indicator's DN
@@ -253,9 +254,8 @@ export default class LdapOrganizations extends DmPlugin {
253
254
  * The same two searches as `getOrganisationSubnodes`, narrowed by a query.
254
255
  *
255
256
  * Both go through the helpers that classify a failure rather than reading
256
- * every one of them as emptiness (#179), and both now carry the request:
257
- * they ran without one, and an authorization plugin skips its check when
258
- * there is none — the same gap the flat routes had until 0.8.2. The
257
+ * every one of them as emptiness (#179), and both carry the request the
258
+ * authorization plugins judge them by. The
259
259
  * attached entries are capped like the other endpoint's, since an answer
260
260
  * the directory refuses to finish is what this is about.
261
261
  *
@@ -1,71 +1,6 @@
1
1
  import type { Express } from 'express';
2
2
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
3
  import type { DM } from '../../bin';
4
- /**
5
- * @openapi-component
6
- * AppAccount:
7
- * type: object
8
- * description: |
9
- * An application-specific identity attached to a user. App accounts
10
- * allow a single principal user to authenticate as different named
11
- * "devices" or "applications" each with its own credential, while
12
- * sharing the same mail address.
13
- * required: [uid]
14
- * properties:
15
- * uid:
16
- * type: string
17
- * description: |
18
- * Unique identifier of the app account. Format:
19
- * `<prefix>_c<8-digits>`, where `<prefix>` is the `:user` path param
20
- * (the resolution value — the mail by default) sanitized into a
21
- * uid-safe token (e.g. `alice@example.com` -> `alice_example_com`).
22
- * example: alice_example_com_c04729183
23
- * name:
24
- * type: string
25
- * description: Human-readable label (stored as LDAP `description`).
26
- * example: Work laptop
27
- * example:
28
- * uid: alice_c04729183
29
- * name: Work laptop
30
- * AppAccountCreated:
31
- * type: object
32
- * description: |
33
- * Response returned when a new app account is successfully created.
34
- * The one-time cleartext password is included here and never returned
35
- * again — callers must store it immediately.
36
- * required: [uid, pwd, mail]
37
- * properties:
38
- * uid:
39
- * type: string
40
- * description: Identifier of the newly created app account.
41
- * example: alice_c04729183
42
- * pwd:
43
- * type: string
44
- * description: |
45
- * One-time cleartext password generated for this account.
46
- * Format: 6 blocks of 4 characters separated by `-`
47
- * (e.g. `Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@`).
48
- * OpenLDAP hashes it via ppolicy before storage.
49
- * example: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@
50
- * mail:
51
- * type: string
52
- * description: Mail address of the principal account this app account belongs to.
53
- * example: alice@example.com
54
- * example:
55
- * uid: alice_c04729183
56
- * pwd: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@
57
- * mail: alice@example.com
58
- * AppAccountCreate:
59
- * type: object
60
- * description: Request body for creating a new app account.
61
- * properties:
62
- * name:
63
- * type: string
64
- * description: Optional human-readable label for the new account.
65
- * example: Work laptop
66
- * example:
67
- * name: Work laptop
68
- */
69
4
  export default class AppAccountsApi extends DmPlugin {
70
5
  name: string;
71
6
  roles: Role[];
@@ -87,8 +22,9 @@ export default class AppAccountsApi extends DmPlugin {
87
22
  * `:user` is matched against the configured resolution attribute
88
23
  * (`app_accounts_user_attribute`, default the mail attribute). Mail is
89
24
  * globally unique; resolving by the LDAP `uid` instead is only safe when uid
90
- * is unique directory-wide, as the same `uid` may repeat under different
91
- * subtrees and resolve to an arbitrary same-named user (see #88).
25
+ * is unique directory-wide: a `uid` repeated under different subtrees is
26
+ * answered 409 when the caller reads several of its holders, and resolves
27
+ * to the one it reads otherwise (see #88).
92
28
  *
93
29
  * On failure (no match, or an ambiguous one) this writes the HTTP error
94
30
  * response and returns null, so callers can simply `return` when it does.
@@ -81,6 +81,40 @@ export default class TwakeGroups extends LdapGroups {
81
81
  * SCIM group mapping leaves the RDN attribute alone.
82
82
  */
83
83
  afterLoad(): void;
84
+ /**
85
+ * A group of `org` with its organization link, under an authorization
86
+ * model judging entries by that link (`authzLinid1`,
87
+ * `--authz-filter-attached-entries`): there, a group without one is every
88
+ * administrator's. Elsewhere the group is left as it is, and judged by its
89
+ * branch as before.
90
+ *
91
+ * The auxiliary class `twakeGroup` is added when the group holds neither
92
+ * it nor `twakeStaticGroup`. A link the writer gave is kept when it names
93
+ * the group's organization, refused otherwise: a group in one
94
+ * organization's branch belonging to another would mix their members.
95
+ *
96
+ * @param dn the group's DN
97
+ * @param org its organization
98
+ * @param entry the group as written
99
+ * @returns the group, linked
100
+ * @throws BadRequestError when the link given names another organization
101
+ */
102
+ linked(dn: string, org: string, entry: AttributesList): AttributesList;
103
+ /**
104
+ * Refuse a change taking a group's link away from its organization, under
105
+ * a model judging by attachment: as on a creation, a group in one
106
+ * organization's branch cannot belong to another, nor to none.
107
+ *
108
+ * @param dn the group's DN
109
+ * @param changes the modification
110
+ * @throws BadRequestError when the change links it elsewhere or unlinks it
111
+ */
112
+ private checkLinkChange;
113
+ /**
114
+ * Whether an authorization plugin judges entries by their organization
115
+ * link: then a group needs one.
116
+ */
117
+ private judgedByAttachment;
84
118
  /** The organization a group DN belongs to, if it is a group at all. */
85
119
  organizationOf(dn: string): string | undefined;
86
120
  groupBaseOf(org: string): string;
@@ -112,14 +146,24 @@ export default class TwakeGroups extends LdapGroups {
112
146
  groupDn(org: string, id: string): string;
113
147
  get userAttribute(): string;
114
148
  userDn(org: string, username: string): string;
115
- /** Every route answers 404 for a missing organization, 410 for a deleted one. */
116
- checkOrganization(org: string): Promise<void>;
149
+ /**
150
+ * Every route answers 404 for a missing organization, 410 for a deleted one.
151
+ *
152
+ * The status is read as the server, whoever asks; an organization the
153
+ * caller of `req` cannot read answers as one that is not there, deleted or
154
+ * not.
155
+ */
156
+ checkOrganization(org: string, req?: Request): Promise<void>;
117
157
  /** Page, search and sort of a list route, or a refusal of them. */
118
158
  page(req: Request, sortable: string[]): Page;
119
159
  sorted<T extends Record<string, unknown>>(items: T[], sortBy: string | undefined, desc: boolean): T[];
120
160
  /** A group as the routes answer it: members named by their RDN value. */
121
161
  groupOf(org: string, entry: AttributesList): Record<string, unknown>;
122
162
  groups(org: string, filter: string, attributes?: string[], req?: Request): Promise<AttributesList[]>;
163
+ /**
164
+ * A group, read as the caller of `req` when there is one: a group they
165
+ * cannot read answers as one that is not there.
166
+ */
123
167
  private readGroup;
124
168
  /** The group of that display name in the organization, if any. */
125
169
  private named;
@@ -37,17 +37,6 @@ export default class James extends TwakePlugin {
37
37
  */
38
38
  private getUserQuota;
39
39
  hooks: Hooks;
40
- /**
41
- * Where the mail domain entries live, and the attribute naming their
42
- * domain: each loaded flat schema declaring the `domainName` role, the
43
- * lookup `core/ldap/enterpriseRules` makes. Without one, the branch of the
44
- * shipped `twake/nomenclature/twakeDomain.json` and the attribute
45
- * `--enterprise-domain-name-attribute` names.
46
- *
47
- * Rebuilt on each call, as the enterprise rules do theirs: a list taken in
48
- * the constructor could predate the schemas.
49
- */
50
- private domainBranches;
51
40
  /**
52
41
  * The attribute naming the mail domain of the entry at a DN, when it lies
53
42
  * in a domain branch: at any depth, as a deployment may nest its domains
package/docs/README.md CHANGED
@@ -32,6 +32,31 @@ Integrate LDAP-Rest into your web applications.
32
32
  - **[JSON Schemas](client-development/schemas/README.md)** - Schema structure and validation
33
33
  - **[Examples](client-development/examples/README.md)** - React, Vue.js, Vanilla JS integration
34
34
 
35
+ ## API stability
36
+
37
+ From 1.0.0, ldap-rest follows semantic versioning. These are its public
38
+ API, which a minor or a patch release does not break:
39
+
40
+ - **Configuration**: the options and environment variables of
41
+ [Configuration](usage/configuration.md) and of each plugin's page.
42
+ - **Routes**: the paths, bodies and answers of the
43
+ [REST API](client-development/api/rest-api.md) and the plugin pages,
44
+ their statuses and their error `code`s.
45
+ - **Plugin API**: what `ldap-rest/plugin` exports — `DmPlugin` and its
46
+ roles (`auth`, `authz`, …), `asyncHandler`, the DN and filter escaping
47
+ helpers and the error classes —, the `DM` type of `ldap-rest` and the
48
+ `Hooks` of `ldap-rest/hooks`, whose arguments are those typed in
49
+ `src/hooks.ts` ([hooks](plugin-development/hooks.md)), and the directory
50
+ operations of [the development guide](plugin-development/README.md):
51
+ `ldap.search`, `add`, `modify`, `rename`, `delete`, `ldap.forRequest`,
52
+ `ldap.system`, `ldap.authorize`, `ldap.readableBy`,
53
+ `ldap.canonicalAttributes`, `ldap.canonicalChanges` and
54
+ `ldap.knowAttributeNames`.
55
+ - **Schemas**: the JSON schema format and the shipped schemas' attributes.
56
+
57
+ Everything else — other exports, internal helpers, log messages — may
58
+ change in any release.
59
+
35
60
  ## Presentations
36
61
 
37
62
  Interactive presentations (using [presenterm](https://github.com/mfontanini/presenterm)):
@@ -455,14 +455,13 @@ export default class MyPlugin extends DmPlugin {
455
455
 
456
456
  The `this.server.ldap` object provides access to all LDAP operations:
457
457
 
458
- > **Serving an HTTP request? Bind it first.**
458
+ > **Say whose each operation is.**
459
459
  >
460
460
  > The authorization plugins (`core/auth/authzPerBranch`,
461
461
  > `core/auth/authzDynamic`, …) hook `ldapsearchrequest`, `ldapaddrequest`,
462
- > `ldapmodifyrequest`, `ldaprenamerequest` and `ldapdeleterequest`, and they
463
- > **skip every check when the request is missing**. The methods below take it as a trailing
464
- > optional argument, so forgetting it does not fail — it silently authorizes
465
- > the operation. Three such bugs shipped in the SCIM plugin.
462
+ > `ldapmodifyrequest`, `ldaprenamerequest` and `ldapdeleterequest`, and judge
463
+ > an operation by the request it carries. The methods below take it as a
464
+ > trailing optional argument.
466
465
  >
467
466
  > In a request handler, bind once and use the result:
468
467
  >
@@ -472,21 +471,50 @@ The `this.server.ldap` object provides access to all LDAP operations:
472
471
  > await directory.modify(dn, changes);
473
472
  > ```
474
473
  >
475
- > `forRequest()` returns a `RequestBoundLdap`, whose methods have no request
476
- > parameter at all — the omission stops being expressible. Use the unbound
477
- > methods only for work that genuinely belongs to no request, such as startup
478
- > or scheduled tasks, where passing nothing is the deliberate answer — and
479
- > say so by reaching for them through `ldap.system`:
474
+ > An operation that names no request while an authenticated request is being
475
+ > served is made for that request all the same, and judged as its caller: a
476
+ > forgotten argument fails closed. Work that is the server's — a uniqueness
477
+ > check has to see a whole branch, and a reference to an entry the caller
478
+ > cannot read is still a valid reference — goes through `ldap.system`, which
479
+ > steps out of the request, with a comment saying why:
480
480
  >
481
481
  > ```typescript
482
+ > // As the server: a name is taken whoever may read the entry holding it
482
483
  > await this.server.ldap.system.search({ scope: 'base' }, dn);
483
484
  > ```
484
485
  >
485
- > It is the same object, and that is the point: `ldap.system.search(…)` reads
486
- > as a decision where `ldap.search(…)` reads as a forgotten argument. Some
487
- > reads are the server's by nature — a uniqueness check has to see a whole
488
- > branch, and a reference to an entry the caller cannot read is still a valid
489
- > reference — and those are the ones it names.
486
+ > A lint rule refuses an operation that names neither a request nor
487
+ > `ldap.system`. Hooks run outside the request: what a hook reads or writes
488
+ > to validate, complete or follow an operation is the server's, and a hook
489
+ > acting as the caller passes the request it is handed. Startup and
490
+ > scheduled tasks run outside any request.
491
+
492
+ > **Read as the server? Judge the caller first.**
493
+ >
494
+ > A read the server makes for a request answers the caller with what it
495
+ > found: a 404 for an entry that is not there, a 400 for a pointer leading
496
+ > nowhere, a 409 for a name taken. Made before the write it prepares is
497
+ > judged, it tells a caller who may not make that write what the directory
498
+ > holds where they cannot read. Have the authorization plugins judge the
499
+ > write first, without making it:
500
+ >
501
+ > ```typescript
502
+ > await this.server.ldap.authorize('ldaprenamerequest', [dn, newDn, req]);
503
+ > // only now: does dn exist, is newDn free, …
504
+ > ```
505
+ >
506
+ > It runs the hooks of the plugins with the `authz` role only, and refuses
507
+ > with their 403 whether the entry is there or not. The write, when made,
508
+ > is judged again with every other hook.
509
+ >
510
+ > A route that finds an entry as the server, across branches, and then
511
+ > answers about it, checks that the caller can read what it found, and
512
+ > answers as for an entry that is not there otherwise:
513
+ >
514
+ > ```typescript
515
+ > if (!(await this.server.ldap.readableBy(dn, req)))
516
+ > throw new NotFoundError(`User ${id} not found`);
517
+ > ```
490
518
 
491
519
  #### Search
492
520
 
@@ -549,6 +577,24 @@ await this.server.ldap.modify('uid=john,ou=users,dc=example,dc=com', {
549
577
  });
550
578
  ```
551
579
 
580
+ #### Attribute names
581
+
582
+ An add or a modify names its attributes as the server does before the
583
+ authorization plugins and the hooks see them: `MAIL`, `rfc822Mailbox` or
584
+ the OID of `mail` reach a hook as `mail`, spelled as the configuration or a
585
+ loaded schema spells it. A hook may therefore look an attribute up by that
586
+ name. An option other than `;binary` is refused, as is one attribute named
587
+ twice in one part of a write.
588
+
589
+ A route that checks a body before handing it to `add` or `modify` names
590
+ its attributes the same way first, and a plugin loading a schema of its
591
+ own says how it spells them:
592
+
593
+ ```typescript
594
+ await this.server.ldap.canonicalChanges(changes); // or canonicalAttributes(entry)
595
+ this.server.ldap.knowAttributeNames(Object.keys(schema.attributes));
596
+ ```
597
+
552
598
  #### Delete
553
599
 
554
600
  ```typescript
@@ -581,7 +581,13 @@ guards.
581
581
 
582
582
  ## Hook Execution Order
583
583
 
584
- Hooks are executed in the order plugins are loaded. For chained hooks:
584
+ Hooks are executed in the order plugins are loaded, with one exception: the
585
+ request hooks of authorization plugins (`ldapsearchrequest`, `ldapaddrequest`,
586
+ `ldapmodifyrequest`, `ldapdeleterequest`, `ldaprenamerequest`) run before any
587
+ other plugin's, in the order the authorization plugins loaded. The first hook
588
+ to throw answers the request, so a caller who may not make an operation is
589
+ refused before a plugin judges its content, or replaces it.
590
+ `ldapsearchfilter` keeps the load order. For chained hooks:
585
591
 
586
592
  1. First plugin in chain can modify/validate data
587
593
  2. Returns modified data to next plugin
@@ -595,7 +601,7 @@ For non-chained hooks:
595
601
  **Example**: `ldapdeleterequest` chain
596
602
 
597
603
  ```
598
- Request → trash (moves to trash) → groups (checks members) → organizations (checks empty) → LDAP
604
+ Request → authorization → trash (moves to trash) → groups (checks members) → organizations (checks empty) → LDAP
599
605
  ```
600
606
 
601
607
  ## Creating Plugins with Dependencies
@@ -1,14 +1,13 @@
1
1
  # Core hooks
2
2
 
3
- Set here the documentation of all hooks.
4
-
5
- Typescript definitions into [hooks.ts](./src/hooks.ts)
3
+ The hooks a plugin can register. Their arguments and return values are
4
+ those typed in [hooks.ts](../../src/hooks.ts), which is the reference.
6
5
 
7
6
  ## Demo hooks
8
7
 
9
- - **hello**: called by [helloworld demo plugin](./src/plugins/helloworld.ts)
8
+ - **hello**: called by [helloworld demo plugin](../../src/plugins/demo/helloworld.ts)
10
9
 
11
- ## [LDAP](./src/lib/ldapActions.ts) hooks
10
+ ## [LDAP](../../src/lib/ldapActions.ts) hooks
12
11
 
13
12
  - **ldapopts**: called before any ldapsearch to modify search options
14
13
  - **ldapsearchresult**: called after any ldapsearch to modify search result
@@ -186,8 +186,10 @@ deployment has run with it on, and a cache that silently serves stale entries
186
186
  is not something to switch on for everyone at once.
187
187
 
188
188
  Writes made through this service (add, modify, rename, move, delete) drop what
189
- they change, the moved subtree included. Writes made anywhere else do not:
190
- another replica of this service, an LSC synchronisation, a hand-run
189
+ they change, the moved subtree included, and what the directory's overlays
190
+ change with them: the `memberOf` of the members a group gains or loses, and
191
+ the entries naming an entry renamed or deleted. Writes made anywhere else do
192
+ not: another replica of this service, an LSC synchronisation, a hand-run
191
193
  `ldapmodify`. Each process caches on its own, and only its own writes drop what
192
194
  it cached. Turn the cache on when this service is the only writer, or when
193
195
  serving an entry up to `--ldap-cache-ttl` seconds old is acceptable; leave it
@@ -509,12 +511,13 @@ Formerly `core/twake/calendarResources`, still accepted as a deprecated alias.
509
511
 
510
512
  #### `core/twake/applicativeAccounts`
511
513
 
512
- | CLI | Plural | Env | Default | Description |
513
- | --------------------------------- | ---------------------------------- | ----------------------------------- | ------------- | ------------------------------------- |
514
- | `--applicative-account-base` | | `DM_APPLICATIVE_ACCOUNT_BASE` | | Applicative accounts base DN |
515
- | `--max-app-accounts` | | `DM_MAX_APP_ACCOUNTS` | `5` | Max accounts per user |
516
- | `--applicative-account-attribute` | `--applicative-account-attributes` | `DM_APPLICATIVE_ACCOUNT_ATTRIBUTES` | _(see below)_ | Attributes copied from the user entry |
517
- | `--ldap-operational-attribute` | `--ldap-operational-attributes` | `DM_LDAP_OPERATIONAL_ATTRIBUTES` | _(see below)_ | Operational attributes to exclude |
514
+ | CLI | Plural | Env | Default | Description |
515
+ | --------------------------------- | ---------------------------------- | ----------------------------------- | ------------------ | ------------------------------------------------------ |
516
+ | `--applicative-account-base` | | `DM_APPLICATIVE_ACCOUNT_BASE` | | Applicative accounts base DN |
517
+ | `--max-app-accounts` | | `DM_MAX_APP_ACCOUNTS` | `5` | Max accounts per user |
518
+ | `--app-accounts-user-attribute` | | `DM_APP_ACCOUNTS_USER_ATTRIBUTE` | the mail attribute | Attribute the `:user` of the routes is matched against |
519
+ | `--applicative-account-attribute` | `--applicative-account-attributes` | `DM_APPLICATIVE_ACCOUNT_ATTRIBUTES` | _(see below)_ | Attributes copied from the user entry |
520
+ | `--ldap-operational-attribute` | `--ldap-operational-attributes` | `DM_LDAP_OPERATIONAL_ATTRIBUTES` | _(see below)_ | Operational attributes to exclude |
518
521
 
519
522
  Default copied attributes: `objectClass`, `cn`, `sn`, `givenName`, `displayName`, `description` — plus the configured mail attribute, always.
520
523