ldap-rest 0.8.2 → 0.9.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 (79) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/bin/index.js +1 -1
  3. package/dist/config/args.js +1 -1
  4. package/dist/lib/auth/base.js +1 -1
  5. package/dist/lib/authz/base.js +1 -1
  6. package/dist/lib/authz/composition.js +1 -0
  7. package/dist/lib/expressFormatedResponses.js +1 -1
  8. package/dist/lib/ldapActions.js +1 -1
  9. package/dist/lib/ldapCodes.js +1 -0
  10. package/dist/lib/parseConfig.js +1 -1
  11. package/dist/lib/storage/store.js +1 -0
  12. package/dist/lib/utils.js +1 -1
  13. package/dist/plugins/auth/authzDynamic.js +1 -1
  14. package/dist/plugins/auth/authzPerBranch.js +1 -1
  15. package/dist/plugins/auth/authzPerRoute.js +1 -1
  16. package/dist/plugins/auth/authzScope.js +1 -1
  17. package/dist/plugins/auth/hmac.js +1 -1
  18. package/dist/plugins/auth/llng.js +1 -1
  19. package/dist/plugins/auth/openidconnect.js +1 -1
  20. package/dist/plugins/auth/token.js +1 -1
  21. package/dist/plugins/auth/totp.js +1 -1
  22. package/dist/plugins/bcl.js +1 -0
  23. package/dist/plugins/ldap/organizations.js +1 -1
  24. package/dist/plugins/priority.json.js +1 -1
  25. package/dist/plugins/scim/baseResolver.js +1 -1
  26. package/dist/plugins/scim/errors.js +1 -1
  27. package/dist/plugins/scim/groups.js +1 -1
  28. package/dist/plugins/scim/list.js +1 -1
  29. package/dist/plugins/scim/users.js +1 -1
  30. package/dist/plugins/storage/file.js +1 -0
  31. package/dist/plugins/storage/ldap.js +1 -0
  32. package/dist/plugins/storage.js +1 -0
  33. package/dist/src/abstract/ldapFlat.d.ts +7 -0
  34. package/dist/src/abstract/plugin.d.ts +20 -0
  35. package/dist/src/bin/index.d.ts +50 -8
  36. package/dist/src/config/args.d.ts +36 -0
  37. package/dist/src/hooks.d.ts +55 -0
  38. package/dist/src/lib/auth/base.d.ts +175 -9
  39. package/dist/src/lib/authz/base.d.ts +133 -4
  40. package/dist/src/lib/authz/composition.d.ts +145 -0
  41. package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
  42. package/dist/src/lib/ldapActions.d.ts +30 -0
  43. package/dist/src/lib/ldapCodes.d.ts +28 -0
  44. package/dist/src/lib/storage/store.d.ts +84 -0
  45. package/dist/src/lib/utils.d.ts +19 -0
  46. package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
  47. package/dist/src/plugins/auth/authzPerBranch.d.ts +11 -0
  48. package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
  49. package/dist/src/plugins/auth/authzScope.d.ts +44 -3
  50. package/dist/src/plugins/auth/hmac.d.ts +2 -0
  51. package/dist/src/plugins/auth/llng.d.ts +39 -0
  52. package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
  53. package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
  54. package/dist/src/plugins/auth/token.d.ts +2 -0
  55. package/dist/src/plugins/auth/totp.d.ts +2 -0
  56. package/dist/src/plugins/bcl.d.ts +65 -0
  57. package/dist/src/plugins/ldap/organizations.d.ts +90 -1
  58. package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
  59. package/dist/src/plugins/scim/errors.d.ts +2 -10
  60. package/dist/src/plugins/storage/file.d.ts +19 -0
  61. package/dist/src/plugins/storage/ldap.d.ts +17 -0
  62. package/dist/src/plugins/storage.d.ts +44 -0
  63. package/docs/plugin-development/README.md +12 -1
  64. package/docs/plugin-development/dependencies.md +40 -15
  65. package/docs/usage/configuration.md +89 -13
  66. package/docs/usage/plugins/README.md +17 -12
  67. package/docs/usage/plugins/auth/README.md +129 -0
  68. package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
  69. package/docs/usage/plugins/auth/authz-linid1.md +32 -0
  70. package/docs/usage/plugins/auth/authz-per-branch.md +8 -0
  71. package/docs/usage/plugins/auth/authz-per-route.md +6 -0
  72. package/docs/usage/plugins/auth/authz-scope.md +69 -16
  73. package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
  74. package/docs/usage/plugins/auth/llng.md +29 -0
  75. package/docs/usage/plugins/auth/oidc.md +52 -0
  76. package/docs/usage/plugins/ldap/organizations.md +37 -0
  77. package/docs/usage/plugins/utilities/storage.md +94 -0
  78. package/docs/usage/upgrading.md +289 -0
  79. package/package.json +17 -1
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import type { Request, Response, NextFunction, RequestHandler } from 'express';
8
8
  import { HttpError } from '../../lib/errors';
9
+ import { extractLdapCode } from '../../lib/ldapCodes';
10
+ export { extractLdapCode };
9
11
  import { type ScimErrorResponse, type ScimErrorType } from './types';
10
12
  export declare const SCIM_CONTENT_TYPE = "application/scim+json";
11
13
  export declare class ScimError extends HttpError {
@@ -22,16 +24,6 @@ export declare const scimUniqueness: (detail?: string) => ScimError;
22
24
  export declare const scimTooMany: (detail?: string) => ScimError;
23
25
  export declare const scimNotFound: (detail?: string) => ScimError;
24
26
  export declare function writeScimError(res: Response, status: number, detail: string, scimType?: ScimErrorType): void;
25
- /**
26
- * Extract an LDAP numeric error code from a thrown ldapts error.
27
- *
28
- * `.code` first: ldapts sets it, and `ldapActions` now carries it across its
29
- * own wrapping rather than reducing the failure to a sentence. The message
30
- * patterns below stay as a fallback for the paths that still throw a bare
31
- * `Error` — they are why a driver that reworded its messages could quietly
32
- * turn a 400 into a 500, so they are the last resort, not the first.
33
- */
34
- export declare function extractLdapCode(err: unknown): number | undefined;
35
27
  /**
36
28
  * Turn any thrown thing into the SCIM error body it deserves.
37
29
  *
@@ -0,0 +1,19 @@
1
+ import type winston from 'winston';
2
+ import Store, { type StoredRecord } from '../../lib/storage/store';
3
+ export default class FileStore extends Store {
4
+ name: string;
5
+ private dir;
6
+ /** How long a temporary file must sit before it counts as abandoned. */
7
+ private abandonedAfter;
8
+ constructor(logger: winston.Logger, dir: string, abandonedAfterSeconds: number);
9
+ /** The directory is created on demand, readable by this user alone. */
10
+ ready(): Promise<void>;
11
+ private path;
12
+ /** `<deadline> <key>\n<value>` — the deadline first, so a short read still has it. */
13
+ private static encode;
14
+ private static decode;
15
+ protected load(key: string): Promise<StoredRecord | null>;
16
+ protected save(key: string, record: StoredRecord): Promise<void>;
17
+ protected drop(key: string): Promise<void>;
18
+ sweep(): Promise<number>;
19
+ }
@@ -0,0 +1,17 @@
1
+ import Store, { type StoredRecord } from '../../lib/storage/store';
2
+ import type { DM } from '../../bin';
3
+ export default class LdapStore extends Store {
4
+ name: string;
5
+ private server;
6
+ private base;
7
+ private objectClass;
8
+ constructor(server: DM, base: string, objectClass: string);
9
+ private dn;
10
+ /** `<deadline> <key>\n<value>`, the deadline first as in the file store. */
11
+ private static encode;
12
+ private static decode;
13
+ protected load(key: string): Promise<StoredRecord | null>;
14
+ protected save(key: string, record: StoredRecord): Promise<void>;
15
+ protected drop(key: string): Promise<void>;
16
+ sweep(): Promise<number>;
17
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @module plugins/storage
3
+ *
4
+ * Keyed storage, for whoever needs to keep something with a deadline.
5
+ *
6
+ * One plugin, one backend, chosen by `--storage-backend`. Consumers ask this
7
+ * one for its store and work through a single interface; where the records
8
+ * actually live is a deployment decision, not theirs.
9
+ *
10
+ * Having exactly one is the point. The alternative — each consumer loading
11
+ * the backend it likes — puts the same record in two places, and makes
12
+ * whichever plugin was registered first decide what happens when one of them
13
+ * is unwell. Registration order is invisible in a configuration, which is a
14
+ * poor place to keep a behaviour.
15
+ *
16
+ * A consumer reaches it with `requirePlugin('storage')` and works through
17
+ * `store`; it should not index `loadedPlugins` itself. And one that makes a
18
+ * security decision must not read "no storage" as "nothing to check":
19
+ * `requirePlugin` answers null and warns once, which is right for an optional
20
+ * feature and wrong for, say, a logout check — such a consumer refuses to
21
+ * load instead.
22
+ *
23
+ * What a consumer must not expect of this layer: it does not decide how long
24
+ * anything lives. A deadline comes in with the record, because retention is
25
+ * the policy of whoever wrote it — the moment a backend decides for itself,
26
+ * two of them can disagree about when the same record died.
27
+ *
28
+ * @group Plugins
29
+ */
30
+ import DmPlugin from '../abstract/plugin';
31
+ import type { DM } from '../bin';
32
+ import type Store from '../lib/storage/store';
33
+ export default class Storage extends DmPlugin {
34
+ name: string;
35
+ store: Store;
36
+ constructor(server: DM);
37
+ /**
38
+ * Release the single-instance hold.
39
+ *
40
+ * For a test building several servers in one process; a running one has no
41
+ * reason to call it.
42
+ */
43
+ static release(instance: Storage): void;
44
+ }
@@ -475,7 +475,18 @@ The `this.server.ldap` object provides access to all LDAP operations:
475
475
  > `forRequest()` returns a `RequestBoundLdap`, whose methods have no request
476
476
  > parameter at all — the omission stops being expressible. Use the unbound
477
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.
478
+ > or scheduled tasks, where passing nothing is the deliberate answer — and
479
+ > say so by reaching for them through `ldap.system`:
480
+ >
481
+ > ```typescript
482
+ > await this.server.ldap.system.search({ scope: 'base' }, dn);
483
+ > ```
484
+ >
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.
479
490
 
480
491
  #### Search
481
492
 
@@ -76,6 +76,16 @@ dependencies = {
76
76
  - **Consumes Hooks**: None
77
77
  - **Notes**: LinID 1.x authorization integration
78
78
 
79
+ #### `bcl` (core/bcl)
80
+
81
+ - **Dependencies**: `core/storage` — **required**: a logout check with
82
+ nowhere to record what died would leave every session outliving the logout
83
+ that closed it, so the plugin refuses to start
84
+ - **Provides Hooks**: None
85
+ - **Consumes Hooks**: `oidclogouttoken`, `oidclogin`, `oidcsessionvalid`
86
+ - **Notes**: Back-Channel Logout. Records only the sessions a provider
87
+ declared dead; needs `core/auth/openidconnect` to raise those hooks
88
+
79
89
  ### LDAP Core Plugins
80
90
 
81
91
  #### `onLdapChange` (core/ldap/onChange)
@@ -238,6 +248,15 @@ dependencies = {
238
248
  - **Consumes Hooks**: None
239
249
  - **Notes**: HTTP request logging
240
250
 
251
+ #### `storage` (core/storage)
252
+
253
+ - **Dependencies**: None (the `ldap` backend uses the server's connection)
254
+ - **Provides Hooks**: None
255
+ - **Consumes Hooks**: None
256
+ - **Notes**: Keyed storage with a deadline. Only one instance may be loaded —
257
+ a second one is refused rather than leaving registration order to decide
258
+ which answers
259
+
241
260
  #### `configApi` (core/configApi)
242
261
 
243
262
  - **Dependencies**: None (reads configuration from other loaded plugins)
@@ -531,24 +550,30 @@ graph TB
531
550
  - `static` - Static files
532
551
  - `weblogs` - Logging
533
552
  - `configApi` - Configuration API
553
+ - `storage` - Keyed storage. Written anywhere in the configuration, but
554
+ loaded before the others: its consumers have to find it registered, so it
555
+ is in the priority list
534
556
 
535
557
  ## Plugin Priority System
536
558
 
537
- LDAP-Rest uses a priority system defined in `src/plugins/priority.json`:
538
-
539
- ```json
540
- [
541
- "core/auth/crowdsec",
542
- "core/auth/rateLimit",
543
- "core/auth/token",
544
- "core/auth/llng",
545
- "core/auth/openidconnect",
546
- "core/auth/authzPerBranch",
547
- "core/auth/authzLinid1"
548
- ]
549
- ```
550
-
551
- Plugins in this list load before others, ensuring security plugins run first.
559
+ LDAP-Rest uses a priority system defined in
560
+ [`src/plugins/priority.json`](../../src/plugins/priority.json), which is the
561
+ list itself — copying it here is how this section came to name two plugins
562
+ the file does not and to omit four it does.
563
+
564
+ Plugins in that list are loaded one at a time, in its order, before every
565
+ other plugin is loaded in parallel. Two reasons put a plugin there: a
566
+ security plugin whose middleware must sit ahead of the routes it guards, and
567
+ a plugin others depend on, which has to be registered before the batch its
568
+ consumers land in — `core/storage` is there for the second reason.
569
+
570
+ The list matches on the **module**, so a named instance
571
+ (`core/auth/token:authAdmins:{…}`) keeps its rank, and every instance of a
572
+ priority module is loaded in that first pass, in configuration order. It
573
+ used to be matched on the whole `--plugin` string, which sent exactly the
574
+ configurations that carry per-instance options into the parallel batch —
575
+ `core/auth/trustedProxy` named that way registered after the routes it
576
+ guards.
552
577
 
553
578
  ## Hook Execution Order
554
579
 
@@ -25,8 +25,11 @@ All LDAP-Rest configuration options.
25
25
  - [core/auth/hmac](#coreauthhmac)
26
26
  - [core/auth/llng](#coreauthllng)
27
27
  - [core/auth/openidconnect](#coreauthopenidconnect)
28
+ - [core/bcl](#corebcl)
28
29
  - [Authorization Plugins](#authorization-plugins)
30
+ - [Common to every authorization plugin](#common-to-every-authorization-plugin)
29
31
  - [core/auth/authzPerBranch](#coreauthauthzperbranch)
32
+ - [core/auth/authzScope](#coreauthauthzscope)
30
33
  - [core/auth/authzPerRoute](#coreauthauthzperroute)
31
34
  - [core/auth/authzLinid1](#coreauthauthzlinid1)
32
35
  - [Security Plugins](#security-plugins)
@@ -41,6 +44,7 @@ All LDAP-Rest configuration options.
41
44
  - [Utility Plugins](#utility-plugins)
42
45
  - [core/static](#corestatic)
43
46
  - [core/weblogs](#coreweblogs)
47
+ - [core/storage](#corestorage)
44
48
  - [core/configApi](#coreconfigapi)
45
49
  - [Configuration File](#configuration-file)
46
50
  - [LDAP Failover](#ldap-failover)
@@ -251,27 +255,86 @@ Empty means the plugin guards every path. Scoping several instances to different
251
255
 
252
256
  #### `core/auth/llng`
253
257
 
254
- | CLI | Env | Default | Description |
255
- | ------------ | ------------- | ------------------------------------ | ------------------------- |
256
- | `--llng-ini` | `DM_LLNG_INI` | `/etc/lemonldap-ng/lemonldap-ng.ini` | LemonLDAP::NG config path |
258
+ | CLI | Env | Default | Description |
259
+ | ------------------------ | ------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------- |
260
+ | `--llng-ini` | `DM_LLNG_INI` | `/etc/lemonldap-ng/lemonldap-ng.ini` | LemonLDAP::NG config path |
261
+ | `--llng-username-header` | `DM_LLNG_USERNAME_HEADER` | | Header LLNG exports with the login, published as `req.userName` (see [llng](plugins/auth/llng.md)) |
257
262
 
258
263
  #### `core/auth/openidconnect`
259
264
 
260
- | CLI | Env | Default | Description |
261
- | ---------------------- | ----------------------- | ------- | ------------------------ |
262
- | `--oidc-server` | `DM_OIDC_SERVER` | | OIDC server URL |
263
- | `--oidc-client-id` | `DM_OIDC_CLIENT_ID` | | OIDC Client ID |
264
- | `--oidc-client-secret` | `DM_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
265
- | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks |
265
+ | CLI | Env | Default | Description |
266
+ | ----------------------- | ------------------------ | ------- | --------------------------------------------------------------------------------------- |
267
+ | `--oidc-server` | `DM_OIDC_SERVER` | | OIDC server URL |
268
+ | `--oidc-client-id` | `DM_OIDC_CLIENT_ID` | | OIDC Client ID |
269
+ | `--oidc-client-secret` | `DM_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
270
+ | `--oidc-username-claim` | `DM_OIDC_USERNAME_CLAIM` | `sub` | Claim naming the caller, published as `req.userName` (see [OIDC](plugins/auth/oidc.md)) |
271
+ | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks |
272
+
273
+ #### `core/bcl`
274
+
275
+ Back-Channel Logout: a logout performed at the provider ends the session here
276
+ on the next request. Needs `core/storage`, and refuses to start without it —
277
+ see [Back-Channel Logout](plugins/auth/back-channel-logout.md).
278
+
279
+ | CLI | Env | Default | Description |
280
+ | ----------------- | ------------------ | -------- | --------------------------- |
281
+ | `--bcl-retention` | `DM_BCL_RETENTION` | `604800` | Seconds a tombstone is kept |
266
282
 
267
283
  ### Authorization Plugins
268
284
 
285
+ #### Common to every authorization plugin
286
+
287
+ | CLI | Env | Default | Description |
288
+ | ------------------------- | -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
289
+ | `--authz-unresolved-user` | `DM_AUTHZ_UNRESOLVED_USER` | `deny` | What an authenticated identity the plugin cannot resolve means: `deny` refuses the operation, `allow` lets it through with a warning |
290
+ | `--authz-identity` | `DM_AUTHZ_IDENTITY` | `req.user` | Which value rules are keyed on: `req.user`, this server's identifier for the caller, or `req.userName`, the caller under a name a person would use. See [what a rule is keyed on](plugins/auth/README.md#what-a-rule-is-keyed-on) |
291
+ | `--authz-for` | `DM_AUTHZ_FOR` | | The authentication plugins, by instance name, whose requests the plugin judges; unset, every authenticated request. Meant for a plugin's own overrides: `{"authz_for":["oidc"]}` |
292
+ | `--authz-combine` | `DM_AUTHZ_COMBINE` | `false` | Let two plugins judging the LDAP operations of the same requests start together, as an AND |
293
+
294
+ `--authz-unresolved-user` is read by the plugins that resolve an identity
295
+ before judging it — `core/auth/authzPerBranch` and `core/auth/authzLinid1`.
296
+ `core/auth/authzPerRoute` matches the identity as it stands and
297
+ `core/auth/authzDynamic` judges a token, so neither resolves anything and
298
+ neither reads it. A value other than `deny` or `allow` is refused at startup.
299
+
300
+ An **anonymous** request is not this case: it carries no identity, and every
301
+ authorization plugin skips it as before. This is about a caller the
302
+ authenticator admitted whose identity the authorization model cannot place —
303
+ `authzLinid1` looking up a `uid` that does not exist, typically because the
304
+ authenticator publishes something else. `allow` is the behaviour every plugin
305
+ had before 0.8.3, and it is an open door where `authzLinid1` is loaded: see
306
+ [the note](upgrading.md#an-identity-that-does-not-resolve-is-refused).
307
+
308
+ `--authz-for` and `--authz-combine` say how plugins loaded together compose:
309
+ without them, two plugins judging the LDAP operations of the same requests
310
+ are refused at startup — see [several authorization
311
+ plugins](plugins/auth/README.md#several-authorization-plugins).
312
+
313
+ `--authz-for` takes one name per occurrence: repeat it
314
+ (`--authz-for oidc --authz-for authToken`), or give the environment variable
315
+ a list (`DM_AUTHZ_FOR="oidc authToken"`). A second word after the value —
316
+ `--authz-for oidc authToken` — is refused rather than dropped, as it is after
317
+ every option taking a list.
318
+
319
+ `--authz-identity` is a different question, and the paragraphs above are not
320
+ about it: `core/auth/authzPerRoute` and the SCIM base map read it as well,
321
+ since a route rule and the base a SCIM operation is served from are keyed on
322
+ whichever of the two names it selects. A value other than `req.user` or
323
+ `req.userName` is refused at startup.
324
+
269
325
  #### `core/auth/authzPerBranch`
270
326
 
271
- | CLI | Env | Default | Description |
272
- | ------------------------------ | ------------------------------- | ------------------------------------------------ | --------------------------- |
273
- | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
274
- | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
327
+ | CLI | Env | Default | Description |
328
+ | --------------------------------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
329
+ | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
330
+ | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
331
+ | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch` and `authzLinid1`; does nothing under `authzDynamic`. |
332
+
333
+ #### `core/auth/authzScope`
334
+
335
+ | CLI | Env | Default | Description |
336
+ | ---------------------- | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
337
+ | `--authz-scope-source` | `DM_AUTHZ_SCOPE_SOURCE` | | The plugin, by instance name, that describes a caller's scope when several judge them; unset, the first loaded. See [authz-scope](plugins/auth/authz-scope.md) |
275
338
 
276
339
  #### `core/auth/authzPerRoute`
277
340
 
@@ -384,6 +447,19 @@ Automatically creates/updates/deletes applicative account entries when users are
384
447
 
385
448
  Web access logging plugin. No configuration options - just add the plugin to enable access logs.
386
449
 
450
+ #### `core/storage`
451
+
452
+ Keyed storage with a deadline, for the plugins that need to keep something —
453
+ see [Storage](plugins/utilities/storage.md).
454
+
455
+ | CLI | Env | Default | Description |
456
+ | ----------------------------- | ------------------------------ | -------------------- | -------------------------------------- |
457
+ | `--storage-backend` | `DM_STORAGE_BACKEND` | | `ldap` or `file`; required |
458
+ | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two expiry passes |
459
+ | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | | Branch the `ldap` backend writes to |
460
+ | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes |
461
+ | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | | Directory the `file` backend writes to |
462
+
387
463
  #### `core/configApi`
388
464
 
389
465
  Exposes API configuration at `/api/v1/config` for client applications. Returns available features, schemas, and endpoints. No configuration options.
@@ -21,13 +21,14 @@ LDAP entity management:
21
21
 
22
22
  Secure API access:
23
23
 
24
- | Plugin | Description |
25
- | ---------------------- | ---------------------------------------- |
26
- | [token](auth/token.md) | Bearer token authentication |
27
- | [totp](auth/totp.md) | TOTP authentication (time-based codes) |
28
- | [hmac](auth/hmac.md) | HMAC-SHA256 signing for backend services |
29
- | [llng](auth/llng.md) | LemonLDAP::NG SSO integration |
30
- | [oidc](auth/oidc.md) | OpenID Connect / OAuth 2.0 |
24
+ | Plugin | Description |
25
+ | -------------------------------------------------- | ---------------------------------------------- |
26
+ | [token](auth/token.md) | Bearer token authentication |
27
+ | [totp](auth/totp.md) | TOTP authentication (time-based codes) |
28
+ | [hmac](auth/hmac.md) | HMAC-SHA256 signing for backend services |
29
+ | [llng](auth/llng.md) | LemonLDAP::NG SSO integration |
30
+ | [oidc](auth/oidc.md) | OpenID Connect / OAuth 2.0 |
31
+ | [back-channel-logout](auth/back-channel-logout.md) | Honour a logout performed at the OIDC provider |
31
32
 
32
33
  ## Authorization Plugins
33
34
 
@@ -61,10 +62,11 @@ Connect to external systems:
61
62
 
62
63
  ## Utility Plugins
63
64
 
64
- | Plugin | Description |
65
- | ------------------------------- | ------------------ |
66
- | [static](utilities/static.md) | Static file server |
67
- | [weblogs](utilities/weblogs.md) | HTTP logging |
65
+ | Plugin | Description |
66
+ | ------------------------------- | ----------------------------- |
67
+ | [static](utilities/static.md) | Static file server |
68
+ | [weblogs](utilities/weblogs.md) | HTTP logging |
69
+ | [storage](utilities/storage.md) | Keyed storage with a deadline |
68
70
 
69
71
  ## Plugin Dependencies
70
72
 
@@ -79,10 +81,13 @@ core/ldap/externalUsersInGroups
79
81
 
80
82
  core/auth/authzPerBranch
81
83
  └─ requires: An authentication plugin
84
+
85
+ core/bcl
86
+ └─ requires: core/storage (refuses to start without it)
82
87
  ```
83
88
 
84
89
  See [the complete dependencies matrix](../../plugin-development/dependencies.md).
85
90
 
86
91
  ## Load Order
87
92
 
88
- Authentication plugins are loaded first to secure API endpoints. The order is defined in `src/plugins/priority.json`.
93
+ Authentication plugins are loaded first, so their middleware sits ahead of the routes it guards. The list also carries the plugins other plugins depend on, which have to be registered before the batch their consumers land in — `core/storage` is one. The order is defined in `src/plugins/priority.json`.
@@ -12,6 +12,135 @@ LDAP-Rest provides multiple authentication plugins to secure API access. These p
12
12
  | [LemonLDAP::NG](llng.md) | `core/auth/llng` | Integration with LemonLDAP::NG SSO |
13
13
  | [OpenID Connect](oidc.md) | `core/auth/openidconnect` | OAuth 2.0 / OpenID Connect authentication |
14
14
 
15
+ ## What a rule is keyed on
16
+
17
+ Authorization rules name a caller, and each authenticator has its own idea
18
+ of what a caller is called. Every one of them publishes the caller twice:
19
+
20
+ | Authenticator | `req.user` | `req.userName` |
21
+ | ------------------------- | ----------------------------------- | ---------------------------------------- |
22
+ | `core/auth/token` | the name given in `--auth-token` | the same |
23
+ | `core/auth/totp` | the name given in `--auth-totp` | the same |
24
+ | `core/auth/hmac` | the service name from `--auth-hmac` | the same |
25
+ | `core/auth/llng` | LLNG's `whatToTrace` | `--llng-username-header`, or the same |
26
+ | `core/auth/openidconnect` | the OIDC `sub` | `--oidc-username-claim` (default: `sub`) |
27
+ | `core/auth/authzDynamic` | the token's tenant | the same |
28
+
29
+ `req.user` is this server's identifier for the caller; `req.userName` is the
30
+ caller under a name a person would use. `--authz-identity` says which one is
31
+ keyed on by `core/auth/authzPerBranch`, `core/auth/authzLinid1`,
32
+ `core/auth/authzPerRoute` and the SCIM base map — every plugin that names a
33
+ caller in its configuration:
34
+
35
+ ```bash
36
+ --authz-identity req.user # the default: every existing rule is unchanged
37
+ --authz-identity req.userName # rules written on logins
38
+ ```
39
+
40
+ The second is what makes a rule portable. Under OpenID Connect the `sub` is
41
+ opaque — `auth0|…`, a UUID — so rules keyed on it have to be looked up per
42
+ administrator and rewritten when the provider changes; keyed on
43
+ `preferred_username` they read as the logins they are.
44
+
45
+ A value other than these two is refused at startup. When the option asks for
46
+ `req.userName` and the authenticator published none, permissions are read for
47
+ `req.user` and a warning says so once — a rule that silently stops matching
48
+ is worse than one that refuses.
49
+
50
+ At startup each authenticator logs what it will publish, and each
51
+ authorization plugin whose configured identities none of them can produce
52
+ says so: token, TOTP and HMAC names are in the configuration, and an
53
+ identity provider's claims are not, in which case the line says it cannot be
54
+ checked before a login rather than staying silent.
55
+
56
+ ## Several authorization plugins
57
+
58
+ Every plugin that judges LDAP operations — `core/auth/authzPerBranch`,
59
+ `core/auth/authzLinid1`, `core/auth/authzDynamic` — registers the same hooks,
60
+ and all of them run: loaded together, they compose as an **AND**, and the
61
+ first refusal wins. The per-caller read filter (`ldapsearchfilter`) is one of
62
+ those hooks: two plugins there compose as an intersection, which is the same
63
+ AND. That is sound when it is meant. When it is not, it is a
64
+ refusal nobody can explain: `authzPerBranch` beside `authzDynamic` judges a
65
+ machine token by a branch configuration written for administrators, finds
66
+ no entry for the tenant, and refuses a write the token's own ACLs grant.
67
+
68
+ The AND needs no second plugin to bite. `--authz-per-branch-config` has a
69
+ default — read everywhere, write nowhere — so a lone `authzPerBranch` judges
70
+ **every** authenticated identity, whatever authenticated it: a token that no
71
+ rule names reads, and every write it attempts is refused.
72
+
73
+ ### Whose requests a plugin judges
74
+
75
+ The dispatcher records which authentication plugins vouched for a request,
76
+ by instance name, in `req.authenticators`. An authorization plugin given
77
+ `authz_for` judges the requests of those plugins and skips the others:
78
+
79
+ ```bash
80
+ --plugin core/auth/authzDynamic
81
+ --plugin 'core/auth/openidconnect:oidc:{"auth_path_prefix":"/api/admin"}'
82
+ --plugin 'core/auth/authzPerBranch:authzPerBranch:{"authz_for":["oidc"]}'
83
+ ```
84
+
85
+ Here the tokens are held to their own ACLs, and the administrators signed in
86
+ through OpenID Connect to the branch configuration: no request is judged by
87
+ both. Unset, `authz_for` means every authenticated request — what every
88
+ plugin did before the option existed. It is read by `authzPerBranch`,
89
+ `authzLinid1` and `authzPerRoute`; `authzDynamic` judges the requests
90
+ carrying its own tokens, and no others, whatever the option says — an
91
+ `authz_for` of its own there is said at startup rather than ignored in
92
+ silence.
93
+
94
+ Two cases stay apart:
95
+
96
+ - a request **another** population's authenticator vouched for is skipped —
97
+ it is not this model's business;
98
+ - a request of **this** population whose identity does not resolve is
99
+ refused — a configuration error, not an absence of one
100
+ (`--authz-unresolved-user`).
101
+
102
+ A request carrying an identity that no authenticator vouched for is judged:
103
+ "not mine" is never inferred from an absence. A plugin that only stepped
104
+ aside — `authzDynamic` letting a request through on
105
+ `--authz-dynamic-bypass` — did not vouch for it and is not recorded.
106
+
107
+ ### What is refused at startup
108
+
109
+ - Two plugins judging LDAP operations for the same authenticator's requests
110
+ — two branch-level plugins with no `authz_for`, or a branch-level plugin
111
+ judging `authzDynamic`'s tokens — are refused, with both names, unless
112
+ `--authz-combine` says the AND is meant. `authzPerRoute` registers no LDAP
113
+ hook and is never part of it: route plus branch, and route plus token, are
114
+ deliberate ANDs on identities each side can name.
115
+ - An `authz_for` naming no loaded authentication plugin is refused: the
116
+ plugin would judge nobody, which reads as a working configuration. A
117
+ server-wide `--authz-for` that no loaded plugin reads — only
118
+ `authzDynamic` there — is said: it scopes nothing.
119
+ - Two plugins whose populations only meet through two authenticators on the
120
+ same path prefix — a request presenting both credentials is judged by
121
+ both — are said, not refused: asking for two credentials is a decision
122
+ already.
123
+
124
+ A host that assembles the server itself — `registerPlugin` after `ready`,
125
+ rather than `--plugin` — gets the same checks, as each plugin arrives: the
126
+ refusal comes out of `registerPlugin`, before any of the plugin's routes or
127
+ hooks exist. An arriving plugin is judged against what is already there, so
128
+ the authentication plugins an `authz_for` or `--authz-scope-source` names
129
+ are registered before the plugin naming them.
130
+
131
+ ### What the log says
132
+
133
+ At startup, one line per plugin names the hooks it takes part in and whose
134
+ requests it judges — `every authenticated request (authToken, oidc)` when
135
+ `authz_for` is unset, so a model judging an authenticator nobody wrote it
136
+ for is visible before a request arrives. When a hook refuses, the log line
137
+ names the plugin that refused; the 403 body does not, as it never discloses
138
+ the model.
139
+
140
+ With two plugins combined on purpose, `--authz-filter-attached-entries` only
141
+ broadens a listing while both honour it — both do, being built on the same
142
+ base class, but nothing obliges a third implementation to.
143
+
15
144
  ## Authorization Plugins
16
145
 
17
146
  | Method | Plugin | Description |
@@ -44,6 +44,44 @@ node lib/bin/index.js \
44
44
  | `--authz-dynamic-config-attribute` | `DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE` | `description` | Attribute holding the JSON ACL document |
45
45
  | `--authz-dynamic-tenant-attribute` | `DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE` | `cn` | Attribute from which `req.user` is read |
46
46
  | `--authz-dynamic-reload-endpoint` | `DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT` | `false` | Register `POST /api/v1/authz-dynamic/reload` for manual cache refresh |
47
+ | `--authz-dynamic-bypass` | `DM_AUTHZ_DYNAMIC_BYPASS` | _(empty)_ | Who may reach the directory without one of these tokens (see below) |
48
+
49
+ ### Loading this plugin beside another authenticator
50
+
51
+ The dispatcher runs every authentication plugin claiming a request's path,
52
+ so a request can arrive here already identified by another one. Until 0.9.0
53
+ this plugin stepped aside whenever that happened, and the token ACLs were
54
+ then applied to nothing: with `core/auth/token` loaded beside it, a static
55
+ token reached the whole directory as an unscoped administrator — or was
56
+ refused for lacking a dynamic token, depending on which plugin had been
57
+ registered first.
58
+
59
+ The ACLs now apply to whatever token the request carries, whoever else
60
+ identified it, and anything allowed past them without a token is named:
61
+
62
+ | `--authz-dynamic-bypass` | Effect |
63
+ | ------------------------ | --------------------------------------------------------------------------------------------------- |
64
+ | _(empty, the default)_ | Nobody. A request must carry one of these tokens |
65
+ | `any-authenticated` | Anything another plugin authenticated — the behaviour before 0.9.0 |
66
+ | `trusted-proxy` | A request from a trusted proxy **that named a caller** (`req.trustedProxy` and `req.proxyAuthUser`) |
67
+ | any other value | The identity another authenticator publishes in `req.user`, e.g. a name from `--auth-token` |
68
+
69
+ Values combine: `--authz-dynamic-bypass trusted-proxy --authz-dynamic-bypass ops-admin`.
70
+
71
+ `trusted-proxy` requires the proxy to have named someone. `trustedProxy`
72
+ marks the _address_ it trusts and fills `proxyAuthUser` only when the
73
+ request carried the authentication header it expects, so accepting the mark
74
+ alone would hand the whole directory to every host of the `--trusted-proxy`
75
+ range — `10.0.0.0/8` being all of it.
76
+
77
+ A bypassed request carries **no** token, so no ACL is enforced on it — that
78
+ is what a bypass is. Each one is logged at `info` with the reason that let it
79
+ through, and a configuration loading this plugin beside another
80
+ authenticator says so once at startup.
81
+
82
+ The answer no longer depends on registration order: when the bypass names an
83
+ identity another plugin has not published yet, the verdict waits until every
84
+ authenticator has run.
47
85
 
48
86
  ## Token entry shape
49
87
 
@@ -148,6 +186,10 @@ ACL, the client is cryptographically constrained to its own SCIM subtree.
148
186
  deploy cleartext secrets in production.
149
187
  - **Timing**: comparisons run through `crypto.timingSafeEqual` or a buffer
150
188
  wrapper, preventing timing oracles on the secret.
189
+ - **Composition**: loading another authenticator on the same paths means a
190
+ request can arrive already identified. It is still checked against the
191
+ token it carries; what may pass without one is `--authz-dynamic-bypass`,
192
+ and nothing else.
151
193
  - **Scope escape**: the authz hooks check every LDAP operation's DN against
152
194
  the token's ACL. The active token is carried via `AsyncLocalStorage`, so it
153
195
  applies even to plugins that do not thread `req` down to `ldapActions`.
@@ -60,6 +60,38 @@ No configuration file needed - permissions are read from LDAP. Simply load the p
60
60
 
61
61
  The plugin caches permissions for 5 minutes by default. This is hardcoded but can be modified in the source if needed.
62
62
 
63
+ ### The identity has to be a `uid`
64
+
65
+ The plugin resolves `req.user` by searching
66
+ `(<ldap_user_main_attribute>=<identity>)` under `--ldap-base`, so it only
67
+ works behind an authenticator that publishes what the directory calls a user:
68
+
69
+ | Authenticator | `req.user` | Resolves here |
70
+ | ------------------------- | -------------------------------- | -------------------------------------- |
71
+ | `core/auth/token` | the name given in `--auth-token` | only if that name is a `uid` |
72
+ | `core/auth/totp` | the name given in `--auth-totp` | only if that name is a `uid` |
73
+ | `core/auth/hmac` | the service name | usually not |
74
+ | `core/auth/llng` | LLNG's `whatToTrace` | when it traces the `uid` |
75
+ | `core/auth/openidconnect` | the OIDC `sub` | **no** — an opaque provider identifier |
76
+
77
+ An identity that does not resolve is **refused** (403), and the refusal is
78
+ logged with the plugin's name. Until 0.9.0 it was let through instead, which
79
+ made this pairing an open door rather than a misconfiguration; see
80
+ [the note](../../upgrading.md#an-identity-that-does-not-resolve-is-refused)
81
+ and `--authz-unresolved-user` for the transition.
82
+
83
+ Both answers are cached for the plugin's TTL (5 minutes), negatives
84
+ included, so a mismatch does not turn every request into a directory search.
85
+ The TTL cuts both ways: an identity added to the directory after being
86
+ refused waits it out, and an administrator whose **own entry is renamed or
87
+ moved** keeps resolving to the former DN — where no organization names them,
88
+ so their own operations are refused. A rename or a delete performed through
89
+ this server drops what was resolved; a change made directly in the directory
90
+ waits out the TTL.
91
+
92
+ `GET /api/v1/authz/scope` resolves the identity outside that cache, so
93
+ during such a window it can describe a scope the hooks no longer grant.
94
+
63
95
  ## Permission Resolution
64
96
 
65
97
  For each LDAP operation, the plugin: