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.
- package/README.md +8 -1
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/abstract/plugin.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/options.js +1 -1
- package/dist/config/schema.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/errors.js +1 -1
- package/dist/lib/ldap/callerView.js +1 -0
- package/dist/lib/ldap/pool.js +1 -0
- package/dist/lib/ldap/schema.js +1 -0
- package/dist/lib/ldap/searchCache.js +1 -0
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/services/index.js +1 -0
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/ldap/accountLifecycle.js +1 -1
- package/dist/plugins/ldap/enterpriseRules.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/passwordPolicy.js +1 -1
- package/dist/plugins/ldap/raw.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/scim/mapping.js +1 -1
- package/dist/plugins/scim/patch.js +1 -1
- package/dist/plugins/storage/file.js +1 -1
- package/dist/plugins/twake/instances.js +1 -1
- package/dist/plugins/twake/lifecycleAttributes.js +1 -1
- package/dist/plugins/twake/lifecycleEvents.js +1 -1
- package/dist/plugins/twake/tombstone.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +13 -0
- package/dist/src/abstract/plugin.d.ts +21 -0
- package/dist/src/bin/index.d.ts +38 -4
- package/dist/src/config/options.d.ts +16 -1
- package/dist/src/lib/errors.d.ts +2 -1
- package/dist/src/lib/ldap/callerView.d.ts +45 -0
- package/dist/src/lib/ldap/pool.d.ts +108 -0
- package/dist/src/lib/ldap/schema.d.ts +157 -0
- package/dist/src/lib/ldap/searchCache.d.ts +112 -0
- package/dist/src/lib/ldapActions.d.ts +25 -262
- package/dist/src/lib/parseConfig.d.ts +26 -2
- package/dist/src/lib/services/index.d.ts +56 -0
- package/dist/src/lib/utils.d.ts +72 -3
- package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
- package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
- package/dist/src/plugins/scim/mapping.d.ts +16 -3
- package/dist/src/plugins/scim/types.d.ts +2 -0
- package/dist/src/plugins/twake/instances.d.ts +31 -0
- package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
- package/dist/src/plugins/twake/tombstone.d.ts +9 -2
- package/docs/README.md +25 -2
- package/docs/architecture/README.md +180 -0
- package/docs/architecture/adr/0001-plugin-load-order.md +50 -0
- package/docs/architecture/adr/0002-authentication-dispatch.md +43 -0
- package/docs/architecture/adr/0003-authorization-composition.md +59 -0
- package/docs/architecture/adr/0004-fail-closed-request-context.md +45 -0
- package/docs/architecture/adr/0005-hook-contract.md +54 -0
- package/docs/architecture/adr/0006-search-cache.md +49 -0
- package/docs/architecture/adr/0007-trash-and-tombstone.md +69 -0
- package/docs/architecture/adr/0008-withconfig-views.md +58 -0
- package/docs/client-development/api/rest-api.md +4 -0
- package/docs/development/contracts.md +119 -0
- package/docs/development/modularization-plan.md +197 -0
- package/docs/plugin-development/README.md +8 -1
- package/docs/plugin-development/dependencies.md +13 -2
- package/docs/plugin-development/hooks.md +4 -2
- package/docs/plugin-development/options.md +40 -13
- package/docs/plugin-development/services.md +95 -0
- package/docs/usage/operations.md +12 -0
- package/docs/usage/plugins/integrations/instances.md +11 -3
- package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
- package/docs/usage/plugins/integrations/scim.md +20 -18
- package/docs/usage/plugins/integrations/tombstone.md +10 -6
- package/docs/usage/plugins/ldap/flat-generic.md +8 -1
- package/docs/usage/plugins/ldap/groups.md +4 -1
- package/docs/usage/plugins/ldap/password-policy.md +7 -0
- package/docs/usage/upgrading.md +59 -0
- package/package.json +21 -12
- package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
- package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
- package/static/browser/ldap-tree-viewer.js.map +1 -1
- package/static/browser/ldap-tree-viewer.min.js.map +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 0002 — Authentication dispatch
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
One server can host populations that authenticate differently: administrators
|
|
6
|
+
behind a token, users behind OpenID Connect, machine clients behind HMAC. An
|
|
7
|
+
authentication plugin declares the path prefixes it claims. A request must run
|
|
8
|
+
the authentication that owns its path, and only that one: running two
|
|
9
|
+
unrelated authenticators on one request composes as an AND that no credential
|
|
10
|
+
can satisfy. A request no plugin claims must not silently become open without
|
|
11
|
+
saying so.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
`DM` mounts one dispatcher layer, once, at the boundary between the protection
|
|
16
|
+
plugins and the routes. For each request it selects the plugins whose prefix
|
|
17
|
+
is the longest match. Several plugins sharing that winning prefix all run, in
|
|
18
|
+
registration order, and every one must let the request through. When no
|
|
19
|
+
plugin claims the path, the unscoped plugins (no prefix) run; when there is
|
|
20
|
+
none, the request passes through unauthenticated, outside any request
|
|
21
|
+
context. A plugin's `afterChain` runs after the whole chain and before the
|
|
22
|
+
route. `runForRequest` then wraps everything downstream of a request an
|
|
23
|
+
authenticator let through, and the dispatcher records which plugins vouched
|
|
24
|
+
for the request in `req.authenticators`.
|
|
25
|
+
|
|
26
|
+
`warnUnauthenticatedRoutes` lists, at startup, the routes that no
|
|
27
|
+
authentication plugin covers. An unscoped authenticator guards everything and
|
|
28
|
+
silences the warning.
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
- Two authenticators on one prefix require both credentials. That is
|
|
33
|
+
legitimate — a session and a token — but it fails closed when accidental.
|
|
34
|
+
- Scoping authentication to a prefix opens every route outside it; the
|
|
35
|
+
startup warning is the only signal, and it names the routes.
|
|
36
|
+
- `afterChain` is the only hook that sees all the selected authenticators'
|
|
37
|
+
verdicts, so an answer that depends on several of them belongs there, not
|
|
38
|
+
in a plugin's own middleware.
|
|
39
|
+
- An authorization plugin can tell whose request it judges from
|
|
40
|
+
`req.authenticators`; see
|
|
41
|
+
[authorization composition](0003-authorization-composition.md).
|
|
42
|
+
|
|
43
|
+
See `src/bin/index.ts` (`dispatchAuth`) and `src/lib/auth/base.ts`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 0003 — Authorization composition
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Every plugin that judges directory operations registers the same request
|
|
6
|
+
hooks (`ldapsearchrequest`, `ldapaddrequest`, `ldapmodifyrequest`,
|
|
7
|
+
`ldapdeleterequest`, `ldaprenamerequest`). Chained hooks run their
|
|
8
|
+
subscribers in order and stop at the first refusal, so two loaded
|
|
9
|
+
authorization plugins compose as an AND: the first to refuse answers. That
|
|
10
|
+
rule is sound when it is meant. It is a refusal nobody can explain when it is
|
|
11
|
+
not: `authzPerBranch` next to `authzDynamic` judges a machine token by a
|
|
12
|
+
branch configuration written for administrators and refuses a write the
|
|
13
|
+
token's ACLs grant.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
A plugin with the `authz` role that registers LDAP hooks is a _judge_. It
|
|
18
|
+
judges the requests authenticated by the plugins named in `--authz-for`, or
|
|
19
|
+
everyone by default. A plugin that authenticates as well judges only its own
|
|
20
|
+
requests: its hooks read the credential it verified.
|
|
21
|
+
|
|
22
|
+
`assertAuthzComposition` runs once every configured plugin is loaded, and
|
|
23
|
+
again for each plugin registered later. It refuses:
|
|
24
|
+
|
|
25
|
+
- an `authz_for` naming a plugin that is not a loaded authenticator — the
|
|
26
|
+
plugin would judge nobody. A server-wide `--authz-for` that no loaded plugin
|
|
27
|
+
reads only warns, as does an `authz_for` on a plugin that also
|
|
28
|
+
authenticates;
|
|
29
|
+
- two judges whose populations overlap on an authenticator — the same one, or
|
|
30
|
+
one of them judging everyone — unless `--authz-combine` makes the AND
|
|
31
|
+
deliberate.
|
|
32
|
+
|
|
33
|
+
Two judges whose authenticators can run on the same prefix, without sharing an
|
|
34
|
+
authenticator name, are reported, not refused: asking for two credentials is
|
|
35
|
+
already a decision, and a request carrying both is judged by both. A judge on
|
|
36
|
+
`authzPerRoute` gates URLs and registers no LDAP hook, so it is not part of
|
|
37
|
+
this AND — route plus branch is a documented combination.
|
|
38
|
+
|
|
39
|
+
The request hooks of judges run before every other plugin's request hook and
|
|
40
|
+
`ldapsearchfilter` keeps the load order. All the LDAP hooks compose as an
|
|
41
|
+
AND, including the filters: two filters narrow the result twice.
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
- A deployment with two branch models on the same population no longer starts
|
|
46
|
+
unless it says `--authz-combine`; the error names both plugins.
|
|
47
|
+
- Naming authenticators in `--authz-for` requires stable instance names, so
|
|
48
|
+
the configuration that scopes authentication and the one that scopes
|
|
49
|
+
authorization must agree.
|
|
50
|
+
- A plugin registered by an embedding host is checked before its routes and
|
|
51
|
+
hooks exist, so a refused plugin leaves nothing behind.
|
|
52
|
+
- Judges run first, in their load order. The chain stops at the first refusal,
|
|
53
|
+
so a judge after a refusing one does not run; the refusal is the answer.
|
|
54
|
+
No judge is skipped while the request is still allowed.
|
|
55
|
+
- A judge that also authenticates judges only its own requests; a request it
|
|
56
|
+
did not vouch for passes its hooks untouched, because the absence of its
|
|
57
|
+
credential is not a refusal it makes.
|
|
58
|
+
|
|
59
|
+
See `src/lib/authz/composition.ts` and `src/config/args.ts`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 0004 — Fail-closed request context
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
An authorization judge decides from the request (`req.user`, its
|
|
6
|
+
authenticators, its path). But a directory operation is often made deep in a
|
|
7
|
+
plugin that never received the request: a provisioning step incremented from a
|
|
8
|
+
hook, a consistency repair, a route helper that searches before it writes.
|
|
9
|
+
Passing the request down every call is easy to forget, and a forgotten request
|
|
10
|
+
used to mean an operation nobody judged — a hole that is invisible until
|
|
11
|
+
someone writes through it.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
The authentication dispatcher runs everything downstream of an authenticated
|
|
16
|
+
request inside `runForRequest(req, next)`, an `AsyncLocalStorage`. Every
|
|
17
|
+
directory operation defaults to `currentRequest()`, so a plugin that forgets
|
|
18
|
+
the request still gets the caller's identity and is judged as them. Operations
|
|
19
|
+
the server makes for itself run inside `outsideRequest()`, which `ldap.system`
|
|
20
|
+
uses: it is the explicit escape from judgement, not a default.
|
|
21
|
+
|
|
22
|
+
The log context is a second `AsyncLocalStorage`, kept apart: a line written as
|
|
23
|
+
the server, outside the request, still names the request that made the server
|
|
24
|
+
act. `outsideAnyRequest()` leaves both, for work that outlives a request (a
|
|
25
|
+
pooled connection, a process-wide client).
|
|
26
|
+
|
|
27
|
+
Hooks are the one place that deliberately leaves the context: the bus runs
|
|
28
|
+
every subscriber inside `outsideRequest()`, so a hook's own writes are the
|
|
29
|
+
server's and not the caller's. A hook that judges or acts for the caller has
|
|
30
|
+
the request in its arguments and names it.
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
- A plugin that means to act as the server must say so with `ldap.system`;
|
|
35
|
+
forgetting the request fails closed instead of opening a hole.
|
|
36
|
+
- A test that calls a plugin's directory operations directly must wrap them in
|
|
37
|
+
`runForRequest` to reproduce what the server does, or get the unjudged
|
|
38
|
+
behaviour of `ldap.system`.
|
|
39
|
+
- Asynchronous work started inside the request inherits it, so a background
|
|
40
|
+
task that should not act as the caller has to leave the context first.
|
|
41
|
+
- Errors and logging inherit the request id, which makes a write traceable to
|
|
42
|
+
the request that caused it.
|
|
43
|
+
|
|
44
|
+
See `src/lib/requestContext.ts` and `src/lib/ldapActions.ts`
|
|
45
|
+
(`forRequest`, `system`, `currentRequest`).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 0005 — Hook contract
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Plugins have to talk to each other without importing each other. A refusal
|
|
6
|
+
has to be able to stop a write; a notification has to be able to run after the
|
|
7
|
+
write, without holding the request open or turning a subscriber's failure into
|
|
8
|
+
the caller's failure. One kind of hook cannot do both.
|
|
9
|
+
|
|
10
|
+
## Decision
|
|
11
|
+
|
|
12
|
+
There are two kinds, told apart by what a subscriber answers, and a third
|
|
13
|
+
notification mode:
|
|
14
|
+
|
|
15
|
+
- **Chained** (`chain(name, value)`). The value goes from one subscriber to
|
|
16
|
+
the next; the last answer is the result. A subscriber that throws stops the
|
|
17
|
+
chain and the error travels to the caller: a refusal is a refusal. Several
|
|
18
|
+
arguments are passed as one tuple.
|
|
19
|
+
- **Notified** (`notify(name, ...args)`). Subscribers run one after the other,
|
|
20
|
+
outside the request that caused the write, and are not awaited. A failing
|
|
21
|
+
subscriber is logged with its plugin and hook, counted in the hook failure
|
|
22
|
+
metrics, and the next one still runs.
|
|
23
|
+
- **Awaited notification** (`notifyAndWait`). As `notify`, but the caller waits
|
|
24
|
+
for the subscribers. A failure is still logged and swallowed.
|
|
25
|
+
- **Strict notification** (`notifyStrict`) for the rare failure that is part
|
|
26
|
+
of the answer. Every subscriber runs; the first failure is raised once they
|
|
27
|
+
all returned.
|
|
28
|
+
|
|
29
|
+
Every hook is declared in `src/hooks.ts` with its arguments and its answer. A
|
|
30
|
+
plugin subscribing to an undeclared hook does not compile, and `npm run
|
|
31
|
+
check:hooks` keeps the documentation in step. Notified hooks take their
|
|
32
|
+
arguments one by one; chained hooks take one value, a tuple when there are
|
|
33
|
+
several.
|
|
34
|
+
|
|
35
|
+
The request hooks of authorization plugins run first, in load order. The
|
|
36
|
+
other hooks run in plugin load order.
|
|
37
|
+
|
|
38
|
+
## Consequences
|
|
39
|
+
|
|
40
|
+
- A notified hook cannot refuse a write; a plugin that needs to refuse
|
|
41
|
+
subscribes to the request hook instead.
|
|
42
|
+
- A chained subscriber must answer the value it was given, changed or not: an
|
|
43
|
+
`undefined` answer becomes the next subscriber's input.
|
|
44
|
+
- A subscriber of a notified hook written `([dn, entry], ctx) => …` compiles
|
|
45
|
+
without annotation — its parameter takes the declared type, a `string`, and
|
|
46
|
+
a string can be destructured — and then reads the first character of the
|
|
47
|
+
DN. The documentation repeats the shape of each hook for this reason.
|
|
48
|
+
- A "done" hook fires once the operation is over, including a modify that had
|
|
49
|
+
nothing to apply; a write refused by a request hook or by the directory
|
|
50
|
+
never reaches it. The "end" hook fires all the same, once the done
|
|
51
|
+
subscribers returned. Keying state by the operation number is what tells
|
|
52
|
+
two writes of one DN apart.
|
|
53
|
+
|
|
54
|
+
See [hooks](../../plugin-development/hooks.md) and `src/lib/hookBus.ts`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# 0006 — Search cache
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Reading one entry by DN is the most frequent operation a route makes: before a
|
|
6
|
+
write, before an authorization decision, to render a page. A directory round
|
|
7
|
+
trip per read is measurable, and a cache in front of it is easy to add and
|
|
8
|
+
hard to keep honest. Serving a stale entry after a write is worse than the
|
|
9
|
+
round trip.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
`SearchCache` caches non-paged, base-scope searches — single-entry lookups by
|
|
14
|
+
DN, with whatever filter — keyed by base, scope, filter, attributes, options
|
|
15
|
+
and values mode, in an LRU bounded by `--ldap-cache-max`. It is **off by
|
|
16
|
+
default** (`--ldap-cache-ttl=0`): no deployment has run with it on, and
|
|
17
|
+
turning it on for everyone would be a silent behaviour change.
|
|
18
|
+
|
|
19
|
+
`ldapActions` drops, around every write it makes through this process:
|
|
20
|
+
|
|
21
|
+
- the reads of the entry written and of its subtree (a rename or a move
|
|
22
|
+
re-parents a whole branch);
|
|
23
|
+
- the `memberOf` of the members a group change adds or removes;
|
|
24
|
+
- the reads of entries naming the entry renamed or deleted, because the
|
|
25
|
+
directory's `memberof` and `refint` overlays rewrite them.
|
|
26
|
+
|
|
27
|
+
Entries are dropped before the directory call as well as after it, and a
|
|
28
|
+
generation counter stops a read that started before the write from storing
|
|
29
|
+
its stale answer afterwards. DNs are compared the way the directory does:
|
|
30
|
+
case, spacing, escapes, aliases and multi-valued RDN order do not matter.
|
|
31
|
+
|
|
32
|
+
Writes made elsewhere — another replica, an LSC synchronisation, a hand-run
|
|
33
|
+
`ldapmodify` — do not invalidate anything. Each process caches on its own.
|
|
34
|
+
|
|
35
|
+
## Consequences
|
|
36
|
+
|
|
37
|
+
- Enable the cache only when this service is the only writer, or when serving
|
|
38
|
+
an entry up to `--ldap-cache-ttl` seconds old is acceptable.
|
|
39
|
+
- A subtree or one-level read, or a paged search, is never cached; the cache
|
|
40
|
+
does not accelerate listings. A base-scope read with a filter is cached,
|
|
41
|
+
keyed by that filter.
|
|
42
|
+
- The cache does not lift a miss: a read of an absent DN throws and caches
|
|
43
|
+
nothing.
|
|
44
|
+
- The invalidation rules follow the overlays this project documents
|
|
45
|
+
(`memberof`, `refint`); a deployment with an overlay that rewrites other
|
|
46
|
+
entries must accept a miss or leave the cache off.
|
|
47
|
+
|
|
48
|
+
See [configuration](../../usage/configuration.md#the-search-cache) and
|
|
49
|
+
`src/lib/ldap/searchCache.ts`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# 0007 — Trash and tombstone
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Deleting an entry destroys the identifiers it held: a mail address, a login, a
|
|
6
|
+
DN. An administrator who deletes the wrong account cannot undo it, and a
|
|
7
|
+
deleted account's address can be taken by a new one immediately — which, for a
|
|
8
|
+
mail system, means the new account receives the old one's mail. The directory
|
|
9
|
+
has no undelete of its own.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Two plugins answer a delete at the `ldapdeleterequest` hook, and a deployment
|
|
14
|
+
picks one per branch:
|
|
15
|
+
|
|
16
|
+
- **`core/ldap/trash`** moves the entry, by an atomic `modifyDN`, to a trash
|
|
17
|
+
branch under `--trash-base`, overwriting an earlier entry with the same RDN
|
|
18
|
+
there; unless `--trash-add-metadata=false`, it then replaces `description`
|
|
19
|
+
with the deletion date and the original DN, a failure there being logged
|
|
20
|
+
and not fatal. The native delete is cancelled. `--trash-watched-bases`
|
|
21
|
+
selects the branches; an empty list watches every branch.
|
|
22
|
+
- **`core/twake/tombstone`** keeps the entry in place: one modify sets the
|
|
23
|
+
deleted attribute and the lock, the deletion date and the reason when their
|
|
24
|
+
attributes are configured, and removes the attributes the deployment names.
|
|
25
|
+
The identifier is reserved until an operator erases the tombstone, once the
|
|
26
|
+
deletion is old enough or immediately when forced. Erasing removes the entry
|
|
27
|
+
and its group memberships and announces nothing.
|
|
28
|
+
|
|
29
|
+
The tombstone moves itself last in the `ldapdeleterequest` and
|
|
30
|
+
`ldapaddrequest` chains (`hookBus.toLast`), so every plugin that may refuse
|
|
31
|
+
has judged. The trash does not: it runs in load order, after the authorization
|
|
32
|
+
judges but possibly before another plugin that can refuse a delete
|
|
33
|
+
(`organizations`, `enterpriseRules`, `james`). A plugin that refuses a delete
|
|
34
|
+
and loads after the trash refuses one that has already been moved. The
|
|
35
|
+
tombstone is written by the server, not as the caller: the caller's delete
|
|
36
|
+
right is what was judged.
|
|
37
|
+
|
|
38
|
+
SCIM treats a tombstone as gone: its reads carry a not-deleted filter, so
|
|
39
|
+
reads answer 404 and lists omit it; a create with the same identity erases
|
|
40
|
+
the tombstone and creates a new entry. A non-SCIM delete of an existing
|
|
41
|
+
tombstone writes nothing and fires `twakedeletionreplay`, so lifecycle events
|
|
42
|
+
can replay a lost deletion. A SCIM DELETE never gets there: its existence
|
|
43
|
+
check hides the tombstone and answers 404.
|
|
44
|
+
|
|
45
|
+
The server refuses to start when the tombstone is loaded beside the trash and
|
|
46
|
+
any watched branch overlaps a `--twake-tombstone-dn` pattern: the trash would
|
|
47
|
+
move the entry before the tombstone saw the delete, and no tombstone would be
|
|
48
|
+
written. An empty `--trash-watched-bases` watches every branch, so it overlaps
|
|
49
|
+
any pattern and refuses too.
|
|
50
|
+
|
|
51
|
+
## Consequences
|
|
52
|
+
|
|
53
|
+
- A delete is no longer a delete. Code that lists a branch sees what the
|
|
54
|
+
trash or the tombstone left there, filtered by the plugin that owns the
|
|
55
|
+
branch.
|
|
56
|
+
- Trash and tombstone cannot watch the same branch; the startup check names
|
|
57
|
+
the overlap.
|
|
58
|
+
- A deployment that loads the trash must load it before the plugins that can
|
|
59
|
+
refuse a delete, or accept that a refusal can come after the entry moved.
|
|
60
|
+
- A tombstone cannot be restored: clearing its deleted attribute by hand is
|
|
61
|
+
not a supported operation.
|
|
62
|
+
- Trash moves the entry, so its DN changes and references to it are not
|
|
63
|
+
rewritten by the move beyond what the directory's `refint` overlay does.
|
|
64
|
+
- Tombstones are erased by an explicit operator action, so a retention policy
|
|
65
|
+
has to run it.
|
|
66
|
+
|
|
67
|
+
See [tombstone](../../usage/plugins/integrations/tombstone.md), the plugin
|
|
68
|
+
list in [plugins](../../usage/plugins/README.md), and
|
|
69
|
+
`src/plugins/ldap/trash.ts`, `src/plugins/twake/tombstone.ts`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 0008 — Server views for plugins with overrides
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
The same plugin module can be loaded several times with different settings:
|
|
6
|
+
`--plugin 'core/auth/token:admins:{…}'` and
|
|
7
|
+
`--plugin 'core/auth/token:users:{…}'`. The plugin constructor receives the
|
|
8
|
+
server, and reads its options from `this.server.config` and `this.config`.
|
|
9
|
+
Those instances must see their own configuration while acting on the one
|
|
10
|
+
server: the same Express app, the same hooks, the same LDAP connection and
|
|
11
|
+
the same plugin registry. A second `DM` would be a second server.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
`DM.withConfig(config)` returns a view of the server built with
|
|
16
|
+
`Object.create(Object.getPrototypeOf(this))` and `Object.assign(…, this,
|
|
17
|
+
{ config })`. The own properties that hold objects — the app, the hooks, the
|
|
18
|
+
LDAP actions, the plugin registry, the authenticators — are shared by
|
|
19
|
+
reference. The configuration is `{ ...this.config, ...overrides }`, a shallow
|
|
20
|
+
copy: overridden top-level keys are the plugin's alone, nested objects and
|
|
21
|
+
arrays are still shared with the server. The loader constructs a plugin with
|
|
22
|
+
overrides on such a view.
|
|
23
|
+
|
|
24
|
+
A primitive field would be copied by value into every view, so state the views
|
|
25
|
+
must share lives in an object: the operation counter (`_operations`, read
|
|
26
|
+
through the `operationSequence` accessor) and the composition flag
|
|
27
|
+
(`_compositionState`), as the dispatcher's own state does (`_authState`). A
|
|
28
|
+
view created while the plugins load therefore sees the composition once the
|
|
29
|
+
server has checked it.
|
|
30
|
+
|
|
31
|
+
The copy is built on the prototype rather than by spreading `{...this}`,
|
|
32
|
+
which copies own properties and leaves the methods behind: a plugin calling
|
|
33
|
+
`this.server.anything()` would throw, and only when configured with
|
|
34
|
+
overrides.
|
|
35
|
+
|
|
36
|
+
## Consequences
|
|
37
|
+
|
|
38
|
+
- A plugin loaded with overrides cannot change the server's configuration for
|
|
39
|
+
anyone else; mutating `this.config` affects its view alone, at the top
|
|
40
|
+
level.
|
|
41
|
+
- Primitive fields are copied by value, not shared: what must be shared is
|
|
42
|
+
held in an object, as the operation counter and the composition flag are.
|
|
43
|
+
A plugin registered after `ready` through a view created while loading is
|
|
44
|
+
therefore checked against the composition like any other (before [#625] and
|
|
45
|
+
[#626], the counter and the flag were primitives, and neither held).
|
|
46
|
+
- An own property added to the server after the view was created is not
|
|
47
|
+
visible to that view. State that must be shared has to live in an object
|
|
48
|
+
that already exists when the view is made.
|
|
49
|
+
- A plugin that closes over `this.server` keeps the view for its lifetime,
|
|
50
|
+
which is the point: its options stay its own.
|
|
51
|
+
- Tests may call `withConfig` to exercise option-dependent behaviour without
|
|
52
|
+
building a second server.
|
|
53
|
+
|
|
54
|
+
See `src/bin/index.ts` (`withConfig`, `importPlugin`) and
|
|
55
|
+
[plugin options](../../plugin-development/options.md).
|
|
56
|
+
|
|
57
|
+
[#625]: https://github.com/linagora/ldap-rest/issues/625
|
|
58
|
+
[#626]: https://github.com/linagora/ldap-rest/issues/626
|
|
@@ -1365,6 +1365,10 @@ const encoded = encodeURIComponent(dn);
|
|
|
1365
1365
|
// Result: uid%3Djohn.doe%2Cou%3Dusers%2Cdc%3Dexample%2Cdc%3Dcom
|
|
1366
1366
|
```
|
|
1367
1367
|
|
|
1368
|
+
A path parameter is percent-decoded once, as RFC 3986 spells it: an
|
|
1369
|
+
identifier holding `%` is sent as `%25`, so `100%` is addressed as
|
|
1370
|
+
`/api/v1/ldap/users/100%25`.
|
|
1371
|
+
|
|
1368
1372
|
### LDAP Attributes are Arrays
|
|
1369
1373
|
|
|
1370
1374
|
LDAP attributes are always returned as arrays, even for single-valued attributes:
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Published contracts
|
|
2
|
+
|
|
3
|
+
Other code depends on three things ldap-rest publishes. A change to one that
|
|
4
|
+
is not additive breaks that code, so it needs a major release and the pull
|
|
5
|
+
request says so before it merges.
|
|
6
|
+
|
|
7
|
+
- **Configuration**: the CLI options and `DM_*` variables of
|
|
8
|
+
[Configuration](../usage/configuration.md) and of each plugin's page.
|
|
9
|
+
- **REST API**: the routes, request bodies and answers of the
|
|
10
|
+
[REST API](../client-development/api/rest-api.md), their parameters,
|
|
11
|
+
responses (references resolved), statuses and response headers. The error
|
|
12
|
+
`code` values are reviewed by hand: the document types them as strings.
|
|
13
|
+
- **Plugin API**: what the npm sub-path exports declare — `ldap-rest`,
|
|
14
|
+
`./hooks`, `./plugin`, `./ldapactions`, `./expressformatedresponses`, the
|
|
15
|
+
`./plugin-*`, `./abstract-*` and `./browser-*` entries — and the JSON
|
|
16
|
+
schema format of the `./schema-*` entries.
|
|
17
|
+
|
|
18
|
+
[API stability](../README.md#api-stability) says what a minor or patch
|
|
19
|
+
release promises. This page says how a change is detected, and what to do
|
|
20
|
+
when it is not additive.
|
|
21
|
+
|
|
22
|
+
## Committed snapshots
|
|
23
|
+
|
|
24
|
+
Two snapshots are generated locally and checked in CI, so a change to a
|
|
25
|
+
contract shows up as a diff instead of a surprise after the release:
|
|
26
|
+
|
|
27
|
+
- `docs/usage/options.snapshot.json` lists every option's CLI name, plural
|
|
28
|
+
form, environment variable, type, default, kind, required flag and aliases.
|
|
29
|
+
Regenerate it with `npm run snapshot:options`; `npm run check:options`, part
|
|
30
|
+
of `npm run check`, fails when it is stale.
|
|
31
|
+
- `openapi.json` is the REST API document the published reference is built
|
|
32
|
+
from. Regenerate it with `npm run generate:openapi`; `npm run check:openapi`
|
|
33
|
+
fails when it is stale.
|
|
34
|
+
- `etc/api/*.api.md` are the API Extractor reports of the five entry points
|
|
35
|
+
#595 covers first — `ldap-rest`, `./plugin`, `./hooks`, `./ldapactions` and
|
|
36
|
+
`./expressformatedresponses` — one file per sub-path. Regenerate them with
|
|
37
|
+
`npm run snapshot:api`; `npm run check:api`, part of `npm run check`, fails
|
|
38
|
+
when one is stale. A report lists what the entry exports and the shapes of
|
|
39
|
+
the types those exports reference, including the types the entry does not
|
|
40
|
+
export itself, so a change to `RouteDefinition` or `OptionSet` shows up even
|
|
41
|
+
though a plugin cannot name them. The 66 `./plugin-*` entries come with the
|
|
42
|
+
modularization, see [the plan](modularization-plan.md).
|
|
43
|
+
|
|
44
|
+
The snapshot records what the parser reads. A narrowed `choices` list, a
|
|
45
|
+
raised `min` or a lowered `max` is checked when a plugin reads its options,
|
|
46
|
+
not by the snapshot, and is reviewed by hand, as is a custom error `code`.
|
|
47
|
+
|
|
48
|
+
## The pull request gate
|
|
49
|
+
|
|
50
|
+
The `contracts` job of `.github/workflows/contracts.yml` generates the
|
|
51
|
+
snapshots from the pull request and compares them with the base branch's
|
|
52
|
+
committed ones. It reports:
|
|
53
|
+
|
|
54
|
+
- additive changes — a new option, route or field — and lets them through;
|
|
55
|
+
- non-additive changes, and fails unless the pull request carries the
|
|
56
|
+
`breaking` label.
|
|
57
|
+
|
|
58
|
+
A change is non-additive when it removes or narrows what was published:
|
|
59
|
+
a removed option or environment variable, a changed type, default, bound,
|
|
60
|
+
`const`, enum or required flag, a request value that becomes an enum, a
|
|
61
|
+
request object that stops accepting additional properties, a removed route,
|
|
62
|
+
method, parameter, response status, response header or field, a request field
|
|
63
|
+
that becomes required, a response that may now be null, changed security
|
|
64
|
+
requirements, a removed union variant, a value an alternative of the type
|
|
65
|
+
closes with an enum or a `const` where the type used to accept any, an
|
|
66
|
+
untyped request value that becomes a union, an `allOf` that adds a
|
|
67
|
+
constraint, or a removed declaration in `etc/api/` that a plugin can name —
|
|
68
|
+
the exported one, or a member of an exported namespace. Renaming a type only
|
|
69
|
+
reachable through a signature is not a break: nobody can write its name, and
|
|
70
|
+
the declarations that use it are reported as changed. A changed declaration is
|
|
71
|
+
printed for review and does not fail the job yet: telling an added optional
|
|
72
|
+
member from a narrowed union needs more than a line diff, and comes with the
|
|
73
|
+
`./plugin-*` reports. Renaming an option is a deprecation, not a removal, as
|
|
74
|
+
long as the old CLI name and environment variable stay in its `aliases` and
|
|
75
|
+
`envAliases`. Adding a union variant, a field or an optional parameter is
|
|
76
|
+
additive, and so is a request union with an alternative that still accepts
|
|
77
|
+
everything the base did — in a response the client reads every alternative,
|
|
78
|
+
so a field the others drop is still a removal. A `oneOf` is the exception:
|
|
79
|
+
it refuses a value two alternatives match, so an alternative as wide as the
|
|
80
|
+
base narrows the others away, and the alternatives are not told apart from
|
|
81
|
+
one another — a disjoint `oneOf` therefore asks for the label too.
|
|
82
|
+
Documentation-only edits, such as a summary or description, are ignored.
|
|
83
|
+
|
|
84
|
+
The job runs the scripts from the pull request, so a change to them is
|
|
85
|
+
reviewed like any other code, as `.github/CODEOWNERS` requires.
|
|
86
|
+
|
|
87
|
+
The `breaking` label records that the maintainers accepted the break, and the
|
|
88
|
+
`CHANGELOG.md` section must say so: the release gate below reads the section,
|
|
89
|
+
not the label, and refuses to ship the break as a minor or patch release. The
|
|
90
|
+
workflow listens to label events, so adding the label runs the job again; it
|
|
91
|
+
has its own concurrency group, so that event does not cancel the test matrix.
|
|
92
|
+
|
|
93
|
+
To try the comparison locally against your base branch:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx tsx scripts/contracts.ts --base origin/master
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## The release gate
|
|
100
|
+
|
|
101
|
+
`.github/workflows/release.yml` runs `scripts/check-release.ts` on the tag. A
|
|
102
|
+
non-major tag whose `CHANGELOG.md` section lists `### Breaking Changes` is
|
|
103
|
+
refused. For a 0.x version, a minor release counts as major, as semantic
|
|
104
|
+
versioning lets it break. When the tag has no section of its own, the gate
|
|
105
|
+
reads `## Unreleased`, so a tag cut before the section was renamed is still
|
|
106
|
+
checked; with neither section it fails. When the CHANGELOG lists no earlier
|
|
107
|
+
release, only a first `X.0.0` counts as major, so a trimmed file cannot open
|
|
108
|
+
the gate.
|
|
109
|
+
|
|
110
|
+
## Deprecating instead of breaking
|
|
111
|
+
|
|
112
|
+
The window and the rules are in
|
|
113
|
+
[CONTRIBUTING.md](../../CONTRIBUTING.md#deprecating-a-published-contract).
|
|
114
|
+
The mechanics of an option alias are in
|
|
115
|
+
[plugin options](../plugin-development/options.md#deprecating-an-option): the
|
|
116
|
+
old CLI name and environment variable go in `aliases` and `envAliases`, and
|
|
117
|
+
the server warns at startup while it still reads them. An array option's
|
|
118
|
+
plural form has no alias slot: renaming it is a breaking change, so it waits
|
|
119
|
+
for a major release.
|