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
@@ -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
 
@@ -61,12 +61,20 @@ Every plugin that judges LDAP operations — `core/auth/authzPerBranch`,
61
61
  `core/auth/authzLinid1`, `core/auth/authzDynamic` — registers the same hooks,
62
62
  and all of them run: loaded together, they compose as an **AND**, and the
63
63
  first refusal wins. The per-caller read filter (`ldapsearchfilter`) is one of
64
- those hooks: two plugins there compose as an intersection, which is the same
65
- AND. That is sound when it is meant. When it is not, it is a
64
+ the hooks they register: two plugins there compose as an intersection, which
65
+ is the same AND. That is sound when it is meant. When it is not, it is a
66
66
  refusal nobody can explain: `authzPerBranch` beside `authzDynamic` judges a
67
67
  machine token by a branch configuration written for administrators, finds
68
68
  no entry for the tenant, and refuses a write the token's own ACLs grant.
69
69
 
70
+ The authorization plugins' request hooks (`ldapsearchrequest`,
71
+ `ldapaddrequest`, `ldapmodifyrequest`, `ldapdeleterequest`,
72
+ `ldaprenamerequest`) run before any other plugin's, whatever order the
73
+ plugins loaded in: a caller who may not make an operation gets the 403, not
74
+ another plugin's 409, and the operation is not replaced (by
75
+ `core/ldap/trash`, for instance) before it is judged. `ldapsearchfilter`
76
+ keeps the load order.
77
+
70
78
  The AND needs no second plugin to bite. `--authz-per-branch-config` has a
71
79
  default — read everywhere, write nowhere — so a lone `authzPerBranch` judges
72
80
  **every** authenticated identity, whatever authenticated it: a token that no
@@ -108,6 +116,16 @@ aside — `authzDynamic` letting a request through on
108
116
 
109
117
  ### What is refused at startup
110
118
 
119
+ - An authorization plugin with no authentication plugin is refused: no
120
+ request carries an identity, so every authorization plugin would step
121
+ aside and everything would pass while authorization looks on.
122
+ `authzDynamic`, which authenticates the requests it judges, needs nothing
123
+ else. With neither kind of plugin, the server serves every request with
124
+ every right, on purpose (local development, a trusted network); with
125
+ authentication alone, every authenticated caller has every right (a
126
+ single administration token, say). The log says which, once. This is
127
+ checked over the plugins the configuration loads (`--plugin`); a host
128
+ registering plugins itself answers for what they enforce.
111
129
  - Two plugins judging LDAP operations for the same authenticator's requests
112
130
  — two branch-level plugins with no `authz_for`, or a branch-level plugin
113
131
  judging `authzDynamic`'s tokens — are refused, with both names, unless
@@ -124,7 +142,8 @@ aside — `authzDynamic` letting a request through on
124
142
  already.
125
143
 
126
144
  A host that assembles the server itself — `registerPlugin` after `ready`,
127
- rather than `--plugin` — gets the same checks, as each plugin arrives: the
145
+ rather than `--plugin` — gets the same checks of the composition, as each
146
+ plugin arrives (not the one asking for an authentication plugin, above): the
128
147
  refusal comes out of `registerPlugin`, before any of the plugin's routes or
129
148
  hooks exist. An arriving plugin is judged against what is already there, so
130
149
  the authentication plugins an `authz_for` or `--authz-scope-source` names
@@ -78,17 +78,26 @@ organization through which entries change hands.
78
78
  ```
79
79
 
80
80
  An entry is **in transit** when it is attached to that organization, or to
81
- none (with or without the option).
81
+ none (with or without the option). An entry carrying no link is in transit
82
+ on purpose: it is how the entries other tools insert without one are
83
+ caught. Nothing ties such an entry to an organization: any administrator
84
+ may claim it into one they manage, and the first to claim it takes it.
82
85
 
83
- - Every administrator sees the entries in transit.
86
+ - Every administrator sees the entries in transit, but in a global branch
87
+ (`--authz-global-branch`), which only its holders read.
84
88
  - **Claiming** one (`POST /api/v1/ldap/users/:id/move` towards an
85
- organization) asks for write on that organization only.
89
+ organization) asks for write on that organization only. The write
90
+ claiming it may carry other changes to the entry — its password, say —
91
+ judged with it: the entry lands in the organization that write is
92
+ allowed in.
86
93
  - **Putting** one in transit asks for write on the organization it leaves,
87
94
  and nothing on the transit branch: an administrator only lets go of what
88
95
  they manage. Handing an account over to an organization one does not manage
89
96
  is two steps: put it in transit, then its new administrator claims it.
90
- - Any other change to an entry in transit (attributes, password, status,
91
- deletion) is judged as before, so the entry is claimed first.
97
+ - Any other change to an entry in transit, not claiming it (attributes,
98
+ password, status, deletion), is judged as before: on the transit branch
99
+ for an entry attached to it, on its parent branch for one attached to
100
+ nothing.
92
101
 
93
102
  `GET /api/v1/authz/scope` returns the transit branch as `transit` (`null`
94
103
  when there is none), so that a client can offer these moves. A group attached
@@ -106,13 +106,44 @@ Permissions applied to all group members:
106
106
 
107
107
  ## Permission Resolution
108
108
 
109
- Permissions are resolved in this order:
110
-
111
- 1. **User-specific permissions** - If user has direct branch permissions, use them
112
- 2. **Group permissions** - Check all groups user belongs to
113
- 3. **Default permissions** - Fall back to defaults if no specific rules match
114
-
115
- For each permission type (read/write/delete), the **most permissive** rule wins.
109
+ The rules holding the target, the user's own and those of every group they
110
+ belong to, are combined:
111
+
112
+ 1. **Within one source** (the user's own rules, or one group's), the rule of
113
+ the **most specific** branch holding the target applies, whatever order
114
+ the rules are written in, broader rules of that source aside. Branches
115
+ are compared as DNs, whatever their case and spacing.
116
+ 2. **Between sources**, for each permission type (read/write/delete), the
117
+ **most permissive** wins.
118
+ 3. **Default permissions** apply to a permission none of these rules sets. They are a fallback, not a floor: `"default": {"read": true}` does
119
+ not undo a rule refusing read.
120
+
121
+ Creating or writing an entry a rule names also asks for that rule's
122
+ permission, and
123
+ moving or deleting one holding such an entry asks for read and write, or
124
+ delete, at every rule below it: a caller granted `ou=organization` cannot
125
+ rename `ou=payroll` from under the rule refusing it, nor change or delete
126
+ the `ou=payroll` entry itself. This holds under
127
+ `--authz-filter-attached-entries` too, where `departmentSync` would relink
128
+ the accounts attached there. A write setting the organization link
129
+ (`--ldap-organization-link-attribute`) is judged on the organizations it
130
+ leaves and joins; anything else it carries still needs write where the
131
+ entry stands: its branch, or, judged by attachment, the organization it
132
+ leaves.
133
+
134
+ A rule's branch matches a DN however it is spelled, as OpenLDAP reads it:
135
+ case, spaces, escapes (`\2C` and `\,`, `\20` and a space, `\C3\A9` and
136
+ `é`), an attribute type by any of its names or its OID
137
+ (`organizationalUnitName=` and `2.5.4.11=` are `ou=`, learned from the
138
+ directory schema at startup), the spaces its matching rules ignore, and
139
+ the LDAPv2 forms (`;` between RDNs, a value in double quotes). A write is
140
+ judged on the entry as the directory names it, read as the server before
141
+ the write. A caller cannot step around a rule by spelling its branch
142
+ another way.
143
+
144
+ A move is judged on the mover's own rules. The rules of other users and
145
+ groups name the branch by its DN: once a caller allowed to move it has done
146
+ so, they no longer apply to it, and need rewriting for the new DN.
116
147
 
117
148
  ## How It Works
118
149
 
@@ -212,6 +243,25 @@ With this config, user `manager` can read:
212
243
  - `ou=team1,ou=dept1,ou=organization,dc=example,dc=com`
213
244
  - ... all descendants
214
245
 
246
+ A rule on a sub-branch takes over below it. To keep `manager` out of one
247
+ department:
248
+
249
+ ```json
250
+ {
251
+ "users": {
252
+ "manager": {
253
+ "ou=organization,dc=example,dc=com": { "read": true },
254
+ "ou=payroll,ou=organization,dc=example,dc=com": { "read": false }
255
+ }
256
+ }
257
+ }
258
+ ```
259
+
260
+ A search granted on `ou=organization` then returns nothing from
261
+ `ou=payroll`: each entry it finds is kept only where the caller may read it.
262
+ A group the user belongs to may still grant the department, as the most
263
+ permissive source wins.
264
+
215
265
  ## Examples
216
266
 
217
267
  ### Example 1: Department-Based Access
@@ -412,8 +462,9 @@ Cache hit/miss logging:
412
462
 
413
463
  ### Permission Inheritance
414
464
 
415
- - Sub-branch permissions inherit from parent branches
416
- - Cannot grant more restrictive permissions on sub-branches
465
+ - Sub-branch rules take over from parent branch rules, for what they set
466
+ - A rule may restrict a sub-branch as well as widen it, but another source
467
+ (a group) granting more still wins
417
468
  - Plan branch structure carefully
418
469
 
419
470
  ### Default Permissions
@@ -100,6 +100,11 @@ DM_MAIL_ATTRIBUTE="mail"
100
100
 
101
101
  - `--applicative-account-base`: LDAP base DN for applicative accounts (required)
102
102
  - `--max-app-accounts`: Maximum number of app accounts per user (default: 5)
103
+ - `--app-accounts-user-attribute`: attribute the `:user` of the routes is
104
+ matched against (default: the mail attribute, unique directory-wide).
105
+ `uid` is safe only where a uid is unique across the whole directory: a
106
+ uid shared by users the caller can read is answered `409`, and one
107
+ shared with a user it cannot read resolves to the one it can
103
108
  - `--mail-attribute`: LDAP attribute for email (default: `mail`)
104
109
  - `--api-prefix`: API endpoint prefix (default: `/api`)
105
110
 
@@ -38,9 +38,42 @@ branch per organization. It serves the groups of an organization under
38
38
  ## Routes
39
39
 
40
40
  Every route answers 404 `ORGANIZATION_NOT_FOUND` when the organization entry
41
- is missing, and 410 `ORGANIZATION_DELETED` when it is deleted. Errors are
42
- `{ "error": "...", "code": "..." }`. A request an authorization plugin refuses
43
- answers 403 `REFUSED`.
41
+ is missing or the caller cannot read it, and 410 `ORGANIZATION_DELETED` when
42
+ it is deleted. Errors are `{ "error": "...", "code": "..." }`. A request an
43
+ authorization plugin refuses answers 403 `REFUSED`.
44
+
45
+ The routes read as the caller: the authorization plugins decide which groups
46
+ and members it gets. A group it cannot read answers 404, a user it cannot
47
+ read is not found (404 `USER_NOT_FOUND` when adding it), and a member it
48
+ cannot read lists as `{ "uid": "..." }`. A write is judged before anything
49
+ is looked for, so a caller who may not make it gets 403 whether the name is
50
+ taken, the users exist or they are already members.
51
+
52
+ What the caller may read is the authorization model's to say:
53
+
54
+ - A model judging by branch (`core/auth/authzPerBranch`) gives an
55
+ organization's groups and users to the callers granted its branch.
56
+ - A model judging entries by their organization link (`core/auth/authzLinid1`,
57
+ or `--authz-filter-attached-entries`) gives an entry carrying no link to
58
+ every administrator. Under such a model only, every group written in an
59
+ organization's branch — through these routes, SCIM or another plugin — is
60
+ given the organization link (`--ldap-organization-link-attribute`, with the
61
+ auxiliary class `twakeGroup` when it holds neither it nor
62
+ `twakeStaticGroup`), and follows it when moved to another organization; a
63
+ link naming another organization is refused (400). A group is then judged
64
+ on its organization: creating or changing one needs write on the
65
+ organization, not on its group branch only. What stays every
66
+ administrator's: whether an organization exists or is deleted, its spaces,
67
+ and users carrying no link, whose link is the provisioning tool's to set.
68
+ Such users are in transit: any administrator may claim them into an
69
+ organization they manage, the first to do so taking them — see
70
+ [transit](../auth/authz-linid1.md#transit-handing-an-entry-over).
71
+ With `core/ldap/organizations` loaded too, the organizations must lie under
72
+ `--ldap-top-organization`, where it accepts a link.
73
+ - A plugin judging every read made for the request, as
74
+ `core/auth/authzDynamic` does, judges the organization check too: an
75
+ organization its token does not reach answers 404, as one that is not
76
+ there.
44
77
 
45
78
  A group reads:
46
79