ldap-rest 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/lib/expressFormatedResponses.js +1 -1
  6. package/dist/plugins/auth/authzDynamic.js +1 -0
  7. package/dist/plugins/auth/authzDynamicHash.js +1 -0
  8. package/dist/plugins/ldap/organization.js +1 -1
  9. package/dist/plugins/ldap/organizations.js +1 -0
  10. package/dist/plugins/scim/baseResolver.js +1 -0
  11. package/dist/plugins/scim/bulk.js +1 -0
  12. package/dist/plugins/scim/discovery.js +1 -0
  13. package/dist/plugins/scim/errors.js +1 -0
  14. package/dist/plugins/scim/filter.js +1 -0
  15. package/dist/plugins/scim/groups.js +1 -0
  16. package/dist/plugins/scim/mapping.js +1 -0
  17. package/dist/plugins/scim/patch.js +1 -0
  18. package/dist/plugins/scim/scim.js +1 -0
  19. package/dist/plugins/scim/types.js +1 -0
  20. package/dist/plugins/scim/users.js +1 -0
  21. package/dist/src/abstract/ldapFlat.d.ts +127 -0
  22. package/dist/src/config/args.d.ts +24 -0
  23. package/dist/src/plugins/auth/authzDynamic.d.ts +117 -0
  24. package/dist/src/plugins/auth/authzDynamicHash.d.ts +27 -0
  25. package/dist/src/plugins/configApi.d.ts +51 -0
  26. package/dist/src/plugins/demo/helloworld.d.ts +21 -0
  27. package/dist/src/plugins/ldap/bulkImport.d.ts +65 -0
  28. package/dist/src/plugins/ldap/groups.d.ts +68 -0
  29. package/dist/src/plugins/ldap/organization.d.ts +8 -65
  30. package/dist/src/plugins/ldap/organizations.d.ts +155 -0
  31. package/dist/src/plugins/ldap/passwordPolicy.d.ts +178 -0
  32. package/dist/src/plugins/scim/baseResolver.d.ts +23 -0
  33. package/dist/src/plugins/scim/bulk.d.ts +43 -0
  34. package/dist/src/plugins/scim/discovery.d.ts +27 -0
  35. package/dist/src/plugins/scim/errors.d.ts +37 -0
  36. package/dist/src/plugins/scim/filter.d.ts +13 -0
  37. package/dist/src/plugins/scim/groups.d.ts +61 -0
  38. package/dist/src/plugins/scim/mapping.d.ts +52 -0
  39. package/dist/src/plugins/scim/patch.d.ts +39 -0
  40. package/dist/src/plugins/scim/scim.d.ts +412 -0
  41. package/dist/src/plugins/scim/types.d.ts +222 -0
  42. package/dist/src/plugins/scim/users.d.ts +58 -0
  43. package/dist/src/plugins/static.d.ts +33 -0
  44. package/dist/src/plugins/twake/appAccountsApi.d.ts +63 -0
  45. package/dist/src/plugins/twake/james.d.ts +47 -0
  46. package/docs/plugin-development/README.md +2 -1
  47. package/docs/plugin-development/openapi.md +144 -0
  48. package/docs/usage/plugins/auth/authz-dynamic.md +183 -0
  49. package/docs/usage/plugins/integrations/scim.md +567 -0
  50. package/package.json +72 -1
  51. package/static/schemas/scim/Group.json +31 -0
  52. package/static/schemas/scim/User.json +147 -0
  53. package/static/schemas/scim/default-mapping.json +48 -0
@@ -1,5 +1,38 @@
1
1
  import type { Express } from 'express';
2
2
  import DmPlugin, { type Role } from '../abstract/plugin';
3
+ /**
4
+ * @openapi-component
5
+ * LdapSchema:
6
+ * type: object
7
+ * description: |
8
+ * A JSON schema file served from the `schemas/` sub-directory of the
9
+ * static path. These schemas describe LDAP entity types (users, groups,
10
+ * organizations, …) and are consumed by browser-embedded editors to
11
+ * render attribute forms and validate input before submission.
12
+ *
13
+ * The server replaces `__FOO_BAR__` placeholders on the fly with the
14
+ * value of the corresponding `--foo-bar` CLI option so that a single
15
+ * schema file can be shared between server-side validation and
16
+ * client-side rendering.
17
+ * additionalProperties: true
18
+ * example:
19
+ * entity:
20
+ * name: standardUser
21
+ * mainAttribute: uid
22
+ * objectClass: [top, inetOrgPerson]
23
+ * singularName: user
24
+ * pluralName: users
25
+ * base: ou=users,dc=example,dc=com
26
+ * strict: true
27
+ * attributes:
28
+ * uid:
29
+ * type: string
30
+ * required: true
31
+ * role: identifier
32
+ * cn:
33
+ * type: string
34
+ * required: true
35
+ */
3
36
  export default class Static extends DmPlugin {
4
37
  name: string;
5
38
  roles: Role[];
@@ -1,6 +1,69 @@
1
1
  import type { Express } from 'express';
2
2
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
3
  import type { DM } from '../../bin';
4
+ /**
5
+ * @openapi-component
6
+ * AppAccount:
7
+ * type: object
8
+ * description: |
9
+ * An application-specific identity attached to a user. App accounts
10
+ * allow a single principal user to authenticate as different named
11
+ * "devices" or "applications" each with its own credential, while
12
+ * sharing the same mail address.
13
+ * required: [uid]
14
+ * properties:
15
+ * uid:
16
+ * type: string
17
+ * description: |
18
+ * Unique identifier of the app account. Format:
19
+ * `<username>_c<8-digits>` (e.g. `alice_c04729183`).
20
+ * example: alice_c04729183
21
+ * name:
22
+ * type: string
23
+ * description: Human-readable label (stored as LDAP `description`).
24
+ * example: Work laptop
25
+ * example:
26
+ * uid: alice_c04729183
27
+ * name: Work laptop
28
+ * AppAccountCreated:
29
+ * type: object
30
+ * description: |
31
+ * Response returned when a new app account is successfully created.
32
+ * The one-time cleartext password is included here and never returned
33
+ * again — callers must store it immediately.
34
+ * required: [uid, pwd, mail]
35
+ * properties:
36
+ * uid:
37
+ * type: string
38
+ * description: Identifier of the newly created app account.
39
+ * example: alice_c04729183
40
+ * pwd:
41
+ * type: string
42
+ * description: |
43
+ * One-time cleartext password generated for this account.
44
+ * Format: 6 blocks of 4 characters separated by `-`
45
+ * (e.g. `Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@`).
46
+ * OpenLDAP hashes it via ppolicy before storage.
47
+ * example: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@
48
+ * mail:
49
+ * type: string
50
+ * description: Mail address of the principal account this app account belongs to.
51
+ * example: alice@example.com
52
+ * example:
53
+ * uid: alice_c04729183
54
+ * pwd: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@
55
+ * mail: alice@example.com
56
+ * AppAccountCreate:
57
+ * type: object
58
+ * description: Request body for creating a new app account.
59
+ * properties:
60
+ * name:
61
+ * type: string
62
+ * description: Optional human-readable label for the new account.
63
+ * example: Work laptop
64
+ * example:
65
+ * name: Work laptop
66
+ */
4
67
  export default class AppAccountsApi extends DmPlugin {
5
68
  name: string;
6
69
  roles: Role[];
@@ -5,6 +5,53 @@ import type { DM } from '../../bin';
5
5
  import type { AttributeValue } from '../../lib/ldapActions';
6
6
  import { Hooks } from '../../hooks';
7
7
  import type { ChangesToNotify } from '../ldap/onChange';
8
+ /**
9
+ * OpenAPI schemas specific to the James integration.
10
+ *
11
+ * @openapi-component
12
+ * JamesQuotaUsage:
13
+ * type: object
14
+ * description: |
15
+ * Quota information for a mailbox user as returned by the James
16
+ * WebAdmin API (`GET /quota/users/{mail}`). Contains separate
17
+ * counters for message count and storage size.
18
+ * properties:
19
+ * count:
20
+ * type: object
21
+ * description: Message-count quota.
22
+ * properties:
23
+ * used:
24
+ * type: integer
25
+ * description: Number of messages currently stored.
26
+ * example: 142
27
+ * max:
28
+ * type: integer
29
+ * nullable: true
30
+ * description: |
31
+ * Maximum number of messages allowed. `null` means unlimited.
32
+ * example: 10000
33
+ * size:
34
+ * type: object
35
+ * description: Storage-size quota (bytes).
36
+ * properties:
37
+ * used:
38
+ * type: integer
39
+ * description: Bytes currently used.
40
+ * example: 524288000
41
+ * max:
42
+ * type: integer
43
+ * nullable: true
44
+ * description: |
45
+ * Maximum bytes allowed. `null` means unlimited.
46
+ * example: 2147483648
47
+ * example:
48
+ * count:
49
+ * used: 142
50
+ * max: 10000
51
+ * size:
52
+ * used: 524288000
53
+ * max: 2147483648
54
+ */
8
55
  export default class James extends TwakePlugin {
9
56
  name: string;
10
57
  roles: Role[];
@@ -13,6 +13,7 @@ This guide will help you create powerful, production-ready plugins for LDAP-Rest
13
13
  - [Configuration Best Practices](#configuration-best-practices)
14
14
  - [Complete Plugin Example](#complete-plugin-example)
15
15
  - [Testing Your Plugin](#testing-your-plugin)
16
+ - [Documenting REST endpoints (OpenAPI)](./openapi.md)
16
17
 
17
18
  ---
18
19
 
@@ -1282,7 +1283,7 @@ source ~/.test-env && npm run test:dev
1282
1283
  ## Additional Resources
1283
1284
 
1284
1285
  - [Contributing Guide](../../CONTRIBUTING.md) - Git workflow and PR guidelines
1285
- - [Hooks Reference](../../HOOKS.md) - Complete list of available hooks
1286
+ - [Hooks Reference](./hooks.md) - Complete list of available hooks
1286
1287
  - [Core Plugin Examples](../../src/plugins/) - Browse existing plugins
1287
1288
  - [Test Examples](../../test/plugins/) - See how core plugins are tested
1288
1289
 
@@ -0,0 +1,144 @@
1
+ # Documenting plugin endpoints (OpenAPI)
2
+
3
+ The published API reference at <https://linagora.github.io/ldap-rest/api/> is
4
+ generated from `openapi.json`, which itself is built statically by
5
+ [`scripts/generate-openapi.ts`](../../scripts/generate-openapi.ts) — it walks
6
+ every plugin's `api()` method and reads the JSDoc comments above each route.
7
+
8
+ You don't need to touch the generator to enrich your plugin's documentation.
9
+ Two JSDoc directives drive the entire pipeline:
10
+
11
+ - `@openapi` — placed **just above an `app.method(path, handler)` call**,
12
+ describes that single route.
13
+ - `@openapi-component` — placed anywhere in the file, registers reusable
14
+ schemas under `components.schemas` so multiple routes can `$ref` them.
15
+
16
+ Both directives carry inline **YAML** that follows the
17
+ [OpenAPI 3.0 Operation Object](https://spec.openapis.org/oas/v3.0.3#operation-object)
18
+ (or [Schema Object](https://spec.openapis.org/oas/v3.0.3#schema-object) for
19
+ components). The generator parses the YAML and merges it on top of the
20
+ defaults it would have emitted, so you only describe what you want to enrich.
21
+
22
+ ## Per-route metadata: `@openapi`
23
+
24
+ ```ts
25
+ /**
26
+ * @openapi
27
+ * summary: Get group by CN
28
+ * description: |
29
+ * The `:cn` segment may be either the group's RDN value or its full DN.
30
+ * Returns the raw LDAP entry.
31
+ * responses:
32
+ * '200':
33
+ * description: Group entry.
34
+ * content:
35
+ * application/json:
36
+ * schema: { $ref: '#/components/schemas/Group' }
37
+ * example:
38
+ * dn: cn=admins,ou=groups,dc=example,dc=com
39
+ * cn: admins
40
+ * description: Server administrators
41
+ * '404':
42
+ * description: Group not found.
43
+ */
44
+ app.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`, asyncHandler(...));
45
+ ```
46
+
47
+ Anything below the `@openapi` line (up to the next `@directive` or the end of
48
+ the JSDoc block) is YAML. The keys you provide override the auto-generated
49
+ ones; everything you omit keeps the generator's defaults.
50
+
51
+ ### What you can override
52
+
53
+ Any field of an OpenAPI Operation Object, including:
54
+
55
+ | Field | Notes |
56
+ | ------------- | -------------------------------------------------------------------------------- |
57
+ | `summary` | Short, one-line title. |
58
+ | `description` | Long description (markdown). |
59
+ | `tags` | Array of strings; defaults to the plugin's tag. |
60
+ | `parameters` | Concatenated with auto-generated path params (your entries win on duplicates). |
61
+ | `requestBody` | Replace the generic `application/json` placeholder with a real schema + example. |
62
+ | `responses` | Replace per status code. |
63
+ | `security` | List of security requirement objects (works as-is). |
64
+ | `deprecated` | `true` to mark the operation as deprecated. |
65
+ | `operationId` | Stable identifier for code generators. |
66
+
67
+ ### What is autodetected
68
+
69
+ The generator already extracts:
70
+
71
+ - the **HTTP method** (`get`, `post`, …) from the call expression;
72
+ - the **path** (substituting local `const prefix = ...` variables, the
73
+ `${this.config.api_prefix}` template, and a few well-known config knobs);
74
+ - **path parameters** from `:param` segments;
75
+ - a default **summary** built from the verb and the last path segment;
76
+ - the plugin's **tag** (configured in `pluginTags` in the generator).
77
+
78
+ So a minimal, correct `@openapi` block is often only two or three keys.
79
+
80
+ ## Reusable schemas: `@openapi-component`
81
+
82
+ Place this anywhere in your plugin file (the convention is right above the
83
+ class). The body is a YAML map of `<SchemaName>: <SchemaDefinition>` pairs:
84
+
85
+ ```ts
86
+ /**
87
+ * @openapi-component
88
+ * Group:
89
+ * type: object
90
+ * required: [dn, cn]
91
+ * properties:
92
+ * dn: { type: string, example: cn=admins,ou=groups,dc=example,dc=com }
93
+ * cn: { type: string, example: admins }
94
+ * member:
95
+ * type: array
96
+ * items: { type: string }
97
+ * Error:
98
+ * type: object
99
+ * properties:
100
+ * error: { type: string }
101
+ * code: { type: integer }
102
+ */
103
+ export default class LdapGroups extends DmPlugin {
104
+ // ...
105
+ }
106
+ ```
107
+
108
+ Refer to them from any route using `$ref`:
109
+
110
+ ```yaml
111
+ schema: { $ref: '#/components/schemas/Group' }
112
+ ```
113
+
114
+ Component names are global to the spec, so prefix them with the plugin's
115
+ domain (`Group`, `ScimUser`, `PasswordPolicy`) to avoid collisions.
116
+
117
+ ## Tips
118
+
119
+ - **Examples beat specs.** A single concrete `example:` block is worth a
120
+ dozen `description:` lines for someone trying the API for the first time.
121
+ - **YAML alignment.** The generator strips the leading `*` from each line
122
+ before parsing, then dedents the block, so you can use any consistent
123
+ indentation as long as it's even within the block.
124
+ - **Multiline strings.** Use YAML block scalars (`description: |`) for
125
+ paragraphs and code samples — they round-trip cleanly through Redoc.
126
+ - **Annotation is opt-in.** Routes without an `@openapi` block are
127
+ **excluded** from the published spec — the generator prints a
128
+ `⚠️ Skipping undocumented route` warning so the author notices, but
129
+ the route does not surface in Redoc. The published doc therefore
130
+ reflects intentionally-documented API surface only, not every Express
131
+ call we happened to find.
132
+ - **A plugin with zero annotated routes disappears entirely** from the
133
+ spec, by the same rule.
134
+
135
+ ## Regenerate locally
136
+
137
+ ```sh
138
+ npm run generate:openapi # writes openapi.json
139
+ npm run build:pages # rebuilds the static site under _site/
140
+ ```
141
+
142
+ Open `_site/api/index.html` to preview the Redoc rendering. The published
143
+ site at <https://linagora.github.io/ldap-rest/api/> is rebuilt automatically
144
+ on every `v*` tag push.
@@ -0,0 +1,183 @@
1
+ # authzDynamic — LDAP-backed tokens + per-branch authorization
2
+
3
+ The `authzDynamic` plugin combines **bearer-token authentication** and
4
+ **per-branch authorization** in a single plugin, sourced entirely from a
5
+ dedicated LDAP branch. Adding, rotating, or revoking a token — or changing its
6
+ ACL — is a matter of writing to LDAP: no process restart, no config file.
7
+
8
+ This is the recommended building block for multi-tenant deployments where
9
+ each SCIM client (or REST API consumer) is scoped to its own subtree.
10
+
11
+ ## How it works
12
+
13
+ 1. The plugin loads all entries under `--authz-dynamic-base` and caches them.
14
+ 2. On every incoming request, it reads `Authorization: Bearer <token>` and
15
+ verifies the token against the `userPassword` hash of each cached entry
16
+ (timing-safe comparison).
17
+ 3. On a match, the plugin sets `req.user` to the tenant name and records the
18
+ token's ACL in an `AsyncLocalStorage` context.
19
+ 4. Downstream LDAP operations (via `ldapActions.search / add / modify / delete / rename`) trigger authz hooks that read the active token from the async context and throw if the requested DN is outside the allowed branches.
20
+ 5. The cache refreshes on a TTL (default 60 s) or on demand via a protected
21
+ reload endpoint.
22
+
23
+ ## Configuration
24
+
25
+ ```bash
26
+ node lib/bin/index.js \
27
+ --ldap-url ldap://ldap.internal \
28
+ --ldap-base dc=example,dc=com \
29
+ --ldap-dn cn=admin,dc=example,dc=com --ldap-pwd '***' \
30
+ \
31
+ --plugin core/auth/authzDynamic \
32
+ --authz-dynamic-base ou=authz-tokens,dc=example,dc=com \
33
+ --authz-dynamic-cache-ttl 60 \
34
+ --authz-dynamic-reload-endpoint
35
+ ```
36
+
37
+ ### CLI / environment
38
+
39
+ | Argument | Environment | Default | Purpose |
40
+ | ---------------------------------- | ----------------------------------- | -------------- | --------------------------------------------------------------------- |
41
+ | `--authz-dynamic-base` | `DM_AUTHZ_DYNAMIC_BASE` | — (required) | LDAP branch that contains the token entries |
42
+ | `--authz-dynamic-cache-ttl` | `DM_AUTHZ_DYNAMIC_CACHE_TTL` | `60` | Cache refresh interval in seconds |
43
+ | `--authz-dynamic-token-attribute` | `DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE` | `userPassword` | Attribute holding the hashed bearer token |
44
+ | `--authz-dynamic-config-attribute` | `DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE` | `description` | Attribute holding the JSON ACL document |
45
+ | `--authz-dynamic-tenant-attribute` | `DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE` | `cn` | Attribute from which `req.user` is read |
46
+ | `--authz-dynamic-reload-endpoint` | `DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT` | `false` | Register `POST /api/v1/authz-dynamic/reload` for manual cache refresh |
47
+
48
+ ## Token entry shape
49
+
50
+ Each token is a plain LDAP entry under `--authz-dynamic-base`. Minimal
51
+ requirements: `cn` (token identifier), `userPassword` (the hashed secret),
52
+ and a JSON ACL document in `description` (by default — override via
53
+ `--authz-dynamic-config-attribute`).
54
+
55
+ ```ldif
56
+ dn: cn=acme,ou=authz-tokens,dc=example,dc=com
57
+ objectClass: top
58
+ objectClass: inetOrgPerson
59
+ cn: acme
60
+ sn: acme
61
+ userPassword: {SSHA}eFt2wP4ykczHzV9pzE3CH3k1t9M=
62
+ description: {
63
+ "tenant": "acme",
64
+ "bases": [
65
+ { "dn": "ou=users,ou=acme,dc=example,dc=com", "read": true, "write": true, "delete": true },
66
+ { "dn": "ou=groups,ou=acme,dc=example,dc=com", "read": true, "write": true, "delete": true }
67
+ ]
68
+ }
69
+ ```
70
+
71
+ ### ACL JSON
72
+
73
+ ```json
74
+ {
75
+ "tenant": "acme",
76
+ "bases": [
77
+ { "dn": "<branch DN>", "read": true, "write": true, "delete": true }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ - **Sub-branch matching**: a permission on `ou=acme,dc=example,dc=com`
83
+ automatically covers `ou=users,ou=acme,…` and any sub-branch.
84
+ - Absent flags default to `false`.
85
+ - `tenant` is optional; if omitted, `req.user` falls back to the value of the
86
+ configured `tenantAttribute` (default `cn`).
87
+
88
+ ### Supported userPassword schemes
89
+
90
+ Handled by a constant-time verifier (`authzDynamicHash.ts`):
91
+
92
+ | Scheme | Notes |
93
+ | ------------------------------------- | ---------------------------------------------------- |
94
+ | `{SSHA}` | salted SHA-1 — OpenLDAP default, recommended minimum |
95
+ | `{SHA}` | unsalted SHA-1 — legacy |
96
+ | `{SSHA256}` / `{SHA256}` | salted / unsalted SHA-256 |
97
+ | `{SSHA512}` / `{SHA512}` | salted / unsalted SHA-512 |
98
+ | `{SMD5}` / `{MD5}` | salted / unsalted MD5 — legacy, avoid |
99
+ | `{CLEARTEXT}` / `{PLAIN}` / no prefix | cleartext — test environments only |
100
+
101
+ To generate an `{SSHA}` hash with OpenLDAP tools: `slappasswd -h '{SSHA}'`.
102
+ To generate programmatically, `ssha(token)` is also exported from
103
+ `authzDynamicHash.ts` for scripts.
104
+
105
+ ## Usage with SCIM
106
+
107
+ This is the combination the plugin was designed for:
108
+
109
+ ```bash
110
+ node lib/bin/index.js \
111
+ --ldap-base dc=example,dc=com \
112
+ --ldap-dn cn=admin,dc=example,dc=com --ldap-pwd '***' \
113
+ \
114
+ --plugin core/auth/authzDynamic \
115
+ --authz-dynamic-base ou=authz-tokens,dc=example,dc=com \
116
+ \
117
+ --plugin core/scim \
118
+ --scim-user-base-template 'ou=users,ou={user},dc=example,dc=com' \
119
+ --scim-group-base-template 'ou=groups,ou={user},dc=example,dc=com'
120
+ ```
121
+
122
+ Each token's `tenant` field populates `req.user`, which the SCIM plugin uses
123
+ to resolve its LDAP bases via the `{user}` template. Combined with the token's
124
+ ACL, the client is cryptographically constrained to its own SCIM subtree.
125
+
126
+ ## Operational notes
127
+
128
+ - **Cache**: read entries are kept in memory. A failed reload (LDAP hiccup)
129
+ keeps the previous snapshot in use — no request is ever served with an
130
+ empty cache unless the very first load fails.
131
+ - **Reload endpoint**: when `--authz-dynamic-reload-endpoint` is set, any
132
+ **already-authenticated** token can call `POST /api/v1/authz-dynamic/reload`
133
+ to force an immediate refresh. Useful after provisioning a new token.
134
+ - **Deletion**: deleting a token entry from LDAP invalidates it within
135
+ `--authz-dynamic-cache-ttl` seconds (or immediately if a reload is
136
+ triggered). Rotate compromised secrets this way.
137
+ - **Audit**: every unauthorized attempt is logged at `warn` with a masked
138
+ token (first 8 chars + `...`). Every authorization failure is logged at
139
+ `error` via the DM error middleware.
140
+ - **configApi**: when `core/configApi` is loaded, the plugin publishes a
141
+ compact status under `features.authzDynamic` (token count, base DN, cache
142
+ TTL, reload endpoint URL) — never hashes or per-token DNs.
143
+
144
+ ## Security considerations
145
+
146
+ - **Secret storage**: always hash `userPassword` (`{SSHA}` or better). The
147
+ plugin refuses unknown scheme prefixes, but accepts cleartext — do not
148
+ deploy cleartext secrets in production.
149
+ - **Timing**: comparisons run through `crypto.timingSafeEqual` or a buffer
150
+ wrapper, preventing timing oracles on the secret.
151
+ - **Scope escape**: the authz hooks check every LDAP operation's DN against
152
+ the token's ACL. The active token is carried via `AsyncLocalStorage`, so it
153
+ applies even to plugins that do not thread `req` down to `ldapActions`.
154
+ - **Binding credentials**: the DM bind user still has admin access — the
155
+ plugin's isolation is logical (path prefix check), not LDAP-layer ACL. For
156
+ a second layer of defence, configure OpenLDAP ACLs to match.
157
+
158
+ ## Example: listing a tenant's configuration
159
+
160
+ With `core/configApi` loaded:
161
+
162
+ ```bash
163
+ curl -H 'Authorization: Bearer <token>' http://localhost:8081/api/v1/config
164
+ ```
165
+
166
+ The response contains (trimmed):
167
+
168
+ ```json
169
+ {
170
+ "features": {
171
+ "authzDynamic": {
172
+ "enabled": true,
173
+ "base": "ou=authz-tokens,dc=example,dc=com",
174
+ "cacheTtlSeconds": 60,
175
+ "tokenCount": 17,
176
+ "tokenAttribute": "userPassword",
177
+ "configAttribute": "description",
178
+ "tenantAttribute": "cn",
179
+ "reloadEndpoint": "/api/v1/authz-dynamic/reload"
180
+ }
181
+ }
182
+ }
183
+ ```