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
@@ -1,8 +1,32 @@
1
1
  import type { Express, Request, Response } from 'express';
2
+ import DmPlugin from '../../abstract/plugin';
3
+ import type { Config } from '../../config/args';
4
+ /**
5
+ * A request as the authentication plugins leave it.
6
+ *
7
+ * `user` is what an authorization rule is keyed on, and every authenticator
8
+ * fills it with something different — a token's name, an OIDC `sub`, a
9
+ * tenant. `userName` is the same caller under the name a person would use
10
+ * for them, so a rule can be written once and survive a change of
11
+ * authenticator; `--authz-identity` says which of the two the authorization
12
+ * plugins read, and the default stays `user`, which changes nothing for an
13
+ * existing deployment.
14
+ */
2
15
  export type DmRequest = Request & {
3
16
  user?: string;
17
+ userName?: string;
18
+ /**
19
+ * The authentication plugins that vouched for this request, by instance
20
+ * name, in the order the dispatcher ran them.
21
+ *
22
+ * Written by the dispatcher alone, once a plugin has let the request
23
+ * through, so an authorization plugin can tell *whose* request it is
24
+ * looking at: a machine token and an administrator's session carry
25
+ * identities from different models, and a rule written for one of them
26
+ * is not a judgement about the other (`--authz-for`).
27
+ */
28
+ authenticators?: string[];
4
29
  };
5
- import DmPlugin from '../../abstract/plugin';
6
30
  /**
7
31
  * Tell whether a mount prefix covers a request path, with the rule Express
8
32
  * itself applies: on segment boundaries, so `/api/m` covers `/api/m` and
@@ -18,18 +42,68 @@ import DmPlugin from '../../abstract/plugin';
18
42
  */
19
43
  export declare function prefixCoversPath(prefix: string, path: string): boolean;
20
44
  /**
21
- * Path prefixes claimed by scoped authentication plugins.
45
+ * Refuse a `--authz-identity` nobody wrote.
46
+ *
47
+ * Called by every plugin that keys on an identity, at construction. A value
48
+ * that is neither would otherwise mean `req.user` in silence, which is the
49
+ * wrong half of a security option to guess at.
50
+ *
51
+ * @param config the server configuration
52
+ * @param who the plugin asking, for the message
53
+ * @throws Error when the value is not one of the two
54
+ */
55
+ export declare function assertIdentityMode(config: Config, who: string): void;
56
+ /**
57
+ * The value an authorization rule is keyed on.
58
+ *
59
+ * `req.user` is this server's identifier for the caller and differs per
60
+ * authenticator — a token's name, an OIDC `sub`, a tenant — so a rule
61
+ * written for one is inert under another. `req.userName` is the same caller
62
+ * under a name a person would use, which is what makes a rule portable.
63
+ *
64
+ * When `req.userName` is asked for and missing, the answer falls back to
65
+ * `req.user` rather than to nothing: an authenticated caller read as
66
+ * anonymous would be *skipped* by the branch plugins, which is the one
67
+ * outcome a misconfiguration must not produce. The caller is told so it can
68
+ * say it once.
69
+ *
70
+ * @param req the request, or undefined
71
+ * @param config the server configuration
72
+ * @returns the identity to key on, and whether it is not the configured one
73
+ */
74
+ export declare function identityFor(req: DmRequest | undefined, config: Config): {
75
+ value?: string;
76
+ fellBack: boolean;
77
+ };
78
+ /**
79
+ * What the loaded authenticators can name, and which of them cannot say.
22
80
  *
23
- * Takes the registry as an argument rather than reaching for the server, so
24
- * `DM` and `AuthBase` share one definition of what "claimed" means. The
25
- * registry is a live reference: it keeps filling as the remaining plugins
26
- * register, which is why the catch-all consults it per request.
81
+ * A rule is keyed on an identity, and a rule whose key no authenticator can
82
+ * produce matches nothing — it is not refused, it is *inert*, and that reads
83
+ * as a permission problem months later. Token, TOTP and HMAC names are in
84
+ * the configuration, so this is answerable at startup for them; an identity
85
+ * provider's claims are not, and saying "cannot verify" is the honest
86
+ * answer rather than silence.
27
87
  *
28
88
  * @param loadedPlugins the server's plugin registry
29
- * @param except name of the plugin asking, excluded from the result
30
- * @returns the prefixes other authentication plugins guard
89
+ * @returns the identities that can be named, and the plugins that cannot say
31
90
  */
32
- export declare function claimedPrefixes(loadedPlugins: Record<string, DmPlugin>, except?: string): string[];
91
+ export declare function identityCoverage(loadedPlugins: Record<string, DmPlugin>): {
92
+ known: Set<string>;
93
+ unverifiable: string[];
94
+ };
95
+ /**
96
+ * Say, once at startup, when configured rules name nobody.
97
+ *
98
+ * @param keys the identities the rules are written for
99
+ * @param loadedPlugins the server's plugin registry
100
+ * @param who the plugin asking
101
+ * @param logger where to say it
102
+ */
103
+ export declare function warnUnmatchedRuleKeys(keys: string[], loadedPlugins: Record<string, DmPlugin>, who: string, logger: {
104
+ warn: (message: string) => unknown;
105
+ info: (message: string) => unknown;
106
+ }): void;
33
107
  export default abstract class AuthBase extends DmPlugin {
34
108
  abstract authMethod(req: DmRequest, res: Response, next: () => void): void;
35
109
  /** Validated prefixes, computed once: the catch-all reads them per request */
@@ -73,6 +147,98 @@ export default abstract class AuthBase extends DmPlugin {
73
147
  * @param next called only when the request is authenticated
74
148
  */
75
149
  authenticate(req: Request, res: Response, next: () => void): Promise<void>;
150
+ /**
151
+ * Publish the caller, under both names.
152
+ *
153
+ * Every authenticator calls this rather than assigning `req.user` itself,
154
+ * so that the second value cannot be forgotten by one of them — which is
155
+ * how a rule keyed on it would silently stop matching for that
156
+ * population.
157
+ *
158
+ * @param req request being authenticated
159
+ * @param user what authorization rules are keyed on, this plugin's own
160
+ * identifier for the caller
161
+ * @param userName the caller under a name a person would use, when the
162
+ * plugin has one; the identifier otherwise
163
+ */
164
+ protected publishIdentity(req: DmRequest, user: string, userName?: string): void;
165
+ /**
166
+ * The identities this plugin can publish, when they are known before any
167
+ * request arrives.
168
+ *
169
+ * A configuration lists token, TOTP and HMAC names, so those plugins can
170
+ * answer; an identity provider's claims are only known once someone logs
171
+ * in, so those answer undefined and the check says so rather than
172
+ * guessing.
173
+ *
174
+ * @returns the identities, or undefined when they cannot be known
175
+ */
176
+ protected knownIdentities(): string[] | undefined;
177
+ /**
178
+ * Where the values this plugin publishes come from, for the startup line.
179
+ *
180
+ * Said once at startup because the mismatch it guards against — a rule
181
+ * written for one authenticator, inert under another — is invisible
182
+ * until a request arrives, and then reads as a permission problem.
183
+ *
184
+ * @returns a sentence naming both sources
185
+ */
186
+ protected identitySource(): string;
187
+ /**
188
+ * Say what this plugin will publish, once every plugin is loaded.
189
+ *
190
+ * A subclass overriding `afterLoad` for its own checks should call this
191
+ * one too.
192
+ */
193
+ afterLoad(): void;
194
+ /** Requests this plugin let through without authenticating them. */
195
+ private readonly passedThrough;
196
+ /**
197
+ * Let a request through without vouching for it.
198
+ *
199
+ * The dispatcher records every plugin that calls `next()` as having
200
+ * authenticated the request, and an authorization plugin scoped with
201
+ * `--authz-for` judges by that record. A plugin that steps aside — a
202
+ * bypass naming what *another* plugin authenticated, a verdict deferred to
203
+ * the end of the chain — has not, and must not appear there: a plugin
204
+ * scoped to it would judge requests whose identity it never produced.
205
+ *
206
+ * @param req the request passed on
207
+ * @param next continuation
208
+ */
209
+ protected passThrough(req: Request, next: () => void): void;
210
+ /**
211
+ * Whether this plugin authenticated the request it just let through.
212
+ *
213
+ * @param req a request this plugin called `next()` for
214
+ * @returns false when it only stepped aside
215
+ */
216
+ vouchedFor(req: Request): boolean;
217
+ /**
218
+ * A verdict this plugin could not reach while the chain was still running.
219
+ *
220
+ * The dispatcher calls it, on every selected plugin that implements it,
221
+ * once each of them has authenticated and before any route sees the
222
+ * request — the only moment where `req.user` is final and nothing has
223
+ * answered yet. A plugin needing it must not mount a middleware of its
224
+ * own: `api()` runs during registration, so such a middleware lands
225
+ * wherever that plugin fell among the others, and a route plugin
226
+ * registered first answers before it.
227
+ *
228
+ * `onAuth`/`afterAuth` are not that moment either: they run inside this
229
+ * plugin's own `authenticate`, while the authenticators after it have yet
230
+ * to run.
231
+ *
232
+ * Implement it only to answer a question that depends on what the *other*
233
+ * authenticators did — `core/auth/authzDynamic` uses it to apply a bypass
234
+ * written in terms of an identity another plugin publishes. Calling
235
+ * `next()` passes the request on; ending the response refuses it.
236
+ *
237
+ * @param req the authenticated request
238
+ * @param res response, ended by the plugin when it refuses
239
+ * @param next continuation, called when the plugin has nothing to say
240
+ */
241
+ afterChain?(req: DmRequest, res: Response, next: () => void): void;
76
242
  /**
77
243
  * Register with the server's authentication dispatcher instead of mounting
78
244
  * a middleware.
@@ -8,16 +8,42 @@
8
8
  */
9
9
  import type { SearchOptions } from 'ldapts';
10
10
  import DmPlugin, { type Role } from '../../abstract/plugin';
11
+ import type { DM } from '../../bin';
11
12
  import type { BranchPermissions } from '../../config/args';
12
- import type { DmRequest } from '../auth/base';
13
- import type { AttributesList, ModifyRequest } from '../ldapActions';
13
+ import { type DmRequest } from '../auth/base';
14
+ import type { AttributesList, ModifyRequest, SearchResult } from '../ldapActions';
14
15
  /**
15
16
  * Abstract base class for authorization plugins
16
17
  * Provides common utility methods and interface for LDAP-based authorization
17
18
  */
18
19
  export default abstract class AuthzBase extends DmPlugin {
20
+ /**
21
+ * Searches whose projection we widened to carry the organization link.
22
+ *
23
+ * The filter cannot ask `opts` whether the caller wanted the attribute: by
24
+ * then `opts` is the widened one and would answer yes about an attribute
25
+ * we added ourselves. Holding the objects we touched keeps the question
26
+ * answerable, and a WeakSet lets them be collected with the request.
27
+ */
28
+ private static widenedForLink;
19
29
  roles: Role[];
20
30
  cacheTTL: number;
31
+ /** What `--authz-unresolved-user` accepts. */
32
+ private static readonly UNRESOLVED_POLICIES;
33
+ /**
34
+ * @param server DM object
35
+ */
36
+ constructor(server: DM);
37
+ /**
38
+ * Whether a search asked for this attribute.
39
+ *
40
+ * No projection at all means every user attribute, and so does `*`. `+`
41
+ * does not: it asks for the operational attributes, and the organization
42
+ * link is a user attribute — a search for `+` alone comes back without it,
43
+ * which is the very case the filter cannot judge, since an entry that
44
+ * arrived without its link looks attached to nothing.
45
+ */
46
+ protected static wantsAttribute(opts: SearchOptions | undefined, attr: string): boolean;
21
47
  /**
22
48
  * Extract the branch DN to check permissions against
23
49
  * For a DN like "uid=user,ou=users,ou=org,dc=example,dc=com"
@@ -26,6 +52,21 @@ export default abstract class AuthzBase extends DmPlugin {
26
52
  * Handles escaped commas in DN values (e.g., "cn=Smith\, John")
27
53
  */
28
54
  extractBranchDn(dn: string): string;
55
+ /**
56
+ * The branch a write is judged against.
57
+ *
58
+ * An account is stored in the same `ou=users` as everyone else, so its
59
+ * parent says nothing about who may touch it: what governs it is the
60
+ * organization it is attached to, which is read from the stored entry. An
61
+ * entry attached nowhere falls back to its parent, which is what an
62
+ * organization or a nomenclature value wants.
63
+ *
64
+ * Creation is not routed through here on purpose: `ldapaddrequest` reads
65
+ * the link off the entry being written and is not behind the flag, so
66
+ * unifying the two would judge an add on `ou=users` whenever the flag is
67
+ * off — a relaxation in the default configuration.
68
+ */
69
+ protected effectiveBranch(dn: string): Promise<string>;
29
70
  /**
30
71
  * Resolve user identifier to the format expected by getUserPermissions
31
72
  * This allows different implementations:
@@ -46,17 +87,105 @@ export default abstract class AuthzBase extends DmPlugin {
46
87
  */
47
88
  abstract getAuthorizedBranches(user: string): Promise<string[]>;
48
89
  /**
49
- * Check if authorization should be skipped for this request
50
- * Can be overridden by subclasses for custom logic
90
+ * Whether this request is none of this plugin's business.
91
+ *
92
+ * Two cases, and only two: a request with no identity (anonymous, skipped
93
+ * by design), and one that authentication plugins outside `--authz-for`
94
+ * vouched for — another population, judged by another model. An identity
95
+ * of *this* population that does not resolve is not skipped here: that is
96
+ * a configuration error, and `resolveCaller` refuses it. Inferring "not
97
+ * mine" from "cannot place it" is exactly the confusion this keeps apart.
98
+ *
99
+ * Subclasses overriding it must call this one.
51
100
  */
52
101
  protected shouldSkipAuthorization(req?: DmRequest): boolean;
102
+ /** What `resolveUser` answered for an identity, and when. */
103
+ private resolutionCache;
104
+ /** How many identities to remember at once. A cap, not a threshold. */
105
+ private static readonly RESOLUTION_CACHE_MAX;
106
+ /** Said once, when the cap is reached: a log line per request helps nobody. */
107
+ private resolutionCacheFullWarned;
108
+ /**
109
+ * Resolve the caller, or say what an identity that does not resolve means.
110
+ *
111
+ * `shouldSkipAuthorization` covers the request that carries no identity at
112
+ * all — anonymous, and skipped by design. This covers the other case: a
113
+ * request that *was* authenticated, whose identity this plugin's model
114
+ * cannot place.
115
+ *
116
+ * That used to return the operation untouched behind a `warn`, which made
117
+ * a configuration mismatch an open door rather than a refusal.
118
+ * `authzLinid1` resolves `req.user` by searching
119
+ * `(<ldap_user_main_attribute>=<identity>)`, so an authenticator
120
+ * publishing anything else — `core/auth/openidconnect` publishes the OIDC
121
+ * `sub`, `core/auth/llng` whatever `whatToTrace` traces — resolved to
122
+ * nothing on every request, and read, write and delete went through
123
+ * across the whole tree, with a line a `notice` production log does not
124
+ * show. (`authzPerBranch` fails the other way: the identity is its own key,
125
+ * so a mismatch yields all-false permissions and a refusal.)
126
+ *
127
+ * An authenticated identity that does not resolve is a failure of the
128
+ * configuration, not an absence of one, so it is refused.
129
+ * `--authz-unresolved-user allow` restores the old behaviour for a
130
+ * deployment that turns out to rely on it — a security fix should not
131
+ * rewrite every configuration by itself.
132
+ *
133
+ * Both answers are cached for `cacheTTL`, negatives included: a mismatch
134
+ * would otherwise turn every request into a directory search, so a
135
+ * configuration mistake would also become a load problem. The TTL cuts
136
+ * both ways, and neither way is free:
137
+ *
138
+ * - an identity that appears in the directory after being refused waits
139
+ * out the TTL before it is looked up again;
140
+ * - a *positive* answer can go stale. `authzLinid1` resolves an identity
141
+ * to a DN, so an administrator whose own entry is renamed or moved is
142
+ * still resolved to the former DN, where no organization names them:
143
+ * every operation is refused until the TTL runs out. A rename or a
144
+ * delete therefore drops what was resolved, which closes the window
145
+ * for the case that produces it; a change made directly in the
146
+ * directory still waits.
147
+ *
148
+ * `authzScope` resolves the identity itself (`authzScope.ts`), outside
149
+ * this cache, so during such a window it can describe a scope the hooks
150
+ * no longer grant.
151
+ *
152
+ * @param req the request being authorized, which carries an identity
153
+ * @returns the resolved user, or null when the policy is to allow it
154
+ * @throws ForbiddenError when the policy is to deny it
155
+ */
156
+ /** Said once: a rule keyed on a value the authenticator did not publish. */
157
+ private fallbackWarned;
158
+ protected resolveCaller(req: DmRequest): Promise<string | null>;
159
+ /** Forget what was resolved, for a test that changes the directory. */
160
+ protected clearResolutionCache(): void;
53
161
  /**
54
162
  * Common hooks for all authorization plugins
55
163
  */
56
164
  hooks: {
165
+ /**
166
+ * A rename moves the entry an identity resolves to, so what was
167
+ * resolved is dropped.
168
+ *
169
+ * `authzLinid1` keys its permissions on the administrator's DN: renamed
170
+ * or moved, they resolve to a DN no organization names any more, and
171
+ * every operation of theirs is refused until the TTL runs out. The
172
+ * whole map goes rather than one key, since what is cached is
173
+ * identity → DN and the hook carries DNs: a handful of lookups is
174
+ * cheaper than working out which identity moved.
175
+ */
176
+ ldaprenamedone: () => void;
57
177
  ldapmodifyrequest: ([dn, changes, opNumber, req]: [string, ModifyRequest, number, DmRequest?]) => Promise<[string, ModifyRequest, number, DmRequest?]>;
58
178
  ldapaddrequest: ([dn, entry, req]: [string, AttributesList, DmRequest?]) => Promise<[string, AttributesList, DmRequest?]>;
59
179
  ldapsearchrequest: ([base, opts, req]: [string, SearchOptions, DmRequest?]) => Promise<[string, SearchOptions, DmRequest?]>;
180
+ /**
181
+ * Drop the accounts attached outside the branches this caller manages.
182
+ *
183
+ * Runs after the cache and carries the request, so what one caller may
184
+ * not see never reaches another. An entry with no organization link —
185
+ * an organization, a group, a nomenclature value — is left alone: those
186
+ * are the reference data every administrator reads.
187
+ */
188
+ ldapsearchfilter: ([result, req, opts]: [SearchResult, DmRequest?, SearchOptions?]) => Promise<[SearchResult, DmRequest?, SearchOptions?]>;
60
189
  getOrganisationTop: ([req, defaultTop]: [DmRequest | undefined, AttributesList | null]) => Promise<[DmRequest | undefined, AttributesList | null]>;
61
190
  ldaprenamerequest: ([oldDn, newDn, req]: [string, string, DmRequest?]) => Promise<[string, string, DmRequest?]>;
62
191
  ldapdeleterequest: ([dn, req]: [string | string[], DmRequest?]) => Promise<[string | string[], DmRequest?]>;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @module lib/authz/composition
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * How authorization plugins loaded together compose, and the configurations
6
+ * where that composition is nobody's decision.
7
+ *
8
+ * Every plugin judging LDAP operations registers the same hooks, and
9
+ * `launchHooksChained` runs them all: two of them compose as an AND, the
10
+ * first refusal winning. That is a sound rule when it is meant. It is a
11
+ * refusal nobody can explain when it is not — `authzPerBranch` beside
12
+ * `authzDynamic` judges a machine token by a branch configuration written
13
+ * for administrators, finds no key for the tenant, and refuses a write the
14
+ * token's own ACLs grant. So a plugin says whose requests it judges
15
+ * (`--authz-for`), and the server refuses to start when two of them judge
16
+ * the same ones without `--authz-combine`.
17
+ * @group Libraries
18
+ */
19
+ import type DmPlugin from '../../abstract/plugin';
20
+ import type { DM } from '../../bin';
21
+ import type { Config } from '../../config/args';
22
+ import type { DmRequest } from '../auth/base';
23
+ /**
24
+ * The hooks an LDAP operation is judged in: refused from, or — for
25
+ * `ldapsearchfilter` — narrowed to what the caller may see. Two plugins on
26
+ * the last one compose as an intersection, which is the same AND.
27
+ */
28
+ export declare const LDAP_JUDGING_HOOKS: readonly ["ldapsearchrequest", "ldapaddrequest", "ldapmodifyrequest", "ldapdeleterequest", "ldaprenamerequest", "ldapsearchfilter"];
29
+ /** Every authenticated request, whatever authenticated it. */
30
+ export declare const EVERYONE = "everyone";
31
+ /** The requests a plugin judges: named authenticators', or everyone's. */
32
+ export type Population = string[] | typeof EVERYONE;
33
+ /** An authentication plugin, as far as composition is concerned. */
34
+ interface Authenticator {
35
+ name: string;
36
+ pathPrefixes: string[];
37
+ }
38
+ /**
39
+ * The authentication plugins an authorization plugin judges the requests of.
40
+ *
41
+ * @param config the plugin's configuration
42
+ * @param who the plugin, for the message
43
+ * @returns their instance names, or undefined for every one of them
44
+ * @throws Error when the option is not a list of names
45
+ */
46
+ export declare function authzFor(config: Config, who: string): string[] | undefined;
47
+ /**
48
+ * Whether a plugin configured with `config` judges this request.
49
+ *
50
+ * A request no authenticator vouched for — the list absent, or empty while
51
+ * something set `req.user` anyway — is judged: skipping is only for a
52
+ * request that *someone else's* authenticator vouched for, and inferring
53
+ * that from an absence would turn a gap into an open door.
54
+ *
55
+ * @param req the request, or undefined for an operation outside one
56
+ * @param config the plugin's configuration
57
+ * @returns false only when every authenticator that vouched is another's
58
+ */
59
+ export declare function servesRequest(req: DmRequest | undefined, config: Config): boolean;
60
+ /**
61
+ * Whether two authenticators can both vouch for one request.
62
+ *
63
+ * The dispatcher runs the plugins claiming the longest matching prefix, all
64
+ * of them, so two plugins run together exactly when they claim the same
65
+ * prefix — or when neither claims any, on the paths nobody else does. A
66
+ * prefix nested in another's is not shared: the longer one wins on its
67
+ * paths, and the shorter one alone runs on the rest.
68
+ *
69
+ * @param a an authenticator
70
+ * @param b another one
71
+ * @returns true when a request can carry both stamps
72
+ */
73
+ export declare function runTogether(a: Authenticator, b: Authenticator): boolean;
74
+ /** A plugin that judges LDAP operations, and whose. */
75
+ export interface Judge {
76
+ plugin: DmPlugin;
77
+ hooks: string[];
78
+ population: Population;
79
+ }
80
+ /**
81
+ * The plugins judging LDAP operations.
82
+ *
83
+ * A plugin that authenticates as well — `authzDynamic` — judges its own
84
+ * requests and no others: its hooks read the token it verified, and a request
85
+ * without one passes them untouched. The others judge the population
86
+ * `--authz-for` gives them, everyone by default.
87
+ *
88
+ * @param server the server, once every plugin is loaded
89
+ * @returns one entry per judging plugin, in registration order
90
+ */
91
+ export declare function ldapJudges(server: DM): Judge[];
92
+ /**
93
+ * Whether two populations can share a request, and how.
94
+ *
95
+ * @param a a population
96
+ * @param b another one
97
+ * @param authenticators the loaded authentication plugins
98
+ * @returns `same` when one authenticator is in both, `together` when a
99
+ * request can be vouched for by one of each, undefined otherwise
100
+ */
101
+ export declare function overlap(a: Population, b: Population, authenticators: Authenticator[]): 'same' | 'together' | undefined;
102
+ /**
103
+ * Say a population in words, for the log.
104
+ *
105
+ * `everyone` names the authenticators it takes in: the accident this module
106
+ * is about is a branch model judging an authenticator nobody wrote it for,
107
+ * and a line saying "every request" would not show it.
108
+ *
109
+ * @param population the population
110
+ * @param authenticators the loaded authentication plugins
111
+ * @returns a phrase
112
+ */
113
+ export declare function describePopulation(population: Population, authenticators: Authenticator[]): string;
114
+ /**
115
+ * Refuse the compositions nobody decided, and say the others.
116
+ *
117
+ * - an `authz_for` naming no loaded authentication plugin is refused: the
118
+ * plugin would judge nobody, which reads as a working configuration;
119
+ * - an `authz_for` of its own on a plugin that authenticates is said: it
120
+ * judges the requests it vouched for, and the option is inert there;
121
+ * - two plugins judging the LDAP operations of the same authenticator's
122
+ * requests are refused unless `--authz-combine` makes the AND deliberate;
123
+ * - two judging authenticators that run on the same prefix — a request
124
+ * carrying both credentials is judged by both — are said, not refused:
125
+ * asking for two credentials is already a decision;
126
+ * - every judge is named with the hooks it takes part in and whose requests
127
+ * it judges.
128
+ *
129
+ * `authzPerRoute` gates URLs and registers no LDAP hook, so it is never part
130
+ * of the AND this refuses: route plus branch is a documented combination,
131
+ * and each side judges identities it can name.
132
+ *
133
+ * Run once every plugin of the configuration is loaded, over all of them;
134
+ * and for a plugin registered after that — a server assembled by hand —
135
+ * over what that one plugin adds, before it is registered. What it adds is
136
+ * judged against what is already there, so an authorization plugin naming
137
+ * authenticators is registered after them.
138
+ *
139
+ * @param server the server
140
+ * @param candidate a plugin about to be registered, present in
141
+ * `server.loadedPlugins`; only what concerns it is checked
142
+ * @throws Error naming the plugins when the composition is ambiguous
143
+ */
144
+ export declare function assertAuthzComposition(server: DM, candidate?: DmPlugin): void;
145
+ export {};
@@ -4,8 +4,15 @@
4
4
  */
5
5
  import type { Request, Response } from 'express';
6
6
  import type winston from 'winston';
7
- export declare const setLogger: (logger: winston.Logger) => void;
8
- export declare const getLogger: () => winston.Logger;
7
+ /**
8
+ * Install the logger, or remove it: a test restores the state it found,
9
+ * which before any `DM` is none.
10
+ *
11
+ * @param logger the logger, or undefined for none
12
+ */
13
+ export declare const setLogger: (logger: winston.Logger | undefined) => void;
14
+ /** @returns the installed logger, or undefined before any `DM` exists */
15
+ export declare const getLogger: () => winston.Logger | undefined;
9
16
  export declare const tryMethod: (res: Response, method: Function, ...args: unknown[]) => Promise<void>;
10
17
  export declare const tryMethodData: (res: Response, method: Function, ...args: unknown[]) => Promise<void>;
11
18
  export declare const ok: (res: Response, data?: object) => void;
@@ -117,11 +117,41 @@ declare class ldapActions {
117
117
  * below it and the extra test costs nothing.
118
118
  */
119
119
  invalidateCache(dn: string): void;
120
+ /**
121
+ * The directory as the server itself, with no request behind it.
122
+ *
123
+ * Every authorization plugin skips its check when a call carries no
124
+ * request, so an unbound call is a bypass — a deliberate one where the
125
+ * work belongs to nobody in particular: a startup task, a cron job, a
126
+ * uniqueness check, a referential-integrity check. Those must see the
127
+ * whole directory to be correct: a caller who cannot *read* `uid=jdoe`
128
+ * must still not be handed it as a free identifier, and a reference to an
129
+ * organization they cannot read is still a valid reference.
130
+ *
131
+ * The methods are the same objects as this instance's own — writes
132
+ * included, since a scheduled task writes: the examples above are reads
133
+ * because that is where the ambiguity lives, not because a write cannot
134
+ * belong to nobody. What this adds is a name: `ldap.system.search(…)` says
135
+ * the omission was meant, where `ldap.search(…)` says nothing and looks
136
+ * exactly like the mistake it takes one review to miss. A plugin serving a
137
+ * request should reach for {@link forRequest} instead.
138
+ */
139
+ get system(): this;
120
140
  /**
121
141
  * Bind every directory operation to one request, so the authorization
122
142
  * hooks always see it. See {@link RequestBoundLdap}.
123
143
  */
124
144
  forRequest(req: Request): RequestBoundLdap;
145
+ /**
146
+ * Hand a search result to `ldapsearchfilter`, which may drop entries the
147
+ * caller is not allowed to see.
148
+ *
149
+ * Applied on every return path of `search`, and deliberately after the
150
+ * cache: `ldapsearchresult` fires before caching and knows no request, so
151
+ * what it removed for one caller would be served to the next. A paginated
152
+ * search is filtered chunk by chunk, as it is consumed.
153
+ */
154
+ private filterForCaller;
125
155
  search(options: SearchOptions, base?: string, req?: Request): Promise<SearchResult | AsyncGenerator<SearchResult>>;
126
156
  /**
127
157
  * Wrap paginated search to ensure connection is released when done
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @module lib/ldapCodes
3
+ *
4
+ * Reading an LDAP result code off a thrown error.
5
+ *
6
+ * Callers have to tell one failure from another — noSuchObject from
7
+ * entryAlreadyExists, a size limit from a server fault — and the code is the
8
+ * only part of a failure the protocol defines. The wording is the driver's,
9
+ * and no driver promises to keep it.
10
+ *
11
+ * This lived in `plugins/scim/errors` until an LDAP plugin needed it too;
12
+ * nothing in it is SCIM's business, so it is here and SCIM re-exports it.
13
+ *
14
+ * @group Libraries
15
+ */
16
+ /**
17
+ * Extract an LDAP numeric error code from a thrown ldapts error.
18
+ *
19
+ * `.code` first: ldapts sets it, and `ldapActions` carries it across its own
20
+ * wrapping rather than reducing the failure to a sentence. The message
21
+ * patterns below stay as a fallback for the paths that still throw a bare
22
+ * `Error` — they are why a driver that reworded its messages could quietly
23
+ * turn a 400 into a 500, so they are the last resort, not the first.
24
+ *
25
+ * @param err whatever was thrown
26
+ * @returns the LDAP result code, or undefined when nothing says
27
+ */
28
+ export declare function extractLdapCode(err: unknown): number | undefined;