@lenne.tech/nest-server 11.42.2 → 11.42.4
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/.claude/rules/configurable-features.md +3 -1
- package/.claude/rules/package-management.md +1 -1
- package/.claude/rules/role-system.md +31 -3
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +7 -1
- package/FRAMEWORK-API.md +2 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +9 -0
- package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
- package/dist/core/common/services/core-body-parser.initializer.js +136 -0
- package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
- package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.js +19 -0
- package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
- package/dist/core/modules/better-auth/better-auth.config.js +27 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +9 -0
- package/dist/core/modules/error-code/error-codes.js +8 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +11 -6
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core.module.d.ts +3 -0
- package/dist/core.module.js +102 -42
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +29 -0
- package/docs/error-codes.md +16 -0
- package/docs/security-overrides.md +18 -2
- package/migration-guides/11.42.2-to-11.42.3.md +145 -0
- package/migration-guides/11.42.3-to-11.42.4.md +185 -0
- package/package.json +3 -3
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
- package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
- package/src/core/common/interfaces/server-options.interface.ts +121 -2
- package/src/core/common/services/core-body-parser.initializer.ts +232 -0
- package/src/core/modules/auth/services/core-auth.service.ts +37 -0
- package/src/core/modules/better-auth/better-auth.config.ts +46 -1
- package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
- package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
- package/src/core/modules/error-code/error-codes.ts +9 -0
- package/src/core/modules/tenant/README.md +8 -8
- package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
- package/src/core/modules/user/core-user.service.ts +19 -6
- package/src/core.module.ts +170 -55
- package/src/index.ts +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { ConfigService } from '../services/config.service';
|
|
4
|
+
import { getTenantContextResolver, ResolvedTenantContext } from '../services/core-tenant-context.registry';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The authorization lifecycle of an OPEN GraphQL WebSocket connection.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS: a WebSocket is authorized at the handshake. `onConnect` validates the session and
|
|
10
|
+
* records the user; the subscribe wrapper (`graphql-ws-context.helper.ts`) resolves the tenant. Before
|
|
11
|
+
* 11.42.3 nothing ever looked again — every delivered event and every further operation on the socket
|
|
12
|
+
* ran on that snapshot. Removing somebody from a tenant, switching a tenant off, withdrawing a role or
|
|
13
|
+
* ending a session therefore took effect on every NEW request and on none of the sockets that were
|
|
14
|
+
* already open, for as long as the client kept them open. For a browser tab that is hours.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT DOES: each connection is registered with the rights it was authorized with — the user's id,
|
|
17
|
+
* roles and verification state, and the tenant scope its operations resolved — plus a function that
|
|
18
|
+
* re-runs the handshake's authentication. Those rights are checked again
|
|
19
|
+
*
|
|
20
|
+
* - when an INVALIDATION names the connection (`revalidateWsConnectionsOf()` from a membership or
|
|
21
|
+
* role change, `revalidateWsConnectionsForTenant()` from a tenant switch, a password reset), at
|
|
22
|
+
* once and before any further event, and
|
|
23
|
+
* - before an event is delivered or an operation runs, once the last check is older than
|
|
24
|
+
* `graphQl.subscriptionRevalidationMs` (default 30 s) — the bound for a change nobody invalidated,
|
|
25
|
+
* such as a session that was signed out or a membership edited directly in the database.
|
|
26
|
+
*
|
|
27
|
+
* A connection whose rights CHANGED — narrowed or widened — is closed with 4403. It is not patched in
|
|
28
|
+
* place, deliberately: a subscription may have chosen its topic or its filter from the tenant at
|
|
29
|
+
* subscribe time, and only a fresh subscribe re-derives that. The graphql-ws client RETRIES 4403 (its
|
|
30
|
+
* own comment: "might grant access after retry"), so the user reconnects and continues with the
|
|
31
|
+
* rights they hold now, or is refused at `onConnect` if the session is gone.
|
|
32
|
+
*
|
|
33
|
+
* WHAT IT DOES NOT DO: an event is checked AFTER graphql-js executed it and BEFORE it is sent. Reads
|
|
34
|
+
* made while computing a dropped event still happened — under the old context — but nothing of them
|
|
35
|
+
* reaches the client. Subscription resolvers that WRITE are outside what this can take back.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Close code for a connection whose authorization changed: graphql-ws's `CloseCode.Forbidden`. Its
|
|
40
|
+
* client retries it, which is what makes the close a re-authorization rather than a hang-up.
|
|
41
|
+
*/
|
|
42
|
+
export const WS_AUTHORIZATION_CHANGED_CLOSE_CODE = 4403;
|
|
43
|
+
|
|
44
|
+
/** Default for `graphQl.subscriptionRevalidationMs`. */
|
|
45
|
+
export const DEFAULT_SUBSCRIPTION_REVALIDATION_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
/** The parts of a socket this helper needs: closing it, and noticing that it closed. */
|
|
48
|
+
export interface IWsConnectionSocket {
|
|
49
|
+
close(code: number, reason?: string): void;
|
|
50
|
+
once?(event: 'close', listener: (...args: any[]) => void): unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IWsConnectionRegistration {
|
|
54
|
+
/**
|
|
55
|
+
* The object every operation of the connection receives as its GraphQL context: `extra` for
|
|
56
|
+
* graphql-ws, the object `onConnect` returns for subscriptions-transport-ws (which copies it per
|
|
57
|
+
* operation — the registration survives that copy).
|
|
58
|
+
*/
|
|
59
|
+
carrier: object;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Re-run the handshake's authentication. Resolves to the user the credential authenticates as NOW,
|
|
63
|
+
* or to a falsy value when it no longer does. A rejection counts as falsy. Without it only the
|
|
64
|
+
* tenant scope is re-checked.
|
|
65
|
+
*/
|
|
66
|
+
reauthenticate?: () => any;
|
|
67
|
+
|
|
68
|
+
/** The raw socket — closed when the rights changed, and watched so a closed connection is forgotten. */
|
|
69
|
+
socket?: IWsConnectionSocket;
|
|
70
|
+
|
|
71
|
+
/** The user the handshake authenticated. */
|
|
72
|
+
user: any;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Who an invalidation is about. Any one field is enough; `userId` is the `users` document id. */
|
|
76
|
+
export interface IWsConnectionIdentity {
|
|
77
|
+
email?: string;
|
|
78
|
+
iamId?: string;
|
|
79
|
+
userId?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @internal The registered state of one connection. */
|
|
83
|
+
export interface WsConnection {
|
|
84
|
+
readonly email?: string;
|
|
85
|
+
readonly iamId?: string;
|
|
86
|
+
readonly reauthenticate?: () => any;
|
|
87
|
+
readonly socket?: IWsConnectionSocket;
|
|
88
|
+
/** The tenant scope the connection's operations resolved, once one has. */
|
|
89
|
+
tenant?: ResolvedTenantContext;
|
|
90
|
+
/** The tenant header the client sent — constant for the life of the connection. */
|
|
91
|
+
readonly tenantHeader?: string;
|
|
92
|
+
terminated: boolean;
|
|
93
|
+
readonly user: any;
|
|
94
|
+
readonly userFingerprint: string;
|
|
95
|
+
readonly userId?: string;
|
|
96
|
+
/** @internal */
|
|
97
|
+
inflight?: Promise<boolean>;
|
|
98
|
+
/** @internal */
|
|
99
|
+
stale: boolean;
|
|
100
|
+
/** @internal */
|
|
101
|
+
tenantFingerprint?: string;
|
|
102
|
+
/** @internal */
|
|
103
|
+
validatedAt: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Module-private and not `Symbol.for()`: nothing a client sends — `connectionParams` are JSON — can
|
|
108
|
+
* produce it, so a registration cannot be forged or overwritten from outside. Enumerable on purpose:
|
|
109
|
+
* subscriptions-transport-ws hands each operation an `Object.assign` copy of the connection context,
|
|
110
|
+
* and only enumerable own properties survive that.
|
|
111
|
+
*/
|
|
112
|
+
const WS_CONNECTION = Symbol('ltWsConnection');
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Connections that can be found by an invalidation. Only those with a socket to watch: without a
|
|
116
|
+
* `close` event there is no moment to forget them, and a registry that only grows is a leak. A
|
|
117
|
+
* connection without one is still re-checked by the interval, through its carrier.
|
|
118
|
+
*/
|
|
119
|
+
const liveConnections = new Set<WsConnection>();
|
|
120
|
+
|
|
121
|
+
const logger = new Logger('GraphQlWsConnection');
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve `graphQl.subscriptionRevalidationMs` to an interval, or `false` for "off".
|
|
125
|
+
*
|
|
126
|
+
* Only an explicit `false` switches the interval off. Anything else that is not a usable interval
|
|
127
|
+
* falls back to the DEFAULT, never to "off": this bounds how long revoked access keeps flowing, and a
|
|
128
|
+
* typo in an environment variable must not be the thing that removes the bound. `0` is a legitimate
|
|
129
|
+
* value meaning "on every operation and every event".
|
|
130
|
+
*/
|
|
131
|
+
export function resolveSubscriptionRevalidationMs(value: unknown): false | number {
|
|
132
|
+
if (value === false || value === 'false') {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const numeric = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
136
|
+
if (typeof numeric === 'number' && Number.isFinite(numeric) && numeric >= 0) {
|
|
137
|
+
return numeric;
|
|
138
|
+
}
|
|
139
|
+
return DEFAULT_SUBSCRIPTION_REVALIDATION_MS;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function configuredRevalidationMs(): false | number {
|
|
143
|
+
const graphQl = ConfigService.configFastButReadOnly?.graphQl;
|
|
144
|
+
return resolveSubscriptionRevalidationMs(graphQl ? graphQl.subscriptionRevalidationMs : undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What a connection's rights depend on, as a comparable string. Role ORDER does not count. */
|
|
148
|
+
function fingerprintUser(user: any): string {
|
|
149
|
+
if (!user) {
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
const roles = Array.isArray(user.roles) ? user.roles.map((role: unknown) => String(role)).sort() : [];
|
|
153
|
+
const verified = !!(user.verified || user.verifiedAt || user.emailVerified);
|
|
154
|
+
return JSON.stringify([String(user.id ?? user._id ?? ''), roles, verified]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fingerprintTenant(tenant: ResolvedTenantContext | undefined): string {
|
|
158
|
+
const tenantIds = Array.isArray(tenant?.tenantIds) ? tenant.tenantIds.map((id) => String(id)).sort() : null;
|
|
159
|
+
return JSON.stringify([tenant?.tenantId ?? null, tenantIds, tenant?.tenantRole ?? null, !!tenant?.isAdminBypass]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Same lookup the context wrapper uses — see `readTenantHeader()` in graphql-ws-context.helper.ts. */
|
|
163
|
+
export function readWsTenantHeader(contextValue: any): string | undefined {
|
|
164
|
+
const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
|
|
165
|
+
const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
|
|
166
|
+
for (const source of sources) {
|
|
167
|
+
if (!source || typeof source !== 'object') {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
|
171
|
+
if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Register a connection after a successful handshake. Call it from `onConnect`, with the same user
|
|
181
|
+
* the handshake recorded on the carrier.
|
|
182
|
+
*/
|
|
183
|
+
export function registerWsConnection(registration: IWsConnectionRegistration): void {
|
|
184
|
+
const { carrier, reauthenticate, socket, user } = registration;
|
|
185
|
+
if (!carrier || typeof carrier !== 'object') {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const userId = user?.id ?? user?._id;
|
|
189
|
+
const connection: WsConnection = {
|
|
190
|
+
email: typeof user?.email === 'string' ? user.email.toLowerCase() : undefined,
|
|
191
|
+
iamId: typeof user?.iamId === 'string' ? user.iamId : undefined,
|
|
192
|
+
reauthenticate,
|
|
193
|
+
socket,
|
|
194
|
+
stale: false,
|
|
195
|
+
tenantHeader: readWsTenantHeader(carrier),
|
|
196
|
+
terminated: false,
|
|
197
|
+
user,
|
|
198
|
+
userFingerprint: fingerprintUser(user),
|
|
199
|
+
userId: userId !== undefined && userId !== null ? String(userId) : undefined,
|
|
200
|
+
validatedAt: Date.now(),
|
|
201
|
+
};
|
|
202
|
+
Object.defineProperty(carrier, WS_CONNECTION, { configurable: true, enumerable: true, value: connection });
|
|
203
|
+
|
|
204
|
+
if (socket && typeof socket.once === 'function') {
|
|
205
|
+
liveConnections.add(connection);
|
|
206
|
+
socket.once('close', () => {
|
|
207
|
+
connection.terminated = true;
|
|
208
|
+
liveConnections.delete(connection);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The registered connection a GraphQL context belongs to, if any. */
|
|
214
|
+
export function getWsConnection(contextValue: any): undefined | WsConnection {
|
|
215
|
+
if (!contextValue || typeof contextValue !== 'object') {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const connection = contextValue[WS_CONNECTION];
|
|
219
|
+
return connection && typeof connection === 'object' ? connection : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Close a connection whose authorization no longer holds. Idempotent; always answers `false` so a
|
|
224
|
+
* caller can `return terminate(...)`.
|
|
225
|
+
*/
|
|
226
|
+
function terminate(connection: WsConnection, reason: string): false {
|
|
227
|
+
if (connection.terminated) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
connection.terminated = true;
|
|
231
|
+
liveConnections.delete(connection);
|
|
232
|
+
logger.log(`Closing GraphQL WebSocket of user ${connection.userId ?? '(unknown)'}: ${reason}`);
|
|
233
|
+
try {
|
|
234
|
+
// A close reason is limited to 123 bytes on the wire; these are short and ASCII.
|
|
235
|
+
connection.socket?.close(WS_AUTHORIZATION_CHANGED_CLOSE_CODE, reason);
|
|
236
|
+
} catch {
|
|
237
|
+
// Already closing — the outcome is the same.
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function revalidate(connection: WsConnection): Promise<boolean> {
|
|
243
|
+
connection.stale = false;
|
|
244
|
+
try {
|
|
245
|
+
let user = connection.user;
|
|
246
|
+
if (connection.reauthenticate) {
|
|
247
|
+
user = await connection.reauthenticate();
|
|
248
|
+
if (!user) {
|
|
249
|
+
return terminate(connection, 'Session no longer valid');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (fingerprintUser(user) !== connection.userFingerprint) {
|
|
253
|
+
return terminate(connection, 'User authorization changed');
|
|
254
|
+
}
|
|
255
|
+
if (connection.tenantFingerprint !== undefined) {
|
|
256
|
+
const resolver = getTenantContextResolver();
|
|
257
|
+
const tenant = resolver ? await resolver.resolve(user, connection.tenantHeader) : {};
|
|
258
|
+
if (fingerprintTenant(tenant) !== connection.tenantFingerprint) {
|
|
259
|
+
return terminate(connection, 'Tenant access changed');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (connection.terminated) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
connection.validatedAt = Date.now();
|
|
266
|
+
return true;
|
|
267
|
+
} catch {
|
|
268
|
+
// Fail closed: a check that cannot be made is not a check that passed. The client reconnects,
|
|
269
|
+
// and the handshake decides again.
|
|
270
|
+
return terminate(connection, 'Authorization check failed');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Is the connection still authorized as it was registered? Re-checks when an invalidation marked it,
|
|
276
|
+
* or when its last check is older than `graphQl.subscriptionRevalidationMs`; otherwise answers from
|
|
277
|
+
* the last check without any I/O. Concurrent callers share one re-check.
|
|
278
|
+
*
|
|
279
|
+
* `false` means the connection has been closed and nothing more may be delivered or executed on it.
|
|
280
|
+
*/
|
|
281
|
+
export function ensureWsConnectionCurrent(connection: WsConnection): Promise<boolean> {
|
|
282
|
+
if (connection.terminated) {
|
|
283
|
+
return Promise.resolve(false);
|
|
284
|
+
}
|
|
285
|
+
const interval = configuredRevalidationMs();
|
|
286
|
+
if (!connection.stale && (interval === false || Date.now() - connection.validatedAt < interval)) {
|
|
287
|
+
return Promise.resolve(true);
|
|
288
|
+
}
|
|
289
|
+
if (!connection.inflight) {
|
|
290
|
+
connection.inflight = revalidate(connection).finally(() => {
|
|
291
|
+
connection.inflight = undefined;
|
|
292
|
+
// An invalidation that arrived while this check was running may have been answered from data
|
|
293
|
+
// that predates it — check once more rather than lose it.
|
|
294
|
+
if (connection.stale && !connection.terminated) {
|
|
295
|
+
void ensureWsConnectionCurrent(connection);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return connection.inflight;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Record the tenant scope an operation on this connection resolved.
|
|
304
|
+
*
|
|
305
|
+
* The first resolution becomes the connection's scope. A later operation resolving a DIFFERENT one
|
|
306
|
+
* means the rights changed underneath the subscriptions that are already running on the old scope —
|
|
307
|
+
* the connection is closed, and `false` returned.
|
|
308
|
+
*/
|
|
309
|
+
export function noteWsConnectionTenant(connection: WsConnection, tenant: ResolvedTenantContext | undefined): boolean {
|
|
310
|
+
if (connection.terminated) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const fingerprint = fingerprintTenant(tenant);
|
|
314
|
+
if (connection.tenantFingerprint === undefined) {
|
|
315
|
+
connection.tenant = tenant ? { ...tenant } : {};
|
|
316
|
+
connection.tenantFingerprint = fingerprint;
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
if (fingerprint !== connection.tenantFingerprint) {
|
|
320
|
+
return terminate(connection, 'Tenant access changed');
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Mark a connection for an immediate re-check and start it. */
|
|
326
|
+
function markStale(connection: WsConnection): void {
|
|
327
|
+
connection.stale = true;
|
|
328
|
+
void ensureWsConnectionCurrent(connection);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Re-check, at once, every open connection of a user — after a membership, role or credential change.
|
|
333
|
+
* Answers the number of connections affected. Process-local: the tenant guard's Redis broadcast is
|
|
334
|
+
* what carries a membership invalidation to the other replicas.
|
|
335
|
+
*/
|
|
336
|
+
export function revalidateWsConnectionsOf(identity: IWsConnectionIdentity): number {
|
|
337
|
+
const userId = identity?.userId !== undefined && identity?.userId !== null ? String(identity.userId) : undefined;
|
|
338
|
+
const iamId = identity?.iamId || undefined;
|
|
339
|
+
const email = typeof identity?.email === 'string' ? identity.email.toLowerCase() : undefined;
|
|
340
|
+
if (!userId && !iamId && !email) {
|
|
341
|
+
return 0;
|
|
342
|
+
}
|
|
343
|
+
let count = 0;
|
|
344
|
+
for (const connection of liveConnections) {
|
|
345
|
+
if (
|
|
346
|
+
(userId && connection.userId === userId) ||
|
|
347
|
+
(iamId && connection.iamId === iamId) ||
|
|
348
|
+
(email && connection.email === email)
|
|
349
|
+
) {
|
|
350
|
+
markStale(connection);
|
|
351
|
+
count++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return count;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Re-check, at once, every open connection scoped to a tenant — after the tenant was switched on or off. */
|
|
358
|
+
export function revalidateWsConnectionsForTenant(tenantId: string): number {
|
|
359
|
+
if (!tenantId) {
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
const id = String(tenantId);
|
|
363
|
+
let count = 0;
|
|
364
|
+
for (const connection of liveConnections) {
|
|
365
|
+
const tenant = connection.tenant;
|
|
366
|
+
if (tenant && (tenant.tenantId === id || (Array.isArray(tenant.tenantIds) && tenant.tenantIds.includes(id)))) {
|
|
367
|
+
markStale(connection);
|
|
368
|
+
count++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return count;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Re-check every open connection — after a change whose reach is unknown (`invalidateAll()`). */
|
|
375
|
+
export function revalidateAllWsConnections(): number {
|
|
376
|
+
let count = 0;
|
|
377
|
+
for (const connection of liveConnections) {
|
|
378
|
+
markStale(connection);
|
|
379
|
+
count++;
|
|
380
|
+
}
|
|
381
|
+
return count;
|
|
382
|
+
}
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
// `graphql-upload` and `lodash` for the same reason.
|
|
6
6
|
import graphql = require('graphql');
|
|
7
7
|
|
|
8
|
-
import { getTenantContextResolver } from '../services/core-tenant-context.registry';
|
|
9
|
-
import { ConfigService } from '../services/config.service';
|
|
8
|
+
import { getTenantContextResolver, ResolvedTenantContext } from '../services/core-tenant-context.registry';
|
|
10
9
|
import { IRequestContext, RequestContext } from '../services/request-context.service';
|
|
10
|
+
import {
|
|
11
|
+
ensureWsConnectionCurrent,
|
|
12
|
+
getWsConnection,
|
|
13
|
+
noteWsConnectionTenant,
|
|
14
|
+
readWsTenantHeader,
|
|
15
|
+
WsConnection,
|
|
16
|
+
} from './graphql-ws-connection.helper';
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* A `RequestContext` for GraphQL operations that arrive over a WEBSOCKET.
|
|
@@ -39,6 +45,12 @@ import { IRequestContext, RequestContext } from '../services/request-context.ser
|
|
|
39
45
|
* only around the `subscribe()` call therefore covers the initial subscribe and nothing else: the
|
|
40
46
|
* store is gone by the time the first message is delivered. Wrapping `next()` (and `return`/`throw`)
|
|
41
47
|
* is what puts every delivered message inside the context.
|
|
48
|
+
*
|
|
49
|
+
* AND THE CONTEXT IS RE-CHECKED (11.42.3). A context built here is a snapshot of the connection's
|
|
50
|
+
* rights. For a connection registered with `registerWsConnection()` — every connection `CoreModule`'s
|
|
51
|
+
* own `onConnect` accepts — each operation and each delivered event first asks
|
|
52
|
+
* `ensureWsConnectionCurrent()` whether that snapshot still holds, and a connection whose rights
|
|
53
|
+
* changed is closed instead of served. See `graphql-ws-connection.helper.ts`.
|
|
42
54
|
*/
|
|
43
55
|
|
|
44
56
|
/** Minimal shape of what graphql-js `execute`/`subscribe` receive. */
|
|
@@ -58,21 +70,12 @@ type ExecuteFn = (args: GraphQlArgs) => any;
|
|
|
58
70
|
* and the raw upgrade request is the fallback for a project with a custom `onConnect` that forwards
|
|
59
71
|
* neither. Header names are matched case-insensitively — `connectionParams` are client-supplied JSON
|
|
60
72
|
* and arrive with whatever casing the client used, unlike Node's lower-cased HTTP headers.
|
|
73
|
+
*
|
|
74
|
+
* Lives in `graphql-ws-connection.helper.ts`, because a connection's re-check has to resolve the
|
|
75
|
+
* tenant from exactly the same header this does.
|
|
61
76
|
*/
|
|
62
77
|
function readTenantHeader(contextValue: any): string | undefined {
|
|
63
|
-
|
|
64
|
-
const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
|
|
65
|
-
for (const source of sources) {
|
|
66
|
-
if (!source || typeof source !== 'object') {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
|
70
|
-
if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
|
|
71
|
-
return value;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return undefined;
|
|
78
|
+
return readWsTenantHeader(contextValue);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/** The user the connection authenticated as, as `CoreModule`'s `onConnect` recorded it. */
|
|
@@ -109,28 +112,83 @@ export async function buildWsRequestContext(contextValue: any): Promise<IRequest
|
|
|
109
112
|
// A failure here must NOT be answered with an unscoped context — that is the leak this file
|
|
110
113
|
// exists to close. Leaving the tenant fields unset makes the plugin's safety net refuse
|
|
111
114
|
// tenant-scoped reads, which is the safe direction.
|
|
115
|
+
let resolved: ResolvedTenantContext = {};
|
|
112
116
|
try {
|
|
113
|
-
|
|
117
|
+
resolved = (await resolver.resolve(user, readTenantHeader(contextValue))) ?? {};
|
|
118
|
+
Object.assign(context, resolved);
|
|
114
119
|
} catch {
|
|
115
120
|
// Intentionally swallowed: see above. Non-tenant operations keep working.
|
|
116
121
|
}
|
|
122
|
+
|
|
123
|
+
// The first operation fixes the connection's tenant scope; a later one that resolves a different
|
|
124
|
+
// scope closes the connection, because subscriptions already running on it still carry the old
|
|
125
|
+
// one (see noteWsConnectionTenant()).
|
|
126
|
+
const connection = getWsConnection(contextValue);
|
|
127
|
+
if (connection) {
|
|
128
|
+
noteWsConnectionTenant(connection, resolved);
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
return context;
|
|
120
133
|
}
|
|
121
134
|
|
|
135
|
+
/**
|
|
136
|
+
* What an operation on a connection whose authorization no longer holds receives instead of being
|
|
137
|
+
* executed. The socket is closed at the same moment, so a client normally sees the close first; this
|
|
138
|
+
* matters for a connection without a socket to close.
|
|
139
|
+
*/
|
|
140
|
+
function refusedResult(): { errors: InstanceType<typeof graphql.GraphQLError>[] } {
|
|
141
|
+
return {
|
|
142
|
+
errors: [
|
|
143
|
+
new graphql.GraphQLError('The authorization of this connection changed — reconnect.', {
|
|
144
|
+
extensions: { code: 'FORBIDDEN' },
|
|
145
|
+
}),
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Re-check a registered connection before an operation runs on it. `true` for an unregistered
|
|
152
|
+
* connection — a project's own `onConnect` that does not register keeps the previous behaviour.
|
|
153
|
+
*/
|
|
154
|
+
async function connectionStillAuthorized(contextValue: any): Promise<boolean> {
|
|
155
|
+
const connection = getWsConnection(contextValue);
|
|
156
|
+
return connection ? ensureWsConnectionCurrent(connection) : true;
|
|
157
|
+
}
|
|
158
|
+
|
|
122
159
|
/**
|
|
123
160
|
* Run an AsyncIterator's pulls inside `context`.
|
|
124
161
|
*
|
|
125
162
|
* `next()` is where graphql-js executes the per-event selection set, so this is what carries the
|
|
126
163
|
* context into every delivered message rather than only into the initial subscribe.
|
|
164
|
+
*
|
|
165
|
+
* With a `connection`, every executed event is held back until `ensureWsConnectionCurrent()` confirms
|
|
166
|
+
* the connection's rights still hold. One that no longer holds is DROPPED and the stream ends — the
|
|
167
|
+
* event was computed under the old context, so delivering it would hand out exactly what was revoked.
|
|
168
|
+
* The check comes after the execution because the event only exists once graphql-js produced it; a
|
|
169
|
+
* check at pull time would be hours early for a quiet subscription.
|
|
127
170
|
*/
|
|
128
171
|
export function withRequestContextAsyncIterator<T>(
|
|
129
172
|
iterator: AsyncIterator<T> & { [Symbol.asyncIterator]?: () => AsyncIterator<T> },
|
|
130
173
|
context: IRequestContext,
|
|
174
|
+
connection?: WsConnection,
|
|
131
175
|
): AsyncIterableIterator<T> {
|
|
176
|
+
const next = (...args: [] | [undefined]) => RequestContext.run(context, () => iterator.next(...(args as [])));
|
|
132
177
|
const wrapped: AsyncIterableIterator<T> = {
|
|
133
|
-
next:
|
|
178
|
+
next: connection
|
|
179
|
+
? async (...args: [] | [undefined]) => {
|
|
180
|
+
const result = await next(...args);
|
|
181
|
+
if (result.done || (await ensureWsConnectionCurrent(connection))) {
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await RequestContext.run(context, () => iterator.return?.());
|
|
186
|
+
} catch {
|
|
187
|
+
// The source is being torn down anyway; nothing to report.
|
|
188
|
+
}
|
|
189
|
+
return { done: true, value: undefined } as IteratorResult<T>;
|
|
190
|
+
}
|
|
191
|
+
: next,
|
|
134
192
|
[Symbol.asyncIterator]() {
|
|
135
193
|
return wrapped;
|
|
136
194
|
},
|
|
@@ -156,10 +214,16 @@ function isAsyncIterable(value: any): boolean {
|
|
|
156
214
|
*/
|
|
157
215
|
export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn {
|
|
158
216
|
return async (args: GraphQlArgs) => {
|
|
217
|
+
if (!(await connectionStillAuthorized(args?.contextValue))) {
|
|
218
|
+
return refusedResult();
|
|
219
|
+
}
|
|
159
220
|
const context = await buildWsRequestContext(args?.contextValue);
|
|
160
221
|
if (!context) {
|
|
161
222
|
return execute(args);
|
|
162
223
|
}
|
|
224
|
+
if (getWsConnection(args?.contextValue)?.terminated) {
|
|
225
|
+
return refusedResult();
|
|
226
|
+
}
|
|
163
227
|
return RequestContext.run(context, () => execute(args));
|
|
164
228
|
};
|
|
165
229
|
}
|
|
@@ -167,12 +231,21 @@ export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn
|
|
|
167
231
|
/** Wrap graphql-js `subscribe` so both the subscribe AND every delivered message run in a context. */
|
|
168
232
|
export function createRequestContextAwareSubscribe(subscribe: ExecuteFn): ExecuteFn {
|
|
169
233
|
return async (args: GraphQlArgs) => {
|
|
234
|
+
if (!(await connectionStillAuthorized(args?.contextValue))) {
|
|
235
|
+
return refusedResult();
|
|
236
|
+
}
|
|
170
237
|
const context = await buildWsRequestContext(args?.contextValue);
|
|
171
238
|
if (!context) {
|
|
172
239
|
return subscribe(args);
|
|
173
240
|
}
|
|
241
|
+
const connection = getWsConnection(args?.contextValue);
|
|
242
|
+
if (connection?.terminated) {
|
|
243
|
+
return refusedResult();
|
|
244
|
+
}
|
|
174
245
|
const result = await RequestContext.run(context, () => subscribe(args));
|
|
175
|
-
return isAsyncIterable(result)
|
|
246
|
+
return isAsyncIterable(result)
|
|
247
|
+
? withRequestContextAsyncIterator(result as AsyncIterator<any>, context, connection)
|
|
248
|
+
: result;
|
|
176
249
|
};
|
|
177
250
|
}
|
|
178
251
|
|