ldap-rest 1.3.0 → 2.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 (82) hide show
  1. package/README.md +8 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/abstract/plugin.js +1 -1
  4. package/dist/bin/index.js +1 -1
  5. package/dist/config/options.js +1 -1
  6. package/dist/config/schema.js +1 -1
  7. package/dist/lib/authz/base.js +1 -1
  8. package/dist/lib/errors.js +1 -1
  9. package/dist/lib/ldap/callerView.js +1 -0
  10. package/dist/lib/ldap/pool.js +1 -0
  11. package/dist/lib/ldap/schema.js +1 -0
  12. package/dist/lib/ldap/searchCache.js +1 -0
  13. package/dist/lib/ldapActions.js +1 -1
  14. package/dist/lib/parseConfig.js +1 -1
  15. package/dist/lib/services/index.js +1 -0
  16. package/dist/lib/utils.js +1 -1
  17. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  18. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  19. package/dist/plugins/ldap/groups.js +1 -1
  20. package/dist/plugins/ldap/organizations.js +1 -1
  21. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  22. package/dist/plugins/ldap/raw.js +1 -1
  23. package/dist/plugins/ldap/trash.js +1 -1
  24. package/dist/plugins/scim/mapping.js +1 -1
  25. package/dist/plugins/scim/patch.js +1 -1
  26. package/dist/plugins/storage/file.js +1 -1
  27. package/dist/plugins/twake/instances.js +1 -1
  28. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  29. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  30. package/dist/plugins/twake/tombstone.js +1 -1
  31. package/dist/src/abstract/ldapFlat.d.ts +13 -0
  32. package/dist/src/abstract/plugin.d.ts +21 -0
  33. package/dist/src/bin/index.d.ts +38 -4
  34. package/dist/src/config/options.d.ts +16 -1
  35. package/dist/src/lib/errors.d.ts +2 -1
  36. package/dist/src/lib/ldap/callerView.d.ts +45 -0
  37. package/dist/src/lib/ldap/pool.d.ts +108 -0
  38. package/dist/src/lib/ldap/schema.d.ts +157 -0
  39. package/dist/src/lib/ldap/searchCache.d.ts +112 -0
  40. package/dist/src/lib/ldapActions.d.ts +25 -262
  41. package/dist/src/lib/parseConfig.d.ts +26 -2
  42. package/dist/src/lib/services/index.d.ts +56 -0
  43. package/dist/src/lib/utils.d.ts +72 -3
  44. package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
  45. package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
  46. package/dist/src/plugins/scim/mapping.d.ts +16 -3
  47. package/dist/src/plugins/scim/types.d.ts +2 -0
  48. package/dist/src/plugins/twake/instances.d.ts +31 -0
  49. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
  50. package/dist/src/plugins/twake/tombstone.d.ts +9 -2
  51. package/docs/README.md +25 -2
  52. package/docs/architecture/README.md +180 -0
  53. package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
  54. package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
  55. package/docs/architecture/adr/0003-authorization-composition.md +59 -0
  56. package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
  57. package/docs/architecture/adr/0005-hook-contract.md +54 -0
  58. package/docs/architecture/adr/0006-search-cache.md +49 -0
  59. package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
  60. package/docs/architecture/adr/0008-withconfig-views.md +58 -0
  61. package/docs/client-development/api/rest-api.md +4 -0
  62. package/docs/development/contracts.md +119 -0
  63. package/docs/development/modularization-plan.md +197 -0
  64. package/docs/plugin-development/README.md +8 -1
  65. package/docs/plugin-development/dependencies.md +13 -2
  66. package/docs/plugin-development/hooks.md +4 -2
  67. package/docs/plugin-development/options.md +40 -13
  68. package/docs/plugin-development/services.md +95 -0
  69. package/docs/usage/operations.md +12 -0
  70. package/docs/usage/plugins/integrations/instances.md +11 -3
  71. package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
  72. package/docs/usage/plugins/integrations/scim.md +20 -18
  73. package/docs/usage/plugins/integrations/tombstone.md +10 -6
  74. package/docs/usage/plugins/ldap/flat-generic.md +8 -1
  75. package/docs/usage/plugins/ldap/groups.md +4 -1
  76. package/docs/usage/plugins/ldap/password-policy.md +7 -0
  77. package/docs/usage/upgrading.md +59 -0
  78. package/package.json +21 -12
  79. package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
  80. package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
  81. package/static/browser/ldap-tree-viewer.js.map +1 -1
  82. package/static/browser/ldap-tree-viewer.min.js.map +1 -1
@@ -0,0 +1,197 @@
1
+ # Modularization plan
2
+
3
+ How the remaining architecture work is sequenced: [#397] and the five
4
+ `code-quality` issues that depend on it or prepare it. The plan is a roadmap,
5
+ not a contract; update it as the work lands.
6
+
7
+ ## Open issues
8
+
9
+ | Issue | Subject | Role | Size |
10
+ | ------ | ----------------------------------------------------------------------------------- | ----------------------------- | ------ |
11
+ | [#397] | Split the repository into `@ldap-rest/*` modules | Final step | Major |
12
+ | [#593] | Finish the plugin service migration: entity registry, call sites, family boundaries | Prerequisite of #397 | Large |
13
+ | [#594] | Finish the `ldapActions` split: operation pipeline and façade | Prerequisite of #397 | Medium |
14
+ | [#595] | Snapshot the plugin API types and gate changes | Safety net, start before #397 | Medium |
15
+ | [#549] | Architecture pages, decision records, comment check | Independent, start early | Medium |
16
+ | [#603] | Enable the type-checked ESLint rules the test block leaves out | Independent, quick win | Small |
17
+
18
+ [#397] is still in the `2.0.0` milestone, which is released. Retarget it to a
19
+ 2.x epic or to 3.0.0 once the compatibility decision below is made. [#593] and
20
+ [#594] are labelled `Minor`/`backlog` but are the real prerequisites of
21
+ [#397]; promote them to the same milestone.
22
+
23
+ ## What blocks #397 today
24
+
25
+ The core still imports plugin code:
26
+
27
+ - `src/config/args.ts` imports the option sets of every plugin family.
28
+ - `src/bin/index.ts` imports `src/plugins/priority.json`.
29
+ - `src/hooks.ts` imports `ChangesToNotify` from
30
+ `src/plugins/ldap/onChange.ts` and the SCIM types from
31
+ `src/plugins/scim/types.ts`.
32
+ - `src/abstract/ldapFlat.ts` reads `server.loadedPlugins` and uses
33
+ `requirePlugin`.
34
+ - `src/lib/ldapActions.ts` is 1,375 lines; `src/lib/ldap/operations.ts` does
35
+ not exist.
36
+ - The single package generates 66 `./plugin-*` exports from
37
+ `rollup.config.mjs`, with no npm workspaces.
38
+
39
+ Between plugin families the coupling is smaller: only the Twake plugins import
40
+ `ldap`/`scim` (15 imports), plus `TwakeGroups extends LdapGroups`.
41
+
42
+ ## Target architecture
43
+
44
+ An npm workspaces monorepo with an umbrella package that preserves the public
45
+ API:
46
+
47
+ ```text
48
+ packages/
49
+ core/ @ldap-rest/core
50
+ plugin-auth/ @ldap-rest/plugin-auth
51
+ plugin-ldap/ @ldap-rest/plugin-ldap
52
+ plugin-scim/ @ldap-rest/plugin-scim
53
+ plugin-storage/ @ldap-rest/plugin-storage
54
+ plugin-twake/ @ldap-rest/plugin-twake
55
+ plugin-misc/ @ldap-rest/plugin-misc
56
+ browser/ @ldap-rest/browser
57
+ schemas/ @ldap-rest/schemas
58
+ ldap-rest/ umbrella package (CLI and re-exports)
59
+ ```
60
+
61
+ Rules:
62
+
63
+ - `core` depends on no family; a family depends on no other family. Cross-family
64
+ needs go through services (`src/lib/services`) or hooks.
65
+ - `TwakeGroups extends LdapGroups` is either the one documented exception or
66
+ the shared base moves to `core`.
67
+ - `ldap-rest` stays the entry package: it depends on every package, keeps the
68
+ CLI and the `core/…` namespace, and re-exports the former sub-paths
69
+ (`ldap-rest/plugin-auth-authzlinid1`, …). The split is therefore not breaking
70
+ for users.
71
+ - The core parser accepts an injected `OptionSet`; the umbrella composes the
72
+ option sets of every family. `Config` may stay a type-only aggregate in
73
+ `core`, but runtime option imports leave the core.
74
+ - Each package builds on its own with a shared configuration; the umbrella
75
+ re-exports. The Twake-specific `bin/` scripts move to their package or to a
76
+ `@ldap-rest/cli` package.
77
+
78
+ A package per plugin (`@ldap-rest/authz-linid1`, …) is possible, but start with
79
+ families and extract optional plugins individually later. Otherwise the first
80
+ day costs 66 packages and 66 release cycles.
81
+
82
+ ## Phases
83
+
84
+ ### Phase 0 — Guardrails and quick wins
85
+
86
+ 1. [#603]: enable the type-checked rules on `test/**/*.ts`, fix the 31
87
+ `no-floating-promises` and 24 `await-thenable` reports, add the block to CI.
88
+ 2. [#549], part 1: `docs/architecture/README.md` (module map, life of a
89
+ request, life of a write) and the decision records for the decisions already
90
+ in place. Add `scripts/check-comments.sh` with a baseline that may only
91
+ shrink. The GitHub ruleset change can land separately.
92
+ 3. [#595], part 1: `@microsoft/api-extractor` on the five main entry points
93
+ (`ldap-rest`, `./plugin`, `./hooks`, `./ldapactions`,
94
+ `./expressformatedresponses`), reports under `etc/api/`, wired into the
95
+ `contracts` job. The 66 `./plugin-*` entries wait for phase 3.
96
+
97
+ Outcome: CI catches regressions, the current architecture is documented, and
98
+ the public types have a baseline.
99
+
100
+ ### Phase 1 — Decouple the core
101
+
102
+ 1. [#593]:
103
+ - typed entity registry replacing the six hand-written loops;
104
+ - `GroupsApi`, `OrganizationsApi`, `TrashApi`, `PasswordPolicyApi`,
105
+ `TwakeGroupsApi`, `AuthenticatorsInfo` service interfaces;
106
+ - replace the 68 `loadedPlugins` reads and the 17 `requirePlugin` uses;
107
+ - move `ChangesToNotify` out of `plugins/ldap/onChange.ts` and the SCIM
108
+ types out of `plugins/scim/` into `src/lib/`;
109
+ - ESLint: forbid `loadedPlugins` under `src/plugins/**` and imports between
110
+ plugin families.
111
+ 2. [#594]: extract the operation pipeline into `src/lib/ldap/operations.ts`,
112
+ bring `ldapActions.ts` under 400 lines, add the file-size test, move the
113
+ ESLint exemption, add the type test on `ldap-rest/ldapactions`.
114
+ 3. Options refactor (needs its own issue): split `src/config/args.ts` into core
115
+ options and per-family option sets; let the parser/`DM` accept an
116
+ `OptionSet`; move `priority.json` into the core or make it a parameter.
117
+
118
+ Outcome: `src/hooks.ts` imports no plugin, the core reads no `loadedPlugins`,
119
+ families do not import each other, and `ldapActions` is a stable façade.
120
+
121
+ ### Phase 2 — Workspaces and extraction
122
+
123
+ 1. Set up npm workspaces, `packages/`, a shared `tsconfig` and per-package
124
+ builds.
125
+ 2. Extract `@ldap-rest/core` first, with the `ldap-rest` umbrella depending on
126
+ it and re-exporting the existing entry points.
127
+ 3. Extract the families in dependency order: storage → auth → ldap → scim →
128
+ twake → misc.
129
+ 4. Extract `browser` and `schemas`.
130
+ 5. After each step: `npm run test:dev`, `npm run check`,
131
+ `npm run check:openapi`, `npm run check:options`, and the `contracts` job.
132
+ 6. Update the Dockerfile, the `bin/` scripts, the examples and the docs.
133
+
134
+ Outcome: a modular repository, with `ldap-rest` still installable and
135
+ compatible.
136
+
137
+ ### Phase 3 — Contracts and finalization
138
+
139
+ 1. [#595], part 2: one api-extractor report per sub-path, including the
140
+ `./plugin-*` entries, and non-additive diffs refused by `contracts`.
141
+ 2. [#549], part 2: the modularization decision record, a shrinking comment
142
+ baseline, and the `require_code_owner_review` ruleset.
143
+ 3. Publish the scoped packages and document the deprecation policy for the
144
+ former sub-paths.
145
+ 4. Close [#397].
146
+
147
+ ## Branch strategy
148
+
149
+ Phase 0 lands on `master`: it is non-breaking, and #603 fixes test bugs that
150
+ matter to the default branch too. Merge `master` into `v3.0` before phase 1,
151
+ so the 3.0 work starts from a green, guarded base. Phases 1 to 3 land on
152
+ `v3.0`.
153
+
154
+ ## Order
155
+
156
+ ```text
157
+ #603 ───────────────┐
158
+ #549 (part 1) ──────┼──> Phase 0
159
+ #595 (part 1) ──────┘
160
+ │
161
+ #593 ────────────────┼──> Phase 1 ──> #397 (Phase 2) ──> #595/#549 (part 2)
162
+ #594 ────────────────┘
163
+ ```
164
+
165
+ [#603] and [#549] part 1 can start in parallel. [#595] part 1 is a short
166
+ spike. [#593] and [#594] can then run in parallel, but both touch the core:
167
+ freeze new cross-family imports and new `loadedPlugins` reads from the start of
168
+ phase 1.
169
+
170
+ ## Decisions to make
171
+
172
+ 1. **Granularity**: packages per family (recommended) or one package per plugin
173
+ from the start?
174
+ 2. **Versioning**: a non-breaking internal split in 2.x with the umbrella
175
+ (recommended), or 3.0.0 with the former sub-paths removed?
176
+ 3. **`TwakeGroups extends LdapGroups`**: documented exception, or move the
177
+ shared base into `core`?
178
+ 4. **`Config` type**: type-only aggregate in `core` (recommended to start), or
179
+ split per package with declaration merging?
180
+ 5. **Build**: Rollup per package (consistent with today) or `tsc` with
181
+ generated exports?
182
+
183
+ ## First actions
184
+
185
+ 1. Retarget [#397] and add the package map and the dependency rule to it.
186
+ 2. Open sub-issues for [#593] and [#594]; both are too large for one pull
187
+ request.
188
+ 3. Start [#603] and [#549] part 1 in parallel.
189
+ 4. Spike [#595] on the five main entry points.
190
+ 5. Then [#593]/[#594], then the package extraction.
191
+
192
+ [#397]: https://github.com/linagora/ldap-rest/issues/397
193
+ [#549]: https://github.com/linagora/ldap-rest/issues/549
194
+ [#593]: https://github.com/linagora/ldap-rest/issues/593
195
+ [#594]: https://github.com/linagora/ldap-rest/issues/594
196
+ [#595]: https://github.com/linagora/ldap-rest/issues/595
197
+ [#603]: https://github.com/linagora/ldap-rest/issues/603
@@ -7,6 +7,7 @@ This guide will help you create powerful, production-ready plugins for LDAP-Rest
7
7
  - [Why Create a Plugin](#why-create-a-plugin)
8
8
  - [Plugin Structure](#plugin-structure)
9
9
  - [Creating Your Plugin](#creating-your-plugin)
10
+ - [Plugin services](./services.md)
10
11
  - [Custom Configuration Options](#custom-configuration-options)
11
12
  - [Plugin options](./options.md)
12
13
  - [Loading Your Plugin](#loading-your-plugin)
@@ -54,6 +55,8 @@ export default class MyPlugin extends DmPlugin {
54
55
  - **roles** (optional): Array categorizing plugin purpose
55
56
  - **dependencies** (optional): Required plugins that must load first
56
57
  - **hooks** (optional): Callbacks for LDAP operations
58
+ - **provides** (optional): Typed services this plugin offers, see
59
+ [plugin services](services.md)
57
60
  - **api()** (optional): Method to register REST endpoints
58
61
 
59
62
  ### Available from Base Class
@@ -65,6 +68,8 @@ export default class MyPlugin extends DmPlugin {
65
68
  - **this.logger**: Winston logger instance
66
69
  - **this.hookBus**: The server's hooks: chain, notify and wait on them, see
67
70
  [hooks](hooks.md#the-bus)
71
+ - **this.service(token)**: The instance another plugin provides under a
72
+ service token, or `null`, see [plugin services](services.md)
68
73
  - **this.fetch**: `fetch` timed into the
69
74
  [outbound metrics](../usage/operations.md#metrics) under the plugin's
70
75
  name; the lint refuses a bare `fetch` or `node-fetch` under `src`, and a
@@ -452,7 +457,8 @@ const results = await this.server.ldap.search(
452
457
  'ou=users,dc=example,dc=com'
453
458
  );
454
459
 
455
- // Search with pagination (returns AsyncGenerator)
460
+ // Search with pagination (returns AsyncGenerator; the connection is taken
461
+ // when the first page is read)
456
462
  const pagedResults = await this.server.ldap.search(
457
463
  {
458
464
  paged: true,
@@ -1320,6 +1326,7 @@ See [Tests and checks](../../CONTRIBUTING.md#tests-and-checks).
1320
1326
 
1321
1327
  - [Contributing Guide](../../CONTRIBUTING.md) - Git workflow and PR guidelines
1322
1328
  - [Hooks Reference](./hooks.md) - Complete list of available hooks
1329
+ - [Plugin services](./services.md) - Typed interfaces between plugins
1323
1330
  - [Core Plugin Examples](../../src/plugins/) - Browse existing plugins
1324
1331
  - [Test Examples](../../test/plugins/) - See how core plugins are tested
1325
1332
 
@@ -1,9 +1,10 @@
1
1
  # Plugin Dependency Graph
2
2
 
3
- This document describes the dependencies between LDAP-Rest plugins. There are two types of dependencies:
3
+ This document describes the dependencies between LDAP-Rest plugins. There are three types of dependencies:
4
4
 
5
5
  1. **Explicit Dependencies**: Defined in the `dependencies` property of a plugin class
6
- 2. **Hook Dependencies**: Implicit dependencies through hooks (one plugin provides a hook, another consumes it)
6
+ 2. **Service Dependencies**: A plugin reaches a typed interface another one provides, see [plugin services](services.md)
7
+ 3. **Hook Dependencies**: Implicit dependencies through hooks (one plugin provides a hook, another consumes it)
7
8
 
8
9
  ## Dependency Types
9
10
 
@@ -24,6 +25,16 @@ overrides — a dependency also given to `--plugin`, or an entry listed twice
24
25
  is loaded once: the second request waits for the first. Only an entry with
25
26
  overrides builds a second instance, and it needs a name of its own.
26
27
 
28
+ ### Service Dependencies
29
+
30
+ A plugin provides a typed interface under a token, and a consumer reaches it
31
+ with `this.service(token)`. The token replaces the registry name as the way to
32
+ find another plugin's code; `dependencies` still decides what loads first. A
33
+ consumer whose provider is absent gets `null` and a single warning.
34
+
35
+ See [plugin services](services.md) for the token, the provided interface and
36
+ the registration rules.
37
+
27
38
  ### Hook Dependencies
28
39
 
29
40
  **Hook Providers**: Plugins that implement hooks (via `hooks: Hooks = { ... }`)
@@ -24,7 +24,8 @@ subscriber answers:
24
24
  A chained hook is handed its arguments as one value, a tuple when it has
25
25
  several. A notified hook is handed them one by one: its subscriber is
26
26
  `(dn, entry, context) => …`, never `([dn, entry], context) => …` — that form
27
- still compiles, the parameters being bivariant, but then reads the DN's first
27
+ still compiles without annotation, since its parameter takes the declared
28
+ `string` and a string can be destructured, but then reads the DN's first
28
29
  character as the DN.
29
30
 
30
31
  - **Chained.** The value goes from one subscriber to the next, each getting
@@ -60,7 +61,7 @@ await server.hookBus.settled();
60
61
  - **hello**: raised by the [demo plugin](../../src/plugins/demo/helloworld.ts) for `/hello`, answered by the demo plugins
61
62
  - **ldapsearchopts**: chained before any search, with its options
62
63
  - **ldapsearchrequest**: chained before any search, with `[base, options, request]`; an authorization plugin refuses by throwing
63
- - **ldapsearchresult**: chained after any search, with its result, before it is cached
64
+ - **ldapsearchresult**: chained after any search, with its result, before it is cached; for a paged search, when the first page is read
64
65
  - **ldapsearchfilter**: chained on the result of a search after the cache, once per page, with `[result, request, options]`, to drop what the caller may not see
65
66
  - **ldapaddrequest**: chained before any add, with `[dn, entry, request]`
66
67
  - **ldapadddone**: notified once an add has written the entry, with the DN, the entry and the context, one by one; not awaited
@@ -118,6 +119,7 @@ await server.hookBus.settled();
118
119
  - **scimgroupdeletedone**: notified once a SCIM group is deleted, with its id
119
120
  - **scimbulkdone**: notified once a SCIM bulk request is answered
120
121
  - **twakedeletionreplay**: notified with the DN, the entry and the context, one by one when a delete asks of an entry that is already a tombstone
122
+
121
123
  <!-- /hooks -->
122
124
 
123
125
  ## Flat entities
@@ -42,17 +42,43 @@ export type ExampleOptions = OptionsOf<typeof exampleOptions>;
42
42
  The key is the `Config` name: `example_timeout` is `--example-timeout`,
43
43
  `DM_EXAMPLE_TIMEOUT` when the descriptor names that variable.
44
44
 
45
+ ### Deprecating an option
46
+
47
+ Rename an option by giving the old name as an alias: the parser still reads
48
+ it, and the server warns once at startup. Keep the alias for two minor
49
+ releases, as [CONTRIBUTING.md](../../CONTRIBUTING.md#deprecating-a-published-contract)
50
+ requires.
51
+
52
+ ```typescript
53
+ export const exampleOptions = defineOptions({
54
+ example_url: opt.string({
55
+ env: 'DM_EXAMPLE_URL',
56
+ aliases: ['example-uri'],
57
+ envAliases: ['DM_EXAMPLE_URI'],
58
+ }),
59
+ });
60
+ ```
61
+
62
+ A CLI alias takes one value per occurrence, as the singular form does, and an
63
+ array option's plural form has no alias: renaming it is a breaking change. An
64
+ environment alias is read when `env` is unset, or empty for a type where an
65
+ empty value means nothing, and only for a non-array option when its canonical
66
+ CLI name is absent; an array option combines the environment value with the
67
+ CLI one, so its alias is still read.
68
+
45
69
  Each declaration takes:
46
70
 
47
- | Field | Meaning |
48
- | ---------- | ------------------------------------------------------------------- |
49
- | `env` | Environment variable read when the CLI does not set the option |
50
- | `default` | Value kept when neither the CLI nor the environment sets the option |
51
- | `required` | Refuse an empty value instead of reading it as unset |
52
- | `plural` | Array only: CLI name of the plural form, without the leading `--` |
53
- | `kind` | Array only: how a value splits, see `ArrayKind` in `parseConfig.ts` |
54
- | `min/max` | Number only: bounds checked when the option is read |
55
- | `choices` | String only: the values the option accepts |
71
+ | Field | Meaning |
72
+ | ------------ | --------------------------------------------------------------------- |
73
+ | `env` | Environment variable read when the CLI does not set the option |
74
+ | `default` | Value kept when neither the CLI nor the environment sets the option |
75
+ | `required` | Refuse an empty value instead of reading it as unset |
76
+ | `plural` | Array only: CLI name of the plural form, without the leading `--` |
77
+ | `kind` | Array only: how a value splits, see `ArrayKind` in `parseConfig.ts` |
78
+ | `min/max` | Number only: bounds checked when the option is read |
79
+ | `choices` | String only: the values the option accepts |
80
+ | `aliases` | Former CLI names still accepted, without the leading `--` |
81
+ | `envAliases` | Former environment variables still accepted, read when `env` is unset |
56
82
 
57
83
  ## Reading options
58
84
 
@@ -103,7 +129,8 @@ moved. A migrated plugin no longer reads them: it uses `options()`.
103
129
 
104
130
  Plugins outside the repository cannot add to `args.ts` yet, but they can
105
131
  declare options the same way and name them in `optionDefinitions`: the startup
106
- report subtracts those keys, and `options()` reads a declared `env` variable
107
- when the parser did not carry the key. An option without a declaration still
108
- reaches `this.config` from an unknown `--key value` pair, and the startup
109
- report names it.
132
+ report subtracts those keys and their aliases, and `options()` reads a declared
133
+ `env` variable or a deprecated CLI alias the parser did not carry, warning at
134
+ startup for the alias as it does for a core option. An option without a
135
+ declaration still reaches `this.config` from an unknown `--key value` pair, and
136
+ the startup report names it.
@@ -0,0 +1,95 @@
1
+ # Plugin services
2
+
3
+ A plugin sometimes needs what another plugin holds: the group operations, the
4
+ entity schemas, the trash. A service is a typed interface a plugin provides
5
+ under a token, and another plugin reaches with `this.service(token)`.
6
+
7
+ The token carries the interface. The compiler checks that an instance bound
8
+ with `with()` answers it, and gives a consumer the type it declared. Whether a
9
+ provider exists is a runtime question: a plugin that stops declaring the token
10
+ still compiles, and its consumers get `null` and a warning. The plugin
11
+ registry name stays what it is for: loading a plugin and naming it in the
12
+ logs.
13
+
14
+ ## Declaring a service
15
+
16
+ Put the token and its interface in a module both sides import — a plugin
17
+ family's own service in its own module. A service shared between families is
18
+ declared in `src/lib/services/` and re-exported from `ldap-rest/plugin`, one
19
+ of the package's entry points; a module under `src/lib/` has no export path of
20
+ its own.
21
+
22
+ ```typescript
23
+ import { serviceToken } from 'ldap-rest/plugin';
24
+
25
+ export interface GroupsApi {
26
+ listGroups(): Promise<string[]>;
27
+ }
28
+
29
+ export const GroupsService = serviceToken<GroupsApi>('groups');
30
+ ```
31
+
32
+ Declare a token once. The object is the registry key; calling `serviceToken`
33
+ again under the same name declares another service, not the same one. The name
34
+ only identifies it in the logs.
35
+
36
+ ## Providing a service
37
+
38
+ Bind the plugin instance to each token it provides:
39
+
40
+ ```typescript
41
+ import DmPlugin from 'ldap-rest/plugin';
42
+
43
+ export default class LdapGroups extends DmPlugin {
44
+ name = 'ldapGroups';
45
+ provides = [GroupsService.with(this)];
46
+
47
+ async listGroups(): Promise<string[]> {
48
+ /* … */
49
+ }
50
+ }
51
+ ```
52
+
53
+ `with(this)` is checked by the compiler: an instance that does not answer the
54
+ interface does not compile. A plugin can provide several services. A service
55
+ is registered once its plugin's registration completes — after `api()` and
56
+ the hooks — so a consumer loaded afterwards finds it, and a provider does not
57
+ find its own service there. A plugin needing another one declares it in
58
+ [`dependencies`](dependencies.md#explicit-dependencies) so that it loads
59
+ first.
60
+
61
+ The first provider to register wins. A second one offering the same token is
62
+ ignored and named in a warning: which one answered would otherwise depend on
63
+ load order. Loading the same module twice under two names gives two instances
64
+ but one module-level token, so the second instance cannot provide it either.
65
+
66
+ When a host registers a plugin after startup, its `assertComposition` runs
67
+ before the service is registered, so a provider does not find its own service
68
+ there; the configuration's plugins are all registered before their
69
+ `assertComposition` runs.
70
+
71
+ ## Consuming a service
72
+
73
+ ```typescript
74
+ const groups = this.service(GroupsService);
75
+ if (!groups) {
76
+ // Nothing provides it: the plugin skips the feature it would serve
77
+ return;
78
+ }
79
+ await groups.listGroups();
80
+ ```
81
+
82
+ `service()` answers the provided instance, or `null` when nothing provides the
83
+ token. It logs one warning per plugin and service rather than one per call. A
84
+ plugin that cannot work without a service declares the provider in
85
+ `dependencies`, checks the answer in `assertComposition`, and throws naming the
86
+ service — that stops the server at startup instead of leaving a feature
87
+ silently absent. A constructor runs before the dependencies load, so it cannot
88
+ be the place for that check.
89
+
90
+ The registry belongs to the server and is shared by the views `withConfig`
91
+ builds for plugins loaded with overrides. It has no removal: a plugin
92
+ registers once and keeps its services for the life of the server.
93
+
94
+ `this.requirePlugin(name)` remains for a plugin whose interface is not yet
95
+ declared as a service; new code reaches another plugin through a token.
@@ -53,6 +53,18 @@ The image runs as its unprivileged `node` user: with `docker run`, a
53
53
  `DM_PORT` below 1024 stops the server at startup, where the default 8081
54
54
  changes nothing.
55
55
 
56
+ The image is built in two stages: a builder produces the package and installs
57
+ its production dependencies, the runtime stage copies them and carries no
58
+ compiler nor package manager cache. The runtime stage upgrades the Alpine
59
+ packages of its base image with `apk upgrade --no-cache`, so a security fix
60
+ published after the base image was built still reaches the image: two builds
61
+ from the same digest may differ in those packages. Its base image is pinned by
62
+ digest in `scripts/buildDockerfile.ts`, so the image built is the one reviewed
63
+ even if the tag moves; the digest follows the tag it names, taken from the
64
+ [Docker Hub tag page](https://hub.docker.com/_/node/tags?name=25-alpine) and
65
+ updated by hand with it. Dependabot ignores the image: it would open a change
66
+ to the generated `Dockerfile`, which the drift check refuses.
67
+
56
68
  ### What the shipped plugins report
57
69
 
58
70
  | Plugin | `degraded` when |
@@ -19,7 +19,13 @@ gets no instance.
19
19
  An entry holding `--twake-instance-skip-value` in
20
20
  `--twake-instance-skip-attribute`, a technical account for instance, gets
21
21
  none, and neither does a tombstone
22
- (`--twake-lifecycle-deleted-attribute`) put back in place.
22
+ (`--twake-lifecycle-deleted-attribute`) put back in place. Each expression
23
+ is tried against the canonical DN first, then against the DN as sent: an
24
+ account created under a branch written with other spacing or case is still
25
+ one, and an expression written with the directory's own spelling (`\2C`, a
26
+ doubled space) still matches. An expression naming a value that holds a
27
+ comma writes it `\2C` or `\,`: a value comma never stands in for an RDN
28
+ separator in the form matched.
23
29
  - The address (`--twake-instance-fqdn-attribute`) and the mark
24
30
  (`--twake-instance-sent-attribute`) are this plugin's to write: an add that
25
31
  carries them has them removed, and an address written by a modify is taken
@@ -80,8 +86,10 @@ before it takes its members' `user.created`. Then `organization.created` is
80
86
  published on the b2b exchange, and the entry is marked. The message carries
81
87
  the organization's id, address, domain, and its name as `organization`, read
82
88
  from `--twake-instance-organization-name-attribute`, which
83
- `ensureOrganization` fills from `name` when the entry has none. The mark, not
84
- the address, says the organization is done.
89
+ `ensureOrganization` fills from `name` when the entry has none. An id captured
90
+ from a `--twake-instance-dn` group is read from the organization entry when
91
+ one exists, so the case a caller spells the branch in is not sent. The mark,
92
+ not the address, says the organization is done.
85
93
 
86
94
  With the Cloudery, a member's `user.created` waits for its organization's: a
87
95
  member under an organization entry that is not marked yet gets its address
@@ -110,7 +110,10 @@ operational one such as `pwdAccountLockedTime` is seen on both sides.
110
110
  means entries are never tombstones, and only a removal publishes `deleted`.
111
111
  - `--twake-lifecycle-deleted-at-attribute`,
112
112
  `--twake-lifecycle-deleted-at-format` (`iso8601`, the default, or
113
- `generalizedTime`): the deletion date. It is always published as ISO 8601.
113
+ `generalizedTime`): the deletion date. It is read whether it is written as
114
+ a GeneralizedTime or as an ISO 8601 date, and always published as ISO 8601;
115
+ a value of neither shape is left out. A GeneralizedTime needs its zone; an
116
+ ISO 8601 date and time without one is read in the server's time zone.
114
117
  - `--twake-lifecycle-member-attribute` (default `member`): the attribute
115
118
  `memberAdded`, `memberRemoved` and member lists read.
116
119
  - `--twake-lifecycle-rules`: a JSON file, or the JSON itself.
@@ -223,16 +223,17 @@ and `--scim-group-mapping`.
223
223
 
224
224
  Each element of `entries` is a `MappingEntry` with the following optional fields:
225
225
 
226
- | Field | Purpose |
227
- | --------------- | -------------------------------------------------------------------------------------------------------------------- |
228
- | `scim` | **Required.** Top-level SCIM attribute name (`userName`, `displayName`, `emails`, …). |
229
- | `ldap` | Simple 1:1 mapping to an LDAP attribute. |
230
- | `ldapPrimary` | For multi-valued SCIM attributes (`emails`, `phoneNumbers`, …), the LDAP attribute that stores the _primary_ value. |
231
- | `ldapSecondary` | LDAP attribute that stores the _non-primary_ values of the same SCIM attribute (array). |
232
- | `sub` | Object that maps SCIM sub-attributes to LDAP attributes. Used for complex SCIM values like `name.familyName` → `sn`. |
233
- | `multi` | `"array"` or `"single"` (default) — hints array serialization for `ldap`. |
234
- | `readOnly` | `true` to skip this attribute on write (create/update ignore it). |
235
- | `operational` | `true` for LDAP operational attributes (e.g. `entryUUID`) — loaded on read only. |
226
+ | Field | Purpose |
227
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
228
+ | `scim` | **Required.** Top-level SCIM attribute name (`userName`, `displayName`, `emails`, …). |
229
+ | `ldap` | Simple 1:1 mapping to an LDAP attribute. |
230
+ | `ldapPrimary` | For multi-valued SCIM attributes (`emails`, `phoneNumbers`, …), the LDAP attribute that stores the _primary_ value. |
231
+ | `ldapSecondary` | LDAP attribute that stores the _non-primary_ values of the same SCIM attribute (array). |
232
+ | `sub` | Object that maps SCIM sub-attributes to LDAP attributes. Used for complex SCIM values like `name.familyName` → `sn`. |
233
+ | `subDn` | Sub-attribute names in `sub` whose value is a DN (for example `["value"]` for `manager`); they are held to RFC 4514 on write. |
234
+ | `multi` | `"array"` or `"single"` (default) — hints array serialization for `ldap`. |
235
+ | `readOnly` | `true` to skip this attribute on write (create/update ignore it). |
236
+ | `operational` | `true` for LDAP operational attributes (e.g. `entryUUID`) — loaded on read only. |
236
237
 
237
238
  ### Merging
238
239
 
@@ -269,7 +270,8 @@ override only what's different and inherit the rest.
269
270
  },
270
271
  {
271
272
  "scim": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager",
272
- "sub": { "value": "manager" }
273
+ "sub": { "value": "manager" },
274
+ "subDn": ["value"]
273
275
  }
274
276
  ]
275
277
  }
@@ -317,7 +319,11 @@ of them; a complex attribute takes an object whose sub-attributes are scalars
317
319
  multi-valued complex attribute such as `emails` takes a list of objects whose
318
320
  `value` is a scalar. Anything else is answered
319
321
  `400 invalidValue`, as is a PATCH operation that is not an object or whose
320
- `op` is not a string.
322
+ `op` is not a string. A reference the mapping declares a DN (`subDn`) is
323
+ refused with `400 invalidValue` when its value holds an escape RFC 4514 does
324
+ not define, as the flat routes hold a client-written DN. The directory
325
+ refuses such a value itself on an attribute of DN syntax; this names the
326
+ escape in the SCIM answer and applies wherever the mapping declares one.
321
327
 
322
328
  A PATCH `add`, or a `remove` naming values, changes only the values it names:
323
329
  values the caller's read did not show, such as group members an
@@ -715,18 +721,14 @@ Response shape (truncated):
715
721
  "id",
716
722
  "active"
717
723
  ],
718
- "mapping": [
719
- /* same shape as default-mapping.json */
720
- ]
724
+ "mapping": [/* same shape as default-mapping.json */]
721
725
  },
722
726
  {
723
727
  "id": "Group",
724
728
  "endpoint": "/scim/v2/Groups",
725
729
  "schema": "urn:ietf:params:scim:schemas:core:2.0:Group",
726
730
  "rdnAttribute": "cn",
727
- "mapping": [
728
- /* ... */
729
- ]
731
+ "mapping": [/* ... */]
730
732
  }
731
733
  ],
732
734
  "idStrategy": "rdn",
@@ -10,10 +10,13 @@ is old enough, or right away when forced.
10
10
 
11
11
  ## Behaviour
12
12
 
13
- - A delete of an entry whose DN matches `--twake-tombstone-dn` writes, in one
14
- modify: the deleted attribute, the deletion date (now), the reason, the
15
- lock, and removes `--twake-tombstone-clear-attributes`. Other entries are
16
- deleted as usual.
13
+ - A delete of an entry whose DN matches `--twake-tombstone-dn` — under any
14
+ spelling the directory folds, so spaces, case and escapes do not matter —
15
+ writes, in one modify: the deleted attribute, the deletion date (now), the
16
+ reason, the lock, and removes `--twake-tombstone-clear-attributes`. Other
17
+ entries are deleted as usual. A multi-valued RDN may not match: an
18
+ expression anchored on `uid=` misses a DN written `cn=…+uid=…`, and that
19
+ delete removes the entry for good.
17
20
  - A delete of an existing tombstone writes nothing and answers as a delete.
18
21
  It fires the `twakedeletionreplay` hook with the tombstone, so
19
22
  [lifecycle events](lifecycle-events.md) publishes the deletion again, with
@@ -62,8 +65,9 @@ Content-Type: application/json
62
65
 
63
66
  - `200`: erased.
64
67
  - `404`: no tombstone at this DN.
65
- - `409`: the deletion is younger than `--twake-tombstone-erase-min-age` and
66
- `force` is not true.
68
+ - `409`: `force` is not true, and either the deletion is younger than
69
+ `--twake-tombstone-erase-min-age` or its date cannot be read as
70
+ [lifecycle events](lifecycle-events.md) reads it.
67
71
 
68
72
  Other plugins call `erase(dn, { force, req })`.
69
73
 
@@ -453,7 +453,14 @@ Content-Type: application/json
453
453
 
454
454
  The identifier may also be the whole DN of the entry, in the entity's
455
455
  branch: `"uid": "uid=Smith\\,John,ou=users,dc=example,dc=com"` creates the
456
- entry `Smith,John`, whose DN spells the name with its escapes.
456
+ entry `Smith,John`, whose DN spells the name with its escapes. The DN must
457
+ use the escapes RFC 4514 defines; an escape it does not define (`\q`) is
458
+ refused with a `400`. Send the bare value to have the server escape it.
459
+
460
+ The name written is the value the RDN gives under the folding the directory
461
+ applies (Unicode NFKC, and its insignificant spaces): `uid=\ zoë` and
462
+ `uid=zoë` name one entry, written `uid=zoë`, and a name sent decomposed is
463
+ stored composed.
457
464
 
458
465
  ### Modify Entry
459
466