ldap-rest 0.10.0 → 0.11.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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { Request, Response, NextFunction, RequestHandler } from 'express';
6
6
  import type { Config } from '../bin';
7
+ import type { AttributeValue } from './ldapActions';
7
8
  /**
8
9
  * Get a compiled RegExp from cache, or compile and cache it
9
10
  * This avoids recompiling the same regex patterns repeatedly
@@ -268,6 +269,21 @@ export declare function normalizeDn(dn: string): string;
268
269
  * ```
269
270
  */
270
271
  export declare function isDummyMemberDn(member: unknown, dummy: string | undefined): boolean;
272
+ /**
273
+ * The organization an entry is attached to, read off its link attribute
274
+ * (`--ldap-organization-link-attribute`), or `undefined` when it is attached
275
+ * to none.
276
+ *
277
+ * "None" arrives in three forms: the attribute missing, an empty array —
278
+ * how ldapts answers an attribute that was asked for and is absent — and an
279
+ * empty value. The empty array read as the branch "undefined" in one copy of
280
+ * this logic, and nobody could write such an entry. Every reader of the link
281
+ * asks here, so the copies cannot drift apart again.
282
+ *
283
+ * @param value - The link attribute as a search returned it
284
+ * @returns its first value, or `undefined` when there is none
285
+ */
286
+ export declare function organizationLink(value: AttributeValue | undefined): string | undefined;
271
287
  /**
272
288
  * Check whether a DN is a branch base itself or sits anywhere below it.
273
289
  *
@@ -17,6 +17,14 @@ export default class AuthzLinid1 extends AuthzBase {
17
17
  name: string;
18
18
  permissionsCache: Map<string, CachedPermissions>;
19
19
  constructor(server: DM);
20
+ /**
21
+ * Always: the branches this plugin grants are organizations, and what an
22
+ * administrator manages in them is the accounts and groups whose
23
+ * organization link names them, wherever they are stored. Judged by their
24
+ * parent instead, a flat `ou=users` is nobody's branch, and every
25
+ * administrator is refused every account.
26
+ */
27
+ protected filtersAttachedEntries(): boolean;
20
28
  /**
21
29
  * Resolve user - for authzLinid1, convert uid to userDn via LDAP
22
30
  */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @module plugins/auth/fake
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Development authentication: every request is served as the identity given
6
+ * on the command line, without asking for anything.
7
+ * @group Plugins
8
+ */
9
+ import type { Response } from 'express';
10
+ import AuthBase, { type DmRequest } from '../../lib/auth/base';
11
+ import type { Role } from '../../abstract/plugin';
12
+ export default class AuthFake extends AuthBase {
13
+ name: string;
14
+ roles: Role[];
15
+ private readonly user;
16
+ constructor(...args: ConstructorParameters<typeof AuthBase>);
17
+ protected knownIdentities(): string[];
18
+ protected identitySource(): string;
19
+ afterLoad(): void;
20
+ authMethod(req: DmRequest, _res: Response, next: () => void): void;
21
+ }
@@ -148,23 +148,26 @@ export default class LdapGroups extends DmPlugin {
148
148
  }>;
149
149
  /**
150
150
  * List groups from LDAP
151
- * @param param0 {filter, attributes}:
151
+ * @param param0 {filter, attributes, req}:
152
152
  * - filter: LDAP filter (default '(objectClass=*)')
153
153
  * - attributes: attributes to fetch (default ['cn','member'])
154
+ * - req: incoming request, forwarded to the authorization hooks
154
155
  * @returns LdapList (Record<string, AttributesList>)
155
156
  */
156
- listGroups({ filter, attributes, }?: {
157
+ listGroups({ filter, attributes, req, }?: {
157
158
  filter?: string;
158
159
  attributes?: string[];
160
+ req?: Request;
159
161
  }): Promise<LdapList>;
160
162
  /**
161
163
  * Simple formatter for listGroups()
162
164
  * @param cn main attribute value (partial or full)
163
165
  * @param partial boolean, true for partial search (default false)
164
166
  * @param attributes array of attributes to return
167
+ * @param req incoming request, forwarded to the authorization hooks
165
168
  * @returns LdapList (means Record<string, AttributesList> where key is the --ldap-user-main-attribute value [default: cn])
166
169
  */
167
- searchGroupsByName(cn: string, partial?: boolean, attributes?: string[]): Promise<LdapList>;
170
+ searchGroupsByName(cn: string, partial?: boolean, attributes?: string[], req?: Request): Promise<LdapList>;
168
171
  protected fixDn(dn: string): string | false;
169
172
  /**
170
173
  * Verify that each member exists in LDAP
@@ -7,9 +7,6 @@ export default class LdapStore extends Store {
7
7
  private objectClass;
8
8
  constructor(server: DM, base: string, objectClass: string);
9
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
10
  protected load(key: string): Promise<StoredRecord | null>;
14
11
  protected save(key: string, record: StoredRecord): Promise<void>;
15
12
  protected drop(key: string): Promise<void>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module plugins/storage/postgres
3
+ *
4
+ * A keyed store kept in one PostgreSQL table.
5
+ *
6
+ * `pg` is an optional dependency, loaded when this backend is chosen: a
7
+ * deployment that keeps its records elsewhere never needs it installed.
8
+ *
9
+ * The key is kept verbatim, split into the consumer's namespace and its own
10
+ * key: a `text` column has no reason to hash it the way a DN or a file name
11
+ * must, so a `SELECT` says what a record is about.
12
+ *
13
+ * @group Plugins
14
+ */
15
+ import type winston from 'winston';
16
+ import Store, { type StoredRecord } from '../../lib/storage/store';
17
+ type Pg = typeof import('pg');
18
+ export default class PostgresStore extends Store {
19
+ name: string;
20
+ private url;
21
+ private table;
22
+ /** `schema.table` or `table`, each part quoted. */
23
+ private quoted;
24
+ private connection?;
25
+ constructor(logger: winston.Logger, url: string, table: string);
26
+ /** Split out so a test can stand in for a missing package. */
27
+ protected driver(): Promise<Pg>;
28
+ open(): Promise<void>;
29
+ close(): Promise<void>;
30
+ /**
31
+ * The pool, once the table is known to be there. A failure is not kept:
32
+ * the next operation tries again, so a database down at startup does not
33
+ * leave the store unusable once it is back.
34
+ */
35
+ private pool;
36
+ private connect;
37
+ /**
38
+ * Create the table when it is not there.
39
+ *
40
+ * Looked up first: `CREATE … IF NOT EXISTS` still needs the right to create
41
+ * in the schema, which a role granted only this table's rows does not have.
42
+ */
43
+ private ensureTable;
44
+ /**
45
+ * The namespace and the consumer's own key. PostgreSQL refuses NUL in
46
+ * `text`, so a key holding one cannot be kept: `null` says so.
47
+ */
48
+ private static parts;
49
+ protected load(key: string): Promise<StoredRecord | null>;
50
+ protected save(key: string, record: StoredRecord): Promise<void>;
51
+ protected drop(key: string): Promise<void>;
52
+ sweep(): Promise<number>;
53
+ }
54
+ export {};
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @module plugins/storage/valkey
3
+ *
4
+ * A keyed store kept in Valkey, or Redis.
5
+ *
6
+ * `iovalkey` is an optional dependency, loaded when this backend is chosen: a
7
+ * deployment that keeps its records elsewhere never needs it installed.
8
+ *
9
+ * The only backend that expires its own records, so it has nothing to sweep.
10
+ * The value still carries its deadline, in the shape the LDAP store uses:
11
+ * reads judge expiry by it, as with every other backend, and the TTL only
12
+ * reclaims the memory.
13
+ *
14
+ * @group Plugins
15
+ */
16
+ import type winston from 'winston';
17
+ import Store, { type StoredRecord } from '../../lib/storage/store';
18
+ type IoValkey = typeof import('iovalkey');
19
+ export default class ValkeyStore extends Store {
20
+ name: string;
21
+ private url;
22
+ private prefix;
23
+ private connection?;
24
+ /** Set once a lost connection is reported, so a reconnect loop says it once. */
25
+ private down;
26
+ constructor(logger: winston.Logger, url: string, prefix: string);
27
+ /** Split out so a test can stand in for a missing package. */
28
+ protected driver(): Promise<IoValkey>;
29
+ open(): Promise<void>;
30
+ close(): Promise<void>;
31
+ /**
32
+ * The client, once connected. A failed first connection is not kept: the
33
+ * next operation tries again. Past that one, the client reconnects on its
34
+ * own.
35
+ */
36
+ private client;
37
+ private connect;
38
+ private key;
39
+ protected load(key: string): Promise<StoredRecord | null>;
40
+ protected save(key: string, record: StoredRecord): Promise<void>;
41
+ protected drop(key: string): Promise<void>;
42
+ /** The TTL has already reclaimed what expired. */
43
+ sweep(): Promise<number>;
44
+ }
45
+ export {};
@@ -25,6 +25,7 @@ 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/auth/fake](#coreauthfake)
28
29
  - [core/bcl](#corebcl)
29
30
  - [Authorization Plugins](#authorization-plugins)
30
31
  - [Common to every authorization plugin](#common-to-every-authorization-plugin)
@@ -238,6 +239,14 @@ Empty means the plugin guards every path. Scoping several instances to different
238
239
  | -------------- | --------------- | ---------------- | ------- | --------------------- |
239
240
  | `--auth-token` | `--auth-tokens` | `DM_AUTH_TOKENS` | `[]` | Authentication tokens |
240
241
 
242
+ #### `core/auth/fake`
243
+
244
+ | CLI | Env | Default | Description |
245
+ | ------------------ | ------------------- | ------- | ---------------------------------------------- |
246
+ | `--auth-fake-user` | `DM_AUTH_FAKE_USER` | | Identity every request is served as (required) |
247
+
248
+ Development only, refused with `NODE_ENV=production` — see [Fake Authentication](plugins/auth/fake.md).
249
+
241
250
  #### `core/auth/totp`
242
251
 
243
252
  | CLI | Plural | Env | Default | Description |
@@ -324,11 +333,12 @@ whichever of the two names it selects. A value other than `req.user` or
324
333
 
325
334
  #### `core/auth/authzPerBranch`
326
335
 
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`. |
336
+ | CLI | Env | Default | Description |
337
+ | --------------------------------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
338
+ | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
339
+ | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
340
+ | `--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`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
341
+ | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). |
332
342
 
333
343
  #### `core/auth/authzScope`
334
344
 
@@ -452,13 +462,17 @@ Web access logging plugin. No configuration options - just add the plugin to ena
452
462
  Keyed storage with a deadline, for the plugins that need to keep something —
453
463
  see [Storage](plugins/utilities/storage.md).
454
464
 
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 |
465
+ | CLI | Env | Default | Description |
466
+ | ----------------------------- | ------------------------------ | -------------------- | ------------------------------------------------ |
467
+ | `--storage-backend` | `DM_STORAGE_BACKEND` | | `ldap`, `file`, `postgres` or `valkey`; required |
468
+ | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two expiry passes |
469
+ | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | | Branch the `ldap` backend writes to |
470
+ | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes |
471
+ | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | | Directory the `file` backend writes to |
472
+ | `--storage-postgres-url` | `DM_STORAGE_POSTGRES_URL` | | Connection string of the `postgres` backend |
473
+ | `--storage-postgres-table` | `DM_STORAGE_POSTGRES_TABLE` | `ldap_rest_storage` | Table it writes to |
474
+ | `--storage-valkey-url` | `DM_STORAGE_VALKEY_URL` | | URL of the `valkey` backend |
475
+ | `--storage-valkey-prefix` | `DM_STORAGE_VALKEY_PREFIX` | `ldap-rest:` | Prefix of the keys it writes |
462
476
 
463
477
  #### `core/configApi`
464
478
 
@@ -62,11 +62,11 @@ Connect to external systems:
62
62
 
63
63
  ## Utility Plugins
64
64
 
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 |
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, in LDAP, files, PostgreSQL or Valkey |
70
70
 
71
71
  ## Plugin Dependencies
72
72
 
@@ -11,6 +11,7 @@ LDAP-Rest provides multiple authentication plugins to secure API access. These p
11
11
  | [HMAC](hmac.md) | `core/auth/hmac` | HMAC-SHA256 request signing for backend services |
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
+ | [Fake](fake.md) | `core/auth/fake` | Development only: a fixed identity, no login |
14
15
 
15
16
  ## What a rule is keyed on
16
17
 
@@ -25,6 +26,7 @@ of what a caller is called. Every one of them publishes the caller twice:
25
26
  | `core/auth/llng` | LLNG's `whatToTrace` | `--llng-username-header`, or the same |
26
27
  | `core/auth/openidconnect` | the OIDC `sub` | `--oidc-username-claim` (default: `sub`) |
27
28
  | `core/auth/authzDynamic` | the token's tenant | the same |
29
+ | `core/auth/fake` | `--auth-fake-user` | the same |
28
30
 
29
31
  `req.user` is this server's identifier for the caller; `req.userName` is the
30
32
  caller under a name a person would use. `--authz-identity` says which one is
@@ -48,6 +48,54 @@ twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com
48
48
 
49
49
  The HR admin (listed in `ou=HR`'s `twakeLocalAdminLink`) can manage John even though he's in `ou=users` because his `twakeDepartmentLink` points to the HR organization.
50
50
 
51
+ ### What an administrator reads
52
+
53
+ `authzLinid1` always judges an attached entry this way, as
54
+ `--authz-filter-attached-entries` does for `authzPerBranch`, whatever that
55
+ option says: judged by its parent instead, an account in a flat `ou=users`
56
+ belongs to nobody's branch, and no administrator could list or change one.
57
+
58
+ - Listings of accounts and groups drop the entries attached to an
59
+ organization outside the caller's branches. A single read of one answers
60
+ `404`.
61
+ - Entries attached to nothing — the organizations, the nomenclature values, a
62
+ group or an account with no `twakeDepartmentLink` — are readable by every
63
+ administrator: the organization tree is reference data. Writes to them are
64
+ judged on their parent. The tree a console draws still starts at the
65
+ caller's own branches: see [getOrganisationTop Hook](#getorganisationtop-hook).
66
+ - A caller named in no `twakeLocalAdminLink` is refused every read (`403`)
67
+ but the top organization's own entry, which `organizations/top` answers.
68
+
69
+ ### Transit: handing an entry over
70
+
71
+ An administrator can only move an entry between organizations they write, and
72
+ an account created by another tool often carries no `twakeDepartmentLink` at
73
+ all. The transit branch covers both cases: `--authz-transit-branch` names an
74
+ organization through which entries change hands.
75
+
76
+ ```bash
77
+ --authz-transit-branch "ou=Transit,ou=organization,dc=example,dc=com"
78
+ ```
79
+
80
+ An entry is **in transit** when it is attached to that organization, or to
81
+ none (with or without the option).
82
+
83
+ - Every administrator sees the entries in transit.
84
+ - **Claiming** one (`POST /api/v1/ldap/users/:id/move` towards an
85
+ organization) asks for write on that organization only.
86
+ - **Putting** one in transit asks for write on the organization it leaves,
87
+ and nothing on the transit branch: an administrator only lets go of what
88
+ they manage. Handing an account over to an organization one does not manage
89
+ is two steps: put it in transit, then its new administrator claims it.
90
+ - Any other change to an entry in transit (attributes, password, status,
91
+ deletion) is judged as before, so the entry is claimed first.
92
+
93
+ `GET /api/v1/authz/scope` returns the transit branch as `transit` (`null`
94
+ when there is none), so that a client can offer these moves. A group attached
95
+ to the transit branch follows the same rules through
96
+ `POST /api/v1/ldap/groups/:cn/move`; that endpoint does not move a group
97
+ attached to no organization.
98
+
51
99
  ## Configuration
52
100
 
53
101
  No configuration file needed - permissions are read from LDAP. Simply load the plugin:
@@ -179,8 +227,9 @@ Intercepts all LDAP search operations:
179
227
  2. Resolves user's DN from username
180
228
  3. Allows base scope searches on top organization (for navigation)
181
229
  4. Allows searches for `twakeLocalAdminLink` (for permission refresh)
182
- 5. Verifies user has read permission for search base
183
- 6. Throws error if access denied
230
+ 5. Refuses a caller who administers no branch; lets the others search, and
231
+ drops from the answer the entries attached outside their branches
232
+ (`ldapsearchfilter`)
184
233
 
185
234
  **Special allowances:**
186
235
 
@@ -421,7 +470,10 @@ User exists in `ou=users,dc=example,dc=com` but local admin cannot manage them.
421
470
  twakeDepartmentLink: ou=HR,ou=organization,dc=example,dc=com
422
471
  ```
423
472
 
424
- 2. Or move user to organization branch:
473
+ 2. Or let the local admin claim it: an account with no `twakeDepartmentLink`
474
+ is in transit, see [Transit](#transit-handing-an-entry-over).
475
+
476
+ 3. Or move user to organization branch:
425
477
  ```
426
478
  From: uid=user,ou=users,dc=example,dc=com
427
479
  To: uid=user,ou=users,ou=HR,ou=organization,dc=example,dc=com
@@ -461,31 +513,6 @@ User jdoe not found in LDAP
461
513
 
462
514
  3. Verify authentication plugin sets `req.user` correctly
463
515
 
464
- ### Problem: Cannot Search Top Organization
465
-
466
- **Symptoms:**
467
-
468
- ```json
469
- {
470
- "error": "User admin does not have read permission for branch ou=organization,dc=example,dc=com"
471
- }
472
- ```
473
-
474
- **Solutions:**
475
-
476
- This is expected behavior - users can only see organizations they manage. To grant access to top organization:
477
-
478
- ```bash
479
- ldapsearch -x -b "ou=organization,dc=example,dc=com" -s base "(objectClass=*)"
480
- # Modify to add user:
481
- ldapmodify <<EOF
482
- dn: ou=organization,dc=example,dc=com
483
- changetype: modify
484
- add: twakeLocalAdminLink
485
- twakeLocalAdminLink: uid=admin,ou=users,dc=example,dc=com
486
- EOF
487
- ```
488
-
489
516
  ## Integration Examples
490
517
 
491
518
  ### With Token Authentication
@@ -0,0 +1,28 @@
1
+ # Fake Authentication
2
+
3
+ For development only: every request is served as the identity given on the
4
+ command line, without asking for anything. Restarting with another
5
+ `--auth-fake-user` shows an interface as another administrator sees it.
6
+
7
+ ```bash
8
+ npx ldap-rest \
9
+ --plugin core/auth/fake --auth-fake-user alice \
10
+ --plugin core/auth/authzLinid1 \
11
+ ...
12
+ ```
13
+
14
+ **Environment Variable:** `DM_AUTH_FAKE_USER=alice`
15
+
16
+ The identity goes to `req.user` and `req.userName` alike, so the
17
+ authorization plugins judge it as they would the same login coming from a
18
+ real authenticator.
19
+
20
+ ## Safeguards
21
+
22
+ - The server refuses to start when `NODE_ENV` is `production`, as it is in
23
+ the Docker image.
24
+ - It refuses to start without `--auth-fake-user`.
25
+ - A warning naming the identity is logged at every start.
26
+
27
+ Loaded without an authorization plugin, it grants everything to anyone who
28
+ reaches the port. Listen on a local address only.
@@ -26,13 +26,17 @@ which is invisible in a configuration.
26
26
  --storage-ldap-base ou=Records,dc=example,dc=com
27
27
  ```
28
28
 
29
- | Option | Environment | Default | Description |
30
- | ----------------------------- | ------------------------------ | -------------------- | --------------------------------------------------------- |
31
- | `--storage-backend` | `DM_STORAGE_BACKEND` | _(none)_ | `ldap` or `file`. Empty means the plugin refuses to start |
32
- | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two passes dropping expired records |
33
- | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | _(none)_ | Branch the `ldap` backend writes to |
34
- | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes |
35
- | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | _(none)_ | Directory the `file` backend writes to |
29
+ | Option | Environment | Default | Description |
30
+ | ----------------------------- | ------------------------------ | -------------------- | ------------------------------------------------------------------------------- |
31
+ | `--storage-backend` | `DM_STORAGE_BACKEND` | _(none)_ | `ldap`, `file`, `postgres` or `valkey`. Empty means the plugin refuses to start |
32
+ | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two passes dropping expired records |
33
+ | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | _(none)_ | Branch the `ldap` backend writes to |
34
+ | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes |
35
+ | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | _(none)_ | Directory the `file` backend writes to |
36
+ | `--storage-postgres-url` | `DM_STORAGE_POSTGRES_URL` | _(none)_ | Connection string of the `postgres` backend |
37
+ | `--storage-postgres-table` | `DM_STORAGE_POSTGRES_TABLE` | `ldap_rest_storage` | Table it writes to, `schema.table` accepted |
38
+ | `--storage-valkey-url` | `DM_STORAGE_VALKEY_URL` | _(none)_ | URL of the `valkey` backend (`redis://…` or `rediss://…`) |
39
+ | `--storage-valkey-prefix` | `DM_STORAGE_VALKEY_PREFIX` | `ldap-rest:` | Prefix of the keys it writes |
36
40
 
37
41
  `core/storage` is in the priority list, so it is registered before the
38
42
  plugins that consume it whatever order they are written in. A consumer that
@@ -61,18 +65,65 @@ The directory is the plugin's own: nothing else should be writing there, and
61
65
  the sweeper removes what it finds expired. It is local to one process, so it
62
66
  suits a single instance, not a cluster sharing one verdict.
63
67
 
68
+ ### `postgres`
69
+
70
+ One row per record in `--storage-postgres-table`, with the consumer's
71
+ namespace and key kept verbatim beside the value and an `expires_at` column,
72
+ so a `SELECT` tells what a record is about. Needs the optional `pg` package.
73
+
74
+ The table is created at the first connection when it is not there. A role
75
+ that may not create tables works once it exists:
76
+
77
+ ```sql
78
+ CREATE TABLE ldap_rest_storage (
79
+ namespace text NOT NULL,
80
+ key text NOT NULL,
81
+ value text NOT NULL,
82
+ expires_at timestamptz NOT NULL,
83
+ PRIMARY KEY (namespace, key)
84
+ );
85
+ CREATE INDEX ldap_rest_storage_expires_at ON ldap_rest_storage (expires_at);
86
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ldap_rest_storage TO ldap_rest;
87
+ ```
88
+
89
+ Several instances can share the table and sweep it at once. PostgreSQL
90
+ refuses a NUL in `text`, so a key holding one cannot be kept: the write
91
+ fails.
92
+
93
+ ### `valkey`
94
+
95
+ One key per record, `--storage-valkey-prefix` followed by the namespace and
96
+ the key, expiring with the record: nothing is left to sweep. Works with Redis
97
+ too. Needs the optional `iovalkey` package.
98
+
99
+ **The instance must not evict.** An instance configured as a cache
100
+ (`allkeys-lru`, `volatile-lru` and the like) drops keys under memory pressure,
101
+ and every key this backend writes has a TTL: a Back-Channel Logout mark
102
+ evicted early reopens the session it closed. Use an instance with
103
+ `maxmemory-policy noeviction`, which answers a full memory with a failed
104
+ write — reported to the identity provider — rather than a silent loss.
105
+
106
+ A connection lost while running is retried in the background; meanwhile, reads
107
+ answer "absent" with a warning and writes fail at once instead of waiting.
108
+
109
+ `postgres` and `valkey` connect at startup, and report there an address that
110
+ does not answer or a package that is not installed; the server starts anyway,
111
+ and every operation tries to connect again.
112
+
64
113
  ## Expiry
65
114
 
66
115
  Two rules the backends do not get to reinterpret:
67
116
 
68
117
  - **Expiry is enforced on read.** A record past its deadline is answered as
69
118
  absent whatever the sweeper has done, so a sweeper running late is a
70
- storage cost, never a wrong answer.
119
+ storage cost, never a wrong answer. Expiry is judged by this server's clock,
120
+ the sweep and the Valkey TTL included, so a database clock running ahead
121
+ cannot drop a record early.
71
122
  - **The deadline belongs to the consumer.** How long a record lives is the
72
123
  policy of whoever wrote it. The store is told an instant and respects it.
73
124
 
74
- Keys are namespaced per consumer, so two of them cannot collide in one branch
75
- or one directory.
125
+ Keys are namespaced per consumer, so two of them cannot collide in one branch,
126
+ directory, table or instance.
76
127
 
77
128
  ## For Plugin Authors
78
129
 
@@ -4,6 +4,47 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## To 0.11.0
8
+
9
+ ### Group reads are judged
10
+
11
+ **Who is affected:** a deployment loading `core/ldap/groups` with an
12
+ authorization plugin.
13
+
14
+ Listing the groups and reading one went to the directory without the
15
+ request, so no authorization plugin saw them: any authenticated caller read
16
+ every group with its members. They are judged like any other read now.
17
+ Under `core/auth/authzPerBranch` without `--authz-filter-attached-entries`,
18
+ that means a `read` grant on the group base (`ou=groups`, or
19
+ `--ldap-group-base`): **a caller without one is answered `403`** where the
20
+ list used to come back.
21
+
22
+ ### authzLinid1 judges accounts and groups by their organization
23
+
24
+ **Who is affected:** every deployment using `core/auth/authzLinid1`.
25
+
26
+ It now works as with `--authz-filter-attached-entries`, whatever the option
27
+ says. Without it, a flat `ou=users` was nobody's branch: no administrator
28
+ could list or change an account.
29
+
30
+ - An administrator lists the accounts and groups attached to their branches,
31
+ and those attached to none.
32
+ - **The organization tree is readable by every administrator**, not only their
33
+ own branches: an organization is reference data. `organizations/top` still
34
+ answers their own branches, so a console starts there. Writes to the tree
35
+ are judged as before.
36
+ - **A caller named in no `twakeLocalAdminLink` is refused every read** but the
37
+ top organization's own entry.
38
+ - An account stored under the organization tree without a
39
+ `twakeDepartmentLink` becomes readable by every administrator. **Where
40
+ accounts sit under the tree, give them their link before upgrading.** This
41
+ lists the accounts that have none, under `twakeDepartmentLink: missing`:
42
+
43
+ ```bash
44
+ npm run audit:directory -- --schema static/schemas/twake/users.json \
45
+ --base ou=users,<base> --filter '(!(twakeDepartmentLink=*))'
46
+ ```
47
+
7
48
  ## To 0.10.0
8
49
 
9
50
  ### LDAP base is now required
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -64,6 +64,10 @@
64
64
  "types": "./dist/src/plugins/auth/crowdsec.d.ts",
65
65
  "import": "./dist/plugins/auth/crowdsec.js"
66
66
  },
67
+ "./plugin-auth-fake": {
68
+ "types": "./dist/src/plugins/auth/fake.d.ts",
69
+ "import": "./dist/plugins/auth/fake.js"
70
+ },
67
71
  "./plugin-auth-hmac": {
68
72
  "types": "./dist/src/plugins/auth/hmac.d.ts",
69
73
  "import": "./dist/plugins/auth/hmac.js"
@@ -228,6 +232,14 @@
228
232
  "types": "./dist/src/plugins/storage/ldap.d.ts",
229
233
  "import": "./dist/plugins/storage/ldap.js"
230
234
  },
235
+ "./plugin-storage-postgres": {
236
+ "types": "./dist/src/plugins/storage/postgres.d.ts",
237
+ "import": "./dist/plugins/storage/postgres.js"
238
+ },
239
+ "./plugin-storage-valkey": {
240
+ "types": "./dist/src/plugins/storage/valkey.d.ts",
241
+ "import": "./dist/plugins/storage/valkey.js"
242
+ },
231
243
  "./plugin-twake-appaccountsapi": {
232
244
  "types": "./dist/src/plugins/twake/appAccountsApi.d.ts",
233
245
  "import": "./dist/plugins/twake/appAccountsApi.js"
@@ -723,6 +735,7 @@
723
735
  "@types/mocha": "^10.0.10",
724
736
  "@types/multer": "^2.0.0",
725
737
  "@types/node": "^24.5.1",
738
+ "@types/pg": "^8.23.1",
726
739
  "@types/supertest": "^6.0.3",
727
740
  "@typescript-eslint/eslint-plugin": "^8.44.0",
728
741
  "@typescript-eslint/parser": "^8.44.0",
@@ -751,7 +764,9 @@
751
764
  "optionalDependencies": {
752
765
  "@linagora/rabbitmq-client": "^0.1.2",
753
766
  "express-openid-connect": "^2.19.2",
767
+ "iovalkey": "^0.4.0",
754
768
  "lemonldap-ng-handler": "^0.7.3",
769
+ "pg": "^8.23.0",
755
770
  "re2": "^1.22.1"
756
771
  },
757
772
  "engines": {