@lenne.tech/nest-server 11.34.1 → 11.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/file.helper.js +3 -2
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- 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-access-audit.initializer.d.ts +13 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
- 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 +31 -0
- package/dist/core/modules/file/file-roles.config.js +127 -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 +16 -34
- 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 +13 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -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 +846 -0
- package/migration-guides/11.35.0-to-11.35.1.md +152 -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/file.helper.ts +11 -5
- 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 +71 -0
- package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -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 +357 -0
- package/src/core/modules/file/file-roles.helper.ts +24 -60
- 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 +72 -3
- package/src/index.ts +7 -0
- package/src/server/modules/file/file.service.ts +45 -2
- package/src/test/test.helper.ts +54 -11
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
1
2
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
|
2
3
|
import { randomBytes } from 'crypto';
|
|
3
4
|
import { diskStorage, memoryStorage } from 'multer';
|
|
@@ -147,9 +148,14 @@ function normalizeMimeType(value: string): string {
|
|
|
147
148
|
* `options.allowScriptableTypes` is set — that is what closes the hole for
|
|
148
149
|
* expressions that already exist in consumer projects.
|
|
149
150
|
*
|
|
150
|
-
* Rejections are reported as a
|
|
151
|
-
*
|
|
152
|
-
* NestJS's `transformException`
|
|
151
|
+
* Rejections are reported as a `BadRequestException`, so the caller gets a 400
|
|
152
|
+
* with a readable reason. An `HttpException` is required, not merely tidier:
|
|
153
|
+
* NestJS's `transformException` returns any NON-`HttpException` unchanged — its
|
|
154
|
+
* switch matches only multer's own message constants (`LIMIT_FILE_SIZE`,
|
|
155
|
+
* `LIMIT_UNEXPECTED_FILE`, …), and a message written here matches none of them.
|
|
156
|
+
* A bare `new Error()` therefore still surfaced to the client as a 500, which
|
|
157
|
+
* reads as "the server broke" for what is in fact a refused file. See
|
|
158
|
+
* `tests/unit/file-upload-rejection-status.spec.ts`.
|
|
153
159
|
*/
|
|
154
160
|
export function multerFileFilter(
|
|
155
161
|
accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
|
|
@@ -163,7 +169,7 @@ export function multerFileFilter(
|
|
|
163
169
|
!options?.allowScriptableTypes &&
|
|
164
170
|
(SCRIPTABLE_UPLOAD_MIME_TYPES.includes(mimeType) || SCRIPTABLE_UPLOAD_EXTENSIONS.includes(extension))
|
|
165
171
|
) {
|
|
166
|
-
return cb(new
|
|
172
|
+
return cb(new BadRequestException(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
|
|
167
173
|
}
|
|
168
174
|
|
|
169
175
|
const accepted =
|
|
@@ -174,7 +180,7 @@ export function multerFileFilter(
|
|
|
174
180
|
if (accepted) {
|
|
175
181
|
return cb(null, true);
|
|
176
182
|
}
|
|
177
|
-
cb(new
|
|
183
|
+
cb(new BadRequestException(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
|
|
178
184
|
};
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// `require`, NOT an ESM import: `@nestjs/graphql` builds the schema with the instance Node's CJS
|
|
2
|
+
// cache holds, and graphql REFUSES a schema "from another module or realm". An ESM import can resolve
|
|
3
|
+
// to a second copy of the module (a bundler's interop namespace, or a duplicated install), and the
|
|
4
|
+
// wrapper would then reject the very schema it was handed. The repo already uses this form for
|
|
5
|
+
// `graphql-upload` and `lodash` for the same reason.
|
|
6
|
+
import graphql = require('graphql');
|
|
7
|
+
|
|
8
|
+
import { getTenantContextResolver } from '../services/core-tenant-context.registry';
|
|
9
|
+
import { ConfigService } from '../services/config.service';
|
|
10
|
+
import { IRequestContext, RequestContext } from '../services/request-context.service';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `RequestContext` for GraphQL operations that arrive over a WEBSOCKET.
|
|
14
|
+
*
|
|
15
|
+
* WHY THIS EXISTS — the gap it closes is a silent, complete tenant bypass:
|
|
16
|
+
*
|
|
17
|
+
* `RequestContextMiddleware` is Express middleware (`consumer.apply(...).forRoutes('*')`). A
|
|
18
|
+
* WebSocket UPGRADE is not a `request` event, so it traverses no middleware — and `CoreTenantGuard`
|
|
19
|
+
* cannot help either, because its `getRequest()` looks for `context.req`, which a subscription
|
|
20
|
+
* context (the graphql-ws `extra` object) does not have, so the guard returns `true` without
|
|
21
|
+
* deciding anything.
|
|
22
|
+
*
|
|
23
|
+
* The consequence was invisible from the query itself. `mongooseTenantPlugin.shouldBypass()` reads
|
|
24
|
+
* "no RequestContext" as "system operation, no filter" — correct for a cron job or a migration, and
|
|
25
|
+
* exactly wrong here. So a tenant-scoped read performed while delivering a subscription message
|
|
26
|
+
* returned EVERY tenant's rows, and the plugin's own safety net (which throws when a tenant-scoped
|
|
27
|
+
* schema is touched without a tenant) never fired, because there was no context to notice.
|
|
28
|
+
*
|
|
29
|
+
* WHERE IT HOOKS IN: `GqlSubscriptionService` reads `execute` and `subscribe` off the GraphQL module
|
|
30
|
+
* options and hands them to BOTH WebSocket transports (`graphql-ws` and `subscriptions-transport-ws`),
|
|
31
|
+
* defaulting to graphql-js's own. Those two functions are the only place where the whole WS operation
|
|
32
|
+
* — including every field resolver and the `resolve`/`filter` callbacks of a subscription — is
|
|
33
|
+
* reachable as one call. HTTP does not go through them (Apollo runs its own pipeline), so wrapping
|
|
34
|
+
* them touches the WS path only.
|
|
35
|
+
*
|
|
36
|
+
* THE ASYNC-ITERATOR PART IS LOAD-BEARING. `subscribe()` returns an AsyncIterator, and graphql-js
|
|
37
|
+
* runs the per-event execution — `resolve`, `filter`, every field resolver — inside that iterator's
|
|
38
|
+
* `next()`, which the transport pulls LONG AFTER `subscribe()` returned. Establishing the context
|
|
39
|
+
* only around the `subscribe()` call therefore covers the initial subscribe and nothing else: the
|
|
40
|
+
* store is gone by the time the first message is delivered. Wrapping `next()` (and `return`/`throw`)
|
|
41
|
+
* is what puts every delivered message inside the context.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Minimal shape of what graphql-js `execute`/`subscribe` receive. */
|
|
45
|
+
interface GraphQlArgs {
|
|
46
|
+
contextValue?: any;
|
|
47
|
+
[key: string]: any;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ExecuteFn = (args: GraphQlArgs) => any;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the tenant header out of whatever the transport left on the context.
|
|
54
|
+
*
|
|
55
|
+
* Both WS transports are covered, and the reason there are three sources is that they genuinely
|
|
56
|
+
* differ: `graphql-ws` gets `extra.headers` from `CoreModule`'s `onConnect` (the client's
|
|
57
|
+
* `connectionParams`), `subscriptions-transport-ws` returns `{ headers }` from its own `onConnect`,
|
|
58
|
+
* and the raw upgrade request is the fallback for a project with a custom `onConnect` that forwards
|
|
59
|
+
* neither. Header names are matched case-insensitively — `connectionParams` are client-supplied JSON
|
|
60
|
+
* and arrive with whatever casing the client used, unlike Node's lower-cased HTTP headers.
|
|
61
|
+
*/
|
|
62
|
+
function readTenantHeader(contextValue: any): string | undefined {
|
|
63
|
+
const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
|
|
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;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The user the connection authenticated as, as `CoreModule`'s `onConnect` recorded it. */
|
|
79
|
+
function readUser(contextValue: any): any {
|
|
80
|
+
return contextValue?.user ?? contextValue?.extra?.user ?? undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the context for one WebSocket operation.
|
|
85
|
+
*
|
|
86
|
+
* Returns `undefined` when the operation is NOT a WebSocket one (an HTTP context carries `req`, and
|
|
87
|
+
* the middleware has already established a context for it) — wrapping that again would replace a
|
|
88
|
+
* live, lazily-evaluated context with a snapshot.
|
|
89
|
+
*/
|
|
90
|
+
export async function buildWsRequestContext(contextValue: any): Promise<IRequestContext | undefined> {
|
|
91
|
+
if (!contextValue || typeof contextValue !== 'object' || contextValue.req) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const user = readUser(contextValue);
|
|
96
|
+
const context: IRequestContext = { currentUser: user };
|
|
97
|
+
|
|
98
|
+
// Language: same source as the HTTP path, when the transport carried it.
|
|
99
|
+
const acceptLanguage =
|
|
100
|
+
contextValue.headers?.['accept-language'] ??
|
|
101
|
+
contextValue.connectionParams?.['accept-language'] ??
|
|
102
|
+
contextValue.request?.headers?.['accept-language'];
|
|
103
|
+
if (typeof acceptLanguage === 'string') {
|
|
104
|
+
context.language = acceptLanguage;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const resolver = getTenantContextResolver();
|
|
108
|
+
if (resolver) {
|
|
109
|
+
// A failure here must NOT be answered with an unscoped context — that is the leak this file
|
|
110
|
+
// exists to close. Leaving the tenant fields unset makes the plugin's safety net refuse
|
|
111
|
+
// tenant-scoped reads, which is the safe direction.
|
|
112
|
+
try {
|
|
113
|
+
Object.assign(context, await resolver.resolve(user, readTenantHeader(contextValue)));
|
|
114
|
+
} catch {
|
|
115
|
+
// Intentionally swallowed: see above. Non-tenant operations keep working.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return context;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run an AsyncIterator's pulls inside `context`.
|
|
124
|
+
*
|
|
125
|
+
* `next()` is where graphql-js executes the per-event selection set, so this is what carries the
|
|
126
|
+
* context into every delivered message rather than only into the initial subscribe.
|
|
127
|
+
*/
|
|
128
|
+
export function withRequestContextAsyncIterator<T>(
|
|
129
|
+
iterator: AsyncIterator<T> & { [Symbol.asyncIterator]?: () => AsyncIterator<T> },
|
|
130
|
+
context: IRequestContext,
|
|
131
|
+
): AsyncIterableIterator<T> {
|
|
132
|
+
const wrapped: AsyncIterableIterator<T> = {
|
|
133
|
+
next: (...args: [] | [undefined]) => RequestContext.run(context, () => iterator.next(...(args as []))),
|
|
134
|
+
[Symbol.asyncIterator]() {
|
|
135
|
+
return wrapped;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
if (iterator.return) {
|
|
139
|
+
wrapped.return = (value?: any) => RequestContext.run(context, () => iterator.return!(value));
|
|
140
|
+
}
|
|
141
|
+
if (iterator.throw) {
|
|
142
|
+
wrapped.throw = (error?: any) => RequestContext.run(context, () => iterator.throw!(error));
|
|
143
|
+
}
|
|
144
|
+
return wrapped;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Is this an AsyncIterator (a subscription source) rather than a plain execution result? */
|
|
148
|
+
function isAsyncIterable(value: any): boolean {
|
|
149
|
+
return !!value && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Wrap graphql-js `execute` so a query/mutation sent over the WebSocket runs in a request context.
|
|
154
|
+
*
|
|
155
|
+
* Not only subscriptions: `graphql-ws` carries queries and mutations too, and those had the same gap.
|
|
156
|
+
*/
|
|
157
|
+
export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn {
|
|
158
|
+
return async (args: GraphQlArgs) => {
|
|
159
|
+
const context = await buildWsRequestContext(args?.contextValue);
|
|
160
|
+
if (!context) {
|
|
161
|
+
return execute(args);
|
|
162
|
+
}
|
|
163
|
+
return RequestContext.run(context, () => execute(args));
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Wrap graphql-js `subscribe` so both the subscribe AND every delivered message run in a context. */
|
|
168
|
+
export function createRequestContextAwareSubscribe(subscribe: ExecuteFn): ExecuteFn {
|
|
169
|
+
return async (args: GraphQlArgs) => {
|
|
170
|
+
const context = await buildWsRequestContext(args?.contextValue);
|
|
171
|
+
if (!context) {
|
|
172
|
+
return subscribe(args);
|
|
173
|
+
}
|
|
174
|
+
const result = await RequestContext.run(context, () => subscribe(args));
|
|
175
|
+
return isAsyncIterable(result) ? withRequestContextAsyncIterator(result as AsyncIterator<any>, context) : result;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The pair `CoreModule` installs — graphql's own `execute` / `subscribe`, wrapped.
|
|
181
|
+
*
|
|
182
|
+
* Zero-arg factories rather than pre-built constants so nothing is captured at module-evaluation
|
|
183
|
+
* time, and so `core.module.ts` needs no graphql import of its own (see the `require` note at the top
|
|
184
|
+
* of this file for why that import form matters).
|
|
185
|
+
*/
|
|
186
|
+
export function buildRequestContextAwareExecute(): (args: GraphQlArgs) => any {
|
|
187
|
+
return createRequestContextAwareExecute(graphql.execute as any);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function buildRequestContextAwareSubscribe(): (args: GraphQlArgs) => any {
|
|
191
|
+
return createRequestContextAwareSubscribe(graphql.subscribe as any);
|
|
192
|
+
}
|
|
@@ -198,10 +198,26 @@ export class GridFSHelper {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/**
|
|
201
|
-
* Find file metadata by filename
|
|
201
|
+
* Find file metadata by filename — the MOST RECENT file with that name.
|
|
202
|
+
*
|
|
203
|
+
* The sort is load-bearing, not cosmetic. `bucket.find({ filename })` returns natural order,
|
|
204
|
+
* which in practice is the OLDEST document first, while `openDownloadStreamByName()` defaults to
|
|
205
|
+
* `revision: -1` — the NEWEST. Filenames are unique in no store and are client-supplied, so with
|
|
206
|
+
* two files of one name this method answered one document and the download served another: an
|
|
207
|
+
* ownership rule built on `getRawFileInfoByName()` approved the caller's own file and the stream
|
|
208
|
+
* handed over the other one.
|
|
209
|
+
*
|
|
210
|
+
* Newest-first (rather than oldest-first) because it is the BYTES semantics that must not move:
|
|
211
|
+
* GridFS treats a re-upload under the same name as a new revision and serves the latest, so
|
|
212
|
+
* aligning the metadata lookup with that keeps every existing download answering the same bytes
|
|
213
|
+
* and only corrects the document describing them. `_id` breaks the tie, so two files written in
|
|
214
|
+
* the same millisecond still order totally.
|
|
202
215
|
*/
|
|
203
216
|
static async findFileByName(bucket: GridFSBucket, filename: string): Promise<GridFSFileInfo | null> {
|
|
204
|
-
|
|
217
|
+
// `uploadDate` FIRST: MongoDB applies sort keys in document order, so leading with `_id` would
|
|
218
|
+
// make the tie-break the primary key and could disagree with the driver's own by-name revision
|
|
219
|
+
// order (which sorts on `uploadDate` alone).
|
|
220
|
+
const files = await bucket.find({ filename }, { limit: 1, sort: { uploadDate: -1, _id: -1 } }).toArray();
|
|
205
221
|
return files.length > 0 ? GridFSHelper.normalizeFileInfo(files[0]) : null;
|
|
206
222
|
}
|
|
207
223
|
|
|
@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
|
|
|
3
3
|
import { map } from 'rxjs/operators';
|
|
4
4
|
|
|
5
5
|
import { getContextData } from '../helpers/context.helper';
|
|
6
|
+
import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker';
|
|
6
7
|
import { getStringIds } from '../helpers/db.helper';
|
|
7
8
|
import { processDeep } from '../helpers/input.helper';
|
|
8
9
|
import { ConfigService } from '../services/config.service';
|
|
@@ -66,7 +67,9 @@ export class CheckSecurityInterceptor implements NestInterceptor {
|
|
|
66
67
|
objectData = data;
|
|
67
68
|
|
|
68
69
|
// Check if data already checked
|
|
69
|
-
|
|
70
|
+
// Recognised by the framework MARKER, not by a truthy property of that name — see
|
|
71
|
+
// restrictions-checked.marker.ts for why a document must not be able to skip securityCheck().
|
|
72
|
+
if (this.config.noteCheckedObjects && hasRestrictionsCheckedMarker(data)) {
|
|
70
73
|
return data;
|
|
71
74
|
}
|
|
72
75
|
|
|
@@ -6,6 +6,7 @@ import { resolveResponseModelClass } from '../helpers/interceptor.helper';
|
|
|
6
6
|
import { CoreModel } from '../models/core-model.model';
|
|
7
7
|
import { ConfigService } from '../services/config.service';
|
|
8
8
|
import { ModelRegistry } from '../services/model-registry.service';
|
|
9
|
+
import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Interceptor that automatically converts plain objects and Mongoose documents
|
|
@@ -48,7 +49,7 @@ export class ResponseModelInterceptor implements NestInterceptor {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
// Already processed by another interceptor
|
|
51
|
-
if (data
|
|
52
|
+
if (hasRestrictionsCheckedMarker(data)) {
|
|
52
53
|
return data;
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -90,7 +91,7 @@ export class ResponseModelInterceptor implements NestInterceptor {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
// Already processed
|
|
93
|
-
if (item
|
|
94
|
+
if (hasRestrictionsCheckedMarker(item)) {
|
|
94
95
|
return item;
|
|
95
96
|
}
|
|
96
97
|
|