@lenne.tech/nest-server 11.34.0 → 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/helpers/validation-message.helper.d.ts +3 -0
- package/dist/core/common/helpers/validation-message.helper.js +41 -0
- package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
- 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/pipes/map-and-validate.pipe.js +16 -6
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- 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.0-to-11.34.1.md +132 -0
- 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/helpers/validation-message.helper.ts +83 -0
- 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/pipes/map-and-validate.pipe.ts +25 -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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-role CONFIGURATION and the two boot warnings — the half that touches no endpoint class.
|
|
3
|
+
*
|
|
4
|
+
* SPLIT OUT OF `file-roles.helper.ts` because that file imports `CoreFileController` and
|
|
5
|
+
* `CoreFileResolver` (it writes role metadata onto their prototypes), and both of them inject
|
|
6
|
+
* `CoreFileService`. So a service reading a warning from there closes
|
|
7
|
+
*
|
|
8
|
+
* core-file.service -> file-roles.helper -> core-file.controller -> core-file.service
|
|
9
|
+
*
|
|
10
|
+
* and the controller dereferences `CoreFileService` in `design:paramtypes`, i.e. at CLASS-DEFINITION
|
|
11
|
+
* time. That is the fatal shape: SWC -> CommonJS dies at startup with
|
|
12
|
+
* `Cannot access 'CoreFileService' before initialization`, while tsc, vitest and oxlint all stay
|
|
13
|
+
* green. `pnpm run check:swc-tdz` caught exactly that when the warning was first added here.
|
|
14
|
+
*
|
|
15
|
+
* This file therefore imports only enums and interfaces. See
|
|
16
|
+
* `.claude/rules/architecture.md` -> "DI Token Placement (SWC-Safe)".
|
|
17
|
+
*/
|
|
18
|
+
import { Logger } from '@nestjs/common';
|
|
19
|
+
|
|
20
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
21
|
+
import { IFileConfig, IS3Config } from '../../common/interfaces/server-options.interface';
|
|
22
|
+
|
|
23
|
+
const logger = new Logger('CoreFileRoles');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Roles applied when `file` is not configured at all.
|
|
27
|
+
*
|
|
28
|
+
* Restrictive on purpose: one GridFS bucket is shared by every feature of the
|
|
29
|
+
* consuming project, and the ObjectIds naming its blobs are not secrets.
|
|
30
|
+
*/
|
|
31
|
+
export type FileRoleKey = 'deleteRoles' | 'downloadRoles' | 'uploadRoles';
|
|
32
|
+
|
|
33
|
+
export const FILE_ROLE_DEFAULTS: Record<FileRoleKey, string[]> = {
|
|
34
|
+
deleteRoles: [RoleEnum.ADMIN],
|
|
35
|
+
downloadRoles: [RoleEnum.ADMIN],
|
|
36
|
+
uploadRoles: [RoleEnum.ADMIN],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve one knob to the role list that will actually be applied.
|
|
41
|
+
*
|
|
42
|
+
* An empty array is treated as "not configured". It cannot mean "nobody": the
|
|
43
|
+
* guards read an all-empty role set as "no roles required" and return true, so
|
|
44
|
+
* honouring it literally would OPEN the route instead of closing it — the exact
|
|
45
|
+
* opposite of what someone writing `[]` intends.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
|
|
48
|
+
const configured = config?.[key];
|
|
49
|
+
|
|
50
|
+
if (configured === undefined) {
|
|
51
|
+
return FILE_ROLE_DEFAULTS[key];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!Array.isArray(configured) || configured.length === 0 || configured.some((role) => typeof role !== 'string')) {
|
|
55
|
+
logger.warn(
|
|
56
|
+
`Ignoring file.${key}: expected a non-empty array of role strings, got ${JSON.stringify(configured)}. ` +
|
|
57
|
+
`Falling back to ${JSON.stringify(FILE_ROLE_DEFAULTS[key])}.`,
|
|
58
|
+
);
|
|
59
|
+
return FILE_ROLE_DEFAULTS[key];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return configured;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Warn when presigned S3 downloads are combined with a restricted `downloadRoles`.
|
|
67
|
+
*
|
|
68
|
+
* The two settings pull in opposite directions, and the conflict is invisible at the call site:
|
|
69
|
+
*
|
|
70
|
+
* - `downloadRoles` says "only these roles may download this file". It is enforced on every request.
|
|
71
|
+
* - `presignedDownloads` answers `302` with a time-limited S3 URL instead of streaming. That URL is
|
|
72
|
+
* a BEARER capability — authorized once, at issue time. Whoever holds it afterwards fetches the
|
|
73
|
+
* object with no session, from any IP, until it expires, and **the grant cannot be revoked in
|
|
74
|
+
* between**. It survives in browser history, `Referer` headers, proxy logs and chat messages.
|
|
75
|
+
*
|
|
76
|
+
* So the second setting hands out exactly what the first one restricts. That is a sound trade for
|
|
77
|
+
* public assets (which is what presigning is FOR — hence no warning when downloads are
|
|
78
|
+
* `S_EVERYONE`), and almost never what a project means when it has narrowed the roles.
|
|
79
|
+
*
|
|
80
|
+
* A warning rather than a boot failure: unlike an incoherent role vocabulary, this combination has
|
|
81
|
+
* legitimate uses (short expiry, a CDN in front, files whose audience really is "anyone who once
|
|
82
|
+
* held the link"). The operator has to be able to choose it — they just should not choose it by
|
|
83
|
+
* accident.
|
|
84
|
+
*/
|
|
85
|
+
export function warnOnPresignedDownloadsWithRestrictedRoles(
|
|
86
|
+
s3Config?: IS3Config,
|
|
87
|
+
fileConfig?: IFileConfig,
|
|
88
|
+
): string | undefined {
|
|
89
|
+
const presigned = s3Config?.presignedDownloads;
|
|
90
|
+
const presignedEnabled =
|
|
91
|
+
presigned === true || (!!presigned && typeof presigned === 'object' && (presigned as any).enabled !== false);
|
|
92
|
+
if (!presignedEnabled) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const downloadRoles = resolveRoles('downloadRoles', fileConfig);
|
|
97
|
+
// Public downloads are the intended use of presigning — nothing to warn about.
|
|
98
|
+
if (downloadRoles.includes(RoleEnum.S_EVERYONE)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Returned as well as logged: the message IS the contract here (an operator has to be able to act
|
|
103
|
+
// on it), and a module-private Logger instance cannot be asserted against from a unit test.
|
|
104
|
+
const message =
|
|
105
|
+
`s3.presignedDownloads is enabled while file.downloadRoles restricts downloads to ` +
|
|
106
|
+
`${JSON.stringify(downloadRoles)}. A presigned URL is a BEARER capability: it is authorized ` +
|
|
107
|
+
`once, at issue time, and afterwards anyone holding it can fetch the object with no session, ` +
|
|
108
|
+
`from any IP, until it expires — the grant cannot be revoked in between, and it survives in ` +
|
|
109
|
+
`browser history, Referer headers and proxy logs. The role check therefore applies to ` +
|
|
110
|
+
`obtaining the link, not to reading the file. If these files are sensitive (personal or ` +
|
|
111
|
+
`medical data), set s3.presignedDownloads: false so the API streams them and re-checks ` +
|
|
112
|
+
`rights on every request.`;
|
|
113
|
+
|
|
114
|
+
logger.warn(message);
|
|
115
|
+
return message;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Warn when the file gate is open and NOTHING decides the per-file policy.
|
|
120
|
+
*
|
|
121
|
+
* THE GAP: the role knobs are a coarse audience filter — they answer "may this caller reach the route
|
|
122
|
+
* at all". They cannot express "…but only their own", because that sentence needs data. So a deployment
|
|
123
|
+
* that widened the gate past platform admins and expressed no per-file policy anywhere has a store
|
|
124
|
+
* every holder of that role can read in full.
|
|
125
|
+
*
|
|
126
|
+
* And that is practically reachable, not theoretically: file ids are ENUMERABLE. An ObjectId is 4 bytes
|
|
127
|
+
* of timestamp + 5 bytes of randomness generated once PER PROCESS + a 3-byte incrementing counter, so a
|
|
128
|
+
* caller who obtains one valid id — their own upload — knows the random part and a counter reference
|
|
129
|
+
* point, and neighbouring files sit on neighbouring values. Nothing rate-limits the file routes either.
|
|
130
|
+
*
|
|
131
|
+
* WHY THE CONDITIONS ARE THIS NARROW — a warning that fires on a correct configuration gets muted, and
|
|
132
|
+
* a muted warning is worse than none. So every way of DECIDING silences it, and all three are
|
|
133
|
+
* legitimate:
|
|
134
|
+
*
|
|
135
|
+
* 1. `file.access` names a project class (`'public'`, `'authenticated'`, `'owner'`, `'tenant'`);
|
|
136
|
+
* 2. `checkRights()` is overridden — the project wrote its own rule, and grading it is beyond what a
|
|
137
|
+
* boot check can do;
|
|
138
|
+
* 3. the gate is still admin-only — a platform admin legitimately sees everything.
|
|
139
|
+
*
|
|
140
|
+
* That leaves exactly one case: the gate is open and nothing says what the policy is. This warning is
|
|
141
|
+
* about the difference between a DECISION and an OMISSION, which is the only thing a boot check can
|
|
142
|
+
* usefully detect.
|
|
143
|
+
*
|
|
144
|
+
* Multi-tenancy changes only the WORDING. An earlier version of this warning fired only for tenant
|
|
145
|
+
* projects, which was too narrow: `downloadRoles: [S_USER]` with no rule leaks every file to every
|
|
146
|
+
* signed-in user whether or not tenants exist. Where tenants DO exist, the leak also crosses that
|
|
147
|
+
* boundary, and that sentence has to appear — the file stores are reached outside Mongoose, so
|
|
148
|
+
* `mongooseTenantPlugin` never scopes them and these role names resolve against `user.roles`, a GLOBAL
|
|
149
|
+
* attribute.
|
|
150
|
+
*
|
|
151
|
+
* A warning, not a boot failure. The framework cannot know whether the files are patient documents or
|
|
152
|
+
* public logos, and refusing to start on a configuration that is correct for the second would be
|
|
153
|
+
* wrong. What it can do is refuse to be silent.
|
|
154
|
+
*
|
|
155
|
+
* @param hasPerFileRule whether `CoreFileService.checkRights()` is overridden — the caller knows,
|
|
156
|
+
* because it has the instance; this helper stays a pure function so it can be unit-tested.
|
|
157
|
+
* @returns the message, or `undefined` when there is nothing to warn about. Returned as well as
|
|
158
|
+
* logged for the same reason as {@link warnOnPresignedDownloadsWithRestrictedRoles}: the message IS
|
|
159
|
+
* the contract, and a module-private Logger cannot be asserted against.
|
|
160
|
+
*/
|
|
161
|
+
export function warnOnUndecidedFileAccess(options: {
|
|
162
|
+
fileConfig?: IFileConfig;
|
|
163
|
+
hasPerFileRule: boolean;
|
|
164
|
+
multiTenancyEnabled: boolean;
|
|
165
|
+
}): string | undefined {
|
|
166
|
+
const { fileConfig, hasPerFileRule, multiTenancyEnabled } = options;
|
|
167
|
+
|
|
168
|
+
// (1) and (2): somebody decided.
|
|
169
|
+
if (hasPerFileRule || (fileConfig?.access && fileConfig.access !== 'custom')) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// (3): admin-only. ADMIN is unioned in by the class-level decorator regardless, so it never widens.
|
|
174
|
+
const widened: string[] = [];
|
|
175
|
+
for (const key of ['deleteRoles', 'downloadRoles', 'uploadRoles'] as FileRoleKey[]) {
|
|
176
|
+
const roles = resolveRoles(key, fileConfig);
|
|
177
|
+
if (roles.some((role) => role !== RoleEnum.ADMIN)) {
|
|
178
|
+
widened.push(`file.${key}=${JSON.stringify(roles)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!widened.length) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const tenantNote = multiTenancyEnabled
|
|
186
|
+
? ' multiTenancy is active, and the leak crosses tenants too: the file stores are reached outside ' +
|
|
187
|
+
'Mongoose, so mongooseTenantPlugin never scopes them and these role names resolve against ' +
|
|
188
|
+
'user.roles — a GLOBAL attribute — never against membership.role.'
|
|
189
|
+
: '';
|
|
190
|
+
|
|
191
|
+
const message =
|
|
192
|
+
`The file gate is open beyond platform admins (${widened.join(', ')}), but no per-file policy is ` +
|
|
193
|
+
`declared: file.access is unset and CoreFileService.checkRights() is not overridden. Every holder ` +
|
|
194
|
+
`of such a role can therefore read, overwrite or delete EVERY file — and file ids are not secret, ` +
|
|
195
|
+
`they are ENUMERABLE (an ObjectId shares a per-process random part and an incrementing counter, so ` +
|
|
196
|
+
`one own upload reveals the neighbourhood), with no rate limit on the file routes.${tenantNote} ` +
|
|
197
|
+
`Declare the project class with file.access ('public' | 'authenticated' | 'owner' | 'tenant'), or ` +
|
|
198
|
+
`override checkRights() — see src/core/modules/file/README.md § Access control.`;
|
|
199
|
+
|
|
200
|
+
logger.warn(message);
|
|
201
|
+
return message;
|
|
202
|
+
}
|
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
import { Logger } from '@nestjs/common';
|
|
2
2
|
|
|
3
|
-
import { RoleEnum } from '../../common/enums/role.enum';
|
|
4
3
|
import { IFileConfig } from '../../common/interfaces/server-options.interface';
|
|
5
4
|
import { CoreFileController } from './core-file.controller';
|
|
6
5
|
import { CoreFileResolver } from './core-file.resolver';
|
|
6
|
+
import { FileRoleKey, resolveRoles } from './file-roles.config';
|
|
7
7
|
|
|
8
8
|
const logger = new Logger('CoreFileRoles');
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
downloadRoles: [RoleEnum.ADMIN],
|
|
21
|
-
uploadRoles: [RoleEnum.ADMIN],
|
|
22
|
-
};
|
|
10
|
+
// Re-exported so no import path broke: `FILE_ROLE_DEFAULTS`, the warnings and the key type are part
|
|
11
|
+
// of the published API and used to live here. They moved into an import-free leaf because this file
|
|
12
|
+
// imports the endpoint classes, which inject CoreFileService — see file-roles.config.ts.
|
|
13
|
+
export {
|
|
14
|
+
FILE_ROLE_DEFAULTS,
|
|
15
|
+
resolveRoles,
|
|
16
|
+
warnOnPresignedDownloadsWithRestrictedRoles,
|
|
17
|
+
warnOnUndecidedFileAccess,
|
|
18
|
+
} from './file-roles.config';
|
|
19
|
+
export type { FileRoleKey } from './file-roles.config';
|
|
23
20
|
|
|
24
21
|
/**
|
|
25
22
|
* Which member is governed by which knob.
|
|
@@ -42,32 +39,6 @@ const ROLE_TARGETS: { key: FileRoleKey; member: string; owner: () => unknown }[]
|
|
|
42
39
|
{ key: 'deleteRoles', member: 'CoreFileResolver.deleteFile', owner: () => CoreFileResolver.prototype.deleteFile },
|
|
43
40
|
];
|
|
44
41
|
|
|
45
|
-
/**
|
|
46
|
-
* Resolve one knob to the role list that will actually be applied.
|
|
47
|
-
*
|
|
48
|
-
* An empty array is treated as "not configured". It cannot mean "nobody": the
|
|
49
|
-
* guards read an all-empty role set as "no roles required" and return true, so
|
|
50
|
-
* honouring it literally would OPEN the route instead of closing it — the exact
|
|
51
|
-
* opposite of what someone writing `[]` intends.
|
|
52
|
-
*/
|
|
53
|
-
function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
|
|
54
|
-
const configured = config?.[key];
|
|
55
|
-
|
|
56
|
-
if (configured === undefined) {
|
|
57
|
-
return FILE_ROLE_DEFAULTS[key];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (!Array.isArray(configured) || configured.length === 0 || configured.some((role) => typeof role !== 'string')) {
|
|
61
|
-
logger.warn(
|
|
62
|
-
`Ignoring file.${key}: expected a non-empty array of role strings, got ${JSON.stringify(configured)}. ` +
|
|
63
|
-
`Falling back to ${JSON.stringify(FILE_ROLE_DEFAULTS[key])}.`,
|
|
64
|
-
);
|
|
65
|
-
return FILE_ROLE_DEFAULTS[key];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return configured;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
42
|
/**
|
|
72
43
|
* Apply the configured file roles to the core file endpoints.
|
|
73
44
|
*
|
|
@@ -42,7 +42,7 @@ multiTenancy: {
|
|
|
42
42
|
headerName: 'x-tenant-id', // Header name (default: 'x-tenant-id')
|
|
43
43
|
membershipModel: 'TenantMember', // Mongoose model name (default)
|
|
44
44
|
adminBypass: true, // System admins bypass membership (default: true)
|
|
45
|
-
excludeSchemas: [
|
|
45
|
+
excludeSchemas: [], // OFF SWITCH for isolation, per model — see the warning below
|
|
46
46
|
cacheTtlMs: 30000, // Membership cache TTL in ms (default: 30s, 0 = disabled)
|
|
47
47
|
roleHierarchy: { // Custom role hierarchy (default below)
|
|
48
48
|
member: 1,
|
|
@@ -55,6 +55,25 @@ multiTenancy: {
|
|
|
55
55
|
multiTenancy: { enabled: false },
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
### `excludeSchemas` is an OFF SWITCH for isolation — read this before adding an entry
|
|
59
|
+
|
|
60
|
+
A listed model gets **no tenant filter at all**: every query on it returns every tenant's rows. That
|
|
61
|
+
is correct for a genuinely global collection and a data leak for anything else. The plugin only ever
|
|
62
|
+
attaches to schemas that declare a `tenantId` field, so listing such a model overrides the intent of
|
|
63
|
+
whoever declared it — since 11.35.0 that logs a warning naming the model.
|
|
64
|
+
|
|
65
|
+
| Situation | Do |
|
|
66
|
+
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| Truly global lookup table (no `tenantId`) | nothing — the plugin never attached |
|
|
68
|
+
| Global USERS: one account reaches several tenants, memberships carry the scope | `excludeSchemas: ['User']` is correct |
|
|
69
|
+
| Per-tenant users: `User` has a `tenantId` | do **not** list `User` — that switches isolation off for the account collection |
|
|
70
|
+
| `populate()` returns null across tenants | that is the isolation working. Scope the reference, or confirm the target really is global — do not silence it here |
|
|
71
|
+
| Membership model (`TenantMember`) | added automatically, no entry needed |
|
|
72
|
+
|
|
73
|
+
Earlier versions of this README suggested `excludeSchemas: ['User', 'Session']` as an example. That
|
|
74
|
+
suggestion is withdrawn: it is only right for the global-user model, and it was being copied into
|
|
75
|
+
projects whose users are per-tenant.
|
|
76
|
+
|
|
58
77
|
## Components
|
|
59
78
|
|
|
60
79
|
| Component | Purpose |
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { GLOBAL_ONLY_ROLES, isSystemRole, looksLikeSystemRole } from '../../common/enums/role.enum';
|
|
2
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
3
|
+
import { DEFAULT_ROLE_HIERARCHY } from './core-tenant.enums';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a role's authority comes from — and therefore which source may answer it.
|
|
7
|
+
*
|
|
8
|
+
* This is the tenant boundary expressed as data. Roles are plain strings at runtime
|
|
9
|
+
* (`RoleEnum.ADMIN` IS `'admin'`), and they arrive from three trust levels: the framework, the
|
|
10
|
+
* project, and the CUSTOMER (membership roles are free text). Comparing them with `===` in one
|
|
11
|
+
* shared namespace is what allowed a tenant owner to grant themselves platform authority by naming
|
|
12
|
+
* their tenant role `'admin'`.
|
|
13
|
+
*
|
|
14
|
+
* Encoding the scope as an ATTRIBUTE rather than in the name is deliberate, and it is what makes
|
|
15
|
+
* this forward-compatible with admin-managed roles stored in the database: a future
|
|
16
|
+
* `DbRoleScopeSource` can feed the same registry, and every caller keeps working unchanged. A
|
|
17
|
+
* naming convention (e.g. storing tenant roles as `t:owner`) would instead put the scope inside a
|
|
18
|
+
* string that whoever creates the role can mistype, and would add a normalization step to every
|
|
19
|
+
* comparison — inside the authorization path, which is the last place that should grow moving parts.
|
|
20
|
+
*/
|
|
21
|
+
export enum RoleScope {
|
|
22
|
+
/** Authority granted by the PLATFORM. Resolved against `user.roles`, never a membership role. */
|
|
23
|
+
GLOBAL = 'global',
|
|
24
|
+
|
|
25
|
+
/** A runtime-context check (`s_*`), never a stored role and never a string comparison. */
|
|
26
|
+
SYSTEM = 'system',
|
|
27
|
+
|
|
28
|
+
/** Authority granted WITHIN one tenant. Resolved against `membership.role` in tenant context. */
|
|
29
|
+
TENANT = 'tenant',
|
|
30
|
+
|
|
31
|
+
/** Declared nowhere. Grants nothing — see {@link CoreRoleScopeRegistry.scopeOf}. */
|
|
32
|
+
UNKNOWN = 'unknown',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A source of role→scope knowledge.
|
|
37
|
+
*
|
|
38
|
+
* Implemented today by the config reader below. The interface exists so an admin-managed,
|
|
39
|
+
* database-backed source can be added later without touching the guards: register it, and every
|
|
40
|
+
* `scopeOf()` caller picks it up.
|
|
41
|
+
*/
|
|
42
|
+
export interface RoleScopeSource {
|
|
43
|
+
/** All roles this source declares as globally scoped. */
|
|
44
|
+
globalRoles(): Iterable<string>;
|
|
45
|
+
|
|
46
|
+
/** All roles this source declares as tenant scoped. */
|
|
47
|
+
tenantRoles(): Iterable<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the scope of a role, from one or more sources.
|
|
52
|
+
*
|
|
53
|
+
* **Deny by default.** A role no source declares resolves to {@link RoleScope.UNKNOWN}, and callers
|
|
54
|
+
* must treat that as "grants nothing". That is the property worth having: an undeclared membership
|
|
55
|
+
* role cannot accidentally line up with an undeclared required role and authorize something. The
|
|
56
|
+
* previous behaviour — exact string match against whatever happened to be stored — is precisely the
|
|
57
|
+
* hole this replaces.
|
|
58
|
+
*
|
|
59
|
+
* The registry is a plain object rather than a Nest provider on purpose: `checkRestricted()` runs
|
|
60
|
+
* far outside DI (it is called from decorators and helpers), and giving it an injected dependency
|
|
61
|
+
* would put a container lookup in the field-level authorization path.
|
|
62
|
+
*/
|
|
63
|
+
export class CoreRoleScopeRegistry {
|
|
64
|
+
private readonly sources: RoleScopeSource[] = [];
|
|
65
|
+
|
|
66
|
+
/** Drop all registered sources. Used by tests and by config reloads. */
|
|
67
|
+
clear(): void {
|
|
68
|
+
this.sources.length = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Roles that are declared in BOTH scopes — a contradiction that must fail the boot.
|
|
73
|
+
*
|
|
74
|
+
* Such a role would have to be resolved against `user.roles` and `membership.role` at the same
|
|
75
|
+
* time. Whichever way it were decided, one of the two declarations would silently not hold.
|
|
76
|
+
*/
|
|
77
|
+
conflicts(): string[] {
|
|
78
|
+
const global = this.collect((source) => source.globalRoles());
|
|
79
|
+
const tenant = this.collect((source) => source.tenantRoles());
|
|
80
|
+
return [...global].filter((role) => tenant.has(role)).sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Is this role declared as tenant-scoped by any source? */
|
|
84
|
+
isTenantRole(role: string): boolean {
|
|
85
|
+
return this.scopeOf(role) === RoleScope.TENANT;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Register a source. Later sources add to, never replace, earlier ones. */
|
|
89
|
+
register(source: RoleScopeSource): void {
|
|
90
|
+
this.sources.push(source);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Scope of a single role.
|
|
95
|
+
*
|
|
96
|
+
* Order matters: system roles first (they are never stored and never string-compared), then
|
|
97
|
+
* global (so a project cannot demote a framework role by declaring it per tenant), then tenant.
|
|
98
|
+
*/
|
|
99
|
+
scopeOf(role: string): RoleScope {
|
|
100
|
+
if (typeof role !== 'string') {
|
|
101
|
+
return RoleScope.UNKNOWN;
|
|
102
|
+
}
|
|
103
|
+
if (isSystemRole(role) || looksLikeSystemRole(role)) {
|
|
104
|
+
return RoleScope.SYSTEM;
|
|
105
|
+
}
|
|
106
|
+
if (GLOBAL_ONLY_ROLES.includes(role) || this.collect((source) => source.globalRoles()).has(role)) {
|
|
107
|
+
return RoleScope.GLOBAL;
|
|
108
|
+
}
|
|
109
|
+
if (this.collect((source) => source.tenantRoles()).has(role)) {
|
|
110
|
+
return RoleScope.TENANT;
|
|
111
|
+
}
|
|
112
|
+
return RoleScope.UNKNOWN;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Is any source able to answer tenant-scope questions at all?
|
|
117
|
+
*
|
|
118
|
+
* Lets callers distinguish "declared as NOT a tenant role" from "nothing is declared yet", which
|
|
119
|
+
* are very different answers when deciding whether to deny.
|
|
120
|
+
*/
|
|
121
|
+
hasTenantKnowledge(): boolean {
|
|
122
|
+
return this.collect((source) => source.tenantRoles()).size > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Split required roles by the source entitled to answer them.
|
|
127
|
+
*
|
|
128
|
+
* `unknown` is returned separately rather than folded into either half, so callers can decide
|
|
129
|
+
* explicitly. The guards treat it as "grants nothing via tenant membership" but still resolve it
|
|
130
|
+
* against `user.roles` — that keeps a project which never configured `multiTenancy` working
|
|
131
|
+
* exactly as before, while ensuring a membership role can never be the thing that satisfies it.
|
|
132
|
+
*/
|
|
133
|
+
split(requiredRoles: string[]): { global: string[]; system: string[]; tenant: string[]; unknown: string[] } {
|
|
134
|
+
const result = { global: [] as string[], system: [] as string[], tenant: [] as string[], unknown: [] as string[] };
|
|
135
|
+
for (const role of requiredRoles ?? []) {
|
|
136
|
+
switch (this.scopeOf(role)) {
|
|
137
|
+
case RoleScope.GLOBAL:
|
|
138
|
+
result.global.push(role);
|
|
139
|
+
break;
|
|
140
|
+
case RoleScope.SYSTEM:
|
|
141
|
+
result.system.push(role);
|
|
142
|
+
break;
|
|
143
|
+
case RoleScope.TENANT:
|
|
144
|
+
result.tenant.push(role);
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
result.unknown.push(role);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The `multiTenancy` config, read fresh on every lookup and ALWAYS consulted.
|
|
155
|
+
*
|
|
156
|
+
* Built in rather than registered, because a registered source only exists after
|
|
157
|
+
* `CoreTenantModule.forRoot()` has run — and the guards can be constructed before that (test
|
|
158
|
+
* fixtures, standalone usage). A registry that silently knows nothing is worse than no registry:
|
|
159
|
+
* `globalOnlyRoles` would be ignored and the roles it protects would fall back to being
|
|
160
|
+
* tenant-satisfiable, which is precisely the escalation this exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
private configSource(): RoleScopeSource {
|
|
163
|
+
return {
|
|
164
|
+
globalRoles: () => ConfigService.configFastButReadOnly?.multiTenancy?.globalOnlyRoles ?? [],
|
|
165
|
+
tenantRoles: () => {
|
|
166
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
167
|
+
return [
|
|
168
|
+
...Object.keys(config?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
|
|
169
|
+
...(config?.additionalMembershipRoles ?? []),
|
|
170
|
+
];
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private collect(pick: (source: RoleScopeSource) => Iterable<string>): Set<string> {
|
|
176
|
+
const collected = new Set<string>();
|
|
177
|
+
for (const source of [this.configSource(), ...this.sources]) {
|
|
178
|
+
for (const role of pick(source) ?? []) {
|
|
179
|
+
if (typeof role === 'string' && role.length) {
|
|
180
|
+
collected.add(role);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return collected;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The process-wide registry.
|
|
190
|
+
*
|
|
191
|
+
* Module-level singleton in an import-free-ish leaf for the same reason the DI tokens live in
|
|
192
|
+
* leaves: it is read from `checkRestricted()`, which sits on the field-level authorization path and
|
|
193
|
+
* must not depend on the Nest container being available.
|
|
194
|
+
*/
|
|
195
|
+
export const roleScopeRegistry = new CoreRoleScopeRegistry();
|
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
UnauthorizedException,
|
|
11
11
|
} from '@nestjs/common';
|
|
12
12
|
import { Reflector } from '@nestjs/core';
|
|
13
|
-
import {
|
|
13
|
+
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
14
14
|
import { InjectModel } from '@nestjs/mongoose';
|
|
15
15
|
import { Model } from 'mongoose';
|
|
16
16
|
|
|
17
17
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
18
|
+
import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
|
|
18
19
|
import { ConfigService } from '../../common/services/config.service';
|
|
20
|
+
import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry';
|
|
19
21
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
20
22
|
import { ErrorCode } from '../error-code/error-codes';
|
|
21
23
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
@@ -27,6 +29,8 @@ import {
|
|
|
27
29
|
getRoleHierarchy,
|
|
28
30
|
isSystemRole,
|
|
29
31
|
mergeRolesMetadata,
|
|
32
|
+
resolveGlobalAndTenantRoles,
|
|
33
|
+
tenantSatisfiableRoles,
|
|
30
34
|
} from './core-tenant.helpers';
|
|
31
35
|
|
|
32
36
|
/**
|
|
@@ -155,6 +159,67 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
155
159
|
if (this.cleanupInterval.unref) {
|
|
156
160
|
this.cleanupInterval.unref();
|
|
157
161
|
}
|
|
162
|
+
|
|
163
|
+
// Make the membership logic reachable from transports that have no Express request — today the
|
|
164
|
+
// GraphQL WebSocket. See core-tenant-context.registry.ts for why this is a registry and not DI.
|
|
165
|
+
setTenantContextResolver({
|
|
166
|
+
resolve: (user, headerTenantId) => this.resolveTenantContext(user, headerTenantId),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Answer "which tenant is this caller in?" WITHOUT an Express request.
|
|
172
|
+
*
|
|
173
|
+
* The HTTP path answers this inside `canActivate()` and writes the result onto the request, which
|
|
174
|
+
* `RequestContextMiddleware` then publishes through AsyncLocalStorage. A WebSocket has neither, so
|
|
175
|
+
* this is the same decision reachable from a transport that only has a user and a header value.
|
|
176
|
+
*
|
|
177
|
+
* It deliberately does NOT decide ACCESS — that is `canActivate()`'s job, driven by `@Roles()`
|
|
178
|
+
* metadata this method cannot see. It only establishes the tenant SCOPE the Mongoose plugin
|
|
179
|
+
* filters by, so a read from such a transport is narrowed instead of running unscoped.
|
|
180
|
+
*
|
|
181
|
+
* A header naming a tenant the user is not an active member of yields NO tenant id rather than the
|
|
182
|
+
* requested one: the plugin then finds no tenant scope and its safety net refuses the read. An
|
|
183
|
+
* unvalidated header would be strictly worse than no header at all.
|
|
184
|
+
*/
|
|
185
|
+
async resolveTenantContext(
|
|
186
|
+
user: { id: string; roles?: string[] } | undefined,
|
|
187
|
+
headerTenantId?: string,
|
|
188
|
+
): Promise<ResolvedTenantContext> {
|
|
189
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
190
|
+
if (!config || config.enabled === false) {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const trimmed =
|
|
195
|
+
headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
|
|
196
|
+
? headerTenantId.trim()
|
|
197
|
+
: undefined;
|
|
198
|
+
|
|
199
|
+
const adminBypass = config.adminBypass !== false;
|
|
200
|
+
if (adminBypass && user?.roles?.includes(RoleEnum.ADMIN)) {
|
|
201
|
+
// Mirrors canActivate(): with a header an admin is scoped to that tenant, without one they see
|
|
202
|
+
// everything. No membership is required either way.
|
|
203
|
+
return trimmed ? { isAdminBypass: true, tenantId: trimmed } : { isAdminBypass: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!user?.id) {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (trimmed) {
|
|
211
|
+
const membership = await this.findMembershipCached(user.id, trimmed);
|
|
212
|
+
if (!membership) {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
return { tenantId: trimmed, tenantRole: membership.role as string };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// No header: scope to every tenant the user is an active member of — the same fallback the HTTP
|
|
219
|
+
// path uses, so the plugin filters by `{ tenantId: { $in: tenantIds } }`.
|
|
220
|
+
const carrier: { tenantIds?: string[]; user: { id: string } } = { user: { id: user.id } };
|
|
221
|
+
await this.resolveUserTenantIds(carrier);
|
|
222
|
+
return { tenantIds: carrier.tenantIds ?? [] };
|
|
158
223
|
}
|
|
159
224
|
|
|
160
225
|
/**
|
|
@@ -425,9 +490,28 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
425
490
|
|
|
426
491
|
const memberRole = membership.role as string;
|
|
427
492
|
|
|
428
|
-
// Check role access if roles are required (hierarchy + normal, against membership.role)
|
|
493
|
+
// Check role access if roles are required (hierarchy + normal, against membership.role).
|
|
494
|
+
//
|
|
495
|
+
// Global-only roles (RoleEnum.ADMIN) are resolved against user.roles instead — a membership
|
|
496
|
+
// role is customer-assigned free text, so comparing it against the framework's global admin
|
|
497
|
+
// role by string equality would let any tenant owner mint platform-wide access by naming
|
|
498
|
+
// their tenant role 'admin'. The two authority levels stay separate:
|
|
499
|
+
// global admin → RoleEnum.ADMIN in user.roles (every tenant)
|
|
500
|
+
// tenant admin → a membership role such as 'tenantAdmin' (this tenant only)
|
|
501
|
+
//
|
|
502
|
+
// OR semantics across both halves, so @Roles(ADMIN, 'owner') keeps reading as alternatives.
|
|
429
503
|
if (checkableRoles.length > 0) {
|
|
430
|
-
|
|
504
|
+
const { global: globalRoles } = resolveGlobalAndTenantRoles(checkableRoles);
|
|
505
|
+
// Deny by default: only roles DECLARED as tenant-scoped may be satisfied by a membership.
|
|
506
|
+
// An undeclared role is not silently matched against customer-assigned free text.
|
|
507
|
+
const tenantRoles = tenantSatisfiableRoles(checkableRoles);
|
|
508
|
+
const satisfiedGlobally = globalRoles.some((r) => user.roles?.includes(r));
|
|
509
|
+
// The length guard is NOT redundant here (unlike the `.some()` above): checkRoleAccess
|
|
510
|
+
// returns TRUE for an empty required-roles list, so calling it with no tenant roles would
|
|
511
|
+
// grant access to a handler that only ever required a global role.
|
|
512
|
+
const satisfiedByTenant = tenantRoles.length > 0 && checkRoleAccess(tenantRoles, undefined, memberRole);
|
|
513
|
+
|
|
514
|
+
if (!satisfiedGlobally && !satisfiedByTenant) {
|
|
431
515
|
throw new ForbiddenException('Insufficient tenant role');
|
|
432
516
|
}
|
|
433
517
|
}
|
|
@@ -538,15 +622,11 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
538
622
|
* Extract request from GraphQL or HTTP context
|
|
539
623
|
*/
|
|
540
624
|
private getRequest(context: ExecutionContext): any {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return context.switchToHttp().getRequest();
|
|
547
|
-
} catch {
|
|
548
|
-
return null;
|
|
549
|
-
}
|
|
625
|
+
// Shared with both role guards — see execution-context-request.helper for why the GraphQL
|
|
626
|
+
// WEBSOCKET branch is load-bearing: without it this method answered `null` for every
|
|
627
|
+
// subscription, `canActivate()` hit `if (!request) return true`, and because the role guard
|
|
628
|
+
// delegates non-system roles here, `@Roles()` on a subscription was enforced by nobody.
|
|
629
|
+
return resolveGuardRequest(context, (ctx) => GqlExecutionContext.create(ctx).getContext()) ?? null;
|
|
550
630
|
}
|
|
551
631
|
|
|
552
632
|
// ===================================================================================================================
|