ldap-rest 0.8.2 → 0.10.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.
- package/bin/index.mjs +1 -1
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/auth/base.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/authz/composition.js +1 -0
- package/dist/lib/changeContext.js +1 -0
- package/dist/lib/errors.js +1 -1
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapCodes.js +1 -0
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/storage/store.js +1 -0
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/authzDynamic.js +1 -1
- package/dist/plugins/auth/authzLinid1.js +1 -1
- package/dist/plugins/auth/authzPerBranch.js +1 -1
- package/dist/plugins/auth/authzPerRoute.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/auth/hmac.js +1 -1
- package/dist/plugins/auth/llng.js +1 -1
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/auth/token.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/bcl.js +1 -0
- package/dist/plugins/ldap/departmentSync.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/priority.json.js +1 -1
- package/dist/plugins/scim/baseResolver.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/scim/groups.js +1 -1
- package/dist/plugins/scim/list.js +1 -1
- package/dist/plugins/scim/scim.js +1 -1
- package/dist/plugins/scim/users.js +1 -1
- package/dist/plugins/storage/file.js +1 -0
- package/dist/plugins/storage/ldap.js +1 -0
- package/dist/plugins/storage.js +1 -0
- package/dist/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/plugins/twake/clouderyProvision.js +1 -1
- package/dist/plugins/twake/cozyProvision.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +7 -0
- package/dist/src/abstract/plugin.d.ts +26 -0
- package/dist/src/bin/index.d.ts +50 -8
- package/dist/src/config/args.d.ts +36 -0
- package/dist/src/hooks.d.ts +70 -6
- package/dist/src/lib/auth/base.d.ts +175 -9
- package/dist/src/lib/authz/base.d.ts +159 -4
- package/dist/src/lib/authz/composition.d.ts +145 -0
- package/dist/src/lib/changeContext.d.ts +24 -0
- package/dist/src/lib/errors.d.ts +15 -0
- package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
- package/dist/src/lib/ldapActions.d.ts +32 -1
- package/dist/src/lib/ldapCodes.d.ts +28 -0
- package/dist/src/lib/storage/store.d.ts +84 -0
- package/dist/src/lib/utils.d.ts +19 -0
- package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
- package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
- package/dist/src/plugins/auth/authzPerBranch.d.ts +46 -0
- package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
- package/dist/src/plugins/auth/authzScope.d.ts +44 -3
- package/dist/src/plugins/auth/hmac.d.ts +2 -0
- package/dist/src/plugins/auth/llng.d.ts +39 -0
- package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
- package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
- package/dist/src/plugins/auth/token.d.ts +2 -0
- package/dist/src/plugins/auth/totp.d.ts +2 -0
- package/dist/src/plugins/bcl.d.ts +65 -0
- package/dist/src/plugins/ldap/groups.d.ts +7 -5
- package/dist/src/plugins/ldap/onChange.d.ts +17 -8
- package/dist/src/plugins/ldap/organizations.d.ts +92 -3
- package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
- package/dist/src/plugins/scim/errors.d.ts +2 -10
- package/dist/src/plugins/storage/file.d.ts +19 -0
- package/dist/src/plugins/storage/ldap.d.ts +17 -0
- package/dist/src/plugins/storage.d.ts +44 -0
- package/docs/plugin-development/README.md +13 -19
- package/docs/plugin-development/dependencies.md +40 -15
- package/docs/plugin-development/hooks.md +6 -0
- package/docs/plugin-development/testing.md +1 -17
- package/docs/usage/configuration.md +90 -14
- package/docs/usage/plugins/README.md +17 -12
- package/docs/usage/plugins/auth/README.md +129 -0
- package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
- package/docs/usage/plugins/auth/authz-linid1.md +33 -0
- package/docs/usage/plugins/auth/authz-per-branch.md +41 -11
- package/docs/usage/plugins/auth/authz-per-route.md +6 -0
- package/docs/usage/plugins/auth/authz-scope.md +74 -16
- package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
- package/docs/usage/plugins/auth/llng.md +29 -0
- package/docs/usage/plugins/auth/oidc.md +52 -0
- package/docs/usage/plugins/ldap/on-change.md +51 -284
- package/docs/usage/plugins/ldap/organizations.md +37 -0
- package/docs/usage/plugins/utilities/storage.md +94 -0
- package/docs/usage/troubleshooting.md +1 -9
- package/docs/usage/upgrading.md +341 -0
- 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
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
|
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,35 @@ 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>;
|
|
70
|
+
/**
|
|
71
|
+
* The DN of the entry an identity names, or null when none does.
|
|
72
|
+
*
|
|
73
|
+
* Searched under `server.ldap.base`, the base every other search uses:
|
|
74
|
+
* `--ldap-base`, which the server refuses to start without.
|
|
75
|
+
*
|
|
76
|
+
* More than one entry is a refusal, not a pick: which one comes first is
|
|
77
|
+
* the server's business, and differs between replicas. A failed search
|
|
78
|
+
* propagates rather than reading as "no such user", so a caller that
|
|
79
|
+
* caches the answer does not cache an outage as a fact.
|
|
80
|
+
*
|
|
81
|
+
* @throws AmbiguousIdentityError when several entries carry the identity
|
|
82
|
+
*/
|
|
83
|
+
protected findUserDn(uid: string): Promise<string | null>;
|
|
29
84
|
/**
|
|
30
85
|
* Resolve user identifier to the format expected by getUserPermissions
|
|
31
86
|
* This allows different implementations:
|
|
@@ -46,17 +101,117 @@ export default abstract class AuthzBase extends DmPlugin {
|
|
|
46
101
|
*/
|
|
47
102
|
abstract getAuthorizedBranches(user: string): Promise<string[]>;
|
|
48
103
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
104
|
+
* Whether this request is none of this plugin's business.
|
|
105
|
+
*
|
|
106
|
+
* Two cases, and only two: a request with no identity (anonymous, skipped
|
|
107
|
+
* by design), and one that authentication plugins outside `--authz-for`
|
|
108
|
+
* vouched for — another population, judged by another model. An identity
|
|
109
|
+
* of *this* population that does not resolve is not skipped here: that is
|
|
110
|
+
* a configuration error, and `resolveCaller` refuses it. Inferring "not
|
|
111
|
+
* mine" from "cannot place it" is exactly the confusion this keeps apart.
|
|
112
|
+
*
|
|
113
|
+
* Subclasses overriding it must call this one.
|
|
51
114
|
*/
|
|
52
115
|
protected shouldSkipAuthorization(req?: DmRequest): boolean;
|
|
116
|
+
/** What `resolveUser` answered for an identity, and when. */
|
|
117
|
+
private resolutionCache;
|
|
118
|
+
/** How many identities to remember at once. A cap, not a threshold. */
|
|
119
|
+
private static readonly RESOLUTION_CACHE_MAX;
|
|
120
|
+
/** Said once, when the cap is reached: a log line per request helps nobody. */
|
|
121
|
+
private resolutionCacheFullWarned;
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the caller, or say what an identity that does not resolve means.
|
|
124
|
+
*
|
|
125
|
+
* `shouldSkipAuthorization` covers the request that carries no identity at
|
|
126
|
+
* all — anonymous, and skipped by design. This covers the other case: a
|
|
127
|
+
* request that *was* authenticated, whose identity this plugin's model
|
|
128
|
+
* cannot place.
|
|
129
|
+
*
|
|
130
|
+
* That used to return the operation untouched behind a `warn`, which made
|
|
131
|
+
* a configuration mismatch an open door rather than a refusal.
|
|
132
|
+
* `authzLinid1` resolves `req.user` by searching
|
|
133
|
+
* `(<ldap_user_main_attribute>=<identity>)`, so an authenticator
|
|
134
|
+
* publishing anything else — `core/auth/openidconnect` publishes the OIDC
|
|
135
|
+
* `sub`, `core/auth/llng` whatever `whatToTrace` traces — resolved to
|
|
136
|
+
* nothing on every request, and read, write and delete went through
|
|
137
|
+
* across the whole tree, with a line a `notice` production log does not
|
|
138
|
+
* show. (`authzPerBranch` fails the other way: the identity is its own key,
|
|
139
|
+
* so a mismatch yields all-false permissions and a refusal.)
|
|
140
|
+
*
|
|
141
|
+
* An authenticated identity that does not resolve is a failure of the
|
|
142
|
+
* configuration, not an absence of one, so it is refused.
|
|
143
|
+
* `--authz-unresolved-user allow` restores the old behaviour for a
|
|
144
|
+
* deployment that turns out to rely on it — a security fix should not
|
|
145
|
+
* rewrite every configuration by itself.
|
|
146
|
+
*
|
|
147
|
+
* Both answers are cached for `cacheTTL`, negatives included: a mismatch
|
|
148
|
+
* would otherwise turn every request into a directory search, so a
|
|
149
|
+
* configuration mistake would also become a load problem. The TTL cuts
|
|
150
|
+
* both ways, and neither way is free:
|
|
151
|
+
*
|
|
152
|
+
* - an identity that appears in the directory after being refused waits
|
|
153
|
+
* out the TTL before it is looked up again;
|
|
154
|
+
* - a *positive* answer can go stale. `authzLinid1` resolves an identity
|
|
155
|
+
* to a DN, so an administrator whose own entry is renamed or moved is
|
|
156
|
+
* still resolved to the former DN, where no organization names them:
|
|
157
|
+
* every operation is refused until the TTL runs out. A rename or a
|
|
158
|
+
* delete therefore drops what was resolved, which closes the window
|
|
159
|
+
* for the case that produces it; a change made directly in the
|
|
160
|
+
* directory still waits.
|
|
161
|
+
*
|
|
162
|
+
* `authzScope` resolves the identity itself (`authzScope.ts`), outside
|
|
163
|
+
* this cache, so during such a window it can describe a scope the hooks
|
|
164
|
+
* no longer grant.
|
|
165
|
+
*
|
|
166
|
+
* @param req the request being authorized, which carries an identity
|
|
167
|
+
* @returns the resolved user, or null when the policy is to allow it
|
|
168
|
+
* @throws ForbiddenError when the policy is to deny it
|
|
169
|
+
*/
|
|
170
|
+
/** Said once: a rule keyed on a value the authenticator did not publish. */
|
|
171
|
+
private fallbackWarned;
|
|
172
|
+
protected resolveCaller(req: DmRequest): Promise<string | null>;
|
|
173
|
+
/** Forget what was resolved, for a test that changes the directory. */
|
|
174
|
+
protected clearResolutionCache(): void;
|
|
53
175
|
/**
|
|
54
176
|
* Common hooks for all authorization plugins
|
|
55
177
|
*/
|
|
56
178
|
hooks: {
|
|
179
|
+
/**
|
|
180
|
+
* A rename moves the entry an identity resolves to, so what was
|
|
181
|
+
* resolved is dropped.
|
|
182
|
+
*
|
|
183
|
+
* `authzLinid1` keys its permissions on the administrator's DN: renamed
|
|
184
|
+
* or moved, they resolve to a DN no organization names any more, and
|
|
185
|
+
* every operation of theirs is refused until the TTL runs out. The
|
|
186
|
+
* whole map goes rather than one key, since what is cached is
|
|
187
|
+
* identity → DN and the hook carries DNs: a handful of lookups is
|
|
188
|
+
* cheaper than working out which identity moved.
|
|
189
|
+
*/
|
|
190
|
+
ldaprenamedone: () => void;
|
|
191
|
+
/**
|
|
192
|
+
* A delete removes the entry an identity resolves to, exactly as a
|
|
193
|
+
* rename moves it, so what was resolved is dropped the same way.
|
|
194
|
+
*
|
|
195
|
+
* An administrator deleted through the API is, for `authzLinid1`, still
|
|
196
|
+
* resolved to the former DN where the organization still names them:
|
|
197
|
+
* their session keeps its rights until the TTL runs out. Like the rename
|
|
198
|
+
* hook, the whole map goes rather than one key — the hook carries the
|
|
199
|
+
* deleted DN, and the cache keys identities, so a handful of lookups is
|
|
200
|
+
* cheaper than working out which identity the DN held.
|
|
201
|
+
*/
|
|
202
|
+
ldapdeletedone: () => void;
|
|
57
203
|
ldapmodifyrequest: ([dn, changes, opNumber, req]: [string, ModifyRequest, number, DmRequest?]) => Promise<[string, ModifyRequest, number, DmRequest?]>;
|
|
58
204
|
ldapaddrequest: ([dn, entry, req]: [string, AttributesList, DmRequest?]) => Promise<[string, AttributesList, DmRequest?]>;
|
|
59
205
|
ldapsearchrequest: ([base, opts, req]: [string, SearchOptions, DmRequest?]) => Promise<[string, SearchOptions, DmRequest?]>;
|
|
206
|
+
/**
|
|
207
|
+
* Drop the accounts attached outside the branches this caller manages.
|
|
208
|
+
*
|
|
209
|
+
* Runs after the cache and carries the request, so what one caller may
|
|
210
|
+
* not see never reaches another. An entry with no organization link —
|
|
211
|
+
* an organization, a group, a nomenclature value — is left alone: those
|
|
212
|
+
* are the reference data every administrator reads.
|
|
213
|
+
*/
|
|
214
|
+
ldapsearchfilter: ([result, req, opts]: [SearchResult, DmRequest?, SearchOptions?]) => Promise<[SearchResult, DmRequest?, SearchOptions?]>;
|
|
60
215
|
getOrganisationTop: ([req, defaultTop]: [DmRequest | undefined, AttributesList | null]) => Promise<[DmRequest | undefined, AttributesList | null]>;
|
|
61
216
|
ldaprenamerequest: ([oldDn, newDn, req]: [string, string, DmRequest?]) => Promise<[string, string, DmRequest?]>;
|
|
62
217
|
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 {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
export interface ChangeContext {
|
|
3
|
+
/** The caller, under the name a person would use when there is one */
|
|
4
|
+
actor?: string;
|
|
5
|
+
/** Shared by every write of one request */
|
|
6
|
+
requestId?: string;
|
|
7
|
+
/** `rest`, `scim`, or what the plugin serving the request declared */
|
|
8
|
+
source?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Declare the door a request came through, for the writes it makes.
|
|
12
|
+
*
|
|
13
|
+
* @param req the request
|
|
14
|
+
* @param source its name, e.g. `scim`
|
|
15
|
+
*/
|
|
16
|
+
export declare function setChangeSource(req: Request, source: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* The context of a write, handed to the "done" hooks.
|
|
19
|
+
*
|
|
20
|
+
* @param req the request behind the write; none for a scheduled task or a
|
|
21
|
+
* message consumer, whose context is empty
|
|
22
|
+
* @returns the context
|
|
23
|
+
*/
|
|
24
|
+
export declare function changeContext(req?: Request): ChangeContext;
|
package/dist/src/lib/errors.d.ts
CHANGED
|
@@ -21,6 +21,21 @@ export declare class UnauthorizedError extends HttpError {
|
|
|
21
21
|
export declare class ForbiddenError extends HttpError {
|
|
22
22
|
constructor(message?: string);
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* An identity that names more than one directory entry.
|
|
26
|
+
*
|
|
27
|
+
* A `uid` (or whatever `--ldap-user-main-attribute` names) is only unique if
|
|
28
|
+
* the directory enforces it, which OpenLDAP does not by default. Taking the
|
|
29
|
+
* first entry would hand the caller whichever one the server happens to list
|
|
30
|
+
* first — and with it that entry's groups or organizations. It is a refusal
|
|
31
|
+
* rather than a `null`: a `null` resolution is what
|
|
32
|
+
* `--authz-unresolved-user allow` lets through unchecked.
|
|
33
|
+
*/
|
|
34
|
+
export declare class AmbiguousIdentityError extends ForbiddenError {
|
|
35
|
+
identity: string;
|
|
36
|
+
count: number;
|
|
37
|
+
constructor(identity: string, count: number);
|
|
38
|
+
}
|
|
24
39
|
export declare class NotFoundError extends HttpError {
|
|
25
40
|
constructor(message?: string);
|
|
26
41
|
}
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Request, Response } from 'express';
|
|
6
6
|
import type winston from 'winston';
|
|
7
|
-
|
|
8
|
-
|
|
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;
|