@lenne.tech/nest-server 11.34.1 → 11.35.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/.claude/rules/architecture.md +3 -1
- package/.claude/rules/configurable-features.md +3 -3
- package/.claude/rules/role-system.md +281 -0
- package/CLAUDE.md +11 -1
- package/FRAMEWORK-API.md +7 -3
- package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
- package/dist/core/common/decorators/nested-type.registry.js +17 -0
- package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
- package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
- package/dist/core/common/decorators/restricted.decorator.js +52 -16
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
- package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
- package/dist/core/common/decorators/unified-field.decorator.js +6 -3
- package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
- package/dist/core/common/enums/role.enum.d.ts +8 -0
- package/dist/core/common/enums/role.enum.js +28 -1
- package/dist/core/common/enums/role.enum.js.map +1 -1
- package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
- package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
- package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +1 -1
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
- package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
- package/dist/core/common/services/core-s3.service.js +4 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
- package/dist/core/common/services/core-tenant-context.registry.js +12 -0
- package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
- package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/roles.guard.js +2 -2
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +3 -1
- package/dist/core/modules/file/core-file.service.js +62 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-access.helper.d.ts +18 -0
- package/dist/core/modules/file/file-access.helper.js +46 -0
- package/dist/core/modules/file/file-access.helper.js.map +1 -0
- package/dist/core/modules/file/file-metadata.helper.js +1 -1
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
- package/dist/core/modules/file/file-roles.config.d.ts +10 -0
- package/dist/core/modules/file/file-roles.config.js +78 -0
- package/dist/core/modules/file/file-roles.config.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
- package/dist/core/modules/file/file-roles.helper.js +8 -20
- package/dist/core/modules/file/file-roles.helper.js.map +1 -1
- package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
- package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +5 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +25 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.service.js +34 -4
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/index.d.ts +1 -0
- package/dist/core/modules/tus/index.js +1 -0
- package/dist/core/modules/tus/index.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +5 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +8 -0
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +10 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file.service.js +4 -1
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +26 -6
- package/dist/test/test.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +60 -1
- package/migration-guides/11.34.x-to-11.35.x.md +832 -0
- package/package.json +1 -1
- package/src/core/common/decorators/nested-type.registry.ts +42 -0
- package/src/core/common/decorators/restricted.decorator.ts +149 -21
- package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
- package/src/core/common/decorators/unified-field.decorator.ts +4 -3
- package/src/core/common/enums/role.enum.ts +116 -0
- package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
- package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
- package/src/core/common/helpers/gridfs.helper.ts +18 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
- package/src/core/common/interfaces/server-options.interface.ts +139 -7
- package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
- package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
- package/src/core/common/services/core-s3.service.ts +9 -1
- package/src/core/common/services/core-tenant-context.registry.ts +50 -0
- package/src/core/modules/auth/guards/roles.guard.ts +7 -5
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
- package/src/core/modules/file/README.md +61 -0
- package/src/core/modules/file/core-file.service.ts +166 -9
- package/src/core/modules/file/file-access.helper.ts +149 -0
- package/src/core/modules/file/file-metadata.helper.ts +13 -5
- package/src/core/modules/file/file-roles.config.ts +202 -0
- package/src/core/modules/file/file-roles.helper.ts +11 -40
- package/src/core/modules/tenant/README.md +20 -1
- package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
- package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
- package/src/core/modules/tenant/core-tenant.module.ts +12 -0
- package/src/core/modules/tenant/core-tenant.service.ts +56 -0
- package/src/core/modules/tus/README.md +46 -7
- package/src/core/modules/tus/core-tus.service.ts +94 -5
- package/src/core/modules/tus/index.ts +1 -0
- package/src/core/modules/tus/tus.constants.ts +17 -1
- package/src/core/modules/user/core-user.service.ts +12 -0
- package/src/core/modules/user/inputs/core-user.input.ts +28 -2
- package/src/core.module.ts +62 -1
- package/src/index.ts +3 -0
- package/src/server/modules/file/file.service.ts +45 -2
- package/src/test/test.helper.ts +54 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.35.0",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which CLASS a model property declares — the only record of that fact at runtime.
|
|
3
|
+
*
|
|
4
|
+
* DELIBERATELY IMPORT-FREE. Two files need this map and they sit on opposite ends of an import
|
|
5
|
+
* edge: `unified-field.decorator` writes it (and already imports `restricted.decorator` for
|
|
6
|
+
* `@Restricted`), while `restricted.decorator` reads it. Keeping the map here means the reader adds
|
|
7
|
+
* no import back into the writer — `restricted.decorator` drives field-level access control and its
|
|
8
|
+
* position on zero import cycles is a defended property, not an accident.
|
|
9
|
+
* See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
|
|
10
|
+
*
|
|
11
|
+
* WHY IT EXISTS AT ALL: TypeScript types are erased, and `design:type` degrades to `Array` for a
|
|
12
|
+
* typed array and is only emitted for decorated properties. So a nested value read out of MongoDB —
|
|
13
|
+
* an embedded subdocument, or an array of them — arrives as a PLAIN object whose constructor is
|
|
14
|
+
* `Object`. Nothing on the value itself can say what it was declared as, which means nothing can
|
|
15
|
+
* find the `@Restricted` / validation metadata that belongs to it. This map is what closes that gap.
|
|
16
|
+
*
|
|
17
|
+
* Filled by `@UnifiedField()` for every non-primitive property. A property declared without
|
|
18
|
+
* `@UnifiedField` is absent, and consumers must treat "absent" as "unknown", never as "no
|
|
19
|
+
* restrictions" — see `checkRestricted()` for how that is handled.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Key: `${className}.${propertyName}` — value: the declared (element) type. */
|
|
23
|
+
export const nestedTypeRegistry = new Map<string, any>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The class a property of `ownerClass` declares, or `undefined` when nothing recorded it.
|
|
27
|
+
*
|
|
28
|
+
* Walks the PROTOTYPE CHAIN of the class, because `@UnifiedField()` registers under the class it was
|
|
29
|
+
* applied to — a property inherited from a base model is recorded under the BASE class name, and a
|
|
30
|
+
* lookup that only tried the leaf would miss it.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveNestedType(ownerClass: unknown, propertyKey: string): any {
|
|
33
|
+
let current: any = ownerClass;
|
|
34
|
+
while (current && current !== Object && typeof current === 'function') {
|
|
35
|
+
const found = nestedTypeRegistry.get(`${current.name}.${propertyKey}`);
|
|
36
|
+
if (found) {
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
current = Object.getPrototypeOf(current);
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
@@ -22,17 +22,37 @@
|
|
|
22
22
|
* See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)".
|
|
23
23
|
*/
|
|
24
24
|
import 'reflect-metadata';
|
|
25
|
+
import { Logger } from '@nestjs/common';
|
|
25
26
|
import _ = require('lodash');
|
|
26
27
|
|
|
27
28
|
import { ProcessType } from '../enums/process-type.enum';
|
|
28
|
-
|
|
29
|
+
// isSystemRole comes from the role.enum LEAF (import-free), not from core-tenant.helpers, so this
|
|
30
|
+
// adds no new import edge to a file whose TDZ-immunity is a deliberate safety property.
|
|
31
|
+
import { isSystemRole, RoleEnum } from '../enums/role.enum';
|
|
29
32
|
import { accessDeniedException } from '../exceptions/access-denied.exception';
|
|
33
|
+
// From the import-free LEAF, never from unified-field.decorator: that file imports THIS one for
|
|
34
|
+
// `@Restricted`, so reading the map from there would put this file back on an import cycle.
|
|
35
|
+
import { resolveNestedType } from './nested-type.registry';
|
|
36
|
+
// From an import-free LEAF: the marker is read here and in both interceptors, and this file's
|
|
37
|
+
// position on zero import cycles is a defended property.
|
|
38
|
+
import {
|
|
39
|
+
hasRestrictionsCheckedMarker,
|
|
40
|
+
hasUnrecognizedRestrictionsFlag,
|
|
41
|
+
RESTRICTIONS_CHECKED_KEY,
|
|
42
|
+
} from './restrictions-checked.marker';
|
|
30
43
|
// Import from the id.helper LEAF, never from db.helper: db.helper imports input.helper, which
|
|
31
44
|
// imports this file back — that cycle is what the extraction removed. See id.helper's docblock.
|
|
32
45
|
import { equalIds, getIncludedIds } from '../helpers/id.helper';
|
|
33
46
|
import { RequestContext } from '../services/request-context.service';
|
|
34
47
|
import { RequireAtLeastOne } from '../types/required-at-least-one.type';
|
|
35
|
-
import { checkRoleAccess } from '../../modules/tenant/core-tenant.helpers';
|
|
48
|
+
import { checkRoleAccess, resolveGlobalAndTenantRoles } from '../../modules/tenant/core-tenant.helpers';
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
hasRestrictionsCheckedMarker,
|
|
52
|
+
hasUnrecognizedRestrictionsFlag,
|
|
53
|
+
markRestrictionsChecked,
|
|
54
|
+
RESTRICTIONS_CHECKED_KEY,
|
|
55
|
+
} from './restrictions-checked.marker';
|
|
36
56
|
|
|
37
57
|
/**
|
|
38
58
|
* Restricted meta key
|
|
@@ -121,6 +141,66 @@ export function getRestricted(object: unknown, propertyKey?: string): Restricted
|
|
|
121
141
|
return metadata;
|
|
122
142
|
}
|
|
123
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the NON-system portion of a field's required roles against the requester.
|
|
146
|
+
*
|
|
147
|
+
* Two things must be filtered before the roles reach `checkRoleAccess`, because that function
|
|
148
|
+
* matches non-hierarchy roles by exact string against a SINGLE available role — the tenant
|
|
149
|
+
* membership role, when a tenant context is active:
|
|
150
|
+
*
|
|
151
|
+
* 1. **System roles.** `S_SELF`, `S_CREATOR`, `S_VERIFIED`, `S_USER` and `S_EVERYONE` are decided
|
|
152
|
+
* by the dedicated checks in `checkRestricted` (ownership, verification, session). Passing them
|
|
153
|
+
* on means a membership role literally named `'s_self'` satisfies `@Restricted(S_SELF)` on
|
|
154
|
+
* ARBITRARY records — no ownership is ever compared. `CoreTenantGuard` already filters these at
|
|
155
|
+
* its own call site; this is the same filter for the field-level path.
|
|
156
|
+
*
|
|
157
|
+
* 2. **Global-only roles.** `RoleEnum.ADMIN` is platform authority and must be answered from
|
|
158
|
+
* `user.roles`, never from a customer-assigned membership role.
|
|
159
|
+
*
|
|
160
|
+
* Declared as a hoisted `function` so it stays temporal-dead-zone immune — this file sits on no
|
|
161
|
+
* import cycle today and the property is defended deliberately (see `.claude/rules/architecture.md`).
|
|
162
|
+
*/
|
|
163
|
+
function checkFieldRoleAccess(roles: string[], user: any): boolean {
|
|
164
|
+
const { global: globalRoles, tenant: tenantRoles } = resolveGlobalAndTenantRoles(
|
|
165
|
+
roles.filter((role) => !isSystemRole(role)),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (globalRoles.some((role) => user?.roles?.includes(role))) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The length guard is NOT redundant: checkRoleAccess returns TRUE for an empty required-roles
|
|
173
|
+
// list, so calling it with no tenant roles would unlock a field that only required a global one.
|
|
174
|
+
return tenantRoles.length > 0 && checkRoleAccess(tenantRoles, user?.roles, RequestContext.get()?.tenantRole);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Emitted at most once per process — a per-object warning on a hot path would be noise, not signal. */
|
|
178
|
+
let unrecognizedFlagWarned = false;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Warn once when an object carries `_objectAlreadyCheckedForRestrictions` without the framework marker.
|
|
182
|
+
*
|
|
183
|
+
* Declared as a hoisted `function` like everything else in this file, so it stays temporal-dead-zone
|
|
184
|
+
* immune. Uses the Nest `Logger` rather than `console.warn` for two reasons: it is the convention every
|
|
185
|
+
* other framework warning follows, and `console` output from a hot path is forwarded over vitest's
|
|
186
|
+
* worker RPC — a late one produces `Closing rpc while "onUserConsoleLog" was pending` in whichever
|
|
187
|
+
* spec happens to be tearing down. `@nestjs/common` is an external package and already in this file's
|
|
188
|
+
* transitive graph via `accessDeniedException`, so it adds no project import edge.
|
|
189
|
+
*/
|
|
190
|
+
function warnUnrecognizedRestrictionsFlag(data: unknown): void {
|
|
191
|
+
if (unrecognizedFlagWarned || !hasUnrecognizedRestrictionsFlag(data)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
unrecognizedFlagWarned = true;
|
|
195
|
+
new Logger('Restricted').warn(
|
|
196
|
+
`[Restricted] An object carries "${RESTRICTIONS_CHECKED_KEY}" without the framework marker, so it ` +
|
|
197
|
+
'is being CHECKED rather than skipped. Since 11.35.0 the opt-out is only honoured for objects ' +
|
|
198
|
+
'marked via markRestrictionsChecked() — a plain truthy property could arrive from the database ' +
|
|
199
|
+
'or a payload and would switch off field-level access control. If you set it deliberately, call ' +
|
|
200
|
+
'markRestrictionsChecked(obj); if this came from stored data, remove the field.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
124
204
|
/**
|
|
125
205
|
* Check data for restricted properties (properties with `Restricted` decorator)
|
|
126
206
|
* For special Roles and member of group checking the dbObject must be set in options
|
|
@@ -150,6 +230,7 @@ export function checkRestricted(
|
|
|
150
230
|
throwError?: boolean;
|
|
151
231
|
} = {},
|
|
152
232
|
processedObjects: WeakSet<object> = new WeakSet(),
|
|
233
|
+
declaredType?: any,
|
|
153
234
|
) {
|
|
154
235
|
// Act like Roles handling: checkObjectItself = false & mergeRoles = true
|
|
155
236
|
// For Input: throwError = true
|
|
@@ -168,16 +249,52 @@ export function checkRestricted(
|
|
|
168
249
|
};
|
|
169
250
|
|
|
170
251
|
// Primitives
|
|
171
|
-
|
|
252
|
+
// The "already checked" opt-out is recognised by the framework's MARKER, never by a truthy property
|
|
253
|
+
// of that name: nothing in src/ ever wrote the property, so a truthy one could only have come from
|
|
254
|
+
// outside — and a document carrying it disabled every field-level check for that object, silently,
|
|
255
|
+
// on the output path. See restrictions-checked.marker.ts.
|
|
256
|
+
if (!data || typeof data !== 'object' || (config.noteCheckedObjects && hasRestrictionsCheckedMarker(data))) {
|
|
172
257
|
return data;
|
|
173
258
|
}
|
|
174
259
|
|
|
260
|
+
// Say it out loud when the KEY is present without the marker. Two very different callers land here
|
|
261
|
+
// and they want opposite answers — a project that used to set the flag by hand has lost its opt-out,
|
|
262
|
+
// a document that carries it from a raw write has lost a bypass it should never have had. A warning
|
|
263
|
+
// states the situation without guessing which; honouring the value is what the marker exists to stop.
|
|
264
|
+
warnUnrecognizedRestrictionsFlag(data);
|
|
265
|
+
|
|
175
266
|
// Prevent infinite recursion
|
|
176
267
|
if (processedObjects.has(data)) {
|
|
177
268
|
return data;
|
|
178
269
|
}
|
|
179
270
|
processedObjects.add(data);
|
|
180
271
|
|
|
272
|
+
// WHERE the restriction metadata for `data` lives.
|
|
273
|
+
//
|
|
274
|
+
// For an instance of a model class this is simply its constructor, and reading it off the object
|
|
275
|
+
// works — which is why the top level has always been checked correctly. One level down it stops
|
|
276
|
+
// working: `CoreModel.map()` is a shallow `Object.assign`, `prepareOutput()` maps only the target
|
|
277
|
+
// model, and `ResponseModelInterceptor` maps only the top-level item. So an embedded subdocument
|
|
278
|
+
// read out of MongoDB arrives as a PLAIN object whose constructor is `Object` — and `Object`
|
|
279
|
+
// carries no `@Restricted` metadata, so every nested restriction silently evaluated to "no
|
|
280
|
+
// restrictions at all" and the field was returned in full. `declaredType` closes that: the type
|
|
281
|
+
// the PARENT declared for this property, looked up in the nested-type registry.
|
|
282
|
+
//
|
|
283
|
+
// An unregistered nested type still resolves to nothing, exactly as before — the framework cannot
|
|
284
|
+
// invent a declaration. That is deliberate rather than fail-closed: nested values also legitimately
|
|
285
|
+
// hold free-form JSON, `Map`s and scalars, and refusing those would break far more than it
|
|
286
|
+
// protects. Declare nested types with `@UnifiedField({ type: () => X })` to have them enforced.
|
|
287
|
+
const metadataOwner: any =
|
|
288
|
+
data.constructor && data.constructor !== Object
|
|
289
|
+
? data.constructor
|
|
290
|
+
: typeof declaredType === 'function'
|
|
291
|
+
? declaredType
|
|
292
|
+
: undefined;
|
|
293
|
+
// What `Reflect.getMetadata` must walk for PROPERTY lookups: the instance when there is one, the
|
|
294
|
+
// declared class's prototype otherwise (metadata for property decorators is defined there, and
|
|
295
|
+
// Reflect traverses the prototype chain either way).
|
|
296
|
+
const metadataTarget: any = !metadataOwner || data.constructor === metadataOwner ? data : metadataOwner.prototype;
|
|
297
|
+
|
|
181
298
|
// Array
|
|
182
299
|
if (Array.isArray(data)) {
|
|
183
300
|
if (data.length === 0) {
|
|
@@ -189,15 +306,18 @@ export function checkRestricted(
|
|
|
189
306
|
// Per-item checks are only needed when S_CREATOR or S_SELF restrictions exist
|
|
190
307
|
// (because createdBy/id differ per item).
|
|
191
308
|
const sample = data[0];
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
sample.constructor
|
|
197
|
-
|
|
198
|
-
|
|
309
|
+
// The element type: the sample's own class, or — for an array of embedded subdocuments, which
|
|
310
|
+
// arrive as plain objects — the type the parent property declared. `declaredType` is the ELEMENT
|
|
311
|
+
// type here: `@UnifiedField({ isArray: true, type: () => X })` registers `X`, not `X[]`.
|
|
312
|
+
const sampleOwner: any =
|
|
313
|
+
sample && typeof sample === 'object' && !Array.isArray(sample) && sample.constructor !== Object
|
|
314
|
+
? sample.constructor
|
|
315
|
+
: typeof declaredType === 'function'
|
|
316
|
+
? declaredType
|
|
317
|
+
: undefined;
|
|
318
|
+
if (sample && typeof sample === 'object' && !Array.isArray(sample) && sampleOwner) {
|
|
199
319
|
// Check class-level restrictions once
|
|
200
|
-
const classRestrictions = getRestricted(
|
|
320
|
+
const classRestrictions = getRestricted(sampleOwner) || [];
|
|
201
321
|
if (classRestrictions.length) {
|
|
202
322
|
const hasCreatorOrSelf = classRestrictions.some(
|
|
203
323
|
(r) =>
|
|
@@ -223,7 +343,7 @@ export function checkRestricted(
|
|
|
223
343
|
// also validates the class-level restriction as a standalone gate. With the
|
|
224
344
|
// default checkObjectItself=false, class restrictions are merged into each
|
|
225
345
|
// property's restrictions (properties get stripped if the class restriction denies).
|
|
226
|
-
const sampleResult = checkRestricted(sample, user, config, processedObjects);
|
|
346
|
+
const sampleResult = checkRestricted(sample, user, config, processedObjects, declaredType);
|
|
227
347
|
if (sampleResult === undefined || sampleResult === null) {
|
|
228
348
|
// Class-level restriction blocks access → entire array is blocked
|
|
229
349
|
if (config.throwError) {
|
|
@@ -236,7 +356,7 @@ export function checkRestricted(
|
|
|
236
356
|
// are O(1) lookups, but we still need to recurse into nested properties per item.
|
|
237
357
|
const result = [sampleResult];
|
|
238
358
|
for (let i = 1; i < data.length; i++) {
|
|
239
|
-
result.push(checkRestricted(data[i], user, config, processedObjects));
|
|
359
|
+
result.push(checkRestricted(data[i], user, config, processedObjects, declaredType));
|
|
240
360
|
}
|
|
241
361
|
if (!config.throwError && config.removeUndefinedFromResultArray) {
|
|
242
362
|
return result.filter((item) => item !== undefined);
|
|
@@ -247,7 +367,7 @@ export function checkRestricted(
|
|
|
247
367
|
}
|
|
248
368
|
|
|
249
369
|
// Fallback: plain objects, mixed types, or S_CREATOR/S_SELF checks needed
|
|
250
|
-
let result = data.map((item) => checkRestricted(item, user, config, processedObjects));
|
|
370
|
+
let result = data.map((item) => checkRestricted(item, user, config, processedObjects, declaredType));
|
|
251
371
|
if (!config.throwError && config.removeUndefinedFromResultArray) {
|
|
252
372
|
result = result.filter((item) => item !== undefined);
|
|
253
373
|
}
|
|
@@ -256,7 +376,7 @@ export function checkRestricted(
|
|
|
256
376
|
|
|
257
377
|
// Check function
|
|
258
378
|
const validateRestricted = (restricted) => {
|
|
259
|
-
if (config.noteCheckedObjects && data
|
|
379
|
+
if (config.noteCheckedObjects && hasRestrictionsCheckedMarker(data)) {
|
|
260
380
|
return true;
|
|
261
381
|
}
|
|
262
382
|
|
|
@@ -320,7 +440,7 @@ export function checkRestricted(
|
|
|
320
440
|
((owner && 'createdBy' in owner && equalIds(owner.createdBy, user)) ||
|
|
321
441
|
(config.allowCreatorOfParent && owner && !('createdBy' in owner) && config.isCreatorOfParent))) ||
|
|
322
442
|
(roles.includes(RoleEnum.S_VERIFIED) && (user?.verified || user?.verifiedAt || user?.emailVerified)) ||
|
|
323
|
-
(user?.id &&
|
|
443
|
+
(user?.id && checkFieldRoleAccess(roles, user))
|
|
324
444
|
) {
|
|
325
445
|
valid = true;
|
|
326
446
|
}
|
|
@@ -375,7 +495,7 @@ export function checkRestricted(
|
|
|
375
495
|
};
|
|
376
496
|
|
|
377
497
|
// Check data object
|
|
378
|
-
const objectRestrictions = getRestricted(
|
|
498
|
+
const objectRestrictions = getRestricted(metadataOwner) || [];
|
|
379
499
|
if (config.checkObjectItself) {
|
|
380
500
|
const objectIsValid = validateRestricted(objectRestrictions);
|
|
381
501
|
if (!objectIsValid) {
|
|
@@ -403,8 +523,9 @@ export function checkRestricted(
|
|
|
403
523
|
continue;
|
|
404
524
|
}
|
|
405
525
|
|
|
406
|
-
// Check restricted
|
|
407
|
-
|
|
526
|
+
// Check restricted. `metadataTarget` is `data` for a real instance and the declared class's
|
|
527
|
+
// prototype for a plain nested object — see its definition above.
|
|
528
|
+
const restricted = getRestricted(metadataTarget, propertyKey) || [];
|
|
408
529
|
const concatenatedRestrictions =
|
|
409
530
|
config.mergeRoles && objectRestrictions.length ? _.uniq(objectRestrictions.concat(restricted)) : restricted;
|
|
410
531
|
const valid = validateRestricted(concatenatedRestrictions);
|
|
@@ -420,8 +541,15 @@ export function checkRestricted(
|
|
|
420
541
|
equalIds(parent, user) ||
|
|
421
542
|
(parent && 'createdBy' in parent ? equalIds(parent.createdBy, user) : config.isCreatorOfParent);
|
|
422
543
|
|
|
423
|
-
// Check deep
|
|
424
|
-
|
|
544
|
+
// Check deep — carrying the type this property DECLARES, so a nested plain object (an
|
|
545
|
+
// embedded subdocument, or an array of them) can still be matched against its own metadata.
|
|
546
|
+
data[propertyKey] = checkRestricted(
|
|
547
|
+
data[propertyKey],
|
|
548
|
+
user,
|
|
549
|
+
config,
|
|
550
|
+
processedObjects,
|
|
551
|
+
metadataOwner ? resolveNestedType(metadataOwner, propertyKey) : undefined,
|
|
552
|
+
);
|
|
425
553
|
} else {
|
|
426
554
|
if (config.debug) {
|
|
427
555
|
console.debug(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "This object has already been checked" — as a MARKER the framework sets, not a property data carries.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS: three places short-circuit on
|
|
5
|
+
* `_objectAlreadyCheckedForRestrictions` — `checkRestricted()` returns the object untouched,
|
|
6
|
+
* `CheckSecurityInterceptor` skips `securityCheck()`, and `ResponseModelInterceptor` skips the
|
|
7
|
+
* Plain→Model conversion. The opt-out is legitimate: without it one response is checked three times.
|
|
8
|
+
*
|
|
9
|
+
* What was NOT legitimate is how it was recognised. Every read was a plain truthy test on a
|
|
10
|
+
* well-known property name, and nothing in `src/` ever wrote it — so the only way the flag could ever
|
|
11
|
+
* be set was from outside the framework. Which means a document carrying
|
|
12
|
+
* `_objectAlreadyCheckedForRestrictions: true` — from a raw write, a `strict: false` schema, a
|
|
13
|
+
* restored export — disabled EVERY field-level access check for that object, silently, on the output
|
|
14
|
+
* path where a skipped check produces no error at all. That is a data-shaped authorization switch,
|
|
15
|
+
* and data must never be able to flip one.
|
|
16
|
+
*
|
|
17
|
+
* The fix keeps the feature and removes the forgeability: the marker's VALUE is a module-private
|
|
18
|
+
* symbol. JSON, BSON and any client payload can produce the key, and none of them can produce that
|
|
19
|
+
* value. The property is also non-enumerable, so it never reaches a response body and cannot re-enter
|
|
20
|
+
* through a round trip as exactly the data shape above.
|
|
21
|
+
*
|
|
22
|
+
* DELIBERATELY IMPORT-FREE, so `restricted.decorator` (whose position on zero import cycles is a
|
|
23
|
+
* defended property) and both interceptors can read it without a new import edge between them.
|
|
24
|
+
* See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The property name, unchanged since it is part of the observable API — a project may look for it.
|
|
29
|
+
* Only the way it is RECOGNISED changed.
|
|
30
|
+
*/
|
|
31
|
+
export const RESTRICTIONS_CHECKED_KEY = '_objectAlreadyCheckedForRestrictions';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The only value that counts as "checked". Module-private on purpose: it is not exported, so nothing
|
|
35
|
+
* outside this file — and nothing that arrives as data — can reproduce it.
|
|
36
|
+
*/
|
|
37
|
+
const MARKER = Symbol('restrictionsChecked');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mark an object as already checked, so the framework's own layers do not check it again.
|
|
41
|
+
*
|
|
42
|
+
* Non-enumerable and non-writable: it must not appear in `Object.keys()`, in `JSON.stringify()` or in
|
|
43
|
+
* a Mongoose write. `configurable: true` so the same object can be re-marked without throwing.
|
|
44
|
+
*
|
|
45
|
+
* Returns the object, so it can be used inline.
|
|
46
|
+
*/
|
|
47
|
+
export function markRestrictionsChecked<T>(data: T): T {
|
|
48
|
+
if (!data || typeof data !== 'object') {
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
Object.defineProperty(data, RESTRICTIONS_CHECKED_KEY, {
|
|
52
|
+
configurable: true,
|
|
53
|
+
enumerable: false,
|
|
54
|
+
value: MARKER,
|
|
55
|
+
writable: false,
|
|
56
|
+
});
|
|
57
|
+
return data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Has the framework marked this object as checked?
|
|
62
|
+
*
|
|
63
|
+
* The identity comparison is the whole point — `!!data[KEY]` would be satisfied by any truthy value,
|
|
64
|
+
* including one that came out of the database.
|
|
65
|
+
*/
|
|
66
|
+
export function hasRestrictionsCheckedMarker(data: unknown): boolean {
|
|
67
|
+
return !!data && typeof data === 'object' && (data as Record<string, unknown>)[RESTRICTIONS_CHECKED_KEY] === MARKER;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Does this object carry the KEY without the marker — i.e. an unforgeable-looking value that is not
|
|
72
|
+
* actually the marker?
|
|
73
|
+
*
|
|
74
|
+
* Used to warn once rather than to decide anything. Two callers can produce this state, and they want
|
|
75
|
+
* opposite answers: a project that used to set the flag by hand (`obj[KEY] = true`) has silently lost
|
|
76
|
+
* its opt-out and should hear about it, while a document that carries the field from a raw write has
|
|
77
|
+
* silently LOST a bypass it should never have had. A warning names the situation without guessing
|
|
78
|
+
* which one it is; treating it as "checked" is what this file exists to stop.
|
|
79
|
+
*/
|
|
80
|
+
export function hasUnrecognizedRestrictionsFlag(data: unknown): boolean {
|
|
81
|
+
return (
|
|
82
|
+
!!data &&
|
|
83
|
+
typeof data === 'object' &&
|
|
84
|
+
RESTRICTIONS_CHECKED_KEY in (data as object) &&
|
|
85
|
+
(data as Record<string, unknown>)[RESTRICTIONS_CHECKED_KEY] !== MARKER
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -22,11 +22,12 @@ import {
|
|
|
22
22
|
import { GraphQLScalarType, isEnumType } from 'graphql';
|
|
23
23
|
|
|
24
24
|
import { RoleEnum } from '../enums/role.enum';
|
|
25
|
+
// Re-exported below so no import path broke. The map moved into an import-free leaf because
|
|
26
|
+
// `restricted.decorator` — which this file imports — now READS it; see that leaf's docblock.
|
|
27
|
+
import { nestedTypeRegistry } from './nested-type.registry';
|
|
25
28
|
import { Restricted, RestrictedType } from './restricted.decorator';
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
// Key: `${className}.${propertyName}`, Value: nested type constructor
|
|
29
|
-
export const nestedTypeRegistry = new Map<string, any>();
|
|
30
|
+
export { nestedTypeRegistry, resolveNestedType } from './nested-type.registry';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Registry to map enum objects to their names.
|
|
@@ -60,3 +60,119 @@ export enum RoleEnum {
|
|
|
60
60
|
// User must be logged in (see context user, e.g. @CurrentUser)
|
|
61
61
|
S_USER = 's_user',
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Prefix that marks a role as a system role (runtime-context check, never a stored role).
|
|
66
|
+
*
|
|
67
|
+
* This is the single source of truth for the prefix. Anything deriving the system-role rule —
|
|
68
|
+
* guards, the `@Restricted` evaluation, the storage guards — must build on it rather than
|
|
69
|
+
* hard-coding `'s_'` again.
|
|
70
|
+
*/
|
|
71
|
+
export const SYSTEM_ROLE_PREFIX = 's_';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Is this role a system role, by the exact rule the runtime checks use?
|
|
75
|
+
*
|
|
76
|
+
* Case-SENSITIVE on purpose: `hasRole()` and `checkRoleAccess()` compare role strings exactly,
|
|
77
|
+
* so `'S_SELF'` is a different string from `'s_self'` and never grants anything. Widening this
|
|
78
|
+
* to case-insensitive would make the guards treat a legitimately-named project role such as
|
|
79
|
+
* `'S_Manager'` as a system role and change access decisions.
|
|
80
|
+
*
|
|
81
|
+
* Use this for RUNTIME checks. To decide whether a value may be STORED in `user.roles`, use
|
|
82
|
+
* {@link looksLikeSystemRole} — that question deserves the stricter, defensive answer.
|
|
83
|
+
*
|
|
84
|
+
* Declared as a hoisted `function` (not a `const` arrow) so it stays temporal-dead-zone immune
|
|
85
|
+
* on any import cycle — see `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
|
|
86
|
+
*/
|
|
87
|
+
export function isSystemRole(role: string): boolean {
|
|
88
|
+
return typeof role === 'string' && role.startsWith(SYSTEM_ROLE_PREFIX);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Could this value be mistaken for a system role once stored in `user.roles`?
|
|
93
|
+
*
|
|
94
|
+
* Deliberately BROADER than {@link isSystemRole}: trims surrounding whitespace and ignores case.
|
|
95
|
+
* A stored `' s_self'` or `'S_SELF'` grants nothing today, because `hasRole()` compares exactly —
|
|
96
|
+
* but that is an accident of the comparison, not a decision. Both become live the moment anything
|
|
97
|
+
* normalizes roles (a Mongoose `trim: true`, a CSV/LDAP import, a project sanitizer), and both
|
|
98
|
+
* pass an eyeball review as legitimate roles in the meantime.
|
|
99
|
+
*
|
|
100
|
+
* This is the predicate the storage guards use. It is intentionally strict enough to also reject
|
|
101
|
+
* a project role that merely happens to start with `s_` (e.g. `'s_manager'`) — the framework
|
|
102
|
+
* cannot tell those apart from a system role, and a false rejection is recoverable by renaming
|
|
103
|
+
* while a false acceptance is a silent authorization hole.
|
|
104
|
+
*/
|
|
105
|
+
export function looksLikeSystemRole(role: unknown): boolean {
|
|
106
|
+
return typeof role === 'string' && role.trim().toLowerCase().startsWith(SYSTEM_ROLE_PREFIX);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Roles that carry GLOBAL, platform-wide authority and can therefore never be satisfied by a
|
|
111
|
+
* tenant membership role.
|
|
112
|
+
*
|
|
113
|
+
* The distinction exists because membership roles are customer-assigned free text: `addMember()`
|
|
114
|
+
* takes any non-empty string, and whoever may manage members is typically a tenant owner — a
|
|
115
|
+
* customer. If a membership role were compared by plain string equality against a framework role,
|
|
116
|
+
* that customer could mint platform-wide authority for themselves simply by naming their tenant
|
|
117
|
+
* role `'admin'`.
|
|
118
|
+
*
|
|
119
|
+
* The two levels are meant to be expressed separately:
|
|
120
|
+
* - **global admin** → `RoleEnum.ADMIN` in `user.roles` — access to every tenant
|
|
121
|
+
* - **tenant admin** → a membership role such as `'tenantAdmin'` / `'spaceAdmin'` — one tenant only
|
|
122
|
+
*
|
|
123
|
+
* Required roles from this set are always resolved against `user.roles`, never against
|
|
124
|
+
* `membership.role`, no matter what the tenant header says.
|
|
125
|
+
*/
|
|
126
|
+
export const GLOBAL_ONLY_ROLES: readonly string[] = [RoleEnum.ADMIN];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Is this a role that only the platform may grant (never a tenant)?
|
|
130
|
+
*
|
|
131
|
+
* Hoisted `function` for the same temporal-dead-zone reason as {@link isSystemRole}.
|
|
132
|
+
*/
|
|
133
|
+
export function isGlobalOnlyRole(role: string): boolean {
|
|
134
|
+
return typeof role === 'string' && GLOBAL_ONLY_ROLES.includes(role);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Could this value be mistaken for a global-only role once stored?
|
|
139
|
+
*
|
|
140
|
+
* Stands to {@link isGlobalOnlyRole} exactly as {@link looksLikeSystemRole} stands to
|
|
141
|
+
* {@link isSystemRole}: the runtime rule is exact (so `'ADMIN'` never grants anything), while the
|
|
142
|
+
* storage rule is defensive, because `' Admin '` reads as legitimate in a members list and becomes
|
|
143
|
+
* live the moment anything normalizes role strings.
|
|
144
|
+
*/
|
|
145
|
+
export function looksLikeGlobalOnlyRole(role: unknown): boolean {
|
|
146
|
+
if (typeof role !== 'string') {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const normalized = role.trim().toLowerCase();
|
|
150
|
+
return GLOBAL_ONLY_ROLES.some((globalRole) => globalRole.toLowerCase() === normalized);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Is this value unusable as a tenant MEMBERSHIP role?
|
|
155
|
+
*
|
|
156
|
+
* True for system roles (which are runtime-context checks, not stored roles) and for global-only
|
|
157
|
+
* roles (which would cross the tenant boundary). Used to refuse such values at assignment time, so
|
|
158
|
+
* the dangerous membership never comes into existence in the first place.
|
|
159
|
+
*
|
|
160
|
+
* Uses the DEFENSIVE variant of both predicates — see {@link looksLikeSystemRole} and
|
|
161
|
+
* {@link looksLikeGlobalOnlyRole} for why storage deserves the stricter answer than runtime.
|
|
162
|
+
*/
|
|
163
|
+
export function isForbiddenMembershipRole(role: unknown): boolean {
|
|
164
|
+
return looksLikeSystemRole(role) || looksLikeGlobalOnlyRole(role);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `class-validator` `@Matches()` pattern that ACCEPTS everything {@link looksLikeSystemRole}
|
|
169
|
+
* rejects, and vice versa.
|
|
170
|
+
*
|
|
171
|
+
* A negative lookahead, so a value is valid exactly when it does NOT begin (after optional leading
|
|
172
|
+
* whitespace, case-insensitively) with the system-role prefix. Kept next to the predicate it
|
|
173
|
+
* mirrors so the two cannot drift; `@Matches` needs a pattern and cannot take a function.
|
|
174
|
+
*
|
|
175
|
+
* Anchored with no quantifier over the input, so it is linear in input length — a 100 KB value
|
|
176
|
+
* costs the same as a 10-character one, with no backtracking to exploit.
|
|
177
|
+
*/
|
|
178
|
+
export const SYSTEM_ROLE_REJECT_PATTERN = new RegExp(`^(?!\\s*${SYSTEM_ROLE_PREFIX})`, 'i');
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-like object a guard decides on, for EVERY transport — including the GraphQL WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: all three role guards resolved the request the same way, and all three got the
|
|
5
|
+
* WebSocket wrong in the same way:
|
|
6
|
+
*
|
|
7
|
+
* ```typescript
|
|
8
|
+
* ctx.getContext()?.req || context.switchToHttp().getRequest()
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* A subscription's GraphQL context is the graphql-ws `extra` object, which has no `req`. The HTTP
|
|
12
|
+
* fallback then runs on a GraphQL context, where `switchToHttp().getRequest()` yields the resolver's
|
|
13
|
+
* ROOT value — `undefined` at subscribe time. So the guards were handed nothing, and what they did
|
|
14
|
+
* with nothing differed in the worst possible way:
|
|
15
|
+
*
|
|
16
|
+
* - `CoreTenantGuard` hit `if (!request) return true` and GRANTED access without deciding anything.
|
|
17
|
+
* With multi-tenancy active the role guard PASSES non-system roles through to it, so `@Roles()` on a
|
|
18
|
+
* subscription was checked by nobody at all: a plain `member` reached a `tenantAdmin`-gated
|
|
19
|
+
* subscription and received its messages.
|
|
20
|
+
* - Without multi-tenancy the role guard found no token and refused EVERYONE, so the same decorator
|
|
21
|
+
* silently meant "locked" instead of "open".
|
|
22
|
+
*
|
|
23
|
+
* Both are wrong, and the first is a hole. Everything a guard needs is present on the subscription
|
|
24
|
+
* context — `CoreModule`'s `onConnect` records the authenticated `user` and the client's
|
|
25
|
+
* `connectionParams` as `headers` — it was simply never looked at.
|
|
26
|
+
*
|
|
27
|
+
* DELIBERATELY IMPORT-FREE apart from the Nest types it is typed against, so it stays usable from
|
|
28
|
+
* `src/core/common/**` and from `src/core/modules/**` alike without adding an import edge between
|
|
29
|
+
* them. See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
|
|
30
|
+
*/
|
|
31
|
+
import type { ExecutionContext } from '@nestjs/common';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The shape a guard reads and writes. It is deliberately loose: on HTTP this IS the Express request,
|
|
35
|
+
* and on a WebSocket it is the graphql-ws `extra` object, which a guard may annotate the same way
|
|
36
|
+
* (`tenantId`, `tenantRole`, …) — those writes are inert on that path, because the Mongoose plugins
|
|
37
|
+
* read the WebSocket's tenant scope from `RequestContext` instead (see `graphql-ws-context.helper`).
|
|
38
|
+
*/
|
|
39
|
+
export interface GuardRequestLike {
|
|
40
|
+
[key: string]: any;
|
|
41
|
+
headers?: Record<string, any>;
|
|
42
|
+
user?: any;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Is this object plausibly a request/subscription context rather than a resolver root value?
|
|
47
|
+
*
|
|
48
|
+
* The check is on the KEYS a guard actually reads. A payload object that happens to carry a `user`
|
|
49
|
+
* field would otherwise be mistaken for an authenticated request — and a subscription payload very
|
|
50
|
+
* plausibly does (`pubSub.publish('userCreated', user)`), which would turn the PUBLISHED user into
|
|
51
|
+
* the apparent caller. Requiring the graphql-ws marker (`socket` / `request` / `connectionParams`)
|
|
52
|
+
* rules that out: a payload does not carry those.
|
|
53
|
+
*/
|
|
54
|
+
function isSubscriptionContext(value: any): boolean {
|
|
55
|
+
return (
|
|
56
|
+
!!value && typeof value === 'object' && ('socket' in value || 'connectionParams' in value || 'request' in value)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the object a guard should decide on.
|
|
62
|
+
*
|
|
63
|
+
* Order matters:
|
|
64
|
+
* 1. `ctx.getContext().req` — HTTP GraphQL, and the shape every existing consumer expects.
|
|
65
|
+
* 2. The graphql-ws subscription context itself, when it looks like one (see above).
|
|
66
|
+
* 3. `switchToHttp().getRequest()` — REST controllers. On a GraphQL context this returns the
|
|
67
|
+
* resolver root, which is why it comes LAST and why step 2 must not fall through to it.
|
|
68
|
+
*
|
|
69
|
+
* @returns the request-like object, or `undefined` when the context carries none. A guard must treat
|
|
70
|
+
* `undefined` as "no information", never as "permitted" — that conflation is what this file fixes.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveGuardRequest(
|
|
73
|
+
context: ExecutionContext,
|
|
74
|
+
gqlContextOf: (context: ExecutionContext) => any,
|
|
75
|
+
): GuardRequestLike | undefined {
|
|
76
|
+
let gqlContext: any;
|
|
77
|
+
try {
|
|
78
|
+
gqlContext = gqlContextOf(context);
|
|
79
|
+
} catch {
|
|
80
|
+
gqlContext = undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (gqlContext?.req) {
|
|
84
|
+
return gqlContext.req;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isSubscriptionContext(gqlContext)) {
|
|
88
|
+
// Normalize the two names the transports use, so a guard reads `headers` unconditionally:
|
|
89
|
+
// `graphql-ws` gets them from CoreModule's onConnect, `subscriptions-transport-ws` from its own,
|
|
90
|
+
// and the raw upgrade request is the fallback for a custom onConnect that forwards neither.
|
|
91
|
+
if (!gqlContext.headers) {
|
|
92
|
+
gqlContext.headers = gqlContext.connectionParams ?? gqlContext.request?.headers ?? {};
|
|
93
|
+
}
|
|
94
|
+
return gqlContext;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return context.switchToHttp().getRequest();
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|